"""Document sharing endpoints.""" from __future__ import annotations import uuid from fastapi import APIRouter, Depends, HTTPException, Query, Request, status from pydantic import BaseModel, field_validator from sqlalchemy import select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from db.models import Document, Share, User from deps.auth import get_regular_user from deps.db import get_db from deps.utils import get_client_ip from services.audit import write_audit_log router = APIRouter(prefix="/api/shares", tags=["shares"]) class ShareCreate(BaseModel): document_id: str recipient_handle: str permission: str = "view" @field_validator("permission") @classmethod def validate_permission(cls, v: str) -> str: if v not in {"view", "edit"}: raise ValueError("permission must be 'view' or 'edit'") return v class SharePermissionPatch(BaseModel): permission: str @field_validator("permission") @classmethod def validate_permission(cls, v: str) -> str: if v not in {"view", "edit"}: raise ValueError("permission must be 'view' or 'edit'") return v def _parse_uuid(value: str, detail: str) -> uuid.UUID: try: return uuid.UUID(value) except ValueError: raise HTTPException(status_code=404, detail=detail) async def _get_owned_document( session: AsyncSession, document_id: str, owner_id: uuid.UUID, ) -> Document: uid = _parse_uuid(document_id, "Document not found") doc = await session.get(Document, uid) if doc is None or doc.user_id != owner_id: raise HTTPException(status_code=404, detail="Document not found") return doc async def _get_owned_share( session: AsyncSession, share_id: str, owner_id: uuid.UUID, ) -> Share: sid = _parse_uuid(share_id, "Share not found") share = await session.get(Share, sid) if share is None or share.owner_id != owner_id: raise HTTPException(status_code=404, detail="Share not found") return share def _share_to_dict(share: Share, recipient: User) -> dict: return { "id": str(share.id), "document_id": str(share.document_id), "owner_id": str(share.owner_id), "recipient_id": str(share.recipient_id), "recipient_handle": recipient.handle, "permission": share.permission, "created_at": share.created_at.isoformat() if share.created_at else None, } @router.post("", status_code=status.HTTP_201_CREATED) async def grant_share( body: ShareCreate, request: Request, session: AsyncSession = Depends(get_db), current_user: User = Depends(get_regular_user), ) -> dict: doc = await _get_owned_document(session, body.document_id, current_user.id) # Recipient lookup by exact handle (D-04) result = await session.execute( select(User).where(User.handle == body.recipient_handle) ) recipient = result.scalar_one_or_none() if recipient is None: raise HTTPException(status_code=404, detail="User not found") # Self-share prevention if recipient.id == current_user.id: raise HTTPException(status_code=400, detail="Cannot share with yourself") # Create the share row share = Share( document_id=doc.id, owner_id=current_user.id, recipient_id=recipient.id, permission=body.permission, ) session.add(share) try: await session.flush() except IntegrityError: await session.rollback() raise HTTPException( status_code=409, detail="Document already shared with this user" ) # Audit log — write within same transaction (D-14) await write_audit_log( session=session, event_type="share.granted", user_id=current_user.id, actor_id=current_user.id, resource_id=doc.id, ip_address=get_client_ip(request), metadata_={"recipient_id": str(recipient.id)}, ) await session.commit() return _share_to_dict(share, recipient) @router.get("") async def list_shares( document_id: str = Query(...), session: AsyncSession = Depends(get_db), current_user: User = Depends(get_regular_user), ) -> dict: doc = await _get_owned_document(session, document_id, current_user.id) stmt = ( select(Share, User) .join(User, User.id == Share.recipient_id) .where(Share.document_id == doc.id) ) result = await session.execute(stmt) items = [ { "id": str(share.id), "recipient_id": str(share.recipient_id), "recipient_handle": recipient.handle, "permission": share.permission, "created_at": share.created_at.isoformat() if share.created_at else None, } for share, recipient in result.all() ] return {"items": items} # CRITICAL: This endpoint MUST be defined BEFORE DELETE /api/shares/{share_id}. # Defining it after would cause FastAPI to route GET /api/shares/received as # DELETE with share_id="received" (path parameter conflict). @router.get("/received") async def list_shared_with_me( session: AsyncSession = Depends(get_db), current_user: User = Depends(get_regular_user), ) -> dict: stmt = ( select(Document, User) .join(Share, Share.document_id == Document.id) .join(User, User.id == Share.owner_id) .where(Share.recipient_id == current_user.id) .order_by(Document.created_at.desc()) ) result = await session.execute(stmt) rows = result.all() items = [ { "id": str(doc.id), "filename": doc.filename, "content_type": doc.content_type, "size_bytes": doc.size_bytes, "created_at": doc.created_at.isoformat() if doc.created_at else None, "owner_handle": owner.handle, } for doc, owner in rows ] return {"items": items} @router.patch("/{share_id}", status_code=200) async def update_share_permission( share_id: str, body: SharePermissionPatch, request: Request, session: AsyncSession = Depends(get_db), current_user: User = Depends(get_regular_user), ) -> dict: share = await _get_owned_share(session, share_id, current_user.id) share.permission = body.permission await write_audit_log( session=session, event_type="share.permission_changed", user_id=current_user.id, actor_id=current_user.id, resource_id=share.document_id, ip_address=get_client_ip(request), metadata_={"share_id": str(share.id), "new_permission": body.permission}, ) await session.commit() return {"id": str(share.id), "permission": share.permission} @router.delete("/{share_id}", status_code=status.HTTP_204_NO_CONTENT) async def revoke_share( share_id: str, request: Request, session: AsyncSession = Depends(get_db), current_user: User = Depends(get_regular_user), ) -> None: share = await _get_owned_share(session, share_id, current_user.id) document_id = share.document_id recipient_id = share.recipient_id await session.delete(share) await write_audit_log( session=session, event_type="share.revoked", user_id=current_user.id, actor_id=current_user.id, resource_id=document_id, ip_address=get_client_ip(request), metadata_={"recipient_id": str(recipient_id)}, ) await session.commit()