Merge feature/project-collab-prep: collaborative project sharing, task assignments, delta polling

Adds multi-user project collaboration mirroring the shared calendar pattern:
- ProjectMember model with invite/accept/reject flow, permission levels (read_only/create_modify)
- ProjectTaskAssignment with multi-assign, auto-membership, field allowlist (SEC-P02)
- Optimistic locking via version column with 409 conflict handling
- Delta polling for projects and calendars (5s interval, background tab support)
- Disconnect cascade cleans up memberships + assignments on connection removal
- Frontend: ProjectShareSheet, AssignmentPicker, permission gating, assigned column in task list
- Notification integration: project_invite, project_invite_accepted, task_assigned with action toasts
- Kanban DragOverlay for smooth drag-and-drop
- 4 migrations (057-060), 31 files, ~2500 LOC
- QA: 3 agent reviews (performance, pentest, code), all findings actioned
This commit is contained in:
Kyle 2026-03-17 05:30:16 +08:00
commit 688ce1c132
31 changed files with 2791 additions and 389 deletions

View File

@ -0,0 +1,49 @@
"""project collab prep: indexes, task version, comment user_id
Revision ID: 057
Revises: 056
Create Date: 2025-01-01 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "057"
down_revision = "056"
branch_labels = None
depends_on = None
def upgrade() -> None:
# 1a. Performance indexes for project_tasks
# Use IF NOT EXISTS to handle indexes that may already exist on the DB
op.execute("CREATE INDEX IF NOT EXISTS ix_project_tasks_project_id ON project_tasks (project_id)")
op.execute("CREATE INDEX IF NOT EXISTS ix_project_tasks_parent_task_id ON project_tasks (parent_task_id) WHERE parent_task_id IS NOT NULL")
op.execute("CREATE INDEX IF NOT EXISTS ix_project_tasks_project_updated ON project_tasks (project_id, updated_at DESC)")
op.execute("CREATE INDEX IF NOT EXISTS ix_projects_user_updated ON projects (user_id, updated_at DESC)")
# 1b. Add user_id to task_comments for multi-user attribution
op.add_column(
"task_comments",
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
)
# 1c. Add version column to project_tasks for optimistic locking
op.add_column(
"project_tasks",
sa.Column("version", sa.Integer(), server_default="1", nullable=False),
)
# Calendar delta polling index (Phase 4 prep)
op.execute("CREATE INDEX IF NOT EXISTS ix_events_calendar_updated ON calendar_events (calendar_id, updated_at DESC)")
def downgrade() -> None:
op.drop_index("ix_events_calendar_updated", table_name="calendar_events")
op.drop_column("project_tasks", "version")
op.drop_column("task_comments", "user_id")
op.drop_index("ix_projects_user_updated", table_name="projects")
op.drop_index("ix_project_tasks_project_updated", table_name="project_tasks")
op.drop_index("ix_project_tasks_parent_task_id", table_name="project_tasks")
op.drop_index("ix_project_tasks_project_id", table_name="project_tasks")

View File

@ -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")

View File

@ -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")

View File

@ -0,0 +1,36 @@
"""Expand notification type CHECK for project invite types
Revision ID: 060
Revises: 059
"""
from alembic import op
revision = "060"
down_revision = "059"
branch_labels = None
depends_on = None
_OLD_TYPES = (
"connection_request", "connection_accepted", "connection_rejected",
"calendar_invite", "calendar_invite_accepted", "calendar_invite_rejected",
"event_invite", "event_invite_response",
"info", "warning", "reminder", "system",
)
_NEW_TYPES = _OLD_TYPES + (
"project_invite", "project_invite_accepted", "project_invite_rejected",
"task_assigned",
)
def _check_sql(types: tuple) -> str:
return f"type IN ({', '.join(repr(t) for t in types)})"
def upgrade() -> None:
op.drop_constraint("ck_notifications_type", "notifications", type_="check")
op.create_check_constraint("ck_notifications_type", "notifications", _check_sql(_NEW_TYPES))
def downgrade() -> None:
op.drop_constraint("ck_notifications_type", "notifications", type_="check")
op.create_check_constraint("ck_notifications_type", "notifications", _check_sql(_OLD_TYPES))

View File

@ -21,6 +21,8 @@ from app.models.user_connection import UserConnection
from app.models.calendar_member import CalendarMember from app.models.calendar_member import CalendarMember
from app.models.event_lock import EventLock from app.models.event_lock import EventLock
from app.models.event_invitation import EventInvitation, EventInvitationOverride from app.models.event_invitation import EventInvitation, EventInvitationOverride
from app.models.project_member import ProjectMember
from app.models.project_task_assignment import ProjectTaskAssignment
__all__ = [ __all__ = [
"Settings", "Settings",
@ -47,4 +49,6 @@ __all__ = [
"EventLock", "EventLock",
"EventInvitation", "EventInvitation",
"EventInvitationOverride", "EventInvitationOverride",
"ProjectMember",
"ProjectTaskAssignment",
] ]

View File

@ -9,6 +9,8 @@ _NOTIFICATION_TYPES = (
"connection_request", "connection_accepted", "connection_rejected", "connection_request", "connection_accepted", "connection_rejected",
"calendar_invite", "calendar_invite_accepted", "calendar_invite_rejected", "calendar_invite", "calendar_invite_accepted", "calendar_invite_rejected",
"event_invite", "event_invite_response", "event_invite", "event_invite_response",
"project_invite", "project_invite_accepted", "project_invite_rejected",
"task_assigned",
"info", "warning", "reminder", "system", "info", "warning", "reminder", "system",
) )

View File

@ -22,6 +22,7 @@ class Project(Base):
created_at: Mapped[datetime] = mapped_column(default=func.now()) created_at: Mapped[datetime] = mapped_column(default=func.now())
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now()) updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
# Relationships # Relationships — lazy="raise" to prevent N+1 (mirrors CalendarMember pattern)
tasks: Mapped[List["ProjectTask"]] = relationship(back_populates="project", cascade="all, delete-orphan") tasks: Mapped[List["ProjectTask"]] = relationship(back_populates="project", cascade="all, delete-orphan", lazy="raise")
todos: Mapped[List["Todo"]] = relationship(back_populates="project") todos: Mapped[List["Todo"]] = relationship(back_populates="project", lazy="raise")
members: Mapped[List["ProjectMember"]] = relationship(back_populates="project", cascade="all, delete-orphan", lazy="raise")

View File

@ -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"
)

View File

@ -1,3 +1,4 @@
import sqlalchemy as sa
from sqlalchemy import String, Text, Integer, Date, ForeignKey, func from sqlalchemy import String, Text, Integer, Date, ForeignKey, func
from sqlalchemy.orm import Mapped, mapped_column, relationship as sa_relationship from sqlalchemy.orm import Mapped, mapped_column, relationship as sa_relationship
from datetime import datetime, date from datetime import datetime, date
@ -20,21 +21,30 @@ class ProjectTask(Base):
due_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True) 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) 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) 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()) created_at: Mapped[datetime] = mapped_column(default=func.now())
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now()) updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
# Relationships # Relationships — lazy="raise" to prevent N+1 (mirrors CalendarMember pattern)
project: Mapped["Project"] = sa_relationship(back_populates="tasks") project: Mapped["Project"] = sa_relationship(back_populates="tasks", lazy="raise")
person: Mapped[Optional["Person"]] = sa_relationship(back_populates="assigned_tasks") person: Mapped[Optional["Person"]] = sa_relationship(back_populates="assigned_tasks", lazy="raise")
parent_task: Mapped[Optional["ProjectTask"]] = sa_relationship( parent_task: Mapped[Optional["ProjectTask"]] = sa_relationship(
back_populates="subtasks", back_populates="subtasks",
remote_side=[id], remote_side=[id],
lazy="raise",
) )
subtasks: Mapped[List["ProjectTask"]] = sa_relationship( subtasks: Mapped[List["ProjectTask"]] = sa_relationship(
back_populates="parent_task", back_populates="parent_task",
cascade="all, delete-orphan", cascade="all, delete-orphan",
lazy="raise",
) )
comments: Mapped[List["TaskComment"]] = sa_relationship( comments: Mapped[List["TaskComment"]] = sa_relationship(
back_populates="task", back_populates="task",
cascade="all, delete-orphan", cascade="all, delete-orphan",
lazy="raise",
)
assignments: Mapped[List["ProjectTaskAssignment"]] = sa_relationship(
back_populates="task",
cascade="all, delete-orphan",
lazy="raise",
) )

View File

@ -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")

View File

@ -1,6 +1,7 @@
from sqlalchemy import Text, Integer, ForeignKey from sqlalchemy import Text, Integer, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship as sa_relationship from sqlalchemy.orm import Mapped, mapped_column, relationship as sa_relationship
from datetime import datetime from datetime import datetime
from typing import Optional
from app.database import Base from app.database import Base
@ -11,8 +12,12 @@ class TaskComment(Base):
task_id: Mapped[int] = mapped_column( task_id: Mapped[int] = mapped_column(
Integer, ForeignKey("project_tasks.id", ondelete="CASCADE"), nullable=False, index=True 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) content: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = mapped_column(default=datetime.now) created_at: Mapped[datetime] = mapped_column(default=datetime.now)
# Relationships # Relationships — lazy="raise" to prevent N+1 (mirrors CalendarMember pattern)
task: Mapped["ProjectTask"] = sa_relationship(back_populates="comments") task: Mapped["ProjectTask"] = sa_relationship(back_populates="comments", lazy="raise")
user: Mapped[Optional["User"]] = sa_relationship(lazy="raise")

View File

@ -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.ext.asyncio import AsyncSession
from sqlalchemy import func, select, update from sqlalchemy import func, select, update
from typing import List 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_event import CalendarEvent
from app.models.calendar_member import CalendarMember from app.models.calendar_member import CalendarMember
from app.schemas.calendar import CalendarCreate, CalendarUpdate, CalendarResponse 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.routers.auth import get_current_user
from app.models.user import User from app.models.user import User
@ -136,3 +140,62 @@ async def delete_calendar(
await db.delete(calendar) await db.delete(calendar)
await db.commit() await db.commit()
return None return None
# ──────────────────────────────────────────────
# DELTA POLLING
# ──────────────────────────────────────────────
class CalendarPollResponse(BaseModel):
has_changes: bool
calendar_updated_at: str | None = None
changed_event_ids: list[int] = []
@router.get("/{calendar_id}/poll", response_model=CalendarPollResponse)
async def poll_calendar(
calendar_id: int = Path(ge=1, le=2147483647),
since: str = Query(..., description="ISO timestamp to check for changes since"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Lightweight poll endpoint — returns changed event IDs since timestamp."""
await require_permission(db, calendar_id, current_user.id, "read_only")
try:
since_dt = datetime.fromisoformat(since)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid ISO timestamp")
# Clamp to max 24h in the past to prevent expensive full-table scans
from datetime import timedelta
min_since = datetime.now() - timedelta(hours=24)
if since_dt < min_since:
since_dt = min_since
# Check calendar-level update
cal_result = await db.execute(
select(Calendar.updated_at).where(Calendar.id == calendar_id)
)
calendar_updated = cal_result.scalar_one_or_none()
if not calendar_updated:
raise HTTPException(status_code=404, detail="Calendar not found")
calendar_changed = calendar_updated > since_dt
# Check event-level changes using the ix_events_calendar_updated index
event_result = await db.execute(
select(CalendarEvent.id).where(
CalendarEvent.calendar_id == calendar_id,
CalendarEvent.updated_at > since_dt,
)
)
changed_event_ids = [r[0] for r in event_result.all()]
has_changes = calendar_changed or len(changed_event_ids) > 0
return CalendarPollResponse(
has_changes=has_changes,
calendar_updated_at=calendar_updated.isoformat() if calendar_updated else None,
changed_event_ids=changed_event_ids,
)

View File

@ -51,6 +51,7 @@ from app.services.connection import (
) )
from app.services.calendar_sharing import cascade_on_disconnect from app.services.calendar_sharing import cascade_on_disconnect
from app.services.notification import create_notification from app.services.notification import create_notification
from app.services.project_sharing import cascade_projects_on_disconnect
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -827,6 +828,9 @@ async def remove_connection(
# Cascade: remove calendar memberships and event locks between these users # Cascade: remove calendar memberships and event locks between these users
await cascade_on_disconnect(db, current_user.id, counterpart_id) 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( await log_audit_event(
db, db,
action="connection.removed", action="connection.removed",

View File

@ -1,18 +1,31 @@
from fastapi import APIRouter, Depends, HTTPException, Path, Query from fastapi import APIRouter, Depends, HTTPException, Path, Query
from sqlalchemy.ext.asyncio import AsyncSession 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 sqlalchemy.orm import selectinload
from typing import List, Optional from typing import List, Optional
from datetime import date, timedelta from datetime import date, datetime, timedelta
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from app.database import get_db from app.database import get_db
from app.models.project import Project from app.models.project import Project
from app.models.project_task import ProjectTask from app.models.project_task import ProjectTask
from app.models.task_comment import TaskComment 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 import ProjectCreate, ProjectUpdate, ProjectResponse, TrackedTaskResponse
from app.schemas.project_task import ProjectTaskCreate, ProjectTaskUpdate, ProjectTaskResponse from app.schemas.project_task import ProjectTaskCreate, ProjectTaskUpdate, ProjectTaskResponse
from app.schemas.task_comment import TaskCommentCreate, TaskCommentResponse 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.routers.auth import get_current_user
from app.models.user import User from app.models.user import User
@ -26,34 +39,61 @@ class ReorderItem(BaseModel):
def _project_load_options(): 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 [ return [
selectinload(Project.tasks).selectinload(ProjectTask.comments), selectinload(Project.tasks).selectinload(ProjectTask.comments).selectinload(TaskComment.user),
selectinload(Project.tasks).selectinload(ProjectTask.subtasks).selectinload(ProjectTask.comments), 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.subtasks).selectinload(ProjectTask.subtasks),
selectinload(Project.tasks).selectinload(ProjectTask.assignments).selectinload(ProjectTaskAssignment.user),
selectinload(Project.tasks).selectinload(ProjectTask.subtasks).selectinload(ProjectTask.assignments).selectinload(ProjectTaskAssignment.user),
selectinload(Project.members),
] ]
def _task_load_options(): def _task_load_options():
"""All load options needed for task responses.""" """All load options needed for task responses."""
return [ return [
selectinload(ProjectTask.comments), 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.subtasks).selectinload(ProjectTask.subtasks),
selectinload(ProjectTask.assignments).selectinload(ProjectTaskAssignment.user),
selectinload(ProjectTask.subtasks).selectinload(ProjectTask.assignments).selectinload(ProjectTaskAssignment.user),
] ]
async def _get_user_name(db: AsyncSession, user_id: int) -> str | None:
"""Get display name for a user from settings.preferred_name or user.username."""
result = await db.execute(
select(Settings.preferred_name, User.username)
.outerjoin(Settings, Settings.user_id == User.id)
.where(User.id == user_id)
)
row = result.one_or_none()
if not row:
return None
preferred, username = row.tuple()
return preferred or username
# ──────────────────────────────────────────────
# PROJECT CRUD
# ──────────────────────────────────────────────
@router.get("/", response_model=List[ProjectResponse]) @router.get("/", response_model=List[ProjectResponse])
async def get_projects( async def get_projects(
tracked: Optional[bool] = Query(None), tracked: Optional[bool] = Query(None),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) 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 = ( query = (
select(Project) select(Project)
.options(*_project_load_options()) .options(*_project_load_options())
.where(Project.user_id == current_user.id) .where(Project.id.in_(accessible_ids))
.order_by(Project.created_at.desc()) .order_by(Project.created_at.desc())
) )
if tracked is not None: if tracked is not None:
@ -72,6 +112,10 @@ async def get_tracked_tasks(
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""Get tasks and subtasks from tracked projects with due dates within the next N days.""" """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() today = date.today()
cutoff = today + timedelta(days=days) cutoff = today + timedelta(days=days)
@ -83,7 +127,7 @@ async def get_tracked_tasks(
selectinload(ProjectTask.parent_task), selectinload(ProjectTask.parent_task),
) )
.where( .where(
Project.user_id == current_user.id, Project.id.in_(accessible_ids),
Project.is_tracked == True, Project.is_tracked == True,
ProjectTask.due_date.isnot(None), ProjectTask.due_date.isnot(None),
ProjectTask.due_date >= today, 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) @router.post("/", response_model=ProjectResponse, status_code=201)
async def create_project( async def create_project(
project: ProjectCreate, project: ProjectCreate,
@ -121,7 +190,6 @@ async def create_project(
db.add(new_project) db.add(new_project)
await db.commit() 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) query = select(Project).options(*_project_load_options()).where(Project.id == new_project.id)
result = await db.execute(query) result = await db.execute(query)
return result.scalar_one() return result.scalar_one()
@ -134,10 +202,12 @@ async def get_project(
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""Get a specific project by ID with its tasks.""" """Get a specific project by ID with its tasks."""
await require_project_permission(db, project_id, current_user.id, "read_only")
query = ( query = (
select(Project) select(Project)
.options(*_project_load_options()) .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) result = await db.execute(query)
project = result.scalar_one_or_none() project = result.scalar_one_or_none()
@ -155,10 +225,10 @@ async def update_project(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""Update a project.""" """Update a project. Owner only."""
result = await db.execute( await require_project_permission(db, project_id, current_user.id, "owner")
select(Project).where(Project.id == project_id, Project.user_id == current_user.id)
) result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none() project = result.scalar_one_or_none()
if not project: if not project:
@ -171,7 +241,6 @@ async def update_project(
await db.commit() await db.commit()
# Re-fetch with eagerly loaded tasks for response serialization
query = select(Project).options(*_project_load_options()).where(Project.id == project_id) query = select(Project).options(*_project_load_options()).where(Project.id == project_id)
result = await db.execute(query) result = await db.execute(query)
return result.scalar_one() return result.scalar_one()
@ -183,10 +252,10 @@ async def delete_project(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""Delete a project and all its tasks.""" """Delete a project and all its tasks. Owner only."""
result = await db.execute( await require_project_permission(db, project_id, current_user.id, "owner")
select(Project).where(Project.id == project_id, Project.user_id == current_user.id)
) result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none() project = result.scalar_one_or_none()
if not project: if not project:
@ -198,6 +267,10 @@ async def delete_project(
return None return None
# ──────────────────────────────────────────────
# TASK CRUD (permission-aware)
# ──────────────────────────────────────────────
@router.get("/{project_id}/tasks", response_model=List[ProjectTaskResponse]) @router.get("/{project_id}/tasks", response_model=List[ProjectTaskResponse])
async def get_project_tasks( async def get_project_tasks(
project_id: int = Path(ge=1, le=2147483647), project_id: int = Path(ge=1, le=2147483647),
@ -205,14 +278,7 @@ async def get_project_tasks(
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""Get top-level tasks for a specific project (subtasks are nested).""" """Get top-level tasks for a specific project (subtasks are nested)."""
# Verify project ownership first await require_project_permission(db, project_id, current_user.id, "read_only")
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")
query = ( query = (
select(ProjectTask) select(ProjectTask)
@ -236,15 +302,8 @@ async def create_project_task(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""Create a new task or subtask for a project.""" """Create a new task or subtask for a project. Requires create_modify permission."""
# Verify project ownership first await require_project_permission(db, project_id, current_user.id, "create_modify")
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")
# Validate parent_task_id if creating a subtask # Validate parent_task_id if creating a subtask
if task.parent_task_id is not None: if task.parent_task_id is not None:
@ -268,7 +327,6 @@ async def create_project_task(
db.add(new_task) db.add(new_task)
await db.commit() await db.commit()
# Re-fetch with subtasks loaded
query = ( query = (
select(ProjectTask) select(ProjectTask)
.options(*_task_load_options()) .options(*_task_load_options())
@ -285,15 +343,8 @@ async def reorder_tasks(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""Bulk update sort_order for tasks.""" """Bulk update sort_order for tasks. Requires create_modify permission."""
# Verify project ownership first await require_project_permission(db, project_id, current_user.id, "create_modify")
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")
# AC-4: Batch-fetch all tasks in one query instead of N sequential queries # AC-4: Batch-fetch all tasks in one query instead of N sequential queries
task_ids = [item.id for item in items] task_ids = [item.id for item in items]
@ -323,13 +374,12 @@ async def update_project_task(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""Update a project task.""" """Update a project task. Permission checked at project and task level."""
# Verify project ownership first, then fetch task scoped to that project perm, project_perm = await get_effective_task_permission(db, current_user.id, task_id, project_id)
project_result = await db.execute( if perm is None:
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") 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( result = await db.execute(
select(ProjectTask).where( select(ProjectTask).where(
@ -344,12 +394,28 @@ async def update_project_task(
update_data = task_update.model_dump(exclude_unset=True) update_data = task_update.model_dump(exclude_unset=True)
# SEC-P02: Assignees (non-owner, non-project-member with create_modify) restricted to content fields
if project_perm not in ("owner", "create_modify"):
# This user's create_modify comes from task assignment — enforce allowlist
disallowed = set(update_data.keys()) - ASSIGNEE_ALLOWED_FIELDS
if disallowed:
raise HTTPException(
status_code=403,
detail=f"Task assignees cannot modify: {', '.join(sorted(disallowed))}",
)
# Optimistic locking: if version provided, check it matches
client_version = update_data.pop("version", None)
if client_version is not None and task.version != client_version:
raise HTTPException(status_code=409, detail="Task was modified by another user")
for key, value in update_data.items(): for key, value in update_data.items():
setattr(task, key, value) setattr(task, key, value)
task.version += 1
await db.commit() await db.commit()
# Re-fetch with subtasks loaded
query = ( query = (
select(ProjectTask) select(ProjectTask)
.options(*_task_load_options()) .options(*_task_load_options())
@ -366,13 +432,8 @@ async def delete_project_task(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""Delete a project task (cascades to subtasks).""" """Delete a project task (cascades to subtasks). Requires create_modify permission."""
# Verify project ownership first, then fetch task scoped to that project await require_project_permission(db, project_id, current_user.id, "create_modify")
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")
result = await db.execute( result = await db.execute(
select(ProjectTask).where( select(ProjectTask).where(
@ -391,6 +452,10 @@ async def delete_project_task(
return None return None
# ──────────────────────────────────────────────
# COMMENTS (permission-aware)
# ──────────────────────────────────────────────
@router.post("/{project_id}/tasks/{task_id}/comments", response_model=TaskCommentResponse, status_code=201) @router.post("/{project_id}/tasks/{task_id}/comments", response_model=TaskCommentResponse, status_code=201)
async def create_task_comment( async def create_task_comment(
project_id: int = Path(ge=1, le=2147483647), project_id: int = Path(ge=1, le=2147483647),
@ -399,13 +464,8 @@ async def create_task_comment(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""Add a comment to a task.""" """Add a comment to a task. All members can comment (read_only minimum)."""
# Verify project ownership first, then fetch task scoped to that project await require_project_permission(db, project_id, current_user.id, "read_only")
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")
result = await db.execute( result = await db.execute(
select(ProjectTask).where( select(ProjectTask).where(
@ -418,12 +478,23 @@ async def create_task_comment(
if not task: if not task:
raise HTTPException(status_code=404, detail="Task not found") 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) db.add(new_comment)
# Get author name before commit
author_name = await _get_user_name(db, current_user.id)
await db.commit() await db.commit()
await db.refresh(new_comment) 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) @router.delete("/{project_id}/tasks/{task_id}/comments/{comment_id}", status_code=204)
@ -434,12 +505,9 @@ async def delete_task_comment(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""Delete a task comment.""" """Delete a task comment. Comment author or project owner only."""
# Verify project ownership first, then fetch comment scoped through task perm = await get_project_permission(db, project_id, current_user.id)
project_result = await db.execute( if perm is None:
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") raise HTTPException(status_code=404, detail="Project not found")
result = await db.execute( result = await db.execute(
@ -453,7 +521,484 @@ async def delete_task_comment(
if not comment: if not comment:
raise HTTPException(status_code=404, detail="Comment not found") 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.delete(comment)
await db.commit() await db.commit()
return None return None
# ──────────────────────────────────────────────
# MEMBERSHIP ROUTES
# ──────────────────────────────────────────────
@router.post("/{project_id}/members", response_model=List[ProjectMemberResponse], status_code=201)
async def invite_members(
project_id: int = Path(ge=1, le=2147483647),
invite: ProjectMemberInvite = ...,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Invite connection(s) to a project. Owner only."""
await require_project_permission(db, project_id, current_user.id, "owner")
# Validate connections
await validate_project_connections(db, current_user.id, invite.user_ids)
# Check pending invite cap (max 10 pending per project)
pending_count_result = await db.execute(
select(ProjectMember.id).where(
ProjectMember.project_id == project_id,
ProjectMember.status == "pending",
)
)
pending_count = len(pending_count_result.all())
if pending_count + len(invite.user_ids) > 10:
raise HTTPException(status_code=400, detail="Maximum 10 pending invites per project")
# Filter out self and existing members
existing_result = await db.execute(
select(ProjectMember.user_id).where(
ProjectMember.project_id == project_id,
ProjectMember.user_id.in_(invite.user_ids),
)
)
existing_user_ids = {r[0] for r in existing_result.all()}
# Get project for notifications
project_result = await db.execute(select(Project.name).where(Project.id == project_id))
project_name = project_result.scalar_one()
inviter_name = await _get_user_name(db, current_user.id)
created_members = []
for uid in invite.user_ids:
if uid == current_user.id or uid in existing_user_ids:
continue
member = ProjectMember(
project_id=project_id,
user_id=uid,
invited_by=current_user.id,
permission=invite.permission,
status="pending",
source="invited",
)
db.add(member)
created_members.append(member)
# In-app notification
await create_notification(
db, uid, "project_invite",
f"Project invitation from {inviter_name}",
f"You've been invited to collaborate on \"{project_name}\"",
data={"project_id": project_id},
source_type="project_member",
)
await db.flush() # Assign IDs before commit (ORM objects expire after commit)
member_ids = [m.id for m in created_members]
await db.commit()
# Re-fetch with relationships
if not created_members:
return []
result = await db.execute(
select(ProjectMember)
.options(
selectinload(ProjectMember.user),
selectinload(ProjectMember.inviter),
)
.where(ProjectMember.id.in_(member_ids))
)
members = result.scalars().all()
# Build response with names
responses = []
for m in members:
resp = ProjectMemberResponse.model_validate(m)
resp.user_name = m.user.username
resp.inviter_name = m.inviter.username if m.inviter else None
responses.append(resp)
return responses
@router.get("/{project_id}/members", response_model=List[ProjectMemberResponse])
async def get_members(
project_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""List members + statuses. Any member can view."""
await require_project_permission(db, project_id, current_user.id, "read_only")
result = await db.execute(
select(ProjectMember)
.options(
selectinload(ProjectMember.user),
selectinload(ProjectMember.inviter),
)
.where(ProjectMember.project_id == project_id)
.order_by(ProjectMember.created_at.asc())
)
members = result.scalars().all()
# Batch-fetch settings for preferred_name
user_ids = [m.user_id for m in members] + [m.invited_by for m in members]
settings_result = await db.execute(
select(Settings.user_id, Settings.preferred_name).where(Settings.user_id.in_(user_ids))
)
name_map = {r[0]: r[1] for r in settings_result.all()}
responses = []
for m in members:
resp = ProjectMemberResponse.model_validate(m)
resp.user_name = name_map.get(m.user_id) or m.user.username
resp.inviter_name = name_map.get(m.invited_by) or (m.inviter.username if m.inviter else None)
responses.append(resp)
return responses
@router.patch("/{project_id}/members/{user_id}", response_model=ProjectMemberResponse)
async def update_member_permission(
project_id: int = Path(ge=1, le=2147483647),
user_id: int = Path(ge=1, le=2147483647),
update: ProjectMemberUpdate = ...,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Update a member's permission level. Owner only."""
await require_project_permission(db, project_id, current_user.id, "owner")
result = await db.execute(
select(ProjectMember)
.options(selectinload(ProjectMember.user), selectinload(ProjectMember.inviter))
.where(
ProjectMember.project_id == project_id,
ProjectMember.user_id == user_id,
)
)
member = result.scalar_one_or_none()
if not member:
raise HTTPException(status_code=404, detail="Member not found")
member.permission = update.permission
# Extract response data BEFORE commit (ORM objects expire after commit)
resp = ProjectMemberResponse.model_validate(member)
resp.user_name = member.user.username
resp.inviter_name = member.inviter.username if member.inviter else None
await db.commit()
return resp
@router.delete("/{project_id}/members/{user_id}", status_code=204)
async def remove_member(
project_id: int = Path(ge=1, le=2147483647),
user_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Remove a member. Owner or self (leave project)."""
perm = await get_project_permission(db, project_id, current_user.id)
if perm is None:
raise HTTPException(status_code=404, detail="Project not found")
# Only owner can remove others; anyone can remove themselves
if user_id != current_user.id and perm != "owner":
raise HTTPException(status_code=403, detail="Only the project owner can remove members")
result = await db.execute(
select(ProjectMember).where(
ProjectMember.project_id == project_id,
ProjectMember.user_id == user_id,
)
)
member = result.scalar_one_or_none()
if not member:
raise HTTPException(status_code=404, detail="Member not found")
# Remove task assignments for this user in this project
await db.execute(
sa_delete(ProjectTaskAssignment).where(
ProjectTaskAssignment.user_id == user_id,
ProjectTaskAssignment.task_id.in_(
select(ProjectTask.id).where(ProjectTask.project_id == project_id)
),
)
)
await db.delete(member)
await db.commit()
return None
@router.post("/memberships/{project_id}/respond", response_model=ProjectMemberResponse)
async def respond_to_invite(
project_id: int = Path(ge=1, le=2147483647),
respond: ProjectMemberRespond = ...,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Accept or reject a project invite."""
result = await db.execute(
select(ProjectMember)
.options(selectinload(ProjectMember.user), selectinload(ProjectMember.inviter))
.where(
ProjectMember.project_id == project_id,
ProjectMember.user_id == current_user.id,
ProjectMember.status == "pending",
)
)
member = result.scalar_one_or_none()
if not member:
raise HTTPException(status_code=404, detail="No pending invitation found")
# Extract response data before any mutations (ORM objects expire after commit)
resp = ProjectMemberResponse.model_validate(member)
resp.user_name = member.user.username
resp.inviter_name = member.inviter.username if member.inviter else None
if respond.response == "accepted":
member.status = "accepted"
member.accepted_at = datetime.now()
# Get project owner for notification
project_result = await db.execute(
select(Project.user_id, Project.name).where(Project.id == project_id)
)
project_row = project_result.one()
owner_id, project_name = project_row.tuple()
responder_name = await _get_user_name(db, current_user.id)
await create_notification(
db, owner_id, "project_invite_accepted",
f"{responder_name} joined your project",
f"{responder_name} accepted the invitation to \"{project_name}\"",
data={"project_id": project_id},
source_type="project_member",
)
resp.status = "accepted"
else:
# Rejected — delete the row to prevent accumulation (W-06)
await db.delete(member)
resp.status = "rejected"
await db.commit()
return resp
# ──────────────────────────────────────────────
# TASK ASSIGNMENT ROUTES
# ──────────────────────────────────────────────
@router.post("/{project_id}/tasks/{task_id}/assignments", response_model=List[TaskAssignmentResponse], status_code=201)
async def assign_users_to_task(
project_id: int = Path(ge=1, le=2147483647),
task_id: int = Path(ge=1, le=2147483647),
assignment: TaskAssignmentCreate = ...,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Assign user(s) to a task. Requires create_modify on project or be owner."""
await require_project_permission(db, project_id, current_user.id, "create_modify")
# Verify task exists in project
task_result = await db.execute(
select(ProjectTask).where(
ProjectTask.id == task_id,
ProjectTask.project_id == project_id,
)
)
task = task_result.scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
# Get project owner for connection validation
project_result = await db.execute(
select(Project.user_id, Project.name).where(Project.id == project_id)
)
project_row = project_result.one()
owner_id, project_name = project_row.tuple()
# Validate connections (all assignees must be connections of the project owner)
non_owner_ids = [uid for uid in assignment.user_ids if uid != owner_id]
if non_owner_ids:
await validate_project_connections(db, owner_id, non_owner_ids)
# Filter out existing assignments
existing_result = await db.execute(
select(ProjectTaskAssignment.user_id).where(
ProjectTaskAssignment.task_id == task_id,
ProjectTaskAssignment.user_id.in_(assignment.user_ids),
)
)
existing_user_ids = {r[0] for r in existing_result.all()}
assigner_name = await _get_user_name(db, current_user.id)
created = []
for uid in assignment.user_ids:
if uid in existing_user_ids:
continue
# Auto-membership: ensure user has ProjectMember row
if uid != owner_id:
await ensure_auto_membership(db, project_id, uid, current_user.id)
new_assignment = ProjectTaskAssignment(
task_id=task_id,
user_id=uid,
assigned_by=current_user.id,
)
db.add(new_assignment)
created.append(new_assignment)
# Notify assignee (don't notify self)
if uid != current_user.id:
await create_notification(
db, uid, "task_assigned",
f"Task assigned by {assigner_name}",
f"You've been assigned to \"{task.title}\" in \"{project_name}\"",
data={"project_id": project_id, "task_id": task_id},
source_type="task_assignment",
)
await db.flush() # Assign IDs before commit (ORM objects expire after commit)
assignment_ids = [a.id for a in created]
await db.commit()
if not created:
return []
# Re-fetch with user info
result = await db.execute(
select(ProjectTaskAssignment)
.options(selectinload(ProjectTaskAssignment.user))
.where(ProjectTaskAssignment.id.in_(assignment_ids))
)
assignments = result.scalars().all()
# Get names
user_ids = [a.user_id for a in assignments]
settings_result = await db.execute(
select(Settings.user_id, Settings.preferred_name).where(Settings.user_id.in_(user_ids))
)
name_map = {r[0]: r[1] for r in settings_result.all()}
return [
TaskAssignmentResponse(
id=a.id,
task_id=a.task_id,
user_id=a.user_id,
assigned_by=a.assigned_by,
user_name=name_map.get(a.user_id) or a.user.username,
created_at=a.created_at,
)
for a in assignments
]
@router.delete("/{project_id}/tasks/{task_id}/assignments/{user_id}", status_code=204)
async def remove_task_assignment(
project_id: int = Path(ge=1, le=2147483647),
task_id: int = Path(ge=1, le=2147483647),
user_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Remove a task assignment. Owner, create_modify member, or the assignee themselves."""
perm = await get_project_permission(db, project_id, current_user.id)
if perm is None:
raise HTTPException(status_code=404, detail="Project not found")
# Self-unassign is always allowed; otherwise need create_modify or owner
if user_id != current_user.id and perm not in ("owner", "create_modify"):
raise HTTPException(status_code=403, detail="Insufficient permission")
result = await db.execute(
sa_delete(ProjectTaskAssignment)
.where(
ProjectTaskAssignment.task_id == task_id,
ProjectTaskAssignment.user_id == user_id,
)
.returning(ProjectTaskAssignment.id)
)
if not result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Assignment not found")
# Cleanup auto-membership if no more assignments
await cleanup_auto_membership(db, project_id, user_id)
await db.commit()
return None
# ──────────────────────────────────────────────
# DELTA POLLING
# ──────────────────────────────────────────────
class PollResponse(BaseModel):
has_changes: bool
project_updated_at: str | None = None
changed_task_ids: list[int] = []
@router.get("/{project_id}/poll", response_model=PollResponse)
async def poll_project(
project_id: int = Path(ge=1, le=2147483647),
since: str = Query(..., description="ISO timestamp to check for changes since"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Lightweight poll endpoint — returns changed task IDs since timestamp."""
await require_project_permission(db, project_id, current_user.id, "read_only")
try:
since_dt = datetime.fromisoformat(since)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid ISO timestamp")
# Clamp to max 24h in the past to prevent expensive full-table scans
min_since = datetime.now() - timedelta(hours=24)
if since_dt < min_since:
since_dt = min_since
# Check project-level update
proj_result = await db.execute(
select(Project.updated_at).where(Project.id == project_id)
)
project_updated = proj_result.scalar_one_or_none()
if not project_updated:
raise HTTPException(status_code=404, detail="Project not found")
project_changed = project_updated > since_dt
# Check task-level changes using the index
task_result = await db.execute(
select(ProjectTask.id).where(
ProjectTask.project_id == project_id,
ProjectTask.updated_at > since_dt,
)
)
changed_task_ids = [r[0] for r in task_result.all()]
has_changes = project_changed or len(changed_task_ids) > 0
return PollResponse(
has_changes=has_changes,
project_updated_at=project_updated.isoformat() if project_updated else None,
changed_task_ids=changed_task_ids,
)

View File

@ -1,8 +1,11 @@
from pydantic import BaseModel, ConfigDict, Field import logging
from pydantic import BaseModel, ConfigDict, Field, model_validator
from datetime import datetime, date from datetime import datetime, date
from typing import Optional, List, Literal from typing import Optional, List, Literal
from app.schemas.project_task import ProjectTaskResponse from app.schemas.project_task import ProjectTaskResponse
logger = logging.getLogger(__name__)
ProjectStatus = Literal["not_started", "in_progress", "completed", "blocked", "review", "on_hold"] ProjectStatus = Literal["not_started", "in_progress", "completed", "blocked", "review", "on_hold"]
@ -30,18 +33,44 @@ class ProjectUpdate(BaseModel):
class ProjectResponse(BaseModel): class ProjectResponse(BaseModel):
id: int id: int
user_id: int = 0
name: str name: str
description: Optional[str] description: Optional[str]
status: str status: str
color: Optional[str] color: Optional[str]
due_date: Optional[date] due_date: Optional[date]
is_tracked: bool is_tracked: bool
member_count: int = 0
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
tasks: List[ProjectTaskResponse] = [] tasks: List[ProjectTaskResponse] = []
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@model_validator(mode="before")
@classmethod
def compute_member_count(cls, data): # type: ignore[override]
"""Compute member_count from eagerly loaded members relationship."""
if hasattr(data, "members"):
try:
data = dict(
id=data.id,
user_id=data.user_id,
name=data.name,
description=data.description,
status=data.status,
color=data.color,
due_date=data.due_date,
is_tracked=data.is_tracked,
member_count=len([m for m in data.members if m.status == "accepted"]),
created_at=data.created_at,
updated_at=data.updated_at,
tasks=data.tasks,
)
except Exception as exc:
logger.debug("member_count compute skipped: %s", exc)
return data
class TrackedTaskResponse(BaseModel): class TrackedTaskResponse(BaseModel):
id: int id: int

View File

@ -0,0 +1,43 @@
from typing import Annotated, Optional, Literal
from pydantic import BaseModel, ConfigDict, Field
from datetime import datetime
MemberPermission = Literal["read_only", "create_modify"]
MemberStatus = Literal["pending", "accepted", "rejected"]
InviteResponse = Literal["accepted", "rejected"]
class ProjectMemberInvite(BaseModel):
model_config = ConfigDict(extra="forbid")
user_ids: list[Annotated[int, Field(ge=1, le=2147483647)]] = Field(min_length=1, max_length=10)
permission: MemberPermission = "create_modify"
class ProjectMemberUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
permission: MemberPermission
class ProjectMemberRespond(BaseModel):
model_config = ConfigDict(extra="forbid")
response: InviteResponse
class ProjectMemberResponse(BaseModel):
id: int
project_id: int
user_id: int
invited_by: int
permission: str
status: str
source: str
user_name: str | None = None
inviter_name: str | None = None
created_at: datetime
updated_at: datetime
accepted_at: datetime | None = None
model_config = ConfigDict(from_attributes=True)

View File

@ -2,6 +2,7 @@ from pydantic import BaseModel, ConfigDict, Field
from datetime import datetime, date from datetime import datetime, date
from typing import Optional, List, Literal from typing import Optional, List, Literal
from app.schemas.task_comment import TaskCommentResponse 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"] TaskStatus = Literal["pending", "in_progress", "completed", "blocked", "review", "on_hold"]
TaskPriority = Literal["none", "low", "medium", "high"] TaskPriority = Literal["none", "low", "medium", "high"]
@ -30,6 +31,7 @@ class ProjectTaskUpdate(BaseModel):
due_date: Optional[date] = None due_date: Optional[date] = None
person_id: Optional[int] = None person_id: Optional[int] = None
sort_order: Optional[int] = None sort_order: Optional[int] = None
version: Optional[int] = None # For optimistic locking
class ProjectTaskResponse(BaseModel): class ProjectTaskResponse(BaseModel):
@ -43,10 +45,12 @@ class ProjectTaskResponse(BaseModel):
due_date: Optional[date] due_date: Optional[date]
person_id: Optional[int] person_id: Optional[int]
sort_order: int sort_order: int
version: int = 1
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
subtasks: List["ProjectTaskResponse"] = [] subtasks: List["ProjectTaskResponse"] = []
comments: List[TaskCommentResponse] = [] comments: List[TaskCommentResponse] = []
assignments: List[TaskAssignmentResponse] = []
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)

View File

@ -0,0 +1,31 @@
from typing import Annotated
from pydantic import BaseModel, ConfigDict, Field, model_validator
from datetime import datetime
class TaskAssignmentCreate(BaseModel):
model_config = ConfigDict(extra="forbid")
user_ids: list[Annotated[int, Field(ge=1, le=2147483647)]] = Field(min_length=1, max_length=20)
class TaskAssignmentResponse(BaseModel):
id: int
task_id: int
user_id: int
assigned_by: int
user_name: str | None = None
created_at: datetime
model_config = ConfigDict(from_attributes=True)
@model_validator(mode="before")
@classmethod
def resolve_user_name(cls, data): # type: ignore[override]
"""Populate user_name from eagerly loaded user relationship."""
if hasattr(data, "user") and data.user is not None and not getattr(data, "user_name", None):
# Build dict from ORM columns so new fields are auto-included
cols = {c.key: getattr(data, c.key) for c in data.__table__.columns}
cols["user_name"] = data.user.username
return cols
return data

View File

@ -1,4 +1,4 @@
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field, model_validator
from datetime import datetime from datetime import datetime
@ -11,7 +11,19 @@ class TaskCommentCreate(BaseModel):
class TaskCommentResponse(BaseModel): class TaskCommentResponse(BaseModel):
id: int id: int
task_id: int task_id: int
user_id: int | None = None
author_name: str | None = None
content: str content: str
created_at: datetime created_at: datetime
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@model_validator(mode="before")
@classmethod
def resolve_author_name(cls, data): # type: ignore[override]
"""Populate author_name from eagerly loaded user relationship."""
if hasattr(data, "user") and data.user is not None and not getattr(data, "author_name", None):
cols = {c.key: getattr(data, c.key) for c in data.__table__.columns}
cols["author_name"] = data.user.username
return cols
return data

View File

@ -0,0 +1,269 @@
"""
Project sharing service permission checks, auto-membership, disconnect cascade.
All functions accept an AsyncSession and do NOT commit callers manage transactions.
"""
import logging
from datetime import datetime
from fastapi import HTTPException
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.project import Project
from app.models.project_member import ProjectMember
from app.models.project_task import ProjectTask
from app.models.project_task_assignment import ProjectTaskAssignment
from app.models.user_connection import UserConnection
logger = logging.getLogger(__name__)
PERMISSION_RANK = {"read_only": 1, "create_modify": 2}
# Fields task assignees (from assignment, not project membership) may edit
ASSIGNEE_ALLOWED_FIELDS = {"title", "description", "status", "priority", "due_date"}
async def get_project_permission(
db: AsyncSession, project_id: int, user_id: int
) -> str | None:
"""
Returns 'owner', 'create_modify', 'read_only', or None.
Single query with LEFT JOIN (mirrors calendar_sharing pattern).
"""
result = await db.execute(
select(
Project.user_id,
ProjectMember.permission,
)
.outerjoin(
ProjectMember,
(ProjectMember.project_id == Project.id)
& (ProjectMember.user_id == user_id)
& (ProjectMember.status == "accepted"),
)
.where(Project.id == project_id)
)
row = result.one_or_none()
if not row:
return None
owner_id, member_permission = row.tuple()
if owner_id == user_id:
return "owner"
return member_permission
async def require_project_permission(
db: AsyncSession, project_id: int, user_id: int, min_level: str
) -> str:
"""
Raises 404 if project doesn't exist or user has no access.
Raises 403 if user has insufficient permission.
Returns the actual permission string (or 'owner').
"""
perm = await get_project_permission(db, project_id, user_id)
if perm is None:
raise HTTPException(status_code=404, detail="Project not found")
if perm == "owner":
return "owner"
if min_level == "owner":
raise HTTPException(status_code=403, detail="Only the project owner can perform this action")
if PERMISSION_RANK.get(perm, 0) < PERMISSION_RANK.get(min_level, 0):
raise HTTPException(status_code=403, detail="Insufficient permission on this project")
return perm
async def get_accessible_project_ids(db: AsyncSession, user_id: int) -> set[int]:
"""Returns owned + accepted membership project IDs."""
result = await db.execute(
select(Project.id).where(Project.user_id == user_id)
.union(
select(ProjectMember.project_id).where(
ProjectMember.user_id == user_id,
ProjectMember.status == "accepted",
)
)
)
return {r[0] for r in result.all()}
async def validate_project_connections(
db: AsyncSession, owner_id: int, user_ids: list[int]
) -> None:
"""Validates all target users are active connections of the owner. Raises 400 on failure."""
if not user_ids:
return
result = await db.execute(
select(UserConnection.connected_user_id).where(
UserConnection.user_id == owner_id,
UserConnection.connected_user_id.in_(user_ids),
)
)
connected = {r[0] for r in result.all()}
missing = set(user_ids) - connected
if missing:
raise HTTPException(
status_code=400,
detail=f"Users {sorted(missing)} are not your connections",
)
async def get_effective_task_permission(
db: AsyncSession, user_id: int, task_id: int, project_id: int
) -> tuple[str | None, str | None]:
"""
Returns (effective_permission, project_level_permission) for a specific task.
1. Get project-level permission (owner/create_modify/read_only)
2. If user is assigned to THIS task max(project_perm, create_modify)
3. If task has parent and user assigned to PARENT same as above
4. Return (effective, project_level)
"""
project_perm = await get_project_permission(db, project_id, user_id)
if project_perm is None:
return None, None
if project_perm == "owner":
return "owner", "owner"
# Check direct assignment on this task
task_result = await db.execute(
select(ProjectTask.parent_task_id).where(ProjectTask.id == task_id)
)
task_row = task_result.one_or_none()
if not task_row:
return project_perm, project_perm
parent_task_id = task_row[0]
# Check assignment on this task or its parent
check_task_ids = [task_id]
if parent_task_id is not None:
check_task_ids.append(parent_task_id)
assignment_result = await db.execute(
select(ProjectTaskAssignment.id).where(
ProjectTaskAssignment.task_id.in_(check_task_ids),
ProjectTaskAssignment.user_id == user_id,
).limit(1)
)
if assignment_result.scalar_one_or_none() is not None:
# Assignment grants at least create_modify
if PERMISSION_RANK.get(project_perm, 0) >= PERMISSION_RANK["create_modify"]:
return project_perm, project_perm
return "create_modify", project_perm
return project_perm, project_perm
async def ensure_auto_membership(
db: AsyncSession, project_id: int, user_id: int, invited_by: int
) -> None:
"""
When assigning a user to a task, ensure they have a ProjectMember row.
If none exists, create one with read_only + auto_assigned + accepted (no invite flow).
"""
existing = await db.execute(
select(ProjectMember.id).where(
ProjectMember.project_id == project_id,
ProjectMember.user_id == user_id,
)
)
if existing.scalar_one_or_none() is not None:
return
member = ProjectMember(
project_id=project_id,
user_id=user_id,
invited_by=invited_by,
permission="read_only",
status="accepted",
source="auto_assigned",
accepted_at=datetime.now(),
)
db.add(member)
async def cleanup_auto_membership(
db: AsyncSession, project_id: int, user_id: int
) -> None:
"""
After removing a task assignment, check if user has any remaining assignments
in this project. If not and membership is auto_assigned, remove it.
"""
remaining = await db.execute(
select(ProjectTaskAssignment.id)
.join(ProjectTask, ProjectTaskAssignment.task_id == ProjectTask.id)
.where(
ProjectTask.project_id == project_id,
ProjectTaskAssignment.user_id == user_id,
)
.limit(1)
)
if remaining.scalar_one_or_none() is not None:
return # Still has assignments
# Remove auto_assigned membership only
await db.execute(
delete(ProjectMember).where(
ProjectMember.project_id == project_id,
ProjectMember.user_id == user_id,
ProjectMember.source == "auto_assigned",
)
)
async def cascade_projects_on_disconnect(
db: AsyncSession, user_a_id: int, user_b_id: int
) -> None:
"""
When a connection is severed:
1. Find all ProjectMember rows where one user is a member of the other's projects
2. Find all ProjectTaskAssignment rows for those memberships
3. Remove assignments, then remove memberships
"""
# Single query: find projects owned by each user
result = await db.execute(
select(Project.id, Project.user_id).where(
Project.user_id.in_([user_a_id, user_b_id])
)
)
a_proj_ids: list[int] = []
b_proj_ids: list[int] = []
for proj_id, owner_id in result.all():
if owner_id == user_a_id:
a_proj_ids.append(proj_id)
else:
b_proj_ids.append(proj_id)
# Remove user_b's assignments + memberships on user_a's projects
if a_proj_ids:
await db.execute(
delete(ProjectTaskAssignment).where(
ProjectTaskAssignment.user_id == user_b_id,
ProjectTaskAssignment.task_id.in_(
select(ProjectTask.id).where(ProjectTask.project_id.in_(a_proj_ids))
),
)
)
await db.execute(
delete(ProjectMember).where(
ProjectMember.project_id.in_(a_proj_ids),
ProjectMember.user_id == user_b_id,
)
)
# Remove user_a's assignments + memberships on user_b's projects
if b_proj_ids:
await db.execute(
delete(ProjectTaskAssignment).where(
ProjectTaskAssignment.user_id == user_a_id,
ProjectTaskAssignment.task_id.in_(
select(ProjectTask.id).where(ProjectTask.project_id.in_(b_proj_ids))
),
)
)
await db.execute(
delete(ProjectMember).where(
ProjectMember.project_id.in_(b_proj_ids),
ProjectMember.user_id == user_a_id,
)
)

View File

@ -1,6 +1,7 @@
import { useEffect, useRef, useCallback } from 'react'; import { useEffect, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Check, X, Bell, UserPlus, Calendar, Clock } from 'lucide-react'; import { Check, X, Bell, UserPlus, Calendar, Clock, FolderKanban, ClipboardList } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { useNotifications } from '@/hooks/useNotifications'; import { useNotifications } from '@/hooks/useNotifications';
import { useConnections } from '@/hooks/useConnections'; import { useConnections } from '@/hooks/useConnections';
@ -26,6 +27,9 @@ export default function NotificationToaster() {
respondRef.current = respond; respondRef.current = respond;
const markReadRef = useRef(markRead); const markReadRef = useRef(markRead);
markReadRef.current = markRead; markReadRef.current = markRead;
const navigate = useNavigate();
const navigateRef = useRef(navigate);
navigateRef.current = navigate;
const handleConnectionRespond = useCallback( const handleConnectionRespond = useCallback(
async (requestId: number, action: 'accept' | 'reject', toastId: string | number, notificationId: number) => { async (requestId: number, action: 'accept' | 'reject', toastId: string | number, notificationId: number) => {
@ -123,6 +127,38 @@ export default function NotificationToaster() {
[], [],
); );
const handleProjectInviteRespond = useCallback(
async (projectId: number, action: 'accepted' | 'rejected', toastId: string | number, notificationId: number) => {
const key = `proj-${projectId}`;
if (respondingRef.current.has(key)) return;
respondingRef.current.add(key);
toast.dismiss(toastId);
const loadingId = toast.loading(
action === 'accepted' ? 'Accepting project invite\u2026' : 'Declining invite\u2026',
);
try {
await api.post(`/projects/memberships/${projectId}/respond`, { response: action });
toast.dismiss(loadingId);
toast.success(action === 'accepted' ? 'Project invite accepted' : 'Project invite declined');
markReadRef.current([notificationId]).catch(() => {});
queryClient.invalidateQueries({ queryKey: ['projects'] });
} catch (err) {
toast.dismiss(loadingId);
if (axios.isAxiosError(err) && err.response?.status === 409) {
toast.success('Already responded');
markReadRef.current([notificationId]).catch(() => {});
} else {
toast.error(getErrorMessage(err, 'Failed to respond to project invite'));
}
} finally {
respondingRef.current.delete(key);
}
},
[],
);
// Track unread count changes to force-refetch the list // Track unread count changes to force-refetch the list
useEffect(() => { useEffect(() => {
if (unreadCount > prevUnreadRef.current && initializedRef.current) { if (unreadCount > prevUnreadRef.current && initializedRef.current) {
@ -141,7 +177,7 @@ export default function NotificationToaster() {
initializedRef.current = true; initializedRef.current = true;
// Toast actionable unread notifications on login so the user can act immediately // Toast actionable unread notifications on login so the user can act immediately
const actionableTypes = new Set(['connection_request', 'calendar_invite', 'event_invite']); const actionableTypes = new Set(['connection_request', 'calendar_invite', 'event_invite', 'project_invite', 'task_assigned']);
const actionable = notifications.filter( const actionable = notifications.filter(
(n) => !n.is_read && actionableTypes.has(n.type), (n) => !n.is_read && actionableTypes.has(n.type),
); );
@ -155,6 +191,10 @@ export default function NotificationToaster() {
showCalendarInviteToast(notification); showCalendarInviteToast(notification);
} else if (notification.type === 'event_invite' && notification.data) { } else if (notification.type === 'event_invite' && notification.data) {
showEventInviteToast(notification); showEventInviteToast(notification);
} else if (notification.type === 'project_invite' && notification.data) {
showProjectInviteToast(notification);
} else if (notification.type === 'task_assigned' && notification.data) {
showTaskAssignedToast(notification);
} }
}); });
return; return;
@ -183,6 +223,9 @@ export default function NotificationToaster() {
queryClient.invalidateQueries({ queryKey: ['calendar-events'] }); queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
queryClient.invalidateQueries({ queryKey: ['event-invitations'] }); 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 // Show toasts
newNotifications.forEach((notification) => { newNotifications.forEach((notification) => {
@ -192,6 +235,10 @@ export default function NotificationToaster() {
showCalendarInviteToast(notification); showCalendarInviteToast(notification);
} else if (notification.type === 'event_invite' && notification.data) { } else if (notification.type === 'event_invite' && notification.data) {
showEventInviteToast(notification); showEventInviteToast(notification);
} else if (notification.type === 'project_invite' && notification.data) {
showProjectInviteToast(notification);
} else if (notification.type === 'task_assigned' && notification.data) {
showTaskAssignedToast(notification);
} else { } else {
toast(notification.title || 'New Notification', { toast(notification.title || 'New Notification', {
description: notification.message || undefined, description: notification.message || undefined,
@ -200,7 +247,7 @@ export default function NotificationToaster() {
}); });
} }
}); });
}, [notifications, handleConnectionRespond, handleCalendarInviteRespond, handleEventInviteRespond]); }, [notifications, handleConnectionRespond, handleCalendarInviteRespond, handleEventInviteRespond, handleProjectInviteRespond]);
const showConnectionRequestToast = (notification: AppNotification) => { const showConnectionRequestToast = (notification: AppNotification) => {
const requestId = notification.source_id!; const requestId = notification.source_id!;
@ -360,5 +407,96 @@ export default function NotificationToaster() {
); );
}; };
const showTaskAssignedToast = (notification: AppNotification) => {
const data = notification.data as Record<string, unknown> | undefined;
const projectId = data?.project_id as number | undefined;
const toastKey = `task-assigned-${notification.id}`;
toast.custom(
(id) => (
<div className="w-[356px] rounded-lg border border-border bg-card p-4 shadow-lg">
<div className="flex items-start gap-3">
<div className="h-9 w-9 rounded-full bg-blue-500/15 flex items-center justify-center shrink-0">
<ClipboardList className="h-4 w-4 text-blue-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground">Task Assigned</p>
<p className="text-xs text-muted-foreground mt-0.5">
{notification.message || 'You were assigned to a task'}
</p>
<div className="flex items-center gap-2 mt-3">
{projectId && (
<button
onClick={() => {
toast.dismiss(id);
markReadRef.current([notification.id]).catch(() => {});
navigateRef.current(`/projects/${projectId}`);
}}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-md bg-accent text-accent-foreground hover:bg-accent/90 transition-colors"
>
View Project
</button>
)}
<button
onClick={() => {
toast.dismiss(id);
markReadRef.current([notification.id]).catch(() => {});
}}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-md text-muted-foreground hover:bg-card-elevated transition-colors"
>
Dismiss
</button>
</div>
</div>
</div>
</div>
),
{ id: toastKey, duration: 15000 },
);
};
const showProjectInviteToast = (notification: AppNotification) => {
const data = notification.data as Record<string, unknown> | undefined;
const projectId = data?.project_id as number | undefined;
if (!projectId) return;
const toastKey = `project-invite-${notification.id}`;
toast.custom(
(id) => (
<div className="w-[356px] rounded-lg border border-border bg-card p-4 shadow-lg">
<div className="flex items-start gap-3">
<div className="h-9 w-9 rounded-full bg-purple-500/15 flex items-center justify-center shrink-0">
<FolderKanban className="h-4 w-4 text-purple-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground">Project Invitation</p>
<p className="text-xs text-muted-foreground mt-0.5">
{notification.message || 'You were invited to collaborate on a project'}
</p>
<div className="flex items-center gap-2 mt-3">
<button
onClick={() => handleProjectInviteRespond(projectId, 'accepted', id, notification.id)}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-md bg-accent text-accent-foreground hover:bg-accent/90 transition-colors"
>
<Check className="h-3.5 w-3.5" />
Accept
</button>
<button
onClick={() => handleProjectInviteRespond(projectId, 'rejected', id, notification.id)}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-md text-muted-foreground hover:bg-card-elevated transition-colors"
>
<X className="h-3.5 w-3.5" />
Decline
</button>
</div>
</div>
</div>
</div>
),
{ id: toastKey, duration: 30000 },
);
};
return null; return null;
} }

View File

@ -1,7 +1,7 @@
import { useState, useMemo, useEffect } from 'react'; import { useState, useMemo, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query'; 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 { formatDistanceToNow } from 'date-fns';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useNotifications } from '@/hooks/useNotifications'; import { useNotifications } from '@/hooks/useNotifications';
@ -22,6 +22,9 @@ const typeIcons: Record<string, { icon: typeof Bell; color: string }> = {
calendar_invite_rejected: { icon: Calendar, color: 'text-muted-foreground' }, calendar_invite_rejected: { icon: Calendar, color: 'text-muted-foreground' },
event_invite: { icon: Calendar, color: 'text-purple-400' }, event_invite: { icon: Calendar, color: 'text-purple-400' },
event_invite_response: { icon: Calendar, color: 'text-green-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' }, info: { icon: Info, color: 'text-blue-400' },
warning: { icon: AlertCircle, color: 'text-amber-400' }, warning: { icon: AlertCircle, color: 'text-amber-400' },
}; };
@ -44,6 +47,7 @@ export default function NotificationsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [filter, setFilter] = useState<Filter>('all'); const [filter, setFilter] = useState<Filter>('all');
const [respondingEventInvite, setRespondingEventInvite] = useState<number | null>(null); const [respondingEventInvite, setRespondingEventInvite] = useState<number | null>(null);
const [respondingProjectInvite, setRespondingProjectInvite] = useState<number | null>(null);
// Build a set of pending connection request IDs for quick lookup // Build a set of pending connection request IDs for quick lookup
const pendingInviteIds = useMemo( 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<string, unknown> | 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) => { const handleNotificationClick = async (notification: AppNotification) => {
// Don't navigate for pending connection requests — let user act inline // Don't navigate for pending connection requests — let user act inline
if ( if (
@ -207,6 +241,10 @@ export default function NotificationsPage() {
if (notification.type === 'event_invite' && !notification.is_read) { if (notification.type === 'event_invite' && !notification.is_read) {
return; 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) { if (!notification.is_read) {
await markRead([notification.id]).catch(() => {}); await markRead([notification.id]).catch(() => {});
} }
@ -218,6 +256,11 @@ export default function NotificationsPage() {
if (notification.type === 'event_invite' || notification.type === 'event_invite_response') { if (notification.type === 'event_invite' || notification.type === 'event_invite_response') {
navigate('/calendar'); 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<string, unknown>)?.project_id;
if (projectId) navigate(`/projects/${projectId}`);
}
}; };
return ( return (
@ -408,6 +451,31 @@ export default function NotificationsPage() {
</div> </div>
)} )}
{/* Project invite actions (inline) */}
{notification.type === 'project_invite' &&
!notification.is_read && (
<div className="flex items-center gap-1.5 shrink-0">
<Button
size="sm"
onClick={(e) => { e.stopPropagation(); handleProjectInviteRespond(notification, 'accepted'); }}
disabled={respondingProjectInvite === notification.id}
className="gap-1 h-7 text-xs"
>
{respondingProjectInvite === notification.id ? <Loader2 className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
Accept
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => { e.stopPropagation(); handleProjectInviteRespond(notification, 'rejected'); }}
disabled={respondingProjectInvite === notification.id}
className="h-7 text-xs"
>
<X className="h-3 w-3" />
</Button>
</div>
)}
{/* Timestamp + actions */} {/* Timestamp + actions */}
<div className="flex items-center gap-1.5 shrink-0"> <div className="flex items-center gap-1.5 shrink-0">
<span className="text-[11px] text-muted-foreground tabular-nums"> <span className="text-[11px] text-muted-foreground tabular-nums">

View File

@ -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 (
<span
className={cn(
"w-6 h-6 rounded-full bg-muted flex items-center justify-center shrink-0",
className
)}
aria-hidden="true"
>
<span className="text-[10px] font-medium text-muted-foreground leading-none">
{initials(name)}
</span>
</span>
);
}
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 (
<span
className="flex items-center"
title={allNames}
aria-label={`Assigned to: ${allNames}`}
>
{visible.map((a) => (
<span
key={a.user_id}
className="w-5 h-5 -ml-1.5 first:ml-0 rounded-full bg-muted border border-background flex items-center justify-center shrink-0"
>
<span className="text-[9px] font-medium text-muted-foreground leading-none">
{initials(a.user_name)}
</span>
</span>
))}
{overflow > 0 && (
<span className="w-5 h-5 -ml-1.5 rounded-full bg-muted border border-background flex items-center justify-center shrink-0">
<span className="text-[9px] font-medium text-muted-foreground leading-none">
+{overflow}
</span>
</span>
)}
</span>
);
}
// ---------------------------------------------------------------------------
// 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<HTMLDivElement>(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 (
<div ref={containerRef} className="relative">
{/* Trigger area */}
<div
role="button"
tabIndex={disabled ? -1 : 0}
aria-haspopup="listbox"
aria-expanded={open}
aria-disabled={disabled}
onClick={handleTriggerClick}
onKeyDown={handleTriggerKeyDown}
className={cn(
"flex flex-wrap items-center gap-1 min-h-[28px] px-1.5 py-1 rounded-md",
"border border-transparent transition-colors",
disabled
? "opacity-50 cursor-not-allowed"
: "cursor-pointer hover:border-border hover:bg-muted/30 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
)}
>
{hasAssignees ? (
<>
{currentAssignments.map((a) => (
<span
key={a.user_id}
className="flex items-center gap-1 bg-secondary rounded-full pl-1 pr-1.5 py-0.5"
>
<AvatarCircle name={a.user_name} />
<span className="text-xs text-secondary-foreground leading-none max-w-[80px] truncate">
{a.user_id === currentUserId ? "Me" : (a.user_name ?? "Unknown")}
</span>
{!disabled && (
<button
type="button"
aria-label={`Remove ${a.user_name ?? "user"}`}
onClick={(e) => handleRemove(e, a.user_id)}
className="text-muted-foreground hover:text-foreground transition-colors ml-0.5 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring rounded-full"
>
<X className="w-3 h-3" />
</button>
)}
</span>
))}
{!disabled && availableMembers.length > 0 && (
<ChevronDown
className={cn(
"w-3.5 h-3.5 text-muted-foreground/60 transition-transform ml-0.5 shrink-0",
open && "rotate-180"
)}
aria-hidden="true"
/>
)}
</>
) : (
<span className="flex items-center gap-1.5 text-muted-foreground/50 text-xs select-none">
<UserCircle className="w-4 h-4 shrink-0" aria-hidden="true" />
Assign...
<ChevronDown
className={cn(
"w-3.5 h-3.5 transition-transform",
open && "rotate-180"
)}
aria-hidden="true"
/>
</span>
)}
</div>
{/* Dropdown */}
{open && availableMembers.length > 0 && (
<ul
role="listbox"
aria-label="Available members"
className="border border-border bg-card shadow-lg rounded-lg absolute z-50 top-full left-0 mt-1 min-w-[180px] max-w-[240px] py-1 overflow-hidden"
>
{availableMembers.map((m) => {
const badge = roleBadge(m.user_id, ownerId, m.permission);
const displayName =
m.user_id === currentUserId
? "Me"
: (m.user_name ?? "Unknown");
return (
<li key={m.user_id}>
<button
type="button"
role="option"
aria-selected={false}
onClick={() => handleSelect(m.user_id)}
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-muted/50 transition-colors text-left focus-visible:outline-none focus-visible:bg-muted/50"
>
<AvatarCircle name={m.user_name} />
<span className="flex-1 truncate text-foreground">
{displayName}
</span>
<span className={cn("text-[11px] shrink-0", badge.className)}>
{badge.label}
</span>
</button>
</li>
);
})}
</ul>
)}
{/* Empty state when all members assigned */}
{open && availableMembers.length === 0 && (
<div className="border border-border bg-card shadow-lg rounded-lg absolute z-50 top-full left-0 mt-1 min-w-[180px] py-2 px-3">
<p className="text-xs text-muted-foreground">All members assigned</p>
</div>
)}
</div>
);
}

View File

@ -1,17 +1,21 @@
import { useState, useCallback } from 'react';
import { import {
DndContext, DndContext,
closestCorners, closestCenter,
PointerSensor, PointerSensor,
TouchSensor, TouchSensor,
useSensor, useSensor,
useSensors, useSensors,
type DragStartEvent,
type DragEndEvent, type DragEndEvent,
useDroppable, useDroppable,
useDraggable, useDraggable,
DragOverlay,
} from '@dnd-kit/core'; } from '@dnd-kit/core';
import { format, parseISO } from 'date-fns'; import { format, parseISO } from 'date-fns';
import type { ProjectTask } from '@/types'; import type { ProjectTask } from '@/types';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { AssigneeAvatars } from './AssignmentPicker';
const COLUMNS: { id: string; label: string; color: string }[] = [ const COLUMNS: { id: string; label: string; color: string }[] = [
{ id: 'pending', label: 'Pending', color: 'text-gray-400' }, { id: 'pending', label: 'Pending', color: 'text-gray-400' },
@ -42,11 +46,13 @@ function KanbanColumn({
column, column,
tasks, tasks,
selectedTaskId, selectedTaskId,
draggingId,
onSelectTask, onSelectTask,
}: { }: {
column: (typeof COLUMNS)[0]; column: (typeof COLUMNS)[0];
tasks: ProjectTask[]; tasks: ProjectTask[];
selectedTaskId: number | null; selectedTaskId: number | null;
draggingId: number | null;
onSelectTask: (taskId: number) => void; onSelectTask: (taskId: number) => void;
}) { }) {
const { setNodeRef, isOver } = useDroppable({ id: column.id }); const { setNodeRef, isOver } = useDroppable({ id: column.id });
@ -77,6 +83,7 @@ function KanbanColumn({
key={task.id} key={task.id}
task={task} task={task}
isSelected={selectedTaskId === task.id} isSelected={selectedTaskId === task.id}
isDragSource={draggingId === task.id}
onSelect={() => onSelectTask(task.id)} onSelect={() => onSelectTask(task.id)}
/> />
))} ))}
@ -85,41 +92,19 @@ function KanbanColumn({
); );
} }
function KanbanCard({ // Card content — shared between in-place card and drag overlay
task, function CardContent({ task, isSelected, ghost }: { task: ProjectTask; isSelected: boolean; ghost?: boolean }) {
isSelected,
onSelect,
}: {
task: ProjectTask;
isSelected: boolean;
onSelect: () => void;
}) {
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
id: task.id,
data: { task },
});
const style = transform
? {
transform: `translate(${transform.x}px, ${transform.y}px)`,
opacity: isDragging ? 0.5 : 1,
}
: undefined;
const completedSubtasks = task.subtasks?.filter((s) => s.status === 'completed').length ?? 0; const completedSubtasks = task.subtasks?.filter((s) => s.status === 'completed').length ?? 0;
const totalSubtasks = task.subtasks?.length ?? 0; const totalSubtasks = task.subtasks?.length ?? 0;
return ( return (
<div <div
ref={setNodeRef} className={`rounded-md border p-3 ${
style={style} ghost
{...listeners} ? 'border-accent/20 bg-accent/5 opacity-40'
{...attributes} : isSelected
onClick={onSelect} ? 'border-accent/40 bg-accent/5 shadow-sm shadow-accent/10'
className={`rounded-md border p-3 cursor-pointer transition-all duration-150 ${ : 'border-border bg-card hover:bg-card-elevated hover:border-accent/20'
isSelected
? 'border-accent/40 bg-accent/5 shadow-sm shadow-accent/10'
: 'border-border bg-card hover:bg-card-elevated hover:border-accent/20'
}`} }`}
> >
<p className="text-sm font-medium leading-tight mb-2">{task.title}</p> <p className="text-sm font-medium leading-tight mb-2">{task.title}</p>
@ -140,6 +125,40 @@ function KanbanCard({
</span> </span>
)} )}
</div> </div>
{task.assignments && task.assignments.length > 0 && (
<div className="flex justify-end mt-2">
<AssigneeAvatars assignments={task.assignments} max={2} />
</div>
)}
</div>
);
}
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 (
<div
ref={setNodeRef}
{...listeners}
{...attributes}
onClick={onSelect}
className="cursor-pointer"
>
<CardContent task={task} isSelected={isSelected} ghost={isDragSource} />
</div> </div>
); );
} }
@ -152,16 +171,24 @@ export default function KanbanBoard({
onStatusChange, onStatusChange,
onBackToAllTasks, onBackToAllTasks,
}: KanbanBoardProps) { }: KanbanBoardProps) {
const [draggingId, setDraggingId] = useState<number | null>(null);
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }) , useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 8 } }) useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 8 } })
); );
// Subtask view is driven by kanbanParentTask (decoupled from selected task)
const isSubtaskView = kanbanParentTask != null && (kanbanParentTask.subtasks?.length ?? 0) > 0; const isSubtaskView = kanbanParentTask != null && (kanbanParentTask.subtasks?.length ?? 0) > 0;
const activeTasks: ProjectTask[] = isSubtaskView ? (kanbanParentTask.subtasks ?? []) : tasks; const activeTasks: ProjectTask[] = isSubtaskView ? (kanbanParentTask.subtasks ?? []) : tasks;
const handleDragEnd = (event: DragEndEvent) => { 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; const { active, over } = event;
if (!over) return; if (!over) return;
@ -172,7 +199,11 @@ export default function KanbanBoard({
if (task && task.status !== newStatus && COLUMNS.some((c) => c.id === newStatus)) { if (task && task.status !== newStatus && COLUMNS.some((c) => c.id === newStatus)) {
onStatusChange(taskId, newStatus); onStatusChange(taskId, newStatus);
} }
}; }, [activeTasks, onStatusChange]);
const handleDragCancel = useCallback(() => {
setDraggingId(null);
}, []);
const tasksByStatus = COLUMNS.map((col) => ({ const tasksByStatus = COLUMNS.map((col) => ({
column: col, column: col,
@ -199,8 +230,10 @@ export default function KanbanBoard({
<DndContext <DndContext
sensors={sensors} sensors={sensors}
collisionDetection={closestCorners} collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
> >
<div className="flex gap-3 overflow-x-auto pb-2"> <div className="flex gap-3 overflow-x-auto pb-2">
{tasksByStatus.map(({ column, tasks: colTasks }) => ( {tasksByStatus.map(({ column, tasks: colTasks }) => (
@ -209,10 +242,20 @@ export default function KanbanBoard({
column={column} column={column}
tasks={colTasks} tasks={colTasks}
selectedTaskId={selectedTaskId} selectedTaskId={selectedTaskId}
draggingId={draggingId}
onSelectTask={onSelectTask} onSelectTask={onSelectTask}
/> />
))} ))}
</div> </div>
{/* Floating overlay — renders above everything, no layout impact */}
<DragOverlay dropAnimation={null}>
{draggingTask ? (
<div className="w-[200px] opacity-95 shadow-lg shadow-black/30 rotate-[2deg]">
<CardContent task={draggingTask} isSelected={false} />
</div>
) : null}
</DragOverlay>
</DndContext> </DndContext>
</div> </div>
); );

View File

@ -2,9 +2,10 @@ import { useNavigate } from 'react-router-dom';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { format, isPast, parseISO } from 'date-fns'; 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 api from '@/lib/api';
import type { Project } from '@/types'; import type { Project } from '@/types';
import { useSettings } from '@/hooks/useSettings';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { statusColors, statusLabels } from './constants'; import { statusColors, statusLabels } from './constants';
@ -17,6 +18,8 @@ interface ProjectCardProps {
export default function ProjectCard({ project }: ProjectCardProps) { export default function ProjectCard({ project }: ProjectCardProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { settings } = useSettings();
const isShared = project.user_id !== (settings?.user_id ?? 0);
const toggleTrackMutation = useMutation({ const toggleTrackMutation = useMutation({
mutationFn: async () => { 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" className="cursor-pointer hover:shadow-lg hover:shadow-accent/5 hover:border-accent/20 transition-all duration-200 relative"
onClick={() => navigate(`/projects/${project.id}`)} onClick={() => navigate(`/projects/${project.id}`)}
> >
<button {!isShared && (
onClick={(e) => { <button
e.stopPropagation(); onClick={(e) => {
toggleTrackMutation.mutate(); e.stopPropagation();
}} toggleTrackMutation.mutate();
className={`absolute top-3 right-3 p-1 rounded-md transition-colors z-10 ${ }}
project.is_tracked className={`absolute top-3 right-3 p-1 rounded-md transition-colors z-10 ${
? 'text-accent hover:bg-accent/10' project.is_tracked
: 'text-muted-foreground/40 hover:text-muted-foreground hover:bg-card-elevated' ? 'text-accent hover:bg-accent/10'
}`} : 'text-muted-foreground/40 hover:text-muted-foreground hover:bg-card-elevated'
title={project.is_tracked ? 'Untrack project' : 'Track project'} }`}
> title={project.is_tracked ? 'Untrack project' : 'Track project'}
<Pin className={`h-3.5 w-3.5 ${project.is_tracked ? 'fill-current' : ''}`} /> >
</button> <Pin className={`h-3.5 w-3.5 ${project.is_tracked ? 'fill-current' : ''}`} />
</button>
)}
<CardHeader> <CardHeader>
<div className="flex items-start justify-between gap-2 pr-6"> <div className="flex items-start justify-between gap-2 pr-6">
<CardTitle className="font-heading text-lg font-semibold">{project.name}</CardTitle> <CardTitle className="font-heading text-lg font-semibold">{project.name}</CardTitle>
@ -95,6 +100,17 @@ export default function ProjectCard({ project }: ProjectCardProps) {
Due {format(parseISO(project.due_date), 'MMM d, yyyy')} Due {format(parseISO(project.due_date), 'MMM d, yyyy')}
</div> </div>
)} )}
{/* Sharing indicator — shows for both owner and shared users */}
{project.member_count > 0 && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mt-2">
<Users className="h-3.5 w-3.5" />
<span>
{isShared
? 'Shared with you'
: `${project.member_count} member${project.member_count !== 1 ? 's' : ''}`}
</span>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
); );

View File

@ -23,21 +23,25 @@ import { CSS } from '@dnd-kit/utilities';
import { import {
ArrowLeft, Plus, Trash2, ListChecks, Pencil, Pin, ArrowLeft, Plus, Trash2, ListChecks, Pencil, Pin,
Calendar, CheckCircle2, PlayCircle, AlertTriangle, Calendar, CheckCircle2, PlayCircle, AlertTriangle,
List, Columns3, ArrowUpDown, List, Columns3, ArrowUpDown, Users, Eye,
} from 'lucide-react'; } from 'lucide-react';
import axios from 'axios';
import api from '@/lib/api'; 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 { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Select } from '@/components/ui/select'; import { Select } from '@/components/ui/select';
import { ListSkeleton } from '@/components/ui/skeleton'; import { ListSkeleton } from '@/components/ui/skeleton';
import { EmptyState } from '@/components/ui/empty-state'; import { EmptyState } from '@/components/ui/empty-state';
import { useSettings } from '@/hooks/useSettings';
import { useDeltaPoll } from '@/hooks/useDeltaPoll';
import TaskRow from './TaskRow'; import TaskRow from './TaskRow';
import TaskDetailPanel from './TaskDetailPanel'; import TaskDetailPanel from './TaskDetailPanel';
import KanbanBoard from './KanbanBoard'; import KanbanBoard from './KanbanBoard';
import TaskForm from './TaskForm'; import TaskForm from './TaskForm';
import ProjectForm from './ProjectForm'; import ProjectForm from './ProjectForm';
import { ProjectShareSheet } from './ProjectShareSheet';
import { statusColors, statusLabels } from './constants'; import { statusColors, statusLabels } from './constants';
import MobileDetailOverlay from '@/components/shared/MobileDetailOverlay'; import MobileDetailOverlay from '@/components/shared/MobileDetailOverlay';
@ -111,6 +115,9 @@ export default function ProjectDetail() {
const [kanbanParentTaskId, setKanbanParentTaskId] = useState<number | null>(null); const [kanbanParentTaskId, setKanbanParentTaskId] = useState<number | null>(null);
const [sortMode, setSortMode] = useState<SortMode>('manual'); const [sortMode, setSortMode] = useState<SortMode>('manual');
const [viewMode, setViewMode] = useState<ViewMode>('list'); const [viewMode, setViewMode] = useState<ViewMode>('list');
const [showShareSheet, setShowShareSheet] = useState(false);
const { settings } = useSettings();
const currentUserId = settings?.user_id ?? 0;
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
@ -134,17 +141,54 @@ export default function ProjectDetail() {
}, },
}); });
// Permission derivation
const isOwner = project ? project.user_id === currentUserId : true;
const isShared = project ? project.user_id !== currentUserId : false;
// For now, if they can see the project but don't own it, check if they can edit
// The backend enforces actual permissions — this is just UI gating
const canEdit = isOwner; // Members with create_modify can also edit tasks (handled per-task)
const canManageProject = isOwner;
// Delta polling for real-time sync — only for shared projects (P-04)
const pollKey = useMemo(() => ['projects', id], [id]);
useDeltaPoll(
id && project && project.member_count > 0 ? `/projects/${id}/poll` : null,
pollKey,
5000,
);
// Fetch members for shared projects
const { data: members = [] } = useQuery<ProjectMember[]>({
queryKey: ['project-members', id],
queryFn: async () => {
const { data } = await api.get(`/projects/${id}/members`);
return data;
},
enabled: !!id,
});
const acceptedMembers = useMemo(() => members.filter((m) => m.status === 'accepted'), [members]);
const myPermission = useMemo(
() => acceptedMembers.find((m) => m.user_id === currentUserId)?.permission ?? null,
[acceptedMembers, currentUserId]
);
const canEditTasks = isOwner || myPermission === 'create_modify';
const toggleTaskMutation = useMutation({ const toggleTaskMutation = useMutation({
mutationFn: async ({ taskId, status }: { taskId: number; status: string }) => { mutationFn: async ({ taskId, status, version }: { taskId: number; status: string; version: number }) => {
const newStatus = status === 'completed' ? 'pending' : 'completed'; const newStatus = status === 'completed' ? 'pending' : 'completed';
const { data } = await api.put(`/projects/${id}/tasks/${taskId}`, { status: newStatus }); const { data } = await api.put(`/projects/${id}/tasks/${taskId}`, { status: newStatus, version });
return data; return data;
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects', id] }); queryClient.invalidateQueries({ queryKey: ['projects', id] });
}, },
onError: () => { onError: (error) => {
toast.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', id] });
} else {
toast.error('Failed to update task');
}
}, },
}); });
@ -199,15 +243,20 @@ export default function ProjectDetail() {
}); });
const updateTaskStatusMutation = useMutation({ const updateTaskStatusMutation = useMutation({
mutationFn: async ({ taskId, status }: { taskId: number; status: string }) => { mutationFn: async ({ taskId, status, version }: { taskId: number; status: string; version: number }) => {
const { data } = await api.put(`/projects/${id}/tasks/${taskId}`, { status }); const { data } = await api.put(`/projects/${id}/tasks/${taskId}`, { status, version });
return data; return data;
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects', id] }); queryClient.invalidateQueries({ queryKey: ['projects', id] });
}, },
onError: () => { onError: (error) => {
toast.error('Failed to update task status'); if (axios.isAxiosError(error) && error.response?.status === 409) {
toast.error('Task was modified by another user — please refresh');
queryClient.invalidateQueries({ queryKey: ['projects', id] });
} else {
toast.error('Failed to update task status');
}
}, },
}); });
@ -389,31 +438,57 @@ export default function ProjectDetail() {
<Badge className={`shrink-0 hidden sm:inline-flex ${statusColors[project.status]}`}> <Badge className={`shrink-0 hidden sm:inline-flex ${statusColors[project.status]}`}>
{statusLabels[project.status]} {statusLabels[project.status]}
</Badge> </Badge>
{/* Permission badge for non-owners */}
{isShared && (
<Badge className="shrink-0 bg-blue-500/10 text-blue-400 border-0">
{myPermission === 'create_modify' ? 'Editor' : 'Viewer'}
</Badge>
)}
{canManageProject && (
<Button
variant="ghost"
size="icon"
onClick={() => toggleTrackMutation.mutate()}
disabled={toggleTrackMutation.isPending}
className={`shrink-0 ${project.is_tracked ? 'text-accent' : 'text-muted-foreground'}`}
title={project.is_tracked ? 'Untrack project' : 'Track project'}
>
<Pin className={`h-4 w-4 ${project.is_tracked ? 'fill-current' : ''}`} />
</Button>
)}
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => toggleTrackMutation.mutate()} className="shrink-0 text-muted-foreground relative"
disabled={toggleTrackMutation.isPending} onClick={() => setShowShareSheet(true)}
className={`shrink-0 ${project.is_tracked ? 'text-accent' : 'text-muted-foreground'}`} title="Project members"
title={project.is_tracked ? 'Untrack project' : 'Track project'}
> >
<Pin className={`h-4 w-4 ${project.is_tracked ? 'fill-current' : ''}`} /> <Users className="h-4 w-4" />
</Button> {acceptedMembers.length > 0 && (
<Button variant="outline" size="sm" className="shrink-0" onClick={() => setShowProjectForm(true)}> <span className="absolute -top-0.5 -right-0.5 w-4 h-4 rounded-full bg-accent text-[9px] font-bold flex items-center justify-center text-background">
<Pencil className="h-3.5 w-3.5 md:mr-2" /><span className="hidden md:inline">Edit</span> {acceptedMembers.length}
</Button> </span>
<Button )}
variant="ghost"
size="sm"
className="shrink-0 text-destructive hover:bg-destructive/10"
onClick={() => {
if (!window.confirm('Delete this project and all its tasks?')) return;
deleteProjectMutation.mutate();
}}
disabled={deleteProjectMutation.isPending}
>
<Trash2 className="h-3.5 w-3.5 md:mr-2" /><span className="hidden md:inline">Delete</span>
</Button> </Button>
{canManageProject && (
<Button variant="outline" size="sm" className="shrink-0" onClick={() => setShowProjectForm(true)}>
<Pencil className="h-3.5 w-3.5 md:mr-2" /><span className="hidden md:inline">Edit</span>
</Button>
)}
{canManageProject && (
<Button
variant="ghost"
size="sm"
className="shrink-0 text-destructive hover:bg-destructive/10"
onClick={() => {
if (!window.confirm('Delete this project and all its tasks?')) return;
deleteProjectMutation.mutate();
}}
disabled={deleteProjectMutation.isPending}
>
<Trash2 className="h-3.5 w-3.5 md:mr-2" /><span className="hidden md:inline">Delete</span>
</Button>
)}
</div> </div>
{/* Content area */} {/* Content area */}
@ -491,6 +566,14 @@ export default function ProjectDetail() {
</Card> </Card>
</div> </div>
{/* Read-only banner for viewers */}
{isShared && !canEdit && (
<div className="mx-4 md:mx-6 mb-3 px-3 py-2 rounded-md bg-secondary/50 border border-border flex items-center gap-2">
<Eye className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span className="text-xs text-muted-foreground">You have view-only access to this project</span>
</div>
)}
{/* Task list header + view controls */} {/* Task list header + view controls */}
<div className="px-4 md:px-6 pb-3 flex items-center justify-between flex-wrap gap-2 shrink-0"> <div className="px-4 md:px-6 pb-3 flex items-center justify-between flex-wrap gap-2 shrink-0">
<h2 className="font-heading text-lg font-semibold">Tasks</h2> <h2 className="font-heading text-lg font-semibold">Tasks</h2>
@ -535,10 +618,12 @@ export default function ProjectDetail() {
</div> </div>
)} )}
<Button size="sm" onClick={() => openTaskForm(null, null)}> {canEditTasks && (
<Plus className="mr-2 h-3.5 w-3.5" /> <Button size="sm" onClick={() => openTaskForm(null, null)}>
Add Task <Plus className="mr-2 h-3.5 w-3.5" />
</Button> Add Task
</Button>
)}
</div> </div>
</div> </div>
@ -561,9 +646,10 @@ export default function ProjectDetail() {
selectedTaskId={selectedTaskId} selectedTaskId={selectedTaskId}
kanbanParentTask={kanbanParentTask} kanbanParentTask={kanbanParentTask}
onSelectTask={handleKanbanSelectTask} onSelectTask={handleKanbanSelectTask}
onStatusChange={(taskId, status) => onStatusChange={(taskId, status) => {
updateTaskStatusMutation.mutate({ taskId, status }) const t = allTasks.find(tt => tt.id === taskId) ?? allTasks.flatMap(tt => tt.subtasks || []).find(st => st.id === taskId);
} updateTaskStatusMutation.mutate({ taskId, status, version: t?.version ?? 1 });
}}
onBackToAllTasks={handleBackToAllTasks} onBackToAllTasks={handleBackToAllTasks}
/> />
) : ( ) : (
@ -595,6 +681,7 @@ export default function ProjectDetail() {
toggleTaskMutation.mutate({ toggleTaskMutation.mutate({
taskId: task.id, taskId: task.id,
status: task.status, status: task.status,
version: task.version,
}) })
} }
togglePending={toggleTaskMutation.isPending} togglePending={toggleTaskMutation.isPending}
@ -641,6 +728,10 @@ export default function ProjectDetail() {
<TaskDetailPanel <TaskDetailPanel
task={selectedTask} task={selectedTask}
projectId={parseInt(id!)} projectId={parseInt(id!)}
members={acceptedMembers}
currentUserId={currentUserId}
ownerId={project?.user_id ?? 0}
canAssign={canEditTasks}
onDelete={handleDeleteTask} onDelete={handleDeleteTask}
onAddSubtask={(parentId) => openTaskForm(null, parentId)} onAddSubtask={(parentId) => openTaskForm(null, parentId)}
onClose={() => setSelectedTaskId(null)} onClose={() => setSelectedTaskId(null)}
@ -698,6 +789,14 @@ export default function ProjectDetail() {
onClose={() => setShowProjectForm(false)} onClose={() => setShowProjectForm(false)}
/> />
)} )}
<ProjectShareSheet
open={showShareSheet}
onOpenChange={setShowShareSheet}
projectId={parseInt(id!)}
isOwner={isOwner}
ownerName={settings?.preferred_name || 'Owner'}
/>
</div> </div>
); );
} }

View File

@ -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 (
<div className="w-7 h-7 rounded-full bg-muted flex items-center justify-center shrink-0">
<span className="text-xs font-medium text-muted-foreground">{letter}</span>
</div>
);
}
function PermissionBadge({ permission }: { permission: ProjectPermission }) {
if (permission === 'create_modify') {
return (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-blue-500/10 text-blue-400 flex items-center gap-1">
<Pencil className="h-2.5 w-2.5" />
Editor
</span>
);
}
return (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-gray-500/10 text-gray-400 flex items-center gap-1">
<Eye className="h-2.5 w-2.5" />
Viewer
</span>
);
}
export function ProjectShareSheet({ open, onOpenChange, projectId, isOwner, ownerName }: ProjectShareSheetProps) {
const queryClient = useQueryClient();
const { connections } = useConnections();
const [search, setSearch] = useState('');
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [invitePermission, setInvitePermission] = useState<ProjectPermission>('create_modify');
const membersQuery = useQuery<ProjectMember[]>({
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 (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent>
<SheetHeader>
<SheetTitle className="flex items-center gap-2">
<Users className="h-4 w-4 text-muted-foreground" />
Project Members
</SheetTitle>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-6 py-5 space-y-6">
{/* Current Members */}
<div className="space-y-2">
<p className="text-[11px] text-muted-foreground uppercase tracking-wider flex items-center gap-1.5">
<Users className="h-3 w-3" />
Members ({acceptedMembers.length + 1})
</p>
{/* Owner row — always first */}
<div className="flex items-center gap-2 py-1">
<AvatarCircle name={ownerName} />
<span className="text-sm flex-1 truncate">{ownerName}</span>
<span
className="px-1.5 py-0.5 text-[10px] font-medium rounded flex items-center gap-1"
style={{ color: 'hsl(var(--accent-color))', backgroundColor: 'hsl(var(--accent-color) / 0.1)' }}
>
<Crown className="h-2.5 w-2.5" />
Owner
</span>
</div>
{/* Accepted members */}
{acceptedMembers.map((member) => (
<div key={member.id} className="flex items-center gap-2 py-1">
<AvatarCircle name={member.user_name ?? 'Unknown'} />
<span className="text-sm flex-1 truncate">{member.user_name ?? 'Unknown'}</span>
{isOwner ? (
<select
value={member.permission}
onChange={(e) =>
updateMutation.mutate({
userId: member.user_id,
permission: e.target.value as ProjectPermission,
})
}
disabled={updateMutation.isPending}
className="text-[11px] bg-transparent border border-border rounded px-1.5 py-0.5 text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
>
<option value="read_only">Viewer</option>
<option value="create_modify">Editor</option>
</select>
) : (
<PermissionBadge permission={member.permission} />
)}
{isOwner && (
<button
type="button"
onClick={() => removeMutation.mutate(member.user_id)}
disabled={removeMutation.isPending}
title="Remove member"
className="p-1 rounded text-muted-foreground hover:text-red-400 hover:bg-red-500/10 transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
))}
{/* Pending invites */}
{pendingMembers.length > 0 && (
<>
<p className="text-[11px] text-muted-foreground uppercase tracking-wider mt-3">
Pending Invites ({pendingMembers.length})
</p>
{pendingMembers.map((member) => (
<div key={member.id} className="flex items-center gap-2 py-1 opacity-60">
<AvatarCircle name={member.user_name ?? 'Unknown'} />
<span className="text-sm flex-1 truncate">{member.user_name ?? 'Unknown'}</span>
<span className="text-[10px] text-muted-foreground">Pending</span>
{isOwner && (
<button
type="button"
onClick={() => removeMutation.mutate(member.user_id)}
disabled={removeMutation.isPending}
title="Cancel invite"
className="p-1 rounded text-muted-foreground hover:text-red-400 hover:bg-red-500/10 transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
))}
</>
)}
</div>
{/* Invite section (owner only) */}
{isOwner && (
<div className="space-y-2">
<p className="text-[11px] text-muted-foreground uppercase tracking-wider flex items-center gap-1.5">
<UserPlus className="h-3 w-3" />
Invite Connections
</p>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground shrink-0">Invite as</span>
<Select
value={invitePermission}
onChange={(e) => setInvitePermission(e.target.value as ProjectPermission)}
className="h-7 text-xs py-0"
>
<option value="create_modify">Editor</option>
<option value="read_only">Viewer</option>
</Select>
</div>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
onBlur={() => setSearch('')}
placeholder="Search connections..."
className="h-8 pl-8 text-xs"
/>
{search.trim() && searchResults.length > 0 && (
<div className="absolute z-50 mt-1 w-full rounded-lg border border-border bg-card shadow-lg overflow-hidden">
{searchResults.map((conn) => (
<button
key={conn.connected_user_id}
type="button"
onMouseDown={(e) => { e.preventDefault(); setSelectedIds(p => [...p, conn.connected_user_id]); setSearch(''); }}
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-left hover:bg-accent/10 transition-colors"
>
<AvatarCircle name={conn.connected_preferred_name || conn.connected_umbral_name} />
<div className="flex-1 min-w-0">
<span className="text-sm truncate block">
{conn.connected_preferred_name || conn.connected_umbral_name}
</span>
{conn.connected_preferred_name && (
<span className="text-[11px] text-muted-foreground">
@{conn.connected_umbral_name}
</span>
)}
</div>
<UserPlus className="h-3.5 w-3.5 text-muted-foreground" />
</button>
))}
</div>
)}
{search.trim() && searchResults.length === 0 && (
<div className="absolute z-50 mt-1 w-full rounded-lg border border-border bg-card shadow-lg p-3">
<p className="text-xs text-muted-foreground text-center">No connections found</p>
</div>
)}
</div>
{selectedConnections.length > 0 && (
<div className="space-y-1">
{selectedConnections.map((conn) => (
<div key={conn.connected_user_id} className="flex items-center gap-2 py-1">
<AvatarCircle name={conn.connected_preferred_name || conn.connected_umbral_name} />
<span className="text-sm flex-1 truncate">
{conn.connected_preferred_name || conn.connected_umbral_name}
</span>
<button
type="button"
onClick={() => setSelectedIds(p => p.filter(id => id !== conn.connected_user_id))}
className="p-0.5 rounded hover:bg-card-elevated text-muted-foreground"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
))}
<Button
size="sm"
onClick={() => inviteMutation.mutate({ user_ids: selectedIds, permission: invitePermission })}
disabled={inviteMutation.isPending}
className="w-full mt-1"
>
{inviteMutation.isPending
? 'Sending...'
: `Invite ${selectedIds.length === 1 ? '1 Person' : `${selectedIds.length} People`}`}
</Button>
</div>
)}
</div>
)}
</div>
</SheetContent>
</Sheet>
);
}

View File

@ -1,14 +1,16 @@
import { useState } from 'react'; import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { format, formatDistanceToNow, parseISO } from 'date-fns'; import { format, formatDistanceToNow, parseISO } from 'date-fns';
import { import {
Pencil, Trash2, Plus, MessageSquare, ClipboardList, Pencil, Trash2, Plus, MessageSquare, ClipboardList,
Calendar, User, Flag, Activity, Send, X, Save, Calendar, User, Flag, Activity, Send, X, Save,
} from 'lucide-react'; } from 'lucide-react';
import axios from 'axios';
import api, { getErrorMessage } from '@/lib/api'; import api, { getErrorMessage } from '@/lib/api';
import { formatUpdatedAt } from '@/components/shared/utils'; import { formatUpdatedAt } from '@/components/shared/utils';
import type { ProjectTask, TaskComment, Person } from '@/types'; import type { ProjectTask, TaskComment, ProjectMember } from '@/types';
import { AssignmentPicker } from './AssignmentPicker';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
@ -45,6 +47,10 @@ const priorityColors: Record<string, string> = {
interface TaskDetailPanelProps { interface TaskDetailPanelProps {
task: ProjectTask | null; task: ProjectTask | null;
projectId: number; projectId: number;
members?: ProjectMember[];
currentUserId?: number;
ownerId?: number;
canAssign?: boolean;
onDelete: (taskId: number) => void; onDelete: (taskId: number) => void;
onAddSubtask: (parentId: number) => void; onAddSubtask: (parentId: number) => void;
onClose?: () => void; onClose?: () => void;
@ -81,6 +87,10 @@ function buildEditState(task: ProjectTask): EditState {
export default function TaskDetailPanel({ export default function TaskDetailPanel({
task, task,
projectId, projectId,
members = [],
currentUserId = 0,
ownerId = 0,
canAssign = false,
onDelete, onDelete,
onAddSubtask, onAddSubtask,
onClose, onClose,
@ -93,20 +103,24 @@ export default function TaskDetailPanel({
task ? buildEditState(task) : { title: '', status: 'pending', priority: 'none', due_date: todayLocal(), person_id: '', description: '' } task ? buildEditState(task) : { title: '', status: 'pending', priority: 'none', due_date: todayLocal(), person_id: '', description: '' }
); );
const { data: people = [] } = useQuery({ // Build a combined members list that includes the owner for the AssignmentPicker
queryKey: ['people'], const allMembers: ProjectMember[] = [
queryFn: async () => { // Synthetic owner entry so they appear in the picker
const { data } = await api.get<Person[]>('/people'); ...(ownerId ? [{
return data; id: 0, project_id: projectId, user_id: ownerId, invited_by: ownerId,
}, permission: 'create_modify' as const, status: 'accepted' as const,
}); source: 'invited' as const, user_name: currentUserId === ownerId ? 'Me (Owner)' : 'Owner',
inviter_name: null, created_at: '', updated_at: '', accepted_at: null,
}] : []),
...members.filter(m => m.status === 'accepted'),
];
// --- Mutations --- // --- Mutations ---
const toggleSubtaskMutation = useMutation({ const toggleSubtaskMutation = useMutation({
mutationFn: async ({ taskId, status }: { taskId: number; status: string }) => { mutationFn: async ({ taskId, status, version }: { taskId: number; status: string; version: number }) => {
const newStatus = status === 'completed' ? 'pending' : 'completed'; const newStatus = status === 'completed' ? 'pending' : 'completed';
const { data } = await api.put(`/projects/${projectId}/tasks/${taskId}`, { status: newStatus }); const { data } = await api.put(`/projects/${projectId}/tasks/${taskId}`, { status: newStatus, version });
return data; return data;
}, },
onSuccess: () => { onSuccess: () => {
@ -125,7 +139,12 @@ export default function TaskDetailPanel({
toast.success('Task updated'); toast.success('Task updated');
}, },
onError: (error) => { onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to update task')); if (axios.isAxiosError(error) && error.response?.status === 409) {
toast.error('Task was modified by another user — please refresh');
queryClient.invalidateQueries({ queryKey: ['projects', projectId.toString()] });
} else {
toast.error(getErrorMessage(error, 'Failed to update task'));
}
}, },
}); });
@ -169,6 +188,30 @@ export default function TaskDetailPanel({
}, },
}); });
const assignMutation = useMutation({
mutationFn: async (userIds: number[]) => {
await api.post(`/projects/${projectId}/tasks/${task!.id}/assignments`, { user_ids: userIds });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects', projectId.toString()] });
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to assign'));
},
});
const unassignMutation = useMutation({
mutationFn: async (userId: number) => {
await api.delete(`/projects/${projectId}/tasks/${task!.id}/assignments/${userId}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects', projectId.toString()] });
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to unassign'));
},
});
// --- Handlers --- // --- Handlers ---
const handleAddComment = () => { const handleAddComment = () => {
@ -197,6 +240,7 @@ export default function TaskDetailPanel({
due_date: editState.due_date || null, due_date: editState.due_date || null,
person_id: editState.person_id ? Number(editState.person_id) : null, person_id: editState.person_id ? Number(editState.person_id) : null,
description: editState.description || null, description: editState.description || null,
version: task.version,
}; };
updateTaskMutation.mutate(payload); updateTaskMutation.mutate(payload);
}; };
@ -217,7 +261,6 @@ export default function TaskDetailPanel({
); );
} }
const assignedPerson = task.person_id ? people.find((p) => p.id === task.person_id) : null;
const comments = task.comments || []; const comments = task.comments || [];
return ( return (
@ -376,21 +419,34 @@ export default function TaskDetailPanel({
<User className="h-3 w-3" /> <User className="h-3 w-3" />
Assigned Assigned
</div> </div>
{isEditing ? ( {canAssign ? (
<Select <AssignmentPicker
value={editState.person_id} currentAssignments={task.assignments ?? []}
onChange={(e) => setEditState((s) => ({ ...s, person_id: e.target.value }))} members={allMembers}
className="h-8 text-xs" currentUserId={currentUserId}
> ownerId={ownerId}
<option value="">Unassigned</option> onAssign={(userIds) => assignMutation.mutate(userIds)}
{people.map((p) => ( onUnassign={(userId) => unassignMutation.mutate(userId)}
<option key={p.id} value={String(p.id)}> disabled={assignMutation.isPending || unassignMutation.isPending}
{p.name} />
</option> ) : task.assignments && task.assignments.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{task.assignments.map((a) => (
<span
key={a.user_id}
className="inline-flex items-center gap-1.5 bg-secondary rounded-full pl-1 pr-2.5 py-0.5"
>
<span className="w-5 h-5 rounded-full bg-muted flex items-center justify-center shrink-0">
<span className="text-[9px] font-medium text-muted-foreground">
{(a.user_name ?? '?').charAt(0).toUpperCase()}
</span>
</span>
<span className="text-xs">{a.user_name ?? 'Unknown'}</span>
</span>
))} ))}
</Select> </div>
) : ( ) : (
<p className="text-sm">{assignedPerson ? assignedPerson.name : '—'}</p> <p className="text-sm text-muted-foreground/50">Unassigned</p>
)} )}
</div> </div>
</div> </div>
@ -458,6 +514,7 @@ export default function TaskDetailPanel({
toggleSubtaskMutation.mutate({ toggleSubtaskMutation.mutate({
taskId: subtask.id, taskId: subtask.id,
status: subtask.status, status: subtask.status,
version: subtask.version,
}) })
} }
disabled={toggleSubtaskMutation.isPending} disabled={toggleSubtaskMutation.isPending}
@ -522,6 +579,9 @@ export default function TaskDetailPanel({
<p className="text-sm whitespace-pre-wrap">{comment.content}</p> <p className="text-sm whitespace-pre-wrap">{comment.content}</p>
<div className="flex items-center justify-between mt-1.5"> <div className="flex items-center justify-between mt-1.5">
<span className="text-[11px] text-muted-foreground"> <span className="text-[11px] text-muted-foreground">
{comment.author_name && (
<span className="font-medium text-foreground/70 mr-1">{comment.author_name}</span>
)}
{formatDistanceToNow(parseISO(comment.created_at), { addSuffix: true })} {formatDistanceToNow(parseISO(comment.created_at), { addSuffix: true })}
</span> </span>
<Button <Button

View File

@ -3,6 +3,7 @@ import { ChevronRight, GripVertical } from 'lucide-react';
import type { ProjectTask } from '@/types'; import type { ProjectTask } from '@/types';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { AssigneeAvatars } from './AssignmentPicker';
const taskStatusColors: Record<string, string> = { const taskStatusColors: Record<string, string> = {
pending: 'bg-gray-500/10 text-gray-400 border-gray-500/20', pending: 'bg-gray-500/10 text-gray-400 border-gray-500/20',
@ -134,6 +135,22 @@ export default function TaskRow({
{hasSubtasks ? `${completedSubtasks}/${task.subtasks.length}` : '—'} {hasSubtasks ? `${completedSubtasks}/${task.subtasks.length}` : '—'}
</span> </span>
{/* Assigned column */}
<span className="hidden sm:flex items-center gap-1.5 shrink-0 w-24 justify-end">
{task.assignments && task.assignments.length > 0 ? (
<>
<AssigneeAvatars assignments={task.assignments} max={2} />
<span className="text-[11px] text-muted-foreground truncate max-w-[60px]">
{task.assignments.length === 1
? (task.assignments[0].user_name ?? 'Unknown')
: `${task.assignments.length} people`}
</span>
</>
) : (
<span className="text-[11px] text-muted-foreground/30">unassigned</span>
)}
</span>
{/* Mobile-only: compact priority dot + overdue indicator */} {/* Mobile-only: compact priority dot + overdue indicator */}
<div className="flex items-center gap-1.5 sm:hidden shrink-0"> <div className="flex items-center gap-1.5 sm:hidden shrink-0">
<div className={`h-2 w-2 rounded-full ${ <div className={`h-2 w-2 rounded-full ${

View File

@ -0,0 +1,56 @@
import { useEffect, useRef } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import api from '../lib/api';
import { toLocalDatetime } from '../lib/date-utils';
interface PollResponse {
has_changes: boolean;
[key: string]: unknown;
}
/**
* Lightweight delta poll hook. Polls a cheap endpoint every `interval` ms.
* When changes are detected, invalidates the specified query key.
*
* HARD REQUIREMENT: refetchIntervalInBackground must be true so
* background tabs still receive updates (mirrors useNotifications pattern).
*/
export function useDeltaPoll(
endpoint: string | null,
queryKeyToInvalidate: unknown[],
interval: number = 5000,
) {
const queryClient = useQueryClient();
const sinceRef = useRef<string>(toLocalDatetime());
// Store key in ref to decouple from useEffect deps (prevents infinite loop
// if caller passes an inline array literal instead of a memoized one)
const keyRef = useRef(queryKeyToInvalidate);
keyRef.current = queryKeyToInvalidate;
const { data } = useQuery<PollResponse>({
queryKey: ['delta-poll', endpoint],
queryFn: async () => {
const { data } = await api.get(endpoint!, {
params: { since: sinceRef.current },
});
return data;
},
enabled: !!endpoint,
refetchInterval: interval,
refetchIntervalInBackground: true, // HARD REQUIREMENT — do not remove
staleTime: 0,
});
useEffect(() => {
if (data?.has_changes) {
queryClient.invalidateQueries({ queryKey: keyRef.current });
sinceRef.current = toLocalDatetime();
}
}, [data, queryClient]);
const updateSince = (timestamp: string) => {
sinceRef.current = timestamp;
};
return { updateSince };
}

View File

@ -137,12 +137,14 @@ export interface Reminder {
export interface Project { export interface Project {
id: number; id: number;
user_id: number;
name: string; name: string;
description?: string; description?: string;
status: 'not_started' | 'in_progress' | 'completed' | 'blocked' | 'review' | 'on_hold'; status: 'not_started' | 'in_progress' | 'completed' | 'blocked' | 'review' | 'on_hold';
color?: string; color?: string;
due_date?: string; due_date?: string;
is_tracked: boolean; is_tracked: boolean;
member_count: number;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
tasks: ProjectTask[]; tasks: ProjectTask[];
@ -162,10 +164,21 @@ export interface TrackedTask {
export interface TaskComment { export interface TaskComment {
id: number; id: number;
task_id: number; task_id: number;
user_id?: number | null;
author_name?: string | null;
content: string; content: string;
created_at: string; created_at: string;
} }
export interface TaskAssignment {
id: number;
task_id: number;
user_id: number;
assigned_by: number;
user_name?: string | null;
created_at: string;
}
export interface ProjectTask { export interface ProjectTask {
id: number; id: number;
project_id: number; project_id: number;
@ -177,10 +190,12 @@ export interface ProjectTask {
due_date?: string; due_date?: string;
person_id?: number; person_id?: number;
sort_order: number; sort_order: number;
version: number;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
subtasks: ProjectTask[]; subtasks: ProjectTask[];
comments: TaskComment[]; comments: TaskComment[];
assignments: TaskAssignment[];
} }
export interface Person { export interface Person {
@ -523,3 +538,22 @@ export interface EventLockInfo {
expires_at: string | null; expires_at: string | null;
is_permanent: boolean; is_permanent: boolean;
} }
// ── Project Sharing ──────────────────────────────────────────────
export type ProjectPermission = 'read_only' | 'create_modify';
export interface ProjectMember {
id: number;
project_id: number;
user_id: number;
invited_by: number;
permission: ProjectPermission;
status: 'pending' | 'accepted' | 'rejected';
source: 'invited' | 'auto_assigned';
user_name?: string | null;
inviter_name?: string | null;
created_at: string;
updated_at: string;
accepted_at?: string | null;
}