UMBRA/backend/app/services/project_sharing.py
Kyle Pope 0a449f166c Polish pass: action all remaining QA suggestions before merge
P-01: Clamp delta poll since param to max 24h in the past (projects +
calendars) to prevent expensive full-table scans from malicious timestamps.

P-02: Validate individual user_id elements in ProjectMemberInvite and
TaskAssignmentCreate with Annotated[int, Field(ge=1, le=2147483647)].

P-04: Only enable delta polling for shared projects (member_count > 0).
Solo projects skip the 5s poll entirely.

P-05: Remove fragile 200ms onBlur timeout in ProjectShareSheet search.
The onMouseDown preventDefault on dropdown items already prevents blur
from firing before click registers.

P-06/S-04: Replace manual dict construction in model_validators with
__table__.columns iteration so new fields are auto-included.

S-01: Replace bare except in ProjectResponse.compute_member_count with
logger.debug to surface errors in development.

S-03: Consolidate cascade_projects_on_disconnect from 2 project ID
queries into 1 using IN clause with both user IDs.

S-05: Send version in toggleTaskMutation, updateTaskStatusMutation,
and toggleSubtaskMutation for full optimistic locking coverage. Handle
409 with refresh toast.

S-07: Replace window.location.href with React Router navigateRef in
task_assigned toast for client-side navigation.

S-08: Already fixed in previous commit (subtask comment selectinload).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 05:28:34 +08:00

270 lines
9.0 KiB
Python

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