Files
F-r-ldraledigplannerare/backend
jacobh2 88197060de Close the residual gaps the 2026-09-18 audit left open
Re-checked every item of the audit's own outcome section against the tree.
Most held, but five items marked fixed had residual gaps — one of them a
live IDOR — and the deferred list was untouched.

Security:
- Analytics IDOR: /api/analytics/{ws}/overview and /projection took
  child_id as an unchecked query param, and get_overview called
  get_days_left_at_age_4 without workspace_id, so the ownership guard
  never ran. Any member could read another workspace's child projection.
  Fixed at both layers.
- Scraper: session ids no longer logged at INFO; a persist-callback
  failure's raw exception text and the FK maintenance banner's scraped
  text no longer reach the poll client.
- Email is now case-normalised at the API boundary, with migration 009
  lowercasing existing rows (aborts naming colliding rows rather than
  failing opaquely on the UNIQUE constraint).

Frontend correctness:
- usePlanProposal/usePlanSync gained the stale-response guard the other
  hooks already had.
- PlanSyncProvider no longer fires POST /plan/sync before auth resolves
  off a blindly-read localStorage workspaceId.
- Busy flags are cleared unconditionally across all 16 useLatestRequest
  consumers. setBusy(true) was unconditional while the clear was guarded
  by isCurrent, so a superseded call stranded its flag forever — under
  StrictMode this hung every in-app navigation behind the PageSkeleton.
  isCurrent decides which response's data wins, never whether a call
  releases the flag it raised.

Performance:
- Calendar no longer blanks on a mutation, and a multi-day save issues N
  writes plus one background refetch instead of 2N requests.
- take_pl_on_red_days moved onto GET /auth/context, dropping a redundant
  GET /workspace/:id from every cold load.

CI and repo hygiene: least-privilege permissions block, actions pinned to
commit SHAs, npm ci, uv sync --locked, failure-only artifact upload,
dependabot; dead frontend/out.js removed; CSP added to the nginx template
(repeated into the location blocks that set their own add_header, since
nginx does not merge them across levels).

Left deferred by decision: refresh-token revocation, the phase-2 contract
change, M4, M5, L2, and H12's cross-route cache. Details and the smaller
follow-ups browser verification turned up are in the audit report.

Backend 901 passed, coverage 92%. Frontend 1322 tests, coverage
98.17/94.79/92.14/98.17.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PngVF38MLpdkgXcByKfdW6
2026-09-21 06:16:23 +02:00
..
2026-06-14 09:58:30 +02:00
2026-06-01 16:48:21 +02:00
2026-06-14 09:58:30 +02:00

Backend — Föräldraledigplannerare

FastAPI + Python 3.14, packages managed with uv. Layered architecture (entities → repositories → services → routers) with dependency injection. Postgres is a partial stub, not a working swap today: only repositories_impl/postgres/vacation_days_repository.py exists; every other app/dependencies.py repository factory raises ValueError for any database_type other than "sqlite". SQLite is the only usable backend until the remaining Postgres repositories land.

Setup

uv sync                 # install deps (incl. dev group)
uv run uvicorn app.main:app --reload   # run dev server on :8000
uv run pytest           # run tests with coverage
uv run ruff check .     # lint

uv run pytest alone does not enforce a coverage floor (pyproject.toml's addopts only requests a report). CI additionally passes --cov-fail-under=80 (see the root CLAUDE.md) — a locally green run can still fail in CI if coverage dropped.

Configuration

Env vars (prefix APP_, nested via __; see .env.example). Settings lives in app/config.py — that file's module docstring mentions a HMAC_PEPPER setting; no such setting exists, so do not look for it here or set it expecting an effect.

  • APP_ENV — development (default) | test | production. In production, Settings refuses to start unless APP_JWT_SECRET is a random value of at least 32 characters (the shipped dev default is rejected). Production also disables /docs, /redoc, and /openapi.json.
  • APP_DATABASE_TYPE — sqlite (default) | postgres (not usable yet — see above).
  • APP_SQLITE_PATH — default ./foraldraledig.db.
  • APP_CORS_ORIGINS — JSON list; default ["http://localhost:5173"].
  • APP_JWT_SECRET — required to be a strong, random secret in production.
  • APP_JWT_ALGORITHM — default HS256.
  • APP_JWT_ACCESS_TOKEN_EXPIRE_MINUTES — default 15.
  • APP_JWT_REFRESH_TOKEN_EXPIRE_DAYS — default 7.
  • APP_LOG_LEVEL — default INFO.
  • APP_SCRAPER_ENABLED — defaults to true. Legal/ToS review of FK scraping + BankID automation completed 2026-06; the scraper is cleared for use. Set to false in any environment without Playwright's Chromium installed (uv run playwright install chromium).
  • APP_SCRAPER_HEADLESS — default true.
  • APP_SCRAPER_SESSION_TTL_SECONDS — default 180; auto-cancels a stalled scraper session.
  • APP_SCRAPER_DEBUG_ENABLED — default false. When enabled, writes personal data to disk (an HTML dump + full-page screenshot of the logged-in FK page — name, balances, payout amounts). Requires APP_SCRAPER_DEBUG_DIR to also be set. Local debugging only; never enable in a shared or production environment.
  • APP_SCRAPER_DEBUG_DIR — where debug artifacts are written when the above is enabled.
  • APP_SCRAPER_DEBUG_DUMP_ON_SUCCESS — default false. Also writes the same debug artifacts (HTML + full-page screenshot) after a successful extraction, not just on error — a capture aid for confirming FK's real DOM (e.g. the family panel structure app/scrapers/fk_scraper.py currently assumes). Same personal-data warning as APP_SCRAPER_DEBUG_ENABLED applies; only takes effect when both APP_SCRAPER_DEBUG_ENABLED and APP_SCRAPER_DEBUG_DIR are also set.
  • APP_SCRAPER_RESULT_RETENTION_SECONDS — default 300; how long a finished scraper session's result stays in memory before being purged.
  • APP_RULES__* — the Swedish parental-leave rule constants (LeaveRules, nested under rules), overridable per field, e.g. APP_RULES__MAX_SAVABLE_DAYS_AT_AGE_4=96. Defaults: 390 sjukpenning days, 90 lägstanivå days, 90 reserved days/parent, 60 max dubbeldagar over the first 15 months, 96 max savable at age 4 (48 per parent when the child has two or more parents — see resolve_age4_savable_cap), age-12 hard expiry, 6 preschool planning days/year, 25 statutory vacation days/year, 20 minimum vacation days/year that must be taken. These are common-case figures only — children born before 2014, multiples, and single custody differ and are not modeled.

Layout

app/
  config.py            # pydantic-settings (app config + frozen LeaveRules)
  dependencies.py      # DI factories (pick repo impl by settings.database_type)
  entities/            # plain dataclasses (domain)
  models/              # pydantic API schemas
  repositories/        # abstract interfaces
  repositories_impl/   # concrete impls per backend (sqlite/, postgres/)
  services/            # business logic
  routers/             # FastAPI routes
  db/connection.py     # connection + (dev) schema bootstrap
  utils/               # errors, logger

Demo data (manual/browser testing)

scripts/seed_demo.py seeds a realistic, ready-to-explore workspace through the normal service/repository layers (no raw SQL) — a couple workspace with a partner, a child born ~6 months ago, vacation rows for only some years (so the is_assumed badge is visible), day overrides, and a spread of leave wishes (fixed, flexible, and one deliberately underfunded to show a shortfall + remedies + surplus advice). Login credentials are fixed and printed at the end.

uv run python scripts/seed_demo.py            # create once; re-running is a no-op
uv run python scripts/seed_demo.py --reset     # wipe the demo workspace and rebuild

Then open http://localhost:5173 and log in with the printed demo@example.com credentials. Safe to re-run: without --reset it detects the existing demo user and changes nothing; --reset only ever deletes the one demo workspace.

Reference vertical slice

VacationDays is implemented end-to-end (entity → schema → repo interface → SQLite impl → service → router → tests). Copy this slice when adding a new entity.

The inline schema in db/connection.py exists only so dev/tests are self-contained. Production schema is owned by Alembic migrations (a senior task).