"""HTTP routes for workspace, members, and invitations.""" from uuid import UUID from fastapi import APIRouter, HTTPException, status from app.dependencies import ( CurrentUserDep, CurrentWorkspaceIdDep, WorkspaceIdDep, WorkspaceServiceDep, ) from app.entities.personal_data import PersonalData from app.models.workspace import ( InvitationResponse, InviteResponse, PersonalDataCreate, PersonalDataResponse, RedeemInviteRequest, WorkspaceCreate, WorkspaceResponse, WorkspaceUpdate, ) from app.utils.errors import InvitationError, NotFoundError, ValidationError router = APIRouter(prefix="/api/workspace", tags=["workspace"]) def _to_response(pd: PersonalData) -> PersonalDataResponse: return PersonalDataResponse( uuid=pd.uuid, workspace_id=pd.workspace_id, name=pd.name, salary_type=pd.salary_type, employment_status=pd.employment_status, created_at=pd.created_at, ) def _to_http_error(exc: NotFoundError | ValidationError) -> HTTPException: """Map a NotFoundError/ValidationError to an HTTPException. Both carry an optional `message_key` + `data` pair (ยง3.5); when present it becomes the structured `detail` so the frontend never has to parse an English sentence. Falls back to the plain message for the (rare) error raised without one. """ code = ( status.HTTP_404_NOT_FOUND if isinstance(exc, NotFoundError) else status.HTTP_422_UNPROCESSABLE_CONTENT ) if exc.message_key: return HTTPException(code, detail={"message_key": exc.message_key, "data": exc.data}) return HTTPException(code, detail=str(exc)) @router.post("", response_model=WorkspaceResponse, status_code=status.HTTP_201_CREATED) async def create_workspace( payload: WorkspaceCreate, service: WorkspaceServiceDep, _current_user: CurrentUserDep, ) -> WorkspaceResponse: ws = await service.create_workspace(payload.name) return WorkspaceResponse.from_entity(ws) @router.get("/{workspace_id}", response_model=WorkspaceResponse) async def get_workspace( workspace_id: WorkspaceIdDep, service: WorkspaceServiceDep, ) -> WorkspaceResponse: try: ws = await service.get_workspace(workspace_id) except NotFoundError as exc: raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc return WorkspaceResponse.from_entity(ws) @router.put("/{workspace_id}", response_model=WorkspaceResponse) async def update_workspace( workspace_id: WorkspaceIdDep, payload: WorkspaceUpdate, service: WorkspaceServiceDep, ) -> WorkspaceResponse: try: ws = await service.update_workspace( workspace_id, payload.name, take_pl_on_red_days=payload.take_pl_on_red_days ) except (NotFoundError, ValidationError) as exc: code = ( status.HTTP_404_NOT_FOUND if isinstance(exc, NotFoundError) else status.HTTP_422_UNPROCESSABLE_CONTENT ) raise HTTPException(code, str(exc)) from exc return WorkspaceResponse.from_entity(ws) @router.get("/{workspace_id}/members", response_model=list[PersonalDataResponse]) async def list_members( workspace_id: WorkspaceIdDep, service: WorkspaceServiceDep, ) -> list[PersonalDataResponse]: members = await service.list_members(workspace_id) return [_to_response(m) for m in members] @router.post( "/{workspace_id}/parents", response_model=PersonalDataResponse, status_code=status.HTTP_201_CREATED, ) async def add_placeholder_parent( workspace_id: WorkspaceIdDep, payload: PersonalDataCreate, service: WorkspaceServiceDep, ) -> PersonalDataResponse: try: pd = await service.add_placeholder_parent(workspace_id, payload.display_name) except (NotFoundError, ValidationError) as exc: raise _to_http_error(exc) from exc return _to_response(pd) @router.patch( "/{workspace_id}/parents/{personal_data_uuid}", response_model=PersonalDataResponse, ) async def rename_parent( workspace_id: WorkspaceIdDep, personal_data_uuid: UUID, payload: PersonalDataCreate, service: WorkspaceServiceDep, ) -> PersonalDataResponse: try: pd = await service.rename_member(workspace_id, personal_data_uuid, payload.display_name) except (NotFoundError, ValidationError) as exc: raise _to_http_error(exc) from exc return _to_response(pd) @router.delete( "/{workspace_id}/parents/{personal_data_uuid}", status_code=status.HTTP_204_NO_CONTENT, ) async def remove_parent( workspace_id: WorkspaceIdDep, personal_data_uuid: UUID, service: WorkspaceServiceDep, current_user: CurrentUserDep, ) -> None: try: await service.remove_member( workspace_id, personal_data_uuid, current_user.personal_data_uuid ) except (NotFoundError, ValidationError) as exc: raise _to_http_error(exc) from exc @router.post( "/{workspace_id}/invitations", response_model=InviteResponse, status_code=status.HTTP_201_CREATED, ) async def create_invitation( workspace_id: WorkspaceIdDep, service: WorkspaceServiceDep, current_user: CurrentUserDep, ) -> InviteResponse: try: raw_token = await service.invite_partner(workspace_id, current_user.id) except NotFoundError as exc: raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc return InviteResponse(token=raw_token) @router.get( "/{workspace_id}/invitations", response_model=list[InvitationResponse], ) async def list_invitations( workspace_id: WorkspaceIdDep, service: WorkspaceServiceDep, ) -> list[InvitationResponse]: """List invitations for this workspace. Never includes `token_hash`. The only way `DELETE /api/invitations/{id}` can be given an id to act on. """ invitations = await service.list_invitations(workspace_id) return [InvitationResponse.from_entity(inv) for inv in invitations] # Invitation redemption is workspace-agnostic (token carries workspace info). _inv_router = APIRouter(prefix="/api/invitations", tags=["invitations"]) @_inv_router.post("/redeem", response_model=InvitationResponse) async def redeem_invitation( payload: RedeemInviteRequest, service: WorkspaceServiceDep, current_user: CurrentUserDep, ) -> InvitationResponse: try: inv = await service.redeem_invitation(payload.token, current_user.personal_data_uuid) except (InvitationError, NotFoundError) as exc: raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc return InvitationResponse.from_entity(inv) @_inv_router.delete("/{invitation_id}", status_code=status.HTTP_204_NO_CONTENT) async def revoke_invitation( invitation_id: UUID, service: WorkspaceServiceDep, workspace_id: CurrentWorkspaceIdDep, ) -> None: try: await service.revoke_invitation(invitation_id, workspace_id) except (NotFoundError, ValidationError) as exc: code = ( status.HTTP_404_NOT_FOUND if isinstance(exc, NotFoundError) else status.HTTP_422_UNPROCESSABLE_CONTENT ) raise HTTPException(code, str(exc)) from exc