"""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.leave_wish_repository import LeaveWishRepository from app.repositories.parental_leave_repository import ParentalLeaveRepository from app.repositories.personal_data_repository import PersonalDataRepository from app.repositories.planned_leave_repository import PlannedLeaveRepository from app.repositories.unit_of_work import NoOpUnitOfWork, UnitOfWork from app.repositories.user_repository import UserRepository from app.repositories.vacation_days_repository import VacationDaysRepository 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, user_repo: UserRepository, leave_wish_repo: LeaveWishRepository, planned_leave_repo: PlannedLeaveRepository, parental_leave_repo: ParentalLeaveRepository, vacation_days_repo: VacationDaysRepository, uow: UnitOfWork | None = None, ) -> None: self._ws = ws_repo self._inv = inv_repo self._pd = pd_repo self._user = user_repo self._leave_wish = leave_wish_repo self._planned_leave = planned_leave_repo self._parental_leave = parental_leave_repo self._vacation_days = vacation_days_repo # Defaults to a no-op so pre-existing direct-construction call sites # (scripts/seed_demo.py, test fixtures) keep today's per-write-commit # behavior; `app.dependencies.get_workspace_service` always wires a # real `SQLiteUnitOfWork`. self._uow = uow or NoOpUnitOfWork() # ------------------------------------------------------------------ # 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", message_key="workspace.notFound", data={"workspace_id": str(workspace_id)}, ) return ws async def update_workspace( self, workspace_id: UUID, name: str | None = None, *, take_pl_on_red_days: bool | None = None, ) -> Workspace: ws = await self.get_workspace(workspace_id) if name is not None: ws.name = name if take_pl_on_red_days is not None: ws.take_pl_on_red_days = take_pl_on_red_days 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). Capped at 2 parents per workspace (a child has exactly two legal parents in this model) — mirrors the same cap enforced on the invitation-redemption path (`redeem_invitation`). """ _validate_display_name(display_name) await self.get_workspace(workspace_id) # verify workspace exists existing_members = await self._pd.list_by_workspace(workspace_id) if len(existing_members) >= 2: raise ValidationError( "This workspace already has its maximum number of parents", message_key="workspace.parents.add.maxReached", data={}, ) pd = PersonalData( uuid=uuid4(), workspace_id=workspace_id, name=display_name, created_at=datetime.now(), ) return await self._pd.create(pd) async def rename_member( self, workspace_id: UUID, personal_data_uuid: UUID, display_name: str ) -> PersonalData: """Rename a workspace member's display name. Allowed for the placeholder partner and for the logged-in user's own profile alike — there is no other endpoint that can correct a typo'd name (the settings profile-update path only covers salary_type / employment_status / min_vacation_days_per_year), so this is the one place a name gets fixed. """ _validate_display_name(display_name) pd = await self._get_member(workspace_id, personal_data_uuid) pd.name = display_name return await self._pd.update(personal_data_uuid, pd) async def remove_member( self, workspace_id: UUID, personal_data_uuid: UUID, requesting_personal_data_uuid: UUID, ) -> None: """Soft-delete a placeholder partner from a workspace. Refuses (ValidationError, 422 at the router) when: - the target is the caller's own profile, - the target has its own login (it is not actually a "placeholder" — e.g. a real partner who redeemed an invite into this workspace), - the target still has dependent plan data (leave wishes, planned leave days, parental-leave rows, or vacation-days rows) that would be orphaned. """ await self._get_member(workspace_id, personal_data_uuid) # 404 if not a member if personal_data_uuid == requesting_personal_data_uuid: raise ValidationError( "Cannot remove your own profile from the workspace", message_key="workspace.parents.remove.self", data={}, ) if await self._has_login(personal_data_uuid): raise ValidationError( "Cannot remove a member that has their own login", message_key="workspace.parents.remove.hasLogin", data={}, ) blockers = await self._dependent_data_blockers(personal_data_uuid) if blockers: raise ValidationError( f"Cannot remove member with existing {', '.join(blockers)}", message_key="workspace.parents.remove.hasDependentData", data={"blockers": blockers}, ) await self._pd.soft_delete(personal_data_uuid) async def _get_member(self, workspace_id: UUID, personal_data_uuid: UUID) -> PersonalData: """Return the member, raising NotFoundError if the workspace or membership is missing. Deliberately checks membership via `list_by_workspace` rather than a bare `pd_repo.get_by_id` — a `personal_data_uuid` that exists but belongs to a *different* workspace must 404 here, not succeed. """ ws = await self._ws.get_by_id(workspace_id) if ws is None: raise NotFoundError( f"Workspace {workspace_id} not found", message_key="workspace.notFound", data={"workspace_id": str(workspace_id)}, ) members = await self._pd.list_by_workspace(workspace_id) for member in members: if member.uuid == personal_data_uuid: return member raise NotFoundError( f"PersonalData {personal_data_uuid} is not a member of workspace {workspace_id}", message_key="workspace.parents.notFound", data={"personal_data_uuid": str(personal_data_uuid)}, ) async def _has_login(self, personal_data_uuid: UUID) -> bool: """True if a User account is linked to this personal_data_uuid. A genuine placeholder (created via `add_placeholder_parent`) never has one; a partner who registered and redeemed an invite into the same workspace does — removing them here would silently break their login. """ return await self._user.get_by_personal_data_uuid(personal_data_uuid) is not None async def _dependent_data_blockers(self, personal_data_uuid: UUID) -> list[str]: """Return the kinds of plan data that would be orphaned by removing this member.""" blockers: list[str] = [] # LeaveWishRepository.list() only recognizes workspace_id/child_id # filters today — passing parent_personal_data_uuid would be silently # ignored rather than applied (returning every wish in every # workspace unfiltered). Fetch unfiltered and filter in Python # instead, so an unsupported filter can never be mistaken for "no # wishes for this parent". all_wishes = await self._leave_wish.list() if any(w.parent_personal_data_uuid == personal_data_uuid for w in all_wishes): blockers.append("leave_wishes") if await self._planned_leave.list(parent_personal_data_uuid=personal_data_uuid): blockers.append("planned_leave") if await self._parental_leave.list(personal_data_uuid=personal_data_uuid): blockers.append("parental_leave") if await self._vacation_days.list(personal_data_uuid=personal_data_uuid): blockers.append("vacation_days") return blockers # ------------------------------------------------------------------ # 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 the redeemer to the invitation's workspace and mark it accepted. The target workspace's second slot is in one of three states: - **Placeholder present** (a `personal_data` row with no linked `User` — see `_has_login`; typically created by the onboarding wizard's `add_placeholder_parent` before the real partner ever registers). The redeemer's `User.personal_data_uuid` is re-pointed at that placeholder instead of moving the redeemer's own profile in, so any plan data already recorded against the placeholder (leave wishes, planned leave, parental-leave snapshots, vacation days — all denormalize `workspace_id` alongside their own `personal_data_uuid`) is preserved untouched: the placeholder's `workspace_id` already equals `inv.workspace_id`, so none of those rows need updating. The redeemer's now-orphaned auto-created `personal_data` row is soft-deleted. - **Free slot, no placeholder**: today's behaviour — move the redeemer's own `personal_data.workspace_id` into the target workspace. - **Both slots already have real logins**: refused. The claim path refuses (`InvitationError`) when the redeemer's own `personal_data_uuid` already has dependent plan data of its own: that data's rows also carry a denormalized `workspace_id`/ `personal_data_uuid`, and no repository method exists to repoint those columns onto the placeholder without either overwriting whatever the placeholder already holds or leaving two conflicting rows (e.g. two `vacation_days` rows for the same year). Refusing is safer than silently losing or corrupting either side's data; this is expected to be rare in practice since redemption normally follows registration immediately, before the redeemer has entered anything. """ 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") pd = await self._pd.get_by_id(personal_data_uuid) if pd is None: raise NotFoundError(f"PersonalData {personal_data_uuid} not found") if pd.workspace_id == inv.workspace_id: raise InvitationError("You already belong to this workspace") existing_members = await self._pd.list_by_workspace(inv.workspace_id) placeholder: PersonalData | None = None for member in existing_members: if not await self._has_login(member.uuid): placeholder = member break if placeholder is None and len(existing_members) >= 2: raise InvitationError("This workspace already has its maximum number of members") if placeholder is not None: blockers = await self._dependent_data_blockers(personal_data_uuid) if blockers: raise InvitationError( "Cannot redeem: your account already has " f"{', '.join(blockers)} that cannot be merged automatically" ) # Every write (including, on the claim path, re-pointing the login # and discarding the redeemer's stub profile) must land together: a # crash partway through would otherwise strand the user with no # usable profile at all, or leave the invitation re-usable/consumed # out of step with the membership change. async with self._uow(): if placeholder is not None: user = await self._user.get_by_personal_data_uuid(personal_data_uuid) if user is not None: user.personal_data_uuid = placeholder.uuid await self._user.update(user.id, user) # Carry the redeemer's already-chosen display name over the # placeholder's provisional one (e.g. "Partner"). if placeholder.name != pd.name: placeholder.name = pd.name await self._pd.update(placeholder.uuid, placeholder) # The placeholder already belongs to `inv.workspace_id`, and # so do its dependent rows -- nothing else to repoint. The # redeemer's stub `personal_data` row is now unreachable from # any login; soft-delete it per the GDPR audit-trail # convention (see `PersonalDataRepository`). Its now- # memberless `workspace` row has no soft-delete column (no # `deleted_at` in schema, no `hard_delete` on # `WorkspaceRepository` — a schema gap, not fixed here per # the "STOP and report" instruction for migrations) and is # left in place as a harmless orphan (no PII beyond a name). await self._pd.soft_delete(personal_data_uuid) else: pd.workspace_id = inv.workspace_id await self._pd.update(pd.uuid, pd) inv.status = "accepted" inv.accepted_at = datetime.now() inv = await self._inv.update(inv.id, inv) return inv async def list_invitations(self, workspace_id: UUID) -> list[WorkspaceInvitation]: """Return every invitation ever issued for this workspace, newest first. Exists so `DELETE /api/invitations/{id}` has a discoverable id to act on. Never exposes `token_hash` — callers must use `InvitationResponse`, which omits it. """ await self.get_workspace(workspace_id) # 404 if the workspace doesn't exist return await self._inv.list_by_workspace(workspace_id) async def revoke_invitation(self, invitation_id: UUID, workspace_id: UUID) -> None: inv = await self._inv.get_by_id(invitation_id) if inv is None or inv.workspace_id != workspace_id: 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() def _validate_display_name(display_name: str) -> None: """Shared validation for add_placeholder_parent and rename_member so the two paths agree on what counts as a valid name (Pydantic's `min_length=1` on `display_name` does not reject a whitespace-only string). """ if not display_name.strip(): raise ValidationError( "display_name must not be empty or whitespace-only", message_key="workspace.parents.invalidName", data={}, )