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>
995 lines
35 KiB
Python
995 lines
35 KiB
Python
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,
|
|
)
|