"""Workspace service: creation, membership, invitation token flow. Invitation security contract: - The service generates a random 32-byte token. - Only SHA-256(token) is persisted (token_hash). - The raw token is returned to the caller exactly once and never stored. - Redemption: hash(presented_token) must match stored token_hash. """ from __future__ import annotations import hashlib import secrets from datetime import datetime, timedelta from uuid import UUID, uuid4 from app.entities.personal_data import PersonalData from app.entities.workspace import Workspace from app.entities.workspace_invitation import WorkspaceInvitation from app.repositories.personal_data_repository import PersonalDataRepository from app.repositories.workspace_repository import ( WorkspaceInvitationRepository, WorkspaceRepository, ) from app.utils.errors import InvitationError, NotFoundError, ValidationError _INVITE_TTL_HOURS = 72 class WorkspaceService: def __init__( self, ws_repo: WorkspaceRepository, inv_repo: WorkspaceInvitationRepository, pd_repo: PersonalDataRepository, ) -> None: self._ws = ws_repo self._inv = inv_repo self._pd = pd_repo # ------------------------------------------------------------------ # Workspace CRUD # ------------------------------------------------------------------ async def create_workspace(self, name: str) -> Workspace: ws = Workspace(id=uuid4(), name=name, created_at=datetime.now()) return await self._ws.create(ws) async def get_workspace(self, workspace_id: UUID) -> Workspace: ws = await self._ws.get_by_id(workspace_id) if ws is None: raise NotFoundError(f"Workspace {workspace_id} not found") return ws async def update_workspace( self, workspace_id: UUID, name: str | None = None, planning_goal: str | None = None ) -> Workspace: ws = await self.get_workspace(workspace_id) if name is not None: ws.name = name if planning_goal is not None: valid = {"max_time", "max_money", "balanced"} if planning_goal not in valid: raise ValidationError(f"planning_goal must be one of {valid}") ws.planning_goal = planning_goal return await self._ws.update(workspace_id, ws) async def list_members(self, workspace_id: UUID) -> list[PersonalData]: """Return all (non-deleted) parent profiles in the workspace.""" return await self._pd.list_by_workspace(workspace_id) async def add_placeholder_parent(self, workspace_id: UUID, display_name: str) -> PersonalData: """Create a parent profile without a login (solo planning for a partner).""" await self.get_workspace(workspace_id) # verify workspace exists pd = PersonalData( uuid=uuid4(), workspace_id=workspace_id, name=display_name, created_at=datetime.now(), ) return await self._pd.create(pd) # ------------------------------------------------------------------ # Invitation flow # ------------------------------------------------------------------ async def invite_partner(self, workspace_id: UUID, invited_by_user_id: UUID) -> str: """Generate a single-use invite token. Returns the RAW token (share out-of-band). Only the SHA-256 hash is stored. The raw token is lost after this call. """ await self.get_workspace(workspace_id) raw_token = secrets.token_urlsafe(32) token_hash = _sha256(raw_token) invitation = WorkspaceInvitation( id=uuid4(), workspace_id=workspace_id, token_hash=token_hash, invited_by_user_id=invited_by_user_id, status="pending", expires_at=datetime.now() + timedelta(hours=_INVITE_TTL_HOURS), created_at=datetime.now(), ) await self._inv.create(invitation) return raw_token async def redeem_invitation( self, raw_token: str, personal_data_uuid: UUID ) -> WorkspaceInvitation: """Link personal_data to the workspace and mark invitation accepted.""" token_hash = _sha256(raw_token) inv = await self._inv.get_by_token_hash(token_hash) if inv is None: raise InvitationError("Invitation not found") if inv.status != "pending": raise InvitationError(f"Invitation is {inv.status}") if datetime.now() > inv.expires_at: inv.status = "expired" await self._inv.update(inv.id, inv) raise InvitationError("Invitation has expired") # Link the personal_data to the workspace. pd = await self._pd.get_by_id(personal_data_uuid) if pd is None: raise NotFoundError(f"PersonalData {personal_data_uuid} not found") pd.workspace_id = inv.workspace_id await self._pd.update(pd.uuid, pd) inv.status = "accepted" inv.accepted_at = datetime.now() return await self._inv.update(inv.id, inv) async def revoke_invitation(self, invitation_id: UUID) -> None: inv = await self._inv.get_by_id(invitation_id) if inv is None: raise NotFoundError(f"Invitation {invitation_id} not found") if inv.status != "pending": raise ValidationError(f"Cannot revoke a {inv.status} invitation") inv.status = "revoked" await self._inv.update(inv.id, inv) def _sha256(value: str) -> str: return hashlib.sha256(value.encode()).hexdigest()