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>
This commit is contained in:
2026-09-20 11:22:07 +02:00
parent 6904961bb4
commit 0e9b20ec37
88 changed files with 5301 additions and 348 deletions
+87 -11
View File
@@ -209,8 +209,7 @@ class WorkspaceService:
one; a partner who registered and redeemed an invite into the same
workspace does — removing them here would silently break their login.
"""
users = await self._user.list()
return any(u.personal_data_uuid == personal_data_uuid for u in users)
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."""
@@ -264,7 +263,38 @@ class WorkspaceService:
async def redeem_invitation(
self, raw_token: str, personal_data_uuid: UUID
) -> WorkspaceInvitation:
"""Link personal_data to the workspace and mark invitation accepted."""
"""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)
@@ -277,7 +307,6 @@ class WorkspaceService:
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")
@@ -286,22 +315,69 @@ class WorkspaceService:
raise InvitationError("You already belong to this workspace")
existing_members = await self._pd.list_by_workspace(inv.workspace_id)
if len(existing_members) >= 2:
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")
# Both writes must land together: a crash between them would leave
# the redeemer either moved to the new workspace with a still-pending
# (re-usable) invitation, or stuck in their old workspace with an
# invitation marked accepted.
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():
pd.workspace_id = inv.workspace_id
await self._pd.update(pd.uuid, pd)
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: