diff --git a/backend/alembic/versions/057_project_collab_prep.py b/backend/alembic/versions/057_project_collab_prep.py new file mode 100644 index 0000000..d3755bf --- /dev/null +++ b/backend/alembic/versions/057_project_collab_prep.py @@ -0,0 +1,49 @@ +"""project collab prep: indexes, task version, comment user_id + +Revision ID: 057 +Revises: 056 +Create Date: 2025-01-01 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "057" +down_revision = "056" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # 1a. Performance indexes for project_tasks + # Use IF NOT EXISTS to handle indexes that may already exist on the DB + op.execute("CREATE INDEX IF NOT EXISTS ix_project_tasks_project_id ON project_tasks (project_id)") + op.execute("CREATE INDEX IF NOT EXISTS ix_project_tasks_parent_task_id ON project_tasks (parent_task_id) WHERE parent_task_id IS NOT NULL") + op.execute("CREATE INDEX IF NOT EXISTS ix_project_tasks_project_updated ON project_tasks (project_id, updated_at DESC)") + op.execute("CREATE INDEX IF NOT EXISTS ix_projects_user_updated ON projects (user_id, updated_at DESC)") + + # 1b. Add user_id to task_comments for multi-user attribution + op.add_column( + "task_comments", + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + ) + + # 1c. Add version column to project_tasks for optimistic locking + op.add_column( + "project_tasks", + sa.Column("version", sa.Integer(), server_default="1", nullable=False), + ) + + # Calendar delta polling index (Phase 4 prep) + op.execute("CREATE INDEX IF NOT EXISTS ix_events_calendar_updated ON calendar_events (calendar_id, updated_at DESC)") + + +def downgrade() -> None: + op.drop_index("ix_events_calendar_updated", table_name="calendar_events") + op.drop_column("project_tasks", "version") + op.drop_column("task_comments", "user_id") + op.drop_index("ix_projects_user_updated", table_name="projects") + op.drop_index("ix_project_tasks_project_updated", table_name="project_tasks") + op.drop_index("ix_project_tasks_parent_task_id", table_name="project_tasks") + op.drop_index("ix_project_tasks_project_id", table_name="project_tasks") diff --git a/backend/alembic/versions/058_project_members.py b/backend/alembic/versions/058_project_members.py new file mode 100644 index 0000000..8d1dac4 --- /dev/null +++ b/backend/alembic/versions/058_project_members.py @@ -0,0 +1,45 @@ +"""add project_members table + +Revision ID: 058 +Revises: 057 +Create Date: 2025-01-01 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "058" +down_revision = "057" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "project_members", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("project_id", sa.Integer(), sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=False), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("invited_by", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("permission", sa.String(20), nullable=False), + sa.Column("status", sa.String(20), nullable=False, server_default="pending"), + sa.Column("source", sa.String(20), nullable=False, server_default="invited"), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now()), + sa.Column("accepted_at", sa.DateTime(), nullable=True), + sa.UniqueConstraint("project_id", "user_id", name="uq_project_members_proj_user"), + sa.CheckConstraint("permission IN ('read_only', 'create_modify')", name="ck_project_members_permission"), + sa.CheckConstraint("status IN ('pending', 'accepted', 'rejected')", name="ck_project_members_status"), + sa.CheckConstraint("source IN ('invited', 'auto_assigned')", name="ck_project_members_source"), + ) + op.create_index("ix_project_members_user_id", "project_members", ["user_id"]) + op.create_index("ix_project_members_project_id", "project_members", ["project_id"]) + op.create_index("ix_project_members_status", "project_members", ["status"]) + + +def downgrade() -> None: + op.drop_index("ix_project_members_status", table_name="project_members") + op.drop_index("ix_project_members_project_id", table_name="project_members") + op.drop_index("ix_project_members_user_id", table_name="project_members") + op.drop_table("project_members") diff --git a/backend/alembic/versions/059_project_task_assignments.py b/backend/alembic/versions/059_project_task_assignments.py new file mode 100644 index 0000000..2b60a87 --- /dev/null +++ b/backend/alembic/versions/059_project_task_assignments.py @@ -0,0 +1,35 @@ +"""add project_task_assignments table + +Revision ID: 059 +Revises: 058 +Create Date: 2025-01-01 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "059" +down_revision = "058" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "project_task_assignments", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("task_id", sa.Integer(), sa.ForeignKey("project_tasks.id", ondelete="CASCADE"), nullable=False), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("assigned_by", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now()), + sa.UniqueConstraint("task_id", "user_id", name="uq_task_assignments_task_user"), + ) + op.create_index("ix_task_assignments_task_id", "project_task_assignments", ["task_id"]) + op.create_index("ix_task_assignments_user_id", "project_task_assignments", ["user_id"]) + + +def downgrade() -> None: + op.drop_index("ix_task_assignments_user_id", table_name="project_task_assignments") + op.drop_index("ix_task_assignments_task_id", table_name="project_task_assignments") + op.drop_table("project_task_assignments") diff --git a/backend/alembic/versions/060_expand_notification_types_project.py b/backend/alembic/versions/060_expand_notification_types_project.py new file mode 100644 index 0000000..019331d --- /dev/null +++ b/backend/alembic/versions/060_expand_notification_types_project.py @@ -0,0 +1,36 @@ +"""Expand notification type CHECK for project invite types + +Revision ID: 060 +Revises: 059 +""" +from alembic import op + +revision = "060" +down_revision = "059" +branch_labels = None +depends_on = None + +_OLD_TYPES = ( + "connection_request", "connection_accepted", "connection_rejected", + "calendar_invite", "calendar_invite_accepted", "calendar_invite_rejected", + "event_invite", "event_invite_response", + "info", "warning", "reminder", "system", +) +_NEW_TYPES = _OLD_TYPES + ( + "project_invite", "project_invite_accepted", "project_invite_rejected", + "task_assigned", +) + + +def _check_sql(types: tuple) -> str: + return f"type IN ({', '.join(repr(t) for t in types)})" + + +def upgrade() -> None: + op.drop_constraint("ck_notifications_type", "notifications", type_="check") + op.create_check_constraint("ck_notifications_type", "notifications", _check_sql(_NEW_TYPES)) + + +def downgrade() -> None: + op.drop_constraint("ck_notifications_type", "notifications", type_="check") + op.create_check_constraint("ck_notifications_type", "notifications", _check_sql(_OLD_TYPES)) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index c6c7aa5..05f7a00 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -21,6 +21,8 @@ from app.models.user_connection import UserConnection from app.models.calendar_member import CalendarMember from app.models.event_lock import EventLock from app.models.event_invitation import EventInvitation, EventInvitationOverride +from app.models.project_member import ProjectMember +from app.models.project_task_assignment import ProjectTaskAssignment __all__ = [ "Settings", @@ -47,4 +49,6 @@ __all__ = [ "EventLock", "EventInvitation", "EventInvitationOverride", + "ProjectMember", + "ProjectTaskAssignment", ] diff --git a/backend/app/models/notification.py b/backend/app/models/notification.py index e0d6f47..dfd7f3e 100644 --- a/backend/app/models/notification.py +++ b/backend/app/models/notification.py @@ -9,6 +9,8 @@ _NOTIFICATION_TYPES = ( "connection_request", "connection_accepted", "connection_rejected", "calendar_invite", "calendar_invite_accepted", "calendar_invite_rejected", "event_invite", "event_invite_response", + "project_invite", "project_invite_accepted", "project_invite_rejected", + "task_assigned", "info", "warning", "reminder", "system", ) diff --git a/backend/app/models/project.py b/backend/app/models/project.py index faefbe5..57e5acc 100644 --- a/backend/app/models/project.py +++ b/backend/app/models/project.py @@ -22,6 +22,7 @@ class Project(Base): created_at: Mapped[datetime] = mapped_column(default=func.now()) updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now()) - # Relationships - tasks: Mapped[List["ProjectTask"]] = relationship(back_populates="project", cascade="all, delete-orphan") - todos: Mapped[List["Todo"]] = relationship(back_populates="project") + # Relationships — lazy="raise" to prevent N+1 (mirrors CalendarMember pattern) + tasks: Mapped[List["ProjectTask"]] = relationship(back_populates="project", cascade="all, delete-orphan", lazy="raise") + todos: Mapped[List["Todo"]] = relationship(back_populates="project", lazy="raise") + members: Mapped[List["ProjectMember"]] = relationship(back_populates="project", cascade="all, delete-orphan", lazy="raise") diff --git a/backend/app/models/project_member.py b/backend/app/models/project_member.py new file mode 100644 index 0000000..ca3b4ce --- /dev/null +++ b/backend/app/models/project_member.py @@ -0,0 +1,58 @@ +from sqlalchemy import ( + CheckConstraint, DateTime, Integer, ForeignKey, Index, + String, UniqueConstraint, func, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship +from datetime import datetime +from typing import Optional +from app.database import Base + + +class ProjectMember(Base): + __tablename__ = "project_members" + __table_args__ = ( + UniqueConstraint("project_id", "user_id", name="uq_project_members_proj_user"), + CheckConstraint( + "permission IN ('read_only', 'create_modify')", + name="ck_project_members_permission", + ), + CheckConstraint( + "status IN ('pending', 'accepted', 'rejected')", + name="ck_project_members_status", + ), + CheckConstraint( + "source IN ('invited', 'auto_assigned')", + name="ck_project_members_source", + ), + Index("ix_project_members_user_id", "user_id"), + Index("ix_project_members_project_id", "project_id"), + Index("ix_project_members_status", "status"), + ) + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + project_id: Mapped[int] = mapped_column( + Integer, ForeignKey("projects.id", ondelete="CASCADE"), nullable=False + ) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + invited_by: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + permission: Mapped[str] = mapped_column(String(20), nullable=False) + status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending") + source: Mapped[str] = mapped_column(String(20), nullable=False, default="invited") + created_at: Mapped[datetime] = mapped_column( + DateTime, default=func.now(), server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime, default=func.now(), server_default=func.now(), onupdate=func.now() + ) + accepted_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + + # Relationships — lazy="raise" to prevent N+1 (mirrors CalendarMember) + project: Mapped["Project"] = relationship(back_populates="members", lazy="raise") + user: Mapped["User"] = relationship(foreign_keys=[user_id], lazy="raise") + inviter: Mapped[Optional["User"]] = relationship( + foreign_keys=[invited_by], lazy="raise" + ) diff --git a/backend/app/models/project_task.py b/backend/app/models/project_task.py index 94a41d1..ad25d27 100644 --- a/backend/app/models/project_task.py +++ b/backend/app/models/project_task.py @@ -1,3 +1,4 @@ +import sqlalchemy as sa from sqlalchemy import String, Text, Integer, Date, ForeignKey, func from sqlalchemy.orm import Mapped, mapped_column, relationship as sa_relationship from datetime import datetime, date @@ -20,21 +21,30 @@ class ProjectTask(Base): due_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True) person_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("people.id", ondelete="SET NULL"), nullable=True) sort_order: Mapped[int] = mapped_column(Integer, default=0) + version: Mapped[int] = mapped_column(Integer, default=1, server_default=sa.text("1")) created_at: Mapped[datetime] = mapped_column(default=func.now()) updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now()) - # Relationships - project: Mapped["Project"] = sa_relationship(back_populates="tasks") - person: Mapped[Optional["Person"]] = sa_relationship(back_populates="assigned_tasks") + # Relationships — lazy="raise" to prevent N+1 (mirrors CalendarMember pattern) + project: Mapped["Project"] = sa_relationship(back_populates="tasks", lazy="raise") + person: Mapped[Optional["Person"]] = sa_relationship(back_populates="assigned_tasks", lazy="raise") parent_task: Mapped[Optional["ProjectTask"]] = sa_relationship( back_populates="subtasks", remote_side=[id], + lazy="raise", ) subtasks: Mapped[List["ProjectTask"]] = sa_relationship( back_populates="parent_task", cascade="all, delete-orphan", + lazy="raise", ) comments: Mapped[List["TaskComment"]] = sa_relationship( back_populates="task", cascade="all, delete-orphan", + lazy="raise", + ) + assignments: Mapped[List["ProjectTaskAssignment"]] = sa_relationship( + back_populates="task", + cascade="all, delete-orphan", + lazy="raise", ) diff --git a/backend/app/models/project_task_assignment.py b/backend/app/models/project_task_assignment.py new file mode 100644 index 0000000..353d6a9 --- /dev/null +++ b/backend/app/models/project_task_assignment.py @@ -0,0 +1,30 @@ +from sqlalchemy import DateTime, Integer, ForeignKey, UniqueConstraint, func +from sqlalchemy.orm import Mapped, mapped_column, relationship +from datetime import datetime +from app.database import Base + + +class ProjectTaskAssignment(Base): + __tablename__ = "project_task_assignments" + __table_args__ = ( + UniqueConstraint("task_id", "user_id", name="uq_task_assignments_task_user"), + ) + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + task_id: Mapped[int] = mapped_column( + Integer, ForeignKey("project_tasks.id", ondelete="CASCADE"), nullable=False, index=True + ) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + assigned_by: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + created_at: Mapped[datetime] = mapped_column( + DateTime, default=func.now(), server_default=func.now() + ) + + # Relationships — lazy="raise" to prevent N+1 + task: Mapped["ProjectTask"] = relationship(back_populates="assignments", lazy="raise") + user: Mapped["User"] = relationship(foreign_keys=[user_id], lazy="raise") + assigner: Mapped["User"] = relationship(foreign_keys=[assigned_by], lazy="raise") diff --git a/backend/app/models/task_comment.py b/backend/app/models/task_comment.py index 0a1423a..b1f372f 100644 --- a/backend/app/models/task_comment.py +++ b/backend/app/models/task_comment.py @@ -1,6 +1,7 @@ from sqlalchemy import Text, Integer, ForeignKey from sqlalchemy.orm import Mapped, mapped_column, relationship as sa_relationship from datetime import datetime +from typing import Optional from app.database import Base @@ -11,8 +12,12 @@ class TaskComment(Base): task_id: Mapped[int] = mapped_column( Integer, ForeignKey("project_tasks.id", ondelete="CASCADE"), nullable=False, index=True ) + user_id: Mapped[Optional[int]] = mapped_column( + Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) content: Mapped[str] = mapped_column(Text, nullable=False) created_at: Mapped[datetime] = mapped_column(default=datetime.now) - # Relationships - task: Mapped["ProjectTask"] = sa_relationship(back_populates="comments") + # Relationships — lazy="raise" to prevent N+1 (mirrors CalendarMember pattern) + task: Mapped["ProjectTask"] = sa_relationship(back_populates="comments", lazy="raise") + user: Mapped[Optional["User"]] = sa_relationship(lazy="raise") diff --git a/backend/app/routers/calendars.py b/backend/app/routers/calendars.py index 7dec7fc..624e0aa 100644 --- a/backend/app/routers/calendars.py +++ b/backend/app/routers/calendars.py @@ -1,4 +1,7 @@ -from fastapi import APIRouter, Depends, HTTPException, Path +from datetime import datetime + +from fastapi import APIRouter, Depends, HTTPException, Path, Query +from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import func, select, update from typing import List @@ -8,6 +11,7 @@ from app.models.calendar import Calendar from app.models.calendar_event import CalendarEvent from app.models.calendar_member import CalendarMember from app.schemas.calendar import CalendarCreate, CalendarUpdate, CalendarResponse +from app.services.calendar_sharing import require_permission from app.routers.auth import get_current_user from app.models.user import User @@ -136,3 +140,62 @@ async def delete_calendar( await db.delete(calendar) await db.commit() return None + + +# ────────────────────────────────────────────── +# DELTA POLLING +# ────────────────────────────────────────────── + +class CalendarPollResponse(BaseModel): + has_changes: bool + calendar_updated_at: str | None = None + changed_event_ids: list[int] = [] + + +@router.get("/{calendar_id}/poll", response_model=CalendarPollResponse) +async def poll_calendar( + calendar_id: int = Path(ge=1, le=2147483647), + since: str = Query(..., description="ISO timestamp to check for changes since"), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """Lightweight poll endpoint — returns changed event IDs since timestamp.""" + await require_permission(db, calendar_id, current_user.id, "read_only") + + try: + since_dt = datetime.fromisoformat(since) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid ISO timestamp") + + # Clamp to max 24h in the past to prevent expensive full-table scans + from datetime import timedelta + min_since = datetime.now() - timedelta(hours=24) + if since_dt < min_since: + since_dt = min_since + + # Check calendar-level update + cal_result = await db.execute( + select(Calendar.updated_at).where(Calendar.id == calendar_id) + ) + calendar_updated = cal_result.scalar_one_or_none() + if not calendar_updated: + raise HTTPException(status_code=404, detail="Calendar not found") + + calendar_changed = calendar_updated > since_dt + + # Check event-level changes using the ix_events_calendar_updated index + event_result = await db.execute( + select(CalendarEvent.id).where( + CalendarEvent.calendar_id == calendar_id, + CalendarEvent.updated_at > since_dt, + ) + ) + changed_event_ids = [r[0] for r in event_result.all()] + + has_changes = calendar_changed or len(changed_event_ids) > 0 + + return CalendarPollResponse( + has_changes=has_changes, + calendar_updated_at=calendar_updated.isoformat() if calendar_updated else None, + changed_event_ids=changed_event_ids, + ) diff --git a/backend/app/routers/connections.py b/backend/app/routers/connections.py index e3ded30..8032f10 100644 --- a/backend/app/routers/connections.py +++ b/backend/app/routers/connections.py @@ -51,6 +51,7 @@ from app.services.connection import ( ) from app.services.calendar_sharing import cascade_on_disconnect from app.services.notification import create_notification +from app.services.project_sharing import cascade_projects_on_disconnect router = APIRouter() logger = logging.getLogger(__name__) @@ -827,6 +828,9 @@ async def remove_connection( # Cascade: remove calendar memberships and event locks between these users await cascade_on_disconnect(db, current_user.id, counterpart_id) + # Cascade: remove project memberships and task assignments between these users + await cascade_projects_on_disconnect(db, current_user.id, counterpart_id) + await log_audit_event( db, action="connection.removed", diff --git a/backend/app/routers/projects.py b/backend/app/routers/projects.py index cd2b1f2..021c8db 100644 --- a/backend/app/routers/projects.py +++ b/backend/app/routers/projects.py @@ -1,18 +1,31 @@ from fastapi import APIRouter, Depends, HTTPException, Path, Query from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select +from sqlalchemy import delete as sa_delete, select, update from sqlalchemy.orm import selectinload from typing import List, Optional -from datetime import date, timedelta +from datetime import date, datetime, timedelta from pydantic import BaseModel, ConfigDict from app.database import get_db from app.models.project import Project from app.models.project_task import ProjectTask from app.models.task_comment import TaskComment +from app.models.project_member import ProjectMember +from app.models.project_task_assignment import ProjectTaskAssignment +from app.models.settings import Settings from app.schemas.project import ProjectCreate, ProjectUpdate, ProjectResponse, TrackedTaskResponse from app.schemas.project_task import ProjectTaskCreate, ProjectTaskUpdate, ProjectTaskResponse from app.schemas.task_comment import TaskCommentCreate, TaskCommentResponse +from app.schemas.project_member import ( + ProjectMemberInvite, ProjectMemberUpdate, ProjectMemberRespond, ProjectMemberResponse, +) +from app.schemas.project_task_assignment import TaskAssignmentCreate, TaskAssignmentResponse +from app.services.project_sharing import ( + get_project_permission, require_project_permission, get_accessible_project_ids, + validate_project_connections, get_effective_task_permission, ensure_auto_membership, + cleanup_auto_membership, ASSIGNEE_ALLOWED_FIELDS, +) +from app.services.notification import create_notification from app.routers.auth import get_current_user from app.models.user import User @@ -26,34 +39,61 @@ class ReorderItem(BaseModel): def _project_load_options(): - """All load options needed for project responses (tasks + subtasks + comments at each level).""" + """All load options needed for project responses (tasks + subtasks + comments + assignments).""" return [ - selectinload(Project.tasks).selectinload(ProjectTask.comments), - selectinload(Project.tasks).selectinload(ProjectTask.subtasks).selectinload(ProjectTask.comments), + selectinload(Project.tasks).selectinload(ProjectTask.comments).selectinload(TaskComment.user), + selectinload(Project.tasks).selectinload(ProjectTask.subtasks).selectinload(ProjectTask.comments).selectinload(TaskComment.user), selectinload(Project.tasks).selectinload(ProjectTask.subtasks).selectinload(ProjectTask.subtasks), + selectinload(Project.tasks).selectinload(ProjectTask.assignments).selectinload(ProjectTaskAssignment.user), + selectinload(Project.tasks).selectinload(ProjectTask.subtasks).selectinload(ProjectTask.assignments).selectinload(ProjectTaskAssignment.user), + selectinload(Project.members), ] def _task_load_options(): """All load options needed for task responses.""" return [ - selectinload(ProjectTask.comments), - selectinload(ProjectTask.subtasks).selectinload(ProjectTask.comments), + selectinload(ProjectTask.comments).selectinload(TaskComment.user), + selectinload(ProjectTask.subtasks).selectinload(ProjectTask.comments).selectinload(TaskComment.user), selectinload(ProjectTask.subtasks).selectinload(ProjectTask.subtasks), + selectinload(ProjectTask.assignments).selectinload(ProjectTaskAssignment.user), + selectinload(ProjectTask.subtasks).selectinload(ProjectTask.assignments).selectinload(ProjectTaskAssignment.user), ] +async def _get_user_name(db: AsyncSession, user_id: int) -> str | None: + """Get display name for a user from settings.preferred_name or user.username.""" + result = await db.execute( + select(Settings.preferred_name, User.username) + .outerjoin(Settings, Settings.user_id == User.id) + .where(User.id == user_id) + ) + row = result.one_or_none() + if not row: + return None + preferred, username = row.tuple() + return preferred or username + + +# ────────────────────────────────────────────── +# PROJECT CRUD +# ────────────────────────────────────────────── + @router.get("/", response_model=List[ProjectResponse]) async def get_projects( tracked: Optional[bool] = Query(None), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): - """Get all projects with their tasks. Optionally filter by tracked status.""" + """Get all projects the user owns or has accepted membership in.""" + accessible_ids = await get_accessible_project_ids(db, current_user.id) + if not accessible_ids: + return [] + query = ( select(Project) .options(*_project_load_options()) - .where(Project.user_id == current_user.id) + .where(Project.id.in_(accessible_ids)) .order_by(Project.created_at.desc()) ) if tracked is not None: @@ -72,6 +112,10 @@ async def get_tracked_tasks( current_user: User = Depends(get_current_user) ): """Get tasks and subtasks from tracked projects with due dates within the next N days.""" + accessible_ids = await get_accessible_project_ids(db, current_user.id) + if not accessible_ids: + return [] + today = date.today() cutoff = today + timedelta(days=days) @@ -83,7 +127,7 @@ async def get_tracked_tasks( selectinload(ProjectTask.parent_task), ) .where( - Project.user_id == current_user.id, + Project.id.in_(accessible_ids), Project.is_tracked == True, ProjectTask.due_date.isnot(None), ProjectTask.due_date >= today, @@ -110,6 +154,31 @@ async def get_tracked_tasks( ] +@router.get("/shared", response_model=List[ProjectResponse]) +async def get_shared_projects( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """List projects where user is an accepted member (not owner).""" + member_result = await db.execute( + select(ProjectMember.project_id).where( + ProjectMember.user_id == current_user.id, + ProjectMember.status == "accepted", + ) + ) + project_ids = [r[0] for r in member_result.all()] + if not project_ids: + return [] + + result = await db.execute( + select(Project) + .options(*_project_load_options()) + .where(Project.id.in_(project_ids)) + .order_by(Project.created_at.desc()) + ) + return result.scalars().unique().all() + + @router.post("/", response_model=ProjectResponse, status_code=201) async def create_project( project: ProjectCreate, @@ -121,7 +190,6 @@ async def create_project( db.add(new_project) await db.commit() - # Re-fetch with eagerly loaded tasks for response serialization query = select(Project).options(*_project_load_options()).where(Project.id == new_project.id) result = await db.execute(query) return result.scalar_one() @@ -134,10 +202,12 @@ async def get_project( current_user: User = Depends(get_current_user) ): """Get a specific project by ID with its tasks.""" + await require_project_permission(db, project_id, current_user.id, "read_only") + query = ( select(Project) .options(*_project_load_options()) - .where(Project.id == project_id, Project.user_id == current_user.id) + .where(Project.id == project_id) ) result = await db.execute(query) project = result.scalar_one_or_none() @@ -155,10 +225,10 @@ async def update_project( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): - """Update a project.""" - result = await db.execute( - select(Project).where(Project.id == project_id, Project.user_id == current_user.id) - ) + """Update a project. Owner only.""" + await require_project_permission(db, project_id, current_user.id, "owner") + + result = await db.execute(select(Project).where(Project.id == project_id)) project = result.scalar_one_or_none() if not project: @@ -171,7 +241,6 @@ async def update_project( await db.commit() - # Re-fetch with eagerly loaded tasks for response serialization query = select(Project).options(*_project_load_options()).where(Project.id == project_id) result = await db.execute(query) return result.scalar_one() @@ -183,10 +252,10 @@ async def delete_project( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): - """Delete a project and all its tasks.""" - result = await db.execute( - select(Project).where(Project.id == project_id, Project.user_id == current_user.id) - ) + """Delete a project and all its tasks. Owner only.""" + await require_project_permission(db, project_id, current_user.id, "owner") + + result = await db.execute(select(Project).where(Project.id == project_id)) project = result.scalar_one_or_none() if not project: @@ -198,6 +267,10 @@ async def delete_project( return None +# ────────────────────────────────────────────── +# TASK CRUD (permission-aware) +# ────────────────────────────────────────────── + @router.get("/{project_id}/tasks", response_model=List[ProjectTaskResponse]) async def get_project_tasks( project_id: int = Path(ge=1, le=2147483647), @@ -205,14 +278,7 @@ async def get_project_tasks( current_user: User = Depends(get_current_user) ): """Get top-level tasks for a specific project (subtasks are nested).""" - # Verify project ownership first - result = await db.execute( - select(Project).where(Project.id == project_id, Project.user_id == current_user.id) - ) - project = result.scalar_one_or_none() - - if not project: - raise HTTPException(status_code=404, detail="Project not found") + await require_project_permission(db, project_id, current_user.id, "read_only") query = ( select(ProjectTask) @@ -236,15 +302,8 @@ async def create_project_task( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): - """Create a new task or subtask for a project.""" - # Verify project ownership first - result = await db.execute( - select(Project).where(Project.id == project_id, Project.user_id == current_user.id) - ) - project = result.scalar_one_or_none() - - if not project: - raise HTTPException(status_code=404, detail="Project not found") + """Create a new task or subtask for a project. Requires create_modify permission.""" + await require_project_permission(db, project_id, current_user.id, "create_modify") # Validate parent_task_id if creating a subtask if task.parent_task_id is not None: @@ -268,7 +327,6 @@ async def create_project_task( db.add(new_task) await db.commit() - # Re-fetch with subtasks loaded query = ( select(ProjectTask) .options(*_task_load_options()) @@ -285,15 +343,8 @@ async def reorder_tasks( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): - """Bulk update sort_order for tasks.""" - # Verify project ownership first - result = await db.execute( - select(Project).where(Project.id == project_id, Project.user_id == current_user.id) - ) - project = result.scalar_one_or_none() - - if not project: - raise HTTPException(status_code=404, detail="Project not found") + """Bulk update sort_order for tasks. Requires create_modify permission.""" + await require_project_permission(db, project_id, current_user.id, "create_modify") # AC-4: Batch-fetch all tasks in one query instead of N sequential queries task_ids = [item.id for item in items] @@ -323,13 +374,12 @@ async def update_project_task( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): - """Update a project task.""" - # Verify project ownership first, then fetch task scoped to that project - project_result = await db.execute( - select(Project).where(Project.id == project_id, Project.user_id == current_user.id) - ) - if not project_result.scalar_one_or_none(): + """Update a project task. Permission checked at project and task level.""" + perm, project_perm = await get_effective_task_permission(db, current_user.id, task_id, project_id) + if perm is None: raise HTTPException(status_code=404, detail="Project not found") + if perm == "read_only": + raise HTTPException(status_code=403, detail="Insufficient permission") result = await db.execute( select(ProjectTask).where( @@ -344,12 +394,28 @@ async def update_project_task( update_data = task_update.model_dump(exclude_unset=True) + # SEC-P02: Assignees (non-owner, non-project-member with create_modify) restricted to content fields + if project_perm not in ("owner", "create_modify"): + # This user's create_modify comes from task assignment — enforce allowlist + disallowed = set(update_data.keys()) - ASSIGNEE_ALLOWED_FIELDS + if disallowed: + raise HTTPException( + status_code=403, + detail=f"Task assignees cannot modify: {', '.join(sorted(disallowed))}", + ) + + # Optimistic locking: if version provided, check it matches + client_version = update_data.pop("version", None) + if client_version is not None and task.version != client_version: + raise HTTPException(status_code=409, detail="Task was modified by another user") + for key, value in update_data.items(): setattr(task, key, value) + task.version += 1 + await db.commit() - # Re-fetch with subtasks loaded query = ( select(ProjectTask) .options(*_task_load_options()) @@ -366,13 +432,8 @@ async def delete_project_task( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): - """Delete a project task (cascades to subtasks).""" - # Verify project ownership first, then fetch task scoped to that project - project_result = await db.execute( - select(Project).where(Project.id == project_id, Project.user_id == current_user.id) - ) - if not project_result.scalar_one_or_none(): - raise HTTPException(status_code=404, detail="Project not found") + """Delete a project task (cascades to subtasks). Requires create_modify permission.""" + await require_project_permission(db, project_id, current_user.id, "create_modify") result = await db.execute( select(ProjectTask).where( @@ -391,6 +452,10 @@ async def delete_project_task( return None +# ────────────────────────────────────────────── +# COMMENTS (permission-aware) +# ────────────────────────────────────────────── + @router.post("/{project_id}/tasks/{task_id}/comments", response_model=TaskCommentResponse, status_code=201) async def create_task_comment( project_id: int = Path(ge=1, le=2147483647), @@ -399,13 +464,8 @@ async def create_task_comment( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): - """Add a comment to a task.""" - # Verify project ownership first, then fetch task scoped to that project - project_result = await db.execute( - select(Project).where(Project.id == project_id, Project.user_id == current_user.id) - ) - if not project_result.scalar_one_or_none(): - raise HTTPException(status_code=404, detail="Project not found") + """Add a comment to a task. All members can comment (read_only minimum).""" + await require_project_permission(db, project_id, current_user.id, "read_only") result = await db.execute( select(ProjectTask).where( @@ -418,12 +478,23 @@ async def create_task_comment( if not task: raise HTTPException(status_code=404, detail="Task not found") - new_comment = TaskComment(task_id=task_id, content=comment.content) + new_comment = TaskComment(task_id=task_id, user_id=current_user.id, content=comment.content) db.add(new_comment) + + # Get author name before commit + author_name = await _get_user_name(db, current_user.id) + await db.commit() await db.refresh(new_comment) - return new_comment + return TaskCommentResponse( + id=new_comment.id, + task_id=new_comment.task_id, + user_id=new_comment.user_id, + author_name=author_name, + content=new_comment.content, + created_at=new_comment.created_at, + ) @router.delete("/{project_id}/tasks/{task_id}/comments/{comment_id}", status_code=204) @@ -434,12 +505,9 @@ async def delete_task_comment( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): - """Delete a task comment.""" - # Verify project ownership first, then fetch comment scoped through task - project_result = await db.execute( - select(Project).where(Project.id == project_id, Project.user_id == current_user.id) - ) - if not project_result.scalar_one_or_none(): + """Delete a task comment. Comment author or project owner only.""" + perm = await get_project_permission(db, project_id, current_user.id) + if perm is None: raise HTTPException(status_code=404, detail="Project not found") result = await db.execute( @@ -453,7 +521,484 @@ async def delete_task_comment( if not comment: raise HTTPException(status_code=404, detail="Comment not found") + # Only comment author or project owner can delete + if comment.user_id != current_user.id and perm != "owner": + raise HTTPException(status_code=403, detail="Only the comment author or project owner can delete this comment") + await db.delete(comment) await db.commit() return None + + +# ────────────────────────────────────────────── +# MEMBERSHIP ROUTES +# ────────────────────────────────────────────── + +@router.post("/{project_id}/members", response_model=List[ProjectMemberResponse], status_code=201) +async def invite_members( + project_id: int = Path(ge=1, le=2147483647), + invite: ProjectMemberInvite = ..., + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """Invite connection(s) to a project. Owner only.""" + await require_project_permission(db, project_id, current_user.id, "owner") + + # Validate connections + await validate_project_connections(db, current_user.id, invite.user_ids) + + # Check pending invite cap (max 10 pending per project) + pending_count_result = await db.execute( + select(ProjectMember.id).where( + ProjectMember.project_id == project_id, + ProjectMember.status == "pending", + ) + ) + pending_count = len(pending_count_result.all()) + if pending_count + len(invite.user_ids) > 10: + raise HTTPException(status_code=400, detail="Maximum 10 pending invites per project") + + # Filter out self and existing members + existing_result = await db.execute( + select(ProjectMember.user_id).where( + ProjectMember.project_id == project_id, + ProjectMember.user_id.in_(invite.user_ids), + ) + ) + existing_user_ids = {r[0] for r in existing_result.all()} + + # Get project for notifications + project_result = await db.execute(select(Project.name).where(Project.id == project_id)) + project_name = project_result.scalar_one() + + inviter_name = await _get_user_name(db, current_user.id) + created_members = [] + + for uid in invite.user_ids: + if uid == current_user.id or uid in existing_user_ids: + continue + + member = ProjectMember( + project_id=project_id, + user_id=uid, + invited_by=current_user.id, + permission=invite.permission, + status="pending", + source="invited", + ) + db.add(member) + created_members.append(member) + + # In-app notification + await create_notification( + db, uid, "project_invite", + f"Project invitation from {inviter_name}", + f"You've been invited to collaborate on \"{project_name}\"", + data={"project_id": project_id}, + source_type="project_member", + ) + + await db.flush() # Assign IDs before commit (ORM objects expire after commit) + member_ids = [m.id for m in created_members] + await db.commit() + + # Re-fetch with relationships + if not created_members: + return [] + result = await db.execute( + select(ProjectMember) + .options( + selectinload(ProjectMember.user), + selectinload(ProjectMember.inviter), + ) + .where(ProjectMember.id.in_(member_ids)) + ) + members = result.scalars().all() + + # Build response with names + responses = [] + for m in members: + resp = ProjectMemberResponse.model_validate(m) + resp.user_name = m.user.username + resp.inviter_name = m.inviter.username if m.inviter else None + responses.append(resp) + + return responses + + +@router.get("/{project_id}/members", response_model=List[ProjectMemberResponse]) +async def get_members( + project_id: int = Path(ge=1, le=2147483647), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """List members + statuses. Any member can view.""" + await require_project_permission(db, project_id, current_user.id, "read_only") + + result = await db.execute( + select(ProjectMember) + .options( + selectinload(ProjectMember.user), + selectinload(ProjectMember.inviter), + ) + .where(ProjectMember.project_id == project_id) + .order_by(ProjectMember.created_at.asc()) + ) + members = result.scalars().all() + + # Batch-fetch settings for preferred_name + user_ids = [m.user_id for m in members] + [m.invited_by for m in members] + settings_result = await db.execute( + select(Settings.user_id, Settings.preferred_name).where(Settings.user_id.in_(user_ids)) + ) + name_map = {r[0]: r[1] for r in settings_result.all()} + + responses = [] + for m in members: + resp = ProjectMemberResponse.model_validate(m) + resp.user_name = name_map.get(m.user_id) or m.user.username + resp.inviter_name = name_map.get(m.invited_by) or (m.inviter.username if m.inviter else None) + responses.append(resp) + + return responses + + +@router.patch("/{project_id}/members/{user_id}", response_model=ProjectMemberResponse) +async def update_member_permission( + project_id: int = Path(ge=1, le=2147483647), + user_id: int = Path(ge=1, le=2147483647), + update: ProjectMemberUpdate = ..., + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """Update a member's permission level. Owner only.""" + await require_project_permission(db, project_id, current_user.id, "owner") + + result = await db.execute( + select(ProjectMember) + .options(selectinload(ProjectMember.user), selectinload(ProjectMember.inviter)) + .where( + ProjectMember.project_id == project_id, + ProjectMember.user_id == user_id, + ) + ) + member = result.scalar_one_or_none() + if not member: + raise HTTPException(status_code=404, detail="Member not found") + + member.permission = update.permission + + # Extract response data BEFORE commit (ORM objects expire after commit) + resp = ProjectMemberResponse.model_validate(member) + resp.user_name = member.user.username + resp.inviter_name = member.inviter.username if member.inviter else None + + await db.commit() + + return resp + + +@router.delete("/{project_id}/members/{user_id}", status_code=204) +async def remove_member( + project_id: int = Path(ge=1, le=2147483647), + user_id: int = Path(ge=1, le=2147483647), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """Remove a member. Owner or self (leave project).""" + perm = await get_project_permission(db, project_id, current_user.id) + if perm is None: + raise HTTPException(status_code=404, detail="Project not found") + + # Only owner can remove others; anyone can remove themselves + if user_id != current_user.id and perm != "owner": + raise HTTPException(status_code=403, detail="Only the project owner can remove members") + + result = await db.execute( + select(ProjectMember).where( + ProjectMember.project_id == project_id, + ProjectMember.user_id == user_id, + ) + ) + member = result.scalar_one_or_none() + if not member: + raise HTTPException(status_code=404, detail="Member not found") + + # Remove task assignments for this user in this project + await db.execute( + sa_delete(ProjectTaskAssignment).where( + ProjectTaskAssignment.user_id == user_id, + ProjectTaskAssignment.task_id.in_( + select(ProjectTask.id).where(ProjectTask.project_id == project_id) + ), + ) + ) + + await db.delete(member) + await db.commit() + + return None + + +@router.post("/memberships/{project_id}/respond", response_model=ProjectMemberResponse) +async def respond_to_invite( + project_id: int = Path(ge=1, le=2147483647), + respond: ProjectMemberRespond = ..., + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """Accept or reject a project invite.""" + result = await db.execute( + select(ProjectMember) + .options(selectinload(ProjectMember.user), selectinload(ProjectMember.inviter)) + .where( + ProjectMember.project_id == project_id, + ProjectMember.user_id == current_user.id, + ProjectMember.status == "pending", + ) + ) + member = result.scalar_one_or_none() + if not member: + raise HTTPException(status_code=404, detail="No pending invitation found") + + # Extract response data before any mutations (ORM objects expire after commit) + resp = ProjectMemberResponse.model_validate(member) + resp.user_name = member.user.username + resp.inviter_name = member.inviter.username if member.inviter else None + + if respond.response == "accepted": + member.status = "accepted" + member.accepted_at = datetime.now() + + # Get project owner for notification + project_result = await db.execute( + select(Project.user_id, Project.name).where(Project.id == project_id) + ) + project_row = project_result.one() + owner_id, project_name = project_row.tuple() + + responder_name = await _get_user_name(db, current_user.id) + + await create_notification( + db, owner_id, "project_invite_accepted", + f"{responder_name} joined your project", + f"{responder_name} accepted the invitation to \"{project_name}\"", + data={"project_id": project_id}, + source_type="project_member", + ) + + resp.status = "accepted" + else: + # Rejected — delete the row to prevent accumulation (W-06) + await db.delete(member) + resp.status = "rejected" + + await db.commit() + + return resp + + +# ────────────────────────────────────────────── +# TASK ASSIGNMENT ROUTES +# ────────────────────────────────────────────── + +@router.post("/{project_id}/tasks/{task_id}/assignments", response_model=List[TaskAssignmentResponse], status_code=201) +async def assign_users_to_task( + project_id: int = Path(ge=1, le=2147483647), + task_id: int = Path(ge=1, le=2147483647), + assignment: TaskAssignmentCreate = ..., + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """Assign user(s) to a task. Requires create_modify on project or be owner.""" + await require_project_permission(db, project_id, current_user.id, "create_modify") + + # Verify task exists in project + task_result = await db.execute( + select(ProjectTask).where( + ProjectTask.id == task_id, + ProjectTask.project_id == project_id, + ) + ) + task = task_result.scalar_one_or_none() + if not task: + raise HTTPException(status_code=404, detail="Task not found") + + # Get project owner for connection validation + project_result = await db.execute( + select(Project.user_id, Project.name).where(Project.id == project_id) + ) + project_row = project_result.one() + owner_id, project_name = project_row.tuple() + + # Validate connections (all assignees must be connections of the project owner) + non_owner_ids = [uid for uid in assignment.user_ids if uid != owner_id] + if non_owner_ids: + await validate_project_connections(db, owner_id, non_owner_ids) + + # Filter out existing assignments + existing_result = await db.execute( + select(ProjectTaskAssignment.user_id).where( + ProjectTaskAssignment.task_id == task_id, + ProjectTaskAssignment.user_id.in_(assignment.user_ids), + ) + ) + existing_user_ids = {r[0] for r in existing_result.all()} + + assigner_name = await _get_user_name(db, current_user.id) + created = [] + + for uid in assignment.user_ids: + if uid in existing_user_ids: + continue + + # Auto-membership: ensure user has ProjectMember row + if uid != owner_id: + await ensure_auto_membership(db, project_id, uid, current_user.id) + + new_assignment = ProjectTaskAssignment( + task_id=task_id, + user_id=uid, + assigned_by=current_user.id, + ) + db.add(new_assignment) + created.append(new_assignment) + + # Notify assignee (don't notify self) + if uid != current_user.id: + await create_notification( + db, uid, "task_assigned", + f"Task assigned by {assigner_name}", + f"You've been assigned to \"{task.title}\" in \"{project_name}\"", + data={"project_id": project_id, "task_id": task_id}, + source_type="task_assignment", + ) + + await db.flush() # Assign IDs before commit (ORM objects expire after commit) + assignment_ids = [a.id for a in created] + await db.commit() + + if not created: + return [] + + # Re-fetch with user info + result = await db.execute( + select(ProjectTaskAssignment) + .options(selectinload(ProjectTaskAssignment.user)) + .where(ProjectTaskAssignment.id.in_(assignment_ids)) + ) + assignments = result.scalars().all() + + # Get names + user_ids = [a.user_id for a in assignments] + settings_result = await db.execute( + select(Settings.user_id, Settings.preferred_name).where(Settings.user_id.in_(user_ids)) + ) + name_map = {r[0]: r[1] for r in settings_result.all()} + + return [ + TaskAssignmentResponse( + id=a.id, + task_id=a.task_id, + user_id=a.user_id, + assigned_by=a.assigned_by, + user_name=name_map.get(a.user_id) or a.user.username, + created_at=a.created_at, + ) + for a in assignments + ] + + +@router.delete("/{project_id}/tasks/{task_id}/assignments/{user_id}", status_code=204) +async def remove_task_assignment( + project_id: int = Path(ge=1, le=2147483647), + task_id: int = Path(ge=1, le=2147483647), + user_id: int = Path(ge=1, le=2147483647), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """Remove a task assignment. Owner, create_modify member, or the assignee themselves.""" + perm = await get_project_permission(db, project_id, current_user.id) + if perm is None: + raise HTTPException(status_code=404, detail="Project not found") + + # Self-unassign is always allowed; otherwise need create_modify or owner + if user_id != current_user.id and perm not in ("owner", "create_modify"): + raise HTTPException(status_code=403, detail="Insufficient permission") + + result = await db.execute( + sa_delete(ProjectTaskAssignment) + .where( + ProjectTaskAssignment.task_id == task_id, + ProjectTaskAssignment.user_id == user_id, + ) + .returning(ProjectTaskAssignment.id) + ) + if not result.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="Assignment not found") + + # Cleanup auto-membership if no more assignments + await cleanup_auto_membership(db, project_id, user_id) + + await db.commit() + + return None + + +# ────────────────────────────────────────────── +# DELTA POLLING +# ────────────────────────────────────────────── + +class PollResponse(BaseModel): + has_changes: bool + project_updated_at: str | None = None + changed_task_ids: list[int] = [] + + +@router.get("/{project_id}/poll", response_model=PollResponse) +async def poll_project( + project_id: int = Path(ge=1, le=2147483647), + since: str = Query(..., description="ISO timestamp to check for changes since"), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """Lightweight poll endpoint — returns changed task IDs since timestamp.""" + await require_project_permission(db, project_id, current_user.id, "read_only") + + try: + since_dt = datetime.fromisoformat(since) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid ISO timestamp") + + # Clamp to max 24h in the past to prevent expensive full-table scans + min_since = datetime.now() - timedelta(hours=24) + if since_dt < min_since: + since_dt = min_since + + # Check project-level update + proj_result = await db.execute( + select(Project.updated_at).where(Project.id == project_id) + ) + project_updated = proj_result.scalar_one_or_none() + if not project_updated: + raise HTTPException(status_code=404, detail="Project not found") + + project_changed = project_updated > since_dt + + # Check task-level changes using the index + task_result = await db.execute( + select(ProjectTask.id).where( + ProjectTask.project_id == project_id, + ProjectTask.updated_at > since_dt, + ) + ) + changed_task_ids = [r[0] for r in task_result.all()] + + has_changes = project_changed or len(changed_task_ids) > 0 + + return PollResponse( + has_changes=has_changes, + project_updated_at=project_updated.isoformat() if project_updated else None, + changed_task_ids=changed_task_ids, + ) diff --git a/backend/app/schemas/project.py b/backend/app/schemas/project.py index 925156e..53f697c 100644 --- a/backend/app/schemas/project.py +++ b/backend/app/schemas/project.py @@ -1,8 +1,11 @@ -from pydantic import BaseModel, ConfigDict, Field +import logging +from pydantic import BaseModel, ConfigDict, Field, model_validator from datetime import datetime, date from typing import Optional, List, Literal from app.schemas.project_task import ProjectTaskResponse +logger = logging.getLogger(__name__) + ProjectStatus = Literal["not_started", "in_progress", "completed", "blocked", "review", "on_hold"] @@ -30,18 +33,44 @@ class ProjectUpdate(BaseModel): class ProjectResponse(BaseModel): id: int + user_id: int = 0 name: str description: Optional[str] status: str color: Optional[str] due_date: Optional[date] is_tracked: bool + member_count: int = 0 created_at: datetime updated_at: datetime tasks: List[ProjectTaskResponse] = [] model_config = ConfigDict(from_attributes=True) + @model_validator(mode="before") + @classmethod + def compute_member_count(cls, data): # type: ignore[override] + """Compute member_count from eagerly loaded members relationship.""" + if hasattr(data, "members"): + try: + data = dict( + id=data.id, + user_id=data.user_id, + name=data.name, + description=data.description, + status=data.status, + color=data.color, + due_date=data.due_date, + is_tracked=data.is_tracked, + member_count=len([m for m in data.members if m.status == "accepted"]), + created_at=data.created_at, + updated_at=data.updated_at, + tasks=data.tasks, + ) + except Exception as exc: + logger.debug("member_count compute skipped: %s", exc) + return data + class TrackedTaskResponse(BaseModel): id: int diff --git a/backend/app/schemas/project_member.py b/backend/app/schemas/project_member.py new file mode 100644 index 0000000..350fcb3 --- /dev/null +++ b/backend/app/schemas/project_member.py @@ -0,0 +1,43 @@ +from typing import Annotated, Optional, Literal +from pydantic import BaseModel, ConfigDict, Field +from datetime import datetime + +MemberPermission = Literal["read_only", "create_modify"] +MemberStatus = Literal["pending", "accepted", "rejected"] +InviteResponse = Literal["accepted", "rejected"] + + +class ProjectMemberInvite(BaseModel): + model_config = ConfigDict(extra="forbid") + + user_ids: list[Annotated[int, Field(ge=1, le=2147483647)]] = Field(min_length=1, max_length=10) + permission: MemberPermission = "create_modify" + + +class ProjectMemberUpdate(BaseModel): + model_config = ConfigDict(extra="forbid") + + permission: MemberPermission + + +class ProjectMemberRespond(BaseModel): + model_config = ConfigDict(extra="forbid") + + response: InviteResponse + + +class ProjectMemberResponse(BaseModel): + id: int + project_id: int + user_id: int + invited_by: int + permission: str + status: str + source: str + user_name: str | None = None + inviter_name: str | None = None + created_at: datetime + updated_at: datetime + accepted_at: datetime | None = None + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/schemas/project_task.py b/backend/app/schemas/project_task.py index 531315e..d80c7d4 100644 --- a/backend/app/schemas/project_task.py +++ b/backend/app/schemas/project_task.py @@ -2,6 +2,7 @@ from pydantic import BaseModel, ConfigDict, Field from datetime import datetime, date from typing import Optional, List, Literal from app.schemas.task_comment import TaskCommentResponse +from app.schemas.project_task_assignment import TaskAssignmentResponse TaskStatus = Literal["pending", "in_progress", "completed", "blocked", "review", "on_hold"] TaskPriority = Literal["none", "low", "medium", "high"] @@ -30,6 +31,7 @@ class ProjectTaskUpdate(BaseModel): due_date: Optional[date] = None person_id: Optional[int] = None sort_order: Optional[int] = None + version: Optional[int] = None # For optimistic locking class ProjectTaskResponse(BaseModel): @@ -43,10 +45,12 @@ class ProjectTaskResponse(BaseModel): due_date: Optional[date] person_id: Optional[int] sort_order: int + version: int = 1 created_at: datetime updated_at: datetime subtasks: List["ProjectTaskResponse"] = [] comments: List[TaskCommentResponse] = [] + assignments: List[TaskAssignmentResponse] = [] model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/schemas/project_task_assignment.py b/backend/app/schemas/project_task_assignment.py new file mode 100644 index 0000000..a4b42d9 --- /dev/null +++ b/backend/app/schemas/project_task_assignment.py @@ -0,0 +1,31 @@ +from typing import Annotated +from pydantic import BaseModel, ConfigDict, Field, model_validator +from datetime import datetime + + +class TaskAssignmentCreate(BaseModel): + model_config = ConfigDict(extra="forbid") + + user_ids: list[Annotated[int, Field(ge=1, le=2147483647)]] = Field(min_length=1, max_length=20) + + +class TaskAssignmentResponse(BaseModel): + id: int + task_id: int + user_id: int + assigned_by: int + user_name: str | None = None + created_at: datetime + + model_config = ConfigDict(from_attributes=True) + + @model_validator(mode="before") + @classmethod + def resolve_user_name(cls, data): # type: ignore[override] + """Populate user_name from eagerly loaded user relationship.""" + if hasattr(data, "user") and data.user is not None and not getattr(data, "user_name", None): + # Build dict from ORM columns so new fields are auto-included + cols = {c.key: getattr(data, c.key) for c in data.__table__.columns} + cols["user_name"] = data.user.username + return cols + return data diff --git a/backend/app/schemas/task_comment.py b/backend/app/schemas/task_comment.py index 6c28615..ac27abc 100644 --- a/backend/app/schemas/task_comment.py +++ b/backend/app/schemas/task_comment.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, model_validator from datetime import datetime @@ -11,7 +11,19 @@ class TaskCommentCreate(BaseModel): class TaskCommentResponse(BaseModel): id: int task_id: int + user_id: int | None = None + author_name: str | None = None content: str created_at: datetime model_config = ConfigDict(from_attributes=True) + + @model_validator(mode="before") + @classmethod + def resolve_author_name(cls, data): # type: ignore[override] + """Populate author_name from eagerly loaded user relationship.""" + if hasattr(data, "user") and data.user is not None and not getattr(data, "author_name", None): + cols = {c.key: getattr(data, c.key) for c in data.__table__.columns} + cols["author_name"] = data.user.username + return cols + return data diff --git a/backend/app/services/project_sharing.py b/backend/app/services/project_sharing.py new file mode 100644 index 0000000..852e9ac --- /dev/null +++ b/backend/app/services/project_sharing.py @@ -0,0 +1,269 @@ +""" +Project sharing service — permission checks, auto-membership, disconnect cascade. + +All functions accept an AsyncSession and do NOT commit — callers manage transactions. +""" +import logging +from datetime import datetime + +from fastapi import HTTPException +from sqlalchemy import delete, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.project import Project +from app.models.project_member import ProjectMember +from app.models.project_task import ProjectTask +from app.models.project_task_assignment import ProjectTaskAssignment +from app.models.user_connection import UserConnection + +logger = logging.getLogger(__name__) + +PERMISSION_RANK = {"read_only": 1, "create_modify": 2} + +# Fields task assignees (from assignment, not project membership) may edit +ASSIGNEE_ALLOWED_FIELDS = {"title", "description", "status", "priority", "due_date"} + + +async def get_project_permission( + db: AsyncSession, project_id: int, user_id: int +) -> str | None: + """ + Returns 'owner', 'create_modify', 'read_only', or None. + Single query with LEFT JOIN (mirrors calendar_sharing pattern). + """ + result = await db.execute( + select( + Project.user_id, + ProjectMember.permission, + ) + .outerjoin( + ProjectMember, + (ProjectMember.project_id == Project.id) + & (ProjectMember.user_id == user_id) + & (ProjectMember.status == "accepted"), + ) + .where(Project.id == project_id) + ) + row = result.one_or_none() + if not row: + return None + owner_id, member_permission = row.tuple() + if owner_id == user_id: + return "owner" + return member_permission + + +async def require_project_permission( + db: AsyncSession, project_id: int, user_id: int, min_level: str +) -> str: + """ + Raises 404 if project doesn't exist or user has no access. + Raises 403 if user has insufficient permission. + Returns the actual permission string (or 'owner'). + """ + perm = await get_project_permission(db, project_id, user_id) + if perm is None: + raise HTTPException(status_code=404, detail="Project not found") + if perm == "owner": + return "owner" + if min_level == "owner": + raise HTTPException(status_code=403, detail="Only the project owner can perform this action") + if PERMISSION_RANK.get(perm, 0) < PERMISSION_RANK.get(min_level, 0): + raise HTTPException(status_code=403, detail="Insufficient permission on this project") + return perm + + +async def get_accessible_project_ids(db: AsyncSession, user_id: int) -> set[int]: + """Returns owned + accepted membership project IDs.""" + result = await db.execute( + select(Project.id).where(Project.user_id == user_id) + .union( + select(ProjectMember.project_id).where( + ProjectMember.user_id == user_id, + ProjectMember.status == "accepted", + ) + ) + ) + return {r[0] for r in result.all()} + + +async def validate_project_connections( + db: AsyncSession, owner_id: int, user_ids: list[int] +) -> None: + """Validates all target users are active connections of the owner. Raises 400 on failure.""" + if not user_ids: + return + result = await db.execute( + select(UserConnection.connected_user_id).where( + UserConnection.user_id == owner_id, + UserConnection.connected_user_id.in_(user_ids), + ) + ) + connected = {r[0] for r in result.all()} + missing = set(user_ids) - connected + if missing: + raise HTTPException( + status_code=400, + detail=f"Users {sorted(missing)} are not your connections", + ) + + +async def get_effective_task_permission( + db: AsyncSession, user_id: int, task_id: int, project_id: int +) -> tuple[str | None, str | None]: + """ + Returns (effective_permission, project_level_permission) for a specific task. + 1. Get project-level permission (owner/create_modify/read_only) + 2. If user is assigned to THIS task → max(project_perm, create_modify) + 3. If task has parent and user assigned to PARENT → same as above + 4. Return (effective, project_level) + """ + project_perm = await get_project_permission(db, project_id, user_id) + if project_perm is None: + return None, None + if project_perm == "owner": + return "owner", "owner" + + # Check direct assignment on this task + task_result = await db.execute( + select(ProjectTask.parent_task_id).where(ProjectTask.id == task_id) + ) + task_row = task_result.one_or_none() + if not task_row: + return project_perm, project_perm + + parent_task_id = task_row[0] + + # Check assignment on this task or its parent + check_task_ids = [task_id] + if parent_task_id is not None: + check_task_ids.append(parent_task_id) + + assignment_result = await db.execute( + select(ProjectTaskAssignment.id).where( + ProjectTaskAssignment.task_id.in_(check_task_ids), + ProjectTaskAssignment.user_id == user_id, + ).limit(1) + ) + if assignment_result.scalar_one_or_none() is not None: + # Assignment grants at least create_modify + if PERMISSION_RANK.get(project_perm, 0) >= PERMISSION_RANK["create_modify"]: + return project_perm, project_perm + return "create_modify", project_perm + + return project_perm, project_perm + + +async def ensure_auto_membership( + db: AsyncSession, project_id: int, user_id: int, invited_by: int +) -> None: + """ + When assigning a user to a task, ensure they have a ProjectMember row. + If none exists, create one with read_only + auto_assigned + accepted (no invite flow). + """ + existing = await db.execute( + select(ProjectMember.id).where( + ProjectMember.project_id == project_id, + ProjectMember.user_id == user_id, + ) + ) + if existing.scalar_one_or_none() is not None: + return + + member = ProjectMember( + project_id=project_id, + user_id=user_id, + invited_by=invited_by, + permission="read_only", + status="accepted", + source="auto_assigned", + accepted_at=datetime.now(), + ) + db.add(member) + + +async def cleanup_auto_membership( + db: AsyncSession, project_id: int, user_id: int +) -> None: + """ + After removing a task assignment, check if user has any remaining assignments + in this project. If not and membership is auto_assigned, remove it. + """ + remaining = await db.execute( + select(ProjectTaskAssignment.id) + .join(ProjectTask, ProjectTaskAssignment.task_id == ProjectTask.id) + .where( + ProjectTask.project_id == project_id, + ProjectTaskAssignment.user_id == user_id, + ) + .limit(1) + ) + if remaining.scalar_one_or_none() is not None: + return # Still has assignments + + # Remove auto_assigned membership only + await db.execute( + delete(ProjectMember).where( + ProjectMember.project_id == project_id, + ProjectMember.user_id == user_id, + ProjectMember.source == "auto_assigned", + ) + ) + + +async def cascade_projects_on_disconnect( + db: AsyncSession, user_a_id: int, user_b_id: int +) -> None: + """ + When a connection is severed: + 1. Find all ProjectMember rows where one user is a member of the other's projects + 2. Find all ProjectTaskAssignment rows for those memberships + 3. Remove assignments, then remove memberships + """ + # Single query: find projects owned by each user + result = await db.execute( + select(Project.id, Project.user_id).where( + Project.user_id.in_([user_a_id, user_b_id]) + ) + ) + a_proj_ids: list[int] = [] + b_proj_ids: list[int] = [] + for proj_id, owner_id in result.all(): + if owner_id == user_a_id: + a_proj_ids.append(proj_id) + else: + b_proj_ids.append(proj_id) + + # Remove user_b's assignments + memberships on user_a's projects + if a_proj_ids: + await db.execute( + delete(ProjectTaskAssignment).where( + ProjectTaskAssignment.user_id == user_b_id, + ProjectTaskAssignment.task_id.in_( + select(ProjectTask.id).where(ProjectTask.project_id.in_(a_proj_ids)) + ), + ) + ) + await db.execute( + delete(ProjectMember).where( + ProjectMember.project_id.in_(a_proj_ids), + ProjectMember.user_id == user_b_id, + ) + ) + + # Remove user_a's assignments + memberships on user_b's projects + if b_proj_ids: + await db.execute( + delete(ProjectTaskAssignment).where( + ProjectTaskAssignment.user_id == user_a_id, + ProjectTaskAssignment.task_id.in_( + select(ProjectTask.id).where(ProjectTask.project_id.in_(b_proj_ids)) + ), + ) + ) + await db.execute( + delete(ProjectMember).where( + ProjectMember.project_id.in_(b_proj_ids), + ProjectMember.user_id == user_a_id, + ) + ) diff --git a/frontend/src/components/notifications/NotificationToaster.tsx b/frontend/src/components/notifications/NotificationToaster.tsx index 64e9300..f67a107 100644 --- a/frontend/src/components/notifications/NotificationToaster.tsx +++ b/frontend/src/components/notifications/NotificationToaster.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; import { toast } from 'sonner'; -import { Check, X, Bell, UserPlus, Calendar, Clock } from 'lucide-react'; +import { Check, X, Bell, UserPlus, Calendar, Clock, FolderKanban, ClipboardList } from 'lucide-react'; import { useQueryClient } from '@tanstack/react-query'; import { useNotifications } from '@/hooks/useNotifications'; import { useConnections } from '@/hooks/useConnections'; @@ -26,6 +27,9 @@ export default function NotificationToaster() { respondRef.current = respond; const markReadRef = useRef(markRead); markReadRef.current = markRead; + const navigate = useNavigate(); + const navigateRef = useRef(navigate); + navigateRef.current = navigate; const handleConnectionRespond = useCallback( async (requestId: number, action: 'accept' | 'reject', toastId: string | number, notificationId: number) => { @@ -123,6 +127,38 @@ export default function NotificationToaster() { [], ); + const handleProjectInviteRespond = useCallback( + async (projectId: number, action: 'accepted' | 'rejected', toastId: string | number, notificationId: number) => { + const key = `proj-${projectId}`; + if (respondingRef.current.has(key)) return; + respondingRef.current.add(key); + + toast.dismiss(toastId); + const loadingId = toast.loading( + action === 'accepted' ? 'Accepting project invite\u2026' : 'Declining invite\u2026', + ); + + try { + await api.post(`/projects/memberships/${projectId}/respond`, { response: action }); + toast.dismiss(loadingId); + toast.success(action === 'accepted' ? 'Project invite accepted' : 'Project invite declined'); + markReadRef.current([notificationId]).catch(() => {}); + queryClient.invalidateQueries({ queryKey: ['projects'] }); + } catch (err) { + toast.dismiss(loadingId); + if (axios.isAxiosError(err) && err.response?.status === 409) { + toast.success('Already responded'); + markReadRef.current([notificationId]).catch(() => {}); + } else { + toast.error(getErrorMessage(err, 'Failed to respond to project invite')); + } + } finally { + respondingRef.current.delete(key); + } + }, + [], + ); + // Track unread count changes to force-refetch the list useEffect(() => { if (unreadCount > prevUnreadRef.current && initializedRef.current) { @@ -141,7 +177,7 @@ export default function NotificationToaster() { initializedRef.current = true; // Toast actionable unread notifications on login so the user can act immediately - const actionableTypes = new Set(['connection_request', 'calendar_invite', 'event_invite']); + const actionableTypes = new Set(['connection_request', 'calendar_invite', 'event_invite', 'project_invite', 'task_assigned']); const actionable = notifications.filter( (n) => !n.is_read && actionableTypes.has(n.type), ); @@ -155,6 +191,10 @@ export default function NotificationToaster() { showCalendarInviteToast(notification); } else if (notification.type === 'event_invite' && notification.data) { showEventInviteToast(notification); + } else if (notification.type === 'project_invite' && notification.data) { + showProjectInviteToast(notification); + } else if (notification.type === 'task_assigned' && notification.data) { + showTaskAssignedToast(notification); } }); return; @@ -183,6 +223,9 @@ export default function NotificationToaster() { queryClient.invalidateQueries({ queryKey: ['calendar-events'] }); queryClient.invalidateQueries({ queryKey: ['event-invitations'] }); } + if (newNotifications.some((n) => n.type === 'project_invite' || n.type === 'project_invite_accepted' || n.type === 'task_assigned')) { + queryClient.invalidateQueries({ queryKey: ['projects'] }); + } // Show toasts newNotifications.forEach((notification) => { @@ -192,6 +235,10 @@ export default function NotificationToaster() { showCalendarInviteToast(notification); } else if (notification.type === 'event_invite' && notification.data) { showEventInviteToast(notification); + } else if (notification.type === 'project_invite' && notification.data) { + showProjectInviteToast(notification); + } else if (notification.type === 'task_assigned' && notification.data) { + showTaskAssignedToast(notification); } else { toast(notification.title || 'New Notification', { description: notification.message || undefined, @@ -200,7 +247,7 @@ export default function NotificationToaster() { }); } }); - }, [notifications, handleConnectionRespond, handleCalendarInviteRespond, handleEventInviteRespond]); + }, [notifications, handleConnectionRespond, handleCalendarInviteRespond, handleEventInviteRespond, handleProjectInviteRespond]); const showConnectionRequestToast = (notification: AppNotification) => { const requestId = notification.source_id!; @@ -360,5 +407,96 @@ export default function NotificationToaster() { ); }; + const showTaskAssignedToast = (notification: AppNotification) => { + const data = notification.data as Record | undefined; + const projectId = data?.project_id as number | undefined; + const toastKey = `task-assigned-${notification.id}`; + + toast.custom( + (id) => ( +
+
+
+ +
+
+

Task Assigned

+

+ {notification.message || 'You were assigned to a task'} +

+
+ {projectId && ( + + )} + +
+
+
+
+ ), + { id: toastKey, duration: 15000 }, + ); + }; + + const showProjectInviteToast = (notification: AppNotification) => { + const data = notification.data as Record | undefined; + const projectId = data?.project_id as number | undefined; + if (!projectId) return; + + const toastKey = `project-invite-${notification.id}`; + + toast.custom( + (id) => ( +
+
+
+ +
+
+

Project Invitation

+

+ {notification.message || 'You were invited to collaborate on a project'} +

+
+ + +
+
+
+
+ ), + { id: toastKey, duration: 30000 }, + ); + }; + return null; } diff --git a/frontend/src/components/notifications/NotificationsPage.tsx b/frontend/src/components/notifications/NotificationsPage.tsx index a27c8da..9d1372a 100644 --- a/frontend/src/components/notifications/NotificationsPage.tsx +++ b/frontend/src/components/notifications/NotificationsPage.tsx @@ -1,7 +1,7 @@ import { useState, useMemo, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { useQueryClient } from '@tanstack/react-query'; -import { Bell, Check, CheckCheck, Trash2, UserPlus, Info, AlertCircle, X, Loader2, Calendar, Clock } from 'lucide-react'; +import { Bell, Check, CheckCheck, Trash2, UserPlus, Info, AlertCircle, X, Loader2, Calendar, Clock, FolderKanban } from 'lucide-react'; import { formatDistanceToNow } from 'date-fns'; import { toast } from 'sonner'; import { useNotifications } from '@/hooks/useNotifications'; @@ -22,6 +22,9 @@ const typeIcons: Record = { calendar_invite_rejected: { icon: Calendar, color: 'text-muted-foreground' }, event_invite: { icon: Calendar, color: 'text-purple-400' }, event_invite_response: { icon: Calendar, color: 'text-green-400' }, + project_invite: { icon: FolderKanban, color: 'text-purple-400' }, + project_invite_accepted: { icon: FolderKanban, color: 'text-green-400' }, + task_assigned: { icon: FolderKanban, color: 'text-blue-400' }, info: { icon: Info, color: 'text-blue-400' }, warning: { icon: AlertCircle, color: 'text-amber-400' }, }; @@ -44,6 +47,7 @@ export default function NotificationsPage() { const navigate = useNavigate(); const [filter, setFilter] = useState('all'); const [respondingEventInvite, setRespondingEventInvite] = useState(null); + const [respondingProjectInvite, setRespondingProjectInvite] = useState(null); // Build a set of pending connection request IDs for quick lookup const pendingInviteIds = useMemo( @@ -194,6 +198,36 @@ export default function NotificationsPage() { } }; + const handleProjectInviteRespond = async ( + notification: AppNotification, + action: 'accepted' | 'rejected', + ) => { + const data = notification.data as Record | undefined; + const projectId = data?.project_id as number | undefined; + if (!projectId) return; + + setRespondingProjectInvite(notification.id); + try { + await api.post(`/projects/memberships/${projectId}/respond`, { response: action }); + toast.success(action === 'accepted' ? 'Project invite accepted' : 'Project invite declined'); + queryClient.invalidateQueries({ queryKey: ['projects'] }); + if (!notification.is_read) { + await markRead([notification.id]).catch(() => {}); + } + } catch (err) { + if (axios.isAxiosError(err) && err.response?.status === 409) { + toast.success('Already responded'); + if (!notification.is_read) { + await markRead([notification.id]).catch(() => {}); + } + } else { + toast.error(getErrorMessage(err, 'Failed to respond')); + } + } finally { + setRespondingProjectInvite(null); + } + }; + const handleNotificationClick = async (notification: AppNotification) => { // Don't navigate for pending connection requests — let user act inline if ( @@ -207,6 +241,10 @@ export default function NotificationsPage() { if (notification.type === 'event_invite' && !notification.is_read) { return; } + // Don't navigate for unread project invites — let user act inline + if (notification.type === 'project_invite' && !notification.is_read) { + return; + } if (!notification.is_read) { await markRead([notification.id]).catch(() => {}); } @@ -218,6 +256,11 @@ export default function NotificationsPage() { if (notification.type === 'event_invite' || notification.type === 'event_invite_response') { navigate('/calendar'); } + // Navigate to project for project-related notifications + if (['project_invite', 'project_invite_accepted', 'task_assigned'].includes(notification.type)) { + const projectId = (notification.data as Record)?.project_id; + if (projectId) navigate(`/projects/${projectId}`); + } }; return ( @@ -408,6 +451,31 @@ export default function NotificationsPage() { )} + {/* Project invite actions (inline) */} + {notification.type === 'project_invite' && + !notification.is_read && ( +
+ + +
+ )} + {/* Timestamp + actions */}
diff --git a/frontend/src/components/projects/AssignmentPicker.tsx b/frontend/src/components/projects/AssignmentPicker.tsx new file mode 100644 index 0000000..2547009 --- /dev/null +++ b/frontend/src/components/projects/AssignmentPicker.tsx @@ -0,0 +1,286 @@ +import { useEffect, useRef, useState } from "react"; +import { ChevronDown, UserCircle, X } from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { TaskAssignment, ProjectMember } from "@/types"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function initials(name: string | null | undefined): string { + if (!name) return "?"; + return name + .split(" ") + .slice(0, 2) + .map((w) => w[0]?.toUpperCase() ?? "") + .join(""); +} + +interface AvatarCircleProps { + name: string | null | undefined; + className?: string; +} + +function AvatarCircle({ name, className }: AvatarCircleProps) { + return ( + + ); +} + +function roleBadge( + userId: number, + ownerId: number, + permission: ProjectMember["permission"] +): { label: string; className: string } { + if (userId === ownerId) + return { label: "Owner", className: "text-accent" }; + if (permission === "create_modify") + return { label: "Editor", className: "text-muted-foreground" }; + return { label: "Viewer", className: "text-muted-foreground/60" }; +} + +// --------------------------------------------------------------------------- +// AssigneeAvatars — stacked display for TaskRow / KanbanBoard +// --------------------------------------------------------------------------- + +export function AssigneeAvatars({ + assignments, + max = 3, +}: { + assignments: TaskAssignment[]; + max?: number; +}) { + const visible = assignments.slice(0, max); + const overflow = assignments.length - max; + const allNames = assignments.map((a) => a.user_name ?? "Unknown").join(", "); + + return ( + + {visible.map((a) => ( + + + {initials(a.user_name)} + + + ))} + {overflow > 0 && ( + + + +{overflow} + + + )} + + ); +} + +// --------------------------------------------------------------------------- +// AssignmentPicker +// --------------------------------------------------------------------------- + +interface AssignmentPickerProps { + currentAssignments: TaskAssignment[]; + members: ProjectMember[]; + currentUserId: number; + ownerId: number; + onAssign: (userIds: number[]) => void; + onUnassign: (userId: number) => void; + disabled?: boolean; +} + +export function AssignmentPicker({ + currentAssignments, + members, + currentUserId, + ownerId, + onAssign, + onUnassign, + disabled = false, +}: AssignmentPickerProps) { + const [open, setOpen] = useState(false); + const containerRef = useRef(null); + + // Close on outside click + useEffect(() => { + if (!open) return; + function handle(e: MouseEvent) { + if ( + containerRef.current && + !containerRef.current.contains(e.target as Node) + ) { + setOpen(false); + } + } + document.addEventListener("mousedown", handle); + return () => document.removeEventListener("mousedown", handle); + }, [open]); + + const assignedIds = new Set(currentAssignments.map((a) => a.user_id)); + + // Build ordered member list: current user first, then rest alphabetically + const sortedMembers = [...members].sort((a, b) => { + if (a.user_id === currentUserId) return -1; + if (b.user_id === currentUserId) return 1; + return (a.user_name ?? "").localeCompare(b.user_name ?? ""); + }); + + const availableMembers = sortedMembers.filter( + (m) => !assignedIds.has(m.user_id) + ); + + function handleSelect(userId: number) { + onAssign([userId]); + setOpen(false); + } + + function handleRemove(e: React.MouseEvent, userId: number) { + e.stopPropagation(); + onUnassign(userId); + } + + function handleTriggerClick() { + if (disabled) return; + setOpen((prev) => !prev); + } + + function handleTriggerKeyDown(e: React.KeyboardEvent) { + if (disabled) return; + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setOpen((prev) => !prev); + } + if (e.key === "Escape") setOpen(false); + } + + const hasAssignees = currentAssignments.length > 0; + + return ( +
+ {/* Trigger area */} +
+ {hasAssignees ? ( + <> + {currentAssignments.map((a) => ( + + + + {a.user_id === currentUserId ? "Me" : (a.user_name ?? "Unknown")} + + {!disabled && ( + + )} + + ))} + {!disabled && availableMembers.length > 0 && ( +
+ + {/* Dropdown */} + {open && availableMembers.length > 0 && ( +
    + {availableMembers.map((m) => { + const badge = roleBadge(m.user_id, ownerId, m.permission); + const displayName = + m.user_id === currentUserId + ? "Me" + : (m.user_name ?? "Unknown"); + + return ( +
  • + +
  • + ); + })} +
+ )} + + {/* Empty state when all members assigned */} + {open && availableMembers.length === 0 && ( +
+

All members assigned

+
+ )} +
+ ); +} diff --git a/frontend/src/components/projects/KanbanBoard.tsx b/frontend/src/components/projects/KanbanBoard.tsx index 04452da..51c07c2 100644 --- a/frontend/src/components/projects/KanbanBoard.tsx +++ b/frontend/src/components/projects/KanbanBoard.tsx @@ -1,219 +1,262 @@ -import { - DndContext, - closestCorners, - PointerSensor, - TouchSensor, - useSensor, - useSensors, - type DragEndEvent, - useDroppable, - useDraggable, -} from '@dnd-kit/core'; -import { format, parseISO } from 'date-fns'; -import type { ProjectTask } from '@/types'; -import { Badge } from '@/components/ui/badge'; - -const COLUMNS: { id: string; label: string; color: string }[] = [ - { id: 'pending', label: 'Pending', color: 'text-gray-400' }, - { id: 'in_progress', label: 'In Progress', color: 'text-blue-400' }, - { id: 'blocked', label: 'Blocked', color: 'text-red-400' }, - { id: 'on_hold', label: 'On Hold', color: 'text-orange-400' }, - { id: 'review', label: 'Review', color: 'text-yellow-400' }, - { id: 'completed', label: 'Completed', color: 'text-green-400' }, -]; - -const priorityColors: Record = { - none: 'bg-gray-500/20 text-gray-400', - low: 'bg-green-500/20 text-green-400', - medium: 'bg-yellow-500/20 text-yellow-400', - high: 'bg-red-500/20 text-red-400', -}; - -interface KanbanBoardProps { - tasks: ProjectTask[]; - selectedTaskId: number | null; - kanbanParentTask?: ProjectTask | null; - onSelectTask: (taskId: number) => void; - onStatusChange: (taskId: number, status: string) => void; - onBackToAllTasks?: () => void; -} - -function KanbanColumn({ - column, - tasks, - selectedTaskId, - onSelectTask, -}: { - column: (typeof COLUMNS)[0]; - tasks: ProjectTask[]; - selectedTaskId: number | null; - onSelectTask: (taskId: number) => void; -}) { - const { setNodeRef, isOver } = useDroppable({ id: column.id }); - - return ( -
- {/* Column header */} -
-
- - {column.label} - - - {tasks.length} - -
-
- - {/* Cards */} -
- {tasks.map((task) => ( - onSelectTask(task.id)} - /> - ))} -
-
- ); -} - -function KanbanCard({ - task, - isSelected, - onSelect, -}: { - task: ProjectTask; - isSelected: boolean; - onSelect: () => void; -}) { - const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ - id: task.id, - data: { task }, - }); - - const style = transform - ? { - transform: `translate(${transform.x}px, ${transform.y}px)`, - opacity: isDragging ? 0.5 : 1, - } - : undefined; - - const completedSubtasks = task.subtasks?.filter((s) => s.status === 'completed').length ?? 0; - const totalSubtasks = task.subtasks?.length ?? 0; - - return ( -
-

{task.title}

-
- - {task.priority} - - {task.due_date && ( - - {format(parseISO(task.due_date), 'MMM d')} - - )} - {totalSubtasks > 0 && ( - - {completedSubtasks}/{totalSubtasks} - - )} -
-
- ); -} - -export default function KanbanBoard({ - tasks, - selectedTaskId, - kanbanParentTask, - onSelectTask, - onStatusChange, - onBackToAllTasks, -}: KanbanBoardProps) { - const sensors = useSensors( - useSensor(PointerSensor, { activationConstraint: { distance: 5 } }) , - useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 8 } }) - ); - - // Subtask view is driven by kanbanParentTask (decoupled from selected task) - const isSubtaskView = kanbanParentTask != null && (kanbanParentTask.subtasks?.length ?? 0) > 0; - const activeTasks: ProjectTask[] = isSubtaskView ? (kanbanParentTask.subtasks ?? []) : tasks; - - const handleDragEnd = (event: DragEndEvent) => { - const { active, over } = event; - if (!over) return; - - const taskId = active.id as number; - const newStatus = over.id as string; - - const task = activeTasks.find((t) => t.id === taskId); - if (task && task.status !== newStatus && COLUMNS.some((c) => c.id === newStatus)) { - onStatusChange(taskId, newStatus); - } - }; - - const tasksByStatus = COLUMNS.map((col) => ({ - column: col, - tasks: activeTasks.filter((t) => t.status === col.id), - })); - - return ( -
- {/* Subtask view header */} - {isSubtaskView && kanbanParentTask && ( -
- - / - - Subtasks of: {kanbanParentTask.title} - -
- )} - - -
- {tasksByStatus.map(({ column, tasks: colTasks }) => ( - - ))} -
-
-
- ); -} +import { useState, useCallback } from 'react'; +import { + DndContext, + closestCenter, + PointerSensor, + TouchSensor, + useSensor, + useSensors, + type DragStartEvent, + type DragEndEvent, + useDroppable, + useDraggable, + DragOverlay, +} from '@dnd-kit/core'; +import { format, parseISO } from 'date-fns'; +import type { ProjectTask } from '@/types'; +import { Badge } from '@/components/ui/badge'; +import { AssigneeAvatars } from './AssignmentPicker'; + +const COLUMNS: { id: string; label: string; color: string }[] = [ + { id: 'pending', label: 'Pending', color: 'text-gray-400' }, + { id: 'in_progress', label: 'In Progress', color: 'text-blue-400' }, + { id: 'blocked', label: 'Blocked', color: 'text-red-400' }, + { id: 'on_hold', label: 'On Hold', color: 'text-orange-400' }, + { id: 'review', label: 'Review', color: 'text-yellow-400' }, + { id: 'completed', label: 'Completed', color: 'text-green-400' }, +]; + +const priorityColors: Record = { + none: 'bg-gray-500/20 text-gray-400', + low: 'bg-green-500/20 text-green-400', + medium: 'bg-yellow-500/20 text-yellow-400', + high: 'bg-red-500/20 text-red-400', +}; + +interface KanbanBoardProps { + tasks: ProjectTask[]; + selectedTaskId: number | null; + kanbanParentTask?: ProjectTask | null; + onSelectTask: (taskId: number) => void; + onStatusChange: (taskId: number, status: string) => void; + onBackToAllTasks?: () => void; +} + +function KanbanColumn({ + column, + tasks, + selectedTaskId, + draggingId, + onSelectTask, +}: { + column: (typeof COLUMNS)[0]; + tasks: ProjectTask[]; + selectedTaskId: number | null; + draggingId: number | null; + onSelectTask: (taskId: number) => void; +}) { + const { setNodeRef, isOver } = useDroppable({ id: column.id }); + + return ( +
+ {/* Column header */} +
+
+ + {column.label} + + + {tasks.length} + +
+
+ + {/* Cards */} +
+ {tasks.map((task) => ( + onSelectTask(task.id)} + /> + ))} +
+
+ ); +} + +// Card content — shared between in-place card and drag overlay +function CardContent({ task, isSelected, ghost }: { task: ProjectTask; isSelected: boolean; ghost?: boolean }) { + const completedSubtasks = task.subtasks?.filter((s) => s.status === 'completed').length ?? 0; + const totalSubtasks = task.subtasks?.length ?? 0; + + return ( +
+

{task.title}

+
+ + {task.priority} + + {task.due_date && ( + + {format(parseISO(task.due_date), 'MMM d')} + + )} + {totalSubtasks > 0 && ( + + {completedSubtasks}/{totalSubtasks} + + )} +
+ {task.assignments && task.assignments.length > 0 && ( +
+ +
+ )} +
+ ); +} + +function KanbanCard({ + task, + isSelected, + isDragSource, + onSelect, +}: { + task: ProjectTask; + isSelected: boolean; + isDragSource: boolean; + onSelect: () => void; +}) { + const { attributes, listeners, setNodeRef } = useDraggable({ + id: task.id, + data: { task }, + }); + + return ( +
+ +
+ ); +} + +export default function KanbanBoard({ + tasks, + selectedTaskId, + kanbanParentTask, + onSelectTask, + onStatusChange, + onBackToAllTasks, +}: KanbanBoardProps) { + const [draggingId, setDraggingId] = useState(null); + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), + useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 8 } }) + ); + + const isSubtaskView = kanbanParentTask != null && (kanbanParentTask.subtasks?.length ?? 0) > 0; + const activeTasks: ProjectTask[] = isSubtaskView ? (kanbanParentTask.subtasks ?? []) : tasks; + + const draggingTask = draggingId ? activeTasks.find((t) => t.id === draggingId) ?? null : null; + + const handleDragStart = useCallback((event: DragStartEvent) => { + setDraggingId(event.active.id as number); + }, []); + + const handleDragEnd = useCallback((event: DragEndEvent) => { + setDraggingId(null); + const { active, over } = event; + if (!over) return; + + const taskId = active.id as number; + const newStatus = over.id as string; + + const task = activeTasks.find((t) => t.id === taskId); + if (task && task.status !== newStatus && COLUMNS.some((c) => c.id === newStatus)) { + onStatusChange(taskId, newStatus); + } + }, [activeTasks, onStatusChange]); + + const handleDragCancel = useCallback(() => { + setDraggingId(null); + }, []); + + const tasksByStatus = COLUMNS.map((col) => ({ + column: col, + tasks: activeTasks.filter((t) => t.status === col.id), + })); + + return ( +
+ {/* Subtask view header */} + {isSubtaskView && kanbanParentTask && ( +
+ + / + + Subtasks of: {kanbanParentTask.title} + +
+ )} + + +
+ {tasksByStatus.map(({ column, tasks: colTasks }) => ( + + ))} +
+ + {/* Floating overlay — renders above everything, no layout impact */} + + {draggingTask ? ( +
+ +
+ ) : null} +
+
+
+ ); +} diff --git a/frontend/src/components/projects/ProjectCard.tsx b/frontend/src/components/projects/ProjectCard.tsx index c922961..6ab3021 100644 --- a/frontend/src/components/projects/ProjectCard.tsx +++ b/frontend/src/components/projects/ProjectCard.tsx @@ -2,9 +2,10 @@ import { useNavigate } from 'react-router-dom'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { format, isPast, parseISO } from 'date-fns'; -import { Calendar, Pin } from 'lucide-react'; +import { Calendar, Pin, Users } from 'lucide-react'; import api from '@/lib/api'; import type { Project } from '@/types'; +import { useSettings } from '@/hooks/useSettings'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { statusColors, statusLabels } from './constants'; @@ -17,6 +18,8 @@ interface ProjectCardProps { export default function ProjectCard({ project }: ProjectCardProps) { const navigate = useNavigate(); const queryClient = useQueryClient(); + const { settings } = useSettings(); + const isShared = project.user_id !== (settings?.user_id ?? 0); const toggleTrackMutation = useMutation({ mutationFn: async () => { @@ -47,20 +50,22 @@ export default function ProjectCard({ project }: ProjectCardProps) { className="cursor-pointer hover:shadow-lg hover:shadow-accent/5 hover:border-accent/20 transition-all duration-200 relative" onClick={() => navigate(`/projects/${project.id}`)} > - + {!isShared && ( + + )}
{project.name} @@ -95,6 +100,17 @@ export default function ProjectCard({ project }: ProjectCardProps) { Due {format(parseISO(project.due_date), 'MMM d, yyyy')}
)} + {/* Sharing indicator — shows for both owner and shared users */} + {project.member_count > 0 && ( +
+ + + {isShared + ? 'Shared with you' + : `${project.member_count} member${project.member_count !== 1 ? 's' : ''}`} + +
+ )} ); diff --git a/frontend/src/components/projects/ProjectDetail.tsx b/frontend/src/components/projects/ProjectDetail.tsx index b85a841..6549aea 100644 --- a/frontend/src/components/projects/ProjectDetail.tsx +++ b/frontend/src/components/projects/ProjectDetail.tsx @@ -23,21 +23,25 @@ import { CSS } from '@dnd-kit/utilities'; import { ArrowLeft, Plus, Trash2, ListChecks, Pencil, Pin, Calendar, CheckCircle2, PlayCircle, AlertTriangle, - List, Columns3, ArrowUpDown, + List, Columns3, ArrowUpDown, Users, Eye, } from 'lucide-react'; +import axios from 'axios'; import api from '@/lib/api'; -import type { Project, ProjectTask } from '@/types'; +import type { Project, ProjectTask, ProjectMember } from '@/types'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent } from '@/components/ui/card'; import { Select } from '@/components/ui/select'; import { ListSkeleton } from '@/components/ui/skeleton'; import { EmptyState } from '@/components/ui/empty-state'; +import { useSettings } from '@/hooks/useSettings'; +import { useDeltaPoll } from '@/hooks/useDeltaPoll'; import TaskRow from './TaskRow'; import TaskDetailPanel from './TaskDetailPanel'; import KanbanBoard from './KanbanBoard'; import TaskForm from './TaskForm'; import ProjectForm from './ProjectForm'; +import { ProjectShareSheet } from './ProjectShareSheet'; import { statusColors, statusLabels } from './constants'; import MobileDetailOverlay from '@/components/shared/MobileDetailOverlay'; @@ -111,6 +115,9 @@ export default function ProjectDetail() { const [kanbanParentTaskId, setKanbanParentTaskId] = useState(null); const [sortMode, setSortMode] = useState('manual'); const [viewMode, setViewMode] = useState('list'); + const [showShareSheet, setShowShareSheet] = useState(false); + const { settings } = useSettings(); + const currentUserId = settings?.user_id ?? 0; const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), @@ -134,17 +141,54 @@ export default function ProjectDetail() { }, }); + // Permission derivation + const isOwner = project ? project.user_id === currentUserId : true; + const isShared = project ? project.user_id !== currentUserId : false; + // For now, if they can see the project but don't own it, check if they can edit + // The backend enforces actual permissions — this is just UI gating + const canEdit = isOwner; // Members with create_modify can also edit tasks (handled per-task) + const canManageProject = isOwner; + + // Delta polling for real-time sync — only for shared projects (P-04) + const pollKey = useMemo(() => ['projects', id], [id]); + useDeltaPoll( + id && project && project.member_count > 0 ? `/projects/${id}/poll` : null, + pollKey, + 5000, + ); + + // Fetch members for shared projects + const { data: members = [] } = useQuery({ + queryKey: ['project-members', id], + queryFn: async () => { + const { data } = await api.get(`/projects/${id}/members`); + return data; + }, + enabled: !!id, + }); + const acceptedMembers = useMemo(() => members.filter((m) => m.status === 'accepted'), [members]); + const myPermission = useMemo( + () => acceptedMembers.find((m) => m.user_id === currentUserId)?.permission ?? null, + [acceptedMembers, currentUserId] + ); + const canEditTasks = isOwner || myPermission === 'create_modify'; + const toggleTaskMutation = useMutation({ - mutationFn: async ({ taskId, status }: { taskId: number; status: string }) => { + mutationFn: async ({ taskId, status, version }: { taskId: number; status: string; version: number }) => { const newStatus = status === 'completed' ? 'pending' : 'completed'; - const { data } = await api.put(`/projects/${id}/tasks/${taskId}`, { status: newStatus }); + const { data } = await api.put(`/projects/${id}/tasks/${taskId}`, { status: newStatus, version }); return data; }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['projects', id] }); }, - onError: () => { - toast.error('Failed to update task'); + onError: (error) => { + if (axios.isAxiosError(error) && error.response?.status === 409) { + toast.error('Task was modified by another user — please refresh'); + queryClient.invalidateQueries({ queryKey: ['projects', id] }); + } else { + toast.error('Failed to update task'); + } }, }); @@ -199,15 +243,20 @@ export default function ProjectDetail() { }); const updateTaskStatusMutation = useMutation({ - mutationFn: async ({ taskId, status }: { taskId: number; status: string }) => { - const { data } = await api.put(`/projects/${id}/tasks/${taskId}`, { status }); + mutationFn: async ({ taskId, status, version }: { taskId: number; status: string; version: number }) => { + const { data } = await api.put(`/projects/${id}/tasks/${taskId}`, { status, version }); return data; }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['projects', id] }); }, - onError: () => { - toast.error('Failed to update task status'); + onError: (error) => { + if (axios.isAxiosError(error) && error.response?.status === 409) { + toast.error('Task was modified by another user — please refresh'); + queryClient.invalidateQueries({ queryKey: ['projects', id] }); + } else { + toast.error('Failed to update task status'); + } }, }); @@ -389,31 +438,57 @@ export default function ProjectDetail() { + {/* Permission badge for non-owners */} + {isShared && ( + + {myPermission === 'create_modify' ? 'Editor' : 'Viewer'} + + )} + {canManageProject && ( + + )} - - + {canManageProject && ( + + )} + {canManageProject && ( + + )}
{/* Content area */} @@ -491,6 +566,14 @@ export default function ProjectDetail() { + {/* Read-only banner for viewers */} + {isShared && !canEdit && ( +
+ + You have view-only access to this project +
+ )} + {/* Task list header + view controls */}

Tasks

@@ -535,10 +618,12 @@ export default function ProjectDetail() {
)} - + {canEditTasks && ( + + )} @@ -561,9 +646,10 @@ export default function ProjectDetail() { selectedTaskId={selectedTaskId} kanbanParentTask={kanbanParentTask} onSelectTask={handleKanbanSelectTask} - onStatusChange={(taskId, status) => - updateTaskStatusMutation.mutate({ taskId, status }) - } + onStatusChange={(taskId, status) => { + const t = allTasks.find(tt => tt.id === taskId) ?? allTasks.flatMap(tt => tt.subtasks || []).find(st => st.id === taskId); + updateTaskStatusMutation.mutate({ taskId, status, version: t?.version ?? 1 }); + }} onBackToAllTasks={handleBackToAllTasks} /> ) : ( @@ -595,6 +681,7 @@ export default function ProjectDetail() { toggleTaskMutation.mutate({ taskId: task.id, status: task.status, + version: task.version, }) } togglePending={toggleTaskMutation.isPending} @@ -641,6 +728,10 @@ export default function ProjectDetail() { openTaskForm(null, parentId)} onClose={() => setSelectedTaskId(null)} @@ -698,6 +789,14 @@ export default function ProjectDetail() { onClose={() => setShowProjectForm(false)} /> )} + + ); } diff --git a/frontend/src/components/projects/ProjectShareSheet.tsx b/frontend/src/components/projects/ProjectShareSheet.tsx new file mode 100644 index 0000000..a161099 --- /dev/null +++ b/frontend/src/components/projects/ProjectShareSheet.tsx @@ -0,0 +1,310 @@ +import { useState, useMemo } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Users, UserPlus, Search, X, Crown, Eye, Pencil } from 'lucide-react'; +import { toast } from 'sonner'; +import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Select } from '@/components/ui/select'; +import api from '@/lib/api'; +import { useConnections } from '@/hooks/useConnections'; +import type { ProjectMember, ProjectPermission, Connection } from '@/types'; + +interface ProjectShareSheetProps { + open: boolean; + onOpenChange: (open: boolean) => void; + projectId: number; + isOwner: boolean; + ownerName: string; +} + +function AvatarCircle({ name }: { name: string }) { + const letter = (name ?? '?').charAt(0).toUpperCase(); + return ( +
+ {letter} +
+ ); +} + +function PermissionBadge({ permission }: { permission: ProjectPermission }) { + if (permission === 'create_modify') { + return ( + + + Editor + + ); + } + return ( + + + Viewer + + ); +} + +export function ProjectShareSheet({ open, onOpenChange, projectId, isOwner, ownerName }: ProjectShareSheetProps) { + const queryClient = useQueryClient(); + const { connections } = useConnections(); + + const [search, setSearch] = useState(''); + const [selectedIds, setSelectedIds] = useState([]); + const [invitePermission, setInvitePermission] = useState('create_modify'); + + const membersQuery = useQuery({ + queryKey: ['project-members', projectId], + queryFn: async () => { + const { data } = await api.get(`/projects/${projectId}/members`); + return data; + }, + enabled: open, + }); + + const members = membersQuery.data ?? []; + const acceptedMembers = members.filter((m) => m.status === 'accepted'); + const pendingMembers = members.filter((m) => m.status === 'pending'); + + const existingMemberIds = useMemo( + () => new Set(members.map((m) => m.user_id)), + [members] + ); + + const invalidate = () => { + queryClient.invalidateQueries({ queryKey: ['project-members', projectId] }); + queryClient.invalidateQueries({ queryKey: ['project', projectId] }); + }; + + const inviteMutation = useMutation({ + mutationFn: (payload: { user_ids: number[]; permission: ProjectPermission }) => + api.post(`/projects/${projectId}/members`, payload), + onSuccess: () => { toast.success('Invites sent'); setSelectedIds([]); invalidate(); }, + onError: () => toast.error('Failed to send invites'), + }); + + const updateMutation = useMutation({ + mutationFn: ({ userId, permission }: { userId: number; permission: ProjectPermission }) => + api.patch(`/projects/${projectId}/members/${userId}`, { permission }), + onSuccess: () => { toast.success('Permission updated'); invalidate(); }, + onError: () => toast.error('Failed to update permission'), + }); + + const removeMutation = useMutation({ + mutationFn: (userId: number) => api.delete(`/projects/${projectId}/members/${userId}`), + onSuccess: () => { toast.success('Member removed'); invalidate(); }, + onError: () => toast.error('Failed to remove member'), + }); + + const searchResults = useMemo(() => { + if (!search.trim()) return []; + const q = search.toLowerCase(); + return (connections as Connection[]) + .filter( + (c) => + !existingMemberIds.has(c.connected_user_id) && + !selectedIds.includes(c.connected_user_id) && + ((c.connected_preferred_name?.toLowerCase().includes(q)) || + c.connected_umbral_name.toLowerCase().includes(q)) + ) + .slice(0, 6); + }, [search, connections, existingMemberIds, selectedIds]); + + const selectedConnections = (connections as Connection[]).filter((c) => + selectedIds.includes(c.connected_user_id) + ); + + return ( + + + + + + Project Members + + + +
+ {/* Current Members */} +
+

+ + Members ({acceptedMembers.length + 1}) +

+ + {/* Owner row — always first */} +
+ + {ownerName} + + + Owner + +
+ + {/* Accepted members */} + {acceptedMembers.map((member) => ( +
+ + {member.user_name ?? 'Unknown'} + + {isOwner ? ( + + ) : ( + + )} + + {isOwner && ( + + )} +
+ ))} + + {/* Pending invites */} + {pendingMembers.length > 0 && ( + <> +

+ Pending Invites ({pendingMembers.length}) +

+ {pendingMembers.map((member) => ( +
+ + {member.user_name ?? 'Unknown'} + Pending + {isOwner && ( + + )} +
+ ))} + + )} +
+ + {/* Invite section (owner only) */} + {isOwner && ( +
+

+ + Invite Connections +

+ +
+ Invite as + +
+ +
+ + setSearch(e.target.value)} + onBlur={() => setSearch('')} + placeholder="Search connections..." + className="h-8 pl-8 text-xs" + /> + {search.trim() && searchResults.length > 0 && ( +
+ {searchResults.map((conn) => ( + + ))} +
+ )} + {search.trim() && searchResults.length === 0 && ( +
+

No connections found

+
+ )} +
+ + {selectedConnections.length > 0 && ( +
+ {selectedConnections.map((conn) => ( +
+ + + {conn.connected_preferred_name || conn.connected_umbral_name} + + +
+ ))} + +
+ )} +
+ )} +
+
+
+ ); +} diff --git a/frontend/src/components/projects/TaskDetailPanel.tsx b/frontend/src/components/projects/TaskDetailPanel.tsx index 3f15928..d8e26f5 100644 --- a/frontend/src/components/projects/TaskDetailPanel.tsx +++ b/frontend/src/components/projects/TaskDetailPanel.tsx @@ -1,14 +1,16 @@ import { useState } from 'react'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { format, formatDistanceToNow, parseISO } from 'date-fns'; import { Pencil, Trash2, Plus, MessageSquare, ClipboardList, Calendar, User, Flag, Activity, Send, X, Save, } from 'lucide-react'; +import axios from 'axios'; import api, { getErrorMessage } from '@/lib/api'; import { formatUpdatedAt } from '@/components/shared/utils'; -import type { ProjectTask, TaskComment, Person } from '@/types'; +import type { ProjectTask, TaskComment, ProjectMember } from '@/types'; +import { AssignmentPicker } from './AssignmentPicker'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Checkbox } from '@/components/ui/checkbox'; @@ -45,6 +47,10 @@ const priorityColors: Record = { interface TaskDetailPanelProps { task: ProjectTask | null; projectId: number; + members?: ProjectMember[]; + currentUserId?: number; + ownerId?: number; + canAssign?: boolean; onDelete: (taskId: number) => void; onAddSubtask: (parentId: number) => void; onClose?: () => void; @@ -81,6 +87,10 @@ function buildEditState(task: ProjectTask): EditState { export default function TaskDetailPanel({ task, projectId, + members = [], + currentUserId = 0, + ownerId = 0, + canAssign = false, onDelete, onAddSubtask, onClose, @@ -93,20 +103,24 @@ export default function TaskDetailPanel({ task ? buildEditState(task) : { title: '', status: 'pending', priority: 'none', due_date: todayLocal(), person_id: '', description: '' } ); - const { data: people = [] } = useQuery({ - queryKey: ['people'], - queryFn: async () => { - const { data } = await api.get('/people'); - return data; - }, - }); + // Build a combined members list that includes the owner for the AssignmentPicker + const allMembers: ProjectMember[] = [ + // Synthetic owner entry so they appear in the picker + ...(ownerId ? [{ + id: 0, project_id: projectId, user_id: ownerId, invited_by: ownerId, + permission: 'create_modify' as const, status: 'accepted' as const, + source: 'invited' as const, user_name: currentUserId === ownerId ? 'Me (Owner)' : 'Owner', + inviter_name: null, created_at: '', updated_at: '', accepted_at: null, + }] : []), + ...members.filter(m => m.status === 'accepted'), + ]; // --- Mutations --- const toggleSubtaskMutation = useMutation({ - mutationFn: async ({ taskId, status }: { taskId: number; status: string }) => { + mutationFn: async ({ taskId, status, version }: { taskId: number; status: string; version: number }) => { const newStatus = status === 'completed' ? 'pending' : 'completed'; - const { data } = await api.put(`/projects/${projectId}/tasks/${taskId}`, { status: newStatus }); + const { data } = await api.put(`/projects/${projectId}/tasks/${taskId}`, { status: newStatus, version }); return data; }, onSuccess: () => { @@ -125,7 +139,12 @@ export default function TaskDetailPanel({ toast.success('Task updated'); }, onError: (error) => { - toast.error(getErrorMessage(error, 'Failed to update task')); + if (axios.isAxiosError(error) && error.response?.status === 409) { + toast.error('Task was modified by another user — please refresh'); + queryClient.invalidateQueries({ queryKey: ['projects', projectId.toString()] }); + } else { + toast.error(getErrorMessage(error, 'Failed to update task')); + } }, }); @@ -169,6 +188,30 @@ export default function TaskDetailPanel({ }, }); + const assignMutation = useMutation({ + mutationFn: async (userIds: number[]) => { + await api.post(`/projects/${projectId}/tasks/${task!.id}/assignments`, { user_ids: userIds }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['projects', projectId.toString()] }); + }, + onError: (error) => { + toast.error(getErrorMessage(error, 'Failed to assign')); + }, + }); + + const unassignMutation = useMutation({ + mutationFn: async (userId: number) => { + await api.delete(`/projects/${projectId}/tasks/${task!.id}/assignments/${userId}`); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['projects', projectId.toString()] }); + }, + onError: (error) => { + toast.error(getErrorMessage(error, 'Failed to unassign')); + }, + }); + // --- Handlers --- const handleAddComment = () => { @@ -197,6 +240,7 @@ export default function TaskDetailPanel({ due_date: editState.due_date || null, person_id: editState.person_id ? Number(editState.person_id) : null, description: editState.description || null, + version: task.version, }; updateTaskMutation.mutate(payload); }; @@ -217,7 +261,6 @@ export default function TaskDetailPanel({ ); } - const assignedPerson = task.person_id ? people.find((p) => p.id === task.person_id) : null; const comments = task.comments || []; return ( @@ -376,21 +419,34 @@ export default function TaskDetailPanel({ Assigned - {isEditing ? ( - + ) : ( -

{assignedPerson ? assignedPerson.name : '—'}

+

Unassigned

)} @@ -458,6 +514,7 @@ export default function TaskDetailPanel({ toggleSubtaskMutation.mutate({ taskId: subtask.id, status: subtask.status, + version: subtask.version, }) } disabled={toggleSubtaskMutation.isPending} @@ -522,6 +579,9 @@ export default function TaskDetailPanel({

{comment.content}

+ {comment.author_name && ( + {comment.author_name} + )} {formatDistanceToNow(parseISO(comment.created_at), { addSuffix: true })}