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:
@@ -12,6 +12,7 @@ TODO (post-MVP): password reset + email verification flows are not implemented.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
@@ -26,8 +27,11 @@ from app.entities.workspace import Workspace
|
||||
from app.repositories.personal_data_repository import PersonalDataRepository
|
||||
from app.repositories.unit_of_work import NoOpUnitOfWork, UnitOfWork
|
||||
from app.repositories.user_repository import UserRepository
|
||||
from app.repositories.workspace_repository import WorkspaceRepository
|
||||
from app.utils.errors import AuthError, ConflictError, UnauthorizedError
|
||||
from app.repositories.workspace_repository import (
|
||||
WorkspaceInvitationRepository,
|
||||
WorkspaceRepository,
|
||||
)
|
||||
from app.utils.errors import AuthError, ConflictError, InvitationError, UnauthorizedError
|
||||
|
||||
_ph = PasswordHasher()
|
||||
|
||||
@@ -45,10 +49,16 @@ class AuthService:
|
||||
personal_data_repo: PersonalDataRepository,
|
||||
workspace_repo: WorkspaceRepository,
|
||||
uow: UnitOfWork | None = None,
|
||||
inv_repo: WorkspaceInvitationRepository | None = None,
|
||||
) -> None:
|
||||
self._users = user_repo
|
||||
self._personal_data = personal_data_repo
|
||||
self._workspaces = workspace_repo
|
||||
# Optional: only needed for `register(..., invite_token=...)`. Kept
|
||||
# optional (rather than required) so pre-existing direct-construction
|
||||
# call sites (scripts/seed_demo.py, some test fixtures) that never
|
||||
# exercise the invite-registration path keep working unchanged.
|
||||
self._inv = inv_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_auth_service` always wires a real
|
||||
@@ -56,9 +66,32 @@ class AuthService:
|
||||
self._uow = uow or NoOpUnitOfWork()
|
||||
|
||||
async def register(
|
||||
self, email: str, password: str, display_name: str
|
||||
self,
|
||||
email: str,
|
||||
password: str,
|
||||
display_name: str,
|
||||
invite_token: str | None = None,
|
||||
) -> tuple[User, PersonalData]:
|
||||
"""Create User + personal workspace + PersonalData. Raises ConflictError if email taken."""
|
||||
"""Create a User, linked to a PersonalData profile.
|
||||
|
||||
Without `invite_token`: creates a brand-new personal workspace +
|
||||
PersonalData, as before.
|
||||
|
||||
With `invite_token`: skips creating a new workspace altogether and
|
||||
joins the inviting workspace directly, mirroring
|
||||
`WorkspaceService.redeem_invitation`'s claim logic —
|
||||
- a placeholder member (no linked User) in that workspace is claimed
|
||||
(the new User links straight to it; its display name is updated to
|
||||
`display_name`), preserving any plan data already recorded against
|
||||
it;
|
||||
- otherwise, if the workspace has a free slot, a fresh PersonalData
|
||||
row is created there instead of in a new workspace;
|
||||
- otherwise (both slots already have real logins) the invitation is
|
||||
rejected.
|
||||
|
||||
Raises ConflictError if the email is taken, InvitationError for an
|
||||
invalid/expired/full/unconfigured invitation.
|
||||
"""
|
||||
existing = await self._users.get_by_email(email)
|
||||
if existing is not None:
|
||||
raise ConflictError("Email already registered")
|
||||
@@ -68,26 +101,70 @@ class AuthService:
|
||||
# write lock for it.
|
||||
hashed_password = await asyncio.to_thread(_ph.hash, password)
|
||||
|
||||
# Workspace + PersonalData + User must land together: a crash between
|
||||
# any two of these would otherwise leave an orphaned workspace, a
|
||||
# personal_data row with no login, or (with the FK ON DELETE CASCADE)
|
||||
# a user pointing at nothing.
|
||||
async with self._uow():
|
||||
# Create a personal workspace for the registering user.
|
||||
workspace = Workspace(
|
||||
id=uuid4(),
|
||||
name=f"{display_name}s planering",
|
||||
created_at=datetime.now(),
|
||||
)
|
||||
workspace = await self._workspaces.create(workspace)
|
||||
inv = None
|
||||
placeholder: PersonalData | None = None
|
||||
if invite_token is not None:
|
||||
if self._inv is None:
|
||||
raise InvitationError("Invitations are not available")
|
||||
token_hash = hashlib.sha256(invite_token.encode()).hexdigest()
|
||||
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 = PersonalData(
|
||||
uuid=uuid4(),
|
||||
workspace_id=workspace.id,
|
||||
name=display_name,
|
||||
created_at=datetime.now(),
|
||||
)
|
||||
pd = await self._personal_data.create(pd)
|
||||
existing_members = await self._personal_data.list_by_workspace(inv.workspace_id)
|
||||
for member in existing_members:
|
||||
if await self._users.get_by_personal_data_uuid(member.uuid) is None:
|
||||
placeholder = member
|
||||
break
|
||||
if placeholder is None and len(existing_members) >= 2:
|
||||
raise InvitationError("This workspace already has its maximum number of members")
|
||||
|
||||
# Workspace/PersonalData creation (or placeholder claim) + User +
|
||||
# invitation acceptance must land together: a crash partway through
|
||||
# would otherwise leave an orphaned workspace, a personal_data row
|
||||
# with no login, a user pointing at nothing (FK ON DELETE CASCADE),
|
||||
# or an invitation stuck out of step with membership.
|
||||
async with self._uow():
|
||||
if inv is not None:
|
||||
if placeholder is not None:
|
||||
pd = placeholder
|
||||
if pd.name != display_name:
|
||||
pd.name = display_name
|
||||
pd = await self._personal_data.update(pd.uuid, pd)
|
||||
else:
|
||||
pd = PersonalData(
|
||||
uuid=uuid4(),
|
||||
workspace_id=inv.workspace_id,
|
||||
name=display_name,
|
||||
created_at=datetime.now(),
|
||||
)
|
||||
pd = await self._personal_data.create(pd)
|
||||
|
||||
inv.status = "accepted"
|
||||
inv.accepted_at = datetime.now()
|
||||
assert self._inv is not None
|
||||
await self._inv.update(inv.id, inv)
|
||||
else:
|
||||
workspace = Workspace(
|
||||
id=uuid4(),
|
||||
name=f"{display_name}s planering",
|
||||
created_at=datetime.now(),
|
||||
)
|
||||
workspace = await self._workspaces.create(workspace)
|
||||
|
||||
pd = PersonalData(
|
||||
uuid=uuid4(),
|
||||
workspace_id=workspace.id,
|
||||
name=display_name,
|
||||
created_at=datetime.now(),
|
||||
)
|
||||
pd = await self._personal_data.create(pd)
|
||||
|
||||
user = User(
|
||||
id=uuid4(),
|
||||
|
||||
Reference in New Issue
Block a user