Files
F-r-ldraledigplannerare/backend/app/services/leave_wish_service.py
T
jacobh2 02db5430eb Harden the API and the data layer after a security and performance audit
Security
- Every route now requires a login and refuses ids outside the caller's
  workspace with 404: plan, leave-wishes, day-overrides and vacation-days
  were fully anonymous; every other router trusted client-supplied
  workspace/child/parent ids. Scraper sessions are bound to their owner.
- Invitations can no longer be created for a foreign workspace, and a
  redeemer who is already a member (or a full workspace) is refused.
- Production refuses the default JWT secret; API docs are hidden in
  production; a failed migration aborts startup instead of serving.
- Login and register no longer reveal whether an email exists; inactive
  accounts are rejected; /auth/refresh is rate limited.
- The FK scraper writes debug artefacts only when explicitly enabled,
  logs no scraped values or page text, clears the BankID QR as soon as it
  is scanned and purges finished sessions after five minutes.
- Real FK page captures are removed from the tree (history rewrite
  follows); the parser tests use a synthetic fixture.

Performance and correctness
- Plan apply/sync run inside one locked transaction with batched writes
  instead of one commit per row, so a failure mid-batch leaves nothing
  half-applied and concurrent syncs no longer corrupt each other.
- SQLite runs in WAL mode with a busy timeout; 14 indexes cover the
  filters that were full scans; argon2 hashing runs off the event loop;
  responses are gzipped.
- Frontend: a 401 now refreshes the token once and retries, so sessions
  no longer die silently after 15 minutes; every data hook discards
  stale responses; logout clears state even when the server call fails.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CsXCCcDfmV4Sps4ZQSvNEY
2026-09-18 07:54:21 +02:00

331 lines
14 KiB
Python

"""Business logic for leave wishes (family recurring home-together intent).
See PLANNING_ENGINE_SPEC.md §2.1. This service owns the validation the
allocator (Phase B) depends on: valid recurrence/parent_mode, sane month/day
ranges, and the parent_mode <-> parent_personal_data_uuid pairing rule.
"""
from datetime import date, datetime
from uuid import UUID, uuid4
from app.entities.leave_wish import LeaveWish
from app.repositories.leave_wish_repository import LeaveWishRepository
from app.repositories.planned_leave_repository import PlannedLeaveRepository
from app.utils.errors import NotFoundError, ValidationError
_VALID_RECURRENCE = {"yearly", "once"}
_VALID_PARENT_MODE = {"either", "both", "specific"}
_VALID_WINDOW_MODE = {"fixed", "flexible"}
# A leap year, so day=29 for month=2 (Feb 29) is accepted when validating ranges.
_LEAP_REFERENCE_YEAR = 2028
class LeaveWishService:
def __init__(
self,
repo: LeaveWishRepository,
planned_leave_repo: PlannedLeaveRepository,
) -> None:
self._repo = repo
self._planned_leave_repo = planned_leave_repo
async def create(
self,
workspace_id: UUID,
name: str,
window_start_month: int,
window_start_day: int,
window_end_month: int,
window_end_day: int,
target_weeks: int,
first_year: int,
child_id: UUID | None = None,
name_message_key: str | None = None,
target_days: int | None = None,
recurrence: str = "once",
last_year: int | None = None,
priority: int = 1,
parent_mode: str = "either",
parent_personal_data_uuid: UUID | None = None,
window_mode: str = "fixed",
) -> LeaveWish:
_validate(
window_start_month=window_start_month,
window_start_day=window_start_day,
window_end_month=window_end_month,
window_end_day=window_end_day,
target_weeks=target_weeks,
target_days=target_days,
recurrence=recurrence,
first_year=first_year,
last_year=last_year,
parent_mode=parent_mode,
parent_personal_data_uuid=parent_personal_data_uuid,
window_mode=window_mode,
)
now = datetime.now()
entity = LeaveWish(
id=uuid4(),
workspace_id=workspace_id,
child_id=child_id,
name=name,
name_message_key=name_message_key,
window_start_month=window_start_month,
window_start_day=window_start_day,
window_end_month=window_end_month,
window_end_day=window_end_day,
target_weeks=target_weeks,
target_days=target_days,
recurrence=recurrence,
first_year=first_year,
last_year=last_year,
priority=priority,
parent_mode=parent_mode,
parent_personal_data_uuid=parent_personal_data_uuid,
window_mode=window_mode,
created_at=now,
updated_at=now,
)
return await self._repo.create(entity)
async def get(self, leave_wish_id: UUID, workspace_id: UUID | None = None) -> LeaveWish:
"""`workspace_id`, when given, must match the entity's own — callers
that already know the caller's tenant (routers) pass it so a foreign
workspace's wish 404s exactly like a nonexistent one; internal
callers that don't need that check (e.g. `update`'s own re-reads
with no tenant context) leave it `None`.
"""
entity = await self._repo.get_by_id(leave_wish_id)
if entity is None or (workspace_id is not None and entity.workspace_id != workspace_id):
raise NotFoundError(f"LeaveWish {leave_wish_id} not found")
return entity
async def list_for_workspace(self, workspace_id: UUID) -> list[LeaveWish]:
return await self._repo.get_by_workspace(workspace_id)
async def update(
self,
leave_wish_id: UUID,
workspace_id: UUID | None = None,
child_id: UUID | None = None,
name: str | None = None,
name_message_key: str | None = None,
window_start_month: int | None = None,
window_start_day: int | None = None,
window_end_month: int | None = None,
window_end_day: int | None = None,
target_weeks: int | None = None,
target_days: int | None = None,
recurrence: str | None = None,
first_year: int | None = None,
last_year: int | None = None,
priority: int | None = None,
parent_mode: str | None = None,
parent_personal_data_uuid: UUID | None = None,
window_mode: str | None = None,
_clear_child_id: bool = False,
_clear_last_year: bool = False,
_clear_parent_personal_data_uuid: bool = False,
_clear_name_message_key: bool = False,
_clear_target_days: bool = False,
) -> LeaveWish:
"""Partial update — only non-None fields are applied.
`_clear_*` flags exist because a nullable field's "no change" and
"set to None" both look like `None` on the wire; the router passes
them based on whether the field was present in the request payload.
`name_message_key` is additionally enforced here rather than left to
callers: whenever `name` actually changes, the key is force-cleared
regardless of what was passed for it, since a caller (frontend bug,
future API client, ...) forgetting to clear it would silently make a
user's edited name revert to a stale preset label.
`_clear_target_days` resets a day-granular wish back to being sized
by `target_weeks` alone.
"""
entity = await self.get(leave_wish_id, workspace_id)
original_name = entity.name
if _clear_child_id:
entity.child_id = None
elif child_id is not None:
entity.child_id = child_id
if _clear_name_message_key:
entity.name_message_key = None
elif name_message_key is not None:
entity.name_message_key = name_message_key
if name is not None:
entity.name = name
if name != original_name:
entity.name_message_key = None
if window_start_month is not None:
entity.window_start_month = window_start_month
if window_start_day is not None:
entity.window_start_day = window_start_day
if window_end_month is not None:
entity.window_end_month = window_end_month
if window_end_day is not None:
entity.window_end_day = window_end_day
if target_weeks is not None:
entity.target_weeks = target_weeks
if _clear_target_days:
entity.target_days = None
elif target_days is not None:
entity.target_days = target_days
if recurrence is not None:
entity.recurrence = recurrence
if first_year is not None:
entity.first_year = first_year
if _clear_last_year:
entity.last_year = None
elif last_year is not None:
entity.last_year = last_year
if priority is not None:
entity.priority = priority
if parent_mode is not None:
entity.parent_mode = parent_mode
if _clear_parent_personal_data_uuid:
entity.parent_personal_data_uuid = None
elif parent_personal_data_uuid is not None:
entity.parent_personal_data_uuid = parent_personal_data_uuid
if window_mode is not None:
entity.window_mode = window_mode
_validate(
window_start_month=entity.window_start_month,
window_start_day=entity.window_start_day,
window_end_month=entity.window_end_month,
window_end_day=entity.window_end_day,
target_weeks=entity.target_weeks,
target_days=entity.target_days,
recurrence=entity.recurrence,
first_year=entity.first_year,
last_year=entity.last_year,
parent_mode=entity.parent_mode,
parent_personal_data_uuid=entity.parent_personal_data_uuid,
window_mode=entity.window_mode,
)
return await self._repo.update(leave_wish_id, entity)
async def delete(self, leave_wish_id: UUID, workspace_id: UUID | None = None) -> None:
"""Delete a wish and every `planned_leave_day` row it produced.
Under the calendar-direct-manipulation UX a period *is* its wish: the
band the user drew, the days the allocator filled it with, and any
of those days the user later hand-edited are all one thing. Deleting
the period must remove all of it — including pinned days.
`wish_id` is nullable and `ON DELETE SET NULL` at the schema level
(so a row can still exist detached/ad-hoc), but that "orphan, don't
delete" behavior is wrong for this call path: the rows are deleted
explicitly, *before* the wish row goes, so the FK's SET NULL never
gets a row left to act on. See `PlannedLeaveRepository.delete_by_wish`.
A pinned day is deleted too rather than surviving as ad-hoc. Pinning
only protects a day from being silently retyped/reassigned by a
later `sync_plan` re-plan (see that method's docstring) — it is not
a signal that the day should outlive the period a user explicitly
chose to delete. This also matches the ad-hoc-period delete path
(`Calendar.handleDeletePeriod`'s no-`wishId` branch), which already
deletes every day in the band regardless of `is_pinned` with no
special-casing.
"""
await self.get(leave_wish_id, workspace_id) # raises NotFoundError if missing/foreign
await self._planned_leave_repo.delete_by_wish(leave_wish_id)
await self._repo.delete(leave_wish_id)
def _validate(
*,
window_start_month: int,
window_start_day: int,
window_end_month: int,
window_end_day: int,
target_weeks: int,
target_days: int | None,
recurrence: str,
first_year: int,
last_year: int | None,
parent_mode: str,
parent_personal_data_uuid: UUID | None,
window_mode: str,
) -> None:
if recurrence not in _VALID_RECURRENCE:
raise ValidationError(f"recurrence must be one of {_VALID_RECURRENCE}")
if parent_mode not in _VALID_PARENT_MODE:
raise ValidationError(f"parent_mode must be one of {_VALID_PARENT_MODE}")
if window_mode not in _VALID_WINDOW_MODE:
raise ValidationError(f"window_mode must be one of {_VALID_WINDOW_MODE}")
_validate_month_day(window_start_month, window_start_day, label="window_start")
_validate_month_day(window_end_month, window_end_day, label="window_end")
if target_weeks <= 0:
raise ValidationError("target_weeks must be > 0")
if target_days is not None and target_days < 1:
raise ValidationError("target_days must be >= 1")
if last_year is not None and last_year < first_year:
raise ValidationError("last_year must be >= first_year")
if parent_mode == "specific" and parent_personal_data_uuid is None:
raise ValidationError(
"parent_personal_data_uuid is required when parent_mode is 'specific'"
)
if parent_mode != "specific" and parent_personal_data_uuid is not None:
raise ValidationError(
"parent_personal_data_uuid must not be set unless parent_mode is 'specific'"
)
if window_mode == "flexible":
# PLANNING_ENGINE_SPEC.md §10.1 — the bounding range's raw length
# (before any horizon clipping) must be able to fit the wanted
# duration; "find a 6-week window inside this 2-week range" is a
# user-input error, not a legitimate future shortfall. Mirrors
# `LeaveWish.target_span_days()`: `target_days` wins when set,
# otherwise `target_weeks * 7` (no entity exists yet in `create()`).
bound_days = _bound_length_days(
window_start_month, window_start_day, window_end_month, window_end_day, first_year
)
needed_days = target_days if target_days is not None else target_weeks * 7
if bound_days < needed_days:
raise ValidationError(
"flexible wish bounding range "
f"({bound_days} days) is shorter than the wanted duration "
f"({needed_days} days)"
)
def _validate_month_day(month: int, day: int, *, label: str) -> None:
if not (1 <= month <= 12):
raise ValidationError(f"{label}_month must be between 1 and 12")
if not (1 <= day <= 31):
raise ValidationError(f"{label}_day must be between 1 and 31")
try:
# Use a leap year as the reference so Feb 29 validates; this only
# checks the (month, day) combination is a real calendar date, not
# that it is valid in every year it might recur in.
date(_LEAP_REFERENCE_YEAR, month, day)
except ValueError as exc:
raise ValidationError(f"{label} is not a valid calendar date: {month}-{day}") from exc
def _bound_length_days(
start_month: int, start_day: int, end_month: int, end_day: int, anchor_year: int
) -> int:
"""Span (inclusive, in days) of a window/bound anchored at `anchor_year`.
Mirrors `LeaveWish.window_wraps()`/`window_for_year()` without requiring a
full entity — used at validation time (§10.1), before an entity exists in
`create()`. `anchor_year` should be a real calendar year (not the leap
reference year `_validate_month_day` uses) so Feb 29 in a non-leap
`first_year` naturally raises via `date()`, consistent with
`_validate_month_day`'s own calendar-date check.
"""
wraps = (end_month, end_day) < (start_month, start_day)
start = date(anchor_year, start_month, start_day)
end_year = anchor_year + 1 if wraps else anchor_year
end = date(end_year, end_month, end_day)
return (end - start).days + 1