Commit Graph
57 Commits
Author SHA1 Message Date
Marc-André Bombeck 330be965ac feat(coach): default analysis window as user preference + per-conversation override
`coachPrefs.defaultWindow` joins the existing prefs blob (tone,
verbosity, excludeMetrics, showEvidenceByDefault). The picker lives in
the Coach settings sheet; the drawer header carries a small pill that
overrides the saved default for the current chat only. Closing the
drawer drops the override so the next session starts on the saved
preference. Backwards-compatible: legacy rows missing the key resolve
to "allTime", matching the v1.4.24 behaviour. The chat route folds the
saved window into snapshot scope when the client doesn't supply one.
2026-05-14 13:28:10 +02:00
Marc-André Bombeck 35f068ac26 feat(analytics): berlinDayKey callers honour user displayTimezone
The four routes that bucket by Berlin calendar day (dashboard summary,
medication intake, analytics correlations and sleep, insights targets)
now read userTz from the authenticated User and pass it through
userDayKey(). Streaks and consistency strips for a Pacific/Auckland user
land on their Auckland-day, not the legacy Berlin-day.

The aggregation logic is unchanged — only the bucket key function
swaps from berlinDayKey(date) to userDayKey(date, userTz). Existing
Berlin users see no behaviour change (Europe/Berlin is the schema
default).

Integration test extended with a two-case assertion: the userDayKey()
boundary case (13:00 UTC May 14 → 2026-05-15 Auckland vs 2026-05-14
Berlin) and the dashboard summary route returning a non-zero streak for
an Auckland account with three consecutive Auckland-day measurements.
2026-05-14 12:52:01 +02:00
Marc-André Bombeck 8e4a8080dc i18n(targets): add EN + DE strings for redesigned card primitives
Removes the duplicate `targets.mood` + `targets.moodStability` flat
strings that collided with the new `targets.mood.stability.{stable,
variable,highlyVariable}` object added in Phase 3. The flat versions
were unreferenced — the canonical mood label lookup goes through
`targets.label.MOOD_SCORE` / `targets.label.MOOD_STABILITY` — so
removing them is a no-op for the UI and unblocks the
i18n-locale-integrity duplicate-key test.

Also rolls in the prettier-normalised formatting on the targets
route + target-card files (no semantic change). All i18n keys
added across phases 3-8 are documented inline:

  • targets.summary.{weekTitle,weekTitleAll,streakHighlight}
  • targets.card.{lastMet,streak}
  • targets.consistency.{ariaLabel,daysInRange,daysLogged,
                          dotInRange,dotNearRange,dotOutOfRange,
                          dotNotLogged,insufficientData,relativeDay.*}
  • targets.relativeDay.{today,yesterday,daysAgo}
  • targets.coach.cta
  • targets.mood.stability.{stable,variable,highlyVariable}

Per-target Coach prefills (the W3e plan called for
targets.coach.prefill.{bp,weight,…}) are NOT added as i18n strings
— they're generated by `buildTargetPrompt()` in
src/lib/ai/coach/target-prompts.ts which interpolates live state
values. Keeping the templating in JS avoids 12 long-form bilingual
strings with i18n interpolation gymnastics and lets the prompt
include conditional fragments (streak, cadence) without exploding
the key count.

Verification:
  - pnpm typecheck: clean
  - pnpm test: 2325 / 2325 passing
  - pnpm lint: clean
  - i18n-locale-integrity: 9 / 9 passing (duplicate keys removed)
2026-05-14 12:43:02 +02:00
Marc-André Bombeck f3c9e2d7a6 feat(targets-api): add daysInRange7d + lastMetGoalAt + streakDays + pageSummary
The Zielwerte redesign needs three new per-target signals so the page
can answer "how consistent am I" without a chart:

- daysInRange7d / daysLogged7d — Berlin-tz day buckets over the last
  week. The client renders 7 dots, one per day, filled when the day's
  mean reading landed in the green band, hollow when logged but
  out-of-band, dimmed when the day had no reading.
- consistency7d — the day-by-day band classification array the strip
  reads directly (avoids re-deriving from sums client-side).
- daysInRange30d / daysLogged30d — same rollup over 30 days for the
  page summary line and the optional details popover.
- lastMetGoalAt — Berlin-tz date the user most recently hit the
  target band, or null when no in-range day in the last 30. Surfaces
  the maintainer-tester case the W5 reconcile flagged (today lands
  in-range by luck but the user has not actually hit goal in weeks).
- streakDays — consecutive in-range days ending today. Capped at 365.
- insufficientData — true when the target has < 3 readings in 30d
  OR < 1 day logged in 7d. The client hides the strip + percentage
  when set so we never surface a misleading "0 of 7" caption.

Schema also adds `pageSummary: { targetsMetThisWeek, totalTargets,
streakHighlight }` for the new account-level header. "Met this week"
threshold is daysInRange7d >= 4 — half of a logged week, lenient
enough to feel earnable. Streak highlight surfaces the longest
current streak ≥ 3 days.

Implementation collapses the v1.4.22 two-window (60-day) fetch back to
a single 30-day fetch — the now-removed delta-vs-last-month was the
only reason for the second window. The route is one Prisma query
shorter than v1.4.22. The shared `rollupConsistency` /
`rollupFromDayMap` helpers also pay off Senior-Dev M2 (the 7-pass
per-type computation collapses to one bucket-by-day pass per metric).

Per-day band rules:
- Continuous metrics (weight, pulse, sleep, body fat, glucose, steps,
  mood) — classify the day's mean against the same green band the
  RangeBar paints. Orange band reuses the pulse / glucose
  configurable orange when available; otherwise symmetric ±30%.
- BP / BP-in-target — sys+dia paired within 5 min, day is "in" when
  every pair clears the target, "near" when at least one does.
- MEDICATION_COMPLIANCE — day-level intake rollup. ≥ 99% taken = in,
  ≥ 50% = near, otherwise out. Days with zero scheduled doses
  return null so they don't break the streak.
2026-05-14 12:21:25 +02:00
Marc-André Bombeck 86a52dac33 refactor(targets): remove v1.4.22 per-card sparkline + delta caption
The v1.4.22 C1 inline sparkline + "Δ vs. last month" caption never
earned their place on /targets. The W5 reconcile filed four findings
against them (Design M2 — sparkline ignores min-points threshold so 3
points read as "lots of activity"; M7 — SVG aria-hidden hides the only
motion cue from screen readers; L3 — preserveAspectRatio="none"
distorts the y axis; Senior-Dev M2 — 7 sequential Array.filter passes
on the route). The replacement primitive (a 7-day consistency strip)
lands in the next phase; this commit just removes the dead weight.

Drops:
- Sparkline component, TargetItem.points30d, TargetItem.deltaVsLastMonth
- The 60-day fetch and the per-type sparklinePoints / deltaVsLastMonth
  helpers in the route handler
- targets.deltaVsLastMonth, targets.deltaVsLastMonthFlat,
  targets.deltaUnavailable i18n keys (EN + DE)
- targets-sparkline.test.tsx (the consistency-strip suite replaces it
  in phase 3)
2026-05-14 12:15:17 +02:00
Marc-André Bombeck 3e9881f3fd refactor(insights): remove duplicate InsightStatusCard mount — superseded by InsightAdvisorCard since v1.4.16
The /insights page rendered both <InsightAdvisorCard> (the polished
severity-ordered recommendations grid + per-rec rationale + confidence
meter + medical-citation footnotes shipped in v1.4.16 phase D
reconcile) AND <InsightStatusCard title={t("insights.generalStatusTitle")}>
(the v1.4.15 text-only summary). The page comment at the advisor
wiring acknowledged the status card "was intended to be replaced"
during D-reconcile but the status mount was never removed, so users
saw two cards saying the same thing — one labelled
"KI-Gesundheitsanalyse" and one labelled "Allgemeiner
Gesundheitszustand".

Removed:
  - The <section id="section-general"> block + its <InsightStatusCard>.
  - The `generalStatus` useQuery + the unused `GeneralStatusData`
    interface.
  - The `generalStatus?.updatedAt` entry from freshestUpdatedAt(); the
    other six per-section caches still feed the hero's "Generated …"
    caption.
  - The "section-general" entry from the sticky pill nav (it had
    nothing left to scroll to).

Kept:
  - The /api/insights/general-status route + the underlying
    generateGeneralStatusForUser lib — the reminder worker still
    pre-warms the daily general-status cache for users opted in to the
    briefing notification. Added a JSDoc @deprecated note on the route
    pointing new callers at <InsightAdvisorCard> /
    useInsightsAdvisorQuery().
  - The insights.generalStatusTitle + insights.generalStatusBadge.*
    translation keys — they're still referenced by the per-section
    badge labels (insights.generalStatusBadge.good / .watch /
    .critical map traffic-light states on every metric section, not
    just the dropped general one).
2026-05-14 11:50:49 +02:00
Marc-André Bombeck ca03c6099f chore(release): v1.4.24
Security + accessibility hardening release. Closes the highest-priority
findings from a focused security audit of the v1.4.23 release:

- Bearer auth fail-closed when a route declares no scope (api-handler)
- Public /api/send Umami proxy now rate-limits 120 req/min/IP
- Proxy nonce generation switched to btoa(...) for Edge portability
- Session-expiry delete race uses deleteMany + catch (no more 500s)
- POST /api/import returns the standard apiError envelope on 422
- Runtime Docker image pins Prisma 7.8 + worker deps to minors
- Notifications page renders a real load-error alert
- Accessibility: switch aria-labels, htmlFor pairing, mobile admin
  Overview link, deep-link anchors on notification settings
- Chart overlays: per-chart comparisonBaseline pref, BMI/bodyFat/
  sleep/steps overlay keys, TrendCard layout tolerates long labels

Verification: pnpm typecheck && pnpm lint && pnpm test (2244/2244)
&& pnpm format:check all green.
2026-05-12 22:33:35 +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 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 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 58ae9bca42 feat(coach): partial-malformed observability on sentinel parser
The W3-of-v1.4.22 sentinel parser silently dropped malformed rows and
fired `coach.keyvalues.parse_failed` only when the entire block was
unusable. Mixed-format drift (one row breaks, the other six parse)
landed without any signal. Surface it.

- `SentinelParseResult.malformedEntries[]` records each skipped row
  with a typed reason (`missing_colon` / `value_overflow` /
  `label_overflow` / `no_END_marker` / `byte_overflow` /
  `schema_invalid`). Block-level failures (truncation, missing close
  marker) land here as synthetic entries so the wide-event always
  carries a typed cause.
- Per-row label and value caps moved out of the Zod schema into the
  parser so the row-level reason can be `label_overflow` /
  `value_overflow` rather than the catch-all `schema_invalid`.
- Chat route splits the annotation: `coach.keyvalues.parse_partial`
  when at least one row parsed AND at least one row failed, the
  existing `coach.keyvalues.parse_failed` stays for full-block
  failures. Both annotations carry the per-line reason array.
- Backwards-compatible `parseKeyValueLine` wrapper preserved.

Tests +5: label_overflow, value_overflow, byte_overflow,
mixed-malformed entry assertions, and a tryParseKeyValueLine
direct-API surface check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:20:01 +02:00
Marc-André BombeckandClaude Opus 4.7 bbdc3fbd00 style: prettier on W5 reconcile touchpoints
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:30:53 +02:00
Marc-André BombeckandClaude Opus 4.7 6c0c69cbce fix: MED-cluster — SameSite=Strict, exact-match PUBLIC_PATHS, Berlin tz, streaming race
Six MEDs grouped per the W5 reconcile brief:

- Sec-MED-1: `hl_onboarding` cookie now `SameSite=Strict` (no
  cross-site flow depends on it; sibling `healthlog_session` stays
  `Lax` for the Withings OAuth callback).

- Sec-MED-2: `/onboarding` PUBLIC_PATHS match tightened from loose
  `startsWith("/onboarding")` to exact path + subroute guard
  (`pathname === "/onboarding" || pathname.startsWith("/onboarding/")`).
  A future `/onboarding-export` route can no longer inherit the
  silent-pass-through. Same guard mirrored on the redirect short-
  circuit. Two new proxy tests pin the contract.

- Code-MED-3: lift `berlinDayKey()` from
  `src/app/api/analytics/route.ts` to a shared
  `src/lib/analytics/berlin-day.ts` and route the targets
  sparkline-bucketing through it. The targets route used to bucket
  by UTC `YYYY-MM-DD` so a 23:30-Berlin reading on Tuesday landed
  in Wednesday's bucket. Four new unit tests pin DST + UTC-midnight
  edge cases.

- Code-MED (streaming race): on slow connections SSE `done` fires
  before the invalidate-refetch resolves, producing a 200-500ms
  duplicate-bubble render. Add a 150ms grace window on the
  message-thread that suppresses the persisted twin while the
  streaming bubble is still in flight.

- Code-MED-2: drop dead i18n keys `insights.coach.settings` and
  `insights.coach.settingsTooltip` (W3 removed the cog; the keys
  had zero call sites and would carry confusing legacy if v1.4.23's
  prompt-tuning panel picks a different key shape).

- Code-LOW-5: centralise `["user", "dashboardWidgets"]` query key
  in `queryKeys.dashboardWidgets()` and migrate the three call
  sites (dashboard, insights, settings/dashboard-layout-section).
  Same query-key-collision discipline as `queryKeys.analytics()`.

Closes Sec-MED-1, Sec-MED-2, Code-MED-2, Code-MED-3, Code-MED-3
(streaming race), Code-LOW-5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:23:04 +02:00
Marc-André BombeckandClaude Opus 4.7 2075d392b1 refactor(api): extract createSseStream helper from chat route
The Coach chat route had three identical `new ReadableStream({
start(controller) { try { ... } finally { controller.close(); } } })`
blocks (refusal / provider-error / happy path), differing only in
which frames they enqueued. Extract the boilerplate into
`src/lib/sse/create-stream.ts` so the v1.5 iOS streaming endpoints
inherit the same try/finally close semantics without copy-paste.

The helper accepts sync or async emit callbacks; the controller is
always closed in `finally` so a thrown emit never hangs the response.

Three unit tests pin the sync, async, and throw paths.

Closes Sr-H2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:12:22 +02:00
Marc-André BombeckandClaude Opus 4.7 75ccc01984 fix(coach): graceful fallback when sentinel-only output strips empty
When the model emits only a `---KEYVALUES---` block with no leading
prose (or a malformed block missing the closing marker), the
post-strip prose is empty. The previous fallback chained back to
`rawReply`, surfacing raw sentinel markers to the user. Replace with
a structured `coach.provider.empty` error frame so the UI renders
the localised provider-error copy instead of leaking the markers.

Add a regression integration test pinning that the streamed body
never contains `---KEYVALUES---` for a sentinel-only reply, and that
no assistant message is persisted in this branch.

Closes Code-H1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:05:14 +02:00
Marc-André BombeckandClaude Opus 4.7 363e73cbf2 chore: voice + filename hygiene + wave-4 state wrap
Maintainer-voice sweep across `src/`. ~190 references to the
maintainer by first name in code comments and JSDoc neutralised to
"the maintainer", "the live tenant", "users", or "the user" depending
on context. Three sed passes covering ~50 distinct patterns
(`Marc reported`, `Marc wants`, `Marc kept`, `Marc rolled back`, etc.).
Test fixtures (`displayName: "Marc B."`, `username: "marc"`,
`<HeroStrip userName="Marc">`) kept as opaque test data per the brief.

CHANGELOG normalisation. The v1.4.14 + v1.4.15 entries paired a
German bullet with its English translation by historical design — the
in-app copy is English-default and the user docs are English, so the
mixed-language style was a holdover from the early-marathon notes.
A one-off node script extracted the `_..._` English paragraph from
each bilingual bullet and rewrote the entry as that text. ~44 bullets
reduced; one Docs bullet that lost a leading word in the regex
fixed by hand.

CLAUDE.md → CONTRIBUTING-AI.md rename so the filename is no longer
AI-vendor-specific. `CONTRIBUTING.md` reference updated. `AGENTS.md`
stays in place for multi-agent compatibility (the conventions doc is
shared with Codex / Cursor / etc.).

Wave-4 state wrap. STATE.md now records C1–C5 + D + D2 as complete;
v1421-backlog.md grew a "Closed in v1.4.22" header listing the 20
items the marathon landed; `.planning/phase-W4-report.md` documents
the per-commit decisions, test deltas, and the 23 items deferred to
v1.4.23 with reasoning.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:46:32 +02:00
Marc-André BombeckandClaude Opus 4.7 a22f77881e feat(targets): 30-day sparkline + Δ-vs-last-month caption per card
The Zielwerte page read as a static reference cheat-sheet — every card
surfaced a current value + range bar but no recent trajectory. Each
target now grows a dependency-free 30-day sparkline beneath the range
bar plus a localised "Δ −2.3 kg vs. last month" caption so the page
reads as a living health log rather than a guideline lookup.

The API computes both fields from a single 60-day query (one round-trip,
not two): `points30d` is the daily-mean series for the last 30 days,
`deltaVsLastMonth` is the signed difference between the current and
prior 30-day averages. Both are null when either window has fewer than
3 readings so cold-start accounts don't see a misleading flat trace
or a "± 0" comparison that's really "no comparison".

BMI piggybacks on the weight series (divided by height²) so the BMI
card's sparkline matches its range bar's y-axis. Derived metrics
(BP-in-target rate, mood stability, medication compliance,
blood-glucose-by-context) keep their previous chrome — the rich
status surface they already render is the right answer there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:21:48 +02:00
Marc-André BombeckandClaude Opus 4.7 7b8efaae14 feat(coach): parse evidence-block sentinel out of streamed prose
Add a text-marker sentinel parser (`---KEYVALUES---` … `---END---`)
that strips the Coach's evidence block out of the prose before it
streams to the client, validates each line against a Zod schema, and
attaches the parsed entries to `provenance.keyValues`. The persisted
envelope round-trips so the disclosure renders on conversation
reload. Hard-capped at 8 lines and 1 KB to guard against
prompt-injection padding; malformed blocks log a wide-event and pass
the prose through unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:07:32 +02:00
Marc-André BombeckandClaude Opus 4.7 52dd9ff9c3 docs(planning): record v1.4.20.1 hotfix wave
New `.planning/phase-v1421-hotfix-report.md` summarises the six
fixes (Daily Briefing regenerate · Coach day-level snapshot · Coach
prompt update · streaming-bubble dedup · drawer header · sources rail
scope picker), the test-count delta (2026 → 2036 unit), the
token-budget impact of the day-level snapshot (~190 → ~3000 tokens
per full-scope turn), and a recommendation to ship as a v1.4.20.1
hotfix.

STATE.md grows a Phase F section + status block tracking the six
commits on develop. Includes prettier polish across the touched
coach surfaces (sources-rail, drawer, message-thread, snapshot,
chat route).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:31:20 +02:00
Marc-André BombeckandClaude Opus 4.7 ed61b17f57 feat(coach): snapshot includes day-level readings with weekday labels
The Coach snapshot used to ship only aggregated statistics (mean,
slope, SD, range, count) per metric. A turn that asked
"why was BP higher on Monday?" had no day-level rows to ground in
and the model fell back to apologetic aggregate-only phrasing
("from the snapshot I cannot isolate the specific Monday").

Extend buildCoachSnapshot to fold in:
  - timeline.recent: one row per UTC day for the last 14 days, each
    tagged with its weekday so the model can answer day or weekday
    questions directly. BP rows pair systolic + diastolic; days with
    only one side measured are dropped to avoid fabricating a
    complement.
  - timeline.weekly: ISO-week buckets covering days 15..N where N is
    the configured window. Keeps the prompt budget tight on 30/90-day
    windows while still letting the Coach cite older weeks.
  - per-day medication adherence rows from MedicationIntakeEvent so
    the Coach can answer "did I miss my dose Tuesday?" without
    inventing the schedule.

Also add the optional `scope` field to coachChatRequestSchema (sources
+ window) so the upcoming sources-rail picker can narrow the snapshot
without breaking older clients — missing fields fall through to
all-source last30days.

A typical 30-day, 5-source snapshot lands around 2.4-2.8 KB JSON
(≈700-900 prompt tokens) — comfortably inside the 25k/day Coach token
budget.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:21:48 +02:00
Marc-André BombeckandClaude Opus 4.7 7921ffc8e6 fix(insights): wire generate route to strict prompt so dailyBriefing emits
The /api/insights/generate route was still calling the legacy
getInsightsSystemPrompt that returns the v1.4.5 {changed, stable,
drivers, …} shape and never asks the model for a dailyBriefing block.
GROUND RULE 8 (emit dailyBriefing when the snapshot has signal) lives
on getStrictInsightsSystemPrompt in src/lib/ai/prompts/insight-
generator.ts; the route never adopted it. Re-runs from the hero strip
therefore stored a payload with no briefing, the use-insights-advisor
lift saw null, and the briefing card painted its empty state forever.

Switch the route to getStrictInsightsSystemPrompt(locale). Existing
strict-shape tolerance is unchanged: insightResultSchema.safeParse
falls through to the raw parsed body on shape mismatch, and
aiInsightResponseSchema's passthrough() keeps any custom keys for
forward compat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:20:50 +02:00
Marc-André BombeckandClaude Opus 4.7 e632e26854 fix(coach): repair SSE idempotency and error-frame handling
- Drop the withIdempotency wrap on POST /api/insights/chat. The wrapper
  caches response bodies via cloned.text() then replays through
  NextResponse.json(JSON.parse(...)) — turning the SSE wire format into
  a null body on retry. The PWA never sends Idempotency-Key so the bug
  is hidden today; the iOS client would walk straight into it. Dedup
  still holds via the conversationId existence check + 20-turn cap.

- Read the SSE response body even on !response.ok when the content type
  is text/event-stream so the structured error frame the route emits
  surfaces its errorCode to the bubble. Without this the stream was
  read only on 2xx and the rich error channel was dead code.

- Stabilise the useSendCoachMessage opts reference via useRef so an
  inline-literal { onDone } from the drawer no longer re-memoises send
  on every render.

- Drop the unused snapshot parameter from streamProviderError (the
  ReturnType<typeof Object.assign> alias widened to any). Switch the
  error response to status 200 so the streaming client reads the SSE
  body and parses the error frame instead of branching on HTTP status.

Fixes Code-HIGH-1, Code-HIGH-2, Code-HIGH-3, Code-HIGH-4, Security-M-1,
Senior-HIGH-1, Simplify-S-01.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:01:40 +02:00
Marc-André BombeckandClaude Opus 4.7 3168ba8e0a feat(coach): GET list, GET single, DELETE conversation endpoints
GET /api/insights/chat returns the caller's conversations newest-first
with cursor pagination (default limit 20, hard cap 50). Each row
carries id, title, timestamps, and a message count so the rail can
render without paying the per-message decrypt cost.

GET /api/insights/chat/[id] fetches one conversation with every
message decrypted in memory before serialisation. Provenance metadata
round-trips alongside the assistant turns.

DELETE /api/insights/chat/[id] hard-deletes the thread; the FK chain
cascades the message rows.

Cross-user lookups map to 404, never 403, so the existence channel
never leaks across accounts.

6 integration tests cover the list ordering, owner-only filter,
decryption round-trip, and the 404 boundary on both verbs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:11:12 +02:00
Marc-André BombeckandClaude Opus 4.7 16f3ece90f feat(coach): add POST /api/insights/chat SSE streaming endpoint
The route gates on requireAuth (cookie or bearer), enforces the daily
token budget, runs the prompt-injection / off-topic refusal scanner,
and only then walks the user's provider chain.

The conversation is created via withIdempotency() when no
conversationId is supplied, so a retry under the same Idempotency-Key
replays the original assistant reply rather than spinning up a second
thread. Existing-thread turns skip the wrap.

Replies stream as SSE: one "token" frame per word-sized chunk, then a
"provenance" frame describing the windows + metrics the assistant
could see, then a "done" frame carrying the persisted message id.
Provider failure cascades to a single "error" frame and the assistant
message is never persisted. Conversation history is capped at 20 turns
in the prompt window via a synthetic summary placeholder so cost stays
bounded.

`buildCoachSnapshot()` reuses the analytics features pipeline so the
Coach narrates the same numbers the dashboard tiles render.
`getCoachSystemPrompt()` extends the strict-insight ground rules with
conversational framing while keeping the zero-hallucination rule.

5 integration tests pin the round-trip, refusal short-circuit, budget
429, foreign-conversation 404, and validation 422 paths against the
postgres testcontainer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:07:47 +02:00
Marc-André BombeckandClaude Opus 4.7 3048dd6637 style(prettier): sweep formatting across the tree
Pre-existing format drift accumulated across v1.4.x; running
prettier brings the whole tree back to baseline so future diffs
read clean. No behaviour change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 11:05:40 +02:00
Marc-André BombeckandClaude Opus 4.7 5f7b9d8652 fix(api): provider-chain GET preserves disabled entries (H2 code-review)
The GET response used to surface the resolveProviderChain()-filtered
list, which DROPS disabled entries upstream. The Settings UI then
seeded `enabled: true` for every returned row, so disabling an entry
made it disappear on next refetch and lose its toggle position —
the toggle was functionally a "remove from chain" button.

Changed GET to read the *persisted* chain via parseProviderChain() so
disabled rows still round-trip with their flag. The active-provider
summary still uses resolveProviderChain so the displayed "Active: …"
line continues to reflect the actual runner ordering (enabled-only).
The Settings UI seeds entries with the wire-supplied `enabled` value
instead of always-true, so re-enabling becomes possible without a
page reload.

4 unit tests pin the GET shape (persisted-chain ordering, disabled
entries surfaced, cached-active overlay, default-chain fallback).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 03:14:29 +02:00
Marc-André BombeckandClaude Opus 4.7 6863ecb87d fix(api): rate-limit feedback endpoint + drop overly broad cache invalidation (H5/H6 code-review)
H5 — `POST /api/insights/feedback` had no rate limit. The unique
index `(userId, recommendationId, recommendationText)` blocks exact
replays but a malicious client could distort the daily aggregator's
bucket slice by varying recommendationId/text per request. Added
`checkRateLimit("insights-feedback:<user.id>", 60, 60 * 60 * 1000)`.
60/h is generous given a comprehensive insight rarely has >10 recs.

H6 — `<RecommendationFeedback>` invalidated the entire `["insights"]`
prefix on every thumbs click, which matches all 8 insights queries
(comprehensive + 7 per-status). None of them depend on the feedback
row. The 24h server cache absorbs the actual LLM round-trip but the
network thrash + stale-cache refetch was waste. Dropped the
invalidation; user-visible verdict state lives in localStorage +
local React state. v1.4.17's prompt-version ratchet will scope its
own invalidation when feedback feeds the next generation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 03:11:34 +02:00
Marc-André BombeckandClaude Opus 4.7 f9f99ea24a feat(insights): AI narrates comparison when toggle is active
Extend `buildUserPrompt()` to accept an optional `ComparisonSnapshot`
that, when supplied, appends a SYSTEM CONTEXT block listing each
metric's current 30d average, baseline 30d average (from 30 / 365
days back), absolute delta, and percentage delta. The strict system
prompt gains a sixth ground rule that instructs the model to narrate
the most clinically-significant non-null delta in the summary's first
sentence and forbids inventing comparison numbers.

The route reads the user's persisted `dashboardWidgetsJson.compari-
sonBaseline` and builds the snapshot from a per-metric `summarize()`
sweep so the numbers the LLM sees match what the dashboard tile
renders — single source of truth for the comparison delta. Comparison
is default-on whenever the toggle is on per research §7 Q4: there's
no secondary "include in AI" preference, the pulldown is the single
affordance.

PROMPT_VERSION bumped 4.16.0 → 4.16.1 so the Wide-Event annotations
attribute responses to the new prompt revision.

Eight new unit tests pin: empty-comparison block render, EN/DE
locale switch, lastMonth vs lastYear language, the
"summary first sentence" instruction, and the anti-hallucination
"do NOT invent" guard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 02:36:35 +02:00
Marc-André BombeckandClaude Opus 4.7 8255e3e49a feat(api): /api/insights/feedback persists thumbs with provider attribution
v1.4.16 phase B5e: ship the POST endpoint that backs the per-rec
thumbs-up / thumbs-down UI. Body is the four rec-attributes the
client just rendered (id, text-snapshot, severity, metric-source +
time-range) plus the boolean verdict; server-fills providerType +
promptVersion via `resolveFeedbackAttribution()` so the client cannot
poison the daily aggregator's slice by claiming a thumbs-down was
attributed to a rival provider.

Idempotent via the existing `withIdempotency()` wrapper — a retry
with the same Idempotency-Key replays the cached response with
`X-Idempotent-Replay: true`. The dedup unique key on the model
(userId, recommendationId, recommendationText) returns 409 when the
same rec-text is re-rated — that's by design, the aggregator wants
per-text signal not per-id signal so a regeneration that rewrites
the same id with new text re-rates cleanly.

The attribution helper prefers `chainProviderType` over `providerType`
in the latest insights.generate audit row so when the B5b multi-
provider chain falls through to a secondary, the feedback row
reflects the working provider not the configured one. Falls back to
"unknown" + the current PROMPT_VERSION constant when no audit row
exists yet — the aggregator buckets that separately.

Audit row: action `insights.recommendation.feedback`, details carry
the feedback id, severity, helpful, providerType, promptVersion.

Tests:
  - 7 unit tests for the Zod request schema (valid path, severity
    poisoning, time-range poisoning, empty fields, stringified bool,
    extra-field stripping)
  - 5 unit tests for `pickProviderType()` priority + fallback
  - 4 integration tests: 201 happy path with provider attribution,
    409 dedup, 422 validation, fallback to unknown when no audit row

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 01:30:06 +02:00
Marc-André BombeckandClaude Opus 4.7 c8b30c112d feat(ai): legacy insight payload detection with regenerate CTA
Cached insight blobs persisted before B5c shipped don't carry the
new `rationale` field — the strict parser would reject them, and
auto-regenerating on every cache-hit would burn rate-limit tokens
silently. Instead the route now detects the legacy shape and
surfaces a `legacyPayload: true` flag in the response so the UI
can render a one-time "regenerate for new explainability features"
CTA. User-initiated regeneration stays the trigger.

`isLegacyInsightPayload(payload)` lives in `src/lib/ai/legacy-payload.ts`
and is intentionally lenient: any rec missing rationale (or the
v1.4.14/v1.4.15 string-only InsightResult shape) flags. Empty
recommendations[] is NOT legacy — it's a legitimate refusal payload.

The annotate() Wide-Event also carries `legacyPayload` on cached
hits so the admin AI quality dashboard can chart the migration
ratio over time. v1.4.17 can flip auto-eviction on once the
ratio drops near zero.

7 new tests in `legacy-payload-detection.test.ts` pin the contract.
Existing 223 AI tests still green; typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 01:17:15 +02:00
Marc-André BombeckandClaude Opus 4.7 3e037b4d42 feat(api): PUT /api/insights/provider-chain persists user fallback chain
The B5b read-only endpoint now has a write counterpart so the new
Settings → AI dropdown UX can save reorder + enable/disable changes.
Server-side priority is recomputed from insertion order (so the UI's
visual contract — "first row = priority 1" — cannot drift from what
gets persisted), unknown provider types and duplicates 422, and an
empty chain is rejected outright. parseProviderChain still falls
back to the default chain when the persisted JSON is malformed,
so a stale-tab resubmit cannot poison generation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 00:58:05 +02:00
Marc-André BombeckandClaude Opus 4.7 613d6619df feat(settings): minimal display of active AI provider + configured chain
The AI section now renders a small read-only summary of the user's
fallback chain: "Active provider: ChatGPT (Codex)" plus "Configured
chain: ChatGPT (Codex), OpenAI (your key), Admin OpenAI". When the
in-process cache reroutes to a fallback (the primary 401'd in the
last hour), the line gains "(cached: OpenAI)" so the user can see the
runner has shifted without digging through the wide-event log.

The full management UX (drag-to-reorder, on/off toggles, add/remove
provider entries) is owned by phase B2 — this surface is deliberately
tight so the two phases don't collide. The card hides itself when no
provider is configured at all so accounts that haven't set up AI
don't see an empty header. EN + DE i18n keys added under
settings.ai.providerChain.*; provider-type labels are localised so
"Local model" reads "Lokales Modell" in German.

The new GET /api/insights/provider-chain endpoint is the data source.
Returns the resolved chain in priority order plus the cached active
provider (when set), wrapped in the standard apiSuccess envelope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 00:34:39 +02:00
Marc-André BombeckandClaude Opus 4.7 901f44e6dc feat(charts): mood chart polish with emoji glyphs at data points
Same Apple-Health visual leap as the other charts, plus the mood-
specific affordance: an emoji glyph at every daily score so a week's
worth of mood reads at a glance without consulting the legend.

What changed:
- ComposedChart replaces LineChart so a gradient-filled Area paints
  under the score line. The mood gradient uses the lavender token
  (--dracula-lavender / #d6acff) which matches the existing line
  stroke. Sibling 0×0 SVG mirrors the gradient for SSR-discoverability.
- Personal baseline reference line: median mood over the visible
  window (≥5 points required). Faint dashed line labelled "Your
  normal" / "Dein Mittel".
- Rich tooltip via RichChartTooltip — date label, score value with
  text-label ("4.0 (Good)"), delta-vs-baseline sub-line.
- Emoji dot per score: 1 → 😖, 2 → 🙁, 3 → 😐, 4 → 🙂, 5 → 😄.
  The Recharts `dot` prop accepts a function returning an SVG node;
  we render <text> centred over (cx, cy) so the glyph sits on the
  line. data-slot="mood-emoji-glyph" + data-score for visual probes.
- 600ms ease-out animation on first render, suppressed when
  prefers-reduced-motion is set.
- Sparse-data placeholder via ChartEmptyState for <3 data points.

1 new test pins the gradient-defs SSR contract; existing
mood-chart-aggregation tests still green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 00:30:40 +02:00
Marc-André BombeckandClaude Opus 4.7 9b01c86e30 feat(charts): medication chart matches other charts (7d trend + indicator + target-range)
Marc: "Bei Medikamenten zum Beispiel wird jetzt nicht 7-Tage-Schnitt,
Trend und Zielbereiche angezeigt. Das möchte ich natürlich für alle
Charts."

The medication-compliance chart shipped in v1.4.15 A4 (replacing the
static placeholder) was missing the trend chip + target-range
visualization the BP/weight/mood charts already paint. Bringing it to
parity:

- 7-day trend chip in the header: signed delta in percentage points
  with a metric-aware sentiment colour (rising compliance → green,
  falling → orange) and an arrow icon. Mirrors the bg-muted/40 chip
  pattern HealthChart uses for its weekly/monthly bucket label.
- New `computeMedicationTrend7d()` helper computes the delta as the
  mean of the most-recent 7 daily rates minus the mean of the prior 7
  (capped at a 14-day window). Mean-difference rather than slope so a
  single outlier day doesn't wag the indicator.
- Target-range visualization: added a 100 % goal ReferenceLine (green,
  solid-ish) ALONGSIDE the existing 80 % minimum-acceptable threshold
  (yellow, paler dash). Two lines together visualise the target range
  the way HealthChart's targetZone band does for BP/weight tiles.

Tests:
- 6 new unit tests pin computeMedicationTrend7d() including direction,
  delta, the 14-day cap, short windows, stable noise rejection.
- The existing chart-render snapshot is extended with an explicit
  assertion that the trend chip is suppressed in the empty state
  (so the empty-state header doesn't paint a misleading "0 pp"
  indicator).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:32:01 +02:00
Marc-André BombeckandClaude Opus 4.7 577d8dd29f fix(insights): BD-Zielbereich computes correctly for real measurement data
The BD-Zielbereich tile rendered 0% for users whose readings sat below
the ESH 2023 narrow goal band (Sys 120-129 / Dia 70-79 for under-65).
Marc's actual production data has many readings like 117/79 — textbook
normotensive, well-controlled — that v1.4.15 A4 still counted as OUT
of target because sys < sysLow (120). 5 of 10 of his last-30-day pairs
should be in-target by clinical interpretation; v1.4.15 reported 0.

Root cause: "in target" was defined as the narrow goal band, which is
the ESH treatment goal for hypertensive patients ON therapy — NOT the
desirable range for a normotensive HealthLog user. Users (and the
dashboard label) intuitively mean "BP is well-controlled = at or below
the ceiling, not implausibly hypotensive".

Fix: change semantics from `sysLow <= sys <= sysHigh && diaLow <= dia <=
diaHigh` to `90 <= sys <= sysHigh && 50 <= dia <= diaHigh`. The 90/50
floors guard against counting symptomatic hypotension as "good control".
Centralised the check in `isBpReadingInTarget()` so the 6 historical
call sites (analytics route, blood-pressure-status, features,
general-status, insights/cards, insights/comprehensive, insights/targets)
share one definition — pre-fix they had 5 independent inline copies of
the narrow-band formula, easy to drift on a future refactor.

Tests:
- 6 new unit tests pin the new ceiling+floor semantics including Marc's
  exact production fixture (10 paired readings -> 50% under v1.4.16,
  was 0% under v1.4.15).
- New `tests/integration/bp-in-target.test.ts` seeds Postgres
  testcontainer with Marc's reading shape + null-tolerance + hypotension
  edge cases.

Cache invalidation already correct: `measurementDependentKeys` includes
`analytics()` so adding a BP measurement invalidates the tile's
TanStack Query cache.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:28:54 +02:00
Marc-André BombeckandClaude Opus 4.7 6b88e56aeb chore(format): prettier sweep over phase-2 touched files + stale planning docs
Picks up the prettier line-wraps the v1.5 phase-1 planning docs and
codex-protocol-spec.md missed, and brings the redact.ts + insights
generate route test into the project's standard form. No behaviour
change — pnpm format:check now passes clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:06:52 +02:00
Marc-André BombeckandClaude Opus 4.7 54038216e4 fix(insights): map AI provider errors to 422/503 instead of 500
v1.4.6 T5 fixed only the parse-error branch (502 → 422 to keep
Cloudflare's HTML-error rewrite from breaking `await res.json()` on
the dashboard). Provider errors — `OpenAI request failed (401)` for
an invalid admin key, or transient `OpenAI request failed (5xx)` —
still propagated to the apiHandler's generic 500. Cloudflare rewrites
the 5xx to its own HTML page, so the React Query mutation crashed
with "Unexpected token '<'" instead of surfacing a readable message.

Wraps `provider.generateCompletion(...)` in try/catch and mirrors the
v1.4.5 ai/test categorisation:

  - 401 / 403 from the provider → 422 with "AI provider rejected the
    request — check your API key in Settings > AI"
  - 5xx from the provider → 503 with "AI provider temporarily
    unavailable, try again in a moment"
  - 429 from the provider → 429 (rate-limit passthrough)
  - any other status → 422 generic provider-connection failure

Full error details (message, httpStatus, bodyExcerpt, providerType)
land in the Wide Event via `annotate()` so the operator can debug
without leaking provider URLs / partial keys to the client.

Adds unit tests covering 401/403/429/500/503/unknown mapping.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:04:15 +02:00
Marc-André BombeckandClaude Opus 4.7 02b99553b9 chore(style): repo-wide prettier sweep before v1.4.6 tag
Long-overdue prettier sweep; prettier had drifted vs. checked-in code
across ~130 files (mostly long-argument line-break wrapping in TS and
table-padding in markdown). Pure formatting — no semantic changes —
and required to clear the v1.4.6 release gate's `pnpm format:check`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 02:27:17 +02:00
Marc-André BombeckandClaude Opus 4.7 4a159d2b6c fix(insights): cache hits no longer burn rate-limit tokens
P13. Move `checkRateLimit` below the 24h cache-return path so a request
that gets served from the cached `insightsCachedText` never consumes one
of the 2/hour LLM-generation tokens. A user reloading the dashboard a
few times no longer locks themselves out of a fresh refresh for an hour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 02:01:17 +02:00
Marc-André BombeckandClaude Opus 4.7 c8ee28d2b0 fix(insights): map LLM-parse failure to 422 instead of 502
Same Cloudflare-rewrites-5xx footgun the v1.4.5 ai/test fix solved. A
malformed JSON response from the upstream model returned HTTP 502, which
Cloudflare swapped for its own HTML error page; the client's
`await res.json()` then crashed with `Unexpected token '<', "<!DOCTYPE "`
and the user — already throttled by the 2/h rate limit — saw nothing
useful. 4xx passes through Cloudflare untouched, so the React Query
mutation can read the JSON envelope and show a real error.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:27:07 +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 643e6ec3a0 release: v1.4.2 — resurrect v1.4.1 work + bug-fix triple (#152)
* Revert "revert: roll source tree back to v1.4.0 to restore production"

This reverts commit e444a90b10.

* fix(v1.4.2): summaries.WEIGHT + medications array guards + healthcheck IPv4

Three production bugs surfaced after the v1.4.0 deploy stabilized:

1. `TypeError: undefined is not an object (evaluating 'E?.summaries.WEIGHT')`
   on /dashboard and /insights for users without weight readings —
   the optional chain stopped at `data` when it needed to also cover
   `data.summaries`. Fixed: `data?.summaries?.WEIGHT` (one level deeper).

2. `TypeError: y?.filter is not a function` on /medications — the
   medications query value was non-array (likely a stale
   service-worker response from an older API shape). Replaced with
   `Array.isArray(medications) ? medications : []` so the page is
   immune to any future shape drift.

3. Healthcheck-induced 503 outage (already in 1.4.1 — included here
   so 1.4.2 is the single coherent release). The docker-compose
   healthcheck used `localhost` which busybox-wget resolves to IPv6
   ::1; Next.js standalone listens on IPv4 0.0.0.0 only. Pinned
   to 127.0.0.1.

This release also includes everything from v1.4.1 (which never
published a clean GHCR tag): the per-section admin extraction, the
testcontainers integration suite, the Playwright + axe-core E2E
foundation, the moodLog SSRF fix, the central error-redaction in
WideEventBuilder + Glitchtip, the medical-citation drift fixes
(ESH 2023, Saint-Maurice attribution, steps source), and the docs
sync.

Co-Authored-By: Marc-André Bombeck <mbombeck@gmail.com>
2026-05-08 17:46:31 +02:00
Marc-André Bombeck e444a90b10 revert: roll source tree back to v1.4.0 to restore production
Production at healthlog.bombeck.io has been 503-ing since the v1.4.1
deploys started landing on apps-01 (Coolify). The container boots —
Next.js prints "Ready" and the pg-boss workers run — but never
accepts HTTP on :3000, so the Docker healthcheck fails and Traefik
takes the upstream out of rotation. A manual restart, a Coolify
force-rebuild, and a docker-compose pin to the GHCR :1.4.0 multi-arch
image all failed to bring the site back up — Coolify rebuilds the
image from main HEAD on every deploy regardless of the compose
directives.

This commit resets the working tree to commit 21bd46d (v1.4.0
release). Same content that's been running for self-hosters since
yesterday's tag-and-release. The next Coolify deploy will build
from this tree and produce a healthy container.

The v1.4.1 work is NOT lost:
  - PRs #144, #145, #137, #146, #147, #148, #149, #150 remain in
    git history.
  - Their commits are still tagged (`v1.4.1`), still on the GHCR
    multi-arch image (`ghcr.io/mbombeck/healthlog:1.4.1`), still in
    the GitHub Release notes.
  - Self-hosters who have already pulled the v1.4.1 image keep it.
  - Local development continues from main HEAD with the v1.4.1
    code — the regression only surfaced under the Coolify build
    flow.

Re-applying v1.4.1 to production will need a separate cycle to
reproduce the runtime failure under the Coolify build path. That
work is tracked in docs/ops/v141-followup-issues.md (added back
when the tree is reapplied) and the deploy gating in
.github/workflows/e2e.yml will catch this class of bug going
forward.

No DB migration. No env-var change. No API contract change.
Co-Authored-By: Marc-André Bombeck <mbombeck@gmail.com>
2026-05-08 17:15:34 +02:00
Marc-André Bombeck 70353882eb fix(security): close moodLog SSRF + central error redaction; fix citation drift (#148)
The v1.4.1 audit pass turned up one CRITICAL, two HIGH, and four
medical-citation drift findings. This commit addresses everything
that's safe to land in a hardening release; deeper architectural
fixes (idempotency-race transactional rewrite, encryption-key
fallback gate, refresh-token reuse-detection serialisation, moodLog
secret HMAC lookup column) are tracked for v1.4.2 in
docs/ops/v141-followup-issues.md.

Security
--------

CRITICAL — moodLog SSRF
  src/lib/validations/moodlog.ts now refines the credentials URL
  with isPublicUrl() so an authenticated user cannot store
  http://169.254.169.254/ (cloud metadata) or RFC1918 hosts.
  src/lib/moodlog/sync.ts re-checks isPublicUrl(baseUrl) at the
  fetch site (so legacy rows stored before the credential guard are
  also refused) and switches to redirect: "manual" + 3xx → fail so a
  public host cannot 302 the request to an internal target with the
  user's apiKey on the redirect hop. New unit test
  src/lib/validations/__tests__/moodlog.test.ts asserts the guard
  rejects 169.254.x.x, 10.x, 172.16.x.x, 192.168.x.x, 127.0.0.1, and
  localhost.

HIGH — error-message secret leakage
  New src/lib/logging/redact.ts redacts Bearer tokens (incl. our
  hlk_/hlr_), Telegram bot URLs (bot<digits>:<token>), and
  query-string secrets (?secret=, ?code=, ?token=, ?api_key=). The
  redactor is called from WideEventBuilder.setError() — every error
  reaching Loki is scrubbed once, centrally — and from the
  reportToGlitchtip() call in api-handler.ts so the Glitchtip
  incident UI is also protected. New unit suite covers the four
  patterns plus over-redaction (Bearer's \\S+ greedily consumes
  anything up to the next whitespace, which is the safer side of
  the trade-off).

Truthfulness — citation drift fixes (audit found 0 hallucinations,
4 drift items)
  - messages/{en,de}.json bpClassificationTitle and avg30dEsc:
    "ESC/ESH 2018" → "ESH 2023". User-visible on the dashboard and
    doctor-report PDF. The numeric thresholds are unchanged from
    2018; the 2023 update is ESH-only since ESC withdrew from
    joint authoring.
  - src/lib/analytics/classifications.ts: section header,
    getBpTargetsByAge() docblock, and inline comment updated to
    cite ESH 2023 with the same "numerically unchanged" caveat.
  - src/app/api/insights/targets/route.ts:375: source label "WHO"
    → "Saint-Maurice JAMA 2020". The AI-prompt anti-pattern guard
    explicitly forbids citing WHO for a step number; this surface
    was the last "WHO" mislabel in the codebase.
  - Saint-Maurice "mortality plateau 8000-12000" attribution
    softened to "continued dose-response benefit through ~12,000
    steps/day, not a plateau" in src/lib/medical-citations.ts,
    src/lib/analytics/effective-range.ts,
    src/lib/analytics/classifications.ts, and both AI prompts. The
    original JAMA 2020 paper shows continuing benefit (HR 0.49 at
    8k vs 4k, HR 0.35 at 12k); the plateau-shaped finding belongs
    to Paluch 2022 Lancet PH (PMID 35247352), not Saint-Maurice.

Quality gates
  pnpm typecheck         clean
  pnpm test              669 / 669 (was 658; +11 from new suites)
  pnpm test:integration  10 / 10 (untouched)
  pnpm lint              0 errors
2026-05-08 16:30:37 +02:00
Marc-André BombeckandMarc-André Bombeck f9ff450e08 perf(insights): batch the medication-compliance and per-type latest queries (#125)
Two N+1 patterns the v1.4 discovery audit caught — the same shape v1.3
fixed in /api/insights/comprehensive but in adjacent code paths:

1. **`extractFeatures` (used by every AI-insight route)** issued one
   `prisma.medicationIntakeEvent.findMany` per active medication,
   in-loop. Replaced with a single windowed query
   (`medicationId IN (...)` + `scheduledFor >= 90 days`) and an
   in-memory Map group, mirroring the V3 fix in `/api/insights/
   comprehensive`. Latency now scales with total recent intakes, not
   `medications × intakes-per-medication`. The 90-day cutoff matches
   the longest window `calculateCompliance` consults.

2. **`/api/insights/targets`** issued one `prisma.measurement.findFirst`
   per `MeasurementType` (×~10) to fetch each type's most recent
   value. Replaced with a single `findMany({ distinct: ["type"],
   orderBy: { measuredAt: "desc" } })`. One round-trip instead of N.

Behaviour identical, just fewer round-trips. Tests stay at 371/371.

Note: the eager Recharts import in `app/insights/page.tsx` (the audit
also flagged it as a bundle bloat that neutralises the existing
`dynamic()` wrappers) is its own refactor — every ScatterChart usage
inside the page needs to be lifted into its own component before
dynamic-loading helps. Tracked for a follow-up perf PR so this one
stays scoped to query-side wins.

Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
2026-05-08 12:11:57 +02:00
Marc-André BombeckandMarc-André Bombeck 8adeb59396 v1.4 medical-safety: protect against silent DBP hypotension, align BP & step citations, ACE body-fat consolidation (#123)
* fix(medical): protect against silent DBP hypotension and align BP citations

Three correctness issues that the v1.3.3 ecosystem audit surfaced and
that we deferred until the v1.4 cycle:

1. **Diastolic blood-pressure orange band reached 60 mmHg.** With the
   default age-based targets (DBP 70-79), the lower orange wing was
   computed as `diaLow - 10 = 60`, putting a sustained reading of
   60 mmHg into "mildly low" territory — visually a yellow tile, not a
   red one. 60 mmHg is widely treated as a hypotension / perfusion-risk
   threshold (general-adult <90/60 from AHA/NIH; J-curve evidence in
   ONTARGET / TRANSCEND for treated hypertensives; ESH 2023's explicit
   caution against treating below 70 in the elderly). The orange floor
   is now clamped at 65 mmHg, so a reading of 60 lands in red. The
   user-override path stays untouched and is already audit-logged.

2. **"ESC/ESH 2023" doesn't exist as a joint guideline.** ESC withdrew
   from the 2023 authoring; the 2023 hypertension document is ESH-only.
   The codebase had a mix of "ESC/ESH 2018" (analytics) and
   "ESC/ESH 2023" (AI prompts). Both are wrong in different directions.
   Every site (`bp-targets.ts`, `targets/route.ts`,
   `ai/prompts/blood-pressure.ts`, `ai/prompts/base-system.ts`) now
   cites "ESH 2023" with the published source URL. Target bands are
   unchanged 2018 → 2023 — only the citation moves.

3. **"WHO ≥ 8 000 steps/day" was a recurring hallucination.** WHO's
   2020 Physical Activity guidelines publish minutes per week, not a
   step quota. The cohort study HealthLog actually relies on is
   Saint-Maurice et al., JAMA 2020 (mortality plateau 8 000–12 000
   steps). The v1.3.3 fix landed in `effective-range.ts` but four AI
   prompt strings (`base-system.ts` DE+EN, `general-status.ts` DE+EN)
   carried the old wording forward. Same fix shape: cite Saint-Maurice
   for the step number and add an explicit "WHO does not publish a
   step quota" note so the model doesn't regress. Sleep target moves
   from "ESC" to AASM 2015 in the same surfaces — ESC publishes no
   adult sleep duration guideline.

Adds `src/lib/medical-citations.ts` as the new single source of truth
for cited guidelines (id, name, year, url, caveat). v1.4 surfaces will
import these constants instead of duplicating strings, and the new
`medical-citations.test.ts` drift-test asserts that every entry has a
non-empty url + caveat, that ids are unique, and that the WHO-as-step-
source hallucination cannot reappear as a constant.

Tests: 358 → 363 (5 new for the master citations list). Typecheck and
existing analytics tests pass. No clinical numbers changed beyond the
DBP orange-floor clamp.

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

* fix(medical): consolidate body-fat ACE bands and document bedtime-glucose source

Two related medical-truthfulness issues from the v1.3.3 ecosystem audit.

**Body-fat classification: ACE bands corrected.** v1.3.3's classifier
used `essential = 6 (M) / 14 (F)` as the *floor* of the "Essential"
category, which is actually ACE's *Athletes* lower bound. Anything
below was lumped under "Essential" — including readings (e.g. 1% male)
that are clinically concerning, not just "athletically extreme". The
classifier now mirrors the ACE table precisely:

- Below essential (M <2 / F <10): danger.
- Essential (M 2–5 / F 10–13): warning.
- Athletic (M 6–13 / F 14–20): normal.
- Fitness (M 14–17 / F 21–24): normal.
- Acceptable (M 18–24 / F 25–31): warning.
- Obese (M ≥25 / F ≥32): danger.

**Three-way drift in body-fat target ranges consolidated.** v1.3.3 had
three sites with three different green-band numbers:

- `value-bands.ts:118-120` returned M{10,20} / F{18,28}.
- `app/api/insights/targets/route.ts:336-338` hardcoded M{14,24} /
  F{21,31} with `source: "ACE"`.
- `classifications.ts:155-205` used a third overlapping set of
  thresholds for the band labels.

ACE fitness + acceptable (M 14–24 / F 21–31) is the realistic healthy
band for a non-athlete adult and is now the single source in
`getBodyFatTargetRange`. Targets route and chart value-bands import the
helper instead of duplicating the numbers.

**Bedtime glucose citation softened.** ADA Standards of Care 2024 §6
publishes pre-prandial 80–130 and post-prandial <180; the document does
NOT publish an adult bedtime target. The 90–150 mg/dL band HealthLog
ships is reasonable advice (within the daytime pre-prandial range,
slightly raised floor to avoid nocturnal hypoglycaemia) but it's not
ADA-published. The inline comment in `effective-range.ts` now states
the absence of an adult guideline explicitly and cites ISPAD 2022
(pediatric T1D, 80–140) as the closest comparator. Numbers unchanged.

Tests: 363 → 369 (6 new for the ACE bands). Existing assertions
unchanged.

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

* fix(medical): align step-range target with the effective-range source

`getStepsRange()` in classifications.ts returned `{7 000, 10 000}`
while `effective-range.ts` (the canonical resolver behind
`getEffectiveRange`) returned `{8 000, 15 000}`. The two surfaces
showed different "green" bands to the same user — `/api/insights/
targets` used the lower pair, the dashboard chart-bands used the
upper pair.

Aligns both on the upper pair, anchored on Saint-Maurice et al. JAMA
2020 (mortality plateau 8 000–12 000 steps/day). Adds the citation
header to the steps section so the next audit pass can verify in
place. WHO is explicitly called out as NOT a source for a step quota
(only an activity-time guideline).

The classifier function `classifySteps` is unchanged — its 3 / 5 / 7.5
/ 10 k bands match Tudor-Locke 2004 pedometer indices and are kept
distinct from the two-number "target range" helper.

Tests stay at 369/369.

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

---------

Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
2026-05-08 12:07:46 +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 b9ade5b519 fix(api): address review findings — N+1, idempotency cache safety, device hijack guard
Three fixes from the 5-way review on PR #117:

1. Insights cards: replace per-medication N+1 query with single batched
   findMany + JS bucketing. For users with many active medications this
   collapses 10+ sequential round-trips into one.

2. Idempotency cache: only persist 2xx/3xx and validation 4xx responses.
   401/403/408/429/5xx are now skipped — a stale 401 (token expired
   mid-flight) or transient 5xx must not be replayed for 24h. Adds a
   defence-in-depth check that refuses to cache any body containing
   "hlk_" so a future caller wrapping an auth route in withIdempotency
   cannot leak a freshly-issued bearer token through the cache.

3. Devices: reject cross-user token re-registration with 409. Prior
   behaviour silently transferred ownership on findUnique-by-token,
   which would let anyone who learns/guesses an APNs token redirect
   another user's pushes. Audit-logs the denial with reason.

Tests adjusted: device-hijack test now expects 409, plus a new test
for same-user re-registration. 279/279 tests passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 22:08:53 +02:00
Marc-André BombeckandClaude Opus 4.7 1f5ad1729d feat(api): aggregator endpoints for headless API clients
Adds seven read/write endpoints that aggregate or adapt existing data
into shapes more practical for non-browser consumers. None of the
pre-existing endpoints are touched.

  • `GET /api/dashboard/summary` — greeting + streak + today compliance
    + per-metric latest value with 7-day sparkline. Single round-trip
    for a "home" view instead of fanning out across 6 endpoints.
  • `GET /api/measurements/series?kind=&days=` — pre-aggregated series +
    stats (mean, min, max, stdDev, count) reusing
    `src/lib/analytics/trends.ts`.
  • `GET/PATCH /api/integrations/healthkit` — opt-in per-metric sync
    config (entries[] of `{ id, kind, direction, enabled }`). Stored in
    the new `users.healthkit_config_json` column.
  • `POST /api/devices` — APNs/FCM token registration only; no push
    sender wired up here. Upsert on `token`.
  • `GET /api/medications/intake?scope=today|compliance` — top-level
    aggregator over all of a user's medications, plus a sibling
    `POST /api/medications/intake` accepting `{ intakeId, status, takenAt }`
    for clients that don't want to maintain medication-id state.
  • `GET /api/insights/cards` — adapter that maps the rule-based alerts
    from `/api/insights/comprehensive` into card-shaped `Insight[]`.
  • `GET /api/insights/correlations` — placeholder array today; flagged
    in audit log as `insights.correlations.empty` until a real engine
    lands.

All endpoints honour `requireAuth()` (cookie or bearer) and the existing
audit/Wide-Event pipeline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:53:16 +02:00
Marc-André Bombeck 908532be73 feat(security): SSRF strict IPv4, PAT logging redaction, threshold rate-limit, N+1 fix, pg-boss SIGTERM, blocking CI
Security hardening from 2026-04-26 audit:

- SEC-002: isPublicUrl() now uses a strict IPv4 parser that rejects
  leading-zero forms like 010.0.0.1, which historically slipped past
  the h.startsWith('10.') check. Also covers CGNAT (100.64/10),
  IPv6 unique-local (fc00::/7), and additional metadata-service edge
  cases. Documented as input-time only (DNS rebinding still possible
  at fetch time).
- SEC-004: GitHub PAT is now redacted from logged error bodies in
  publish-github.ts before flowing to Wide Events / Loki. Adds an
  escapeRegex helper for safe RegExp construction.
- SEC-006: PUT /api/user/thresholds now goes through checkRateLimit
  (30 writes / 5 min, per-user) — audit-log was already there but
  unbounded. Makes audit-log enumeration unattractive.
- PERF-001: insights/comprehensive route now batches medication-intake
  events into a single 'medicationId IN (...)' query and groups in
  memory instead of N+1 per medication. Latency was scaling poorly
  for users with many active meds.
- REL-003: pg-boss now installs SIGTERM/SIGINT handlers that call
  boss.stop({ graceful: true, timeout: 30s }) so in-flight handlers
  drain on docker stop / coolify redeploy instead of being killed.
- LOG-001: Replaced console.log/error in reminder-worker with a
  workerLog helper. Lifecycle errors still go to stderr (only audience
  outside any handler context); routine init messages are silent
  because pg-boss own events + Wide Events from handlers carry the
  state.
- CI: ESLint and TypeScript checks are now blocking — continue-on-error
  removed. Tests already were. Required pre-existing 'any' types in
  api-handler.ts to be properly justified with eslint-disable comments
  (Next.js variadic handler signature; the bound type still constrains
  return to Promise<Response>).
2026-04-27 00:28:28 +02:00