Commit Graph
53 Commits
Author SHA1 Message Date
Marc-André Bombeck b99b07ad38 feat(profile): capture the insurer IK number
Add an optional German insurer institution number (IKNR, 9 digits) to
the user profile, alongside the existing insurer name and KVNR. The
IKNR is a public registry identifier for the institution, not a
per-person quasi-identifier, so it is stored plaintext, matching
insurer_name rather than the encrypted KVNR column.

The profile GET echoes the field; PATCH/PUT accept it via Zod —
optional, empty string maps to null, and a non-empty value must be
exactly 9 digits (422 otherwise, no checksum). The write is built
field-by-field. /api/user/profile gains an OpenAPI registration with
the profile read/write schemas.

Migration 0102 adds the nullable column, NULL-defaulted, forward-only.
2026-06-01 23:24:10 +02:00
Marc-André Bombeck cf649f72ce feat(mood): structured tag taxonomy + note capture and timeline
Add the Category -> Tag taxonomy the mood surface was missing and the
free-text note the web form could never write, then surface both on the
Insights mood page.

Schema (migration 0101): a global MoodTagCategory -> MoodTag catalog
plus a MoodEntryTagLink join, seeded with a default five-category set
(i18n-keyed labels, Lucide icons). Purely additive — the legacy flat
MoodEntry.tags JSON column, the GLP-1 quick-tags, and the Telegram /
MoodLog sync are untouched; an entry can carry both axes.

Capture: the mood-logging form and the edit sheet gain a Textarea for
the note (model + API already accepted it) and an icon-chip picker over
the taxonomy catalog (GET /api/mood/tags). Create / update resolve the
picked keys to links; unknown keys drop silently against the catalog.

Insights: a structured-tag breakdown (icon + per-tag avg-mood lift) and
a notes timeline join the existing distribution / weekday / flat-tag
sections. The aggregate extension also fixes the flat-tag axis, which
read the raw JSON string and so never populated.

i18n across all six locales. Tests cover the taxonomy aggregation, the
note round-trip, the structured-tag schema, and the notes timeline.
2026-06-01 20:25:42 +02:00
Marc-André Bombeck b99a015266 feat(medications): make injection-site tracking and rotation real
The injection_site column on intake events was read across several
surfaces but never written, so site tracking was effectively dead. Wire
it end-to-end as a per-medication opt-in, gated on an INJECTION delivery
form and default off.

- Schema: add medications.track_injection_sites + allowed_injection_sites
  and users.global_excluded_injection_sites (migrations 0099, 0100;
  additive, defaulted, no backfill).
- Intake write path: accept an optional injectionSite on the per-med,
  status-toggle, and bulk intake routes. Persist it only on a taken
  injection with tracking enabled, validated against the medication's
  effective allowed set (per-med allowed sites minus the user-level
  global exclusion, deny wins) — a disallowed site returns 422 (the bulk
  route skips the entry).
- Rotation: extend the least-recently-used recommender with an allowed
  filter; the picker disables sites outside the effective set.
- UI: per-medication tracking toggle + allowed-sites editor in the dose
  wizard step, a skippable post-dose site prompt on both medication
  cards, and a Settings card for the user-level global exclusion.
- Contract: injectionSite is additive on the intake request; OpenAPI
  regenerated. New GET/PATCH /api/auth/me/injection-site-prefs.

The site capture is always optional and never blocks logging a dose.
2026-06-01 20:25:34 +02:00
Marc-André Bombeck 65bbedd83d fix(seed): repair demo seed against the current schema
Two gaps stopped a fresh demo seed:

- medication_intake_events gained a NOT NULL updated_at column in the
  v1.7.0 sync keyset work. The raw INSERTs omitted it; set it alongside
  created_at on every intake row so the seed does not lean on the
  column's migration-only default.

- app_settings carried four columns on the schema (default_locale plus
  the telegram/ntfy/web-push global toggles) that never landed in the
  migration history — applied via db push to existing environments but
  never committed. A database built from migrations alone is missing
  them, and the admin settings read path selects default_locale, so a
  fresh deploy reports "column default_locale does not exist" on first
  read. Add an idempotent ADD COLUMN IF NOT EXISTS drift-fix migration
  mirroring 0025_user_locale_drift_fix: a no-op where the columns exist,
  a clean add on a migrations-only build.

Verified end to end against a fresh Postgres 16 built from the full
migration set: the seed completes and re-runs cleanly.
2026-06-01 17:31:45 +02:00
Marc-André BombeckandClaude Opus 4.8 50e0a65228 chore(release): v1.7.0
v1.7.0 — health-record export (PDF + FHIR), flexible medication schedules, first-paint dashboard snapshot, full HealthKit coverage, offline sync delta feed, Coach data clustering. See CHANGELOG.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 14:36:17 +02:00
Marc-André BombeckandClaude Opus 4.7 8ee7aaac44 chore(release): v1.6.0
Medication editor overhaul, route of administration, today-tile read-flip.

- Route-of-administration column (ORAL | INJECTION | OTHER) decoupled from
  treatmentClass; injection-site picker surfaces for any injection.
  Migration 0088 backfills ORAL via a constant column default.
- One-time injection (oneShot + deliveryForm = INJECTION) editor path.
- Profile-photo upload card on Settings → Account.
- Today-tile read-flip: /api/medications/intake?scope=today and
  /api/dashboard/summary gate through the canonical recurrence engine,
  matching the reminder worker for bi-weekly, rolling, RRULE, and one-time
  cadences.
- Measurement note cap 25 → 200; dashboard range colors on semantic tokens;
  admin sidebar icons + shared-helper consolidation; API-tokens multi-language
  snippets restored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 00:10:12 +02:00
Marc-André BombeckandClaude Opus 4.7 8528c3b4fe chore(release): v1.5.6
Pure-history medication detail page, legacy step consolidation, and the
finish of the outbound-fetch migration.

- /medications/[id] becomes a read-only history surface; every setting
  moves into a new advanced-settings sheet, and the edit trigger becomes
  a two-option picker. The list page is unchanged.
- A pg-boss job folds pre-v1.5.0 granular step rows into one canonical
  daily total per user per day and soft-deletes the originals; migration
  0087 adds a partial index for the converging discovery scan.
- Every remaining server-side fetch routes through safeFetch (Withings,
  Codex OAuth + SSE, bug-report, Umami, GlitchTip, Loki); a new
  healthlog/safe-fetch-required ESLint rule guards the boundary.
- Fixes: intake-edit dialog seeded from real values, consolidation skips
  a unique-constraint day instead of stranding it, avatar upload reads
  the body once under a bounded cap, egress lint rejects absolute URLs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 22:07:10 +02:00
Marc-André BombeckandClaude Opus 4.7 7f95a2a44f fix(measurements): wire walking step length and walking speed
The iOS team had gated both client-side waiting on the server map.
Both flow raw SI on the wire (metres and metres per second
respectively) — the server-side ×100 scaling the percent gait
metrics use does NOT apply here. The convention block at the top of
apple-health-mapping.ts now spells the split out so a future
gait-metric addition picks the right path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 23:38:21 +02:00
Marc-André BombeckandClaude Opus 4.7 ca1c52ffcb feat(avatar): self-hosted avatar storage replacing the Gravatar leak
The previous /me payload carried a gravatarUrl built from
SHA-256(email) and pointed every authenticated page-load at
www.gravatar.com — Automattic could correlate the digest against
its known-email table on each request. This release moves the
avatar in-house: the user uploads through POST /api/user/avatar
(multipart, JPEG/PNG/WebP, 2 MiB / 2048×2048 max), the bytes ride
on the User row alongside a content-type and updatedAt timestamp,
and GET /api/user/avatar/{id} serves them from same-origin with
the cache-busting timestamp on the profile response.

The image-header parsing is hand-rolled in src/lib/avatar.ts so
the standalone image does not gain a native dep just to read width
and height. Validation rejects oversize / wrong content-type at
the multipart parse boundary; DELETE clears the row. The bytes
ride pg_dump alongside the rest of the row so the existing weekly
encrypted backup path already covers them.

The sidebar, the coach panel, the use-auth hook, and the disable-
coach card stop reading gravatarUrl; src/lib/gravatar.ts retires.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 22:39:20 +02:00
Marc-André BombeckandClaude Opus 4.7 a75b96b2a9 feat(insights): server-persist insights tile order + visibility
Mirrors /api/dashboard/widgets for the insights surface: GET returns
the user's saved layout (defaulting to project order when unset),
PUT writes the layout, the response is the canonical shape the
clients can render directly. Closes the cross-device drift where
insights ordering lived in iOS local state only.

Storage is a new `insights_layout_json` JSONB column on `users`,
parallel to `dashboard_widgets_json` so each surface's resolver owns
its tile-id allowlist. Migration 0083 adds the column idempotently;
default NULL resolves to `DEFAULT_INSIGHTS_LAYOUT`. PUT routes
through the shared multi-issue 422 envelope + payload-redaction +
audit-row dedup pattern the dashboard-widgets route established.
A 5-minute per-user cache mirrors the dashboard-widgets cache and
busts on every save / reset.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 22:32:40 +02:00
Marc-André BombeckandClaude Opus 4.7 befac0ec65 fix(measurements): wire the six deferred Apple Health identifiers
Adds RESPIRATORY_RATE, BODY_MASS_INDEX, and the four remaining
deferred quantity types to MeasurementType plus the Apple Health
mapping. The batch route already returns 200 with a per-entry skip
for unmappable identifiers; iOS reads that as success and advances
its sync anchor, so any sample with one of these identifiers used
to be dropped forever. The mapping now accepts them and the rows
land.

The walking-asymmetry and walking-double-support percent values
adopt the project's server-side scaling convention (raw HK reading
on the wire, server applies the ×100 at ingest), matching the
walking-steadiness path. iOS releases that still send the
pre-multiplied percent need a one-release shim and are tracked in
the iOS coord notes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 22:32:18 +02:00
Marc-André BombeckandClaude Opus 4.7 a868bd1cf2 feat(meds): extend MedicationCategory with diabetes and antibiotic
Adds DIABETES and ANTIBIOTIC to MEDICATION_CATEGORY_VALUES so the
upcoming modal wizard's Step 2 taxonomy can write the two rows as
first-class clinical categories instead of falling back to OTHER.
The clinical category column on medication_categories is plain TEXT
backed by Zod for validation; no Prisma enum exists to ALTER, so the
migration is documentation-only and the category-label drift-guard
gets the matching keys. Both new values feed back into the rendered
badges on the medication card.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:33:11 +02:00
Marc-André BombeckandClaude Opus 4.7 f80e3375b2 feat(medications): v1.5 scheduling fields + backfill migration
Adds the columns the v1.5 cadence work needs without breaking any
v1.4-era reader. Pre-existing `windowStart` / `windowEnd` /
`daysOfWeek` stay live; v1.5.x writes both legacy + new; the read
flip lands in v1.5.1 once production traffic has validated the new
fields.

### Medication

- `startsOn DATE?` — course start; anchors RRULE BYDAY / BYMONTHDAY
  patterns and the rolling-interval countdown's first window. NULL
  preserves the legacy implicit "from creation" anchor.
- `endsOn DATE?` — course end; reminder worker stops minting slots
  past this date. NULL = chronic.
- `oneShot BOOLEAN NOT NULL DEFAULT FALSE` — single-administration
  flag (flu shots, vaccines, post-op final doses). Auto-deactivates
  the medication after the single intake is logged.

### MedicationSchedule

- `timesOfDay TEXT[]` — first-class HH:mm entries. Patients think
  in points-in-time, not in windows; the legacy single-window
  primitive misframed the mental model. Migration backfills
  `[windowStart]` for every pre-v1.5 row.
- `reminderGraceMinutes INT?` — replaces the implicit
  `windowEnd - windowStart` span for late-classification. NULL
  falls back to the legacy math.
- `rrule TEXT?` — RFC 5545 RRULE string for calendar-anchored
  cadences (every N weeks, monthly, quarterly, yearly, etc.).
- `rolling_interval_days INT?` — flexible-rolling interval counted
  from the latest intake. Mutually exclusive with `rrule` via the
  `medication_schedules_rrule_xor_rolling` CHECK constraint.

### Backfill

The migration walks every existing `medication_schedules` row and
derives an `rrule` + a single-entry `timesOfDay` from the legacy
encoding. Three branches:

- `days_of_week IS NULL / ""` → `FREQ=DAILY`
- `days_of_week = "1,3,5"` → `FREQ=WEEKLY;BYDAY=MO,WE,FR`
- `days_of_week = "i2;1,3,5"` → `FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR`

A `pg_temp` helper handles the HealthLog-weekday → RFC-5545-BYDAY
conversion. Anything unexpected stays NULL so the v1.5.1 reader
falls back to the legacy decoder.

Indexes:

- Partial index `medication_schedules_rolling_idx` on
  `rolling_interval_days IS NOT NULL` so the reminder worker can
  fan in over rolling schedules cheaply on every intake event.

Column comments document the legacy → v1.5 → v1.6 migration plan
so a future reader doesn't accidentally drop the legacy columns
before the read-flip lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 07:52:11 +02:00
Marc-André Bombeck f92e644154 chore(release): v1.4.49 — audit-backlog closure + server reminder suppression + diagnostic endpoint + analytics cold-path fix
Bundles the deferred v1.4.47 Mediums + Lows with the v1.4.48-deferred items
into a single release. Headline: server-side suppression of MEDICATION_REMINDER
APNs for iOS clients that own their local reminders (closes GH #206), a
push_attempts table backing /api/admin/notifications/diagnostic, and a sweep
of v1.4.48 QA forward-findings.

Performance (the user-visible win):
- /api/analytics cold-fallback path splits the heavy single-statement
  aggregate into two parallel queries. The linearly composable columns
  (count / min / max / mean) keep the full-partition scan; the windowed
  7 / 30 / 90-day avg / slope / r² columns take a 90-day outer cap so
  the planner does an index range scan on (user_id, type, measured_at)
  instead of a full-partition sequential scan. Output is bit-identical;
  cold slim slice drops from multi-second to sub-second on tenants with
  large measurement partitions.

Server-side notifications:
- GET + PATCH /api/auth/me/notification-prefs with deep-merge semantics.
  Initial category medication.clientManaged: boolean (default false). Cron
  medicationReminderJob skips dose-due APNs when the flag is true,
  emitting a medication_reminder.suppressed_client_managed wide-event
  annotation per skip.
- push_attempts table backs the diagnostic endpoint. Every APNS /
  Web-Push / Telegram / NTFY sender writes a fire-and-forget row.
  Daily 03:35 Europe/Berlin cleanup prunes rows older than 90 days.
- /api/admin/notifications/diagnostic — admin-only endpoint surfacing
  device APNs token presence (masked), channel config presence, and
  recent push attempts. Registered in OpenAPI.
- Per-channel APNs "Send test" button now fires with
  eventType: "MEDICATION_REMINDER" so the dispatcher's time-sensitive +
  priority-10 branch runs. A successful self-test now exercises the
  exact path production reminders take; an unsuccessful one points the
  user at iOS Settings → Time Sensitive Notifications rather than
  appearing to be a server-side bug.

Withings reliability:
- OAuth callback consumes the nonce atomically (delete-first with
  P2025 catch for the replay branch). The previous read-then-delete
  pattern allowed a race between two concurrent callbacks.
- ?reason=state redirect differentiated into csrf1 / replay / expired /
  cross_user so operators can tell mismatch types apart without DB
  shell access.
- connect endpoint rate-limited to 10/60s/user; row-create failures
  redirect to a consistent error surface.

Observability hardening:
- redactSensitiveFields(body) denylist helper covers password / token /
  secret / apiKey / authorization / csrfState / nonce, recursive,
  case-insensitive. Caught: /api/devices invalid_format issues were
  echoing apnsToken values verbatim into the audit log.
- sanitiseZodIssues(issues, { stripValuesFromMessage: true }) overload
  applied to 14 audit-log sites.

Migrations: 0079 (notification_prefs), 0080 (push_attempts).

Tests: 4815 → 5272 unit + integration. Workout-batch integration suite
that had been red on main since v1.4.45 (three release commits shipped
through it) now green again — updated to match v1.4.42 cross-source
dedup semantics.

Resolves PR #207.
2026-05-23 12:51:41 +02:00
Marc-André Bombeck 66a7f8b6fa chore(release): v1.4.47 — audit-backlog closure + drag-to-reorder + Coach disable + OAuth nonce ledger
Eight changes: W0 idempotency test pin, W1 legacy column drop, W2 Textarea primitive, W3 Coach disable toggle, W4 dnd-kit drag-to-reorder, W5 onboarding 24h gate, W6 Withings OAuth nonce ledger, W7+W8 legacy_form_total drop + Coach onLine pre-check. 6-axis QA reconciled. Migrations 0076/0077/0078 required pre-image. Tests 5093 → ~5170.
2026-05-22 09:18:52 +02:00
Marc-André Bombeck 6c7fc7dc84 chore(release): v1.4.45 — analytics 9 s perf fix + audit-marathon closure + v1.4.44 hotfix merged
Audit-marathon close: 14 waves + W10 QA + reconcile. Perf fix (analytics 9 s -> 2-3 s), Zod multi-issue rollout (41 routes), Withings parked-state automation, mobile-UI WCAG 2.5.5 close, QoL i18n + offline banner + account delete, security PII drop + replay-injection scan. Migration 0075 required. Tests 4815 -> 5076.
2026-05-21 23:13:21 +02:00
Marc-André Bombeck 1114cf7e63 chore(release): v1.4.40 — comprehensive Critical+High architecture closure plus iOS PB30 enablement
Eleven touch-disjoint waves landed on develop plus a six-axis QA
reconcile round before tagging. See CHANGELOG.md for the user-facing
prose; this commit message documents the wave + report inventory for
release-management traceability.

Waves shipped (each with a phase report under .planning/):

- phase-W-POOL-v1440-report.md — p-limit(4) wrapper around the
  analytics 15-way fan-out + pg.Pool max raised 10 -> 20. Closes the
  v1.4.39 empirical-trace finding #1 (Prisma pool starvation).
- phase-W-DELETED-v1440-report.md — 11 reader-tier helpers + 3
  integration contracts close audit Critical Finding #3 (soft-delete
  invisibility) across dashboard, analytics, summaries, rollup tiers,
  and the Coach snapshot path.
- phase-W-INSIGHTS-v1440-report.md — mood-rollup swap on the three
  /api/insights/{features,targets,cards} routes plus the
  compliance-rollup hook gap closure for bulk-projection mints; the
  post-QA reconcile patch closes the six remaining measurement.findMany
  sites in the cluster that still leaked tombstoned rows.
- phase-W-INFRA-v1440-report.md — rollup helper umbrella consolidation
  under src/lib/rollups/* with zero orphan re-exports; lint, typecheck,
  knip, and 93/93 rollup tests green post-restructure.
- phase-W-RSC-v1440-report.md — per-tile Suspense boundaries plus the
  queryKey factory-bypass walker test (extended to comparison/ in the
  reconcile pass).
- phase-W-PRIVACY-v1440-report.md — bilingual /privacy page with 17
  paired-section tests pinning the 9 SB-3 disclosure requirements.
- phase-W-AASA-v1440-report.md — /.well-known/apple-app-site-association
  endpoint pinned to S8WDX4W5KX.dev.healthlog.app with app-ID parity
  assertions guarding against split rotation.
- phase-W-APNS-NOTIFY-v1440-report.md — MEDICATION_REMINDER
  time-sensitive APNs payload (priority 10 + interruption-level)
  scoped via a parameterised test that pins all 6 other event-types
  do not bypass Focus. AP-2 .p8 install gates real delivery.
- phase-W-CONSENT-v1440-report.md — AI consent receipts CRUD with
  append-only invariant pinned in test; revoke-then-regrant leaves
  both rows; idempotent DELETE returns 200 + receipt: null.
- phase-W-WMY-WIRE-v1440-report.md — three previously-dead WMY readers
  (week/month/year) wired through summaries-slice and
  health-score-fast-path with a linear-composability parity test
  pinning the cross-consumer routing contract.
- phase-W-GHOSTS-v1440-report.md — ~1 177 lines of dead code removed
  across 9 atomic commits; startOfUtcDay + wallClockInTz consolidation
  eliminates a sibling-drift risk; knip whitelist trimmed to three
  files pending follow-up.

QA reconcile patches applied per the v1.4.40 QA round (six reports
under .planning/round-v1440-QA-*-findings.md):

- senior C1 — dashboard-suspense-boundaries regex aligned with the
  post-W-INFRA Thread 2 userTimezone-local lift.
- senior H1 — soft-delete filter added to the six remaining
  insights measurement.findMany sites.
- specialist H1 — Promise.allSettled around bulk-projection
  compliance recompute on both /api/medications/intake and
  /api/dashboard/summary.
- ux H1 — compare-toggle migrated to queryKeys.dashboardWidgets
  factory; walker test extended to src/components/comparison/.
- security M1 — consent artefact 64 KB cap enforced via
  Buffer.byteLength (UTF-8 bytes, not UTF-16 code units).

Quality gates green at tag: pnpm typecheck, pnpm lint, pnpm knip
--include files,dependencies,binaries,unlisted, pnpm test --run
(4726 passing / 1 skipped). Tests 4525 -> 4726 unit + 1 skipped over
the v1.4.39 -> v1.4.40 span.

User-facing prose: see CHANGELOG.md.
2026-05-21 12:48:02 +02:00
Marc-André Bombeck f9996552ed chore(release): v1.4.39.4 — dashboard symmetry for daily-schedule intake projection 2026-05-21 11:20:24 +02:00
Marc-André Bombeck d84922cdc3 chore(release): v1.4.39 — mood, medication-compliance, and cumulative-sum rollup tiers
Squashed merge of the v1.4.39 wave work from develop. The release
extends the v1.4.38 fast-path posture to two more endpoints that
still walked the source table on every cold mount and folds a
cumulative `sum_value` column into the existing rollup tier so step /
flight / distance / daylight / active-energy sparklines no longer
re-derive their daily totals in Node.

Raw measurements / mood_entries / medication_intake_events tables are
byte-unchanged. Three new tiers (mood_entry_rollups,
medication_compliance_rollups, measurement_rollups.sum_value) are
derived caches that self-heal via boot-time backfill on first reach.

Migrations 0070, 0071, 0072 are additive only. The sum_value column
add is catalog-only DDL on PostgreSQL 11+ — no table rewrite, writes
resume in milliseconds. No environment-variable change. No API
contract break. No iOS contract change.

Expected wins (live perf-verify rides the post-deploy window):
- /api/mood/analytics cold 12.7 s -> ~200 ms
- /api/medications/intake?scope=compliance cold 3.2 s -> ~200 ms
- /api/dashboard/summary cumulative sparkline ~500 ms -> ~300 ms
- /api/analytics live-fallback row cap 347 k -> ~5 k (425 d cap
  preserves the year-ago baseline tile)

Tests 4 524 -> 4 640 (+116). Typecheck clean. Lint clean.

See CHANGELOG.md for the full v1.4.39 entry.
2026-05-21 07:39:10 +02:00
Marc-André Bombeck 4049c6c724 feat(notifications): APNs category + MOOD_REMINDER event for iOS v0.5.4 (#190)
* feat(notifications): set APNs category for med-reminders

Unblocks iOS HealthLog v0.5.3 which registers a UNNotificationCategory
named MEDICATION_REMINDER with three actions (Take / Snooze 15 min /
Skip). Without aps.category on the push payload iOS renders the
notification as a plain alert, so the action-button row never
materialises on the lock-screen.

- ApnsPayload gains optional `category` + `mutableContent` fields.
  The sender writes `category` through to node-apn's setter (a
  forward-compat cast wraps the d.ts gap in node-apn 8.1).
- sendViaApns automatically forwards `payload.eventType` as the
  category so every event-type that lands here surfaces as a
  matching iOS category identifier without further plumbing. Future
  event-types that the iOS app doesn't register fall back to a plain
  alert — iOS silently ignores unknown categories.
- mutableContent=1 is set by default so an iOS Notification Service
  Extension can hook the payload later without a server-side change.
- New unit tests pin aps.category=MEDICATION_REMINDER on the dispatcher
  path, aps.category=MOOD_REMINDER for the new event-type, and the
  explicit-category override on the lower-level sendApnsPush entry.

* feat(prisma): add moodReminderEnabled + MoodReminderDispatch ledger

Schema foundation for the v0.5.4 daily mood-reminder push:

- `users.mood_reminder_enabled` (Boolean, default false): per-user
  opt-in flag. Default off because mood capture is an emotionally
  loaded surface — a fresh-install user must explicitly toggle the
  preference in Settings before the server starts nudging them.
- `mood_reminder_dispatches` (new table, unique on userId+date):
  idempotency ledger that records exactly one nudge per user per
  local date. Doubles as the race-guard for the every-15-min cron
  re-ticking inside the same 22:00 window — the unique constraint
  makes parallel workers safe even though the worker boots at
  localConcurrency=1.

Migration is additive: existing users stay on the opt-out path
byte-identical to v1.4.38 behaviour. Uses IF NOT EXISTS guards
matching the idempotent pattern from 0061 / 0068.

* feat(notifications): add MOOD_REMINDER event type with daily 22:00 cron

Second half of the v0.5.4 ios-coord patch. Now that the schema can
remember "user opted in" and "we already nudged today", wire the
worker side:

- types.ts: extend EVENT_TYPES with MOOD_REMINDER. Default off on
  the per-event policy as a defence-in-depth gate behind the
  per-user moodReminderEnabled flag.
- mood-reminder.ts: pure dispatcher module with three pinned-down
  entry points so the unit tests don't have to spin up pg-boss:
    * `evaluateMoodReminderWindow` — boundary contract for
      "21:59 → skip, 22:00 → fire, 23:00 → skip" across any IANA tz.
    * `buildMoodReminderPayload` — locale-aware title/body
      ("Stimmung erfassen" / "Wie geht es dir heute?" for DE,
      "Log your mood" / "How are you feeling today?" for EN; falls
      back to the app default for null/unknown locales).
    * `runMoodReminderTick` — orchestrator that pulls opted-in
      users, applies the window + already-logged + already-
      dispatched filters, reserves the dedup row, and only then
      calls the dispatcher. The P2002 unique-constraint violation
      is caught and reclassified as "already dispatched" so a
      lost race doesn't pollute the wide-event error stream.
- reminder-worker.ts: registers the `mood-reminder-check` queue
  with a 15-min cron, mirroring the medication-reminder cadence so
  any user's 22:00 boundary is caught within 15 minutes of wall-
  clock without one-cron-per-zone. localConcurrency=1 keeps a
  worker from racing itself. Also enriches the existing med-
  reminder dispatch with a `scheduledAt` ISO string so the iOS
  notification handler can pin "snooze 15 min" against the actual
  schedule slot rather than the wall-clock moment APNs delivered.
- messages/{de,en,es,fr,it,pl}.json: new `moodReminders.dailyTitle`
  + `moodReminders.dailyBody` keys for all six locales.
- mood-reminder.test.ts: 13 unit tests covering opt-in gating,
  window boundaries (21:59 / 22:00 / 23:00 + non-Berlin DST),
  logged-today skip, ledger-already-exists skip, lost-race P2002
  handling, and the multi-user multi-locale fan-out.
2026-05-17 23:08:17 +02:00
Marc-André Bombeck 0e2a99f8e7 v1.4.36 — perf, charts, AI payload trim, UX punch
Heavy-aggregate skip on the rollup-fresh + fully-covered path,
daily chart fetches opt into rollup buckets via source=rollup,
Insights extractFeatures swap drops the prompt payload from
double-digit MB to the low hundreds of kB on power-user accounts,
page-level isLoading gate replaced with per-section early-skeleton
paint, trends-row chart slot pinned for CLS, IntakeHistoryListV2
restored on the medication detail page, About card folded into the
Admin Console with an auto-checking update badge, cumulative metric
tiles read the day's running sum, IP-whois fallback surfaces city
+ country when the ASN lookup misses. Migration 0068 adds an
additive users.insights_exclude_metrics column. Unit suite 4354
passing; typecheck + lint clean.
2026-05-17 13:22:51 +02:00
Marc-André Bombeck 8dc9edb65e v1.4.35 — persistent measurement rollups + partial read-swap
Foundation release for the persistent measurement_rollups cache tier.
Adds the additive table + enum, populates it from every measurement
write hook, lands the Apple-Health-import end-of-job backfill, and
ships the parity-checked read-swap on two reader surfaces.

The comprehensive insights aggregator and the slim analytics
summaries slice now source count / min / max / mean per type from the
DAY rollup buckets via aggregateBuckets, plus the comprehensive
dailyByType correlation feed straight from the bucket means. Slope /
R² / standard deviation / anomaly counts stay on live SQL — they do
not compose across DAY buckets. A defensive parity check against the
live aggregate's COUNT(*) falls back to live SQL when divergence is
detected.

Includes the new scripts/backfill-rollups.ts CLI for the one-shot
fold of historical measurements into all four granularities.

Tests: 4280 unit + 228 integration. tsc + lint clean. Migration
0067_v1434_measurement_rollups is idempotent under re-run.
2026-05-17 09:55:33 +02:00
Marc-André Bombeck affede31a4 v1.4.34 — apple-health import + reliability + web-freeze
The final functional web release before the iOS native client lands.
Headline work: a streaming Apple Health `export.zip` importer for
prospective iOS-migrating accounts, a server-side aggregation cache
that collapses the three hottest dashboard reads onto in-process LRU
slots with single-flight coalescing and per-user invalidation, a
broader compliance classifier with a dedicated `early` bucket so
ahead-of-window doses count as compliant, the Coach launch surface
hoisted onto every authed page with a dashboard-hero CTA, and a
trimmer Settings shelf that folds Sources into Targets. The release
also lights up the `cache.<name>.outcome` annotation on the active
wide event so production logs carry the cache hit-ratio signal
without leaking userIds.

### Added

- **Server-side aggregation cache.** A new `ServerCache<T>` primitive
  (`src/lib/cache/server-cache.ts`) extends the v1.4.33 Coach snapshot
  LRU shape into a reusable per-route layer with TTL expiry,
  capacity-bounded LRU eviction, single-flight read coalescing, and
  per-instance hit / miss / eviction / stampede counters. Wires the
  three hottest dashboard reads — `/api/analytics` (slim + default),
  `/api/gamification/achievements`, `/api/medications/intake?scope=compliance`
  — and bolts per-user invalidation onto every measurement / mood /
  medication / workout / dashboard-widget write endpoint so the next
  read paints fresh data instead of waiting out the TTL. Every cache
  hit / miss surfaces on the active wide event as
  `cache.<name>.outcome` + `cache.<name>.key_hash` so production logs
  carry the hit-ratio signal without leaking userIds.
- **Apple Health `export.zip` import.** New endpoint
  `POST /api/import/apple-health-export` (synchronous multipart
  upload, asynchronous ingest via a dedicated pg-boss
  `apple-health-import` worker, per-`MeasurementType` ingestion
  stats). Streams the upload straight to disk so a multi-gigabyte
  export never lands in V8 heap, hashes the bytes inline for
  content-based idempotency, and unpacks `apple_health_export/export.xml`
  with a hand-rolled ZIP central-directory walker that handles Zip64
  for archives past the 4 GB barrier. The parser folds every
  `<Record>`, `<Workout>`, `<Correlation>`, and `<ClinicalRecord>`
  into the existing `Measurement` and `Workout` row shapes — spot
  rows keyed by `HKMetadataKeyExternalUUID` (or a deterministic
  `sample:<sha256>` fallback), cumulative HK types collapsed into
  one `stats:<HKType>:<YYYY-MM-DD>` row per user-local day to match
  the iOS daily-aggregation convention. Live per-stage progress
  surfaces through `GET /api/import/apple-health-export/{jobId}/status`.
  An admin variant at `POST /api/admin/import-apple-health-export`
  imports on behalf of a target user (cookie-only `requireAdmin()`
  gate; Bearer tokens never elevate).
- **`ImportJob` schema model + migration.** New Prisma model captures
  the per-upload state machine (`queued | unpacking | parsing |
  upserting | done | failed`), the content-hash for idempotency
  short-circuits, and the per-`MeasurementType` ingestion counters
  the status route surfaces back to the client.
- **`lastSeenByType` on `/api/analytics`.** Both the slim
  (`?slice=summaries`) and default slices return a per-type
  `{ lastSeenAt, daysAgo }` map. The dashboard trend tiles wire 12
  mounts to a new `tileStaleDays()` helper so an "X days / weeks /
  months ago" hint paints under any per-metric tile whose last
  sample crosses the 7-day floor. Six locales gained additive
  week / month plural keys; Polish uses the genitive-plural form
  that covers every non-1 count the bucket math can produce.
- **Dashboard "Ask the coach" hero CTA.** A new pill next to the
  existing "Hinzufügen" / "Add" launches the Coach drawer directly
  from the dashboard hero. The `<CoachLaunchProvider>` hoisted from
  the insights layout to the auth shell so the drawer is reachable
  from every authed route; the floating action button stays scoped
  to `/insights/**` so it cannot distract on the dashboard.
- **Shared achievements query hook.** `useAchievementsQuery()`
  centralises the three previous consumers — recent-achievements
  card, the `/achievements` mother page, and the unlock notifier —
  onto one TanStack queryKey + cache slot so dashboard cold mount
  fires the endpoint once instead of twice.
- **Typed authed Cache-Control presets.** New
  `src/lib/http/cache-headers.ts` module exports
  `NO_STORE_BUT_BFCACHE = "private, max-age=0, must-revalidate"`,
  `SHORT_LIVED_PUBLIC`, and an `applyAuthedHeaders()` helper. The
  presets land at the framework level via a `next.config.ts`
  `headers()` rule that stamps the bfcache-friendly directive on
  every authed HTML response, restoring Chromium bfcache eligibility
  for in-app back / forward navigation.
- **`scripts/print-bundle-report.mjs`.** Restores the at-a-glance
  "top client chunks by parsed size" signal Turbopack dropped from
  `next build`; reads `.next/analyze/client.json` (written by
  `@next/bundle-analyzer` when `pnpm analyze` runs with `ANALYZE=1`)
  and prints a sorted table plus totals.

### Changed

- **Settings sidebar: "Sources" folded into "Targets & Sources".**
  Per-metric threshold ranges and per-metric source priority now
  share one `/settings/thresholds` page so the same metric's
  threshold and device-source preference sit together instead of
  one sidebar entry away. `/settings/sources` keeps a
  `permanentRedirect` so external bookmarks and docs links follow
  through unchanged. Section count: 11 → 10.
- **Insights tab strip: vital pills collapse under a "Vitals"
  parent.** Five vital pills (HRV, Resting HR, Oxygen, Body
  Temperature, Active Energy) hide behind one parent pill that
  opens a popover sub-list. Parent-pill active state mirrors the
  URL so spatial orientation survives the collapse; each sub-page
  keeps its own URL and bookmark resolves unchanged. Strip
  footprint: 14 → 10 entries when every metric has data.
- **Coolify env-var audit.** `mcp__coolify-apps01__env_vars`
  inspection captured the section-1 / section-2 duplicates that
  have accumulated under apps-01 since v1.3.1. Audit pinned at
  `.planning/round-v1434-iwa-coolify-env-audit.md`; no env-var
  deletes performed (operator action).

### Fixed

- **Compliance classifier: early intakes count as compliant.** The
  classifier picked up a dedicated `early` bucket (window-start
  minus three hours through window-start), the `on_time` post-window
  tolerance widened from one hour to three hours, and the `late`
  band sits between the new `on_time` ceiling and the configurable
  `lateMinutes` knob. Heatmap consumers route the new bucket
  through the compliant path; the dedicated `early` counter rides
  on `DailyComplianceEntry` for downstream consumers that want to
  distinguish ahead-of-window from on-window intakes.
- **Compliance heatmap fallback retired.** The v1.4.33 defensive
  `looksClassifierBug` fallthrough is gone now that the underlying
  classifier widening removes the every-dose-very-late mode the
  fallback covered.

### Performance

- **`/api/gamification/achievements` consumer collapse.** Two of
  three previous consumers shared the same TanStack queryKey
  literal; the third carried a per-user discriminator so the cache
  treated it as a fresh slot. Dashboard cold mount fired the
  endpoint twice. Collapsing onto the shared hook trims that to a
  single request; with the new server-side cache slot warm, the
  duplicate-mount worst case rides single-flight coalescing.
- **GHCR build fires once per release tag.** The
  `docker-publish.yml` workflow lost its `push.branches: [main]`
  trigger; the `:latest` raw-tag enable rule moved onto the tag
  ref so each release produces exactly one multi-arch build that
  refreshes both the semver tags and the `:latest` alias.
- **NFT-trace warnings silenced.** `next.config.ts` gained an
  `outputFileTracingExcludes` block that narrows the Turbopack
  tracer away from the `MAXMIND_LICENSE_KEY` env-access path so
  the trace-report no longer warns on every dependent route file.

### Refactor

- **Dashboard analytics consumers.** The two cards that previously
  decided their own freshness copy now lean on the typed
  `tileStaleDays()` helper and the additive `lastSeenByType` field
  on the analytics response. Mood and BD-Zielbereich tiles keep the
  default `null` because they have no underlying per-type freshness
  signal.

### Accessibility

- **Dashboard hero CTA hits the WCAG 2.5.5 touch-target floor.**
  The new "Ask the coach" pill carries `min-h-11` on mobile and
  the matching `sm:min-h-9` desktop floor so the touch target
  stays above 44 px on the Pixel 5 boundary.
- **Insights tab-strip Vitals parent reads correctly to assistive
  tech.** Parent pill carries `aria-current="page"` whenever the
  current URL matches one of its child vital sub-pages, plus a
  `data-slot="insights-tab-strip-group"` hook for visual-regression
  testing.

### Internal

- **Two e2e flake windows tightened.** `onboarding-flicker` swapped
  its 12-sample 50 ms poll loop for a single Playwright
  auto-retrying `toBeHidden({ timeout: 700 })` assertion so the
  1-2 ms race window between the analytics-pending shell and
  `useAuth().user` resolving collapses to a single retry slot.
  `mobile-viewport` dropped `nav a[href]` from the touch-target
  sweep (the bottom-nav owns its own WCAG spec) and gated the
  44-px floor on `matchMedia('(min-width: 640px)').matches === false`
  so the Pixel 5 viewport never tripped into the `sm:` tier during
  WebKit render commits.
- **Server-cache observability.** Every `cached()` invocation passes
  an `annotate` callback that lands two keys on the wide event:
  `cache.<name>.outcome` ∈ `{ hit | miss | stampede }` and
  `cache.<name>.key_hash` (non-reversible djb2 32-bit hash). Ops
  can grep `cache.analytics.outcome` over any time window to
  compute the hit ratio per deployment.
- **AASA followups verified.** The v1.4.33 `/.well-known/apple-app-site-association`
  handler serves a direct 200 on every fronting origin; Apple's
  CDN has ingested matching bodies for all three domains.

### Web freeze

v1.4.34 marks the last functional release of the web codebase before
the iOS native client launches. Subsequent v1.4.x tags carry security
fixes, dependency bumps, and hotfix-only corrections. New feature work
is paused until the iOS app clears Apple review, at which point a
v1.5.0 version-bump-only release tags the milestone. The Prisma
schema head comment and `.planning/v15-strategic-plan.md` §5
decision-log row pin the freeze trigger in-tree.
2026-05-16 21:25:26 +02:00
Marc-André Bombeck 5ea813e20c chore(release): v1.4.31
Operator-side assistant feature-flag matrix, /insights tab-strip blocking fix on mobile, and the GHCR-propagation-race Coolify auto-deploy fix. Full notes in CHANGELOG.
2026-05-16 14:19:57 +02:00
Marc-André Bombeck 3ece95f192 chore(release): v1.4.30
Server-side prep for the next iOS TestFlight build. Five surfaces land together so the iOS engineer can pick them up in one cut-over: a locked externalId shape for daily-aggregated cumulative HealthKit rows, the SyncMode foundation columns + handshake + bulk-backfill endpoints, a first-class MoodEntry.note column that replaces the legacy tag-based workaround, a cross-source workout dedup helper, and two new MeasurementType enums plus the shared category overlay that drives the iOS permission picker and the future Insights nav.
2026-05-16 12:37:55 +02:00
Marc-André Bombeck 4f09c3f862 chore(release): v1.4.27
v1.4.27 — Mobile capability and maintainer-finding cleanup.

ResponsiveSheet primitive (bottom-sheet on narrow viewports, centred dialog on desktop)
applied to BD, Weight, Glucose, Mood, Activity forms. Maintainer-finding cleanups across
Coach, Insights, Reports, Privacy. GeoLite2 offline geocoding with resilience layer
(graceful fallback when EULA not yet propagated). E2E gap-fill coverage. 450-line
release notes attached to the GitHub Release.
2026-05-15 22:59:10 +02:00
Marc-André Bombeck 17b8d8d2c2 chore(release): v1.4.25
Largest feature delta in the v1.4.x line. Insights expands into seven dedicated metric sub-pages; GLP-1 medication tracking lands end-to-end (drug-level chart with Research Mode acknowledgment, EMA titration ladder, cadence + compliance, side-effect taxonomy, pen/vial inventory); cross-source priority becomes a two-axis resolver; per-user timezone threads through ten analytics surfaces; Withings activity + sleep v2 sync; onboarding rebuilt as a nested-route wizard; Personal Records ship end-to-end; Health Score provenance accordion; Coach runs native first-party prompts across six locales with a 1800-assertion refusal-probe matrix; OpenAPI drift gate hard-fails; multi-arch GHCR image. Migrations 0043 through 0060 are additive and forward-only. PROMPT_VERSION 4.24.0 → 4.25.0 with GROUND RULE 9 (refuses dose recommendations) and GROUND RULE 15 (refuses drug-level estimates with MDR + MDCG 2021-24 cites).

See CHANGELOG.md for the full per-section delta.
2026-05-14 23:49:32 +02:00
Marc-André BombeckandClaude Opus 4.7 1a46bfe78a chore(security): MED cluster — Coolify URL scrub + isCurrent device-id + SLEEP_DURATION migration note
- Coolify runbook + audit briefs scrub the live deploy URL to a
  placeholder (Sec-MED-2). Real values live in repo secrets.
- /api/auth/me/devices isCurrent now compares against the session's
  device id, not the unauthenticated X-Device-Id header (Sr-MED-5).
- Migration 0036 sleep-duration unit shift documented + verification
  command added to the coolify-auto-deploy howto.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:44:12 +02:00
Marc-André BombeckandClaude Opus 4.7 650f150353 fix(coach): feedback references coach_messages, drops plaintext column
Reviewer note: W5 H7 wrote Coach assistant prose plaintext into
recommendation_feedback.recommendation_text. Same prose lives
encrypted in coach_messages — backups captured the leak.

Migration 0042_coach_feedback_message_ref adds nullable coachMessageId
(FK + cascade) and drops recommendation_text. Aggregator joins +
decrypts on read; admin view reads through the same path.

Fixes: Sr-H3 (Wave-6 v1.4.23 senior-dev review)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:38:17 +02:00
Marc-André BombeckandClaude Opus 4.7 d5202e5dd8 fix(devices): enforce global apnsToken uniqueness via partial index
Two collision paths let the same APNs token attach to multiple Device
rows for one user, fanning a single push out to two rows and double-
charging the APNs quota:

  1. The cross-user-hijack guard filtered `NOT: { userId }` so it
     skipped same-user collisions entirely.
  2. The `apns_token` index was non-unique.

Drop the `NOT: { userId }` clause so the lookup catches both cases —
cross-user collisions still 409, same-user collisions fall through to
the existing legacy-token upsert path. Pair with migration 0041
introducing a partial UNIQUE index over `apns_token IS NOT NULL` and
dropping the redundant non-unique sibling. Apple's APNs contract
guarantees one token per (device, app), so global uniqueness matches
the upstream invariant.

Migration is strictly additive at the rolling-upgrade boundary; the
NULL-allowed partial-index shape preserves legacy iOS rows without an
APNs handshake. Schema-side comment documents why the partial index is
introduced via raw SQL (Prisma can't express it natively) and warns
against re-adding the non-unique sibling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:13:47 +02:00
Marc-André BombeckandClaude Opus 4.7 05c7f1474d feat(coach): per-message thumbs feedback + admin aggregate view
The v1.4.22 prose rewrite shipped without a way for users to signal
whether the new tone is landing. v1.4.23 H7 closes that loop with a
light-touch thumbs row + an admin first-week dashboard.

Schema: extend `RecommendationFeedback` with a polymorphic
`target_type` column (default "recommendation" so legacy rows stay
representative) plus a nullable `reason` field. New index on
(target_type, created_at) so the aggregator can scan per-surface
buckets without a full sweep. Migration `0040_recommendation_feedback_target_type`.

Route: POST /api/insights/chat/messages/:id/feedback. Body
{ rating: "helpful" | "unhelpful", reason?: string }. Verifies
ownership (404 on cross-account messages — no existence leak),
decrypts the message body for the dedup key, and writes the row with
target_type="coach" + a metric-source string encoding the user's
active prefs (`coach:tone=warm:verbosity=default`). Per-user 60/h rate
limit. 409 on a P2002 (already_rated) so a double-click is a no-op.

Aggregator: new `buildCoachFeedbackBuckets()` slices Coach rows by
(promptVersion, tone, verbosity). The summary blob the worker writes
to AppSettings.adminAiInsightsFeedbackSummary now carries both
`buckets` (pre-existing) and `coachBuckets` (H7 addition). Targets
the same singleton row so no second worker handler is needed.

Admin view: `/admin/coach-feedback` (new section slug) renders the
Coach buckets as a small DataTable (prompt version × tone × verbosity
× helpful × not-helpful × n × helpful-rate). Helpful-rate column
tinted via Dracula tokens (green ≥80%, yellow ≥50%, orange below).
First useful question this answers: "Is the v1.4.22 prose rewrite
landing well, or did the warm tone overshoot?" If the helpful-rate
for PROMPT_VERSION 4.23.x drops below 50% within the first 100
ratings, v1.4.24 walks the persona back.

UI: `<ThumbsUp>` / `<ThumbsDown>` buttons (lucide icons, no emoji)
under each persisted assistant bubble. Streaming bubbles + refusals
+ error bubbles skip the row so users can't rate before the message
lands on disk. Localised "Thanks for the signal." after submit.

OpenAPI: spec carries the new POST route + the request schema.
Coverage now includes the H4 + H7 routes — three additions over
v1.4.22 baseline.

Tests +9 unit (aggregator Coach-bucket slicing, admin section
render-paths) plus an integration round-trip for the route.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:51:27 +02:00
Marc-André BombeckandClaude Opus 4.7 0eda1de16f chore(schema): deploy medication_schedules.days_of_week column
The schema declared `MedicationSchedule.daysOfWeek` (mapped to
`days_of_week`) but no migration ever created the column. Code
paths in src/components/medications/*, src/app/api/medications/*,
src/app/api/admin/notifications/reminder-check/*, and the
achievements route all reference it via the comma-separated
"0,1,3,5" recurrence string.

Pre-v1.4.23 deploys silently survived because the existing query
sites only selected the column on SELECT — Postgres would have
failed loudly the moment a user actually saved a non-default
recurrence string via the medication form.

Migration `0039_medication_schedule_days_of_week` adds the column
nullable, default NULL. NULL means "daily" — `parseScheduleRecurrence(null)`
already returns the all-7-days set, so existing rows get the correct
semantics without backfill.

H5 decision rationale: deploy (Option A) over drop (Option B). The
field is referenced by 9 source files across 4 user-visible surfaces
(medication form, card, intake list, reminder dispatcher). Removing
it would force a UI rewrite of the recurrence picker and break
existing test fixtures. Deploying matches the schema's stated intent
and unblocks the v1.4.21 senior-dev review's MED-5 flag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:36:27 +02:00
Marc-André BombeckandClaude Opus 4.7 3f60c81b9e feat(coach): per-user prompt-tuning surface (settings cog returns)
The v1.4.22 B5 audit removed the placeholder cog from the Coach
drawer header because it had no real wiring. v1.4.23 H4 returns
the cog with an actual settings surface backed by a per-user
preferences row.

Schema: nullable JSONB column `users.coach_prefs_json` (migration
0038_coach_prefs). Null = legacy v1.4.22 defaults; non-null carries
`{ tone, verbosity, excludeMetrics, showEvidenceByDefault }` validated
by the Zod schema in `src/lib/validations/coach-prefs.ts`.

Routes: GET + PUT `/api/auth/me/coach-prefs`. PUT validates the body,
persists the canonical defaulted form, and emits an
`auth.me.coach-prefs.put` annotation with the four-knob meta. OpenAPI
registry covers both verbs; the spec regenerates without drift.

Coach prompt builder: `getCoachSystemPrompt(locale, prefs)` prepends a
per-user OVERRIDE paragraph for non-default tone (neutral / concise)
and verbosity (brief / detailed). Concise tone forces brief verbosity
regardless of the explicit selection — concise == short. Default
prefs leave the prompt unchanged so the v1.4.22 train stays in place.

Snapshot builder: reads the prefs row BEFORE any measurement query
and intersects `excludeMetrics` with the resolved scope. The model
never sees data the user opted out of — that's the load-bearing
order: filter first, query second.

UI: settings cog in the drawer header opens a right-edge `<Sheet>`
with three Select pickers (tone, verbosity) and one Switch column
(per-metric exclusions + showEvidenceByDefault). Save calls the PUT
route and invalidates the `["coach-prefs"]` query so the next reply
honours the new defaults without a reload. EN + DE i18n keys for
every label and hint.

Message-thread reads the same `["coach-prefs"]` cache and toggles the
`open` attribute on the evidence `<details>` element when
`showEvidenceByDefault` is true.

Tests +24 unit (coach-prefs schema, system-prompt prefix, snapshot
mock for new prefs row). Integration round-trip seeds a session,
PUTs a non-default prefs shape, asserts the GET reflects the saved
values + the DB row carries the canonical defaulted form.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:35:45 +02:00
Marc-André BombeckandClaude Opus 4.7 70789170cf feat(schema): APNs device columns + dispatcher channel-type extension
Adds the per-device APNs identity pair (`apnsToken` + `apnsEnvironment`)
to the Device model so the dispatcher can fan a notification out to a
user's iPhone via Apple's push gateway. The iOS client decides whether
the token belongs to the sandbox or production gateway at registration
time — the server never auto-detects, mirroring the W1 stream-2
recommendation.

Migration 0037 is strictly additive: both columns nullable, a CHECK
constraint pairs them so neither can be set without the other, and a
single-column index on `apns_token` backs the dispatcher's per-user
lookup plus the cross-user-hijack guard already enforced in
`POST /api/devices`. The `NotificationChannel.type` column stays a
free-form string — adding "APNS" as the fourth channel-type is a
TypeScript-side change in the next commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:35:58 +02:00
Marc-André BombeckandClaude Opus 4.7 95209ebf79 feat(schema): Apple Health measurement types + sleep stages + dedup index
Extend MeasurementType with the seven HealthKit-shaped values the iOS
batch ingest needs (HRV, resting HR, active energy, flights climbed,
distance, VO2 max, body temperature) and add APPLE_HEALTH to the
MeasurementSource enum. Sleep duration unit shifts from hours to
minutes so per-stage HKCategorySample rows persist without precision
loss; the new SleepStage enum mirrors the existing GlucoseContext
column shape and a CHECK constraint scopes it to SLEEP_DURATION rows.

The composite unique index `(user_id, type, source, external_id)`
gives the new batch endpoint an idempotent dedup key while the legacy
`(user_id, type, measured_at, source)` index continues to back the
manual UI's no-duplicate-at-the-same-wall-clock guard. NULL externalId
rows stay distinct under Postgres semantics so manual entries never
collide.

Migration is strictly additive: new enum values append, new columns
are nullable, no rows mutate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:15:56 +02:00
Marc-André BombeckandClaude Opus 4.7 5527001a01 feat(coach): add CoachConversation, CoachMessage, CoachUsage models
Persist Coach chat threads with AES-256-GCM-encrypted message bodies
and a per-user, per-day token-spend ledger. Encryption reuses the
active ENCRYPTION_KEYS entry so rotation continues to work without a
backfill. metric_source_json carries provenance labels only (window +
metric names, never raw values), keeping it queryable for analytics.

Cascade-on-user-delete is wired through the FK chain so GDPR delete
removes Coach data with the rest of the account. The integration-suite
truncation list picks up the new tables.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:54:28 +02:00
Marc-André BombeckandClaude Opus 4.7 badc893853 feat(db): RecommendationFeedback model with GDPR cascade + provider attribution
v1.4.16 phase B5e: ship the table that backs per-recommendation
thumbs-up / thumbs-down feedback. Each row captures (userId, recId,
recText) with snapshotted severity + metric-source attributes plus the
provider + prompt-version that produced the rec — server-fills the
attribution from the user's most recent cached payload so the client
can't tamper.

Dedup key (userId, recommendationId, recommendationText) lets a
regeneration that rewrites the same id with new text re-rate cleanly
while blocking accidental double-submission of the same (id + text).

Indexes: (userId, createdAt) for the per-user timeline and
(severity, helpful, createdAt) for the daily aggregator's slice. Both
are needed by the v1.4.16 admin AI quality preview.

GDPR: ON DELETE CASCADE through users; cascade-delete integration
test now seeds and asserts a feedback row.

Same migration adds AppSettings.adminAiInsightsFeedbackSummary — the
JSON blob the daily aggregator (next commit) writes a per-(severity x
provider x prompt) helpful-rate to. Null until the aggregator runs
or until there is feedback in the 30-day window.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 01:25:57 +02:00
Marc-André BombeckandClaude Opus 4.7 2611bb4fae feat(db): User.aiProviderChain for ordered fallback config
Add a JSONB column on the users table that stores an ordered list of
provider preferences for multi-provider redundancy. Each entry carries
{ providerType, priority, enabled }; credentials still live in the
dedicated encrypted columns so AES-256-GCM key rotation keeps working
unchanged. Null preserves the legacy single-provider resolution path.

Ships a defensive parser/serializer pair with a sensible default chain
(codex -> openai -> anthropic -> local -> admin-openai) so a malformed
JSON on disk falls back to the default rather than 500-ing insight
generation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 00:20:12 +02:00
Marc-André BombeckandClaude Opus 4.7 5d1ece1fd5 feat(db): HostMetric model for host-load sampling
Adds the per-minute snapshot table that backs the new admin host-load
chart above /admin/system-status. One row per minute, captured by the
pg-boss sampler in the next commit, with 7-day retention enforced
inside the worker so the table stays under a few thousand rows.

Schema mirrors what the chart needs:
  - load_avg_1/5/15 (DOUBLE PRECISION) — os.loadavg() output
  - mem_used_bytes / mem_total_bytes — derived from os.totalmem() and
    os.freemem(), kept as raw bytes so the API can compute either a
    percent or a MiB delta downstream
  - disk_read_bytes / disk_write_bytes — nullable cumulative byte
    counters from /proc/diskstats. NULL on macOS/Windows dev hosts;
    Linux production picks them up automatically. The API endpoint
    converts deltas to bytes-per-second between samples

Indexed by captured_at so the "last 2 hours" query stays index-only.

Migration 0032_host_metric. integration setup.ts adds host_metrics to
the truncate list so testcontainers stays clean across runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:46:56 +02:00
Marc-André BombeckandClaude Opus 4.7 28467b2590 feat(doctor-report): practice name on cover page (persisted as user preference)
Marc wanted the addressee visible at first glance on the printout —
"Praxis Dr. Müller" should be the first thing the GP sees, so the
report doubles as a polite opening line.

- New optional free-text field "Praxis / clinic name" in the export
  dialog. The dialog pre-fills it with the user's last-used value so
  recurring exports don't require re-typing.
- Persisted as a user preference: new `User.lastReportPracticeName`
  column (migration `0031_user_last_report_practice_name`). The value
  is updated only on a successful POST that supplied a non-empty
  string — passing `null` does NOT clear the stored preference so the
  value sticks across years of follow-ups. Best-effort write: a DB
  failure here MUST NOT break the report (caught + ignored).
- `sanitisePracticeName()` enforces a strict envelope upstream: strips
  ASCII C0 + DEL controls (which jsPDF can't render), collapses
  whitespace, and hard-caps at 120 characters. Defends the PDF layout
  from runaway input + protects the PDF text stream from
  control-character corruption.
- PDF cover prints the line in 11pt bold above the separator when set;
  omitted entirely when `null` so the cover stays compact for
  self-archive use. Both client (`doctor-report-pdf.ts`) and server
  (`doctor-report-pdf-core.ts`) renderers carry the change so the
  visual output matches whichever path the user takes (browser
  download vs iOS Safari fallback).
- `/api/auth/me` now echoes `lastReportPracticeName` so the dialog
  pre-fills without an extra round-trip.
- Audit-log + Wide-Event annotations record `practiceNameProvided:
  boolean` (NOT the value itself — practice names are personal and we
  already store one canonical copy on the user record).

i18n: 6 new keys (`doctorReport.practiceLabel`, `doctorReport.dialog.{practiceLabel,practicePlaceholder}`)
in EN + DE.

Tests: 12 new unit cases covering sanitisation (whitespace, controls,
length cap, type guard), PDF cover rendering (DE label "Praxis:", EN
"Practice:", omission when null), API persistence (prisma.user.update
called with the right shape, audit detail flag set). 965 / 965 unit
pass, 31 / 31 integration pass. Lint 0 errors. Typecheck clean for B6
files (pre-existing dashboard-layout.test.ts errors are A4 territory).
v1.4.15 phase B6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:36:47 +02:00
Marc-André BombeckandClaude Opus 4.7 db5a49d593 feat(db): User.onboardingTourCompleted flag
v1.4.15 Phase B5 — first commit. Adds a boolean column
`users.onboarding_tour_completed` (default false) to track whether the
user has finished or explicitly dismissed the new spotlight tour that
overlays the dashboard on first arrival.

This is *distinct* from the existing `onboarding_completed_at` column
which gates the 3-step wizard at `/onboarding`. The new tour runs
*after* the wizard, on the dashboard, walking new users through the
tile strip, quick-add menu, insights page, integrations, and (when
B4 ships) achievements. Flipped to `true` on tour completion or
explicit dismiss; users can replay the tour from
Settings → Account → "Restart onboarding tour".

Migration `0030_onboarding_tour_completed` is additive-only (column
default `false`), so no backfill is needed and existing users will
see the tour on next dashboard load — acceptable because the tour is
a 4-6 step tooltip overlay with a Skip button on every step, not a
forced flow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:14:00 +02:00
Marc-André BombeckandClaude Opus 4.7 4db72a8c2e feat(integrations): connection-state + sync-error UI in Settings → Integrations
v1.4.15 Phase B2, criterion 1. Until this release the Settings →
Integrations card showed "Connected / Last sync 23 min ago" and
nothing else. A user whose Withings refresh token had revoked saw a
green badge; a user whose moodLog instance was down saw the same
green badge with a slightly older timestamp. The signal was useless.

This commit adds:

  - New `IntegrationStatus` table (`integration_statuses` migration
    `0029`). One row per (user, integration) capturing `state`
    (`connected | error_transient | error_reauth | disconnected`),
    `lastSuccessAt`, `lastAttemptAt`, encrypted `lastError`,
    `consecutiveFailures` counter, `alertedAt` window-guard.
  - `src/lib/integrations/status.ts` — the only writers
    (`recordSyncSuccess`, `recordSyncFailure`, `markReauthRequired`,
    `markReconnected`, `markDisconnected`) plus a read-only
    `getIntegrationStatus()` for the API. The error column is
    AES-256-GCM encrypted at rest because Withings/moodLog 401s can
    echo URL/apiKey fragments — we can't ship that into a future
    backup tarball.
  - `GET /api/integrations/status` — single roundtrip returns both
    Withings and moodLog snapshots plus the global threshold (so the
    UI's "{n}/{threshold}" string is server-sourced and rotates with
    the env var).
  - Settings → Integrations cards both gain an
    `IntegrationStatusBanner` row above the credentials form: state
    badge ("Connected" / "Reconnect required" / "Error" /
    "Disconnected"), `{n}/{threshold} consecutive failures` chip,
    last-success / last-attempt times, and a destructive-toned
    last-error line. The banner is suppressed entirely when the
    integration is fresh-connected with no history (no false alarms).

Tests: +20 unit (status helper threshold + alert-window state machine,
banner rendering across all four states) + 8 integration
(`tests/integration/integration-status.test.ts`) confirming
encrypted-at-rest, audit-log row shape, alert idempotency window,
and CASCADE-delete on User. 116 files / 883 unit pass, 11 integration
files / 39 pass.

Encryption uses the existing `src/lib/crypto.ts` AES-256-GCM helper —
no new key material. The `consecutive_failures` counter and
`alerted_at` window are wired to the dispatcher in the next commit
(`feat(integrations): admin Telegram alert on persistent sync
failure`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:04:04 +02:00
Marc-André BombeckandClaude Opus 4.7 87a40fd0a4 fix(notifications): auto-disable channels on persistent hard rejects (410, etc.)
v1.4.15 Phase B3, criteria 1 + 2 (atomic — they share migration + retry-
policy types). Until this release the dispatcher fired channels best-
effort and kept retrying every 60s on a 410 Gone — that meant a stale
Web-Push subscription never recovered, a Telegram bot whose chat was
deleted got the same treatment, and the user had no visibility into
WHY their phone had stopped buzzing.

Reliability rules now:
- HARD reject (HTTP 410 web-push / ntfy, Telegram "chat not found" /
  "blocked by the user") → channel auto-disabled, disabled_reason
  captured, audit log notification.channel.auto_disabled written.
  Sender NEVER retried until the user clicks "Re-enable" in Settings.
- SOFT reject (5xx, 429, network timeout) → exponential backoff:
  [30s, 5min, 30min, 2h]. Counter consecutive_failures increments
  per failure; after the 5th in-a-row the channel is auto-disabled
  with reason give_up_after_5_failures.
- COOLDOWN window (next_retry_at > now) → dispatcher skips the
  channel without calling the sender — defeats the API-quota-burn
  loop a flapping upstream would otherwise create.

Implementation:
- Migration 0028 adds 6 columns to notification_channels (all
  nullable / zero-default → no-op for existing rows).
- src/lib/notifications/retry-policy.ts: classifiers + frozen
  BACKOFF_SCHEDULE_MS + nextRetryAt() helpers.
- src/lib/notifications/channel-state.ts: only writer of the new
  columns (recordChannelSuccess, recordChannelHardReject,
  recordChannelTransientFailure, reEnableChannel,
  isChannelInCooldown). Each transient/hard path writes audit.
- Senders return a structured SendOutcome instead of bare booleans.
- Dispatcher: cooldown-skip → success → hard-reject → transient/
  give-up. Sender-throws absorbed as transient failures.

Tests: +20 unit tests (retry-policy.test.ts, channel-state.test.ts,
dispatcher.test.ts). 883/883 unit tests pass; typecheck clean; lint
clean (no new warnings).

Pre-existing bug fix: src/app/api/settings/telegram/test/route.ts
had if (!ok) against a SendMessageResult object — always truthy,
so the route never reported test-send failures. Now reads result.ok.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:01:45 +02:00
Marc-André BombeckandClaude Opus 4.7 d438a8520d feat(admin): bug-report toggle, unify "Trennen" button styling
Bucket E+F of the v1.4.3 polish pass.

**Disconnect-button styling unified.** Three integrations had three
different "Trennen" buttons: Withings used `outline` + `text-
destructive`; moodLog used solid `destructive` (red bg, white text);
the AI/Codex card used `ghost` + `text-destructive`. Same action,
three readings — confusing. All three now use the Withings pattern
(`outline` + `text-destructive`). The actual confirmation button
inside the AlertDialog keeps its solid-red treatment so the final
"yes, do it" still reads as a strong commit.

**Bug-report explicit on/off toggle.** Until v1.4.3 the bug-report
feature was implicitly gated on "is the GitHub token + repo
configured". An admin who wanted to *temporarily* hide the report
button (e.g. during a freeze window) had to clear the encrypted
token, forcing re-entry on resume — destructive for a reversible
intent. Migration `0027_bug_report_enabled_toggle` adds
`app_settings.bug_report_enabled BOOLEAN NOT NULL DEFAULT TRUE`,
the admin settings schema accepts `bugReportEnabled`, and a Switch
in the admin → Bug Report card lets the operator flip it.
`POST /api/bugreport` now returns 503 ("Bug reports are disabled by
the administrator") when the flag is off — the client already
renders a "not configured" screen on that status, so the report
button hides gracefully without further client-side wiring.

i18n keys added: `admin.bugReportEnabledLabel` + `…Description` in
both DE and EN.

Tests + typecheck green (671/671).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:45:18 +02:00
Marc-André BombeckandClaude Opus 4.7 c05f48f520 feat(settings): KI-provider — OpenAI key field, model preset dropdown, gated OAuth
Bucket C of the v1.4.3 polish pass.

Three independent bugs in the KI-provider settings, fixed together
because they all live on the same panel:

1. **Selecting "OpenAI" left no way to provide a key.** The schema
   stored Anthropic and Local API keys, but OPENAI was a valid
   `aiProvider` value with no matching credential column — the
   resolver silently fell through to the admin-level OpenAI key. So
   a self-hosted user who picked OpenAI and typed nothing got the
   admin's key (might not exist), and a user who *wanted* their own
   OpenAI key had no UI surface to enter it.

   Migration `0026_user_ai_openai_key` adds `users.ai_openai_key_
   encrypted`. The resolver's `OPENAI` branch now picks up the
   user-level key first and only falls back to admin if the user
   slot is empty. The settings UI grew an OpenAI-key field gated on
   `provider === "OPENAI"` mirroring the Anthropic flow. The
   `/api/ai/test` override schema accepts `openaiKey` so the
   "Verbindung testen" button can validate an unsaved key without
   persisting plaintext.

2. **Model field was a freetext Input, easy to typo.** Replaced
   with a `<select>` populated from `MODEL_PRESETS` per provider:
   gpt-4o / gpt-4o-mini / gpt-4-turbo / gpt-5 / o3-mini for OPENAI;
   claude-sonnet-4-6 / claude-opus-4-7 / claude-haiku-4-5 / claude-
   3-5-sonnet-latest for ANTHROPIC; llama3.1:8b / llama3.1:70b /
   mistral / qwen2.5 for LOCAL. An "Eigenes…" sentinel still
   surfaces the freetext Input so power users can target preview
   models or fine-tunes. The dropdown disables when no provider is
   chosen — saves the user from filling a model that won't be used.

3. **"Mit ChatGPT verbinden" silently bounced to chatgpt.com login
   with no OAuth grant.** The authorize URL was built without a
   `client_id` parameter, so chatgpt.com couldn't recognise the
   request as an OAuth handshake — it just rendered the regular
   login form. The user came back, nothing connected, the button
   "did nothing".

   Added `CODEX_OAUTH_CLIENT_ID` env var. The URL builder now
   throws `CodexOAuthNotConfiguredError` if it's unset; the
   authorize route maps that to a 503 with an actionable message
   instead of dispatching the dead URL. The settings card reads
   `codexOauthConfigured` from `/api/insights/settings` and hides
   the Connect button when the flag is false, replacing it with a
   tiny italicised "ChatGPT-OAuth ist auf dieser Instanz nicht
   konfiguriert — nutze stattdessen einen eigenen API-Key unten."
   `CHATGPT_OAUTH` was also removed as a manually-selectable
   provider option in the dropdown — the top connect button is the
   path; having both confused the affordance.

Refresh + token-exchange both forward the client_id now too. Test
fixture seeds `process.env.CODEX_OAUTH_CLIENT_ID="test_client_id"`
in `beforeEach` so the existing URL-shape assertion still works,
plus a new test asserts the unset case throws.

Tests + typecheck green (670/670 — one new assertion).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:36:36 +02:00
Marc-André BombeckandMarc-André Bombeck d7778c1085 feat(v1.4): multi-tenant prep — off-host backup, encryption-key versioning, worker/web split, native-Bearer UA gating (#135)
* feat(crypto): G2 — encryption-key versioning + rotation CLI

Adds backwards-compatible support for ENCRYPTION_KEYS (JSON map of
{ "id": "<key>" }) plus ENCRYPTION_ACTIVE_KEY_ID. Ciphertext format is now
"<keyId>.<base64payload>"; legacy bare-base64 ciphertext still decrypts
under the synthetic "v1" id derived from the existing ENCRYPTION_KEY.

scripts/rotate-encryption-key.ts re-encrypts every encrypted column on
User / WithingsConnection / AppSettings with the active key. Idempotent —
rows already on the active id are skipped.

Why: enables zero-downtime rotation when a key is suspected leaked,
without forcing every encrypted row through a single big-bang rewrite.

Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com>

* feat(auth): G4 — native-Bearer UA gating + rotating refresh tokens

Web callers (UA contains 'Mozilla/') keep the legacy 90d Bearer when they
explicitly opt in via X-Client-Type: native. Native callers (HealthLog-iOS,
HealthLog-iPad, n8n, Health-Connect, unrecognised UAs) now receive a 24h
access token plus a 60d rotating refresh token in the login response.

New /api/auth/refresh endpoint atomically validates + rotates the refresh
token (one-time-use), revokes the previously paired access token, and
audit-logs every refresh + revoke. Reuse of an already-consumed refresh
token revokes the entire family for the user/device — defence against a
stolen refresh token.

Migration 0025_refresh_tokens adds the table; the only DDL is CREATE
TABLE so it is forward-compatible. Down migration documented at
docs/ops/migrations/0025-down.sql.

Why: the V3 audit (still-V2-A) flagged that 90d tokens are too long for
native callers when refresh-token rotation is now table-stakes for mobile
apps. Web flows are unchanged so existing browser sessions keep working.

Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com>

* feat(infra): G3 — web/worker container split via HEALTHLOG_PROCESS_TYPE

Adds a tiny startup gate (src/lib/process-type.ts) so the same Docker image
can run as web-only, worker-only, or 'all' (default — single-container,
backwards compatible). assertSubsystemEnabled() refuses to boot the
reminder worker when HEALTHLOG_PROCESS_TYPE=web, preventing accidental
double-cron when an operator splits the deployment.

docker-compose.yml ships the optional app-worker service block commented
out so 'docker compose up' keeps working out of the box; uncommenting it
+ flipping app to HEALTHLOG_PROCESS_TYPE=web is the only change to scale
HTTP horizontally. Healthchecks are isolated per service so neither
startup deadlocks the other; both wait on db: service_healthy.

docs/self-hosting/scaling.md walks through when + how to split.

Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com>

* feat(jobs): G1 — daily off-host encrypted backup to S3-compatible storage

New pg-boss queue 'data-backup-offhost' runs at 02:30 Europe/Berlin, dumps
each user's measurements + medications + intake events + mood entries,
encrypts the JSON under a SEPARATE key (BACKUP_ENCRYPTION_KEY, isolated
from the application ENCRYPTION_KEY), and uploads to any S3-compatible
target via @aws-sdk/client-s3.

Wire format documented in docs/ops/backup-restore.md:
  magic "HLBK" || version 0x01 || iv(12) || authTag(16) || ciphertext

The worker prunes objects older than BACKUP_RETENTION_DAYS (default 30);
operators are encouraged to also configure a bucket-level lifecycle rule.

Admin endpoint POST /api/admin/backup/test issues a 1-byte PUT+GET
roundtrip and returns endpoint + bucket + per-call latency. Credentials
are never returned.

scripts/restore-backup.ts downloads + decrypts a single object so
operators can verify the chain end-to-end without standing up a separate
restore harness.

Why: GDPR Art. 32 + V3 audit "data durability" — without an off-host copy
the only line of defence against a Postgres volume loss is the in-DB
DataBackup model on the same host. Separate-key isolation means a leak of
the application encryption key does not also expose every user's archive.

Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com>

* fix(multi-tenant): address review findings — rotation coverage, hard decrypt errors, web/worker gates

Multi-agent review on PR #135 caught several CRITICAL + HIGH issues that
would have shipped silent data corruption or weakened the threat model:

Encryption + rotation
- scripts/rotate-encryption-key.ts: stop selecting `bugReportTokenEncrypted`
  (column doesn't exist in the schema; the script crashed on the AppSettings
  step). Add the three encrypted columns it was missing —
  NotificationChannel.config, PushSubscription.p256dh, PushSubscription.auth
  — so rotating to a new key actually re-encrypts every encrypted row.
  Without this, dropping the old key id from ENCRYPTION_KEYS would leave
  notification channels and web-push subscriptions permanently
  undecryptable.
- src/lib/crypto.ts: refuse to silently fall back to the active key when a
  legacy bare-base64 row is decrypted but no v1 entry is configured. Throws
  a clear "Restore ENCRYPTION_KEY (or add a v1 entry to ENCRYPTION_KEYS)
  and run scripts/rotate-encryption-key.ts before removing it" so an
  operator who pulled the v1 too early notices immediately instead of
  hitting opaque GCM tag errors.
- docs/ops/encryption-key-rotation.md: full step-by-step + rollback note +
  troubleshooting section. Spells out that rotation is a forward-only
  operation and what to do if the rollback path is needed.

Web / worker split (G3)
- src/proxy.ts: refuse HTTP traffic with a 503 +
  X-HealthLog-Process-Type: worker header when HEALTHLOG_PROCESS_TYPE=worker.
  The worker container (which still runs `node server.js` for now) no
  longer accepts requests, so a misrouted client gets a clear hint instead
  of a partially-served response.
- src/instrumentation.ts: short-circuit before importing the reminder
  worker when HEALTHLOG_PROCESS_TYPE=web — avoids the noisy
  "[instrumentation] CRITICAL" stderr line we emitted on every web boot.
- /api/version added to PUBLIC_PATHS so the compose healthcheck doesn't
  silently 302-redirect through the auth gate (the prior wget treated the
  login page as a 200 success and would never notice an unhealthy backend).

Refresh-token rotation (G4)
- src/lib/auth/refresh-token.ts: reuse-detection now revokes ALL active
  refresh tokens for the user, not only those scoped to the same deviceId.
  An attacker who rotated without an X-Device-Id header would otherwise
  keep their family alive on the no-device branch. Defence-in-depth.
- revokeRefreshToken() also revokes the paired access token so a leaked
  access token cannot outlive the refresh-token sibling that the caller
  just killed (logout-on-device).

Off-host backup (G1)
- src/lib/jobs/offhost-backup.ts: drop the worker-side DeleteObject path.
  Retention is the bucket's lifecycle-policy job; the worker keeps a
  PutObject + GetObject grant only. A compromised worker can no longer
  wipe the backup history. Per-user errors are now surfaced as Wide
  Event warnings + a structured failures[] array on the report so an
  operator can tell which user failed and why.
- scripts/restore-backup.ts: schema-validate the decrypted payload before
  writing to disk, plus an optional --user-id=<id> flag that refuses to
  proceed unless the dump's userId matches. Prevents silent
  cross-tenant restores when an operator points the script at the wrong
  S3 key.

Idempotency
- src/lib/idempotency.ts: the leak-guard already blocked `hlk_`
  (access-token prefix); also block `hlr_` so a future caller wrapping
  the new /api/auth/refresh endpoint with withIdempotency() can't
  accidentally cache a freshly-issued refresh token.

Migration / schema
- prisma/migrations/0025_refresh_tokens/migration.sql: wrap in a single
  transaction; switch to CREATE { TABLE | INDEX } IF NOT EXISTS so a
  partial-deploy retry is a no-op instead of "relation already exists";
  guard the FK ADD with information_schema lookup for the same reason.
- Schema replaces the single-column `@@index([deviceId])` with a
  composite `@@index([userId, deviceId])` (every lookup also filters on
  userId; the old index would never have been used). Comment on
  accessTokenHash corrected to HMAC-SHA-256.

Container / ops
- Dockerfile: install tzdata + ENV TZ=Europe/Berlin so the daily
  pg-boss schedule (`30 2 * * *` Europe/Berlin) actually fires at 02:30
  Berlin instead of silently falling back to UTC on the alpine base.
- docker-compose.yml: tighten the healthcheck to `wget --spider` (matches
  the Dockerfile HEALTHCHECK style, doesn't pipe the body), pass through
  BACKUP_RETENTION_DAYS so a custom retention env reaches the container.
- .env.example: document every new env var added in this PR
  (HEALTHLOG_PROCESS_TYPE, ENCRYPTION_KEYS, ENCRYPTION_ACTIVE_KEY_ID,
  BACKUP_S3_*, BACKUP_ENCRYPTION_KEY, BACKUP_RETENTION_DAYS) with
  comments and the all-or-none semantics for the backup block.

491 tests still green, typecheck clean.

* chore: retrigger CI

---------

Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
2026-05-08 14:33:39 +02:00
Marc-André Bombeck 873f3eb8d0 fix(db): backfill the users.locale column as a migration (#140)
`users.locale` has been on `schema.prisma` since the v1.3 locale-aware
reminder work but never landed in the migration history — the column
was applied via `prisma db push` to dev/prod and not committed as a
migration. The drift is invisible against any environment that was
ever pushed to, and only surfaces when a fresh database is built
strictly from `prisma/migrations/` (CI integration test containers,
brand-new self-host installs from a tagged image, etc.) — Postgres
then reports `column users.locale does not exist` the first time the
Prisma client touches the column.

The migration is `ADD COLUMN IF NOT EXISTS`, so it's a clean add on a
fresh database and a safe no-op against any environment that was
already pushed-to.
2026-05-08 14:29:44 +02:00
Marc-André BombeckandClaude Opus 4.7 98a57eb786 audit(v1.3.3): SpO2 + body-composition + 14 CRITICAL/HIGH security/correctness fixes (#121)
* fix(measurements): close issue #109 + sync body composition across UI/PDF/thresholds

User-visible: TOTAL_BODY_WATER, BONE_MASS, and BLOOD_GLUCOSE now appear in
the measurements filter, badge, mobile icon, and edit dialog (issue #109).
Server-rendered doctor report now includes body composition rows (audit
C-9). Body water + bone mass have proper effective-range thresholds so
severity logic doesn't silently return `nominal` (audit C-15).

Structural: extracted the per-type display maps from measurement-list.tsx
into measurement-list-meta.ts as a deliberate first step toward the
single-source manifest planned for phase P5. Coverage tests now fail-fast
if a future enum addition skips any of: list label/icon/color, doctor-PDF
label/unit/vital-types, chart label, threshold metric order/label/bounds.

Audit-2026-05-07 / phase P0:
- closes #109 (filter UI gap, root cause for the German raw-enum display)
- closes audit C-9 (server PDF used a stale type map)
- closes audit C-15 (no warn/critical bands for body composition)

Tests: 282 → 296 (14 new). Typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: align OpenAPI spec + migration comment with body-composition reality

- OpenAPI MeasurementType enum: add TOTAL_BODY_WATER + BONE_MASS (was at
  v1.3.0 baseline; spec drifted past the v1.3 server release)
- Bump openapi version 1.3.0 → 1.3.2 to match package.json
- Migration 0022 comment: TOTAL_BODY_WATER stores kg (water content),
  not percent of body weight. Validators (VALUE_RANGES) and the Withings
  client (type 77 → kg) treat it as kg already; the comment was the lone
  outlier and would mislead the next reviewer.

Audit-2026-05-07 / phase P1a — closes audit M-B1 (partial; OpenAPI enum)
and M-B2 (migration unit comment).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(security): close 3 audit CRITICALs (Withings header / idempotency Bearer / GlitchTip URL strip)

C-3 (Withings webhook secret in URL):
Now reads X-Withings-Webhook-Secret header in preference; URL ?secret=…
fallback retained for legacy Withings configurations and emits a Wide
Event warning so operators can spot still-using-the-old-flow integrators.
The query-string secret was leaking into Coolify access logs and any
GlitchTip event whose request.url got captured.

C-4 (idempotency dead for Bearer-token clients):
The default userIdResolver only consulted getSession(). Bearer-authed
clients (the iOS / n8n / headless-API fleet that PR #117 was built for)
fell through, so every retry created duplicate measurements. The default
now tries cookie session first, then Bearer token via
hashToken+apiToken.findUnique with revoked/expiry checks. All three
existing call sites (measurements, mood-entries, medications/intake) get
the fix automatically.

H-B7 (request.url with secrets shipped to GlitchTip):
reportToGlitchtip now strips the query string before forwarding the URL,
so Withings legacy ?secret=… and any OAuth ?code=… that errors during
the callback don't end up in someone else's incident UI.

Audit-2026-05-07 / phase P2.
Tests: 296 → 301 (5 new in idempotency.test for the Bearer fallback +
revoked/expired/null cases).
Deferred to a follow-up (needs DB migration / lock primitive):
  C-2  moodLog webhook → AES-GCM at rest + HMAC-over-body
  C-5  idempotency TOCTOU advisory_lock or in-flight sentinel row

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(measurements): add OXYGEN_SATURATION (SpO2) as first-class measurement type — v1.3.3

Closes the OxygenSaturation feature request from #109.

- Migration 0024 extends MeasurementType enum (additive, IF NOT EXISTS)
- Zod validators: plausibility 50–100% (below 50 is incompatible with
  sustained life; above 100 is physically impossible)
- effective-range thresholds: green 95–100, orange 92–94, red <92.
  Sources: BTS Guideline 2017 (target 94–98%), ATS clinical practice
  (≥95% at rest is healthy), AHA/clinical literature (<92% = call
  provider, <88% = ER). Lower-only concern — upper orange wing
  collapses to greenMax since saturation cannot exceed 100%.
- Personalisation: existing thresholds-override UI lets COPD /
  chronic-respiratory users with a doctor-set 88–92 baseline tune the
  bands. METRIC_ORDER + METRIC_LABEL_KEYS extended in
  thresholds-section.tsx; the exhaustive `Record<ThresholdMetric>`
  forces TS to catch any missed surface.
- Withings ScanWatch type 54 mapping (best-effort — only fires for
  pulse-ox-capable devices).
- Wired through measurement-form, measurement-list-meta (icon: Wind),
  health-chart, both doctor-report-pdf renderers (core + browser-side
  near-duplicate), OpenAPI spec (version 1.3.0 → 1.3.3), and i18n in
  4 sections × 2 locales.
- iOS DTO already declared `OXYGEN_SATURATION` — server enum addition
  closes the long-standing drift flagged in audit V2.
- 4 new tests: numeric green band, upper-wing collapse,
  user-override (COPD baseline), METRIC_BOUNDS shape.

Tests: 301 → 305. Typecheck: clean.

Audit-2026-05-07 / v1.3.3 / closes #109 SpO2 feature request.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(analytics): truthfulness pass on medical citations + clamp override orangeMax

Doctor-agent + hallucination-hunter audit V3 found six medical citations
in effective-range.ts that the code did not actually implement. Plus a
latent bug in the user-override path that produced physically-impossible
orangeMax values (>100% saturation, <0% body-fat).

Citation rewrites (no numeric changes):

- ACTIVITY_STEPS: "WHO ≥8000 steps/day" → Saint-Maurice JAMA 2020.
  WHO publishes activity in min/week, not steps. Step-count was a
  misattribution that also leaked into AI prompts.
- TOTAL_BODY_WATER: "ESPEN Body Composition Guidance 2017" → Watson
  formula / ICRP Reference Man. ESPEN 2017 is BIA practice guidance,
  it does not publish kg cut-offs.
- BONE_MASS: "DEXA-equivalent bone mineral content" → "Bioimpedance-
  estimated; not DEXA-comparable". Withings BIA values typically run
  5–7% below DEXA — calling them DEXA-equivalent in a doctor-report
  PDF was a real legal-exposure misattribution.
- OXYGEN_SATURATION: "BTS Guideline 2017 (target 94–98%) + ATS
  clinical practice (≥95% at rest is healthy)" → "consumer pulse-
  oximeter consensus (≥95% normal at rest); slightly tighter than
  BTS 2017 (94–98%); NICE NG115 ≤92% escalation, BMJ panel / FDA
  pulse-oximeter labelling ≤88% ER threshold". Code uses 95-100/92-100
  band — that's an ATS/consumer-consensus number, not BTS's literal
  94-98 target. Soften the citation to match the code, not vice versa.

Bug fix (1 latent, 1 real-today):

- effective-range override path now clamps orangeMin/orangeMax to
  METRIC_BOUNDS.{min,max}. Without the clamp, a COPD user setting
  SpO2 override to {95,100} emitted orangeMax = 100.75 (impossible
  saturation), and a BODY_FAT override of {2,80} emitted orangeMin
  = -9.7 (negative percent). Default-range case 210 already
  hard-coded orangeMax: 100 for the same reason — the override path
  was the only branch missing the invariant.

Tests: 305 → 306 (1 new: clamps override orangeMax to physiological 100%).
Typecheck: clean.

Audit-2026-05-07-V3 / closes Doctor + Senior-Dev medical-citation findings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(security): Bearer-scope wildcard handling + GDPR Art.17 account-delete erasure

Two CRITICAL fixes from audit V3.

NEW-V3-1: Bearer-scope bypass.
  apiToken.permissions ["*"] is meant to be the wildcard "session-equivalent"
  scope (the iOS app receives this on login). The previous check
    permissions.includes(requiredPermission)
  treated "*" as a literal string — never matched. Today many sensitive
  routes call requireAuth() *without* a requiredPermission, so a leaked iOS
  token can act as a full-scope token (account delete, settings wipe). Once
  those routes adopt requireAuth("scope:name"), this wildcard handling keeps
  the iOS app working while narrower-scoped tokens (e.g. user-issued
  ["medication:ingest"]) get correctly 403'd.

  Same wildcard handling added to the standalone permission checks in
  src/app/api/ingest/medication/route.ts:65, 119.

NEW-V3-2: GDPR Art. 17 account-delete erasure completeness.
  Feedback + AuditLog rows had `onDelete: SetNull` in the schema, which
  preserved PII (free-text symptom descriptions, IP addresses, login-city
  geolocation) after the user was deleted. We now explicitly
  prisma.feedback.deleteMany + prisma.auditLog.deleteMany before the
  cascade-delete, making account deletion genuinely complete erasure.

Tests 306/306 still pass; typecheck clean. Audit V3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(pdf): real text-content assertions for body composition + SpO2 rows

Replaces bytes-only "renders body composition rows" theatre test with
pdf-parse-driven text extraction that asserts the actual rendered labels
+ values for TOTAL_BODY_WATER, BONE_MASS, OXYGEN_SATURATION (DE + EN).

Closes V3 audit finding STILL-V2-NEW-3: the previous test only verified
"%PDF-" header + byte-count, so a regression that drops a row would
still pass. Now a missing label fails the test.

- adds dev dep: pdf-parse@^2.4.5
- 308/308 tests pass (was 306, +2 SpO2 + EN-locale coverage)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(measurements): close enum drift cousins — derive 5 type-arrays from canonical enum + extend 2 contract enums

V3 audit finding "enum drift cousins": 7 hardcoded module-level type arrays
silently dropped SpO2/TBW/BoneMass/BloodGlucose from their respective
domains. Adding a new measurement type required hunting these down.

Now all aggregation paths derive from `measurementTypeEnum.options`:
- /api/insights/comprehensive (AI insights)
- /api/dashboard/summary (iOS dashboard adapter)
- /api/analytics (analytics aggregator)
- /lib/insights/general-status (AI general-status fetch)
- /api/import (Zod schema — round-trip now covers all 11 types)

External-contract enums extended additively (no breakage for old clients):
- /api/measurements/series kindEnum: + oxygen, totalBodyWater, boneMass
- /api/dashboard/widgets widgetIdEnum: + oxygenSaturation
- DashboardWidgetId + DEFAULT_DASHBOARD_LAYOUT: + oxygenSaturation row
- formatMeasurementsForExport: glucoseContext now round-trips

Coverage test (`measurement-type-enum-coverage.test.ts`) asserts the
canonical enum stays the source of truth and documents PDF exclusions
(BLOOD_GLUCOSE renders via context section, SLEEP/STEPS lifestyle-only).

313/313 tests pass (was 308, +5 new).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(security): close 6 V3-audit HIGH findings (CSP, web-push SSRF, IP geo, AI provider leak, import rate-limit)

- CSP: chatgpt.com + api.openai.com only on /settings/ai/** routes (was
  blanket connect-src on every page including /auth/login → DOM-XSS exfil
  channel)
- web-push: webPushSubscriptionSchema.endpoint requires HTTPS + isPublicUrl
  refinement; was z.url() only → blind SSRF probe via Push subscription
- isPublicUrl: also fixes a regression where DNS labels starting with
  "fc"/"fd" (fcm.googleapis.com etc.) were falsely classified as IPv6
  unique-local. Check is now gated on a colon being present in the host.
- /api/ai/test: stop returning provider err.message + bodyExcerpt to the
  client (leaked provider URLs / partial keys / internal headers). Now
  returns categorised generic message; full details land in the wide-event
  via annotate() for the operator.
- /lib/geo (auditLog IP geo lookup): switch default provider to
  ipwho.is over HTTPS, accept ipwho.is + ip-api pro response shapes,
  add IP_GEO_LOOKUP_DISABLED env, refuse non-HTTPS configured URLs.
  Previously leaked IP+timestamp over plaintext HTTP every auth event
  (GDPR Art. 32 + Art. 44).
- /api/import: add 5/hour rate-limit (was unlimited; bulk-injection vector)

Tests: 325/325 pass (was 313, +12 new — webPushSubscriptionSchema SSRF
guards, fc/fd-prefix domain regression, geo HTTPS-only, geo provider
shapes, geo opt-out env).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(security,gdpr): trusted-proxy IP, audit-log retention, idempotency cachable filter, Bearer mock tightening

V3 audit follow-on bundle covering 4 HIGH findings.

(I) getClientIp — TRUST_PROXY_HOPS env (default 1) replaces blind XFF
trust. Reads chain right-to-left so client-supplied leftmost entries
cannot rotate the per-IP rate-limit bucket. Default 1 matches typical
single-proxy self-host (Coolify/Caddy/Cloudflare-Tunnel); 0 forces
x-real-ip / null. New 7-test suite covers the rotation attack +
malformed entries + zero-hop fallback.

(F) Audit-log retention job — daily 03:15 cleanup of audit_logs older
than AUDIT_LOG_RETENTION_DAYS (default 365). Closes GDPR Art. 5(1)(e)
"storage limitation" gap; previously IPs + city + login events
accumulated forever. 6-test suite covers default + override +
misconfig guard (rejects <7 days).

(G) isCachableStatus — extracted the inline 4xx/5xx filter into a
named, exported, tested function. 7 cases cover the full do-not-cache
contract (401 / 403 / 408 / 429 / any 5xx) and confirm 2xx + 4xx
validation are still cached.

(H) Bearer-mock tightening — apiToken.findUnique mocks in
require-auth-bearer.test + idempotency.test now assert the
where.tokenHash shape uses the hashed value (not the raw bearer).
A regression that switched back to raw-token comparison would have
been silent without this assertion.

Tests: 345/345 pass (was 332, +13 new).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(security): encrypt moodLog webhook secret at rest with AES-256-GCM (V3 audit STILL-V2-C-2)

The mood_log_webhook_secret column previously stored a plaintext secret
that the webhook handler compared timing-safe against every enabled
user. A DB dump or backup snapshot leaked active webhook credentials.

New contract — no schema migration required:
- Writes go through encryptMoodLogSecret() (AES-256-GCM via existing
  crypto helper, ENCRYPTION_KEY).
- Reads go through readMoodLogSecret() which transparently decrypts
  envelopes and tolerates legacy plaintext rows during the transition
  (graceful upgrade path — no breaking change for self-hosted users).
- Webhook lookup decrypts each candidate before timing-safe compare.
- Status endpoint decrypts for the user's settings page.

One-shot startup migration in reminder-worker rotates any leftover
plaintext rows on next worker boot. Idempotent; encrypted rows are
skipped.

Tests: 350/350 pass (was 345, +5 new — round-trip, legacy passthrough,
isLegacyPlaintext semantics, rotateLegacy idempotence).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: sync README + AGENTS + CLAUDE + CHANGELOG + OpenAPI for v1.3.3 (V3 audit doc-drift)

V3 audit Doku Specialist findings — drift between code and meta-docs.

- README §Key Features now mentions pulse oximetry (SpO₂) + Withings
  ScanWatch sync; Tech Stack model count corrected 23 → 25.
- AGENTS.md status promoted v1.3.2 → v1.3.3; vitest config corrected
  `vitest.config.ts` → `vitest.config.mts`; migration range corrected
  `0001–0022` → `0001–0024`; model count + list updated to 25 (adds
  Device, IdempotencyKey).
- CLAUDE.md model count updated (2 occurrences).
- CHANGELOG.md v1.3.3 expanded with Security + Internal sections that
  were missing the audit-fix-marathon work (Bearer-scope, account-delete
  cascade, moodLog encryption, CSP tightening, Web-Push SSRF, IP-geo
  HTTPS, AI-test leak fix, import rate-limit, trusted-proxy XFF,
  audit-log retention, idempotency cachable filter, Bearer mock
  tightening, enum-drift cousins, PDF text-content tests).
- OpenAPI Bearer description corrected `SHA-256 hashes` →
  `HMAC-SHA-256 hashes (keyed with API_TOKEN_HMAC_KEY)` so the docs
  match the actual server-side implementation. Wildcard scope contract
  documented.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(audit-review): close 4 HIGH + 2 MEDIUM findings from multi-agent review pass

Multi-agent review of the audit-fix branch surfaced four HIGH issues
and a couple of MEDIUM hardenings. All addressed before merge.

HIGH-1 — OpenAPI MetricKind enum drift (iOS strict-decode crash risk).
The dashboard-summary route now emits totalBodyWater/boneMass/
oxygenSaturation MetricCards, but the spec still listed only the
original 7 kinds. Generated iOS Codable would have crashed the dash
the moment a user logged SpO2. MetricKind extended with the 3 new
kinds + comment documenting unknown-value defensive decoding.

HIGH-2 — kindEnum naming consistency on /api/measurements/series.
The series adapter shipped "oxygen" while the dashboard summary used
"oxygenSaturation"; both routes now use "oxygenSaturation" so iOS
decoders can share a single MetricKind type across endpoints.

HIGH-3 — /api/ai/test no-leak guard had no test. Added 4 tests that
mock provider errors with sk-leaked-key + provider URLs in the err
fields and assert the response body never echoes them, plus the
3-status-categorised generic messages (401/429/5xx/other).

HIGH-4 — /api/import 5/hour rate-limit had no test. Added 2 tests that
mock checkRateLimit allowed/disallowed and assert the 429 path uses
the right key shape (`import:<userId>`, 5, 1h) and that the within-
quota path proceeds.

MEDIUM-3 — getClientIp under-length XFF chain regression. With
TRUST_PROXY_HOPS=2 and a chain of length 1, the previous clamp
returned the leftmost (attacker-controlled) entry, silently
re-introducing the rotation attack the helper was meant to close.
Now: when chain is shorter than configured hops, refuse XFF entirely
(fall back to x-real-ip / null). Added regression test.

LOW-1 — TRUST_PROXY_HOPS=garbage now throws at boot instead of
silently degrading to hops=0. Added regression test.

Tests: 358/358 pass (was 350, +8 new). Typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 01:05:56 +02:00
Marc-André BombeckandClaude Opus 4.7 c8387d4f2b chore(db): migration 0023 — idempotency_keys, devices, healthkit user columns
Purely additive migration:
  • users.display_name, users.healthkit_config_json,
    users.healthkit_last_synced_at — all nullable, no default backfill needed.
  • New table idempotency_keys for the per-user POST-replay cache,
    unique on (user_id, key, method, path), plus expires_at index for
    the future cleanup sweep.
  • New table devices for native push-token registration. Token has a
    unique index; re-registering moves ownership.

No existing column was altered; no data backfill; deploy is safe under
concurrent traffic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:52:57 +02:00
Marc-André Bombeck 11ed6faedd feat(measurements): add total body water and bone mass types (#89)
- Adds TOTAL_BODY_WATER and BONE_MASS to MeasurementType enum
- Migration 0022 (purely additive ALTER TYPE ... ADD VALUE)
- Both stored canonically in kilograms (matches Withings hydration/bone mass measures and Health Connect TotalBodyWaterRecord/BoneMassRecord)
- Validation, formatters, i18n keys (en + de) for measurement form and doctor-report PDF
- Dashboard widgets registered (default-invisible, opt-in via settings)
- Withings client maps measure types 77 (hydration) and 88 (bone mass)
- Doctor report PDF includes both new types in vital-signs table when data exists
2026-04-27 00:28:28 +02:00