diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 46604615f..16598243b 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -37,7 +37,10 @@ Reserved next strategic milestones: alerts. Refreshed strategic plan with concrete file paths at `.planning/phase-W6-v1423-product-lead-review.md`. P1 is now a ~5-day iOS Swift sprint because every server-side contract - landed in v1.4.23. + landed in v1.4.23. Per-user timezone foundation + (`.planning/feature-user-timezone.md`, issue #167) is now scoped + into v1.4.25 — Marc directive 2026-05-14, packs the iOS-launch + prep into the v1.4.25 polish window instead of slipping it. - **v1.6+** — Auto-correlation discovery (FDR-controlled), Coach full-page route at `/insights/coach`, conversation-driven goal setting. diff --git a/.planning/STATE.md b/.planning/STATE.md index 1b22a766b..d4dff4c10 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -216,9 +216,10 @@ MEDs, all LOWs, S-05 simplify, pre-existing `coach-prefs.test.ts` mock issue, sandbox `git commit` no-op). Test deltas: W5 ended at 2223 unit. Session A added the `revoke.test.ts` -+ `apns-dispatch` device tests landing at 2227. Session E ended at 2235. -Session F added the forged-X-Device-Id regression — final at 2236 unit -(+45 across the W5+W6 reconcile arc). + +- `apns-dispatch` device tests landing at 2227. Session E ended at 2235. + Session F added the forged-X-Device-Id regression — final at 2236 unit + (+45 across the W5+W6 reconcile arc). ## Wave 7 — Release v1.4.23 diff --git a/.planning/feature-user-timezone.md b/.planning/feature-user-timezone.md new file mode 100644 index 000000000..06aa8fcbb --- /dev/null +++ b/.planning/feature-user-timezone.md @@ -0,0 +1,345 @@ +# Feature proposal — per-user timezone + +**Status:** documented, not scheduled. +**Trigger:** GitHub issue +[#167](https://github.com/MBombeck/HealthLog/issues/167) (Warsaw user, +v1.4.23). +**Captured:** 2026-05-12, during the v1.4.24 release retrospective. +**Owner:** unassigned — picks up in v1.5 product-lead planning. + +This document is the long-form record so a future planning pass does +not start from zero. Read together with +`.planning/v15-backlog.md` (which only carries the one-line entry). + +--- + +## 1. Background + +Issue #167 reports that a Warsaw user enters a measurement at 11:05 +local time, then sees `09:05` in the CSV export. He set +`TZ=Europe/Warsaw` + `PGTZ=Europe/Warsaw` on the container; the +discrepancy persisted. + +That is not a deployment bug. The export writes ISO-8601 with the `Z` +suffix (UTC), and his viewer (Excel / LibreOffice) drops the marker, +so the user reads UTC as local time. The CSV is technically correct, +the framing is hostile. See `src/lib/export.ts:53, :103, :104, :129`. + +The interesting part of the issue is what it surfaces: HealthLog is +hardcoded to `Europe/Berlin` end-to-end. The Warsaw reporter is in the +same UTC offset as Berlin year-round (CET/CEST), so he is the easiest +case. The same code path mis-labels every reading for a user in +London, New York, Tokyo or São Paulo — and does it silently. + +--- + +## 2. Current state — where Berlin is wired in + +Hardcoded `"Europe/Berlin"` references at the time of writing +(`grep -rn "Europe/Berlin" src/`): + +| File | Role | +| ----------------------------------------------------- | --------------------------------------------------------- | +| `src/lib/format-locale.ts:18` | `DISPLAY_TIMEZONE` constant — single source for display. | +| `src/lib/analytics/berlin-day.ts:17` | Day-bucket key for analytics (`YYYY-MM-DD` in Berlin). | +| `src/lib/analytics/correlations.ts:109, :117` | Weekly ISO-Monday bucketing for correlations. | +| `src/lib/analytics/bp-in-target.ts:64, :70, :160` | SYS/DIA pairing fallback when timestamps drift ≥ 5 min. | +| `src/lib/charts/bucket-time-series.ts:38` | Daily / weekly / monthly chart buckets. | +| `src/lib/charts/comparison-shift.ts` | `chartData_compare` overlay anchored at Berlin-day-noon. | +| `src/components/charts/health-chart.tsx:195` | X-axis tick formatter. | +| `src/components/medications/medication-card.tsx` | "Last taken" + next-window time labels. | +| `src/components/admin/api-token-overview-section.tsx` | "Created / last used" timestamps in the admin table. | +| `Dockerfile` | `ENV TZ=Europe/Berlin` — container TZ for the Node clock. | +| `CLAUDE.md` | Convention: "Berlin for display, UTC for storage". | + +Reminder schedules: + +| File | Role | +| ---------------------------------------------------------- | ----------------------------------------------------------------------------- | +| `src/lib/jobs/reminder-worker.ts` | pg-boss cron tick interpreted in container TZ. | +| `src/lib/medications/window-resolution.ts` (or equivalent) | `windowStart` / `windowEnd` strings (`"07:00"`–`"09:00"`) are container-time. | +| `ReminderPhaseConfig` rows in `prisma/schema.prisma:387` | Window thresholds per user, schema-naive ("just a string"). | + +Persistence: + +- `Measurement.measuredAt`, `MedicationIntakeEvent.scheduledFor` / + `.takenAt`, `MoodEntry.moodLoggedAt` are `DateTime` (`timestamptz`). + Postgres stores UTC; correct. No write-path changes needed. +- `MoodEntry.date` is a `String` formatted `YYYY-MM-DD` — anchored to + Berlin at the time of write. **This is the migration risk**: the + same calendar moment falls into different `date` strings depending + on the chosen anchor. See §7. +- `MedicationSchedule.windowStart` / `.windowEnd` are `String` + `"HH:MM"` — same problem: a "08:00" window is Berlin local today. + +--- + +## 3. Symptom catalogue — beyond the CSV + +Closing the CSV alone is a 4-files patch. The architectural problem is +broader. Each row below is a separate symptom that an end-user can +hit: + +1. **CSV / JSON export reads as UTC.** Issue #167. Fix in `export.ts`. +2. **Reminders fire at Berlin time, not user time.** A Tokyo user sets + a 08:00 morning reminder; the pg-boss cron tick checks against + container TZ, so the message arrives at 16:00 Tokyo. Hidden today + because the only known users are CET/CEST. +3. **"Today" buckets misalign.** A user logs a 23:30 reading in their + local zone that maps to "tomorrow" UTC and "tomorrow" Berlin → + their day-summary card carries the reading on the wrong calendar + day. Same effect on the streak counter (achievements) and the + "today vs yesterday" comparison. +4. **Mood-entry date drift.** `MoodEntry.date` is a `String` written + in Berlin. A New York user entering at 21:00 EDT (= 03:00 next-day + Berlin) gets the wrong `date` string, which then disagrees with the + `moodLoggedAt` timestamp. +5. **Withings sync day-mapping.** Withings ships UTC timestamps; + `withings-sync` buckets them via `berlin-day.ts`. A reading taken + at 23:50 local in Tokyo (14:50 UTC) lands on yesterday-Berlin + instead of today-Tokyo. The user sees their evening reading on + yesterday's chart. +6. **Charts X-axis labels.** Mobile chart x-axis label formatter + passes `timeZone: "Europe/Berlin"` directly. A New York user sees + `Mai 11` for a reading they remember entering on `May 10` 22:30 EDT. +7. **Email / push notification timestamps.** Notification body + templates render times in the dispatcher, container TZ. Same + mis-attribution as #2 once #2 is fixed. +8. **PDF doctor report.** `src/app/api/doctor-report/pdf/route.ts` + renders rows with `formatInTimeZone` against Berlin. A PDF a US + user takes to their doctor lists every reading at a Berlin + timestamp. +9. **AI Coach + analytics context.** The Coach prompt context says + "yesterday" and "this week" against Berlin-anchored buckets. Same + day-misalignment problem as #3, but tucked inside the prompt where + nobody will see it until a user complains the recommendation is + for the wrong day. +10. **OpenAPI / iOS DTO contract.** `docs/api/openapi.yaml` ships + timestamps as `string($date-time)` without a contract on offset. + iOS will round-trip them as UTC. If the future iOS app renders + its own clock, it will diverge from the web UI until the web UI + learns user TZ too. + +That is ten distinct surfaces, not one. + +--- + +## 4. Design space + +### Option A — leave display TZ alone, fix the export only + +CSV/JSON write ISO-8601 with offset (`2026-05-11T11:05:00+02:00`) +instead of `Z`. Same instant, machine-parseable, human readable. + +- Scope: 4 lines in `src/lib/export.ts` + 2 test updates. +- Closes issue #167. +- Does not fix the 9 other symptoms. +- Sensible if the product stays Europe-only. +- **Cost:** ~2 h. + +### Option B — per-user `displayTimezone` setting, opt-in, default Berlin + +Add a `User.displayTimezone` column (DEFAULT `"Europe/Berlin"`). +Surface a picker in `/profile`. Every display-side helper takes the +user pref instead of `DISPLAY_TIMEZONE`. Reminder cron + Withings +day-mapping + Coach context all become user-TZ-aware. + +- Scope: large. ~30 files touch the constant, ~10 buckets need to + parametrise their bucketing helper, ~5 surfaces switch from `Intl` + literal to a hook/util. +- Existing data is unaffected by storage (still `timestamptz`). +- Existing `MoodEntry.date` strings and `MedicationSchedule.windowStart` + / `.windowEnd` are the migration risk — see §7. +- Reminder worker rewrite: pg-boss cron is timezone-naive; choose + between (a) per-user cron rows, (b) global 1-minute tick with + in-app "is it time in user TZ?", (c) precompute the next absolute + UTC fire time per reminder on every settings change and on each fire. +- **Cost:** 1–2 weeks elapsed, conservative. The honest number when + every symptom from §3 is closed and the test suite is parametrised + per TZ branch. +- **Recommended.** + +### Option C — auto-detect from browser + +`Intl.DateTimeFormat().resolvedOptions().timeZone` from the client, +stored in a cookie or sent with every request. No user-visible +picker. + +- Implicit: the user never opted in, never sees what they are set to, + cannot fix a wrong guess. +- Useless for the iOS app, CLI consumers, API-token clients. +- **Rejected** as a primary mechanism. Reasonable as the _default + value_ for the §B picker on signup. + +--- + +## 5. Recommended path + +1. **v1.4.25 quick fix** (Option A, separate from this proposal): + ship the ISO-8601-with-offset export so issue #167 closes + immediately. Adds an `aria-label` / footer note in the export-UI + that timestamps carry the user's display zone. Documented in the + v1.4.25 changelog as a follow-on; no roadmap impact. +2. **v1.5 (or later) full feature** (Option B): plan as a dedicated + wave. The migration risk in §7 dominates the schedule. + +The two are independent — A can ship next week, B can sit on the +backlog without blocking it. + +--- + +## 6. Migration strategy for Option B + +### 6.1 Schema additions (forward-only, no breakage) + +```prisma +model User { + ... + displayTimezone String @default("Europe/Berlin") + ... +} +``` + +Migration `00XX_user_display_timezone` — additive, DEFAULT covers +every existing row, no backfill needed. + +### 6.2 Constant → user-pref accessor + +Replace every direct `DISPLAY_TIMEZONE` import with a context-aware +helper: + +```ts +// Server side +const tz = await getUserTimezone(); // reads from session.user.displayTimezone + +// Client side +const tz = useUserTimezone(); // hook, reads from auth context +``` + +`format-locale.ts` keeps exporting `DISPLAY_TIMEZONE` as a _fallback_ +for non-user-scoped surfaces (admin tables, audit log viewer, +analytics QA dashboard — every place where "whose timezone?" has no +answer). Worker code (`reminder-worker.ts`, `withings-sync`) reads the +target user's pref before any bucketing. + +### 6.3 Per-surface fixes (order matches §3) + +| # | Surface | Change | +| --- | ---------------------- | ---------------------------------------------------------------------- | +| 1 | CSV/JSON export | `formatInTimeZone(..., userTz, "yyyy-MM-dd'T'HH:mm:ssXXX")` | +| 2 | Reminder worker | recompute next-fire on settings change, in user TZ | +| 3 | "Today" buckets | `berlin-day.ts` → `user-day.ts` taking `tz` arg | +| 4 | `MoodEntry.date` drift | see §7 | +| 5 | Withings sync | bucket via user-tz instead of `BERLIN_TZ` | +| 6 | Chart x-axis | tick formatter takes `userTz` prop, falls back to Berlin | +| 7 | Notification body | dispatcher reads recipient's tz before formatting | +| 8 | PDF doctor report | header reads user's tz; if export is for a target user, use theirs | +| 9 | Coach prompt context | "yesterday" / "this week" computed in user tz | +| 10 | OpenAPI / iOS DTO | document timestamps as `string($date-time)` w/ note: always UTC offset | + +### 6.4 Test strategy + +`berlin-day.test.ts` has the right shape but pins to Berlin. The +parametrised version runs each test under 3–5 representative zones: + +- `Europe/Berlin` — DST boundary cases stay green. +- `UTC` — sanity baseline. +- `America/New_York` — large negative offset, DST. +- `Asia/Tokyo` — large positive offset, no DST. +- `Pacific/Kiritimati` — UTC+14, extreme positive offset (catches + off-by-one-day bugs in date-only strings). + +A test helper `withTimezone(tz, () => { ... })` wraps the existing +suites. Expect ~30 existing tests to be parametrised; total suite size +roughly doubles for the TZ-sensitive units. Acceptable. + +--- + +## 7. The real risk — date-only strings + +The only thing in the schema that is _not_ time-instant data is the +two strings: + +- `MoodEntry.date` (`YYYY-MM-DD`) — written from + `formatInTimeZone(loggedAt, BERLIN_TZ, "yyyy-MM-dd")`. +- `MedicationSchedule.windowStart` / `.windowEnd` (`HH:MM`) — set by + the user via the medication form; they read as "Berlin local time" + today. + +When the user switches their `displayTimezone` from `Europe/Berlin` to +`America/New_York`: + +- **Mood entries:** every historical `MoodEntry.date` is now a Berlin + calendar day, but the UI will render its day-summary card by + New-York-calendar-day. Existing entries near midnight will appear + to "jump" between days. + - **Decision A — freeze:** TZ is captured at the time of write. + Schema gains `MoodEntry.tz TEXT NOT NULL DEFAULT 'Europe/Berlin'`, + UI groups by `(tz, date)`. Historical entries stay where the user + logged them. **Recommended.** + - **Decision B — recompute:** treat `date` as derived from + `moodLoggedAt`, redo the string under the new TZ. Cleaner code, + but historical "day" assignments shift, which can change streak + counts and break achievements awarded retroactively. +- **Medication windows:** "07:00" today means "07:00 in Berlin". A + user moving to Tokyo legitimately wants their morning dose at + Tokyo-07:00, not Berlin-07:00. **Decision A — freeze** is wrong + here, because the user's expectation is that their reminder follows + them. **Decision B — re-anchor** is correct: schedules are stored + as "wall clock in current display TZ" and reinterpreted under the + new TZ. +- The asymmetry is real: historical observations want freeze, future + schedules want re-anchor. The schema needs to express both + contracts. + +### 7.1 DST edge cases + +The existing `berlin-day.test.ts` already covers Berlin DST. The +parametrised version inherits this. **Open question:** what happens +when a reminder window straddles a DST boundary in the user's TZ? +Today this is a Berlin-only concern and pg-boss handles it implicitly +via container TZ. With per-user TZ, the resolver has to make a choice +("fire at the wall-clock time on both sides" vs "fire at the absolute +UTC time of the pre-DST occurrence"). Document and pick a convention +before implementation. + +--- + +## 8. Out-of-scope for this proposal + +- Per-tenant / per-org TZ: HealthLog is single-tenant. +- TZ-by-IP fallback for unauthenticated surfaces: pricing dashboards + and the auth pages stay Berlin. +- Localised number formats (German 1.234,56 vs US 1,234.56): tracked + separately under i18n. +- Calendar locale (Monday-start vs Sunday-start week): same. + +--- + +## 9. Open questions for the v1.5 product-lead pass + +1. Is per-user TZ a v1.5 commitment, or does it slip to v1.6+ behind + the iOS work? +2. If v1.5, does it ship before or after Apple Health import? Apple + Health entries carry their own source-TZ metadata — could simplify + the migration if we lift that as the canonical representation. +3. Decision A (freeze) vs Decision B (re-anchor) split per surface + needs a product call, not an engineering call. Pre-fill from §7. +4. Acceptance criterion for "done": pick 1–2 user complaints we are + willing to ship with, and 1–2 that block release. Probably "Coach + prompt context drift" is acceptable for an MVP; "reminder fires at + wrong wall-clock time" is not. + +--- + +## 10. Pointers for the next agent + +- This file lives at `.planning/feature-user-timezone.md`. +- One-line carry in `.planning/v15-backlog.md` under "From issue + triage". +- One-line carry in `.planning/ROADMAP.md` under v1.5 / v1.6+ reserved + themes. +- Triggering issue: GitHub #167 (Warsaw user, v1.4.23, CSV export + shows UTC). +- The v1.4.25 quick fix (Option A) is independent — file it as its + own backlog row, not blocked on this proposal. diff --git a/.planning/phase-W6-v1423-reconcile-report.md b/.planning/phase-W6-v1423-reconcile-report.md index 370ad73d2..598fd2c89 100644 --- a/.planning/phase-W6-v1423-reconcile-report.md +++ b/.planning/phase-W6-v1423-reconcile-report.md @@ -6,13 +6,13 @@ six-review brief into shippable develop-branch state. ## Per-session SHA chain -| Session | Scope | Commit SHAs | -| --- | --- | --- | -| A | CRITICAL + HIGH 1-3 | `5486507`, `13977bc`, `d5202e5`, `a2dfc5e` | -| B | HIGH 4 + simplify (S-02 → S-04) + HIGH 6 | `3ee6dab`, `dfffe6b`, `d25e50e` | -| D | HIGH 5 (analytics chunked aggregate landed in W5; D session re-confirmed against the W6 senior-dev brief) | `deadc73` | -| E | HIGH 7 (Coach feedback referencing `coach_messages`, plaintext column drop) | `650f150` | -| F | MED cluster (Sec-MED-2 Coolify scrub, Sr-MED-5 isCurrent device-id, SLEEP_DURATION migration verification note) + planning record | `1a46bfe` (MED) + this commit (planning) | +| Session | Scope | Commit SHAs | +| ------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | +| A | CRITICAL + HIGH 1-3 | `5486507`, `13977bc`, `d5202e5`, `a2dfc5e` | +| B | HIGH 4 + simplify (S-02 → S-04) + HIGH 6 | `3ee6dab`, `dfffe6b`, `d25e50e` | +| D | HIGH 5 (analytics chunked aggregate landed in W5; D session re-confirmed against the W6 senior-dev brief) | `deadc73` | +| E | HIGH 7 (Coach feedback referencing `coach_messages`, plaintext column drop) | `650f150` | +| F | MED cluster (Sec-MED-2 Coolify scrub, Sr-MED-5 isCurrent device-id, SLEEP_DURATION migration verification note) + planning record | `1a46bfe` (MED) + this commit (planning) | ## Counts @@ -28,14 +28,14 @@ six-review brief into shippable develop-branch state. ## Test count delta -| Snapshot | Unit count | -| --- | --- | -| W5 close (commit `72829b1`) | 2223 | -| Session A close (CRIT + HIGH 1-3 added device + revoke tests) | 2227 | -| Session B close (HIGH 4 + simplify + HIGH 6) | ~2229 | -| Session D close (HIGH 5 reconfirm) | ~2229 | -| Session E close (HIGH 7 + coach-feedback updates) | 2235 | -| Session F close (forged X-Device-Id regression) | **2236** | +| Snapshot | Unit count | +| ------------------------------------------------------------- | ---------- | +| W5 close (commit `72829b1`) | 2223 | +| Session A close (CRIT + HIGH 1-3 added device + revoke tests) | 2227 | +| Session B close (HIGH 4 + simplify + HIGH 6) | ~2229 | +| Session D close (HIGH 5 reconfirm) | ~2229 | +| Session E close (HIGH 7 + coach-feedback updates) | 2235 | +| Session F close (forged X-Device-Id regression) | **2236** | Net delta over the W6 reconcile arc: **+13 unit tests**. diff --git a/.planning/v1423-backlog.md b/.planning/v1423-backlog.md index 3efc928f4..65183c8ea 100644 --- a/.planning/v1423-backlog.md +++ b/.planning/v1423-backlog.md @@ -12,7 +12,7 @@ without re-reading the full review pack. the settings cog) duplicates intent. Defer to v1.4.24 once W5 H7 surfaces enough thumbs data to know whether per-user prompt prefs drift from per-message ratings. Source: `phase-W6-v1423-design-review.md` - + `phase-W6-v1423-product-lead-review.md`. + - `phase-W6-v1423-product-lead-review.md`. ## Statistics diff --git a/.planning/v15-backlog.md b/.planning/v15-backlog.md index f8105b0b6..1865eeb98 100644 --- a/.planning/v15-backlog.md +++ b/.planning/v15-backlog.md @@ -384,6 +384,21 @@ of the strategic content there is the v1.4.20 plan (Insights redesign commits (A4, A5, A6 in v1.4.19). v1.4.20 should require one worktree per parallel agent. +## From issue triage + +- **Per-user timezone (Option B in the proposal).** Issue #167 + surfaced that the entire app is hardcoded to `Europe/Berlin` — UI + display, analytics buckets, reminder cron, Withings sync mapping, + PDF report, Coach context, ten distinct symptoms in total. The + full design space, migration strategy, schema changes, DST and + date-string risks (`MoodEntry.date` freeze vs `MedicationSchedule` + re-anchor — they need different contracts), test parametrisation + plan and product-call questions are written up in + `.planning/feature-user-timezone.md`. Sized L: 1–2 weeks elapsed. + Independent from the v1.4.25 quick fix (ISO-8601-with-offset CSV + export) which closes #167 on its own without touching this scope. + Triggering issue: GitHub #167. + ## Process / meta - **Cross-agent commit-message drift** — reduced materially in diff --git a/CHANGELOG.md b/CHANGELOG.md index 64f9fc0f1..ac7ed9014 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,130 @@ # Changelog +## [1.4.24] — 2026-05-12 + +Security + accessibility hardening release. Closes the highest-priority +findings from a focused security audit of the v1.4.23 release: a Bearer +token that omits a scope no longer authenticates routes which never +declared one, the public Umami proxy is rate-limited, the proxy nonce +is Edge-runtime portable, a session-expiry delete race can no longer +500 a parallel request, the import-validation envelope now matches the +standard error contract, and the runtime Docker image installs Prisma +7.8 alongside pinned worker dependencies. Accessibility passes on the +notifications surface, admin shell, chart overlays and notification +settings deep-link anchors round out the user-facing work. + +### Security + +- **Bearer auth is fail-closed when a route declares no scope.** + `requireAuth()` previously let any non-revoked, non-expired API token + through whenever a handler omitted the `requiredPermission` argument. + A narrowly-scoped token (e.g. `["medication:ingest"]`) could therefore + reach every handler that called `requireAuth()` bare — including + account-sensitive routes such as `/api/auth/profile`, + `/api/auth/password`, `/api/withings/credentials`, + `/api/settings/data`, `/api/export/full-backup` and `/api/tokens`. + The Bearer path now throws `HttpError(403)` with audit reason + `scope_required` unless the token carries the `["*"]` wildcard + (the iOS app login token), preserving the existing session-cookie + path and the explicit-scope path (`/api/ingest/medications`). The + admin surface is unaffected — `requireAdmin()` was already + cookie-only. +- **Public Umami analytics proxy now rate-limits by client IP.** + `POST /api/send` forwards browser analytics events to the configured + Umami origin. The SSRF allow-list and 64 KB body cap were already in + place, but the endpoint itself was unbounded. Added a 120-events-per- + minute-per-IP gate using the existing `checkRateLimit` infrastructure; + abuse returns the standard `429` envelope without invoking the + upstream fetch. +- **Edge-runtime portable nonce generation in the proxy.** The proxy + previously assembled the CSP nonce via `Buffer.from(...)`, which is + not guaranteed in the Edge runtime. Switched to + `btoa(String.fromCharCode(...new Uint8Array(16)))`. Same 128 bits of + entropy, Node-and-Edge compatible without a runtime declaration. +- **Session-expiry delete race no longer 500s a parallel request.** + `getSession()` reaped expired session rows with `prisma.session +.delete()`; two requests arriving at the same expired session would + race and the loser would surface a 500. Replaced with `deleteMany` + + a catch-all guard, matching the discipline already used in + `destroySession()`. + +### Fixed + +- **`POST /api/import` returns the standard `{ data: null, error }` + envelope on validation failure.** The handler previously returned + the validation message inside `apiSuccess(...)` with status 422, + which clients could not distinguish from a successful import. +- **Runtime Docker image pins Prisma 7.8 + worker dependencies.** The + multi-stage image was installing `prisma@7.4.0`, `@prisma/engines +@7.4.0`, unpinned `pg-boss@12`, `@prisma/adapter-pg@7` and `pg@8` + into the worker prefix while the app code shipped Prisma 7.8 from + the lockfile. Pinned to `prisma@7.8`, `@prisma/engines@7.8`, + `pg-boss@12.18`, `@prisma/adapter-pg@7.8` and `pg@8.20` so + migration engine and generated client cannot drift across a deploy. +- **Notifications page surfaces a load error instead of a silent + "no channels" empty state.** `useQuery` failures now render an + alert with `role="alert"` and `notifications.loadError` copy + (EN/DE), so a transient 500 on `/api/notifications/preferences` + stops looking like a misconfiguration. + +### Accessibility + +- **Notification-preference switches carry accessible names.** Each + switch in the per-event table now exposes an `aria-label` combining + event-type label and channel label; the mobile per-event layout pairs + each `` with its `