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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user