From bef856fd15620171eedf244b672d74cc3ba0d8ba Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Tue, 17 Mar 2026 03:18:35 +0800 Subject: [PATCH 01/15] Add collaborative project sharing, task assignments, and delta polling Enables multi-user project collaboration mirroring the shared calendar pattern. Includes ProjectMember model with permission levels, task assignment with auto-membership, optimistic locking, field allowlist for assignees, disconnect cascade, delta polling for projects and calendars, and full frontend integration with share sheet, assignment picker, permission gating, and notification handling. Migrations: 057 (indexes + version + comment user_id), 058 (project_members), 059 (project_task_assignments) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../versions/057_project_collab_prep.py | 69 ++ .../alembic/versions/058_project_members.py | 45 ++ .../versions/059_project_task_assignments.py | 35 + backend/app/models/__init__.py | 4 + backend/app/models/project.py | 7 +- backend/app/models/project_member.py | 58 ++ backend/app/models/project_task.py | 16 +- backend/app/models/project_task_assignment.py | 30 + backend/app/models/task_comment.py | 9 +- backend/app/routers/calendars.py | 59 +- backend/app/routers/connections.py | 4 + backend/app/routers/projects.py | 687 ++++++++++++++++-- backend/app/schemas/project.py | 1 + backend/app/schemas/project_member.py | 43 ++ backend/app/schemas/project_task.py | 4 + .../app/schemas/project_task_assignment.py | 19 + backend/app/schemas/task_comment.py | 21 +- backend/app/services/project_sharing.py | 267 +++++++ .../notifications/NotificationToaster.tsx | 5 +- .../notifications/NotificationsPage.tsx | 70 +- .../components/projects/AssignmentPicker.tsx | 286 ++++++++ .../src/components/projects/KanbanBoard.tsx | 7 + .../src/components/projects/ProjectCard.tsx | 41 +- .../src/components/projects/ProjectDetail.tsx | 129 +++- .../components/projects/ProjectShareSheet.tsx | 310 ++++++++ .../components/projects/TaskDetailPanel.tsx | 12 +- frontend/src/components/projects/TaskRow.tsx | 8 + frontend/src/hooks/useDeltaPoll.ts | 52 ++ frontend/src/types/index.ts | 33 + 29 files changed, 2201 insertions(+), 130 deletions(-) create mode 100644 backend/alembic/versions/057_project_collab_prep.py create mode 100644 backend/alembic/versions/058_project_members.py create mode 100644 backend/alembic/versions/059_project_task_assignments.py create mode 100644 backend/app/models/project_member.py create mode 100644 backend/app/models/project_task_assignment.py create mode 100644 backend/app/schemas/project_member.py create mode 100644 backend/app/schemas/project_task_assignment.py create mode 100644 backend/app/services/project_sharing.py create mode 100644 frontend/src/components/projects/AssignmentPicker.tsx create mode 100644 frontend/src/components/projects/ProjectShareSheet.tsx create mode 100644 frontend/src/hooks/useDeltaPoll.ts 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..7b2263b --- /dev/null +++ b/backend/alembic/versions/057_project_collab_prep.py @@ -0,0 +1,69 @@ +"""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 + op.create_index( + "ix_project_tasks_project_id", + "project_tasks", + ["project_id"], + ) + op.create_index( + "ix_project_tasks_parent_task_id", + "project_tasks", + ["parent_task_id"], + postgresql_where=sa.text("parent_task_id IS NOT NULL"), + ) + op.create_index( + "ix_project_tasks_project_updated", + "project_tasks", + ["project_id", sa.text("updated_at DESC")], + ) + op.create_index( + "ix_projects_user_updated", + "projects", + ["user_id", sa.text("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.create_index( + "ix_events_calendar_updated", + "calendar_events", + ["calendar_id", sa.text("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/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/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..0c15a47 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,56 @@ 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") + + # 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..594e5af 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(Project.tasks).selectinload(ProjectTask.subtasks).selectinload(ProjectTask.assignments), + selectinload(Project.members), ] def _task_load_options(): """All load options needed for task responses.""" return [ - selectinload(ProjectTask.comments), + selectinload(ProjectTask.comments).selectinload(TaskComment.user), selectinload(ProjectTask.subtasks).selectinload(ProjectTask.comments), selectinload(ProjectTask.subtasks).selectinload(ProjectTask.subtasks), + selectinload(ProjectTask.assignments), + selectinload(ProjectTask.subtasks).selectinload(ProjectTask.assignments), ] +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 = 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,29 @@ 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 + project_perm = await get_project_permission(db, project_id, current_user.id) + 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 +433,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 +453,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 +465,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 +479,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 +506,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 +522,473 @@ 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.commit() + + # Re-fetch with relationships + if not created_members: + return [] + + member_ids = [m.id for m in created_members] + 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") + + member.status = respond.response + if respond.response == "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) + + if respond.response == "accepted": + 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", + ) + + # Extract response data before 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 + + +# ────────────────────────────────────────────── +# 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.commit() + + if not created: + return [] + + # Re-fetch with user info + assignment_ids = [a.id for a in created] + 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") + + # 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..74a0bc0 100644 --- a/backend/app/schemas/project.py +++ b/backend/app/schemas/project.py @@ -30,6 +30,7 @@ class ProjectUpdate(BaseModel): class ProjectResponse(BaseModel): id: int + user_id: int = 0 name: str description: Optional[str] status: str diff --git a/backend/app/schemas/project_member.py b/backend/app/schemas/project_member.py new file mode 100644 index 0000000..0057f91 --- /dev/null +++ b/backend/app/schemas/project_member.py @@ -0,0 +1,43 @@ +from pydantic import BaseModel, ConfigDict, Field +from datetime import datetime +from typing import Optional, Literal + +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[int] = 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..4d21f1f --- /dev/null +++ b/backend/app/schemas/project_task_assignment.py @@ -0,0 +1,19 @@ +from pydantic import BaseModel, ConfigDict, Field +from datetime import datetime + + +class TaskAssignmentCreate(BaseModel): + model_config = ConfigDict(extra="forbid") + + user_ids: list[int] = 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) diff --git a/backend/app/schemas/task_comment.py b/backend/app/schemas/task_comment.py index 6c28615..328ae55 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,26 @@ 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: + if not getattr(data, "author_name", None): + # Use username as fallback — preferred_name is on Settings, not User + data = dict( + id=data.id, + task_id=data.task_id, + user_id=data.user_id, + author_name=data.user.username, + content=data.content, + created_at=data.created_at, + ) + return data diff --git a/backend/app/services/project_sharing.py b/backend/app/services/project_sharing.py new file mode 100644 index 0000000..e56602d --- /dev/null +++ b/backend/app/services/project_sharing.py @@ -0,0 +1,267 @@ +""" +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 +) -> str | None: + """ + Returns effective 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 permission + """ + project_perm = await get_project_permission(db, project_id, user_id) + if project_perm is None: + return None + if project_perm == "owner": + return "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 + + 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 + return "create_modify" + + return 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 + """ + # Find projects owned by each user + a_proj_result = await db.execute( + select(Project.id).where(Project.user_id == user_a_id) + ) + a_proj_ids = [r[0] for r in a_proj_result.all()] + + b_proj_result = await db.execute( + select(Project.id).where(Project.user_id == user_b_id) + ) + b_proj_ids = [r[0] for r in b_proj_result.all()] + + # Remove user_b's assignments + memberships on user_a's projects + if a_proj_ids: + # Delete task assignments first + 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..101e957 100644 --- a/frontend/src/components/notifications/NotificationToaster.tsx +++ b/frontend/src/components/notifications/NotificationToaster.tsx @@ -141,7 +141,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']); const actionable = notifications.filter( (n) => !n.is_read && actionableTypes.has(n.type), ); @@ -183,6 +183,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) => { 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..cf3cbfd 100644 --- a/frontend/src/components/projects/KanbanBoard.tsx +++ b/frontend/src/components/projects/KanbanBoard.tsx @@ -12,6 +12,7 @@ import { 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' }, @@ -140,6 +141,12 @@ function KanbanCard({
)}
+ {/* Assignee avatars */} + {task.assignments && task.assignments.length > 0 && ( +
+ +
+ )} ); } diff --git a/frontend/src/components/projects/ProjectCard.tsx b/frontend/src/components/projects/ProjectCard.tsx index c922961..dc48aca 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,12 @@ export default function ProjectCard({ project }: ProjectCardProps) { Due {format(parseISO(project.due_date), 'MMM d, yyyy')}
)} + {isShared && ( +
+ + Shared with you +
+ )} ); diff --git a/frontend/src/components/projects/ProjectDetail.tsx b/frontend/src/components/projects/ProjectDetail.tsx index b85a841..a9fe578 100644 --- a/frontend/src/components/projects/ProjectDetail.tsx +++ b/frontend/src/components/projects/ProjectDetail.tsx @@ -23,21 +23,24 @@ 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 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 +114,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,6 +140,33 @@ 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 on shared projects + const pollKey = useMemo(() => ['projects', id], [id]); + useDeltaPoll( + id ? `/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 = members.filter((m) => m.status === 'accepted'); + const toggleTaskMutation = useMutation({ mutationFn: async ({ taskId, status }: { taskId: number; status: string }) => { const newStatus = status === 'completed' ? 'pending' : 'completed'; @@ -389,31 +422,57 @@ export default function ProjectDetail() { + {/* Permission badge for non-owners */} + {isShared && ( + + {acceptedMembers.find((m) => m.user_id === currentUserId)?.permission === 'create_modify' ? 'Editor' : 'Viewer'} + + )} + {canManageProject && ( + + )} - - + {canManageProject && ( + + )} + {canManageProject && ( + + )} {/* Content area */} @@ -491,6 +550,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 +602,12 @@ export default function ProjectDetail() {
)} - + {(isOwner || acceptedMembers.find((m) => m.user_id === currentUserId)?.permission === 'create_modify') && ( + + )} @@ -698,6 +767,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..28aafb7 --- /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={() => setTimeout(() => setSearch(''), 200)} + 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..87c3bd7 100644 --- a/frontend/src/components/projects/TaskDetailPanel.tsx +++ b/frontend/src/components/projects/TaskDetailPanel.tsx @@ -6,6 +6,7 @@ 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'; @@ -125,7 +126,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')); + } }, }); @@ -197,6 +203,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); }; @@ -522,6 +529,9 @@ export default function TaskDetailPanel({

{comment.content}

+ {comment.author_name && ( + {comment.author_name} + )} {formatDistanceToNow(parseISO(comment.created_at), { addSuffix: true })} + +
+ + + + ), + { id: toastKey, duration: 30000 }, + ); + }; + return null; } From a7e93aa2a3835c27d947d9a9cf8df22ee7cda5cc Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Tue, 17 Mar 2026 03:30:19 +0800 Subject: [PATCH 03/15] Fix migration 057: use IF NOT EXISTS for indexes that may pre-exist The ix_project_tasks_parent_task_id index already existed on the production DB, causing migration 057 to fail with DuplicateTableError. Switched all CREATE INDEX statements to raw SQL with IF NOT EXISTS. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../versions/057_project_collab_prep.py | 32 ++++--------------- 1 file changed, 6 insertions(+), 26 deletions(-) diff --git a/backend/alembic/versions/057_project_collab_prep.py b/backend/alembic/versions/057_project_collab_prep.py index 7b2263b..d3755bf 100644 --- a/backend/alembic/versions/057_project_collab_prep.py +++ b/backend/alembic/versions/057_project_collab_prep.py @@ -17,27 +17,11 @@ depends_on = None def upgrade() -> None: # 1a. Performance indexes for project_tasks - op.create_index( - "ix_project_tasks_project_id", - "project_tasks", - ["project_id"], - ) - op.create_index( - "ix_project_tasks_parent_task_id", - "project_tasks", - ["parent_task_id"], - postgresql_where=sa.text("parent_task_id IS NOT NULL"), - ) - op.create_index( - "ix_project_tasks_project_updated", - "project_tasks", - ["project_id", sa.text("updated_at DESC")], - ) - op.create_index( - "ix_projects_user_updated", - "projects", - ["user_id", sa.text("updated_at DESC")], - ) + # 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( @@ -52,11 +36,7 @@ def upgrade() -> None: ) # Calendar delta polling index (Phase 4 prep) - op.create_index( - "ix_events_calendar_updated", - "calendar_events", - ["calendar_id", sa.text("updated_at DESC")], - ) + op.execute("CREATE INDEX IF NOT EXISTS ix_events_calendar_updated ON calendar_events (calendar_id, updated_at DESC)") def downgrade() -> None: From f0850ad3bf1f77c765749c4a6d4139d26c38279e Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Tue, 17 Mar 2026 03:49:28 +0800 Subject: [PATCH 04/15] Fix MissingGreenlet in invite_members and assign_users_to_task Both endpoints accessed ORM object IDs after db.commit(), which expires all loaded objects in async SQLAlchemy. Added db.flush() before commit to assign IDs while objects are still live. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/routers/projects.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/backend/app/routers/projects.py b/backend/app/routers/projects.py index 8e8d143..b95b32b 100644 --- a/backend/app/routers/projects.py +++ b/backend/app/routers/projects.py @@ -600,13 +600,13 @@ async def invite_members( 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 [] - - member_ids = [m.id for m in created_members] result = await db.execute( select(ProjectMember) .options( @@ -876,13 +876,14 @@ async def assign_users_to_task( 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 - assignment_ids = [a.id for a in created] result = await db.execute( select(ProjectTaskAssignment) .options(selectinload(ProjectTaskAssignment.user)) From 05f5b49e261ef2c1feaed3327f76fcb4ca1b5f98 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Tue, 17 Mar 2026 03:53:42 +0800 Subject: [PATCH 05/15] =?UTF-8?q?Fix=20500=20on=20POST=20/api/projects/:id?= =?UTF-8?q?/members=20=E2=80=94=20add=20project=5Finvite=20types=20to=20no?= =?UTF-8?q?tification=20CHECK=20constraint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The invite_members handler called create_notification with type="project_invite", which is not in the ck_notifications_type CHECK constraint. The db.flush() inside the handler flushed both the ProjectMember and Notification INSERTs atomically, causing a CheckViolationError → 500. Added "project_invite", "project_invite_accepted", "project_invite_rejected" to the model tuple and migration 060 drops/recreates the constraint to include them. Co-Authored-By: Claude Sonnet 4.6 --- .../060_expand_notification_types_project.py | 35 +++++++++++++++++++ backend/app/models/notification.py | 1 + 2 files changed, 36 insertions(+) create mode 100644 backend/alembic/versions/060_expand_notification_types_project.py 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..723295d --- /dev/null +++ b/backend/alembic/versions/060_expand_notification_types_project.py @@ -0,0 +1,35 @@ +"""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", +) + + +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/notification.py b/backend/app/models/notification.py index e0d6f47..676be3e 100644 --- a/backend/app/models/notification.py +++ b/backend/app/models/notification.py @@ -9,6 +9,7 @@ _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", "info", "warning", "reminder", "system", ) From 61e48c3f144729a0d0eb3691bd77083b68bdf309 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Tue, 17 Mar 2026 03:54:54 +0800 Subject: [PATCH 06/15] Add project notification types to CHECK constraint (migration 060) The notifications table CHECK constraint did not include project_invite, project_invite_accepted, project_invite_rejected, or task_assigned. This caused 500 errors on invite_members and assign_users_to_task because create_notification violated ck_notifications_type. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../alembic/versions/060_expand_notification_types_project.py | 1 + backend/app/models/notification.py | 1 + 2 files changed, 2 insertions(+) diff --git a/backend/alembic/versions/060_expand_notification_types_project.py b/backend/alembic/versions/060_expand_notification_types_project.py index 723295d..019331d 100644 --- a/backend/alembic/versions/060_expand_notification_types_project.py +++ b/backend/alembic/versions/060_expand_notification_types_project.py @@ -18,6 +18,7 @@ _OLD_TYPES = ( ) _NEW_TYPES = _OLD_TYPES + ( "project_invite", "project_invite_accepted", "project_invite_rejected", + "task_assigned", ) diff --git a/backend/app/models/notification.py b/backend/app/models/notification.py index 676be3e..dfd7f3e 100644 --- a/backend/app/models/notification.py +++ b/backend/app/models/notification.py @@ -10,6 +10,7 @@ _NOTIFICATION_TYPES = ( "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", ) From f42175b3fe6480355b56698e01ec403ee6728f92 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Tue, 17 Mar 2026 04:09:07 +0800 Subject: [PATCH 07/15] Improve sharing visibility: member count on cards, task assignment toast - Add member_count to ProjectResponse via model_validator (computed from eagerly loaded members relationship). Shows on ProjectCard for both owners ("2 members") and shared users ("Shared with you"). - Fix share button badge positioning (add relative class). - Add dedicated showTaskAssignedToast with blue ClipboardList icon, "View Project" action button, and 15s duration. - Wire task_assigned into both initial-load and new-notification toast dispatch flows in NotificationToaster. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/schemas/project.py | 27 ++++++++- .../notifications/NotificationToaster.tsx | 56 ++++++++++++++++++- .../src/components/projects/ProjectCard.tsx | 9 ++- .../src/components/projects/ProjectDetail.tsx | 2 +- frontend/src/types/index.ts | 1 + 5 files changed, 89 insertions(+), 6 deletions(-) diff --git a/backend/app/schemas/project.py b/backend/app/schemas/project.py index 74a0bc0..8e03e36 100644 --- a/backend/app/schemas/project.py +++ b/backend/app/schemas/project.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel, ConfigDict, Field +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 @@ -37,12 +37,37 @@ class ProjectResponse(BaseModel): 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: + pass # If members aren't loaded, default to 0 + return data + class TrackedTaskResponse(BaseModel): id: int diff --git a/frontend/src/components/notifications/NotificationToaster.tsx b/frontend/src/components/notifications/NotificationToaster.tsx index 9396200..2062ffd 100644 --- a/frontend/src/components/notifications/NotificationToaster.tsx +++ b/frontend/src/components/notifications/NotificationToaster.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useCallback } from 'react'; import { toast } from 'sonner'; -import { Check, X, Bell, UserPlus, Calendar, Clock, FolderKanban } 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'; @@ -173,7 +173,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', 'project_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), ); @@ -189,6 +189,8 @@ export default function NotificationToaster() { showEventInviteToast(notification); } else if (notification.type === 'project_invite' && notification.data) { showProjectInviteToast(notification); + } else if (notification.type === 'task_assigned' && notification.data) { + showTaskAssignedToast(notification); } }); return; @@ -231,6 +233,8 @@ export default function NotificationToaster() { 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, @@ -399,6 +403,54 @@ 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; diff --git a/frontend/src/components/projects/ProjectCard.tsx b/frontend/src/components/projects/ProjectCard.tsx index dc48aca..6ab3021 100644 --- a/frontend/src/components/projects/ProjectCard.tsx +++ b/frontend/src/components/projects/ProjectCard.tsx @@ -100,10 +100,15 @@ export default function ProjectCard({ project }: ProjectCardProps) { Due {format(parseISO(project.due_date), 'MMM d, yyyy')} )} - {isShared && ( + {/* Sharing indicator — shows for both owner and shared users */} + {project.member_count > 0 && (
- Shared with you + + {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 a9fe578..bf37fa7 100644 --- a/frontend/src/components/projects/ProjectDetail.tsx +++ b/frontend/src/components/projects/ProjectDetail.tsx @@ -443,7 +443,7 @@ export default function ProjectDetail() { - / - - 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} +
+
+
+ ); +} From dd637bdc842d7e34449de5b606b827b48f6323c9 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Tue, 17 Mar 2026 04:55:47 +0800 Subject: [PATCH 14/15] Fix QA findings from performance, pentest, and code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Perf-1: Eliminate duplicate permission query on task update. get_effective_task_permission now returns (effective, project_level) tuple so the SEC-P02 allowlist check reuses the project-level permission from the first call instead of querying again. Perf-2: Memoize member permission lookup in ProjectDetail. Replace 3 inline acceptedMembers.find() calls with useMemo-derived myPermission and canEditTasks. S-06: Pass members/currentUserId/ownerId/canAssign to mobile TaskDetailPanel (was missing — AssignmentPicker never appeared on mobile). S-08: Add missing selectinload(TaskComment.user) to subtask comments chain in _task_load_options. Subtask comment author_name was always null. W-01: useDeltaPoll stores queryKeyToInvalidate in a ref to prevent infinite re-render if caller passes inline array literal. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/routers/projects.py | 5 ++--- backend/app/services/project_sharing.py | 18 +++++++++--------- .../src/components/projects/ProjectDetail.tsx | 13 +++++++++---- frontend/src/hooks/useDeltaPoll.ts | 8 ++++++-- 4 files changed, 26 insertions(+), 18 deletions(-) diff --git a/backend/app/routers/projects.py b/backend/app/routers/projects.py index 2f85ce0..32cd0e7 100644 --- a/backend/app/routers/projects.py +++ b/backend/app/routers/projects.py @@ -54,7 +54,7 @@ def _task_load_options(): """All load options needed for task responses.""" return [ selectinload(ProjectTask.comments).selectinload(TaskComment.user), - selectinload(ProjectTask.subtasks).selectinload(ProjectTask.comments), + 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), @@ -375,7 +375,7 @@ async def update_project_task( current_user: User = Depends(get_current_user) ): """Update a project task. Permission checked at project and task level.""" - perm = await get_effective_task_permission(db, current_user.id, task_id, project_id) + 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": @@ -395,7 +395,6 @@ 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 - project_perm = await get_project_permission(db, project_id, current_user.id) 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 diff --git a/backend/app/services/project_sharing.py b/backend/app/services/project_sharing.py index e56602d..6f6360d 100644 --- a/backend/app/services/project_sharing.py +++ b/backend/app/services/project_sharing.py @@ -110,19 +110,19 @@ async def validate_project_connections( async def get_effective_task_permission( db: AsyncSession, user_id: int, task_id: int, project_id: int -) -> str | None: +) -> tuple[str | None, str | None]: """ - Returns effective permission for a specific task: + 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 permission + 4. Return (effective, project_level) """ project_perm = await get_project_permission(db, project_id, user_id) if project_perm is None: - return None + return None, None if project_perm == "owner": - return "owner" + return "owner", "owner" # Check direct assignment on this task task_result = await db.execute( @@ -130,7 +130,7 @@ async def get_effective_task_permission( ) task_row = task_result.one_or_none() if not task_row: - return project_perm + return project_perm, project_perm parent_task_id = task_row[0] @@ -148,10 +148,10 @@ async def get_effective_task_permission( 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 - return "create_modify" + return project_perm, project_perm + return "create_modify", project_perm - return project_perm + return project_perm, project_perm async def ensure_auto_membership( diff --git a/frontend/src/components/projects/ProjectDetail.tsx b/frontend/src/components/projects/ProjectDetail.tsx index 5908930..235a182 100644 --- a/frontend/src/components/projects/ProjectDetail.tsx +++ b/frontend/src/components/projects/ProjectDetail.tsx @@ -165,7 +165,12 @@ export default function ProjectDetail() { }, enabled: !!id, }); - const acceptedMembers = members.filter((m) => m.status === 'accepted'); + 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 }) => { @@ -425,7 +430,7 @@ export default function ProjectDetail() { {/* Permission badge for non-owners */} {isShared && ( - {acceptedMembers.find((m) => m.user_id === currentUserId)?.permission === 'create_modify' ? 'Editor' : 'Viewer'} + {myPermission === 'create_modify' ? 'Editor' : 'Viewer'} )} {canManageProject && ( @@ -602,7 +607,7 @@ export default function ProjectDetail() { )} - {(isOwner || acceptedMembers.find((m) => m.user_id === currentUserId)?.permission === 'create_modify') && ( + {canEditTasks && (