Add collaborative project sharing, task assignments, and delta polling

Enables multi-user project collaboration mirroring the shared calendar
pattern. Includes ProjectMember model with permission levels, task
assignment with auto-membership, optimistic locking, field allowlist
for assignees, disconnect cascade, delta polling for projects and
calendars, and full frontend integration with share sheet, assignment
picker, permission gating, and notification handling.

Migrations: 057 (indexes + version + comment user_id), 058
(project_members), 059 (project_task_assignments)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-03-17 03:18:35 +08:00
parent 7903e454dc
commit bef856fd15
29 changed files with 2201 additions and 130 deletions

View File

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

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

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

@ -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,56 @@ async def delete_calendar(
await db.delete(calendar)
await db.commit()
return None
# ──────────────────────────────────────────────
# DELTA POLLING
# ──────────────────────────────────────────────
class CalendarPollResponse(BaseModel):
has_changes: bool
calendar_updated_at: str | None = None
changed_event_ids: list[int] = []
@router.get("/{calendar_id}/poll", response_model=CalendarPollResponse)
async def poll_calendar(
calendar_id: int = Path(ge=1, le=2147483647),
since: str = Query(..., description="ISO timestamp to check for changes since"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Lightweight poll endpoint — returns changed event IDs since timestamp."""
await require_permission(db, calendar_id, current_user.id, "read_only")
try:
since_dt = datetime.fromisoformat(since)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid ISO timestamp")
# Check calendar-level update
cal_result = await db.execute(
select(Calendar.updated_at).where(Calendar.id == calendar_id)
)
calendar_updated = cal_result.scalar_one_or_none()
if not calendar_updated:
raise HTTPException(status_code=404, detail="Calendar not found")
calendar_changed = calendar_updated > since_dt
# Check event-level changes using the ix_events_calendar_updated index
event_result = await db.execute(
select(CalendarEvent.id).where(
CalendarEvent.calendar_id == calendar_id,
CalendarEvent.updated_at > since_dt,
)
)
changed_event_ids = [r[0] for r in event_result.all()]
has_changes = calendar_changed or len(changed_event_ids) > 0
return CalendarPollResponse(
has_changes=has_changes,
calendar_updated_at=calendar_updated.isoformat() if calendar_updated else None,
changed_event_ids=changed_event_ids,
)

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

View File

@ -30,6 +30,7 @@ class ProjectUpdate(BaseModel):
class ProjectResponse(BaseModel):
id: int
user_id: int = 0
name: str
description: Optional[str]
status: str

View File

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

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,19 @@
from pydantic import BaseModel, ConfigDict, Field
from datetime import datetime
class TaskAssignmentCreate(BaseModel):
model_config = ConfigDict(extra="forbid")
user_ids: list[int] = Field(min_length=1, max_length=20)
class TaskAssignmentResponse(BaseModel):
id: int
task_id: int
user_id: int
assigned_by: int
user_name: str | None = None
created_at: datetime
model_config = ConfigDict(from_attributes=True)

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,26 @@ class TaskCommentCreate(BaseModel):
class TaskCommentResponse(BaseModel):
id: int
task_id: int
user_id: int | None = None
author_name: str | None = None
content: str
created_at: datetime
model_config = ConfigDict(from_attributes=True)
@model_validator(mode="before")
@classmethod
def resolve_author_name(cls, data): # type: ignore[override]
"""Populate author_name from eagerly loaded user relationship."""
if hasattr(data, "user") and data.user is not None:
if not getattr(data, "author_name", None):
# Use username as fallback — preferred_name is on Settings, not User
data = dict(
id=data.id,
task_id=data.task_id,
user_id=data.user_id,
author_name=data.user.username,
content=data.content,
created_at=data.created_at,
)
return data

View File

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

View File

@ -141,7 +141,7 @@ export default function NotificationToaster() {
initializedRef.current = true;
// Toast actionable unread notifications on login so the user can act immediately
const actionableTypes = new Set(['connection_request', 'calendar_invite', 'event_invite']);
const actionableTypes = new Set(['connection_request', 'calendar_invite', 'event_invite', 'project_invite']);
const actionable = notifications.filter(
(n) => !n.is_read && actionableTypes.has(n.type),
);
@ -183,6 +183,9 @@ export default function NotificationToaster() {
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
queryClient.invalidateQueries({ queryKey: ['event-invitations'] });
}
if (newNotifications.some((n) => n.type === 'project_invite' || n.type === 'project_invite_accepted' || n.type === 'task_assigned')) {
queryClient.invalidateQueries({ queryKey: ['projects'] });
}
// Show toasts
newNotifications.forEach((notification) => {

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

@ -12,6 +12,7 @@ import {
import { format, parseISO } from 'date-fns';
import type { ProjectTask } from '@/types';
import { Badge } from '@/components/ui/badge';
import { AssigneeAvatars } from './AssignmentPicker';
const COLUMNS: { id: string; label: string; color: string }[] = [
{ id: 'pending', label: 'Pending', color: 'text-gray-400' },
@ -140,6 +141,12 @@ function KanbanCard({
</span>
)}
</div>
{/* Assignee avatars */}
{task.assignments && task.assignments.length > 0 && (
<div className="flex justify-end mt-2">
<AssigneeAvatars assignments={task.assignments} max={2} />
</div>
)}
</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,6 +50,7 @@ export default function ProjectCard({ project }: ProjectCardProps) {
className="cursor-pointer hover:shadow-lg hover:shadow-accent/5 hover:border-accent/20 transition-all duration-200 relative"
onClick={() => navigate(`/projects/${project.id}`)}
>
{!isShared && (
<button
onClick={(e) => {
e.stopPropagation();
@ -61,6 +65,7 @@ export default function ProjectCard({ project }: ProjectCardProps) {
>
<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,12 @@ export default function ProjectCard({ project }: ProjectCardProps) {
Due {format(parseISO(project.due_date), 'MMM d, yyyy')}
</div>
)}
{isShared && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mt-2">
<Users className="h-3.5 w-3.5" />
<span>Shared with you</span>
</div>
)}
</CardContent>
</Card>
);

View File

@ -23,21 +23,24 @@ import { CSS } from '@dnd-kit/utilities';
import {
ArrowLeft, Plus, Trash2, ListChecks, Pencil, Pin,
Calendar, CheckCircle2, PlayCircle, AlertTriangle,
List, Columns3, ArrowUpDown,
List, Columns3, ArrowUpDown, Users, Eye,
} from 'lucide-react';
import api from '@/lib/api';
import type { Project, ProjectTask } from '@/types';
import type { Project, ProjectTask, ProjectMember } from '@/types';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { Select } from '@/components/ui/select';
import { ListSkeleton } from '@/components/ui/skeleton';
import { EmptyState } from '@/components/ui/empty-state';
import { useSettings } from '@/hooks/useSettings';
import { useDeltaPoll } from '@/hooks/useDeltaPoll';
import TaskRow from './TaskRow';
import TaskDetailPanel from './TaskDetailPanel';
import KanbanBoard from './KanbanBoard';
import TaskForm from './TaskForm';
import ProjectForm from './ProjectForm';
import { ProjectShareSheet } from './ProjectShareSheet';
import { statusColors, statusLabels } from './constants';
import MobileDetailOverlay from '@/components/shared/MobileDetailOverlay';
@ -111,6 +114,9 @@ export default function ProjectDetail() {
const [kanbanParentTaskId, setKanbanParentTaskId] = useState<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,6 +140,33 @@ export default function ProjectDetail() {
},
});
// Permission derivation
const isOwner = project ? project.user_id === currentUserId : true;
const isShared = project ? project.user_id !== currentUserId : false;
// For now, if they can see the project but don't own it, check if they can edit
// The backend enforces actual permissions — this is just UI gating
const canEdit = isOwner; // Members with create_modify can also edit tasks (handled per-task)
const canManageProject = isOwner;
// Delta polling for real-time sync on shared projects
const pollKey = useMemo(() => ['projects', id], [id]);
useDeltaPoll(
id ? `/projects/${id}/poll` : null,
pollKey,
5000,
);
// Fetch members for shared projects
const { data: members = [] } = useQuery<ProjectMember[]>({
queryKey: ['project-members', id],
queryFn: async () => {
const { data } = await api.get(`/projects/${id}/members`);
return data;
},
enabled: !!id,
});
const acceptedMembers = members.filter((m) => m.status === 'accepted');
const toggleTaskMutation = useMutation({
mutationFn: async ({ taskId, status }: { taskId: number; status: string }) => {
const newStatus = status === 'completed' ? 'pending' : 'completed';
@ -389,6 +422,13 @@ 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">
{acceptedMembers.find((m) => m.user_id === currentUserId)?.permission === 'create_modify' ? 'Editor' : 'Viewer'}
</Badge>
)}
{canManageProject && (
<Button
variant="ghost"
size="icon"
@ -399,9 +439,27 @@ export default function ProjectDetail() {
>
<Pin className={`h-4 w-4 ${project.is_tracked ? 'fill-current' : ''}`} />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground"
onClick={() => setShowShareSheet(true)}
title="Project members"
>
<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"
@ -414,6 +472,7 @@ export default function ProjectDetail() {
>
<Trash2 className="h-3.5 w-3.5 md:mr-2" /><span className="hidden md:inline">Delete</span>
</Button>
)}
</div>
{/* Content area */}
@ -491,6 +550,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 +602,12 @@ export default function ProjectDetail() {
</div>
)}
{(isOwner || acceptedMembers.find((m) => m.user_id === currentUserId)?.permission === 'create_modify') && (
<Button size="sm" onClick={() => openTaskForm(null, null)}>
<Plus className="mr-2 h-3.5 w-3.5" />
Add Task
</Button>
)}
</div>
</div>
@ -698,6 +767,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={() => setTimeout(() => setSearch(''), 200)}
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

@ -6,6 +6,7 @@ import {
Pencil, Trash2, Plus, MessageSquare, ClipboardList,
Calendar, User, Flag, Activity, Send, X, Save,
} from 'lucide-react';
import axios from 'axios';
import api, { getErrorMessage } from '@/lib/api';
import { formatUpdatedAt } from '@/components/shared/utils';
import type { ProjectTask, TaskComment, Person } from '@/types';
@ -125,7 +126,12 @@ export default function TaskDetailPanel({
toast.success('Task updated');
},
onError: (error) => {
if (axios.isAxiosError(error) && error.response?.status === 409) {
toast.error('Task was modified by another user — please refresh');
queryClient.invalidateQueries({ queryKey: ['projects', projectId.toString()] });
} else {
toast.error(getErrorMessage(error, 'Failed to update task'));
}
},
});
@ -197,6 +203,7 @@ export default function TaskDetailPanel({
due_date: editState.due_date || null,
person_id: editState.person_id ? Number(editState.person_id) : null,
description: editState.description || null,
version: task.version,
};
updateTaskMutation.mutate(payload);
};
@ -522,6 +529,9 @@ export default function TaskDetailPanel({
<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,13 @@ export default function TaskRow({
{hasSubtasks ? `${completedSubtasks}/${task.subtasks.length}` : '—'}
</span>
{/* Assignee avatars */}
{task.assignments && task.assignments.length > 0 && (
<span className="hidden sm:flex shrink-0">
<AssigneeAvatars assignments={task.assignments} max={3} />
</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,52 @@
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());
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: queryKeyToInvalidate });
sinceRef.current = toLocalDatetime();
}
}, [data, queryClient, queryKeyToInvalidate]);
const updateSince = (timestamp: string) => {
sinceRef.current = timestamp;
};
return { updateSince };
}

View File

@ -137,6 +137,7 @@ 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';
@ -162,10 +163,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 +189,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 +537,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;
}