Files
F-r-ldraledigplannerare/backend/app/services/workspace_service.py
T
jacobh2 0e9b20ec37 Track preschool planning days, partner invites and real vacation forfeiture
Three strands of work that grew together across the same settings screen and
API client, so they land as one change.

Per-year preschool planning days ("studiedagar"): new table, repository
(SQLite + Postgres), service, router and migration 007, plus the hooks and
Settings UI to plan and record them per child and year. Allocation now
reserves the recorded figure for a year where one exists and only falls back
to the child-level estimate otherwise, marking that fallback as assumed.

Partner invitations: workspaces issue and list invitations, registration
accepts an invite token to join the inviter's workspace instead of creating a
personal one, and a /join/:token page plus the settings invite panel drive it.

Vacation forfeiture: tips and the dashboard now distinguish days a parent
genuinely loses from days that merely age, so the warning stops firing on
balances that were never at risk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 11:22:07 +02:00

406 lines
18 KiB
Python

"""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={},
)