0e9b20ec37
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>
406 lines
18 KiB
Python
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={},
|
|
)
|