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.event_lock import EventLock
from app.models.event_invitation import EventInvitation, EventInvitationOverride
from app.models.project_member import ProjectMember
from app.models.project_task_assignment import ProjectTaskAssignment
__all__ = [
"Settings",
@ -47,4 +49,6 @@ __all__ = [
"EventLock",
"EventInvitation",
"EventInvitationOverride",
"ProjectMember",
"ProjectTaskAssignment",
]

View File

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

View File

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

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

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

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

View File

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

View File

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

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

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

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

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 { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { Check, X, Bell, UserPlus, Calendar, Clock } from 'lucide-react';
import { Check, X, Bell, UserPlus, Calendar, Clock, FolderKanban, ClipboardList } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import { useNotifications } from '@/hooks/useNotifications';
import { useConnections } from '@/hooks/useConnections';
@ -26,6 +27,9 @@ export default function NotificationToaster() {
respondRef.current = respond;
const markReadRef = useRef(markRead);
markReadRef.current = markRead;
const navigate = useNavigate();
const navigateRef = useRef(navigate);
navigateRef.current = navigate;
const handleConnectionRespond = useCallback(
async (requestId: number, action: 'accept' | 'reject', toastId: string | number, notificationId: number) => {
@ -123,6 +127,38 @@ export default function NotificationToaster() {
[],
);
const handleProjectInviteRespond = useCallback(
async (projectId: number, action: 'accepted' | 'rejected', toastId: string | number, notificationId: number) => {
const key = `proj-${projectId}`;
if (respondingRef.current.has(key)) return;
respondingRef.current.add(key);
toast.dismiss(toastId);
const loadingId = toast.loading(
action === 'accepted' ? 'Accepting project invite\u2026' : 'Declining invite\u2026',
);
try {
await api.post(`/projects/memberships/${projectId}/respond`, { response: action });
toast.dismiss(loadingId);
toast.success(action === 'accepted' ? 'Project invite accepted' : 'Project invite declined');
markReadRef.current([notificationId]).catch(() => {});
queryClient.invalidateQueries({ queryKey: ['projects'] });
} catch (err) {
toast.dismiss(loadingId);
if (axios.isAxiosError(err) && err.response?.status === 409) {
toast.success('Already responded');
markReadRef.current([notificationId]).catch(() => {});
} else {
toast.error(getErrorMessage(err, 'Failed to respond to project invite'));
}
} finally {
respondingRef.current.delete(key);
}
},
[],
);
// Track unread count changes to force-refetch the list
useEffect(() => {
if (unreadCount > prevUnreadRef.current && initializedRef.current) {
@ -141,7 +177,7 @@ export default function NotificationToaster() {
initializedRef.current = true;
// Toast actionable unread notifications on login so the user can act immediately
const actionableTypes = new Set(['connection_request', 'calendar_invite', 'event_invite']);
const actionableTypes = new Set(['connection_request', 'calendar_invite', 'event_invite', 'project_invite', 'task_assigned']);
const actionable = notifications.filter(
(n) => !n.is_read && actionableTypes.has(n.type),
);
@ -155,6 +191,10 @@ export default function NotificationToaster() {
showCalendarInviteToast(notification);
} else if (notification.type === 'event_invite' && notification.data) {
showEventInviteToast(notification);
} else if (notification.type === 'project_invite' && notification.data) {
showProjectInviteToast(notification);
} else if (notification.type === 'task_assigned' && notification.data) {
showTaskAssignedToast(notification);
}
});
return;
@ -183,6 +223,9 @@ export default function NotificationToaster() {
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
queryClient.invalidateQueries({ queryKey: ['event-invitations'] });
}
if (newNotifications.some((n) => n.type === 'project_invite' || n.type === 'project_invite_accepted' || n.type === 'task_assigned')) {
queryClient.invalidateQueries({ queryKey: ['projects'] });
}
// Show toasts
newNotifications.forEach((notification) => {
@ -192,6 +235,10 @@ export default function NotificationToaster() {
showCalendarInviteToast(notification);
} else if (notification.type === 'event_invite' && notification.data) {
showEventInviteToast(notification);
} else if (notification.type === 'project_invite' && notification.data) {
showProjectInviteToast(notification);
} else if (notification.type === 'task_assigned' && notification.data) {
showTaskAssignedToast(notification);
} else {
toast(notification.title || 'New Notification', {
description: notification.message || undefined,
@ -200,7 +247,7 @@ export default function NotificationToaster() {
});
}
});
}, [notifications, handleConnectionRespond, handleCalendarInviteRespond, handleEventInviteRespond]);
}, [notifications, handleConnectionRespond, handleCalendarInviteRespond, handleEventInviteRespond, handleProjectInviteRespond]);
const showConnectionRequestToast = (notification: AppNotification) => {
const requestId = notification.source_id!;
@ -360,5 +407,96 @@ export default function NotificationToaster() {
);
};
const showTaskAssignedToast = (notification: AppNotification) => {
const data = notification.data as Record<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;
}

View File

@ -1,7 +1,7 @@
import { useState, useMemo, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { Bell, Check, CheckCheck, Trash2, UserPlus, Info, AlertCircle, X, Loader2, Calendar, Clock } from 'lucide-react';
import { Bell, Check, CheckCheck, Trash2, UserPlus, Info, AlertCircle, X, Loader2, Calendar, Clock, FolderKanban } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { toast } from 'sonner';
import { useNotifications } from '@/hooks/useNotifications';
@ -22,6 +22,9 @@ const typeIcons: Record<string, { icon: typeof Bell; color: string }> = {
calendar_invite_rejected: { icon: Calendar, color: 'text-muted-foreground' },
event_invite: { icon: Calendar, color: 'text-purple-400' },
event_invite_response: { icon: Calendar, color: 'text-green-400' },
project_invite: { icon: FolderKanban, color: 'text-purple-400' },
project_invite_accepted: { icon: FolderKanban, color: 'text-green-400' },
task_assigned: { icon: FolderKanban, color: 'text-blue-400' },
info: { icon: Info, color: 'text-blue-400' },
warning: { icon: AlertCircle, color: 'text-amber-400' },
};
@ -44,6 +47,7 @@ export default function NotificationsPage() {
const navigate = useNavigate();
const [filter, setFilter] = useState<Filter>('all');
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
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) => {
// Don't navigate for pending connection requests — let user act inline
if (
@ -207,6 +241,10 @@ export default function NotificationsPage() {
if (notification.type === 'event_invite' && !notification.is_read) {
return;
}
// Don't navigate for unread project invites — let user act inline
if (notification.type === 'project_invite' && !notification.is_read) {
return;
}
if (!notification.is_read) {
await markRead([notification.id]).catch(() => {});
}
@ -218,6 +256,11 @@ export default function NotificationsPage() {
if (notification.type === 'event_invite' || notification.type === 'event_invite_response') {
navigate('/calendar');
}
// Navigate to project for project-related notifications
if (['project_invite', 'project_invite_accepted', 'task_assigned'].includes(notification.type)) {
const projectId = (notification.data as Record<string, unknown>)?.project_id;
if (projectId) navigate(`/projects/${projectId}`);
}
};
return (
@ -408,6 +451,31 @@ export default function NotificationsPage() {
</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 */}
<div className="flex items-center gap-1.5 shrink-0">
<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,219 +1,262 @@
import {
DndContext,
closestCorners,
PointerSensor,
TouchSensor,
useSensor,
useSensors,
type DragEndEvent,
useDroppable,
useDraggable,
} from '@dnd-kit/core';
import { format, parseISO } from 'date-fns';
import type { ProjectTask } from '@/types';
import { Badge } from '@/components/ui/badge';
const COLUMNS: { id: string; label: string; color: string }[] = [
{ id: 'pending', label: 'Pending', color: 'text-gray-400' },
{ id: 'in_progress', label: 'In Progress', color: 'text-blue-400' },
{ id: 'blocked', label: 'Blocked', color: 'text-red-400' },
{ id: 'on_hold', label: 'On Hold', color: 'text-orange-400' },
{ id: 'review', label: 'Review', color: 'text-yellow-400' },
{ id: 'completed', label: 'Completed', color: 'text-green-400' },
];
const priorityColors: Record<string, string> = {
none: 'bg-gray-500/20 text-gray-400',
low: 'bg-green-500/20 text-green-400',
medium: 'bg-yellow-500/20 text-yellow-400',
high: 'bg-red-500/20 text-red-400',
};
interface KanbanBoardProps {
tasks: ProjectTask[];
selectedTaskId: number | null;
kanbanParentTask?: ProjectTask | null;
onSelectTask: (taskId: number) => void;
onStatusChange: (taskId: number, status: string) => void;
onBackToAllTasks?: () => void;
}
function KanbanColumn({
column,
tasks,
selectedTaskId,
onSelectTask,
}: {
column: (typeof COLUMNS)[0];
tasks: ProjectTask[];
selectedTaskId: number | null;
onSelectTask: (taskId: number) => void;
}) {
const { setNodeRef, isOver } = useDroppable({ id: column.id });
return (
<div
ref={setNodeRef}
className={`flex-1 min-w-[160px] md:min-w-[200px] rounded-lg border transition-colors duration-150 ${
isOver ? 'border-accent/40 bg-accent/5' : 'border-border bg-card/50'
}`}
>
{/* Column header */}
<div className="px-3 py-2.5 border-b border-border">
<div className="flex items-center justify-between">
<span className={`text-sm font-semibold font-heading ${column.color}`}>
{column.label}
</span>
<span className="text-[11px] text-muted-foreground tabular-nums bg-secondary rounded-full px-2 py-0.5">
{tasks.length}
</span>
</div>
</div>
{/* Cards */}
<div className="p-2 space-y-2 min-h-[100px]">
{tasks.map((task) => (
<KanbanCard
key={task.id}
task={task}
isSelected={selectedTaskId === task.id}
onSelect={() => onSelectTask(task.id)}
/>
))}
</div>
</div>
);
}
function KanbanCard({
task,
isSelected,
onSelect,
}: {
task: ProjectTask;
isSelected: boolean;
onSelect: () => void;
}) {
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
id: task.id,
data: { task },
});
const style = transform
? {
transform: `translate(${transform.x}px, ${transform.y}px)`,
opacity: isDragging ? 0.5 : 1,
}
: undefined;
const completedSubtasks = task.subtasks?.filter((s) => s.status === 'completed').length ?? 0;
const totalSubtasks = task.subtasks?.length ?? 0;
return (
<div
ref={setNodeRef}
style={style}
{...listeners}
{...attributes}
onClick={onSelect}
className={`rounded-md border p-3 cursor-pointer transition-all duration-150 ${
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>
<div className="flex items-center gap-1.5 flex-wrap">
<Badge
className={`text-[9px] px-1.5 py-0.5 rounded-full ${priorityColors[task.priority] ?? priorityColors.none}`}
>
{task.priority}
</Badge>
{task.due_date && (
<span className="text-[11px] text-muted-foreground tabular-nums">
{format(parseISO(task.due_date), 'MMM d')}
</span>
)}
{totalSubtasks > 0 && (
<span className="text-[11px] text-muted-foreground tabular-nums">
{completedSubtasks}/{totalSubtasks}
</span>
)}
</div>
</div>
);
}
export default function KanbanBoard({
tasks,
selectedTaskId,
kanbanParentTask,
onSelectTask,
onStatusChange,
onBackToAllTasks,
}: KanbanBoardProps) {
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }) ,
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 8 } })
);
// Subtask view is driven by kanbanParentTask (decoupled from selected task)
const isSubtaskView = kanbanParentTask != null && (kanbanParentTask.subtasks?.length ?? 0) > 0;
const activeTasks: ProjectTask[] = isSubtaskView ? (kanbanParentTask.subtasks ?? []) : tasks;
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over) return;
const taskId = active.id as number;
const newStatus = over.id as string;
const task = activeTasks.find((t) => t.id === taskId);
if (task && task.status !== newStatus && COLUMNS.some((c) => c.id === newStatus)) {
onStatusChange(taskId, newStatus);
}
};
const tasksByStatus = COLUMNS.map((col) => ({
column: col,
tasks: activeTasks.filter((t) => t.status === col.id),
}));
return (
<div className="flex flex-col gap-3">
{/* Subtask view header */}
{isSubtaskView && kanbanParentTask && (
<div className="flex items-center gap-3 px-1">
<button
onClick={onBackToAllTasks}
className="text-xs text-muted-foreground hover:text-foreground transition-colors underline underline-offset-2"
>
Back to all tasks
</button>
<span className="text-muted-foreground text-xs">/</span>
<span className="text-xs text-foreground font-medium">
Subtasks of: {kanbanParentTask.title}
</span>
</div>
)}
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragEnd={handleDragEnd}
>
<div className="flex gap-3 overflow-x-auto pb-2">
{tasksByStatus.map(({ column, tasks: colTasks }) => (
<KanbanColumn
key={column.id}
column={column}
tasks={colTasks}
selectedTaskId={selectedTaskId}
onSelectTask={onSelectTask}
/>
))}
</div>
</DndContext>
</div>
);
}
import { useState, useCallback } from 'react';
import {
DndContext,
closestCenter,
PointerSensor,
TouchSensor,
useSensor,
useSensors,
type DragStartEvent,
type DragEndEvent,
useDroppable,
useDraggable,
DragOverlay,
} from '@dnd-kit/core';
import { format, parseISO } from 'date-fns';
import type { ProjectTask } from '@/types';
import { Badge } from '@/components/ui/badge';
import { AssigneeAvatars } from './AssignmentPicker';
const COLUMNS: { id: string; label: string; color: string }[] = [
{ id: 'pending', label: 'Pending', color: 'text-gray-400' },
{ id: 'in_progress', label: 'In Progress', color: 'text-blue-400' },
{ id: 'blocked', label: 'Blocked', color: 'text-red-400' },
{ id: 'on_hold', label: 'On Hold', color: 'text-orange-400' },
{ id: 'review', label: 'Review', color: 'text-yellow-400' },
{ id: 'completed', label: 'Completed', color: 'text-green-400' },
];
const priorityColors: Record<string, string> = {
none: 'bg-gray-500/20 text-gray-400',
low: 'bg-green-500/20 text-green-400',
medium: 'bg-yellow-500/20 text-yellow-400',
high: 'bg-red-500/20 text-red-400',
};
interface KanbanBoardProps {
tasks: ProjectTask[];
selectedTaskId: number | null;
kanbanParentTask?: ProjectTask | null;
onSelectTask: (taskId: number) => void;
onStatusChange: (taskId: number, status: string) => void;
onBackToAllTasks?: () => void;
}
function KanbanColumn({
column,
tasks,
selectedTaskId,
draggingId,
onSelectTask,
}: {
column: (typeof COLUMNS)[0];
tasks: ProjectTask[];
selectedTaskId: number | null;
draggingId: number | null;
onSelectTask: (taskId: number) => void;
}) {
const { setNodeRef, isOver } = useDroppable({ id: column.id });
return (
<div
ref={setNodeRef}
className={`flex-1 min-w-[160px] md:min-w-[200px] rounded-lg border transition-colors duration-150 ${
isOver ? 'border-accent/40 bg-accent/5' : 'border-border bg-card/50'
}`}
>
{/* Column header */}
<div className="px-3 py-2.5 border-b border-border">
<div className="flex items-center justify-between">
<span className={`text-sm font-semibold font-heading ${column.color}`}>
{column.label}
</span>
<span className="text-[11px] text-muted-foreground tabular-nums bg-secondary rounded-full px-2 py-0.5">
{tasks.length}
</span>
</div>
</div>
{/* Cards */}
<div className="p-2 space-y-2 min-h-[100px]">
{tasks.map((task) => (
<KanbanCard
key={task.id}
task={task}
isSelected={selectedTaskId === task.id}
isDragSource={draggingId === task.id}
onSelect={() => onSelectTask(task.id)}
/>
))}
</div>
</div>
);
}
// Card content — shared between in-place card and drag overlay
function CardContent({ task, isSelected, ghost }: { task: ProjectTask; isSelected: boolean; ghost?: boolean }) {
const completedSubtasks = task.subtasks?.filter((s) => s.status === 'completed').length ?? 0;
const totalSubtasks = task.subtasks?.length ?? 0;
return (
<div
className={`rounded-md border p-3 ${
ghost
? 'border-accent/20 bg-accent/5 opacity-40'
: 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>
<div className="flex items-center gap-1.5 flex-wrap">
<Badge
className={`text-[9px] px-1.5 py-0.5 rounded-full ${priorityColors[task.priority] ?? priorityColors.none}`}
>
{task.priority}
</Badge>
{task.due_date && (
<span className="text-[11px] text-muted-foreground tabular-nums">
{format(parseISO(task.due_date), 'MMM d')}
</span>
)}
{totalSubtasks > 0 && (
<span className="text-[11px] text-muted-foreground tabular-nums">
{completedSubtasks}/{totalSubtasks}
</span>
)}
</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>
);
}
export default function KanbanBoard({
tasks,
selectedTaskId,
kanbanParentTask,
onSelectTask,
onStatusChange,
onBackToAllTasks,
}: KanbanBoardProps) {
const [draggingId, setDraggingId] = useState<number | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 8 } })
);
const isSubtaskView = kanbanParentTask != null && (kanbanParentTask.subtasks?.length ?? 0) > 0;
const activeTasks: ProjectTask[] = isSubtaskView ? (kanbanParentTask.subtasks ?? []) : tasks;
const draggingTask = draggingId ? activeTasks.find((t) => t.id === draggingId) ?? null : null;
const handleDragStart = useCallback((event: DragStartEvent) => {
setDraggingId(event.active.id as number);
}, []);
const handleDragEnd = useCallback((event: DragEndEvent) => {
setDraggingId(null);
const { active, over } = event;
if (!over) return;
const taskId = active.id as number;
const newStatus = over.id as string;
const task = activeTasks.find((t) => t.id === taskId);
if (task && task.status !== newStatus && COLUMNS.some((c) => c.id === newStatus)) {
onStatusChange(taskId, newStatus);
}
}, [activeTasks, onStatusChange]);
const handleDragCancel = useCallback(() => {
setDraggingId(null);
}, []);
const tasksByStatus = COLUMNS.map((col) => ({
column: col,
tasks: activeTasks.filter((t) => t.status === col.id),
}));
return (
<div className="flex flex-col gap-3">
{/* Subtask view header */}
{isSubtaskView && kanbanParentTask && (
<div className="flex items-center gap-3 px-1">
<button
onClick={onBackToAllTasks}
className="text-xs text-muted-foreground hover:text-foreground transition-colors underline underline-offset-2"
>
Back to all tasks
</button>
<span className="text-muted-foreground text-xs">/</span>
<span className="text-xs text-foreground font-medium">
Subtasks of: {kanbanParentTask.title}
</span>
</div>
)}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<div className="flex gap-3 overflow-x-auto pb-2">
{tasksByStatus.map(({ column, tasks: colTasks }) => (
<KanbanColumn
key={column.id}
column={column}
tasks={colTasks}
selectedTaskId={selectedTaskId}
draggingId={draggingId}
onSelectTask={onSelectTask}
/>
))}
</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>
</div>
);
}

View File

@ -2,9 +2,10 @@ import { useNavigate } from 'react-router-dom';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { format, isPast, parseISO } from 'date-fns';
import { Calendar, Pin } from 'lucide-react';
import { Calendar, Pin, Users } from 'lucide-react';
import api from '@/lib/api';
import type { Project } from '@/types';
import { useSettings } from '@/hooks/useSettings';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { statusColors, statusLabels } from './constants';
@ -17,6 +18,8 @@ interface ProjectCardProps {
export default function ProjectCard({ project }: ProjectCardProps) {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { settings } = useSettings();
const isShared = project.user_id !== (settings?.user_id ?? 0);
const toggleTrackMutation = useMutation({
mutationFn: async () => {
@ -47,20 +50,22 @@ export default function ProjectCard({ project }: ProjectCardProps) {
className="cursor-pointer hover:shadow-lg hover:shadow-accent/5 hover:border-accent/20 transition-all duration-200 relative"
onClick={() => navigate(`/projects/${project.id}`)}
>
<button
onClick={(e) => {
e.stopPropagation();
toggleTrackMutation.mutate();
}}
className={`absolute top-3 right-3 p-1 rounded-md transition-colors z-10 ${
project.is_tracked
? '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'}
>
<Pin className={`h-3.5 w-3.5 ${project.is_tracked ? 'fill-current' : ''}`} />
</button>
{!isShared && (
<button
onClick={(e) => {
e.stopPropagation();
toggleTrackMutation.mutate();
}}
className={`absolute top-3 right-3 p-1 rounded-md transition-colors z-10 ${
project.is_tracked
? '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'}
>
<Pin className={`h-3.5 w-3.5 ${project.is_tracked ? 'fill-current' : ''}`} />
</button>
)}
<CardHeader>
<div className="flex items-start justify-between gap-2 pr-6">
<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')}
</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>
</Card>
);

View File

@ -23,21 +23,25 @@ import { CSS } from '@dnd-kit/utilities';
import {
ArrowLeft, Plus, Trash2, ListChecks, Pencil, Pin,
Calendar, CheckCircle2, PlayCircle, AlertTriangle,
List, Columns3, ArrowUpDown,
List, Columns3, ArrowUpDown, Users, Eye,
} from 'lucide-react';
import axios from 'axios';
import api from '@/lib/api';
import type { Project, ProjectTask } from '@/types';
import type { Project, ProjectTask, ProjectMember } from '@/types';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { Select } from '@/components/ui/select';
import { ListSkeleton } from '@/components/ui/skeleton';
import { EmptyState } from '@/components/ui/empty-state';
import { useSettings } from '@/hooks/useSettings';
import { useDeltaPoll } from '@/hooks/useDeltaPoll';
import TaskRow from './TaskRow';
import TaskDetailPanel from './TaskDetailPanel';
import KanbanBoard from './KanbanBoard';
import TaskForm from './TaskForm';
import ProjectForm from './ProjectForm';
import { ProjectShareSheet } from './ProjectShareSheet';
import { statusColors, statusLabels } from './constants';
import MobileDetailOverlay from '@/components/shared/MobileDetailOverlay';
@ -111,6 +115,9 @@ export default function ProjectDetail() {
const [kanbanParentTaskId, setKanbanParentTaskId] = useState<number | null>(null);
const [sortMode, setSortMode] = useState<SortMode>('manual');
const [viewMode, setViewMode] = useState<ViewMode>('list');
const [showShareSheet, setShowShareSheet] = useState(false);
const { settings } = useSettings();
const currentUserId = settings?.user_id ?? 0;
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
@ -134,17 +141,54 @@ export default function ProjectDetail() {
},
});
// Permission derivation
const isOwner = project ? project.user_id === currentUserId : true;
const isShared = project ? project.user_id !== currentUserId : false;
// For now, if they can see the project but don't own it, check if they can edit
// The backend enforces actual permissions — this is just UI gating
const canEdit = isOwner; // Members with create_modify can also edit tasks (handled per-task)
const canManageProject = isOwner;
// Delta polling for real-time sync — only for shared projects (P-04)
const pollKey = useMemo(() => ['projects', id], [id]);
useDeltaPoll(
id && project && project.member_count > 0 ? `/projects/${id}/poll` : null,
pollKey,
5000,
);
// Fetch members for shared projects
const { data: members = [] } = useQuery<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({
mutationFn: async ({ taskId, status }: { taskId: number; status: string }) => {
mutationFn: async ({ taskId, status, version }: { taskId: number; status: string; version: number }) => {
const newStatus = status === 'completed' ? 'pending' : 'completed';
const { data } = await api.put(`/projects/${id}/tasks/${taskId}`, { status: newStatus });
const { data } = await api.put(`/projects/${id}/tasks/${taskId}`, { status: newStatus, version });
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects', id] });
},
onError: () => {
toast.error('Failed to update task');
onError: (error) => {
if (axios.isAxiosError(error) && error.response?.status === 409) {
toast.error('Task was modified by another user — please refresh');
queryClient.invalidateQueries({ queryKey: ['projects', id] });
} else {
toast.error('Failed to update task');
}
},
});
@ -199,15 +243,20 @@ export default function ProjectDetail() {
});
const updateTaskStatusMutation = useMutation({
mutationFn: async ({ taskId, status }: { taskId: number; status: string }) => {
const { data } = await api.put(`/projects/${id}/tasks/${taskId}`, { status });
mutationFn: async ({ taskId, status, version }: { taskId: number; status: string; version: number }) => {
const { data } = await api.put(`/projects/${id}/tasks/${taskId}`, { status, version });
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects', id] });
},
onError: () => {
toast.error('Failed to update task status');
onError: (error) => {
if (axios.isAxiosError(error) && error.response?.status === 409) {
toast.error('Task was modified by another user — please refresh');
queryClient.invalidateQueries({ queryKey: ['projects', id] });
} else {
toast.error('Failed to update task status');
}
},
});
@ -389,31 +438,57 @@ export default function ProjectDetail() {
<Badge className={`shrink-0 hidden sm:inline-flex ${statusColors[project.status]}`}>
{statusLabels[project.status]}
</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
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'}
className="shrink-0 text-muted-foreground relative"
onClick={() => setShowShareSheet(true)}
title="Project members"
>
<Pin className={`h-4 w-4 ${project.is_tracked ? 'fill-current' : ''}`} />
</Button>
<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>
<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>
<Users className="h-4 w-4" />
{acceptedMembers.length > 0 && (
<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">
{acceptedMembers.length}
</span>
)}
</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>
{/* Content area */}
@ -491,6 +566,14 @@ export default function ProjectDetail() {
</Card>
</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 */}
<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>
@ -535,10 +618,12 @@ export default function ProjectDetail() {
</div>
)}
<Button size="sm" onClick={() => openTaskForm(null, null)}>
<Plus className="mr-2 h-3.5 w-3.5" />
Add Task
</Button>
{canEditTasks && (
<Button size="sm" onClick={() => openTaskForm(null, null)}>
<Plus className="mr-2 h-3.5 w-3.5" />
Add Task
</Button>
)}
</div>
</div>
@ -561,9 +646,10 @@ export default function ProjectDetail() {
selectedTaskId={selectedTaskId}
kanbanParentTask={kanbanParentTask}
onSelectTask={handleKanbanSelectTask}
onStatusChange={(taskId, status) =>
updateTaskStatusMutation.mutate({ taskId, status })
}
onStatusChange={(taskId, status) => {
const t = allTasks.find(tt => tt.id === taskId) ?? allTasks.flatMap(tt => tt.subtasks || []).find(st => st.id === taskId);
updateTaskStatusMutation.mutate({ taskId, status, version: t?.version ?? 1 });
}}
onBackToAllTasks={handleBackToAllTasks}
/>
) : (
@ -595,6 +681,7 @@ export default function ProjectDetail() {
toggleTaskMutation.mutate({
taskId: task.id,
status: task.status,
version: task.version,
})
}
togglePending={toggleTaskMutation.isPending}
@ -641,6 +728,10 @@ export default function ProjectDetail() {
<TaskDetailPanel
task={selectedTask}
projectId={parseInt(id!)}
members={acceptedMembers}
currentUserId={currentUserId}
ownerId={project?.user_id ?? 0}
canAssign={canEditTasks}
onDelete={handleDeleteTask}
onAddSubtask={(parentId) => openTaskForm(null, parentId)}
onClose={() => setSelectedTaskId(null)}
@ -698,6 +789,14 @@ export default function ProjectDetail() {
onClose={() => setShowProjectForm(false)}
/>
)}
<ProjectShareSheet
open={showShareSheet}
onOpenChange={setShowShareSheet}
projectId={parseInt(id!)}
isOwner={isOwner}
ownerName={settings?.preferred_name || 'Owner'}
/>
</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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { format, formatDistanceToNow, parseISO } from 'date-fns';
import {
Pencil, Trash2, Plus, MessageSquare, ClipboardList,
Calendar, User, Flag, Activity, Send, X, Save,
} from 'lucide-react';
import axios from 'axios';
import api, { getErrorMessage } from '@/lib/api';
import { formatUpdatedAt } from '@/components/shared/utils';
import type { ProjectTask, TaskComment, Person } from '@/types';
import type { ProjectTask, TaskComment, ProjectMember } from '@/types';
import { AssignmentPicker } from './AssignmentPicker';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
@ -45,6 +47,10 @@ const priorityColors: Record<string, string> = {
interface TaskDetailPanelProps {
task: ProjectTask | null;
projectId: number;
members?: ProjectMember[];
currentUserId?: number;
ownerId?: number;
canAssign?: boolean;
onDelete: (taskId: number) => void;
onAddSubtask: (parentId: number) => void;
onClose?: () => void;
@ -81,6 +87,10 @@ function buildEditState(task: ProjectTask): EditState {
export default function TaskDetailPanel({
task,
projectId,
members = [],
currentUserId = 0,
ownerId = 0,
canAssign = false,
onDelete,
onAddSubtask,
onClose,
@ -93,20 +103,24 @@ export default function TaskDetailPanel({
task ? buildEditState(task) : { title: '', status: 'pending', priority: 'none', due_date: todayLocal(), person_id: '', description: '' }
);
const { data: people = [] } = useQuery({
queryKey: ['people'],
queryFn: async () => {
const { data } = await api.get<Person[]>('/people');
return data;
},
});
// Build a combined members list that includes the owner for the AssignmentPicker
const allMembers: ProjectMember[] = [
// Synthetic owner entry so they appear in the picker
...(ownerId ? [{
id: 0, project_id: projectId, user_id: ownerId, invited_by: ownerId,
permission: 'create_modify' as const, status: 'accepted' as const,
source: 'invited' as const, user_name: currentUserId === ownerId ? 'Me (Owner)' : 'Owner',
inviter_name: null, created_at: '', updated_at: '', accepted_at: null,
}] : []),
...members.filter(m => m.status === 'accepted'),
];
// --- Mutations ---
const toggleSubtaskMutation = useMutation({
mutationFn: async ({ taskId, status }: { taskId: number; status: string }) => {
mutationFn: async ({ taskId, status, version }: { taskId: number; status: string; version: number }) => {
const newStatus = status === 'completed' ? 'pending' : 'completed';
const { data } = await api.put(`/projects/${projectId}/tasks/${taskId}`, { status: newStatus });
const { data } = await api.put(`/projects/${projectId}/tasks/${taskId}`, { status: newStatus, version });
return data;
},
onSuccess: () => {
@ -125,7 +139,12 @@ export default function TaskDetailPanel({
toast.success('Task updated');
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to update task'));
if (axios.isAxiosError(error) && error.response?.status === 409) {
toast.error('Task was modified by another user — please refresh');
queryClient.invalidateQueries({ queryKey: ['projects', projectId.toString()] });
} else {
toast.error(getErrorMessage(error, 'Failed to update task'));
}
},
});
@ -169,6 +188,30 @@ export default function TaskDetailPanel({
},
});
const assignMutation = useMutation({
mutationFn: async (userIds: number[]) => {
await api.post(`/projects/${projectId}/tasks/${task!.id}/assignments`, { user_ids: userIds });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects', projectId.toString()] });
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to assign'));
},
});
const unassignMutation = useMutation({
mutationFn: async (userId: number) => {
await api.delete(`/projects/${projectId}/tasks/${task!.id}/assignments/${userId}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects', projectId.toString()] });
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to unassign'));
},
});
// --- Handlers ---
const handleAddComment = () => {
@ -197,6 +240,7 @@ export default function TaskDetailPanel({
due_date: editState.due_date || null,
person_id: editState.person_id ? Number(editState.person_id) : null,
description: editState.description || null,
version: task.version,
};
updateTaskMutation.mutate(payload);
};
@ -217,7 +261,6 @@ export default function TaskDetailPanel({
);
}
const assignedPerson = task.person_id ? people.find((p) => p.id === task.person_id) : null;
const comments = task.comments || [];
return (
@ -376,21 +419,34 @@ export default function TaskDetailPanel({
<User className="h-3 w-3" />
Assigned
</div>
{isEditing ? (
<Select
value={editState.person_id}
onChange={(e) => setEditState((s) => ({ ...s, person_id: e.target.value }))}
className="h-8 text-xs"
>
<option value="">Unassigned</option>
{people.map((p) => (
<option key={p.id} value={String(p.id)}>
{p.name}
</option>
{canAssign ? (
<AssignmentPicker
currentAssignments={task.assignments ?? []}
members={allMembers}
currentUserId={currentUserId}
ownerId={ownerId}
onAssign={(userIds) => assignMutation.mutate(userIds)}
onUnassign={(userId) => unassignMutation.mutate(userId)}
disabled={assignMutation.isPending || unassignMutation.isPending}
/>
) : 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>
@ -458,6 +514,7 @@ export default function TaskDetailPanel({
toggleSubtaskMutation.mutate({
taskId: subtask.id,
status: subtask.status,
version: subtask.version,
})
}
disabled={toggleSubtaskMutation.isPending}
@ -522,6 +579,9 @@ export default function TaskDetailPanel({
<p className="text-sm whitespace-pre-wrap">{comment.content}</p>
<div className="flex items-center justify-between mt-1.5">
<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 })}
</span>
<Button

View File

@ -3,6 +3,7 @@ import { ChevronRight, GripVertical } from 'lucide-react';
import type { ProjectTask } from '@/types';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { AssigneeAvatars } from './AssignmentPicker';
const taskStatusColors: Record<string, string> = {
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}` : '—'}
</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 */}
<div className="flex items-center gap-1.5 sm:hidden shrink-0">
<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 {
id: number;
user_id: number;
name: string;
description?: string;
status: 'not_started' | 'in_progress' | 'completed' | 'blocked' | 'review' | 'on_hold';
color?: string;
due_date?: string;
is_tracked: boolean;
member_count: number;
created_at: string;
updated_at: string;
tasks: ProjectTask[];
@ -162,10 +164,21 @@ export interface TrackedTask {
export interface TaskComment {
id: number;
task_id: number;
user_id?: number | null;
author_name?: string | null;
content: 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 {
id: number;
project_id: number;
@ -177,10 +190,12 @@ export interface ProjectTask {
due_date?: string;
person_id?: number;
sort_order: number;
version: number;
created_at: string;
updated_at: string;
subtasks: ProjectTask[];
comments: TaskComment[];
assignments: TaskAssignment[];
}
export interface Person {
@ -523,3 +538,22 @@ export interface EventLockInfo {
expires_at: string | null;
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;
}