Files
F-r-ldraledigplannerare/backend/app/routers/analytics.py
T
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

109 lines
3.7 KiB
Python

"""HTTP routes for analytics / projections."""
from uuid import UUID
from fastapi import APIRouter, HTTPException, status
from app.dependencies import (
ChildRepositoryDep,
ParentalLeaveRepositoryDep,
PersonalDataRepositoryDep,
PlannedLeaveRepositoryDep,
VacationDaysRepositoryDep,
WorkspaceIdDep,
WorkspaceRepositoryDep,
WorkspaceScopeDep,
)
from app.models.analytics import Age4ProjectionResponse, WorkspaceOverviewResponse
from app.services.analytics_service import AnalyticsService
from app.utils.errors import NotFoundError
router = APIRouter(prefix="/api/analytics", tags=["analytics"])
def _make_service(
leave_repo: ParentalLeaveRepositoryDep,
planned_repo: PlannedLeaveRepositoryDep,
child_repo: ChildRepositoryDep,
pd_repo: PersonalDataRepositoryDep,
workspace_repo: WorkspaceRepositoryDep,
vacation_repo: VacationDaysRepositoryDep,
) -> AnalyticsService:
return AnalyticsService(
leave_repo, planned_repo, child_repo, pd_repo, workspace_repo, vacation_repo
)
@router.get("/{workspace_id}/overview", response_model=WorkspaceOverviewResponse)
async def get_overview(
workspace_id: WorkspaceIdDep,
child_id: UUID,
leave_repo: ParentalLeaveRepositoryDep,
planned_repo: PlannedLeaveRepositoryDep,
child_repo: ChildRepositoryDep,
pd_repo: PersonalDataRepositoryDep,
workspace_repo: WorkspaceRepositoryDep,
vacation_repo: VacationDaysRepositoryDep,
scope: WorkspaceScopeDep,
) -> WorkspaceOverviewResponse:
await scope.check_child(child_id)
service = _make_service(
leave_repo, planned_repo, child_repo, pd_repo, workspace_repo, vacation_repo
)
try:
overview = await service.get_overview(workspace_id, child_id)
except NotFoundError as exc:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
return WorkspaceOverviewResponse.from_entity(overview)
@router.get("/{workspace_id}/projection", response_model=WorkspaceOverviewResponse)
async def get_projection(
workspace_id: WorkspaceIdDep,
child_id: UUID,
leave_repo: ParentalLeaveRepositoryDep,
planned_repo: PlannedLeaveRepositoryDep,
child_repo: ChildRepositoryDep,
pd_repo: PersonalDataRepositoryDep,
workspace_repo: WorkspaceRepositoryDep,
vacation_repo: VacationDaysRepositoryDep,
scope: WorkspaceScopeDep,
) -> WorkspaceOverviewResponse:
await scope.check_child(child_id)
service = _make_service(
leave_repo, planned_repo, child_repo, pd_repo, workspace_repo, vacation_repo
)
try:
overview = await service.get_overview(workspace_id, child_id)
except NotFoundError as exc:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
return WorkspaceOverviewResponse.from_entity(overview)
@router.get(
"/parent/{personal_data_uuid}/child/{child_id}/age4", response_model=Age4ProjectionResponse
)
async def get_age4_projection(
personal_data_uuid: UUID,
child_id: UUID,
leave_repo: ParentalLeaveRepositoryDep,
planned_repo: PlannedLeaveRepositoryDep,
child_repo: ChildRepositoryDep,
pd_repo: PersonalDataRepositoryDep,
workspace_repo: WorkspaceRepositoryDep,
vacation_repo: VacationDaysRepositoryDep,
scope: WorkspaceScopeDep,
) -> Age4ProjectionResponse:
await scope.check_member(personal_data_uuid)
await scope.check_child(child_id)
service = _make_service(
leave_repo, planned_repo, child_repo, pd_repo, workspace_repo, vacation_repo
)
try:
proj = await service.get_days_left_at_age_4(
personal_data_uuid, child_id, workspace_id=scope.workspace_id
)
except NotFoundError as exc:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
return Age4ProjectionResponse.from_entity(proj)