from __future__ import annotations from typing import Annotated, Any from uuid import UUID from fastapi import APIRouter, Header, Path, Query, Request, Response, status from app.projects.editor_schemas import ( EditorSaveRequest, EditorStateResponse, ProjectRenderCreate, ProjectRenderListResponse, ProjectRenderResponse, ) from app.projects.schemas import ( ProjectAssetAttach, ProjectAssetListResponse, ProjectAssetResponse, ProjectCreate, ProjectGenerationJobAttach, ProjectGenerationJobListResponse, ProjectGenerationJobResponse, ProjectListResponse, ProjectResponse, ProjectStatus, ProjectUpdate, ) from app.projects.schemas.collaboration import ( TeamResponse, InvitationCreate, InvitationResponse, TeamBase, MemberResponse ) from app.projects.schemas.approval import ApprovalRequest, ReviewComment from app.security.errors import ForbiddenError router = APIRouter(prefix="/v1/projects", tags=["projects"]) @router.post("/workspace/teams", response_model=TeamResponse) async def create_team(request: Request, payload: TeamBase) -> TeamResponse: workspace_id, _, _, _ = _identity(request) return await request.app.state.container.collaboration.create_team(workspace_id, payload.name) @router.get("/workspace/teams", response_model=list[TeamResponse]) async def list_teams(request: Request) -> list[TeamResponse]: workspace_id, _, _, _ = _identity(request) return await request.app.state.container.collaboration.list_teams(workspace_id) @router.get("/workspace/members", response_model=list[MemberResponse]) async def list_members(request: Request) -> list[MemberResponse]: workspace_id, _, _, _ = _identity(request) return await request.app.state.container.collaboration.list_members(workspace_id) @router.get("/workspace/notifications/preferences", response_model=list[dict[str, Any]]) async def list_notification_preferences(request: Request) -> list[dict[str, Any]]: workspace_id, user_id, _, _ = _identity(request) return await request.app.state.container.notifications.get_preferences(workspace_id, user_id) @router.post("/workspace/notifications/preferences", response_model=dict[str, Any]) async def update_notification_preference(request: Request, payload: dict[str, Any]) -> dict[str, Any]: workspace_id, user_id, _, _ = _identity(request) return await request.app.state.container.notifications.update_preference( workspace_id, user_id, payload["event_type"], payload["enabled"] ) @router.post("/workspace/invitations", response_model=InvitationResponse) async def invite_member(request: Request, payload: InvitationCreate) -> InvitationResponse: workspace_id, _, _, _ = _identity(request) return await request.app.state.container.collaboration.invite_member(workspace_id, payload.email, payload.role) @router.patch("/workspace/teams/{team_id}", response_model=TeamResponse) async def update_team(request: Request, team_id: str, payload: TeamBase) -> TeamResponse: workspace_id, _, _, _ = _identity(request) return await request.app.state.container.collaboration.update_team(workspace_id, team_id, payload.name) @router.delete("/workspace/teams/{team_id}", status_code=status.HTTP_204_NO_CONTENT) async def archive_team(request: Request, team_id: str) -> Response: workspace_id, _, _, _ = _identity(request) await request.app.state.container.collaboration.archive_team(workspace_id, team_id) return Response(status_code=status.HTTP_204_NO_CONTENT) @router.get("/workspace/workflows/{workflow_id}/requests", response_model=list[ApprovalRequest]) async def list_approval_requests(request: Request, workflow_id: str) -> list[ApprovalRequest]: return await request.app.state.container.approval.list_requests(workflow_id) @router.post("/workspace/requests/{request_id}/approve", response_model=ApprovalRequest) async def approve_request( request: Request, request_id: str ) -> ApprovalRequest: _, user_id, _, _ = _identity(request) return await request.app.state.container.approval.approve_request(request_id, user_id) @router.post("/workspace/requests/{request_id}/reject", response_model=ApprovalRequest) async def reject_request( request: Request, request_id: str ) -> ApprovalRequest: _, user_id, _, _ = _identity(request) return await request.app.state.container.approval.reject_request(request_id, user_id) @router.post("/workspace/requests/{request_id}/comments", response_model=ReviewComment) async def add_review_comment( request: Request, request_id: str, content: str ) -> ReviewComment: workspace_id, user_id, _, _ = _identity(request) return await request.app.state.container.approval.add_comment( request_id, user_id, workspace_id, content ) @router.delete("/workspace/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT) async def remove_member(request: Request, user_id: str) -> Response: workspace_id, actor_user_id, _, _ = _identity(request) await request.app.state.container.collaboration.remove_member(workspace_id, actor_user_id, user_id) return Response(status_code=status.HTTP_204_NO_CONTENT) @router.patch("/workspace/members/{user_id}/role", response_model=MemberResponse) async def update_member_role(request: Request, user_id: str, new_role: str) -> MemberResponse: workspace_id, actor_user_id, _, _ = _identity(request) await request.app.state.container.collaboration.update_member_role(workspace_id, actor_user_id, user_id, new_role) # Return updated membership membership = await request.app.state.container.collaboration.get_membership(workspace_id, user_id) return MemberResponse(id=membership.id, workspace_id=membership.workspace_id, user_id=membership.user_id, role=membership.role, created_at=membership.created_at.isoformat()) def _identity(request: Request) -> tuple[str, str, str, str]: context = request.state.auth if not context.workspace_id or not context.user_id: # API-key middleware resolves this authoritative membership. Client # request bodies and query strings are never tenant selectors. raise ForbiddenError return ( context.workspace_id, context.user_id, context.api_key_id, request.state.request_id, ) @router.get("", response_model=ProjectListResponse) async def list_projects( request: Request, project_status: Annotated[ProjectStatus | None, Query(alias="status")] = ProjectStatus.ACTIVE, search: Annotated[str | None, Query(min_length=1, max_length=200)] = None, limit: Annotated[int, Query(ge=1, le=100)] = 50, cursor: Annotated[str | None, Query(min_length=1, max_length=1024)] = None, ) -> ProjectListResponse: workspace_id, user_id, _, _ = _identity(request) return await request.app.state.container.projects.list( workspace_id=workspace_id, user_id=user_id, status=project_status, search=search, limit=limit, cursor=cursor, ) @router.post("", response_model=ProjectResponse, status_code=status.HTTP_201_CREATED) async def create_project(request: Request, payload: ProjectCreate) -> ProjectResponse: workspace_id, user_id, api_key_id, request_id = _identity(request) return await request.app.state.container.projects.create( workspace_id=workspace_id, user_id=user_id, api_key_id=api_key_id, request_id=request_id, payload=payload, ) @router.get("/{project_id}/editor", response_model=EditorStateResponse) async def get_project_editor( request: Request, project_id: Annotated[UUID, Path(description="Canonical project UUID")], ) -> EditorStateResponse: workspace_id, user_id, _, _ = _identity(request) return await request.app.state.container.editor.get( workspace_id=workspace_id, user_id=user_id, project_id=str(project_id) ) @router.put("/{project_id}/editor", response_model=EditorStateResponse) async def save_project_editor( request: Request, payload: EditorSaveRequest, project_id: Annotated[UUID, Path(description="Canonical project UUID")], ) -> EditorStateResponse: workspace_id, user_id, api_key_id, request_id = _identity(request) return await request.app.state.container.editor.save( workspace_id=workspace_id, user_id=user_id, api_key_id=api_key_id, request_id=request_id, project_id=str(project_id), payload=payload, ) @router.get("/{project_id}/renders", response_model=ProjectRenderListResponse) async def list_project_renders( request: Request, project_id: Annotated[UUID, Path(description="Canonical project UUID")], ) -> ProjectRenderListResponse: workspace_id, user_id, _, _ = _identity(request) return ProjectRenderListResponse( items=await request.app.state.container.renders.list( workspace_id=workspace_id, user_id=user_id, project_id=str(project_id) ) ) @router.post( "/{project_id}/renders", response_model=ProjectRenderResponse, status_code=status.HTTP_202_ACCEPTED, ) async def create_project_render( request: Request, payload: ProjectRenderCreate, project_id: Annotated[UUID, Path(description="Canonical project UUID")], idempotency_key: Annotated[str | None, Header(alias="Idempotency-Key")] = None, ) -> ProjectRenderResponse: workspace_id, user_id, api_key_id, request_id = _identity(request) request.app.state.container.api_keys.authorize(request.state.auth, "jobs:create") return await request.app.state.container.renders.create( workspace_id=workspace_id, user_id=user_id, api_key_id=api_key_id, request_id=request_id, project_id=str(project_id), payload=payload, idempotency_key=idempotency_key or "", ) @router.get("/{project_id}/renders/{render_id}", response_model=ProjectRenderResponse) async def get_project_render( request: Request, project_id: Annotated[UUID, Path(description="Canonical project UUID")], render_id: Annotated[UUID, Path(description="Canonical render job UUID")], ) -> ProjectRenderResponse: workspace_id, user_id, _, _ = _identity(request) return await request.app.state.container.renders.get( workspace_id=workspace_id, user_id=user_id, project_id=str(project_id), render_id=str(render_id), ) @router.post("/{project_id}/renders/{render_id}/cancel", response_model=ProjectRenderResponse) async def cancel_project_render( request: Request, project_id: Annotated[UUID, Path(description="Canonical project UUID")], render_id: Annotated[UUID, Path(description="Canonical render job UUID")], ) -> ProjectRenderResponse: workspace_id, user_id, api_key_id, request_id = _identity(request) request.app.state.container.api_keys.authorize(request.state.auth, "jobs:cancel") return await request.app.state.container.renders.cancel( workspace_id=workspace_id, user_id=user_id, api_key_id=api_key_id, request_id=request_id, project_id=str(project_id), render_id=str(render_id), ) @router.get("/{project_id}/assets", response_model=ProjectAssetListResponse) async def list_project_assets( request: Request, project_id: Annotated[UUID, Path(description="Canonical project UUID")], ) -> ProjectAssetListResponse: workspace_id, user_id, _, _ = _identity(request) return await request.app.state.container.projects.list_assets( workspace_id=workspace_id, user_id=user_id, project_id=str(project_id), ) @router.post( "/{project_id}/assets", response_model=ProjectAssetResponse, status_code=status.HTTP_201_CREATED, ) async def attach_project_asset( request: Request, payload: ProjectAssetAttach, project_id: Annotated[UUID, Path(description="Canonical project UUID")], ) -> ProjectAssetResponse: workspace_id, user_id, api_key_id, request_id = _identity(request) return await request.app.state.container.projects.attach_asset( workspace_id=workspace_id, user_id=user_id, api_key_id=api_key_id, request_id=request_id, project_id=str(project_id), asset_id=payload.asset_id, ) @router.get("/{project_id}/collaborators", response_model=list[MemberResponse]) async def list_project_collaborators( request: Request, project_id: UUID ) -> list[MemberResponse]: return await request.app.state.container.collaboration.list_project_collaborators(str(project_id)) @router.post("/{project_id}/collaborators", response_model=MemberResponse) async def add_project_collaborator( request: Request, project_id: UUID, user_id: str, role: str ) -> MemberResponse: workspace_id, _, _, _ = _identity(request) return await request.app.state.container.collaboration.add_project_collaborator( workspace_id, str(project_id), user_id, role ) @router.delete("/{project_id}/collaborators/{user_id}", status_code=status.HTTP_204_NO_CONTENT) async def remove_project_collaborator( request: Request, project_id: UUID, user_id: str ) -> Response: await request.app.state.container.collaboration.remove_project_collaborator(str(project_id), user_id) return Response(status_code=status.HTTP_204_NO_CONTENT) @router.get("/{project_id}/jobs", response_model=ProjectGenerationJobListResponse) async def list_project_generation_jobs( request: Request, project_id: Annotated[UUID, Path(description="Canonical project UUID")], ) -> ProjectGenerationJobListResponse: workspace_id, user_id, _, _ = _identity(request) return await request.app.state.container.projects.list_generation_jobs( workspace_id=workspace_id, user_id=user_id, project_id=str(project_id), ) @router.post( "/{project_id}/jobs", response_model=ProjectGenerationJobResponse, status_code=status.HTTP_201_CREATED, ) async def attach_project_generation_job( request: Request, payload: ProjectGenerationJobAttach, project_id: Annotated[UUID, Path(description="Canonical project UUID")], ) -> ProjectGenerationJobResponse: workspace_id, user_id, api_key_id, request_id = _identity(request) return await request.app.state.container.projects.attach_generation_job( workspace_id=workspace_id, user_id=user_id, api_key_id=api_key_id, request_id=request_id, project_id=str(project_id), generation_job_id=payload.generation_job_id, ) @router.delete("/{project_id}/jobs/{job_id}", status_code=status.HTTP_204_NO_CONTENT) async def detach_project_generation_job( request: Request, project_id: Annotated[UUID, Path(description="Canonical project UUID")], job_id: Annotated[UUID, Path(description="Durable generation job UUID")], ) -> Response: workspace_id, user_id, api_key_id, request_id = _identity(request) await request.app.state.container.projects.detach_generation_job( workspace_id=workspace_id, user_id=user_id, api_key_id=api_key_id, request_id=request_id, project_id=str(project_id), generation_job_id=str(job_id), ) return Response(status_code=status.HTTP_204_NO_CONTENT) @router.get("/{project_id}", response_model=ProjectResponse) async def get_project( request: Request, project_id: Annotated[UUID, Path(description="Canonical project UUID")], ) -> ProjectResponse: workspace_id, user_id, _, _ = _identity(request) return await request.app.state.container.projects.get( workspace_id=workspace_id, user_id=user_id, project_id=str(project_id), ) @router.patch("/{project_id}", response_model=ProjectResponse) async def update_project( request: Request, payload: ProjectUpdate, project_id: Annotated[UUID, Path(description="Canonical project UUID")], ) -> ProjectResponse: workspace_id, user_id, api_key_id, request_id = _identity(request) return await request.app.state.container.projects.update( workspace_id=workspace_id, user_id=user_id, api_key_id=api_key_id, request_id=request_id, project_id=str(project_id), payload=payload, ) @router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_project( request: Request, project_id: Annotated[UUID, Path(description="Canonical project UUID")], ) -> Response: workspace_id, user_id, api_key_id, request_id = _identity(request) await request.app.state.container.projects.delete( workspace_id=workspace_id, user_id=user_id, api_key_id=api_key_id, request_id=request_id, project_id=str(project_id), ) return Response(status_code=status.HTTP_204_NO_CONTENT)