from fastapi import APIRouter, Depends, HTTPException, Path, Query from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import delete as sa_delete, select, update from sqlalchemy.orm import selectinload from typing import List, Optional 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 router = APIRouter() class ReorderItem(BaseModel): model_config = ConfigDict(extra="forbid") id: int sort_order: int def _project_load_options(): """All load options needed for project responses (tasks + subtasks + comments + assignments).""" return [ 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(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 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.id.in_(accessible_ids)) .order_by(Project.created_at.desc()) ) if tracked is not None: query = query.where(Project.is_tracked == tracked) result = await db.execute(query) projects = result.scalars().unique().all() return projects # This route MUST be defined before /{project_id} to avoid path parameter shadowing @router.get("/tracked-tasks", response_model=List[TrackedTaskResponse]) async def get_tracked_tasks( days: int = Query(7, ge=1, le=90), db: AsyncSession = Depends(get_db), 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) query = ( select(ProjectTask) .join(Project, ProjectTask.project_id == Project.id) .options( selectinload(ProjectTask.project), selectinload(ProjectTask.parent_task), ) .where( Project.id.in_(accessible_ids), Project.is_tracked == True, ProjectTask.due_date.isnot(None), ProjectTask.due_date >= today, ProjectTask.due_date <= cutoff, ProjectTask.status != "completed", ) .order_by(ProjectTask.due_date.asc()) ) result = await db.execute(query) tasks = result.scalars().unique().all() return [ TrackedTaskResponse( id=t.id, title=t.title, status=t.status, priority=t.priority, due_date=t.due_date, project_name=t.project.name, project_id=t.project_id, parent_task_title=t.parent_task.title if t.parent_task else None, ) for t in 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, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """Create a new project.""" new_project = Project(**project.model_dump(), user_id=current_user.id) db.add(new_project) await db.commit() query = select(Project).options(*_project_load_options()).where(Project.id == new_project.id) result = await db.execute(query) return result.scalar_one() @router.get("/{project_id}", response_model=ProjectResponse) async def get_project( project_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), 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) ) result = await db.execute(query) project = result.scalar_one_or_none() if not project: raise HTTPException(status_code=404, detail="Project not found") return project @router.put("/{project_id}", response_model=ProjectResponse) async def update_project( project_id: int = Path(ge=1, le=2147483647), project_update: ProjectUpdate = ..., db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """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: raise HTTPException(status_code=404, detail="Project not found") update_data = project_update.model_dump(exclude_unset=True) for key, value in update_data.items(): setattr(project, key, value) await db.commit() query = select(Project).options(*_project_load_options()).where(Project.id == project_id) result = await db.execute(query) return result.scalar_one() @router.delete("/{project_id}", status_code=204) async def delete_project( project_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """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: raise HTTPException(status_code=404, detail="Project not found") await db.delete(project) await db.commit() 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), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """Get top-level tasks for a specific project (subtasks are nested).""" await require_project_permission(db, project_id, current_user.id, "read_only") query = ( select(ProjectTask) .options(*_task_load_options()) .where( ProjectTask.project_id == project_id, ProjectTask.parent_task_id.is_(None), ) .order_by(ProjectTask.sort_order.asc()) ) result = await db.execute(query) tasks = result.scalars().unique().all() return tasks @router.post("/{project_id}/tasks", response_model=ProjectTaskResponse, status_code=201) async def create_project_task( project_id: int = Path(ge=1, le=2147483647), task: ProjectTaskCreate = ..., db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """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: parent_result = await db.execute( select(ProjectTask).where( ProjectTask.id == task.parent_task_id, ProjectTask.project_id == project_id, ProjectTask.parent_task_id.is_(None), # Parent must be top-level ) ) parent_task = parent_result.scalar_one_or_none() if not parent_task: raise HTTPException( status_code=400, detail="Parent task not found or is itself a subtask", ) task_data = task.model_dump() task_data["project_id"] = project_id new_task = ProjectTask(**task_data) db.add(new_task) await db.commit() query = ( select(ProjectTask) .options(*_task_load_options()) .where(ProjectTask.id == new_task.id) ) result = await db.execute(query) return result.scalar_one() @router.put("/{project_id}/tasks/reorder", status_code=200) async def reorder_tasks( project_id: int = Path(ge=1, le=2147483647), items: List[ReorderItem] = ..., db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """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] task_result = await db.execute( select(ProjectTask).where( ProjectTask.id.in_(task_ids), ProjectTask.project_id == project_id, ) ) tasks_by_id = {t.id: t for t in task_result.scalars().all()} order_map = {item.id: item.sort_order for item in items} for task_id, task in tasks_by_id.items(): if task_id in order_map: task.sort_order = order_map[task_id] await db.commit() return {"status": "ok"} @router.put("/{project_id}/tasks/{task_id}", response_model=ProjectTaskResponse) async def update_project_task( project_id: int = Path(ge=1, le=2147483647), task_id: int = Path(ge=1, le=2147483647), task_update: ProjectTaskUpdate = ..., db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """Update a project task. Permission checked at project and task level.""" perm = await get_effective_task_permission(db, current_user.id, task_id, project_id) 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( ProjectTask.id == task_id, ProjectTask.project_id == project_id ) ) task = result.scalar_one_or_none() if not task: raise HTTPException(status_code=404, detail="Task not found") 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() query = ( select(ProjectTask) .options(*_task_load_options()) .where(ProjectTask.id == task_id) ) result = await db.execute(query) return result.scalar_one() @router.delete("/{project_id}/tasks/{task_id}", status_code=204) async def delete_project_task( project_id: int = Path(ge=1, le=2147483647), task_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """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( ProjectTask.id == task_id, ProjectTask.project_id == project_id ) ) task = result.scalar_one_or_none() if not task: raise HTTPException(status_code=404, detail="Task not found") await db.delete(task) await db.commit() 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), task_id: int = Path(ge=1, le=2147483647), comment: TaskCommentCreate = ..., db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """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( ProjectTask.id == task_id, ProjectTask.project_id == project_id ) ) task = result.scalar_one_or_none() if not task: raise HTTPException(status_code=404, detail="Task not found") 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 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) async def delete_task_comment( project_id: int = Path(ge=1, le=2147483647), task_id: int = Path(ge=1, le=2147483647), comment_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """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( select(TaskComment).where( TaskComment.id == comment_id, TaskComment.task_id == task_id ) ) comment = result.scalar_one_or_none() 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, )