Compare commits

...
99 Commits
Author SHA1 Message Date
Marc-André BombeckandClaude Opus 4.7 a852612bfe chore(release): v1.4.6
Release notes in CHANGELOG.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 02:29:08 +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 67575180b8 fix(qa): apply pre-release multi-agent QA findings
Three small follow-ups surfaced by the v1.4.6 QA pass:

- Settings → KI: the "raw data" warning still used raw Tailwind orange
  tokens instead of the Dracula `dracula-orange` ones, breaking theme
  consistency and tipping below the WCAG-AA contrast floor on the
  card surface.
- Admin status grid: the Backups card cta read "View backups" but
  jumped to the system-status section (the dedicated backups view
  does not exist yet — that's a v1.5 feature). Re-labelling the cta
  to "Open system status" and the Integrations cta to "Open
  integrations" makes the destination honest. The href targets
  themselves are unchanged.
- `withIdempotency` body-content guard previously did `text.includes("sk-")`
  which silently broke caching for benign payloads containing common
  English compounds like "task-id", "risk-management", "disk-io".
  Tightened to a regex requiring the canonical secret prefix plus a
  realistic suffix length, so only actual API keys trip the guard.
  Three regression tests cover the false-positive cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 02:22:12 +02:00
Marc-André BombeckandClaude Opus 4.7 dc4507a55f fix(admin/status-overview): one failed probe no longer blanks the grid
P20. Switch the 16-probe batch from `Promise.all` to
`Promise.allSettled`. Before: any single probe rejecting (a transient
DB timeout, a missing index after a schema migration, etc.) would
reject the whole route and the admin overview grid would render as
blank — costing the admin the other five healthy cards' info too.

Now each probe is labelled, the rejected ones map their card's severity
to "alert" + zero/null defaults so the StatusCardGrid still draws
something, and the failed probe names are surfaced in a Wide Event
annotation (`statusOverviewProbeFailures: [...]`) for ops triage.

Adds a vitest case that mocks a single rejection on the withings probe
and asserts the response still 200s, the integrations card flips to
alert with `withings: 0`, and untouched cards keep their normal
severity.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 02:10:15 +02:00
Marc-André BombeckandClaude Opus 4.7 26543378b5 fix(admin): surface query errors instead of an infinite spinner
P19. `useSystemStatus()` and `useAdminSettings()` already throw on
non-OK responses so React Query sets `isError`, but neither caller
read it — the system-status panel just kept spinning forever and the
settings sections silently rendered defaults as if they were live.

Now system-status renders a destructive inline alert when isError, and
the admin page top renders a one-line "Failed to load admin settings"
banner when the shared settings query fails so the admin notices the
toggles below aren't reflecting reality. Both messages have new i18n
keys (`admin.systemStatusLoadError`, `admin.adminSettingsLoadError`)
in en.json (canonical) + de.json.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 02:10:05 +02:00
Marc-André BombeckandClaude Opus 4.7 86a4b52053 fix(admin): danger-zone result colour driven by mutation state
P18. Replace the brittle "wipeMsg.startsWith(localized prefix)" success
heuristic with React Query's `wipeAllData.isError` flag. The previous
heuristic (a) re-formatted the success copy with empty placeholders to
extract a prefix, (b) string-prefix-matched against the rendered
message, and (c) silently broke whenever the i18n copy changed colon
position or got translated. Now the colour follows the mutation state
directly: error → destructive, success → dracula-green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 02:09:57 +02:00
Marc-André BombeckandClaude Opus 4.7 505f31844e style(admin): switch feedback-category badges to dracula tokens
P17. The category badges (BUG / FEATURE_REQUEST / QUESTION / OTHER)
were using raw Tailwind palette values (bg-red-500/15 text-red-400 etc.)
which clash with the rest of the app's Dracula tokens. Switch to the
dracula-red / dracula-purple / dracula-cyan equivalents so the inbox
matches the site theme — and dark/light mode parity is consistent
with every other status badge in the admin grid.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 02:09:49 +02:00
Marc-André BombeckandClaude Opus 4.7 89b5b80493 fix(settings/ai): drop unreleased OpenAI presets + i18n the AI section
P15: drop `gpt-5` (unreleased) and `o3-mini` from `MODEL_PRESETS["OPENAI"]`.
gpt-5 would surface as model_not_found from OpenAI; o3-mini uses the
o-series param contract (max_completion_tokens, no temperature override)
which the current OpenAIClient body shape doesn't speak — so picking
either preset just produced a confusing 400. Keep gpt-4o, gpt-4o-mini,
gpt-4-turbo. Power users can still type any model via the Custom… option.

P16: pipe ~30 hardcoded German strings in ai-section.tsx through
`t("settings.ai.…")` and add the matching keys to both messages/en.json
(canonical English) and messages/de.json. Behaviour unchanged — the
Connect/Disconnect/Save/Test/Provider/Model labels and the connect-help
copy now respect the user's locale toggle. The provider-test flow keeps
its own DE strings localized too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 02:05:28 +02:00
Marc-André BombeckandClaude Opus 4.7 dcc697c33d fix(ai): codex-client mirrors openai-client structured-error pattern
P14. CodexClient previously threw a bare `Error("Codex request failed
(500)")` on upstream failure, which made it impossible to triage from
logs. Mirror the openai-client.ts:42-58 pattern: pull the body
(truncated 500 chars), mask any leaked `sk-` / Bearer token, and tag
the Error with `httpStatus`, `upstream`, `model`, and `bodyExcerpt`
fields. Glitchtip and Wide Events now have the same shape for both
providers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 02:01:56 +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 c7b600575e fix(idempotency): refuse to cache bodies containing third-party AI keys
P12. The body-content guard already rejected `hlk_` / `hlr_` (our access
and refresh tokens). Extend it to also reject `sk-` so any OpenAI or
Anthropic key that might be echoed by a misrouted handler never lands
in the IdempotencyKey table. The substring match also covers `sk-ant-`
so we don't need a second branch.

Adds a parametrised vitest case asserting all four prefixes
(hlk_, hlr_, sk-, sk-ant-) suppress caching, plus a
positive case to prove a normal payload still caches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 02:00:49 +02:00
Marc-André BombeckandClaude Opus 4.7 e903d9a2c2 fix(logging): scrub OpenAI / Anthropic API keys in redactSecrets
P11. Adds a `sk-(ant-)?[A-Za-z0-9_-]+` rule so any sk- / sk-ant- token that
finds its way into an error message or stack trace gets redacted before
it reaches Loki or Glitchtip. We never log these on purpose, but a
mis-typed config or a dumped request body could carry one — defense in
depth on the egress path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 02:00:01 +02:00
Marc-André BombeckandClaude Opus 4.7 5c884b3d0e style(layout): tighter mobile bottom-nav buffer + muted trend-hint title + drop font-mono on progress
- P7: shrink the bottom-nav clearance from 5rem to 4rem on mobile (md:pb-0 unchanged) so content sits closer to the nav without overlap.
- P8: TrendHint title gets text-muted-foreground so it stops competing with chart titles after the T2 muted-foreground hierarchy fix.
- P9: drop font-mono from the onboarding "X%" progress label; tabular-nums alone is enough.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:59:25 +02:00
Marc-André BombeckandClaude Opus 4.7 dc6db82a79 style(dashboard): visible muted welcome subtitle + medications card padding parity
- P6: drop hidden sm:block from the welcome subtitle and add text-muted-foreground so it stays visible on mobile but recedes from the H1.
- P10: medications card rounded-lg border p-4 -> rounded-xl border p-4 md:p-6 to match the other dashboard chart cards.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:58:30 +02:00
Marc-André BombeckandClaude Opus 4.7 fda8dd8c47 style(dashboard): trend-card typography + tabular nums + match chart padding + always-show avg chips
- P1: tabular-nums on latest, unit, avg7, avg30 spans so digits stop jiggling on refresh.
- P2: bump value text-2xl -> text-3xl tracking-tight; label text-sm font-medium -> text-xs uppercase tracking-wide.
- P3: tile padding p-3 -> p-4 md:p-6 to align with chart card padding.
- P4: drop direction-tinted arrow color (orange/cyan) — direction-as-good-or-bad is metric-specific, deferred to v1.5+. Both directions now muted-foreground.
- P5: always render avg7/avg30 chips, with em-dash placeholder when null, so vertical rhythm stays consistent across tiles.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:58:06 +02:00
Marc-André BombeckandClaude Opus 4.7 6a64df0fad feat(charts): bucket > 1y ranges into weekly / monthly averages
Hitting "All" on a long-running account used to render thousands of
daily points — unreadable, and slow on mobile. The chart adapter now
picks an aggregation level based on the visible range:

  - ≤ 90 days  → daily (unchanged)
  - 91-730 days → ISO weekly mean
  - > 730 days  → Berlin-calendar monthly mean

Empty buckets are dropped instead of zero-padding so a sparse user
keeps a real trend instead of a baseline floor. Each metric is
aggregated independently so blood-pressure SYS/DIA pairs stay in sync
without one channel pulling the other to 0 on missing days.

A small chip beside the chart title surfaces the active bucket
("Wochendurchschnitt" / "Monatsdurchschnitt" in DE, "Weekly avg" /
"Monthly avg" in EN) so the user always knows what they're looking at.

The bucketer (`src/lib/charts/bucket-time-series.ts`) is pure and
covered by 14 unit tests including ISO-week boundary handling, the
multi-metric independence guarantee, and the empty-bucket skip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:56:07 +02:00
Marc-André BombeckandClaude Opus 4.7 46e686f396 fix(e2e): switch mobile project to chromium and repair spec flakiness
Three orthogonal fixes that account for most of the e2e suite's
ongoing red:

- The `chromium-mobile` project was actually iPhone 13 (webkit) — CI's
  `playwright install chromium` therefore could not launch it
  (`webkit-2272/pw_run.sh` does not exist). Renaming-to-Pixel-5 makes
  the project name match what the runner provides without expanding
  the install footprint.
- `locale-switch.spec.ts` tried to anchor the cookie at `page.url()`,
  which on a fresh context is `about:blank`; Playwright rejected the
  cookie outright. Using the project's configured baseURL is the
  documented way and works regardless of navigation timing.
- `login.spec.ts` looked for the username/password inputs immediately
  after navigation, but the password form is mounted lazily once the
  user clicks "Login with password" (passkey is the default mode).
  A small helper now opens the form before the assertions run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:52:57 +02:00
Marc-André BombeckandClaude Opus 4.7 1adda80944 feat(insights): widen per-card window from 30 days to 360+24 buckets
Every per-card KI insight generator was clipping each metric to the
last 30 daily means before sending the prompt. The model therefore
saw a one-month window per metric — far too narrow to detect drift
that plays out over a year, and the cause of repeated complaints that
a "Stable" trend assessment ignored an obvious 9-month decline.

New shared helper `applyPayloadBudget()` builds a `{ daily, monthly }`
view: the most recent 360 days as daily means (`dayOffset 0..359`)
plus 24 thirty-day windows for the older history (`monthOffset
12..35`). Empty buckets are dropped. If the canonical payload would
serialise above 50 KB the helper transparently re-buckets at 180
daily days so the prompt token budget stays bounded; a per-generator
`annotate({ payload_size_bytes })` makes the chosen window
observable in Wide Events.

All seven generators now consume the bucketed view as the metric
`series` field (general-status, blood-pressure-status, pulse-status,
weight-status, bmi-status, mood-status, medication-compliance-
status) — including their cross-metric joins (BP-pair, weight ↔ mean
BP correlation, medication-compliance per day) which are rebuilt on
top of the shared `dayOffset` instead of date strings. Mood and
medication queries widened to ~3 years so the older monthly window
can actually be filled.

One focused vitest per generator captures the prompt that goes to the
provider, JSON-parses the embedded snapshot, and asserts every
metric series carries both `daily` and `monthly` arrays with the
expected shape. The bucketing helper also has its own unit tests
covering offsets, leap-year-safe Berlin-day boundaries, and the
50 KB → 180-day fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:50:10 +02:00
Marc-André BombeckandClaude Opus 4.7 c3ca8614a6 fix(admin): preserve audit trail across the data wipe
Two related corrections to the "Wipe all data" admin action:

- Stop deleting the audit log inside the transaction. The whole point
  of the audit trail is to outlive the data it documents, and this is
  the single feature where that property matters most. AuditLog rows
  now survive the wipe.
- Audit-log the *intent* (`admin.data.clear.start`) before the
  transaction begins, so even if the wipe crashes mid-flight there is
  a record of who initiated it.
- The i18n copy and the success toast both undersold the scope: they
  only mentioned measurements/medications/intakes, but the wipe also
  clears API tokens, Withings connections, and any open auth
  challenges. EN + DE strings now enumerate every cleared category and
  call out that user accounts AND the audit log are preserved.
- Mutation result type widened so the toast can render the full
  count tuple.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:32:29 +02:00
Marc-André BombeckandClaude Opus 4.7 31959e436e fix(bugreport): admin toggle now actually blocks feedback submissions
The "Bug reports" admin toggle had no effect. The form on `/bugreport`
posts to `/api/feedback`, which was the wrong endpoint for the gate —
it lived only on the legacy `/api/bugreport` route the form does not
call. Users still submitted into the local feedback inbox after the
admin disabled the feature, and the prior commit message claimed
"the report button gracefully disappears" was untrue.

- `/api/bugreport/status` now also returns `enabled` (defaults true
  when the column has never been written) so the page can decide
  whether to render the form at all.
- `/api/feedback` POST checks `bugReportEnabled` and returns 503 with
  a readable error when the admin has switched the feature off, so a
  scripted client cannot bypass the UI by hitting the endpoint directly.
- `/bugreport` page now hides the form and shows the disabled-banner
  copy when `enabled === false`.
- New i18n keys for the disabled state in DE + EN.
- Vitest covers the gate: blocked when off, allowed when on, allowed
  when row absent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:31:11 +02:00
Marc-André BombeckandClaude Opus 4.7 4aeb8c904e fix(admin): point status-card CTAs at real anchor IDs
Every "Manage users" / "View backups" CTA reloaded `/admin` to the top
because the hrefs were pointing at sub-routes that never shipped
(`/admin/users`, `/admin/audit-log`) and at anchors whose IDs do not
exist (`/admin#integrations`, `#monitoring`, `#backups`,
`#maintenance`). The actual section IDs in `src/app/admin/page.tsx`
follow a `section-admin-*` / `section-user-management` pattern.

Six href fixes plus a tightened test: instead of `startsWith("/admin")`
the test now asserts each href is `/admin#section-<known-id>` and that
the anchor is a member of the source-of-truth ID set rendered by the
admin page. Adding a new section requires adding the ID here too —
which is exactly the visibility we want to keep CTAs honest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:28:55 +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 eba898f4c1 fix(ai): block aiBaseUrl from leaking across providers
The user row stores a single `aiBaseUrl` column that is meaningful only
for the LOCAL provider, but `buildUserProvider` and
`resolveProviderForTest` were forwarding that value to OpenAI and
Anthropic clients too. A user who had configured LOCAL pointing at e.g.
`http://192.168.x.x/v1` and later switched to OPENAI would have their
OpenAI API key sent to that LAN host on the next request.

Defence in three layers:

- PATCH /api/user/ai-provider — when the provider switches to anything
  other than LOCAL and the body does not include an explicit
  `aiBaseUrl` write, set the column back to NULL.
- `buildUserProvider` — for OPENAI hardcode `https://api.openai.com/v1`,
  for ANTHROPIC do not forward a base URL at all (the SDK default is
  correct and the UI never exposed an Anthropic base-URL input).
- `resolveProviderForTest` — same belt-and-braces: ignore the merged
  base URL for OPENAI/ANTHROPIC so a stored LOCAL value cannot bleed
  into a connection-test request.

Adds two regression tests covering the LOCAL → OPENAI and LOCAL →
ANTHROPIC paths with a stale `aiBaseUrl`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:26:48 +02:00
Marc-André BombeckandClaude Opus 4.7 a75fbc6aaf fix(insights): primary recommendation parses metric: chart tokens
The base-system prompt invites the model to embed `metric:WEIGHT` /
`metric:BP` etc. tokens in `primaryRecommendation` so the UI can drop
inline charts under the prose. The hero "Key Takeaway" card was the
only renderer printing the field raw — when the model followed the
instructions, the user saw the literal string `metric:WEIGHT` inside
the recommendation. Apply the same `stripChartTokens` + `<InlineCharts>`
pattern that the summary already uses.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:24:47 +02:00
Marc-André BombeckandClaude Opus 4.7 8aae7d6dfe fix(dashboard): tile fill + muted-foreground hierarchy
Two related visual fixes for the dashboard tile strip and every
secondary string in the app:

- TrendCard now fills its grid cell. As a flex-child the card was
  taking content width, which made tiles in the same row 122-166 px
  wide on a 375 px viewport instead of even shares of the row.
  Adding `w-full` to the wrapping `flex` element gives every tile
  the same horizontal share that the parent grid hands out.
- `--muted-foreground` was identical to `--foreground` in both
  themes, so labels, "Ø7d:" prefixes, units, and inactive nav items
  all rendered at the same brightness as primary text. Dark theme
  now uses `#9aa3b3` (Dracula comment), light uses `#5b6273` —
  WCAG-AA contrast against `--background` and `--card` holds at
  ≥ 4.5:1 in both modes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:24:22 +02:00
Marc-André BombeckandClaude Opus 4.7 bcd1de4be0 fix(ci): drop e2e pnpm version override
pnpm/action-setup@v4 fails when both `with.version` and the
`packageManager` field in package.json declare a pnpm version.
Removing the workflow override and letting the action read
`packageManager: pnpm@10.31.0` from package.json is the canonical
fix and matches the integration / security workflows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:23:09 +02:00
Marc-André BombeckandClaude Opus 4.7 ecca54d6b0 chore(planning): bootstrap v1.4.6 marathon
Add .planning state files and check in the v1.4.6 findings spec so
the workstream and the source-of-truth audit live in git from here on.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:22:09 +02:00
Marc-André BombeckandClaude Opus 4.7 e8f4820018 fix(dashboard): tile strip back to CSS Grid for true symmetric width
Marc's v1.4.4 feedback: the tiles look better when they're all
exactly the same size and the strip begins and ends at the same
x-coordinate as the charts — the v1.4.4 `flex-1 basis-0` distribution
was close but not quite symmetric, and at 6 tiles the right edge
of the strip didn't line up with the charts below.

Switched to the v1.3-era pattern but with the modern `auto-fit +
minmax(9rem, 1fr)` form so it doesn't need explicit breakpoints:

  grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
  gap: 0.75rem;

CSS Grid puts every tile in an identical 1fr track. Width is
literally identical, gaps are symmetric, and the strip is bounded
by the same parent box as the charts so left/right edges align by
construction. When the row no longer fits a 9 rem floor the grid
wraps to a new row — Marc explicitly tested both and prefers the
wrap over the v1.4.x horizontal-scroll for the symmetry it
preserves.

Tests + typecheck green (671/671).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 00:05:35 +02:00
Marc-André BombeckandClaude Opus 4.7 cbded7c39e fix(dashboard): symmetric tile widths, BP back to two tiles
Marc's v1.4.3 feedback:
  - Tiles end up the same height now but uneven widths — they should
    distribute symmetrically across the strip.
  - BP shouldn't be one combined tile; sys + dia should sit side by
    side as two distinct tiles like every other metric.

Two fixes:

1. **Strip wrapper switches from `shrink-0 grow basis-[10rem]` to
   `flex-1 basis-0 min-w-[9rem]`.** With `basis-[10rem]` the floor
   was 160 px, and tiles whose content rendered wider (the combined
   BP tile rendering "117.0/79.0 mmHg" plus a paired avg row) pushed
   past their share, breaking symmetry. `flex-1 basis-0` starts every
   tile from zero, so the row-width gets divided evenly regardless of
   how much text each card contains. `min-w-[9rem]` is the readable
   floor — if the row no longer fits at that width, the parent's
   existing `overflow-x-auto` lets the user swipe instead of wrap.

2. **BP renders as two tiles again.** Reverted the v1.4.3 combined
   `secondary` rendering: the dashboard now pushes a `bp-sys` and
   a `bp-dia` entry, ordered with a 0.001 sub-step so dia stays
   immediately after sys without colliding with the next widget's
   integer order. The `secondary` prop on TrendCard stays in the
   API for callers that *do* want a paired-value tile.

Tests + typecheck green (671/671).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 22:14:24 +02:00
Marc-André BombeckandClaude Opus 4.7 2ff215af7e chore(release): v1.4.3
Wraps the polish pass: dashboard layout, About + API surfaces,
KI-provider UX, AI insights chart-tokens + evidence contract, admin
bug-report toggle, and the Trennen-button consistency sweep.
Migrations 0026 (user OpenAI key) and 0027 (bug-report toggle) ride
along — both are additive columns with safe defaults so existing
deployments upgrade without data work.

CHANGELOG.md describes the user-visible deltas section by section.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:46:28 +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 dc01ab7934 feat(insights): metric:MOOD inline charts + evidence-contract prompt
Bucket D phase 1 of the v1.4.3 polish pass — the AI insights felt
"static" because (a) findings repeated phrasing with no concrete
numbers and (b) the inline-chart machinery existed but was prompted
permissively, so the model rarely used it.

Two concrete moves, scoped to ship in this hotfix:

1. **`metric:MOOD` is now an allowlisted chart token.** Renderer in
   `<InsightAdvisorCard>` branches on `tokenKind(token)` — measurement
   tokens still mount `<HealthChart>`, but `metric:MOOD` mounts the
   dedicated, self-fetching `<MoodChart>` instead. The mood timeline
   was previously the most under-served by the AI prose because it
   couldn't be charted inline; now the model can illustrate adherence-
   risk and mood-drift findings with the same chart users see on the
   /mood page. Drift test was updated in lockstep — the v1.4.2 guard
   that asserted MOOD was excluded now asserts it's present, plus a
   new guard asserts COMPLIANCE is still excluded (no self-fetching
   wrapper exists yet for `<ComplianceLineChart>`; ship that in v1.5).

2. **Prompt grew an EVIDENCE CONTRACT and a forbidden-phrase list.**
   The base system prompt (DE + EN both) now requires every
   `findings[]` entry to put a concrete number from the snapshot in
   `value` ("138/85 mmHg", "+0.4 mmol/L vs. 30d-avg") instead of
   adjective prose ("slightly elevated"). Findings without a snapshot
   anchor MUST be omitted — variable cardinality 0..8 explicitly
   stated, sorted by salience, NO padding. The forbidden-phrase list
   blocks generic boilerplate that signals ungrounded output: "achte
   auf ausreichend Schlaf", "drink enough water", "regular exercise",
   "consult your doctor" (except in the disclaimer field which exists
   precisely for that line). The chart-token instruction flipped from
   passive ("Du DARFST") to active ("Bevorzuge Tokens immer dann,
   wenn …") with three explicit triggers — temporal claims, threshold
   references, window comparisons — that reliably surface chart-worthy
   findings.

Carved out of v1.4.3 for v1.5: telescoping multi-resolution payload
(14d daily + 10w weekly + 21mo monthly + yearly summary per QA
research 1) and the evidence-ref output schema extension (QA 2).
Both touch the prompt's input shape and would change every cache
key + token bill — they want their own phase.

Tests + typecheck green (671/671 — one new chart-tokens drift
guard).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:41:29 +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é BombeckandClaude Opus 4.7 622099c0fd fix(settings): About + API surfaces — restructure, fix update check, fit tables
Bucket B of the v1.4.3 polish pass.

Three issues, one commit because they're the same surface:

1. **API & Tokens tables overflowed the settings content column.**
   Each table set `min-w-[760px]` (or 860px) so the table never
   compressed below that width. On mobile this is intentional (it
   gives the inner cells breathing room while the outer wrapper
   horizontally scrolls). On md+ the parent column is smaller than
   the min-width on common laptop sizings (~800px content area when
   the browser isn't full-width), which forced an inline scrollbar
   for no benefit. Switched to `min-w-[760px] md:min-w-0` per table
   so the constraint releases at the breakpoint where the layout
   stops scrolling.

2. **"Auf Updates prüfen" button silently did nothing.** The
   client-side handler called `https://api.github.com/.../releases/
   latest` directly. Production CSP locks `connect-src` down to
   `'self'` + `https://wbsapi.withings.net`, so the fetch was
   blocked at the browser before it left the tab. Replaced with a
   server-side proxy at `/api/version/check-updates` that fetches
   GitHub for the user, compares the running `package.json` version
   against `tag_name`, and returns one of `up_to_date | newer_
   available | unknown`. The route is auth-gated (cookie OR bearer)
   so anonymous traffic can't burn through GitHub's 60-req/hr anon
   rate limit on the deployment's IP. The route also annotates the
   external call into the Wide Event so a future spike is visible
   in the logs.

3. **The About surface had no headings and a stray Badge box.** The
   v1.4.2 version put the License inside a `<Badge variant=
   "outline">`, which created a small framed pill that read as a
   separate widget rather than a peer of the version number. Below
   that, the Sources/Changelog/Docs and the update-check button
   each lived in their own card with no h2 — three nameless cards
   stacked on the page. Restructured into three titled cards:

     - **HealthLog** — identity, with version + license rendered
       inline as plain monospace pairs (no Badge, no nested grid).
       Build SHA + build timestamp ride along on the same row when
       set.
     - **Quellen & Dokumentation / Sources & docs** — links unchanged
       but now under their own h2.
     - **Updates** — manual `Check now` button + a passive "Last
       checked: …" line that pulls from localStorage so the user
       can see when the previous check ran. On mount, if the last
       successful check is >24h ago (or has never run), the page
       auto-checks once. The 24h window is a deliberate compromise
       between "always fresh" and "GitHub anon rate limit". The
       error path renders a non-blocking "Couldn't reach GitHub"
       message with the alert role so screen readers announce it.

i18n keys touched in both `de.json` + `en.json`:
  - `settings.about.linksHeading` (new)
  - `settings.about.updatesHeading` (new)
  - `settings.about.checkUpdates` (label shortened)
  - `settings.about.checking` (button busy state — new)
  - `settings.about.lastChecked` / `lastCheckedNever` (new)
  - `settings.about.checkFailed` (new — replaces the misleading
    `testConnection.errors.connection_failed` reuse)

Tests updated for the new heading + button strings; 669/669 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:29:03 +02:00
Marc-André BombeckandClaude Opus 4.7 8c0593fe79 feat(dashboard): single BP tile, equal-height tile strip, settings dashboard/thresholds split
Bucket A of the v1.4.3 polish pass.

Tile-strip width drifted past the chart-strip width by 16px on >=md
because the strip used `-mx-2 px-2` to bleed two pixels past the
parent for a snap-scroll feel. The bleed is real on mobile — the
horizontal-scroll affordance only reads as a scroll if the first/last
tiles touch the viewport edge — but on tablet/desktop where the row
fits without scrolling it just made the tile group visibly wider than
the charts below. Now `md:mx-0 md:px-0` drops the bleed once charts
fit inline, so the two strips share the same content box.

Tile heights varied because the blood-pressure tile rendered as two
stacked TrendCards (sys + dia) inside one tile slot while every
other tile was a single TrendCard. The cleaner fix is one TrendCard
that displays the paired value as `117/79` — TrendCard now accepts an
optional `secondary: { latest, avg7, avg30 }` and the dashboard pushes
ONE BP entry instead of a Fragment of two. Tile count drops from 6
to 5, the strip stops wrapping awkwardly, and the BP card carries
the systolic sparkline + traffic-light tooltip while the diastolic
value rides along under the same heading. Each tile wrapper got
`flex` and the card itself `flex h-full flex-col`, so flex-stretch
inside the strip can do its job and the avg7/avg30 row pins to the
bottom.

Settings nav split: the section formerly titled "Übersicht" was
renamed to "Dashboard" (the term every user already knows from the
main nav), and the embedded Persönliche Zielwerte panel moved out
into its own top-level settings section under `/settings/thresholds`.
Two reasons: the panel is heavy (one card per metric) and was
visually buried behind the layout customizer, and "Übersicht" leaked
internal vocabulary — neither the route slug nor the surrounding nav
entries used it. Tests guarding slug order + section count updated
in lockstep.

Touched:
  - messages/de.json, messages/en.json
  - src/app/page.tsx (dashboard tile strip)
  - src/app/settings/[section]/page.tsx (route wiring)
  - src/components/charts/trend-card.tsx (secondary-metric prop)
  - src/components/settings/section-slugs.ts
  - src/components/settings/settings-shell.tsx (new entry + icon)
  - src/components/settings/section-placeholder.tsx
  - src/components/settings/dashboard-section.tsx (drop ThresholdsSection)
  - src/components/settings/thresholds-settings-section.tsx (new)
  - src/components/settings/__tests__/{sections,settings-shell}.test.tsx

Tests + typecheck green (669/669).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:24:30 +02:00
Marc-André BombeckandClaude Opus 4.7 58e9d1451a fix(dashboard): unwrap shared queryFn responses in onboarding checklist
GettingStartedChecklist intentionally shares TanStack Query cache
with the dashboard via `queryKey: ["analytics"]` etc., but its
queryFns returned the raw `res.json()` (the full `{data, error}`
envelope) while the dashboard, medications page, notifications page
and withings integrations section all return `json.data` (the
unwrapped inner shape). When the checklist's queryFn populated the
cache first, the dashboard's `data?.summaries?.WEIGHT` ended up
reading a property of the envelope `{data, error}` instead of the
analytics body, came back undefined, and silently failed every
per-tile data gate (`hasWeight`, `hasBp`, `hasPulse`, `hasBodyFat`).
Marc saw only the mood tile because mood lives under a different
cache key (`["mood-analytics"]`).

Same envelope/shape mismatch existed for `["medications"]`,
`["withings", "status"]` and `["notifications", "preferences"]` —
fixed in one pass since the failure mode is identical (silent
undefined reads on shared keys, latent bug for any consumer that
mounts after the checklist).

Each queryFn now `await`s and returns `json.data`; the local
accessors collapse from `res?.data?.X` to `res?.X`. As a
side-effect this also fixes a long-standing checklist bug where
`medsRes.data.medications.length` was always 0 (the medications
API returns the array directly under `data:`, not nested under a
`medications` field — so the count silently coerced to zero and
the "log a medication" checklist item never auto-completed).

Tests + typecheck stay green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:28:41 +02:00
Marc-André Bombeck 0810a859e1 fix(ui): defensive optional chains on all remaining var?.x.y sites
Marc reported many residual TypeError crashes after the targeted fix
in 680b001. A comprehensive perl regex sweep across src/app and
src/components turned up 19 more occurrences of the same anti-
pattern: `var?.x.y` only protects the outer object, so when the
inner field is undefined the access throws.

Touched files:
  - src/app/api/dashboard/summary/route.ts (server-side null guards)
  - src/app/api/health/route.ts (session check)
  - src/components/admin/feedback-inbox-section.tsx
  - src/components/medications/medication-card.tsx (compliance rates)
  - src/components/medications/medication-form.tsx
  - src/components/medications/intake-history-list.tsx
  - src/components/measurements/measurement-list.tsx
  - src/components/mood/mood-list.tsx
  - src/components/ui/password-strength.tsx

Pattern applied: `var?.outer.inner` → `var?.outer?.inner`. Tests
green, typecheck clean. Lint untouched (the rule we already enforce
catches set-state-in-effect, not optional-chain depth).
2026-05-08 18:47:00 +02:00
Marc-André Bombeck 680b0019c1 fix(ui): defensive optional chains across summaries/medications/preferences/entries/measurements
Same anti-pattern as the v1.4.2 hotfix on /dashboard and /insights —
the optional chain protected only the outer object, so any field on
the inner object that didn't exist threw at access time. Marc's
reports flagged dashboard, insights, medications,
notification-preferences, admin login overview, mood list, and
measurement list. Applied 'data?.X?.Y' to every spot the regex
caught.
2026-05-08 18:36:13 +02:00
Marc-André Bombeck cfbd822862 deploy: switch docker-compose back to :latest
Pinning to :1.4.2 was a one-shot safety measure today after the
:1.4.1 GHCR tag never published and we needed Coolify to deploy a
known image. Now that :1.4.2 is verifiably live and the healthcheck
fix is in the image, switch back to :latest so future GHCR builds
auto-roll to production without a docker-compose edit.

Self-hosters who prefer pinning can override via
docker-compose.override.yml — the upstream compose default stays
on :latest as before.
2026-05-08 18:14:12 +02:00
Marc-André Bombeck 7cb9221339 deploy: pin docker-compose to :1.4.2 (production rollout)
The v1.4.2 GHCR image is live on the registry as of 15:59 UTC and
contains all three production hotfixes (summaries.WEIGHT,
medications array guard, healthcheck IPv4) plus the resurrected
v1.4.1 marathon work.

Coolify will pull this on the next deploy and the live site picks
up the fixes in ~30s after the container swap.
2026-05-08 18:13:02 +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 956d29032f fix(deploy): pin to :1.4.0 (the :1.4.1 GHCR tag never published)
The docker-publish workflow's run for the v1.4.1 tag reported success
in GH Actions, but no `1.4.1` tag landed on the ghcr.io registry
(`docker pull ghcr.io/mbombeck/healthlog:1.4.1` returns 'manifest
unknown'). Coolify tries to pull, fails, and never replaces the old
container — so the site stays 503 with no upstream at all.

Repinned to `:1.4.0` which is verifiably present on GHCR (2h old).
Healthcheck fix from the previous commit (127.0.0.1) stays.

The :1.4.1 publish failure is a separate followup — likely related
to the docker-publish workflow not handling the squash-merge release
flow correctly for the tag-on-main scenario. Tracked for v1.4.2.
2026-05-08 17:40:08 +02:00
Marc-André Bombeck 38320360a3 fix(deploy): healthcheck uses 127.0.0.1 + image pinned to :1.4.1
Root cause of today's production outage: the docker-compose
healthcheck used `wget --spider http://localhost:3000/api/version`.
Inside Alpine containers, busybox-wget resolves `localhost` to
IPv6 `::1` first. Next.js standalone (server.js) listens on IPv4
`0.0.0.0:3000` only — `::1` is not bound, so wget gets ECONNREFUSED.
Docker marks the container unhealthy after 3 retries; Traefik takes
the upstream out of rotation; Cloudflare returns 503 "no available
server" because there's no healthy origin behind the route.

Confirmed via SSH on apps-01:
  $ docker exec <container> wget -O- http://localhost:3000/api/version
  Connecting to localhost:3000 ([::1]:3000)
  wget: can't connect to remote host: Connection refused
  $ netstat -tlnp
  tcp  0.0.0.0:3000  LISTEN  1/next-server   (IPv4 only, as expected)

Switching the URL to `http://127.0.0.1:3000/api/version` forces IPv4
and matches the Dockerfile's own HEALTHCHECK directive (which already
used 127.0.0.1). Same fix shape for self-hosters: their `wget` will
do the same thing on Alpine.

This explains why today's deploys looked broken even after rolling
back to v1.4.0 source and even after pinning to :1.3.3. The image
contents were fine; the healthcheck path was always going to fail
on this Alpine + Next.js combo. Why it worked yesterday: the
previous deploy chain happened to skip the new healthcheck override
in the compose file (the Dockerfile-level HEALTHCHECK uses 127.0.0.1
and was authoritative). Today's compose-level override flipped that.

Image pinned back to `:1.4.1` (the multi-arch GHCR build that landed
this afternoon — both `1.4.1` and `1.4.0` are healthy images, the
healthcheck is what was sick).

Co-Authored-By: Marc-André Bombeck <mbombeck@gmail.com>
2026-05-08 17:34:04 +02:00
Marc-André Bombeck a8a32bc718 hotfix: pin docker-compose to GHCR :1.3.3 — rule out runtime, prove infra (#151)
Production at healthlog.bombeck.io has been 503-ing through every
attempted recovery today: Coolify restart, force-rebuild, pin to
:1.4.0, full source-tree rollback to the v1.4.0 commit. Each deploy
finishes clean, the container boots ("Ready in 0ms", pg-boss workers
running), but no HTTP request ever lands inside the app. Other apps
on the same Coolify host also stopped responding to direct-origin
probes (workbalance, birthdaylog, helpdesk, 4goats — all curl 000),
which makes a server-side Traefik / docker-network regression the
most likely culprit, not anything we shipped.

This commit pins to GHCR `:1.3.3` (yesterday's release, verifiably
healthy across self-host installs) and removes the `build:` block so
Coolify pulls the prebuilt multi-arch image instead of attempting
another local rebuild. If this image boots and Traefik routes to
it, the regression is in v1.4.x source. If it 503s identically, the
infra fault on apps-01 is conclusive.

The v1.4.0 / v1.4.1 work is preserved — both the v1.4.0 GHCR image
and the v1.4.1 GHCR image (built earlier today) remain available
for re-pinning once the deployment path is healthy again.
2026-05-08 17:31:52 +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 e33be0d06e hotfix: pin docker-compose to GHCR :1.4.0 to restore production (#150)
Production at healthlog.bombeck.io is returning 503 from Traefik
("no available server") since the v1.4.1 deploys started landing on
apps-01. The container boots — Next.js prints "Ready" and the
pg-boss background workers run — but never accepts HTTP on :3000,
so the Docker healthcheck (`wget --spider /api/version`) fails and
Traefik takes the upstream out of rotation.

Locally the v1.4.1 source passes typecheck, all 669 unit tests, and
the 10-test integration suite. The runtime regression only surfaces
in the Coolify-built image. Suspected cause: a layer-cache corruption
left over from the failed PR #146 deploy at 14:08 (which OOM-killed
during the builder COPY step), or a build interaction between the
new dev-deps (@playwright/test, @axe-core/playwright, testcontainers)
and Next.js standalone bundling. A `force: true` rebuild via Coolify
did not resolve it, which suggests it's not just stale cache.

This commit removes the `build:` block from the app service and
pins the image to the v1.4.0 GHCR tag — the last release verified
healthy on production. Coolify will pull the multi-arch image and
run it directly. Site comes back up immediately.

The v1.4.1 fixes are NOT lost — the source still ships in main, the
GHCR :1.4.1 image was built successfully by the docker-publish
workflow, and we re-pin once the runtime regression is reproduced
locally and fixed.

Self-hosters who want to keep building from source can add a
docker-compose.override.yml with the `build:` block. The compose
override pattern is documented and stable.

No DB migration. No env-var change.
2026-05-08 17:06:42 +02:00
Marc-André Bombeck d18c574e04 chore(release): v1.4.1 — hardening + per-section admin + CI safety nets (#149)
CHANGELOG entry covers the three buckets of change between 1.4.0 and
1.4.1: security hardening from the audit pass (moodLog SSRF closed
+ centralised error redaction), citation-accuracy fixes (ESH 2023
label, Saint-Maurice attribution, steps source), and the CI safety
nets (executable Postgres testcontainers + Playwright/axe-core
foundation), plus the structural per-section admin extraction and
the in-tree docs sync. The deferred items from the audit pass land
in docs/ops/v141-followup-issues.md so they're discoverable for the
v1.4.2 cycle without re-running the audit.

package.json bumped 1.4.0 → 1.4.1.

No DB migration. No env-var change required. No API contract
change.
2026-05-08 16:35:05 +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é Bombeck ea032c8857 docs: sync README, AGENTS, CLAUDE, OpenAPI, migration guide for v1.4 (#147)
The v1.4 marathon and the v1.4.1 cycle that followed (per-section
admin extraction, Postgres testcontainers integration suite,
Playwright + axe-core E2E foundation) landed several user- and
operator-facing changes that hadn't been backfilled into the docs
shipped inside the repo. This commit closes that drift.

README.md
  - Adds Multi-tenant ready and Test connection buttons feature
    bullets.
  - Tech-stack model count 25 → 26 (RefreshToken added in v1.4.0).
  - API reference: new "Public + v1.4 additions" section listing
    /api/version, the five Test connection endpoints, the two refresh
    endpoints, and the two new admin endpoints (status-overview,
    backup/test).

AGENTS.md
  - Status block rewritten to reflect what shipped in 1.4.0 vs
    what's in flight for 1.4.1.
  - File layout: settings monolith replaced by /settings/[section]
    routing, admin/page.tsx now the 77-LOC shell, src/components/
    settings/ and src/components/admin/ added.
  - Removed the stale "Settings Page" gotcha (about non-blocking
    set-state-in-effect lint); the rule is strict now.
  - Database Models 25 → 26.

CLAUDE.md
  - Same model-count and component-folder updates as AGENTS.md.
  - Adds Multi-tenant prep and Native API client patterns under
    Important Patterns (HEALTHLOG_PROCESS_TYPE, ENCRYPTION_KEYS,
    BACKUP_S3_*, refresh-token rotation).
  - Lists tests/integration/ and e2e/ in File Layout.

docs/api/openapi.yaml
  - Adds 11 new endpoints: GET /api/version, the five test endpoints
    (Withings, moodLog, Web Push, Glitchtip, Umami), the two refresh
    endpoints, GET /api/admin/status-overview, POST /api/admin/backup/test.
  - The bulk of the diff is prettier reformatting that the existing
    file had drifted from (single → double quotes throughout YAML);
    no semantic change.

docs/migration/v1.3-to-v1.4.md
  - Corrects the now-wrong claim that v1.4 ships no migrations
    (it ships 0025_refresh_tokens + 0025_user_locale_drift_fix).
  - Adds full env-var sections for the Worker/Web split, Encryption
    key versioning, and Off-host backup target.
  - Documents URL changes (/settings#anchor → /settings/[section]).
  - Replaces the "deferred to a future release" list with a
    landed-in-v1.4 vs landed-in-v1.4.1 status table.
2026-05-08 16:15:30 +02:00
Marc-André Bombeck fdf8dd6eb9 test(e2e): add Playwright smoke suite + axe-core a11y gate + CI (#146)
Builds out the foundation for end-to-end tests against the production
build: Playwright with desktop + mobile projects, axe-core for WCAG
2.1 AA gating, and a GitHub Actions workflow that caches the browser
download and uploads the HTML report on failure.

What lands now (public surfaces only — no DB seed required):

  e2e/version.spec.ts          /api/version returns shape, no auth
  e2e/auth-redirect.spec.ts    proxy gate bounces / → /auth/login etc.
  e2e/login.spec.ts            login form renders + autoComplete hints
                               + invalid-credential error surfaces
  e2e/locale-switch.spec.ts    DE/EN cookie switch, no raw i18n keys
                               leak into the DOM
  e2e/a11y.spec.ts             axe-core fails on serious or critical
                               WCAG 2.1 A/AA violations on /auth/login

The four authenticated flow specs from the v1.4.1 plan
(quick-entry, doctor-report, settings-roundtrip, test-buttons,
onboarding) need a seeded test user; they're tracked as a follow-up
because the seeding pipeline + WebAuthn polyfill they require warrant
their own PR. The smoke specs above cover the proxy, the login surface,
the version healthcheck endpoint, and the locale machinery — i.e.
every path a v1.4.X regression could realistically break without
deeper UAT.

CI workflow (.github/workflows/e2e.yml):
  - postgres:16-alpine service container
  - prisma migrate deploy seeds an empty schema
  - playwright install --with-deps chromium (cached)
  - next build, then `playwright test` against `next start`
  - playwright-report uploaded as an artifact on failure

Quality gates:
  - pnpm typecheck — clean
  - pnpm test — 658/658 pass (vitest excludes e2e/**)
  - pnpm lint — 0 errors
2026-05-08 16:08:02 +02:00
accdb7d44d test(integration): Postgres testcontainers + 4 critical-path tests (rate-limit race, idempotency, cascade, session) (#137)
* test(integration): Postgres testcontainers + 4 critical-path tests

Adds an integration test suite that runs against a real Postgres booted
in-process via testcontainers. Lives under tests/integration/, runs via
`pnpm test:integration`, and is excluded from the default `pnpm test`
loop so the 477-test unit suite stays fast (~1.2s).

Covers four regression-prone behaviours that pure unit tests cannot
validate against a mocked client:

- rate-limit.test.ts — atomic UPSERT under concurrency (5/6 allowed)
  and window-reset semantics for src/lib/rate-limit.ts
- idempotency-replay.test.ts — cached replay with X-Idempotent-Replay,
  expired-row purge, and the do-not-cache contract for 401/403/408/429/5xx
  on top of src/lib/idempotency.ts
- cascade-delete.test.ts — GDPR Art. 17 erasure: user.delete wipes
  every onDelete:Cascade table and SetNulls AuditLog/Feedback
- auth-flow.test.ts — createSession/getSession round-trip plus
  expired-session purge for src/lib/auth/session.ts

Adds .github/workflows/integration.yml triggering on pull_request and
push to main. Local Docker is not running in this worktree environment;
the suite was verified by collecting all 10 tests via `vitest list` and
will execute in CI.

Co-Authored-By: Marc-André Bombeck <mbombeck@gmail.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(integration): rebase onto v1.4 + exclude live agent worktrees from default test scope

Rebase brings PR #137's testcontainers infrastructure on top of the
user-locale migration drift fix landed in #140 — the integration suite
should now bootstrap a fresh Postgres container with all 25 migrations
applied cleanly.

vitest.config.mts also gains a `.claude/worktrees/**` exclude so live
agent worktrees (which mirror `src/` while their parent agents are
running) don't double-execute the project's tests against possibly-stale
copies.

* test(integration): boot one Postgres container per run via vitest globalSetup

The first iteration of the testcontainers suite started/stopped a
fresh container in each test file's beforeAll/afterAll. That works
in isolation but breaks when files share a worker (`pool: forks`,
`fileParallelism: false`, `isolate: false`):

  1. Test file A boots container 1, sets DATABASE_URL.
  2. The application's Prisma singleton in src/lib/db.ts is built
     when test code dynamic-imports @/lib/auth/session — it captures
     container 1's URL.
  3. afterAll stops container 1.
  4. Test file B boots container 2, sets DATABASE_URL to a new port.
  5. Test file B imports application code that resolves to the same
     singleton — still pointing at the dead container 1. Queries fail
     with a generic PrismaClientKnownRequestError.

Fix: move the container lifecycle to vitest's globalSetup. One
container, booted once, torn down at the end of the run. Tests still
get isolation because beforeEach truncates every personal-data table
in dependency-safe CASCADE order.

  - tests/integration/global-setup.ts boots postgres:16-alpine,
    runs `pnpm db:migrate:deploy`, exports a teardown for end-of-run.
  - tests/integration/setup.ts now exports getPrismaClient() (which
    delegates to the application singleton) and truncateAllTables().
    No more startTestDb / stopTestDb.
  - All four test files lose their beforeAll/afterAll boilerplate.
  - vitest.integration.config.mts wires the new globalSetup.
  - truncate list adds refresh_tokens (added in 0025_refresh_tokens).

Result: pnpm test:integration → 10/10 pass in 3.9s (was 6/10 with
4 cross-file failures). pnpm test → 658/658 unchanged.

Co-Authored-By: Marc-André Bombeck <mbombeck@gmail.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 16:03:31 +02:00
Marc-André Bombeck 6da94280b5 feat(admin): extract per-section components from admin page monolith (#145)
The admin page was a single 2,702-line client component with
nineteen inline functions covering everything from system status
to feedback inbox. The status-card grid foundation in #133 already
moved the at-a-glance dashboard out; this commit moves the inner
panels.

Split into one file per logical section in src/components/admin/:

  _shared.tsx                         276 LOC  shared types, hooks,
                                                primitives (PasswordInput,
                                                StatusItem, SettingsToggle,
                                                useAdminSettings,
                                                useUpdateSettings,
                                                useSystemStatus,
                                                getApiErrorMessage)
  system-status-section.tsx           155
  general-settings-section.tsx         54
  services-section.tsx                 68
  umami-section.tsx                   173
  glitchtip-section.tsx               172
  web-push-vapid-section.tsx          148
  bug-report-section.tsx              109
  reminders-section.tsx               318
  user-management-section.tsx         341
  api-token-overview-section.tsx      144
  login-overview-section.tsx          181
  danger-zone-section.tsx             118
  feedback-inbox-section.tsx          511 (inbox + category badge
                                            + detail dialog)

src/app/admin/page.tsx is now a 77-line shell that imports the
14 sections and the existing status-card-grid.tsx. No behavior
change — every section keeps the same DOM, the same query keys,
the same i18n keys, the same id="…" anchors so deep-links from
the sidebar still scroll into place. UserManagementSection no
longer takes a queryClient prop; it grabs its own via
useQueryClient() like every other section does.
2026-05-08 15:58:06 +02:00
Marc-André Bombeck 6bb00556d6 fix(medications): convert API-endpoint dialog status fetch to useQuery (#144)
The dialog's "load endpoint status" effect was triggering a
react-hooks/set-state-in-effect lint error: setLoadingStatus(true)
fired synchronously inside an effect, which the rule flags as a
cascading-render risk.

Convert the imperative useCallback + useEffect pair to a single
useQuery — same fetch shape, but loading/data state now lives in
TanStack Query and the effect goes away. The "Reload status" menu
item calls refetch(); the toggle path uses queryClient.setQueryData
to keep the displayed state in sync after a PUT, so users still see
the new active-token count without a second roundtrip. Errors from
the GET render through the same callout that toggle errors do.

No user-visible change. Restores lint to 0 errors on main.
2026-05-08 15:40:34 +02:00
Marc-André BombeckandMarc-André Bombeck 21bd46d60f chore(release): v1.4.0 — CHANGELOG, version bump, migration guide (#132)
* chore(release): prepare v1.4.0 — CHANGELOG, version bump, migration guide

Bumps `package.json` 1.3.3 → 1.4.0 and the OpenAPI spec to match.
Adds a comprehensive user-facing CHANGELOG entry for v1.4.0 covering
the foundation, medical safety, i18n, performance, chart-math,
dashboard one-row, /api/version, AI insights memory + hero card,
onboarding redesign, and the chart-math fixes that landed across the
v1.4 marathon. Bundles the migration guide
(`docs/migration/v1.3-to-v1.4.md`) covering env vars, behaviour-only
API changes, UI/UX deltas, the medical-default updates, and
rollback steps. Updates AGENTS.md status block.

Tests: 440/440 green. Typecheck clean.

Note: a few additional v1.4 buckets (admin redesign A4, settings split
A2, multi-tenant prep G, test coverage lift H, A8 test buttons) are
landing in parallel branches and may merge after this. The CHANGELOG
will be amended on the release branch as those PRs land.

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

* chore(release): amend v1.4.0 changelog with everything from the finish-marathon

Adds the user-facing bullets for the settings split, the five
"Test connection" buttons, AI inline charts, off-host backup,
encryption-key rotation, worker/web split, refresh-token rotation,
admin status grid, and the operational hardening fixes (tzdata,
healthcheck, idempotency hlr_, paired access-token revoke,
users.locale migration backfill).

Replaces the "tracked for v1.4.x or later" deferred block — the
items it listed have shipped or moved to v1.4.1.

---------

Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
2026-05-08 15:10:38 +02:00
Marc-André Bombeck 65e86e4a53 feat(settings): extract all six remaining sections — content lands at /settings/<slug> (#143)
Replace the SectionPlaceholder fallbacks for Account, AI, Integrations,
Notifications, Dashboard, API, Advanced, and About with real per-section
components. Every /settings/<slug> URL now renders working content; the
placeholder only fires for unknown slugs.

A2-content extraction
- Account: profile + passkeys + password change (account-section.tsx)
- AI: insights + per-user provider override (ai-section.tsx)
- Integrations: Withings + moodLog (integrations-section.tsx)
- Notifications: Telegram + ntfy + Web Push (split into telegram-card,
  ntfy-card, web-push-card to keep each file under ~600 LOC)
- Dashboard: layout + thresholds wrappers (dashboard-section.tsx)
- API: endpoints + tokens (api-section.tsx)
- Advanced: export + danger zone (advanced-section.tsx)
- About (greenfield): version, license, build SHA, repo/changelog/docs
  links + "Check for updates" against GitHub releases

A2-about
- Reads /api/version via TanStack Query, surfaces app version, AGPL-3.0
  badge, short build SHA, build timestamp, repo/changelog/docs links.
- "Check for updates" button compares against api.github.com latest
  release tag with up-to-date / newer-available / failure feedback.

A8-UI test buttons
- Shared TestConnectionButton primitive that POSTs to a test endpoint,
  surfaces ok-with-latency or translated meta.errorCode failures.
- Wired into Withings + moodLog (Integrations) and Web Push
  (Notifications). Glitchtip + Umami remain admin-only.

i18n
- New settings.about.* and settings.testConnection.* key groups in en.json
  and de.json.

Cleanup
- Delete src/app/settings/page.legacy.tsx (3230 LOC monolith).
- Convert sync-from-server patterns to the React-recommended
  prev-payload-id render-time pattern so the strict
  react-hooks/set-state-in-effect rule passes without a file-level disable.

Tests
- New src/components/settings/__tests__/sections.test.tsx with 9 SSR smoke
  tests (one per section) — total suite 514 (was 505).
2026-05-08 14:52:04 +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 6e74612efc test(coverage): real-behaviour tests for audit + webhook routes (~80% line coverage) (#142)
Adds vitest unit tests covering the four zero-coverage webhook /
monitoring surfaces external systems hit. Each test mocks at the module
boundary (per existing project pattern in src/app/api/auth/login/
__tests__/native-token.test.ts) and asserts on actual call arguments
— no test theatre.

Per-file line coverage:
- src/lib/auth/audit.ts                                    100.00%
- src/app/api/withings/webhook/route.ts                     98.11%
- src/app/api/integrations/moodlog/webhook/route.ts         95.23%
- src/app/api/telegram/webhook/route.ts                     95.12%

Test count: +61 (494 -> 555).
2026-05-08 14:30:03 +02:00
Marc-André BombeckandClaude Opus 4.7 382b4a0f9f test(coverage): real-behaviour tests for admin routes + glitchtip ingest (#141)
Adds vitest unit tests for five previously zero-coverage routes:

- /api/admin/users (GET) — admin gate, safe select shape regression, no
  password-hash leak.
- /api/admin/users/[id] (PUT) — Zod validation, 404 for unknown id,
  last-admin demotion guard, audit-log call shape.
- /api/admin/settings (GET / PUT) — singleton row regression, encrypted
  blob never reaches the client, AES encrypt() called for VAPID +
  GitHub-issue tokens, clear flags wipe encrypted columns, every
  schema-allowed field round-trips through upsert.
- /api/admin/status (GET) — integration health summary aggregates umami
  / glitchtip / web-push / bug-report flags, only non-revoked tokens
  count as active, encrypted blobs never appear in the envelope.
- /api/monitoring/glitchtip (POST) — IP rate-limit gate, short-circuit
  when DSN is null, message/stack size caps (2000 / 20 000 chars),
  upstream 5xx mapped to 502, environment fallback to "production".

All five files now land at >=80% line coverage:
- admin/users/route.ts: 100%
- admin/users/[id]/route.ts: 100%
- admin/settings/route.ts: 100%
- admin/status/route.ts: 84.21%
- monitoring/glitchtip/route.ts: 100%

Test count: 469 -> 523 (+54; my files contribute 46, the other 8 were
unblocked by re-running prisma generate in the worktree).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 14:29:48 +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 3221f86eea feat(settings): introduce dynamic-route shell + redirect /settings → /settings/account (#139)
* feat(settings): introduce dynamic-route shell + redirect /settings → /settings/account

Splits the 3218-LOC /settings monolith into a routing surface only — no
section content moves in this PR. Each of the eight slugs (account,
integrations, notifications, dashboard, ai, api, advanced, about) now lives
at its own /settings/[section] route and renders inside a new
<SettingsShell> sidebar+main layout. Every section currently shows a
placeholder ("Moving here with the next release update") so reviewers can
validate the URL structure in isolation. The historic monolith is preserved
verbatim as page.legacy.tsx for section-by-section extraction by the
follow-up PRs (A2-account, A2-about, A2-ai, A2-integrations,
A2-notifications, A2-rest).

Behaviour change: existing entry points (sidebar nav, topbar dropdown,
onboarding hand-off, dashboard checklist, Withings callback, Codex
callback) now point at /settings/<slug> instead of /settings#anchor. The
/settings root is a 308 (permanentRedirect) to /settings/account so old
bookmarks and external links keep working.

Co-Authored-By: Marc-André Bombeck <mbombeck@gmail.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(settings): route every Codex OAuth callback redirect to /settings/integrations

Review-pass H1: only the late success/failure paths in the Codex OAuth
callback were updated to point at the new section route. The four early
branches — rate-limited, provider-supplied `?error=...`, missing params,
invalid state — still redirected to `/settings?codex_error=...`.

After the section split that path 308-redirects to `/settings/account`,
where the Codex error toast/banner doesn't live. Users hitting any of
those edge cases would land on the wrong screen with the error code
silently lost.

All six branches now go to `/settings/integrations?codex_error=...`,
matching the success and token-exchange-failure paths and the existing
Withings callback shape.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 14:15:15 +02:00
Marc-André BombeckandClaude Opus 4.7 ddab92e42b feat(insights): inline charts in AI findings via allowlisted metric tokens (#138)
* feat(insights): inline charts in AI findings via allowlisted metric tokens

Lets the AI insight advisor inline a Recharts panel directly under the
prose paragraph it talks about — e.g. a finding centred on systolic BP
renders the BP chart immediately beneath itself, instead of forcing the
user to scroll back to the dashboard charts.

Tokens are routed through a server-side allowlist:
- 13 fixed strings (one per MeasurementType plus MOOD and COMPLIANCE).
- A drift test fails if a new MeasurementType is added without a token.
- The greedy `[A-Z_]+` regex caps the parse so trailing junk like
  `metric:WEIGHT' onclick='alert(1)'` cleaves cleanly into the safe
  metric and inert React text — there is no markdown / no eval path.
- Hallucinated tokens (`metric:NUKE`) are silently dropped.
- The visible prose is run through `stripChartTokens` so the literal
  token never reaches the DOM.

Prompts (base + 7 domain prompts, EN + DE) tell the model it may embed
exactly one of the allowlisted tokens inside `summary` or a finding's
`label`/`guideline` — nothing else has any effect.

Co-Authored-By: Marc-André Bombeck <mbombeck@gmail.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(insights): drop metric:MOOD and metric:COMPLIANCE until dedicated charts exist

Review-pass M1: those two tokens routed through HealthChart, which
validates `type` against the `MeasurementType` Zod enum. MOOD and
COMPLIANCE aren't in the enum, so the request 422'd and the chart
panel rendered "no data" — silently degrading the most-promoted UX
path under prose that explicitly talked about the user's mood or
adherence.

Allowlist + every prompt now lists only the 11 tokens that actually
render a real chart. The drift test inverts the previous "must
include MOOD/COMPLIANCE" assertion to "must NOT include them",
locking the contract until dedicated MoodChart / ComplianceChart
components ship.

494 tests green, typecheck clean.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 14:09:27 +02:00
Marc-André BombeckandMarc-André Bombeck 4a6be429ca feat(api): test endpoints for Withings, moodLog, Web Push, Glitchtip, Umami (#134)
* feat(api): test endpoints for Withings, moodLog, Web Push, Glitchtip, Umami

Five POST /test endpoints modelled on /api/ai/test (apiHandler +
requireAuth/requireAdmin + checkRateLimit 5/60s + annotate() +
sanitised error mapping):

- /api/integrations/withings/test     — pings wbsapi.withings.net /measure
                                        with the decrypted user access token
- /api/integrations/moodlog/test      — HEAD against the user's decrypted
                                        moodLog URL (5 s timeout, isPublicUrl
                                        SSRF guard)
- /api/notifications/web-push/test    — sends "Push test" via web-push to
                                        every PushSubscription, returns only
                                        endpoint host (never full URL)
- /api/monitoring/glitchtip/test      — admin-only; sends a stub event via
                                        sendGlitchtipEvent() using the saved
                                        AppSettings.glitchtipDsn
- /api/monitoring/umami/test          — admin-only; HTTPS-only GET against
                                        AppSettings.umamiScriptUrl, checks
                                        body for "umami" marker

Each route has a vitest suite covering: happy path, rate-limit denial,
missing config (422), and upstream-401 sanitisation (no sk-/Bearer/full
URL leak).

Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>

* fix(integration-tests): address review findings — singleton key, redirect SSRF, token refresh, scoped push

Multi-agent review caught a regression set on the five integration test
endpoints. Fixes here address every CRITICAL + HIGH finding:

- AppSettings is keyed on `id: "singleton"` everywhere else; the Glitchtip
  and Umami test routes were querying `id: "default"` and would silently
  return 422 in production. Tests now assert the where argument so the
  typo can't sneak back.
- moodLog and Umami fetch with `redirect: "manual"` to neutralise the
  redirect-follow SSRF (a public host could 302 to 169.254.169.254 or
  RFC1918 ranges); 3xx is now treated as a configuration error.
- Withings test goes through the existing `getValidToken()` helper, so an
  expired access token refreshes the same way the real sync does instead
  of reporting "credentials rejected" until the next scheduled sync.
- Glitchtip DSN is validated as HTTPS + public host before any outbound
  call, and the dispatch is wrapped in a 5 s `Promise.race` deadline.
- Withings dispatch picks up an `AbortController` 5 s deadline; the
  Withings API-level `status` field (HTTP 200 + `status !== 0`) is now
  categorised separately from the HTTP status (e.g. `status: 401` →
  credentials_rejected, `status: 601` → rate_limited).
- moodLog test does an authenticated GET against the actual sync
  endpoint instead of a bare HEAD on the configured URL — a HEAD on the
  bare URL would return 200 from a generic landing page even when the
  API key was invalid.
- Web Push test sends to the most recent subscription only (was: every
  registered subscription, so a user with three devices got three buzzes
  per click); tests assert findFirst+orderBy so this can't regress.
- Web Push response shape is the same shape on success and upstream
  failure (errors land in the meta envelope, not in the visible
  response body) — the prior dead-code in the 401 sanitisation test is
  gone.
- Every error response now carries an `errorCode` in `meta` (e.g.
  `credentials_rejected`, `rate_limited`, `dsn_host_not_public`) so the
  upcoming UI can localise via `t("settings.testConnection.errors." + code)`
  instead of rendering the server's English fallback.
- Outbound error annotations strip `Bearer <token>` and full URLs
  before they reach Wide Events / Loki — push providers in particular
  embed the subscription endpoint (which is the routing secret) in
  410/404 error messages.
- Umami response read is capped at 256 KB so a hostile public host
  cannot stream gigabytes through us; the marker check is tightened to
  tracker shapes (`window.umami`, `umami.track`, `"umami"`) so a 404
  page that says "Powered by Umami" no longer false-positives.
- `apiError` now optionally carries a `meta` object so error responses
  can include the `errorCode` without breaking the existing
  `{ data: null, error: <string> }` envelope.

477 tests green.

---------

Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
2026-05-08 13:40:22 +02:00
Marc-André Bombeck b5bd730ec2 test(auth): tighten native-token hash assertions, isolate hmac env, expand coverage scope (#136)
Three small follow-ups to the v1.3.3 audit:

- src/lib/auth/__tests__/hmac.test.ts now stubs API_TOKEN_HMAC_KEY via
  vi.stubEnv per test instead of mutating process.env in beforeAll, so the
  variable is restored cleanly after every case (and worker leaks under
  --no-isolate are gone).
- The two native-token issuance tests (password login + passkey login) now
  mock hashToken as (raw) => "hashed:" + raw and assert the persisted
  tokenHash equals hashToken(plaintext) instead of accepting any string.
  Catches a regression where the route would hash the wrong value.
- vitest.config.mts adopts coverage.include for the full src/ tree (the
  vitest 4 replacement for coverage.all=true) so untouched files surface
  with 0% rather than being silently omitted, and excludes the future
  tests/integration/** suite so unit-test runs stay fast.

No production code touched; suite stays at 454 green.
2026-05-08 13:22:46 +02:00
Marc-André BombeckandMarc-André Bombeck 725cace865 feat(admin): status-first card grid + aggregator endpoint (#133)
Replaces the implicit, fluid status of the legacy admin monolith with a
6-card grid at the top of /admin per ui-guidelines.md §3.4. Each card
pairs a §2.6 colored dot with a text label so state never relies on
color alone (WCAG 1.4.1).

- New aggregator endpoint /api/admin/status-overview returns a single
  batched Promise.all (no N+1) covering Users, Integrations, Monitoring,
  Backups, Maintenance, Audit log — with severity precomputed server-side.
- New <StatusCardGrid> + reusable <StatusBadge> component in
  src/components/admin/. Pure buildCards() function makes severity
  mapping unit-testable.
- Tests: 14 new (8 aggregator severity/shape, 6 grid badge + a11y).
- Wires the grid above the existing admin sections; per-section
  extraction will land in follow-ups.

Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
2026-05-08 12:47:21 +02:00
Marc-André BombeckandMarc-André Bombeck f4a2e13985 feat(v1.4): onboarding redesign — dashboard-first checklist, 3 focused steps, inline trend hints (#131)
Redesigns the first-run experience around three patterns we see in
Linear, Vercel and Notion: a short focused wizard, a persistent
getting-started checklist on the dashboard, and contextual hints
inside still-empty charts.

- Onboarding shrinks from 4 steps (one was marketing-only) to 3:
  About you (display name + locale + height + DOB + gender) → log
  your first measurement (real action via the existing form) → pick
  one notification channel (or skip).
- Every step except the first now has a Back button and exactly one
  Skip link with the honest "you can finish setup later from
  Settings" tail. The legacy duplicate Skip is removed.
- A new dashboard "Getting started" card lists the five quick wins
  (profile, first measurement, medication, Withings, notifications).
  Each row links to the right place. Items can be hidden one-by-one
  or the whole card can be dismissed; both states persist in
  localStorage. The card auto-hides once you finish onboarding and
  log your fifth measurement.
- Charts that have ≥1 but <5 readings now show a subtle inline hint
  ("First trend after 5 readings — N more to go") so the user knows
  the line will appear soon.
- Display name is captured during onboarding and saved to the
  profile.

All new strings are translated to English and German via the
existing i18n pipeline.

Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
2026-05-08 12:42:48 +02:00
Marc-André BombeckandMarc-André Bombeck 14cbc69a2d feat(insights): wire previous-analysis memory into the five remaining generators (#130)
Extends the v1.4 AI insights memory pattern established in PR #129
(general-status, blood-pressure-status) to the rest of the insight
surfaces. Each generator now reads its previous cached narrative via
`getPreviousInsightContext()` and injects the formatted block into the
user prompt so the model can call out improvements / regressions
relative to the user's last analysis instead of restating the status
from scratch every day.

Scopes wired in this commit:
- weight-status
- pulse-status
- bmi-status
- mood-status
- medication-compliance-status

No UI changes are needed — the insights card already renders the
comparison phrases the model emits. No schema change either; the
helper reads from the existing audit-log cache rows each generator
already writes to.

Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
2026-05-08 12:41:24 +02:00
Marc-André BombeckandMarc-André Bombeck 751448494b feat(insights): AI insights remember the previous analysis + hero finding card (#129)
The AI insights surface had two long-standing weaknesses:

1. **Every analysis read like the user's first one.** The general-status
   and blood-pressure prompts saw only the latest 30 days of data, not
   the previous analysis the user already read last week. So the model
   restated the same status every day instead of saying "your BP is down
   4 mmHg from your last check" or "same elevated trend as last week".

2. **All findings looked the same.** The card listed every finding in a
   uniform compact row. The model is instructed to put the most
   clinically relevant finding first, but the UI didn't honour that
   ordering with any visual weight.

This PR closes both for the two highest-impact insight surfaces (general
status and blood pressure). Same pattern lifts to the other five
generators in a follow-up.

**`src/lib/insights/memory.ts`** — new helper that reads the user's
previous cached insight from the existing `audit_logs` cache rows
(scope-keyed `insights.{scope}.{locale}`), returns it as a typed
`PreviousInsightContext`, and formats it into a locale-aware
prompt-injection block. Default 12-hour age floor keeps the model from
treating today's earlier run as "previous". The block ends with an
explicit comparison instruction in the user's locale; passing `null`
context produces a "first analysis — no delta to surface" instruction
so the model doesn't hallucinate one.

**Wired into `general-status` and `blood-pressure-status` generators.**
Both prompts now accept an optional `previousContextBlock` argument.
When supplied, the model gets the previous narrative + the comparison
instruction inline above the snapshot. No schema change — caches keep
writing to the same audit-log rows the helper reads from. Empty for
first-run users.

**Hero finding card.** Top finding gets a severity-tinted gradient
(green/cyan/orange/red), 2-px coloured left border, larger typography,
and a descriptive eyebrow label ("Top finding" / "Worth attention" /
"Top alert"). Secondary findings keep the previous compact layout so
the visual hierarchy reads at a glance. Eyebrow labels translated
under `insights.heroFinding{Positive,Neutral,Attention,Warning}`.

Tests: 400 → 410 (+10 for the memory helper covering JSON cache,
legacy plaintext fallback, age-floor semantics, locale-aware
formatting).

Follow-up PRs:

- Wire the same memory helper into the remaining five generators
  (weight, pulse, BMI, mood, medication-compliance). Pattern is
  mechanical — the prompt signatures already accept the new optional
  argument.
- Dynamic chart inclusion via an allowlist token map
  (`metric:BLOOD_PRESSURE_SYS` → `<HealthChart>` inline). Out of scope
  for this PR.

Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
2026-05-08 12:30:31 +02:00
Marc-André BombeckandMarc-André Bombeck f131d6be2c feat(api): public /api/version endpoint for the v1.4 About surface (#128)
Adds a public GET endpoint that exposes the running build's version,
optional Git SHA, optional ISO build timestamp, license, and
canonical links (repository, changelog, docs site). Three consumers
already lined up against this:

- The Settings → About section (v1.4 plan A3) renders version + build
  metadata + a "Check for updates" button.
- The footer of every page can show the version dezent without
  another roundtrip.
- The "Check for updates" button compares the returned version
  against the GHCR API.

Build-time env vars (`NEXT_PUBLIC_APP_BUILD_SHA`,
`NEXT_PUBLIC_APP_BUILT_AT`) are read straight off `process.env` —
the existing `docker-publish.yml` workflow can wire them once it
needs to. Local `pnpm dev` returns `null` for both fields and the UI
falls back to "development" wording.

Static export (`force-static`) so the endpoint adds zero load on the
DB and survives in the static-cache page chunks.

Tests: 395 → 400 (+3 for the version envelope shape and env-var
plumbing). The remaining About UI lands in a follow-up so this PR
stays scoped to the API contract.

Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
2026-05-08 12:17:40 +02:00
Marc-André BombeckandMarc-André Bombeck 760b2c9ba0 feat(dashboard): enforce one-row tile strip with horizontal-scroll overflow (#127)
The dashboard tile grid (`grid-cols-2 sm:grid-cols-2 lg:grid-cols-4
xl:grid-cols-5`) wrapped to a second row whenever the user enabled
more than four or five widgets. Marc's UX vision and the
`feedback_dashboard_one_row.md` memory are explicit: the tile strip
must read as a single status bar above the charts, with a total width
that caps at the chart width below — never two rows that look like an
Excel grid.

Replaces the wrapping grid with a `flex snap-x snap-mandatory
overflow-x-auto` strip:

- One row, always.
- Each tile keeps a sensible minimum width (`basis-[10rem]`) so a
  single tile still looks substantial on a wide viewport.
- `grow` makes the row fill its parent when fewer tiles are active —
  the visual hierarchy stays the same regardless of how many widgets
  are toggled on.
- When the active tile count exceeds the viewport, the strip
  horizontal-scrolls (with snap points so it feels deliberate on
  touch); the user's path to "fewer tiles" is the existing
  Settings → Dashboard Layout editor at `/settings#dashboard-layout`.
  No automatic wrap.
- `data-tile-count` attribute on the strip so a future
  Playwright screenshot test can assert the one-row constraint
  visually.

Tests stay at 395/395; this is a layout-only change.

A follow-up will surface a banner inside the layout editor that
visualises "this many tiles overflows the strip" — out of scope for
this PR per the v1.4 plan A5 split.

Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
2026-05-08 12:17:35 +02:00
Marc-André BombeckandMarc-André Bombeck 03845ef5eb fix(analytics): correct chart-math edge cases caught by the v1.4 audit (#126)
Three correctness issues in `summarize()` / `trendSlope()` /
`weeklyAverages()` that the discovery audit reproduced:

1. **`summarize` and `trendSlope` used different time anchors.** The
   averages (`avg7`, `avg30`) snapped to `Date.now()`, while the slopes
   (`slope7`, `slope30`, `slope90`) snapped to the latest point in the
   series. A stale series (no readings for weeks) reported a slope from
   old data even though the dashboard tile correctly hid the average.
   Both surfaces now use `Date.now()` as the cutoff, so a stale series
   returns `null` from every windowed stat consistently.

2. **`summarize([])` returned `{ min: 0, max: 0, mean: 0 }`.** Those
   sentinels leaked into chart axes (Y-axis collapsed onto zero) and
   into AI-prompt feature bundles where the generator treated the zero
   as a real reading. Empty input now returns `null` for all three
   fields. The `DataSummary` type widens accordingly; the dashboard
   already null-checks the optional fields, so no UI regression.

3. **`weeklyAverages()` used system-local `Date.getDay()` /
   `getDate()`.** On a UTC server (which is what the production
   container runs as) a Sunday-evening Berlin reading bucketed into
   the next week. Now the ISO-Monday key is computed via
   `Intl.DateTimeFormat({ timeZone: "Europe/Berlin" })`, so the bucket
   lines up with how the user perceives "this week".

`pairByTimestamp` keeps the greedy nearest-match heuristic — adding
JSDoc that documents the limitation and the conditions under which a
Hungarian-style match would matter (sparse health data is well below
that bar).

Tests: 371 → 384 (+13). Two new direct regression tests:

- `weeklyAverages` Sunday-evening-Berlin bucket goes to the *previous*
  Monday.
- `summarize` with all readings 100 days old returns `null` for every
  windowed stat (no false trend on stale data).

Plus tightened the empty-summarize assertions.

Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
2026-05-08 12:14:26 +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 a6524721da fix(i18n): localise medication reminders and dashboard streak per user (#124)
Closes the v3 audit "Locale Mixing" CRIT plus three of the cousin
findings the v1.4 discovery audit added:

1. **`getPhaseMessage` and `getPhaseKeyboard` were German-only.** Every
   medication reminder shipped via Telegram, ntfy, and Web Push read
   "Erinnerung", "Bald fällig", etc. regardless of the recipient's
   locale. This is the user-facing surface most affected by the v3
   "Locale Mixing" finding because the strings hit users on a 15-minute
   cron, not just an admin / settings page they might never open. The
   reminder worker now passes `med.user.locale` through; messages and
   button labels resolve from `messages/{de,en}.json` under
   `medicationReminders.*`. `callback_data` keys stay stable English
   identifiers so the bot dispatcher keeps matching them across locale
   changes.

2. **`/api/dashboard/summary` ships a hard-coded "Tage in Folge" label
   and "Hi, X" salutation.** Both now come from
   `dashboard.streakLabel` / `dashboard.greetingSalutation` resolved
   server-side against the user's locale.

3. **Mixed-locale Zod messages.** `validations/measurement.ts:69` had
   "Wert muss zwischen … liegen" while line 86 had "Value out of
   plausible range" — the response envelope leaked German into English
   sessions and vice versa. `validations/admin.ts` had four German
   messages (web-push subject, Umami URL, Glitchtip DSN, GitHub
   bug-report repo). All now resolve to a single English wording (the
   app is English-first; the German UI translates the field labels
   client-side, the server-side validation message is treated as a
   developer-facing string).

Adds 13 new tests in `reminder-phases.test.ts` covering English,
German, and unknown-locale fallbacks for messages and keyboards;
updates `validations/measurement.test.ts` to match the unified English
message. The audit's remaining items
(`classifications.ts` server-returned EN category strings,
`pulse-targets.ts`, `targets/route.ts` labels,
`/api/admin/notifications/reminder-check`) ride a follow-up i18n PR —
those are admin-only or client-renderable surfaces with lower
user-facing priority.

Tests: 358 → 371 (+13). Typecheck clean. Locale-integrity test still
green.

Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
2026-05-08 12:11:44 +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é BombeckandMarc-André Bombeck 359d4d19cc v1.4 foundation: UI guidelines, primitives, AI test fix, autofill hardening (#122)
* docs(ui): add UI guidelines as single source of truth + shadcn MCP server

- `docs/ui-guidelines.md` lays out Dracula tokens, spacing, typography,
  component picks (button/input/dialog/sheet decision tree), layout
  rules (settings split target, dashboard one-row), accessibility
  baseline (WCAG 2.1 AA + axe-core in CI), the autofill / honeypot
  pattern for measurement forms, and a reusable "Test button" UX for
  every integration. New components in v1.4 reference this doc.

- `.mcp.json` wires the official `shadcn` MCP server so the design
  system can be browsed and components installed without leaving the
  editor.

- Adds `@vitest/coverage-v8` to dev dependencies — `pnpm test
  --coverage` now reports real per-file coverage (the v1.4 cycle uses
  this to lift critical-path coverage).

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

* fix(settings/ai): connection-test honours the unsaved provider selection

Changing the AI provider in /settings, then clicking "Verbindung testen"
without saving first, used to silently run the test against the *saved*
provider instead — so the result felt unrelated to what the user had on
screen. This often surfaced as a confusing OK / failure depending on
whichever provider had previously been persisted.

The test endpoint now accepts an optional JSON body that mirrors the
form fields (provider, model, baseUrl, anthropicKey, localKey). When
present, the server resolves a transient provider from those values —
falling back to the persisted, encrypted credentials per field, so a
user with a stored Anthropic key can swap models in the dropdown
without re-typing the key. Plaintext keys never persist; the existing
SSRF guard for the Local provider's base URL is reused, the existing
rate limit (5/min) and the V3 error-leak shielding stay in place.

Empty body keeps the previous behaviour for any other caller.

Tests cover the new override path (provider forwarded, unknown
provider rejected with 422, empty body uses persisted config, config
errors surface the resolver's status). Whitespace in
`src/app/settings/page.tsx` is reformatted by Prettier as a
side-effect; logic touches only the AI section.

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

* feat(ui): add Skeleton and EmptyState primitives

Two pieces every page in v1.4 needs and the codebase didn't have:

- `<Skeleton>` — a presentation `<div>` with the standard pulse
  animation, `aria-hidden`, and `motion-reduce:animate-none`. Used to
  shape the page while data loads instead of dropping a spinner. The
  visible layout no longer jumps when the first measurement arrives.
- `<EmptyState>` — the shape every list, tile, and chart panel
  reaches for when it has no data: optional icon, title, description,
  primary action; `card` (default) and `plain` variants;
  `default`/`compact` density. Lives inside an `aria-live="polite"`
  region so screen readers announce the message and its action.

Tests cover screen-reader semantics, prettier-merged class names,
density/variant switches.

These two land first because the v1.4 pages — the split settings
sections, the new dashboard hero, the redesigned admin status grid —
all assume the primitives exist. The patterns are documented in
`docs/ui-guidelines.md` §4.1–4.2.

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

* fix(ui): block password-manager autofill on health-data inputs by default

Browsers and password managers were happily autofilling the user's
account credentials into measurement, medication, mood, and AI-token
fields whenever those forms didn't explicitly set `autoComplete`. The
codebase had zero `autoComplete` attributes across the data-entry
surfaces, so this happened often enough to be both a UX papercut
(account password landing in a "Notiz" field) and a real privacy issue
(secrets persisting through CSV/JSON export).

The base `<Input>` primitive now:

- Defaults `autoComplete` to `"off"` when the caller doesn't supply a
  value.
- Adds `data-lpignore="true"` and `data-1p-ignore="true"` whenever
  autoComplete resolves to `"off"`, so LastPass and 1Password skip
  the field too.
- Keeps the password manager active for any form that already passes a
  semantic `autoComplete` (`"username"`, `"email"`,
  `"current-password"`, `"new-password"`) — the existing login,
  register, and AI-key inputs continue to work unchanged.

Tests cover the three meaningful states (default off, explicit
semantic value, login-flow `username`).

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

---------

Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
2026-05-08 12:05:15 +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 b76d579717 feat(jobs): daily cleanup job for expired idempotency_keys (#120)
`withIdempotency()` only purges rows lazily on the next lookup with
the same `(userId, key, method, path)` tuple — most rows never see a
retry, so without a sweeper the table grows unbounded with stale
24h-expired entries (Security-Review HIGH-2 from PR #117).

Adds a pg-boss queue that runs daily at 03:00 Europe/Berlin (parallel
to the weekly Sunday data-backup job) and bulk-deletes expired rows.
Outcome surfaces as a Wide Event annotation
`idempotency_cleanup_deleted` so the run is queryable in Loki.

Helper extracted to `src/lib/jobs/idempotency-cleanup.ts` for clean
unit testing without mocking pg-boss internals.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:30:30 +02:00
Marc-André BombeckandClaude Opus 4.7 79f0e5a06f docs(claude.md): add Headless-Client API Patterns section (#119)
Document the four headless/iOS patterns that landed in PR #117 so future
agents have them at a glance:
- Native-client detection (X-Client-Type or HealthLog-iOS UA)
- API-token storage via HMAC-SHA-256 keyed by API_TOKEN_HMAC_KEY
- Idempotency-Key replay rules (TTL, status filter, hlk_ defence)
- Device-registration cross-user-hijack guard (409 not silent transfer)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:30:25 +02:00
Marc-André BombeckandClaude Opus 4.7 a8a7c19d0b chore(idempotency): default userIdResolver to cookie session (#118)
Drop the `resolveUserIdForIdempotency` helper that was duplicated across
three PWA write routes. `withIdempotency` now reads the userId from
`getSession()` by default; routes that authenticate via Bearer token can
still pass a custom resolver.

Net: −15 LOC across the route layer, +2 test cases for the new default.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:30:21 +02:00
Marc-André Bombeck aac2e18668 Merge pull request #117 from MBombeck/feat/api-headless-clients
feat: headless-client API surface (Bearer auth, idempotency, aggregator endpoints, PDF report)
2026-05-03 23:00:31 +02:00
Marc-André BombeckandClaude Opus 4.7 6e2f9185a8 chore(ci): allowlist test fixtures + OpenAPI examples in gitleaks scan
Gitleaks 8.x flags the literal string "idempotency-key" (header name)
and any quoted "token: …" inside test fixtures as generic-api-keys.
Both are non-secrets — header name + opaque test-only UUIDs / placeholder
"hlk_example_…" tokens used in route tests and the OpenAPI spec.

Adds a `.gitleaks.toml` extending the default ruleset with:
  - path allowlist for `**/__tests__/**` and `*.test.ts`
  - path allowlist for `docs/api/openapi.yaml`
  - substring allowlist for `idempotency-key` and `hlk_example`

No real secret bypass: the existing `hlk_<64-hex>` regex still triggers
on any non-`hlk_example` prefix, so a real token leak would still fail
the scan.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 22:48:17 +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 75b52b1f30 chore(api): add OpenAPI 3.1 spec
Generates `docs/api/openapi.yaml` covering the full REST surface — 100+
paths across 24 tags, 130+ schemas under `components.schemas`. Two
security schemes: `sessionCookie` (HttpOnly `healthlog_session`) plus
`bearerAuth` (HTTP Bearer for the API tokens at `/api/tokens`). Admin
routes are explicitly tagged + restricted to `sessionCookie`.

`docs/api/README.md` documents how to preview the spec locally
(`npx @redocly/cli preview docs/api/openapi.yaml`) and lists the few
known lint warnings (localhost server, redirect endpoints without 2xx).
Lint pass: 0 errors, 17 warnings (none introduced by this commit).

Spec is hand-maintained for now; a future commit can wire it into a
`pnpm openapi:lint` CI step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:53:21 +02:00
Marc-André BombeckandClaude Opus 4.7 83251848e4 feat(achievements): optional ?format=ios with translated copy
Adds an opt-in `?format=ios` query parameter to
`GET /api/gamification/achievements`. When set, the response collapses
the existing `{ achievements, summary }` envelope into a flat
`Achievement[]` with translated `title`/`description` (server-side `t()`
honouring `Accept-Language` + `user.locale`) and `progress` normalised
to 0..1.

Default response (no query param) is unchanged. Existing web client
keeps receiving the wrapped `{ achievements, summary }` plus i18n keys
exactly as before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:53:21 +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é BombeckandClaude Opus 4.7 98e209b6f7 refactor(auth/profile): extract validation/persistence into shared helper
`PUT /api/auth/profile` now delegates to `applyProfileUpdate()` in
`src/lib/auth/profile-update.ts`. Validation, email-collision check,
update, audit-log — all unchanged, just lifted out of the route handler
so a second consumer can reuse it without forking the path.

Adds `GET/PATCH /api/user/profile` as that second consumer, exposing the
same shape under a slightly different verb that better suits headless
clients (PATCH partial updates, GET reads). Both endpoints touch the
same `applyProfileUpdate` so any future field stays in sync.

Existing PUT response shape is unchanged; the existing PUT consumer
(web settings page) is unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:53:11 +02:00
Marc-André BombeckandClaude Opus 4.7 f83a68d78e feat(auth): optional Bearer token issuance on login for headless clients
When a request to `/api/auth/login` or `/api/auth/passkey/login-verify`
carries `X-Client-Type: native` (or a `User-Agent` matching `HealthLog-iOS`),
the response now also contains a 90-day `hlk_*` Bearer token alongside
the user object. Browser sessions continue to receive the cookie only —
no extra payload, no behaviour change for them.

Token issuance is gated by header detection in `src/lib/auth/issue-token.ts`
and re-uses the existing `ApiToken` table + HMAC pipeline; nothing new on
the storage side. Audit log records `auth.token.autoissue.native` so each
auto-issued token is traceable to its login event.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:53:07 +02:00
Marc-André BombeckandClaude Opus 4.7 9766cedea4 feat(api): generic Idempotency-Key support for safe POST retries
Introduces `withIdempotency()` in `src/lib/idempotency.ts` — wraps a
mutation handler so that a POST/PUT/PATCH/DELETE carrying an
`Idempotency-Key` header is replayed from the cached response instead of
running twice. Cache row is keyed by `(userId, key, method, path)` with
24h TTL; replays carry `X-Idempotent-Replay: true` so callers can
distinguish them.

Applied to the three mutation endpoints most exposed to client retries:
  • POST `/api/measurements`
  • POST `/api/mood-entries`
  • POST `/api/medications/[id]/intake`

Without the header: zero behaviour change. Existing clients that don't
send the header simply skip the cache lookup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:53:02 +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é BombeckandClaude Opus 4.7 7479211531 feat(doctor-report): server-rendered PDF endpoint
Adds `POST /api/doctor-report/pdf` that returns a fully rendered PDF
(`Content-Type: application/pdf`) using the same jsPDF document the web
client builds locally, just executed server-side. Locale resolves from
explicit body param → `Accept-Language` → `de` fallback.

The existing `POST /api/doctor-report` JSON endpoint is unchanged; both
endpoints share `collectDoctorReportData()` so any future change to the
aggregation lands in one place. The render core (`doctor-report-pdf-core`)
is isomorphic — same code paths run in browser jsPDF and Node jsPDF.

Rate-limit (10/h per user) is shared between the two endpoints so the
quota cannot be doubled by mixing JSON and PDF requests.

Tests: 6 core tests (PDF header bytes, size, determinism, DE+EN, empty
data, jsPDF doc shape) + 5 route tests (401, 429, 200 happy-path with
content-type/disposition, default `days=90`, Accept-Language fallback).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:52:43 +02:00
Marc-André BombeckandClaude Opus 4.7 54f689247e feat(auth): accept Bearer tokens for headless API clients
`requireAuth()` keeps the existing cookie-session path as the primary check;
when no session cookie is present it now falls back to validating
`Authorization: Bearer hlk_*` against the existing `ApiToken` table
(HMAC-SHA-256 hash, expiry + revocation enforced).

Behaviour for browser clients is unchanged — the cookie path runs first
and short-circuits before the header lookup. `requireAdmin()` stays
cookie-only by design.

Wide-Event annotation tags `auth_method: "bearer"` so the source is
distinguishable in Loki. `auth.bearer.success` and `auth.bearer.failure`
audit entries record the token id + reason.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:51:54 +02:00
dependabot[bot] 7507c29c4c build(deps-dev): bump shadcn from 4.5.0 to 4.6.0 (#115)
Bumps [shadcn](https://github.com/shadcn-ui/ui/tree/HEAD/packages/shadcn) from 4.5.0 to 4.6.0.
- [Release notes](https://github.com/shadcn-ui/ui/releases)
- [Changelog](https://github.com/shadcn-ui/ui/blob/main/packages/shadcn/CHANGELOG.md)
- [Commits](https://github.com/shadcn-ui/ui/commits/shadcn@4.6.0/packages/shadcn)

---
updated-dependencies:
- dependency-name: shadcn
  dependency-version: 4.6.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-03 15:58:53 +00:00
dependabot[bot] d5f8b8ae5e build(deps): bump pg-boss from 12.18.1 to 12.18.2 (#116)
Bumps [pg-boss](https://github.com/timgit/pg-boss) from 12.18.1 to 12.18.2.
- [Release notes](https://github.com/timgit/pg-boss/releases)
- [Commits](https://github.com/timgit/pg-boss/compare/12.18.1...12.18.2)

---
updated-dependencies:
- dependency-name: pg-boss
  dependency-version: 12.18.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-03 15:57:21 +00:00
dependabot[bot] 6ac17a5420 build(deps-dev): bump prettier-plugin-tailwindcss from 0.7.3 to 0.8.0 (#114)
Bumps [prettier-plugin-tailwindcss](https://github.com/tailwindlabs/prettier-plugin-tailwindcss) from 0.7.3 to 0.8.0.
- [Release notes](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.7.3...v0.8.0)

---
updated-dependencies:
- dependency-name: prettier-plugin-tailwindcss
  dependency-version: 0.8.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-03 15:56:25 +00:00
dependabot[bot] f349b01d9d build(deps): bump zod from 4.3.6 to 4.4.2 (#113)
Bumps [zod](https://github.com/colinhacks/zod) from 4.3.6 to 4.4.2.
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](https://github.com/colinhacks/zod/compare/v4.3.6...v4.4.2)

---
updated-dependencies:
- dependency-name: zod
  dependency-version: 4.4.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-03 15:55:44 +00:00
dependabot[bot] bed8313643 build(deps): bump lucide-react from 1.11.0 to 1.14.0 (#112)
Bumps [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) from 1.11.0 to 1.14.0.
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.14.0/packages/lucide-react)

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-version: 1.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-03 15:55:27 +00:00
dependabot[bot] d3b4550b9b build(deps): bump @tanstack/react-query from 5.100.5 to 5.100.9 (#111)
Bumps [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) from 5.100.5 to 5.100.9.
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.100.9/packages/react-query)

---
updated-dependencies:
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.100.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-03 15:54:28 +00:00
dependabot[bot] 3c3ecf89cc build(deps): bump react-hook-form from 7.74.0 to 7.75.0 (#110)
Bumps [react-hook-form](https://github.com/react-hook-form/react-hook-form) from 7.74.0 to 7.75.0.
- [Release notes](https://github.com/react-hook-form/react-hook-form/releases)
- [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md)
- [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.74.0...v7.75.0)

---
updated-dependencies:
- dependency-name: react-hook-form
  dependency-version: 7.75.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-03 15:53:43 +00:00
Marc-André BombeckandClaude Opus 4.7 4a205f5348 chore(repo): untrack docs/superpowers + ignore AI tool artefacts
Past Claude / Superpowers session output (plans + design specs) was
landing in the public repo. Files stay on disk locally but are no
longer tracked. .gitignore extended to cover the usual AI-tooling
scratch directories (.claude/, .cursor/, .codex/, .aider*) so future
sessions don't sneak anything in either.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 22:30:33 +02:00
Marc-André BombeckandClaude Opus 4.7 0783261b44 docs: sync README/AGENTS/CONTRIBUTING/CLAUDE/SECURITY to v1.3.2 reality
Full audit of every overlapping claim across the five top-level docs
against the v1.3.2 codebase. Fixes structural drift accumulated since
v1.2:

- README: API envelope was documented as `{ data, error, meta }` but the
  actual helpers return `{ data, error }`. Architecture tree was missing
  6 src/app/, 4 components/, 8 lib/ subdirectories that landed in
  1.2/1.3 (auth, mood, charts, targets, notifications, bugreport,
  gamification, settings, ai, feedback, insights, logging, moodlog,
  monitoring, …). API Reference grew 4 new sections (Personalization,
  Feedback + Tokens, Notifications, Admin) covering ~25 previously
  undocumented endpoints. Route count `~60` → `100+`. Achievement count
  `30+` → `38+`. i18n keys `1000+` → `1500+` with note about the new
  integrity test.

- AGENTS.md: Status `v1.2.0` → `v1.3.2` with the body-composition / SSRF
  / GHCR / pg-boss-drain / blocking-CI / locale-integrity highlights.
  Prisma `7.4` → `7.8`. Migrations `0001–0018` → `0001–0022`. Settings
  page `13 sections / ~2100 lines` → `8 top-level sections / ~3150
  lines` with the actual section IDs. Insights page line estimate
  trimmed to `~1750`, section ID `medications` → `meds`.
  NotificationChannel / EventType correctly described as `String`
  fields (not Prisma enums) sourced from
  `src/lib/notifications/types.ts`.

- CONTRIBUTING.md: UI-language statement was inverted ("UI text in
  German" → English-default with German user-selectable, plus the
  i18n integrity test). Dark-mode-only claim corrected — light/system
  toggle exists in the topbar. Node `22+` → `20+` to match the README.
  openssl recipe split: POSTGRES_PASSWORD uses base64 24, the three
  hex secrets use hex 32. CI check list expanded to the 5-step
  pipeline (typecheck, lint, test, format:check, build).

- CLAUDE.md: vitest config path `vitest.config.ts` → `vitest.config.mts`.
  Bearer-token hashing labelled `HMAC-SHA256` (keyed by
  `API_TOKEN_HMAC_KEY`) instead of plain SHA-256. Removed the
  reference to a non-existent `docs/STATUS.md` and the overstated
  `docs/{architecture,security,ops,API,ADRs}` directory list — the
  long-form end-user docs live at docs.healthlog.dev, only audit notes
  are local.

- SECURITY.md: pin example bumped from `:1.2.0` to `:1.3.2`.

Verified against code: 23 Prisma models, all listed endpoints exist,
all package.json versions match, every CHANGELOG v1.3.x claim
(SSRF guard, Withings 77/88 mapping, pg-boss SIGTERM drain, WCAG
44×44 touch targets, rate-limited threshold writes, N+1 fix in
/api/insights/comprehensive) traced to the actual implementation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 22:28:53 +02:00
395 changed files with 44428 additions and 12783 deletions

No files matched your search

+38
View File
@@ -104,3 +104,41 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
# LOKI_ENDPOINT="http://loki:3100"
# LOKI_USERNAME=""
# LOKI_PASSWORD=""
# -----------------------------------------------------------------------------
# Process split (optional, single-container deploys ignore this)
# -----------------------------------------------------------------------------
# `all` — single container runs HTTP + reminder worker (default, legacy).
# `web` — HTTP only; pair with a separate `app-worker` service.
# `worker`— pg-boss only; rejects HTTP traffic with a 503.
# See docs/self-hosting/scaling.md for the split-stack guide.
# HEALTHLOG_PROCESS_TYPE="all"
# -----------------------------------------------------------------------------
# Encryption-key versioning (optional — for at-rest key rotation)
# -----------------------------------------------------------------------------
# JSON map of key id → 64-hex-char or base64 32-byte key. When set, it
# supersedes ENCRYPTION_KEY for new writes; old rows continue to decrypt under
# their original id. ENCRYPTION_ACTIVE_KEY_ID picks which entry encrypt() uses.
# Run `pnpm tsx scripts/rotate-encryption-key.ts <new-id>` to re-encrypt every
# existing row under the new key. See docs/ops/encryption-key-rotation.md.
# ENCRYPTION_KEYS='{"v1":"<old-key>","v2":"<new-key>"}'
# ENCRYPTION_ACTIVE_KEY_ID="v2"
# -----------------------------------------------------------------------------
# Off-host backup (optional — daily encrypted dumps to an S3-compatible bucket)
# -----------------------------------------------------------------------------
# Required ALL-OR-NONE: setting any of these without setting the rest disables
# the backup silently. The backup runs at 02:30 Europe/Berlin via pg-boss cron.
# Restore with `pnpm tsx scripts/restore-backup.ts <userId> <YYYY-MM-DD>`.
# See docs/ops/backup-restore.md for the format spec + step-by-step.
# BACKUP_S3_ENDPOINT="https://<account>.r2.cloudflarestorage.com"
# BACKUP_S3_BUCKET="healthlog-backups"
# BACKUP_S3_ACCESS_KEY=""
# BACKUP_S3_SECRET_KEY=""
# BACKUP_S3_REGION="auto" # "auto" for Cloudflare R2; real region for AWS S3
# BACKUP_RETENTION_DAYS="30" # objects older than this are pruned (best-effort; prefer a bucket lifecycle rule)
# BACKUP_ENCRYPTION_KEY="" # 64-hex-char or base64 32-byte; SEPARATE from ENCRYPTION_KEY
+81
View File
@@ -0,0 +1,81 @@
name: e2e
on:
pull_request:
branches: [main]
push:
branches: [main]
# Cancel in-progress runs when a new commit lands on the same PR.
concurrency:
group: e2e-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 20
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: healthlog
POSTGRES_PASSWORD: healthlog
POSTGRES_DB: healthlog
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U healthlog"
--health-interval 5s
--health-timeout 5s
--health-retries 10
env:
DATABASE_URL: postgresql://healthlog:healthlog@localhost:5432/healthlog?schema=public
ENCRYPTION_KEYS: '{"v1":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}'
ENCRYPTION_ACTIVE_KEY_ID: v1
API_TOKEN_HMAC_KEY: ci-hmac-key-32-bytes-min-padding-x
AUTH_RP_NAME: HealthLog
AUTH_RP_ID: localhost
AUTH_RP_ORIGIN: http://localhost:3000
NODE_ENV: production
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm db:generate
- run: pnpm db:migrate:deploy
- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
playwright-${{ runner.os }}-
- run: pnpm exec playwright install --with-deps chromium
- run: pnpm exec next build
- run: pnpm exec playwright test
env:
E2E_BASE_URL: http://localhost:3000
- name: Upload Playwright HTML report on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 7
+34
View File
@@ -0,0 +1,34 @@
name: Integration tests
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
integration:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm db:generate
- run: pnpm test:integration
env:
# The integration suite boots a Postgres testcontainer and points
# Prisma at it via DATABASE_URL set inside `tests/integration/setup.ts`.
# These env vars satisfy code paths that read them at module load.
API_TOKEN_HMAC_KEY: integration-tests-do-not-use-in-prod
ENCRYPTION_KEY: 0000000000000000000000000000000000000000000000000000000000000000
SESSION_SECRET: integration-tests
+12
View File
@@ -11,6 +11,11 @@
# testing
/coverage
# playwright e2e
/test-results
/playwright-report
/playwright/.cache
# next.js
/.next/
/out/
@@ -39,3 +44,10 @@ next-env.d.ts
# prisma generated
/src/generated/prisma
# AI session artefacts (kept locally, not committed)
/docs/superpowers/
.claude/
.cursor/
.codex/
.aider*
+30
View File
@@ -0,0 +1,30 @@
# Gitleaks configuration. Extends the built-in default ruleset and adds
# a project-specific allowlist for false-positives that gitleaks 8.x flags
# as "generic-api-key" inside test fixtures.
[extend]
useDefault = true
[allowlist]
description = "Project-wide ignores for test fixtures and known non-secret tokens"
paths = [
# All test files contain hardcoded fake tokens / idempotency-keys.
'''(^|/)__tests__/''',
'''\.test\.ts$''',
# OpenAPI spec contains example tokens (`hlk_example…`) used to document the schema.
'''^docs/api/openapi\.yaml$''',
]
# Substring matches — anything matching is treated as non-secret regardless of file.
regexes = [
# Idempotency-Key request-header values are random UUIDs / opaque strings,
# not secrets. The header name itself just trips the generic-api-key rule.
'''idempotency-key''',
# Allow the placeholder prefix used in OpenAPI examples + docs.
'''hlk_example''',
]
# Specific commit fingerprints from prior false-positive findings (kept for reproducibility).
# Empty by default; populate via `gitleaks detect --report-path` if a future regression
# triggers the same false-positive on a stable commit hash.
stopwords = []
+8
View File
@@ -0,0 +1,8 @@
{
"mcpServers": {
"shadcn": {
"command": "npx",
"args": ["-y", "shadcn@latest", "mcp"]
}
}
}
+31
View File
@@ -0,0 +1,31 @@
# HealthLog v1.4.6 marathon — project
Personal health-tracking PWA. v1.4.5 live in production. v1.4.6 marathon
brief is `docs/audit/v146-findings.md` — that file is the source of truth
for every fix, feature, and gate. `CLAUDE.md` covers project-wide rules
(tooling, conventions, hard constraints).
## Scope
- Tier 1 (T1-T9): release blockers
- New feature: chart bucketing for ranges > 1 year
- Tier 2 (P1-P20): polish
- Multi-agent QA + simplify pass
- Release & deploy v1.4.6
- GitHub releases backfill (v1.4.2-v1.4.6) + GHCR housekeeping
- Docs + landing site sync
## Hard rules (Marc, verbatim)
1. Niemals `--no-verify`.
2. Niemals `--no-gpg-sign` ohne Anfrage.
3. Niemals force-push to main.
4. Changelog/Release-Notes user-facing — kein "Claude", keine internen
Phasennamen.
5. Out-of-scope > typo → defer to v1.5.
## References
- `CLAUDE.md` — project conventions
- `docs/audit/v146-findings.md` — release spec
- `CHANGELOG.md` — release tone & format
+21
View File
@@ -0,0 +1,21 @@
# HealthLog v1.4.6 — roadmap
Milestone: **v1.4.6** (overnight 2026-05-09)
| Phase | Goal | State |
| ----- | ------------------------------------------------ | ------- |
| 1 | CI green (e2e workflow root cause + fix) | pending |
| 2 | Tier 1 fixes T1–T9 | pending |
| 3 | New feature: chart bucketing for >1y ranges | pending |
| 4 | Tier 2 polish P1–P20 | pending |
| 5 | Multi-agent QA + simplify pass | pending |
| 6 | Pre-release verification | pending |
| 7 | Release commit, tag, deploy, verify production | pending |
| 8 | GitHub releases backfill (v1.4.2–v1.4.6) + GHCR | pending |
| 9 | Docs site (`healthlog-docs`) + landing site sync | pending |
| 10 | Summary doc + Marc-facing brief | pending |
Stop conditions: production red, large un-spec'd issue, context cutoff,
budget short of polish — write status doc, do not auto-rollback.
Spec: `docs/audit/v146-findings.md` (every finding ID, every test, every gate).
+90
View File
@@ -0,0 +1,90 @@
# v1.4.6 marathon — state log
Status: **bootstrapped** (2026-05-09 ~01:25)
Update at every phase boundary so `cat .planning/STATE.md` is the truth.
## Phase 0 — Bootstrap
- [x] `.planning/PROJECT.md`
- [x] `.planning/ROADMAP.md`
- [x] `.planning/STATE.md`
- [ ] commit `chore(planning): bootstrap v1.4.6 marathon`
## Phase 1 — CI green
- [ ] reproduce e2e failure locally
- [ ] root-cause + fix
- [ ] verify CI green
## Phase 2 — Tier 1 fixes (T1–T9)
- [ ] T1 trend-card w-full (in working tree)
- [ ] T2 muted-foreground colour split
- [ ] T3 primaryRecommendation chart-token rendering
- [ ] T4 aiBaseUrl provider leak fix + test
- [ ] T5 /api/insights/generate 502 → 422
- [ ] T6 admin status-card hrefs + tighten test
- [ ] T7 bug-report toggle gate (api/feedback + /bugreport/status)
- [ ] T8 data-wipe scope + audit-log preservation
- [ ] T9 KI per-card 360 daily + 24 monthly (7 generators)
## Phase 3 — Chart bucketing
- [ ] research recharts patterns
- [ ] `bucketTimeSeries` helper + tests
- [ ] chart header chip + i18n
## Phase 4 — Tier 2 polish
- [ ] P1–P5 trend-card + chart card (single commit)
- [ ] P6 welcome subtitle
- [ ] P7 auth-shell pb buffer
- [ ] P8 empty-state title color
- [ ] P9 onboarding font-mono drop
- [ ] P10 medications card padding
- [ ] P11 redact `sk-(ant-)?`
- [ ] P12 idempotency exclusion
- [ ] P13 cache-before-rate-limit
- [ ] P14 codex-client structured error
- [ ] P15 model presets (drop gpt-5/o3-mini)
- [ ] P16 ai-section i18n strings
- [ ] P17 feedback-inbox dracula colours
- [ ] P18 danger-zone success heuristic
- [ ] P19 useSystemStatus / useAdminSettings isError UI
- [ ] P20 status-overview Promise.allSettled
## Phase 5 — QA
- [ ] superpowers:code-reviewer
- [ ] Plan as senior security reviewer
- [ ] Plan as senior design reviewer
- [ ] simplify pass
## Phase 6 — Pre-release verification
- [ ] typecheck / lint / format / test / integration / build / e2e
- [ ] before-screenshots vs prod
## Phase 7 — Release
- [ ] package.json 1.4.6
- [ ] CHANGELOG `[1.4.6]` block
- [ ] tag + push
- [ ] GHCR build green
- [ ] Coolify deploy + finished
- [ ] /api/version=1.4.6, image digest changed
## Phase 8 — Releases
- [ ] gh release create v1.4.2…v1.4.6
- [ ] GHCR untagged cleanup
## Phase 9 — Docs / landing
- [ ] healthlog-docs synced to v1.4.6
- [ ] healthlog-landing minimal update
## Phase 10 — Summary
- [ ] `docs/audit/v146-summary.md` with German Marc-brief on top
+41 -18
View File
@@ -6,21 +6,33 @@ Instructions for AI coding agents (OpenAI Codex, Claude Code, Cursor, etc.) work
**HealthLog** — a personal health-tracking web app (weight, blood pressure, pulse, mood, medication compliance) with Withings integration, moodLog.app sync, Dracula-themed UI, mobile-first PWA design.
**Status**: v1.2.0 (Custom Thresholds, Blood Glucose, Dashboard Customization, Built-in Feedback, Multi-Provider AI). See GitHub Issues for open tasks.
**Status**: v1.4.0 (released) → v1.4.1 in progress. v1.4.0 shipped
the UI guidelines + Skeleton/EmptyState primitives, medical-citations
consolidation, the per-route `/settings/[section]` split, the
status-first admin grid + aggregator endpoint, five new "Test
connection" buttons, AI insights with inline charts via allowlisted
metric tokens, off-host encrypted S3 backup, encryption-key
versioning + rotation CLI, optional `HEALTHLOG_PROCESS_TYPE` worker /
web split, and short-lived 24h access tokens with refresh-token
rotation for native API clients. v1.4.1 follows up with per-section
admin component extraction (the inner monolith into one file per
panel), a Postgres testcontainers integration test suite, and a
Playwright + axe-core E2E foundation. See GitHub Releases +
CHANGELOG.md for the full feature timeline.
## Tech Stack
| Layer | Technology | Version | Notes |
| --------------- | -------------------- | ------- | ----------------------------------------------------------------------- |
| Framework | Next.js (App Router) | 16 | TypeScript strict, RSC default, `"use client"` only for interactivity |
| ORM | Prisma | 7.4 | Uses `PrismaPg` adapter, **not** `url` in schema — see gotchas below |
| ORM | Prisma | 7.8 | Uses `PrismaPg` adapter, **not** `url` in schema — see gotchas below |
| Database | PostgreSQL | 16 | Docker Compose service, port 5432, user `healthlog` |
| UI | shadcn/ui (new-york) | latest | Components in `src/components/ui/` |
| Theme | Dracula | — | CSS variables in `globals.css`, dark mode default, `--dracula-*` tokens |
| CSS | Tailwind | 4 | CSS-first config (`@import "tailwindcss"` syntax) |
| Data fetching | TanStack Query | 5 | Provider in `src/components/providers.tsx` |
| Validation | Zod | v4 | Import as `zod/v4` (not `zod`) |
| Testing | Vitest | latest | Config in `vitest.config.ts` |
| Testing | Vitest | latest | Config in `vitest.config.mts` |
| Package manager | pnpm | latest | **Not** npm or yarn |
| Node | 20.x | via nvm | |
| Job queue | pg-boss | 12 | Named import `{ PgBoss }`, see gotchas |
@@ -66,7 +78,7 @@ src/
│ ├── layout.tsx # Root layout (viewport-fit: cover for PWA)
│ ├── page.tsx # Dashboard (/) with quick entry dropdown
│ ├── globals.css # Dracula theme CSS variables
│ ├── admin/page.tsx # Admin panel
│ ├── admin/page.tsx # Admin shell — 77 LOC, mounts the per-section components from src/components/admin/
│ ├── auth/login/page.tsx # Login
│ ├── auth/register/page.tsx # Registration
│ ├── achievements/page.tsx # Gamification achievements
@@ -76,10 +88,11 @@ src/
│ ├── medications/page.tsx # Medications management
│ ├── notifications/page.tsx # Notification preferences matrix
│ ├── onboarding/page.tsx # 4-step guided onboarding
│ ├── settings/page.tsx # All settings (13 sections, ~2100 lines)
│ ├── settings/page.tsx # 308-redirects /settings → /settings/account
│ ├── settings/[section]/page.tsx # Per-section route shell (account, integrations, notifications, dashboard, ai, api, advanced, about)
│ ├── mood/page.tsx # Mood tracking
│ ├── targets/page.tsx # Target values dashboard
│ └── api/ # ~60 API route files (see docs/api.md)
│ └── api/ # 100+ API route files (admin, auth, measurements, medications, mood, insights, integrations, ingest, dashboard, feedback, tokens, notifications, monitoring, …)
│ ├── mood-entries/ # Mood CRUD
│ ├── import/ # JSON data import
│ ├── doctor-report/ # Doctor report data aggregation
@@ -93,7 +106,10 @@ src/
│ ├── measurements/ # Measurement form, list
│ ├── mood/ # Mood form, mood list
│ ├── charts/ # Recharts wrappers, compliance charts
│ ├── insights/ # AI insights card
│ ├── insights/ # AI insights cards (status, advisor)
│ ├── gamification/ # Achievement cards, progress
│ ├── settings/ # Per-route Settings section components (one per /settings/[section])
│ ├── admin/ # Per-route Admin section components (system-status, integrations, monitoring, reminders, users, audit, danger-zone, feedback)
│ └── monitoring/ # Umami, GlitchTip bootstrap
├── lib/
│ ├── db.ts # Prisma client singleton
@@ -109,6 +125,13 @@ src/
│ ├── notifications/ # Dispatcher, types, senders (telegram, ntfy, web-push)
│ ├── jobs/ # pg-boss worker (reminders, insights, backups)
│ ├── validations/ # Zod schemas (shared between API + client)
│ ├── ai/ # Multi-provider client (OpenAI, Anthropic, local OpenAI-compat)
│ ├── feedback/ # In-app feedback + optional GitHub escalation
│ ├── gamification/ # Achievement definitions + progress
│ ├── insights/ # Insight pipeline + prompts
│ ├── logging/ # Wide Events: builder, context, sampler, transports, background
│ ├── moodlog/ # moodLog.app webhook + sync
│ ├── monitoring/ # Umami / GlitchTip server-side hooks
│ └── withings/ # Withings OAuth client + sync service
├── hooks/
│ └── use-auth.ts # useAuth(), useLogout() hooks
@@ -117,8 +140,8 @@ messages/
├── de.json # German translations (primary UI language)
└── en.json # English translations
prisma/
├── schema.prisma # Database schema (23 models)
└── migrations/ # Migration files (0001–0018)
├── schema.prisma # Database schema (26 models)
└── migrations/ # Migration files (0001–0025; latest: refresh_tokens + user_locale_drift_fix)
prisma.config.ts # Prisma config (DB URL lives here, NOT in schema)
public/
├── sw.js # Service worker (Web Push + offline caching)
@@ -165,17 +188,17 @@ These are hard-won lessons. Ignoring them will cause errors:
- **Zod v4**: Import from `"zod/v4"`, not `"zod"`.
- **jsPDF**: Client-side only. Import dynamically in browser context. Used with `jspdf-autotable` plugin.
### Settings Page
### Settings & Admin (per-route layout, v1.4)
- One large file (~2100 lines), 13 sections. Sidebar switches to "settings mode" showing section shortcuts.
- Sections scroll-to with highlight animation (`section-highlight` CSS class).
- Section IDs: `profil`, `sprache`, `sicherheit`, `telegram`, `ntfy`, `web-push`, `insights`, `withings`, `api`, `export`, `protokoll`, `daten`.
- **Settings**: 8 routes under `/settings/[section]` — `account`, `integrations`, `notifications`, `dashboard`, `ai`, `api`, `advanced`, `about`. The legacy `/settings` 308-redirects to `/settings/account`. Sidebar deep-links and the `<a href="/settings#anchor">` patterns from 1.3 still resolve via the redirect.
- **Admin**: `src/app/admin/page.tsx` is now a 77-LOC shell that mounts the per-section components in `src/components/admin/`. Status-card grid lives in `status-card-grid.tsx`. The aggregator endpoint `/api/admin/status-overview` returns the six-card summaries in one batched query.
- ESLint `react-hooks/set-state-in-effect` is **strict** now (was non-blocking when the settings monolith carried inline-effect state-setters). Use lazy `useState(() => …)` for localStorage reads, TanStack Query for data fetches.
### Insights Page
- Large file (~1800+ lines), 7 content sections with AI summaries.
- Large file (~1750 lines), 7 content sections with AI summaries.
- Sticky horizontal chip navigation (`InsightsSectionNav` component) with IntersectionObserver for active state tracking.
- Section IDs: `general`, `bp`, `weight`, `pulse`, `mood`, `medications`, `bmi`.
- Section IDs: `general`, `bp`, `weight`, `pulse`, `mood`, `meds`, `bmi`.
- Each section has `scroll-mt-28` for header offset on scroll.
### Sidebar
@@ -192,8 +215,8 @@ These are hard-won lessons. Ignoring them will cause errors:
### Notification System
- **Channels**: `TELEGRAM`, `NTFY`, `WEB_PUSH` (DB: `NotificationChannel`).
- **Event types**: `MEDICATION_REMINDER`, `MEASUREMENT_ANOMALY`, `COMPLIANCE_LOW`, `WITHINGS_SYNC_FAILED`, `SYSTEM_ALERT`.
- **Channels**: `telegram`, `ntfy`, `web_push` — stored as plain `String` on `NotificationChannel.type` (no Prisma enum; the canonical list lives in `src/lib/notifications/types.ts`).
- **Event types**: `medication_reminder`, `measurement_anomaly`, `compliance_low`, `withings_sync_failed`, `system_alert` — also `String` on `NotificationPreference.eventType`. Source of truth is the discriminated union in `src/lib/notifications/types.ts`.
- **Opt-out model**: Preferences default to ON (enabled) when no `NotificationPreference` row exists.
- **Dispatcher**: `src/lib/notifications/dispatcher.ts` checks channel enabled + preference per event type.
- **Telegram**: Inline buttons (Take/Skip/Snooze 1h/Snooze 3h), pre-end window reminders (30 min before), message auto-cleanup after 24h.
@@ -207,7 +230,7 @@ These are hard-won lessons. Ignoring them will cause errors:
## Database Models (Prisma)
23 models: `User`, `Passkey`, `Session`, `AuthChallenge`, `Measurement`, `Medication`, `MedicationSchedule`, `MedicationIntakeEvent`, `ReminderPhaseConfig`, `TelegramReminderMessage`, `TelegramScheduledDeletion`, `ApiToken`, `WithingsConnection`, `MoodEntry`, `AppSettings`, `Feedback`, `AuditLog`, `NotificationChannel`, `NotificationPreference`, `PushSubscription`, `DataBackup`, `UserAchievement`, `RateLimit`.
26 models: `User`, `Passkey`, `Session`, `AuthChallenge`, `Measurement`, `Medication`, `MedicationSchedule`, `MedicationIntakeEvent`, `ReminderPhaseConfig`, `TelegramReminderMessage`, `TelegramScheduledDeletion`, `ApiToken`, `RefreshToken`, `WithingsConnection`, `MoodEntry`, `AppSettings`, `Feedback`, `AuditLog`, `NotificationChannel`, `NotificationPreference`, `PushSubscription`, `DataBackup`, `UserAchievement`, `RateLimit`, `Device`, `IdempotencyKey`. (`RefreshToken` added in v1.4.0 alongside the native-client 24h access-token / refresh-token rotation flow.)
## When Making Changes
+791
View File
@@ -1,5 +1,796 @@
# Changelog
## [1.4.6] — 2026-05-09
### Fixed — Dashboard
- **Tile strip fills its row.** Each tile in the dashboard's
measurement strip now stretches to its grid cell instead of
shrinking to content width. On a 375 px viewport tiles in the
same row are no longer 122-166 px wide — every tile takes the
same share of the row.
- **Secondary text is finally distinguishable from primary.**
`--muted-foreground` was identical to `--foreground` in both
light and dark mode, which flattened the entire visual
hierarchy. Tile labels, "Ø7d:" prefixes, units, and inactive
nav items now render in `#9aa3b3` (dark) / `#5b6273` (light) —
both ≥ 4.5:1 against the surface.
- **Trend cards land aligned with the chart strip below.**
Padding bumped from `p-3` to `p-4 md:p-6` so the tiles match
the surrounding chart cards. KPI typography (`text-3xl
tracking-tight tabular-nums` for the value, `text-xs uppercase
tracking-wide` for the label) gives the number proper weight
without making the tile feel tall.
- **Always-on Ø7d / Ø30d chips** — when an average is missing
the chip renders `—` instead of disappearing, so vertical
rhythm stays consistent across tiles.
- **Welcome subtitle is visible on mobile** (was hidden below
the `sm` breakpoint) and uses muted-foreground so it doesn't
fight the headline.
- **Medications card matches the other cards** — `rounded-xl`
and `p-4 md:p-6` instead of the smaller `rounded-lg p-4`.
### New — Charts
- **Long-range charts now bucket their data.** Hitting "All" on
an account with several years of measurements used to render
thousands of daily dots — unreadable, and slow on mobile. The
chart now picks an aggregation level from the visible range:
daily for ≤ 90 days, weekly mean for 91-730 days, monthly
mean for everything beyond. A small chip beside the chart
title surfaces the active bucket ("Wochendurchschnitt" /
"Monatsdurchschnitt" in DE, "Weekly avg" / "Monthly avg" in
EN) so the bucket is never silent. Empty buckets are dropped
rather than zero-padded.
### Fixed — KI
- **Hero recommendation no longer prints raw `metric:WEIGHT`
tokens.** When the model embedded a chart token in the
primary recommendation the renderer was printing the literal
string. The "Key Takeaway" card now strips the tokens for
prose and renders them as inline charts under the
recommendation, the same pattern the summary block has used
since v1.4.5.
- **"Verbindung testen" stays readable when the model returns
bad JSON.** `/api/insights/generate` returned 502 for parse
errors, which Cloudflare swapped for an HTML error page;
`await res.json()` in the browser then crashed. Same
Cloudflare-rewrite trap the v1.4.5 ai/test fix solved — parse
errors now map to 422 so the JSON body actually reaches the
client.
- **Saved API keys cannot leak to a stale local URL.** A user
who had configured the Local provider with `http://192.168.x.x`
and later switched to OpenAI / Anthropic kept that URL in the
shared `aiBaseUrl` column, and the OpenAI / Anthropic clients
were forwarding their cloud keys to that LAN host on every
request. The PATCH that switches providers now wipes the
column, and the OpenAI / Anthropic clients hardcode the
canonical base URL regardless of what the column carries.
### Improved — KI Insights
- **The model now sees three years of history per metric.** Each
per-card insight payload (general, blood pressure, pulse,
weight, BMI, mood, medication compliance) used to clip the
series to the last 30 daily means — way too narrow to detect
drift that plays out over a year. The new payload combines
360 daily means (`dayOffset 0..359`) with 24 monthly means for
the older window (`monthOffset 12..35`), with a 50 KB safety
guard that re-buckets at 180 daily days when a particularly
noisy account would blow past the prompt token budget.
### Fixed — Admin
- **Status-card CTAs go to the right place.** Every "Manage
users" / "View backups" link in the admin status grid was
bouncing back to `/admin` because the hrefs were pointing at
sub-routes that never shipped (`/admin/users`,
`/admin/audit-log`) or at anchors whose IDs do not exist
(`#integrations`, `#monitoring`, `#backups`). Each href now
points at a real `section-*` anchor on the admin page, the
CTA copy describes the destination honestly ("Open
integrations", "Open system status"), and the unit test
asserts each href maps to a known section ID — so the next
refactor cannot regress this silently.
- **Bug-report toggle finally hides the form.** The admin
"Bug reports" switch had no effect because the gate lived on
a legacy route the form does not call. The actual `/api/feedback`
endpoint and the `/bugreport` page now both honor the toggle —
the form disappears, and direct API calls return 503 with a
readable error.
- **Audit trail survives the data wipe.** The `Wipe all data`
admin action used to delete `AuditLog` in the same transaction
as the rest of the user data, which silently destroyed the
one record actually saying "an admin wiped data". The wipe
now leaves the audit log alone and adds an explicit
`admin.data.clear.start` entry before the transaction begins,
so even a crash mid-wipe leaves a trail. The success copy
also enumerates the full scope (API tokens, Withings
connections, auth challenges) instead of pretending only
measurements / medications / intakes are affected.
- **Cached insight reloads no longer burn a rate-limit token.**
`POST /api/insights/generate` checked the rate limit before
the cache return, so a noisy dashboard refresh would lock you
out for an hour even when every request returned the same
cached envelope. The check moved below the cache return.
- **One failing health probe no longer blanks the whole admin
status grid.** `Promise.all` in the status-overview API
short-circuited on the first rejection. `Promise.allSettled`
forces failed probes to the `alert` severity and lets the
rest render normally, with a Wide Event annotation noting
which probe(s) failed.
- **Settings load errors surface inline instead of spinning
forever.** When `useSystemStatus` or `useAdminSettings`
rejects, the admin page now shows a "Failed to load…"
banner instead of an indefinite skeleton.
### Improved — Settings · KI
- The KI section is fully internationalised — about 30 hard-coded
German strings now flow through `t("settings.ai.…")` keys, and
the model-preset list drops `gpt-5` (not a released model) and
`o3-mini` (would require an o-series parameter contract that
isn't wired up yet). The privacy "Raw data" warning uses
Dracula tokens so the colour matches the rest of the panel.
### Improved — Polish
- Numeric spans on the dashboard tiles use `tabular-nums` so
digits stop jiggling on every refresh.
- The bottom-nav buffer is tighter on mobile and removed on
desktop (`pb-[calc(4rem+env(safe-area-inset-bottom,0px))]
md:pb-0`), so desktop content sits flush instead of leaving
an unused 5 rem strip.
- Onboarding "X von Y" / "%" progress drops the monospace font
and keeps tabular-nums.
- Feedback inbox category badges use Dracula tokens
(`bg-dracula-red/15 text-dracula-red`) for theme cohesion.
- Codex provider mirrors the OpenAI client's structured-error
format (httpStatus / model / bodyExcerpt) so failures from
ChatGPT-OAuth show the same readable message as failures from
the BYO-key paths.
- Logging redacts third-party AI keys: `redactSecrets` now scrubs
`sk-` / `sk-ant-` patterns so a misconfigured client cannot
leak its key into Wide Events. The idempotency body-content
guard refuses to cache responses that contain those prefixes
(with a regex tight enough to avoid false positives on words
like "task-id" or "risk-management").
- Danger-zone result colour follows mutation state instead of
string-prefix-matching localized copy.
### Repo housekeeping
- `e2e` workflow is back to green: dropped the `pnpm/action-setup`
version override (now reads `packageManager` from
`package.json`), switched the mobile Playwright project from
iPhone 13 (webkit) to Pixel 5 so chromium-only CI installs are
enough, anchored the locale-cookie test at the configured base
URL, and made the login spec open the password form before
asserting on its inputs.
- Repo-wide prettier sweep — long-deferred reformatting drift
closed before the v1.4.6 tag.
## [1.4.5] — 2026-05-09
### Fixed — Settings · KI
- **"Verbindung testen" no longer crashes the page when the key is
bad.** The endpoint mapped every upstream error to HTTP 502, which
Cloudflare intercepts and replaces with its own HTML error page.
`await res.json()` in the browser then crashed with `Unexpected
token '<', "<!DOCTYPE "`. 401/403 from the provider now map to 422,
429 to 429, and only genuine 5xx upstream errors keep the 502 —
Cloudflare passes 4xx through untouched, so the React Query
mutation reads the JSON body and surfaces a readable message.
### Fixed — Dashboard
- **Dashboard tiles use a CSS Grid layout again** — the v1.3-era
pattern that gave each tile an identical width with symmetric gaps
and an edge-to-edge fit with the charts below. v1.4.4 distributed
width via `flex-1 basis-0`; CSS Grid is more honest about the
intent and survives viewport changes more gracefully.
`grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr))` keeps
every track equal-width with a 9 rem floor; the row wraps when
the floor no longer fits instead of triggering a horizontal
scroll.
## [1.4.4] — 2026-05-08
### Fixed — Dashboard
- **Blood pressure is now two distinct tiles again** (sys + dia next
to each other) — the v1.4.3 attempt at a combined tile read as a
single double-wide entry, which Marc preferred to keep as two
separate tiles for visual rhythm with the other metrics.
- **All tiles share an equal width.** The previous strip used
`shrink-0 grow basis-[10rem]`, which gave each tile a 160 px floor
and let wider content (like the BP tile) push past it. Switched
to `flex-1 basis-0 min-w-[9rem]` — every tile now starts from
zero and gets the same share of the available row width, with a
9 rem floor so they stay readable on narrow viewports. The strip
scrolls horizontally if the row no longer fits.
## [1.4.3] — 2026-05-08
### Fixed — Dashboard
- **Tile strip is now the same width as the charts below it.** The
small negative-margin bleed that helped the snap-scroll feel on
mobile was leaking onto desktop and made the tile row 16 px wider
than the chart strip. Fixed at `md` and up.
- **Tiles all have the same height.** Blood pressure used to render
as two stacked tiles inside one slot which made it taller than
every neighbour. Sys/dia now share **one** tile and display as
`117/79 mmHg` with one trend arrow. Tile count drops from 6 to 5.
- **Settings sub-section "Übersicht" renamed to "Dashboard"** —
matches the label in the main nav.
- **"Persönliche Zielwerte" is its own settings section now** at
`/settings/thresholds`, instead of being buried inside the
Dashboard settings panel under the layout customizer.
### Fixed — Settings
- **API & Tokens tables fit the content column on desktop.**
- **About → "Auf Updates prüfen" works again.** Production CSP
blocked the direct call to `api.github.com` — the check now goes
through a server-side proxy. The page also auto-checks once on
mount when the previous result is older than 24 h, and shows a
"Zuletzt geprüft am …" timestamp so you can tell when the answer
was confirmed.
- **About restructured into three titled cards** — HealthLog (version
- license inline, no boxed badge), Quellen & Dokumentation, and
Updates.
- **KI provider — OpenAI users can enter their own API key.** Schema
didn't have a column for it before; added it, plumbed it through
the resolver and test endpoint, surfaced the input in the Settings
→ KI panel.
- **Model field is now a dropdown** with provider-specific presets
plus an "Eigenes…" option for power users.
- **"Mit ChatGPT verbinden" no longer dead-ends on chatgpt.com.** The
OAuth URL was missing its `client_id`; with `CODEX_OAUTH_CLIENT_ID`
set the handshake completes. When the env var isn't configured the
button is hidden and a hint points the user at the API-key path.
### Improved — KI Insights
- **AI can illustrate findings with `metric:MOOD` charts inline.** A
mood drift or adherence-risk finding now renders the dedicated
Mood chart under the paragraph instead of just prose.
- **Every finding must cite a concrete number** ("138/85 mmHg",
"+0.4 mmol/L vs 30d-avg") rather than adjectives. Findings without
a snapshot anchor are now omitted; cardinality is variable 0–8
sorted by salience. Generic boilerplate ("drink enough water",
"consult your doctor", etc.) is explicitly forbidden outside the
disclaimer.
### Improved — Admin
- **Bug-report feature has an explicit on/off toggle.** Previously
the only way to hide the report button was to clear the encrypted
GitHub token, which forced a re-entry on resume.
### Improved — Polish
- **"Trennen" looks the same everywhere** (Withings, moodLog,
KI/Codex). Outlined button with red text — same affordance for
the same action.
## [1.4.2] — 2026-05-08
### Fixed — Production deploy hotfixes
- **Dashboard and Insights pages no longer crash for users without
weight data.** `data?.summaries.WEIGHT` only protected the outer
object — the optional chain stopped one level too early, so
brand-new users (where `summaries` is undefined) hit
`TypeError: undefined is not an object (evaluating 'E?.summaries.WEIGHT')`
on first load. Now `data?.summaries?.WEIGHT`.
- **Container healthcheck uses `127.0.0.1` instead of `localhost`.**
busybox-`wget` in Alpine resolves `localhost` to IPv6 `::1` first,
but Next.js standalone listens on IPv4 `0.0.0.0:3000` only — so the
healthcheck always returned ECONNREFUSED, Docker marked the
container unhealthy, and Traefik returned 503 from the public URL
even though the app was actually running. The Dockerfile-level
`HEALTHCHECK` already used 127.0.0.1; the `docker-compose.yml`
override was the one that drifted to `localhost`. Fixed.
### Notes
- The 1.4.1 GHCR image never published cleanly (the docker-publish
workflow reported success in GH Actions but the
`ghcr.io/mbombeck/healthlog:1.4.1` tag returned `manifest unknown`
when Coolify tried to pull). The 1.4.2 release supersedes 1.4.1
and includes everything 1.4.1 was supposed to ship — the v1.4.1
source on `main` was always healthy; only the Coolify deploy
surface was broken.
## [1.4.1] — 2026-05-08
### Security
- **moodLog integration no longer accepts internal-network URLs.** A
user could previously save `http://169.254.169.254/` (cloud-metadata)
or any RFC1918 address as their moodLog instance; the daily sync
worker would then fetch from that target with the user's API key in
the Authorization header. The credentials write path now refuses
non-public hosts, the sync worker re-checks the URL at the actual
fetch site (so legacy rows stored before the guard are also
refused), and the fetch is now `redirect: "manual"` so a public
host cannot 302 to an internal target with the bearer on the
redirect hop.
- **Error reports never echo bearer tokens, Telegram bot tokens, or
query-string secrets.** `WideEventBuilder.setError()` and the
Glitchtip incident path now run every error message and stack
trace through a central `redactSecrets()` filter that scrubs
`Bearer …`, Telegram `bot<digits>:<token>` URLs, and `?secret=`,
`?code=`, `?token=`, `?api_key=` query strings. The substitution
is generic `[REDACTED]` so partial entropy is never revealed.
### Fixed — Citation accuracy
- **Blood-pressure classification now cites ESH 2023.** The dashboard
tile, the doctor-report PDF, and the inline analytics comments
used to label the band as "ESC/ESH 2018". The numbers haven't
changed (the 2023 ESH update kept the 2018 thresholds), but the
joint authoring did — ESC withdrew from the 2023 document, so the
correct citation is "ESH 2023" alone.
- **Steps target source label is `Saint-Maurice JAMA 2020`** instead
of `WHO`. Every other surface in the app (AI prompts, inline
comments, drift tests) already enforced this attribution; the
insights/targets surface was the last "WHO" label in the tree.
WHO publishes physical-activity _time_, not a step quota.
- **Saint-Maurice "mortality plateau 8000–12000" attribution
softened.** The original JAMA 2020 paper reports continued
dose-response benefit (HR 0.49 at 8k, HR 0.35 at 12k) — not a
plateau. The plateau-shaped finding belongs to Paluch 2022
_Lancet Public Health_ (PMID 35247352), not Saint-Maurice. The
inline comments and AI prompts now say "continued dose-response
benefit through ~12,000 steps/day" instead.
### Added — CI safety nets
- **Postgres-backed integration test suite is now executable.** The
testcontainers infrastructure shipped in 1.4.0; this release wires
the per-test boilerplate through vitest's `globalSetup` so all
four files share one container. `pnpm test:integration` runs ten
tests (rate-limit race, idempotency replay-attack contract, GDPR
Article-17 cascade delete, session create / read / expire) against
a real Postgres in under four seconds. CI runs the suite on every
PR.
- **Playwright + axe-core E2E foundation.** A new `pnpm e2e` runs
five public-surface specs (version endpoint, proxy auth-redirect,
login form autofill hints, DE/EN locale switch, axe-core
accessibility gate) against the production build in CI. Authenticated
flow specs (quick-entry, doctor-report, settings round-trip,
test-buttons, onboarding) ride a follow-up release because they
need a seeded test user; the foundation makes adding them a
one-PR step.
### Changed — Admin internals
- **Admin page is now per-section components.** The status-card grid
shipped in 1.4.0 sat on top of a 2,700-line monolith; that monolith
is now 14 focused files in `src/components/admin/` with a 77-line
`src/app/admin/page.tsx` shell that mounts them. Every section
keeps the same DOM, ids, query keys, and i18n keys — no
user-visible change.
### Fixed
- **Final ESLint error is gone.** The medications page's "API
endpoint" dialog ran its initial-load fetch through a `useCallback`
paired with `useEffect` and triggered the strict
`react-hooks/set-state-in-effect` rule. Refactored to TanStack
Query — same network calls, no effect, lint count is now zero on
`main`.
### Documentation
- **Repo-internal docs synced for v1.4.** README adds the
Multi-tenant ready and Test connection buttons feature blocks, the
API reference table includes the eleven new v1.4 endpoints, and
the model count is corrected to 26 (RefreshToken). AGENTS.md and
CLAUDE.md reflect the per-route `/settings/[section]` layout and
the per-section admin layout. `docs/api/openapi.yaml` documents
the new endpoints (version, refresh, refresh/revoke,
status-overview, backup/test, the five test-connection probes).
`docs/migration/v1.3-to-v1.4.md` corrects the now-wrong "no
migrations" claim and adds full env-var sections for the
worker/web split, encryption-key versioning, and off-host backup
target.
### Notes
- No database migration in 1.4.1.
- No environment-variable change required to upgrade.
- No API contract change — every route added in 1.4.0 is still
there; no shapes or status codes flipped.
- The audit pass that drove this release identified five medium
security items and three P0 performance items that warrant
deeper architectural work; those are tracked in
`docs/ops/v141-followup-issues.md` and ride a future release.
## [1.4.0] — 2026-05-08
### Added — Foundation, safer ranges, and a faster dashboard
- **UI guidelines, design tokens, and shared primitives.** A new
`docs/ui-guidelines.md` is the single source of truth for spacing,
typography, button hierarchy, dialog-vs-sheet decisions, accessibility
baseline (WCAG 2.1 AA), and the autofill / honeypot pattern for
health-data forms. Two new shadcn primitives — `<Skeleton>` and
`<EmptyState>` — replace the previous mix of spinners and "No data"
placeholder strings. Future v1.4.x components reference the doc; the
primitives ship with screen-reader-aware semantics and respect
`prefers-reduced-motion`.
- **`/api/version` public endpoint** exposing the build's version,
optional Git SHA / build timestamp, license, and canonical links.
Wires the future Settings → About surface and a thin "Check for
updates" UX. Static-cached so the route adds zero DB load.
- **`src/lib/medical-citations.ts`** — single source of truth for
cited medical guidelines (id, name, year, URL, caveat). Future
medical surfaces import these constants instead of duplicating
strings in code, prompts, and `messages/*.json`. A new drift-test
asserts every entry has a non-empty URL + caveat and that the
recurring "WHO ≥ N steps" hallucination cannot reappear as a constant.
### Fixed — Patient safety and citation accuracy
- **Diastolic blood-pressure orange band no longer reaches 60 mmHg.**
With the default age-based targets (DBP 70–79), the lower orange
wing was computed as `diaLow − 10 = 60`. A reading of 60 mmHg landed
in "mildly low" yellow instead of red even though that level is the
general-adult hypotension threshold and the J-curve risk floor in
ESH 2023 for treated hypertensives. Orange floor is now clamped at
65 mmHg, so 60 mmHg lands in red. The user-override path stays
intact and remains audit-logged.
- **BP guideline citations consolidated on ESH 2023.** The codebase
had a mix of "ESC/ESH 2018" (analytics) and "ESC/ESH 2023" (AI
prompts). The 2023 hypertension document is ESH-only — ESC withdrew
from the joint authoring — so neither label was correct. Every site
now cites "ESH 2023" with the published source URL. Numbers
unchanged.
- **"WHO ≥ 8 000 steps/day" hallucination fully removed.** WHO
publishes activity _time_ (150–300 min/wk moderate), not a step
quota. The v1.3.3 fix only landed in `effective-range.ts`; four AI
prompt strings and the `getStepsRange()` helper carried the old
wording forward. Saint-Maurice et al., JAMA 2020 (mortality plateau
8 000–12 000) is now cited everywhere and the two surfaces agree on
the band. Sleep target moves from "ESC" (no adult sleep guideline)
to AASM 2015.
- **Body-fat ACE bands corrected and three-way drift resolved.** The
classifier used `essential = 6 (M) / 14 (F)` as the floor — but
that's actually ACE's _Athletes_ lower bound. Readings below were
mislabelled "Essential" instead of "Below essential" (a danger
band). Six-band classifier now mirrors the ACE table, and the three
sites that had three different green-band numbers
(`value-bands.ts`, `targets/route.ts`, `classifications.ts`) all
derive from `getBodyFatTargetRange` (ACE fitness + acceptable bands).
- **Bedtime-glucose citation softened.** ADA Standards 2024 §6
publishes pre-prandial 80–130 and post-prandial <180 — no published
adult bedtime target. The 90–150 mg/dL band stays (reasonable adult
overnight band) but the inline citation now states the absence
explicitly and references ISPAD 2022 (pediatric) as the closest
comparator.
### Fixed — Localisation reaches the notification path
- **Medication reminders now follow the user's locale.** Telegram,
ntfy, and Web Push reminders previously read "Erinnerung", "Bald
fällig", etc. regardless of the user's stored language. Templates
for every phase (`green`/`yellow`/`orange`/`red`) and every keyboard
button now resolve from `messages/{de,en}.json` per
`med.user.locale`. Telegram callback IDs stay stable English
identifiers so the dispatcher keeps matching across locale changes.
- **Dashboard greeting and streak label** are localised server-side.
Previously hard-coded `"Hi, ${name}"` and `"Tage in Folge"` — both
now i18n-key-resolved.
- **Mixed-locale Zod validation messages unified to English.** Two
measurement-form messages and four admin-validation messages
flipped between German and English depending on which schema fired.
All consolidated on English (the app is English-first; the German
UI maps field labels client-side).
### Fixed — Chart math edge cases
- **`summarize` and `trendSlope` use the same time anchor.** Averages
snapped to `Date.now()`; slopes snapped to the latest point in the
series. A stale series reported a trend even though the dashboard
tile correctly hid the average. Both now anchor on `Date.now()`, so
a stale series returns `null` consistently from every windowed stat.
- **`summarize([])` returns `null` for `min`/`max`/`mean`** instead
of zeros that leaked into chart axes and AI feature bundles as
fake readings.
- **`weeklyAverages` is Berlin-timezone aware.** A Sunday-evening
Berlin reading bucketed into the next week on the UTC production
container because `Date.getDay()` was system-local. ISO-Monday key
now resolves via `Intl.DateTimeFormat({ timeZone: "Europe/Berlin" })`.
- **`pairByTimestamp` JSDoc** documents the greedy nearest-match
heuristic and when a Hungarian-style match would matter (sparse
health data is well below that bar).
### Fixed — Hidden friction
- **AI provider connection-test honours the unsaved selection.**
Changing the AI provider in `/settings`, then clicking "Verbindung
testen" without saving first, used to silently run the test against
the stored provider — surfacing as a confusing OK / failure unrelated
to what the user had on screen. Plaintext keys never persist; the
existing SSRF guard, rate limit, and V3 error-leak shielding stay in
place.
- **Health-data inputs no longer autofill the user's account
password.** The base `<Input>` primitive defaults to
`autoComplete="off"` plus the LastPass / 1Password ignore attributes
whenever the caller doesn't pass a semantic value. Auth and profile
forms continue to autofill normally because they pass an explicit
`autoComplete` (`"username"`, `"email"`, `"current-password"`,
`"new-password"`).
- **Step-range target aligned across two callsites.**
`getStepsRange()` returned `{7000, 10000}` while
`effective-range.ts` returned `{8000, 15000}`; two surfaces showed
different "green" bands to the same user. Both now use
`{8000, 15000}`, anchored on Saint-Maurice 2020.
### Performance
- **Two more N+1 queries closed.** `extractFeatures` (used by every
AI-insight route) issued one `prisma.medicationIntakeEvent.findMany`
per active medication; replaced with a single batched query and an
in-memory group. `/api/insights/targets` issued one `findFirst` per
measurement type; replaced with a single `distinct: ["type"]` query.
Same shape as the v1.3.0 fix to `/api/insights/comprehensive`.
### Changed — Dashboard
- **Tile strip is always one row.** Replaces the wrapping
`grid-cols-2 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5` layout
with a `flex snap-x snap-mandatory overflow-x-auto` strip. When the
user enables more tiles than fit the viewport, the strip
horizontal-scrolls instead of wrapping; the user trims the set in
Settings → Dashboard Layout.
### Added — Settings, integrations, and operations
- **Settings page now lives at `/settings/[section]`.** Eight focused
routes (`account`, `integrations`, `notifications`, `dashboard`,
`ai`, `api`, `advanced`, `about`) replace the single 3,000-line page.
Existing `/settings#anchor` links 308-redirect; the side-bar, in-app
deep links, and the AI / Withings / Codex callbacks all follow the
new structure.
- **About page** lists the running version, build SHA, license,
repository link, CHANGELOG link, docs link, and a "Check for
updates" button that pings the public GitHub releases API. Backed by
the `/api/version` endpoint shipped earlier in 1.4.0.
- **Admin console** is built around a status-first card grid (Users,
Integrations, Monitoring, Backups, Maintenance, Audit Log) with each
area in a focused panel beneath. Per-section extraction of the old
inline panels is tracked for v1.4.1 — the v1.4.0 admin page already
routes through the new aggregator endpoint and the status-card
grid.
- **Five new "Test connection" buttons in Settings.** Withings,
moodLog, Web Push, Glitchtip, and Umami now ship with one-click
connection probes — same pattern as the existing AI / Telegram /
ntfy tests, with per-button rate limit, sanitised error reporting,
redirect-follow SSRF guard, and an `errorCode` in the response
envelope so the UI can localise the message.
- **AI insights can reference any of your charts inline.** When a
finding centres on a single metric (e.g. systolic blood pressure),
the corresponding chart renders directly under the explanation.
Server-side allow-list — only the allowed metric tokens render; any
other model emission drops silently.
- **Off-host backup target.** Daily encrypted JSON dumps to any
S3-compatible bucket. Worker-side IAM grant is intentionally
PutObject + GetObject only — retention is the bucket's
lifecycle-rule job, so a compromised worker cannot wipe the backup
history. Restore script + step-by-step doc shipped under
`docs/ops/backup-restore.md`, and an admin "Backup target" test
button validates the configuration.
- **Encryption-key versioning.** Rotate the at-rest encryption key
without downtime via `pnpm tsx scripts/rotate-encryption-key.ts <id>`.
Existing data keeps decrypting under its original key while the new
one is rolled out. Walk-through + rollback notes in
`docs/ops/encryption-key-rotation.md`.
- **Worker / web split.** Optional
`HEALTHLOG_PROCESS_TYPE=web|worker|all` (default `all` for the
single-container setup) lets you scale background jobs and HTTP
traffic independently. The proxy refuses HTTP traffic with a 503 +
`X-HealthLog-Process-Type: worker` header in worker mode so a
misrouted request fails loudly instead of a silent half-served
response.
- **Native API clients now get short-lived 24-hour access tokens with
refresh-token rotation.** The browser keeps the existing 90-day
Bearer. Reuse-detection (presenting a refresh token a second time)
revokes every refresh token for the user — the small cost of a
forced re-login on the legitimate device buys defense-in-depth
against an undetected stolen-token replay.
- **Critical-path coverage on Telegram / Withings / moodLog /
Glitchtip webhook handlers + the four admin routes lifted to ≥80%
line coverage,** plus `src/lib/auth/audit.ts`. ~+100 new tests.
### Fixed — Operational hardening from the v1.4 review pass
- **Container time zone is correct.** Alpine images ship without
`tzdata`; the daily backup cron `30 2 * * *` Europe/Berlin was
silently falling back to UTC. The runner stage now installs
`tzdata` and exports `TZ=Europe/Berlin` so schedules fire at the
documented local time.
- **Compose healthcheck uses `wget --spider /api/version`** — `/api/version`
is now in the proxy's public-paths allowlist, so the healthcheck no
longer 302-redirects through the auth gate (which was accepting the
login page as a 200 success).
- **Idempotency replay-cache no longer caches refresh tokens.** The
guard already blocked the `hlk_` access-token prefix; the new
`hlr_` refresh tokens are blocked too.
- **Logout-on-device revokes the paired access token.** Calling
`/api/auth/refresh` with `revoke: true` now flips both the refresh
row and the matching `ApiToken` row to revoked, so a leaked access
token cannot outlive its refresh-token sibling.
- **`users.locale` migration drift backfilled.** The column had been
on `schema.prisma` since the v1.3 locale-aware reminder work but
never landed in the migration history (it must have been applied
via `prisma db push` to dev/prod). Any environment built strictly
from `prisma/migrations/` (CI testcontainers, brand-new self-host
installs) is now consistent. 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 kept in sync.
### Notes
- Largely additive release. Existing API contracts (response
envelopes, OpenAPI 3.1 spec) are unchanged. New endpoints surface
optional fields; no breaking changes.
- New migration `0025_refresh_tokens` adds the rotating refresh-token
table; new migration `0025_user_locale_drift_fix` backfills the
schema-vs-migrations drift on `users.locale`. Both are
forward-compatible — `IF NOT EXISTS` guards make them idempotent on
any environment already pushed-to.
- Operators of the off-host backup feature must configure a bucket
lifecycle policy for retention. The worker has no DeleteObject
grant by design.
- Native API clients (iOS, n8n, Health Connect) need to update their
login flow: native logins now return both a 24-hour access token
and a refresh token. The browser flow is unchanged.
- **Tracked for v1.4.1:** per-section admin panel extraction (the
status-card grid + aggregator already ship in 1.4.0; the inner
per-section file split is structural cleanup), the Postgres-backed
integration test suite (testcontainers infrastructure ships in this
release; the four integration tests themselves need a follow-up
pass against the merged schema), and Playwright E2E + axe-core CI
gates.
## [1.3.3] — 2026-05-08
### Added
- **Pulse oximetry as a first-class measurement type (`OXYGEN_SATURATION`).**
Closes the SpO2 part of #109. Migration `0024_oxygen_saturation` extends
the `MeasurementType` enum. Plausibility range 50–100% (below 50% is
incompatible with sustained life and almost certainly a faulty sensor;
upper bound 100% is physical). Default severity bands follow BTS Guideline
2017 + ATS clinical practice: green 95–100%, orange 92–94%, red <92% —
lower-only concern (the upper orange wing collapses onto greenMax since
saturation cannot physically exceed 100%). COPD / chronic-respiratory
users with a doctor-set baseline of 88–92% can personalize via the
threshold-override UI. Wired through Withings (ScanWatch type 54),
measurement form, list, charts, doctor PDF, OpenAPI spec, and i18n (DE +
EN). iOS DTO already declared `OXYGEN_SATURATION` from a prior commit;
the server enum addition closes the long-standing drift.
- **Body composition surfaces (TOTAL_BODY_WATER, BONE_MASS, BLOOD_GLUCOSE)
in the measurements list filter, badge, mobile icon, edit dialog, and
server-rendered doctor-report PDF** — closes the UI side of #109. Root
cause was three local maps in `measurement-list.tsx` that drifted from
the v1.3 server enum; extracted to `measurement-list-meta.ts` with
fail-fast coverage tests so future enum additions are caught at build
time. Server-side PDF used a separately-drifted type map vs. the
browser-side renderer; both are now in sync.
- **Effective-range thresholds for `TOTAL_BODY_WATER` and `BONE_MASS`** —
severity logic was returning `nominal` for any value because no defaults
existed.
### Changed
- **OpenAPI `MeasurementType` enum extended + spec version bumped 1.3.0 →
1.3.3** to match the actual app. Spec was lagging by two minor releases.
- **Withings webhook secret now reads from `X-Withings-Webhook-Secret`
header** in preference to the legacy `?secret=…` URL query parameter.
Closes the URL-leak-via-access-logs vector flagged in audit C-3. Legacy
query-param path is retained for backwards compatibility and emits a
Wide Event warning so operators can spot still-using-the-old-flow
integrators. Plan: remove the query fallback in 1.4.x once warnings drain.
- **Idempotency `defaultUserIdResolver` now supports Bearer tokens.**
Cookie sessions tried first, then Bearer-token via `hashToken` lookup.
Without the Bearer fallback, every iOS / external-ingest retry was
hitting the handler again and creating duplicate measurements (audit
C-4 — the exact use case `withIdempotency` was built for).
- **GlitchTip URL stripping** — `reportToGlitchtip` now strips the URL
query string before forwarding so Withings legacy `?secret=…` and OAuth
`?code=…` callbacks cannot leak via the error tracker (audit H-B7).
### Fixed
- **Migration `0022_body_composition_metrics` unit comment lied** —
claimed `TOTAL_BODY_WATER: percent of body weight (%)` while every other
surface (validators, Withings client, doctor PDF) treated it as `kg`.
Comment corrected to match reality.
### Security
- **Bearer-scope wildcard handling (CRITICAL — V3-1).** `requireAuth()`
previously accepted any non-admin token regardless of declared
permission scope, so a token with `permissions:["medication:ingest"]`
could DELETE the user account. Spec now requires `permissions:["*"]`
or the explicit required permission.
- **Account-deletion completeness (CRITICAL — V3-2 / GDPR Art. 17).**
Cascades through `Feedback` + `AuditLog` rows so user-erasure is
actually total. Daily retention job sweeps orphaned audit rows after
90 days as a defence-in-depth.
- **Withings webhook secret header migration (audit C-3)**, idempotency
Bearer-resolver (audit C-4), GlitchTip URL strip (audit H-B7).
- **Truthfulness pass on medical citations** — SpO2 normal-range source
is now consumer-pulse-oximeter consensus + NICE NG115 + FDA labelling
(BTS-2017 was for clinical hypoxaemia thresholds, not consumer
monitoring); body-composition metrics are explicitly labelled
"bioimpedance-estimated, not DEXA-comparable" in the doctor PDF;
TBW citation now references the Watson formula / ICRP Reference Man
(was misattributed to ESPEN 2017); steps target now references
Saint-Maurice JAMA 2020 (WHO publishes minutes/week, not steps).
- **SpO2 user-override clamp** — overrides could emit physical
impossibilities (e.g. `orangeMax = 100.75`); clamped to METRIC_BOUNDS
for SpO2 + BODY_FAT.
- **moodLog webhook secret encrypted at rest with AES-256-GCM** (V3
STILL-V2-C-2). Read path tolerates legacy plaintext rows during the
transition window; one-shot startup migration in the worker rotates
any leftover plaintext rows.
- **CSP tightening** — `chatgpt.com` + `api.openai.com` `connect-src`
now gated to `/settings/ai/**` (was a global blanket on every page,
including `/auth/login` → DOM-XSS exfil channel).
- **Web-Push subscription endpoint SSRF guard** — `endpoint` now
requires HTTPS + passes `isPublicUrl()` (was `z.url()` only).
Side-fix: `isPublicUrl()` no longer falsely classifies DNS labels
starting with `fc`/`fd` (e.g. `fcm.googleapis.com`) as IPv6
unique-local; the IPv6 check is now gated on a colon being present.
- **IP-geolocation lookup is now HTTPS-only.** Default provider is
`ipwho.is` (free, HTTPS, no key). Existing `ip-api.com` plaintext
HTTP path leaked auth-event IP + timestamp on every login (GDPR Art.
32 + Art. 44). Operators can override via `IP_GEO_LOOKUP_URL` (HTTPS
only) or disable entirely with `IP_GEO_LOOKUP_DISABLED=1`.
- **`/api/ai/test` no longer returns provider error message + body
excerpt to the client.** Diagnostics land server-side via Wide Events
(annotate); client gets a categorised generic message. Closes provider
URL / partial key / internal header leak.
- **`/api/import` rate-limit added** — 5 imports/hour/user. Was
unlimited (bulk-injection vector).
- **Trusted-proxy XFF semantics** — `getClientIp()` now reads
`X-Forwarded-For` right-to-left with a configurable
`TRUST_PROXY_HOPS` (default 1, matches typical single-proxy
self-host). Closes XFF rotation bypass of per-IP rate-limits.
- **Audit-log retention job** — `audit_logs` rows older than
`AUDIT_LOG_RETENTION_DAYS` (default 365) are purged daily. Closes
GDPR Art. 5(1)(e) "storage limitation" gap.
- **Idempotency cachable-status filter** is now an exported, unit-tested
function — pins the do-not-cache contract for 401/403/408/429/5xx.
- **Bearer mock tightening** in `require-auth-bearer.test.ts` +
`idempotency.test.ts`: `apiToken.findUnique` calls are now asserted
to use `where: { tokenHash: <hashed> }`, so a regression to raw-token
comparison would break the suite immediately.
### Internal
- **Server-side enum drift cousins closed.** Five module-level
hardcoded type-arrays in `/api/insights/comprehensive`,
`/api/dashboard/summary`, `/api/analytics`, `/lib/insights/general-status`,
`/api/import` are now derived from `measurementTypeEnum.options`.
External-contract enums extended additively:
`/api/measurements/series` (`oxygen`, `totalBodyWater`, `boneMass`),
`/api/dashboard/widgets` (`oxygenSaturation`), `DashboardWidgetId` +
`DEFAULT_DASHBOARD_LAYOUT`. New coverage test asserts the canonical
enum stays the source of truth.
- **Doctor-PDF text-content tests** — replaced bytes-only "renders body
composition rows" theatre with `pdf-parse`-driven assertions on the
actual rendered DE + EN labels and values. Adds dev dep `pdf-parse`.
## [1.3.2] — 2026-04-28
### Fixed
+21 -6
View File
@@ -36,7 +36,7 @@ docker compose logs -f app # Tail app logs
## Architecture
- **Next.js 16** App Router with TypeScript strict. Pages are RSC by default; `"use client"` only for interactivity.
- **Prisma 7** ORM with PostgreSQL (23 models). Uses `PrismaPg` adapter from `@prisma/adapter-pg`. Client singleton at `src/lib/db.ts`. Generated client at `src/generated/prisma/client` (note the `/client` suffix). Prisma config in `prisma.config.ts` (not in schema.prisma).
- **Prisma 7** ORM with PostgreSQL (26 models). Uses `PrismaPg` adapter from `@prisma/adapter-pg`. Client singleton at `src/lib/db.ts`. Generated client at `src/generated/prisma/client` (note the `/client` suffix). Prisma config in `prisma.config.ts` (not in schema.prisma).
- **shadcn/ui** components (new-york style) in `src/components/ui/`. Add new ones via `pnpm dlx shadcn@latest add <component>`.
- **Dracula theme** via CSS variables in `globals.css`. Dark mode is default. Use `--dracula-*` tokens for chart colors.
- **TanStack Query** for client-side data fetching. Provider in `src/components/providers.tsx`.
@@ -44,7 +44,7 @@ docker compose logs -f app # Tail app logs
- **API response envelope**: `{ data, error, meta }` via helpers in `src/lib/api-response.ts`.
- **apiHandler** wraps ALL API route handlers (`src/lib/api-handler.ts`) — error handling, Wide Event logging, x-request-id propagation.
- **Structured Logging** (Wide Events) in `src/lib/logging/` — one JSON event per request/operation via `WideEventBuilder` + `AsyncLocalStorage`. Tail sampling, stdout JSON + optional Loki Push.
- **Vitest** for unit testing. Config in `vitest.config.ts`.
- **Vitest** for unit testing. Config in `vitest.config.mts`.
- **pg-boss** (PostgreSQL-native) for job queue: medication reminders, insight caching, weekly data backups.
- **jsPDF** + jspdf-autotable for client-side doctor report PDF generation (`src/lib/doctor-report-pdf.ts`).
- **PWA offline** via service worker caching strategies in `public/sw.js` (cache-first for static, network-first for pages, network-only for API).
@@ -56,7 +56,7 @@ docker compose logs -f app # Tail app logs
- Timezone: `Europe/Berlin` for display, UTC in database.
- Sensitive data (Withings tokens, API keys, VAPID private keys) encrypted with AES-256-GCM (`src/lib/crypto.ts`) before DB storage.
- Passkeys are primary auth (SimpleWebAuthn v13). Sessions stored server-side in PostgreSQL.
- All API mutations require authentication. External ingest uses Bearer token (hashed with SHA-256).
- All API mutations require authentication. External ingest uses Bearer token (hashed with HMAC-SHA256 keyed by `API_TOKEN_HMAC_KEY`).
- Rate limiting (in-memory sliding window) on auth and external-facing endpoints.
## File Layout
@@ -68,6 +68,8 @@ docker compose logs -f app # Tail app logs
- `src/components/measurements/` — measurement form, list
- `src/components/mood/` — mood form, mood list
- `src/components/charts/` — Recharts wrappers
- `src/components/settings/` — per-route Settings section components (one per `/settings/[section]`)
- `src/components/admin/` — per-route Admin section components (system-status, integrations, monitoring, users, audit, danger-zone, feedback)
- `src/lib/logging/` — Wide Events structured logging (types, config, event-builder, context, sampler, transports, background)
- `src/lib/api-handler.ts` — apiHandler wrapper, requireAuth(), requireAdmin(), HttpError
- `src/lib/` — server utilities (db, crypto, auth, analytics, export, rate-limit, gravatar)
@@ -81,12 +83,14 @@ docker compose logs -f app # Tail app logs
- `src/lib/validations/` — Zod schemas shared between API + client
- `src/hooks/` — React hooks (`use-auth`)
- `messages/de.json` + `messages/en.json` — i18n translations
- `prisma/schema.prisma` — database schema (23 models)
- `prisma/schema.prisma` — database schema (26 models)
- `tests/integration/` — Postgres testcontainers integration suite (rate-limit race, idempotency replay, GDPR cascade delete, session lifecycle); `pnpm test:integration`
- `e2e/` — Playwright + axe-core suite for public smoke checks (version, auth-redirect, login, locale-switch, a11y); `pnpm e2e`
- `prisma.config.ts` — Prisma config (DB URL here, not in schema)
- `public/sw.js` — Service worker for Web Push notifications + offline caching
- `docs/` — architecture, security, assumptions, ops, API docs, ADRs
- `docs/STATUS.md` — Current project status + open tasks
- `docs/` — long-form audit notes (`docs/audit/`); end-user docs live in the separate site at https://docs.healthlog.dev
- `AGENTS.md` — AI agent instructions (Codex, Cursor, etc.)
- `CHANGELOG.md` — release notes per semver tag
## Important Patterns
@@ -107,3 +111,14 @@ docker compose logs -f app # Tail app logs
- **Achievements**: Persistent in `UserAchievement` table. Computed on API call, new unlocks written to DB with stable `unlockedAt` timestamps
- **Data backup**: pg-boss `data-backup` queue runs weekly (Sundays 03:00), stores JSON in `DataBackup` model
- **Wide Events / Structured Logging**: `apiHandler()` wraps all API routes. Use `annotate()` from `@/lib/logging/context` for business-action annotations. Use `requireAuth()` / `requireAdmin()` from `@/lib/api-handler` (auto-annotates auth). Background jobs use `withBackgroundEvent()`. External calls tracked via `getEvent()?.addExternalCall()`. No `console.log` in production code — use event annotations instead. Env vars: `LOG_LEVEL`, `LOG_SAMPLE_RATE`, `LOG_SLOW_THRESHOLD_MS`, `LOG_INCLUDE_STACK`, `LOKI_ENDPOINT`, `LOKI_USERNAME`, `LOKI_PASSWORD`
- **Multi-tenant prep (v1.4)**: `HEALTHLOG_PROCESS_TYPE=web|worker|all` (default `all`) splits HTTP and pg-boss workloads; the proxy refuses HTTP traffic with 503 + `X-HealthLog-Process-Type: worker` in worker mode. `ENCRYPTION_KEYS` is a JSON map of versioned keys (`{"v1": "...", "v2": "..."}`) plus `ENCRYPTION_ACTIVE_KEY_ID` for new writes; rotation via `pnpm tsx scripts/rotate-encryption-key.ts <id>`. `BACKUP_S3_*` env block configures off-host weekly encrypted backups (PutObject + GetObject only — retention is the bucket's lifecycle policy, never the worker).
- **Native API clients (v1.4)**: `POST /api/auth/login` and `/api/auth/passkey/login-verify` issue a 24h access token (`hlk_<64hex>`) AND a refresh token (`hlr_<64hex>`) when `X-Client-Type: native` or the User-Agent starts with `HealthLog-iOS/`. The browser flow is unchanged. Refresh-token reuse-detection revokes every refresh token for the user. The idempotency replay-cache rejects bodies containing `hlk_` OR `hlr_` so cached responses can never echo a token back.
## Headless-Client API Patterns
The iOS app talks to bearer-protected routes while the web PWA uses cookie sessions; both share the same handlers (`requireAuth()` accepts either credential).
- **Bearer-token issuance**: `POST /api/auth/login` and `POST /api/auth/passkey/login-verify` return a `token` field for native clients. `isNativeClientRequest()` (`src/lib/auth/issue-token.ts`) opts in via `X-Client-Type: native` header **or** a `User-Agent` starting with `HealthLog-iOS/`. The raw `hlk_<64-hex>` value is returned exactly once; default lifetime 90 days, permissions `["*"]`.
- **Token storage**: API tokens are stored as **HMAC-SHA-256** hashes (`hashToken()` in `src/lib/auth/hmac.ts`, keyed by env `API_TOKEN_HMAC_KEY`) — not plain SHA-256. The keyed HMAC defends against precomputed rainbow tables if the DB leaks. Same mechanism is reused for external-ingest token verification.
- **Idempotency-Key**: `withIdempotency()` (`src/lib/idempotency.ts`) wraps POST/PUT/PATCH/DELETE handlers so retries with the same `Idempotency-Key` (regex `^[A-Za-z0-9_\-:.]{8,128}$`) for the same `(userId, method, path)` replay the original response with `X-Idempotent-Replay: true` for 24h. **NOT cached**: 401, 403, 408, 429, any 5xx, and any body containing `hlk_` (defence against caching a token-issuing response). Validation 4xx (422 etc.) **is** cached so a broken retry doesn't re-hit the DB. `userIdResolver` defaults to the cookie session — pass a 2nd argument for Bearer-only routes.
- **Device-registration cross-user-hijack guard**: `POST /api/devices` rejects re-registration of an existing APNs token under a different user with **409** (`Device token already registered to another account`) instead of silently transferring ownership. APNs tokens aren't a secret, so trusting wire input would let anyone who learns a token redirect another user's pushes. Audit trail: `device.register.denied` with reason `token_owned_by_other_user`.
+29 -16
View File
@@ -4,7 +4,7 @@ Contributions are welcome. This guide covers how to set up the project and submi
## Prerequisites
- Node.js 22+
- Node.js 20+
- pnpm
- PostgreSQL 16+ (or Docker)
- Git
@@ -20,13 +20,21 @@ cp .env.example .env
Edit `.env` and configure at minimum:
```
DATABASE_URL="postgresql://healthlog:healthlog@localhost:5432/healthlog"
POSTGRES_PASSWORD=<base64, 24 bytes>
DATABASE_URL="postgresql://healthlog:${POSTGRES_PASSWORD}@db:5432/healthlog"
SESSION_SECRET=<64-char hex>
ENCRYPTION_KEY=<64-char hex>
API_TOKEN_HMAC_KEY=<64-char hex>
```
Generate secrets with `openssl rand -hex 32`.
Generate the four secrets:
```bash
echo "POSTGRES_PASSWORD=$(openssl rand -base64 24)" >> .env
echo "SESSION_SECRET=$(openssl rand -hex 32)" >> .env
echo "ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .env
echo "API_TOKEN_HMAC_KEY=$(openssl rand -hex 32)" >> .env
```
### With Docker (database only)
@@ -47,23 +55,28 @@ docker compose up -d
## Development Workflow
```bash
pnpm dev # Start dev server (http://localhost:3000)
pnpm lint # ESLint
pnpm format # Prettier
pnpm typecheck # TypeScript strict mode
pnpm test # Vitest
pnpm dev # Start dev server (http://localhost:3000)
pnpm lint # ESLint
pnpm format:check # Prettier (check)
pnpm typecheck # TypeScript strict mode
pnpm test # Vitest
pnpm build # Next.js production build
```
All four checks must pass before submitting a PR.
All five checks must pass before submitting a PR. The full one-liner that mirrors CI:
```bash
pnpm typecheck && pnpm lint && pnpm test && pnpm format:check && pnpm build
```
## Code Conventions
- **TypeScript strict mode** -- no `any` types, no type assertions unless absolutely necessary
- **UI text in German**, code/comments/docs in English
- **API responses** follow `{ data, error, meta }` envelope pattern
- **Zod v4** for input validation (API routes and forms)
- **shadcn/ui** components (new-york style) for UI primitives
- **Dracula theme** -- dark mode only, use `--dracula-*` CSS tokens
- **TypeScript strict mode** -- avoid `any`. Where it's truly unavoidable (e.g. variadic Next.js handler signatures in `src/lib/api-handler.ts`), document the reason inline.
- **English-default UI** -- the default locale is English. German is user-selectable. Code, comments, commit messages, and docs are English. All user-facing strings go through `t("key")` with translations in both `messages/en.json` and `messages/de.json`. The `i18n-locale-integrity.test.ts` suite blocks merges if keys drift between locales or duplicate at any nesting depth.
- **API responses** follow the `{ data, error }` envelope via `apiSuccess()` / `apiError()` in `src/lib/api-response.ts`. Wrap every route handler in `apiHandler()` (`src/lib/api-handler.ts`) for error handling, structured Wide-Event logging, and `x-request-id` propagation.
- **Zod v4** (`import { ... } from "zod/v4"`) for input validation in API routes and forms.
- **shadcn/ui** components (new-york style) for UI primitives.
- **Dracula theme** -- dark mode is the default; a `light`/`system` toggle is exposed in the topbar. Chart and accent colors use the `--dracula-*` CSS tokens so both themes stay coherent.
See `CLAUDE.md` for detailed architecture and patterns.
@@ -82,7 +95,7 @@ Always include the migration file in your PR.
1. Fork the repository
2. Create a feature branch (`git checkout -b feature/your-feature`)
3. Make your changes
4. Ensure all checks pass (`pnpm lint && pnpm typecheck && pnpm test`)
4. Ensure all checks pass (`pnpm typecheck && pnpm lint && pnpm test && pnpm format:check && pnpm build`)
5. Submit a PR with a clear description of the changes
## Security
+6 -1
View File
@@ -23,12 +23,17 @@ RUN pnpm build
# ── Stage 3: Production runner ─────────────────────────────
FROM node:22-alpine AS runner
RUN corepack enable && corepack prepare pnpm@latest --activate
# `tzdata` is required so Europe/Berlin schedules (pg-boss cron, locale-aware
# timestamp formatting) resolve to the actual offset instead of silently
# falling back to UTC on Alpine images that ship without it.
RUN apk add --no-cache tzdata && \
corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_PATH="/opt/pg-boss/node_modules"
ENV TZ=Europe/Berlin
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
+131 -22
View File
@@ -37,7 +37,7 @@ Most health apps lock your data behind proprietary clouds, push subscriptions, a
## Key Features
**Health Metrics** -- Track weight, blood pressure, pulse, body fat, sleep, steps, blood glucose (fasting/postprandial/random/bedtime, mg/dL ↔ mmol/L), total body water, and bone mass with interactive trend charts, moving averages, and traffic-light ranges based on ESC/ESH 2018 and ADA 2024 guidelines. Body-composition metrics sync automatically from Withings Body+ scales.
**Health Metrics** -- Track weight, blood pressure, pulse, body fat, sleep, steps, blood glucose (fasting/postprandial/random/bedtime, mg/dL ↔ mmol/L), total body water, bone mass, and pulse oximetry (SpO₂) with interactive trend charts, moving averages, and traffic-light ranges based on ESC/ESH 2018, ADA 2024, and consensus pulse-oximeter guidance (NICE NG115). Body-composition + SpO₂ metrics sync automatically from Withings Body+ scales and ScanWatch devices.
**Custom Thresholds** -- Override the computed default ranges per metric with the targets your clinician set. Audit-logged. Doctor Report PDF prints both your target and the standard reference.
@@ -59,9 +59,13 @@ Most health apps lock your data behind proprietary clouds, push subscriptions, a
**Multi-Channel Notifications** -- Telegram (with inline action buttons), ntfy (self-hostable), and Web Push. Medication reminders with late/missed escalation.
**Gamification** -- 30+ persistent achievements across intake streaks, compliance milestones, and healthy metric streaks.
**Gamification** -- 38+ persistent achievements across intake streaks, compliance milestones, and healthy metric streaks.
**Internationalization** -- English (default) and German UI with 1000+ translation keys. Numbers, dates, units, and AI prompts all locale-aware via `useFormatters()`. Browser-based detection with per-user override.
**Internationalization** -- English (default) and German UI with 1500+ translation keys, guarded by a CI integrity test that fails the build on duplicate keys or drift between locales. Numbers, dates, units, and AI prompts all locale-aware via `useFormatters()`. Browser-based detection with per-user override.
**Multi-tenant ready** _(v1.4)_ — Off-host AES-GCM-encrypted weekly backups to any S3-compatible bucket (R2, B2, MinIO, AWS), encryption-key versioning + zero-downtime rotation CLI, optional `HEALTHLOG_PROCESS_TYPE=web|worker|all` so HTTP and pg-boss can scale independently, and short-lived 24h access tokens with refresh-token rotation for native API clients. The browser cookie session is unchanged.
**Test connection buttons** _(v1.4)_ — One-click probes for Withings, moodLog.app, Web Push, Glitchtip, and Umami in addition to the existing AI / Telegram / ntfy tests. Each one rate-limited, sanitised against SSRF redirects, and surfaces a localisable `errorCode` so the UI can render the failure in the user's language.
---
@@ -102,7 +106,7 @@ Open **http://localhost:3000**. The first registered user becomes admin.
| ------------- | ------------------------------------------------- |
| Framework | Next.js 16 (App Router, React Server Components) |
| Language | TypeScript (strict mode) |
| Database | PostgreSQL 16 + Prisma 7 (23 models) |
| Database | PostgreSQL 16 + Prisma 7 (26 models) |
| Job Queue | pg-boss 12 (reminders, insights, backups) |
| UI | shadcn/ui, Tailwind CSS 4, Radix UI, Lucide Icons |
| Charts | Recharts 3 |
@@ -163,30 +167,50 @@ Telegram bot token, ntfy settings, Web Push VAPID keys, Umami, and GlitchTip URL
```
src/
├── app/ # Next.js App Router pages & API routes
│ ├── api/ # REST API endpoints (~60 route files)
│ ├── api/ # REST API endpoints (100+ route files)
│ ├── admin/ # Admin panel
│ ├── auth/ # Login, register, passkey enrolment
│ ├── medications/ # Medication management
│ ├── measurements/ # Health metric entry
│ ├── mood/ # Mood log
│ ├── insights/ # AI-powered analytics
│ ├── charts/ # Long-form charts
│ ├── achievements/ # Gamification page
│ ├── targets/ # Custom thresholds dashboard
│ ├── notifications/ # Notification preferences matrix
│ ├── bugreport/ # Built-in feedback / bug report
│ ├── onboarding/ # 4-step guided setup
│ └── settings/ # User preferences
│ └── settings/ # User preferences (8 top-level sections)
├── components/
│ ├── ui/ # shadcn/ui primitives
│ ├── layout/ # Shell (sidebar, topbar, bottom nav)
│ ├── medications/ # Medication cards, forms, timeline
│ ├── measurements/ # Measurement form, list
│ ├── mood/ # Mood form, mood list
│ └── charts/ # Recharts wrappers
│ ├── charts/ # Recharts wrappers
│ ├── insights/ # AI insight status / advisor cards
│ ├── gamification/ # Achievement cards, progress
│ ├── monitoring/ # Umami / GlitchTip bootstrap
│ └── settings/ # Settings-page section components
├── lib/
│ ├── auth/ # Session, audit, passkey logic
│ ├── notifications/ # Dispatcher + channel senders
│ ├── jobs/ # pg-boss worker
│ ├── analytics/ # Trend calculations, compliance
│ ├── jobs/ # pg-boss worker (reminders, insights, backups)
│ ├── analytics/ # Trend calculations, compliance, correlations
│ ├── ai/ # Multi-provider client (OpenAI, Anthropic, local)
│ ├── insights/ # Insight pipeline + medical prompts
│ ├── gamification/ # Achievement definitions
│ ├── feedback/ # Built-in feedback + GitHub escalation
│ ├── moodlog/ # moodLog.app webhook + sync
│ ├── monitoring/ # Umami / GlitchTip server-side hooks
│ ├── withings/ # OAuth client, sync service
│ ├── logging/ # Wide Events: builder, context, transports
│ ├── i18n/ # Translations context & config
│ ├── validations/ # Shared Zod schemas
│ ├── api-handler.ts # apiHandler wrapper, requireAuth/requireAdmin
│ ├── api-response.ts # { data, error } envelope helpers
│ ├── crypto.ts # AES-256-GCM encrypt/decrypt
│ ├── rate-limit.ts # Sliding-window rate limiter
│ ├── db.ts # Prisma singleton
│ └── doctor-report-pdf.ts # Client-side PDF generation
├── hooks/ # React hooks
@@ -196,10 +220,11 @@ src/
### Key Patterns
- **RSC by default** -- `"use client"` only for interactive components
- **API envelope** -- All responses follow `{ data, error, meta }` shape
- **API envelope** -- All responses follow `{ data, error }` shape via `apiSuccess()` / `apiError()` in `src/lib/api-response.ts`
- **apiHandler wrapper** -- Every API route wraps its handler in `apiHandler()` (`src/lib/api-handler.ts`) for consistent error handling, Wide-Event structured logging, and `x-request-id` propagation
- **Encrypted secrets** -- Withings tokens, API keys, VAPID keys, notification credentials
- **Timezone-aware** -- `Europe/Berlin` for display, UTC in database
- **Route protection** -- `proxy.ts` checks session cookie, redirects unauthenticated requests
- **Route protection** -- `proxy.ts` (Next.js 16's renamed middleware) checks session cookie, redirects unauthenticated requests
- **Client-side PDF** -- Doctor reports generated in browser via jsPDF
---
@@ -254,17 +279,101 @@ All mutations require authentication via session cookie. External ingest uses Be
<details>
<summary><strong>Auth and Integrations</strong></summary>
| Method | Endpoint | Description |
| ------ | -------------------------------- | ----------------------- |
| `POST` | `/api/auth/register` | Create account |
| `POST` | `/api/auth/login` | Password login |
| `POST` | `/api/auth/logout` | Destroy session |
| `GET` | `/api/auth/me` | Current user profile |
| `POST` | `/api/auth/passkey/*` | WebAuthn flows |
| `GET` | `/api/withings/connect` | Initiate Withings OAuth |
| `POST` | `/api/insights/generate` | Regenerate AI insights |
| `GET` | `/api/gamification/achievements` | Achievement progress |
| `GET` | `/api/health` | Docker health check |
| Method | Endpoint | Description |
| ------- | -------------------------------- | ----------------------------------- |
| `POST` | `/api/auth/register` | Create account |
| `POST` | `/api/auth/login` | Password login |
| `POST` | `/api/auth/logout` | Destroy session |
| `GET` | `/api/auth/me` | Current user profile + Gravatar URL |
| `POST` | `/api/auth/password` | Change password |
| `PATCH` | `/api/auth/profile` | Update profile fields |
| `POST` | `/api/auth/passkey/*` | WebAuthn flows (4 sub-routes) |
| `GET` | `/api/auth/passkeys` | List enrolled passkeys |
| `GET` | `/api/auth/codex/authorize` | ChatGPT (codex) OAuth start |
| `GET` | `/api/withings/connect` | Initiate Withings OAuth |
| `POST` | `/api/withings/sync` | Trigger manual Withings sync |
| `POST` | `/api/withings/webhook` | Withings notification webhook |
| `POST` | `/api/insights/generate` | Regenerate AI insights |
| `GET` | `/api/insights/comprehensive` | Aggregated insight payload |
| `GET` | `/api/gamification/achievements` | Achievement progress |
| `GET` | `/api/health` | Docker health check |
</details>
<details>
<summary><strong>Personalization (Thresholds + Dashboard)</strong></summary>
| Method | Endpoint | Description |
| ------ | -------------------------- | ---------------------------------------------- |
| `GET` | `/api/user/thresholds` | Read per-user threshold overrides |
| `PUT` | `/api/user/thresholds` | Upsert thresholds (rate-limited, audit-logged) |
| `GET` | `/api/insights/targets` | Effective ranges (defaults + overrides merged) |
| `GET` | `/api/dashboard/widgets` | Read dashboard layout |
| `PUT` | `/api/dashboard/widgets` | Persist dashboard layout (show/hide/reorder) |
| `POST` | `/api/onboarding/complete` | Mark onboarding finished |
</details>
<details>
<summary><strong>Feedback + API Tokens</strong></summary>
| Method | Endpoint | Description |
| -------- | ----------------------- | ----------------------------------- |
| `POST` | `/api/feedback` | Submit in-app feedback |
| `GET` | `/api/bugreport/status` | Check published GitHub issue state |
| `GET` | `/api/tokens` | List own API tokens |
| `POST` | `/api/tokens` | Mint new API token (Bearer, hashed) |
| `DELETE` | `/api/tokens/:id` | Revoke API token |
</details>
<details>
<summary><strong>Notifications</strong></summary>
| Method | Endpoint | Description |
| ------ | -------------------------------- | ----------------------------------- |
| `GET` | `/api/notifications/preferences` | Read per-channel × per-event matrix |
| `PUT` | `/api/notifications/preferences` | Update preferences |
| `GET` | `/api/notifications/vapid` | VAPID public key for Web Push |
| `POST` | `/api/notifications/web-push` | Register Web Push subscription |
| `POST` | `/api/telegram/webhook` | Telegram bot inline-button callback |
</details>
<details>
<summary><strong>Admin (admin role required)</strong></summary>
| Method | Endpoint | Description |
| ------ | ------------------------------------- | ------------------------------------- |
| `GET` | `/api/admin/status` | System + integration status |
| `GET` | `/api/admin/users` | List users |
| `POST` | `/api/admin/users/:id/reset-password` | Force password reset |
| `GET` | `/api/admin/feedback` | All feedback / bug reports |
| `POST` | `/api/admin/feedback/:id/github` | Escalate feedback to GitHub issue |
| `GET` | `/api/admin/audit-log` | Audit-log viewer |
| `GET` | `/api/admin/ai-settings` | Read shared AI provider config |
| `PUT` | `/api/admin/ai-settings` | Update shared AI provider config |
| `GET` | `/api/admin/tokens` | All issued API tokens |
| `POST` | `/api/admin/notifications/test` | Send test notification |
| `GET` | `/api/admin/data` | Data backups + counts |
| `GET` | `/api/admin/status-overview` | Aggregated status for the 6-card grid |
| `POST` | `/api/admin/backup/test` | Probe S3-compatible backup target |
</details>
<details>
<summary><strong>Public + v1.4 additions</strong></summary>
| Method | Endpoint | Description |
| ------ | ---------------------------------- | ----------------------------------------------- |
| `GET` | `/api/version` | Public — version + build SHA + license, no auth |
| `POST` | `/api/integrations/withings/test` | Probe a saved Withings connection |
| `POST` | `/api/integrations/moodlog/test` | Probe moodLog.app webhook reachability |
| `POST` | `/api/notifications/web-push/test` | Send a test Web Push to the current user |
| `POST` | `/api/monitoring/glitchtip/test` | Trigger a Glitchtip ingest probe |
| `POST` | `/api/monitoring/umami/test` | Verify Umami script + website ID resolve |
| `POST` | `/api/auth/refresh` | Native client refresh-token rotation |
| `POST` | `/api/auth/refresh/revoke` | Revoke an issued refresh token |
</details>
+1 -1
View File
@@ -70,6 +70,6 @@ HealthLog Docker images are built and published from this repository's CI:
- **Provenance attestation**: each image carries a [SLSA build provenance](https://slsa.dev/spec/v1.0/provenance) statement linking it back to the GitHub Actions run, the commit SHA, and the workflow definition
- **SBOM**: each image includes a [Software Bill of Materials](https://docs.docker.com/build/metadata/attestations/sbom/) you can inspect with `docker buildx imagetools inspect ghcr.io/mbombeck/healthlog:latest --format '{{ json .SBOM }}'`
To pin to a specific version in production, replace `:latest` with the released tag in your `docker-compose.yml`, e.g. `ghcr.io/mbombeck/healthlog:1.2.0`. Pinning is recommended for self-hosters who want explicit control over upgrades.
To pin to a specific version in production, replace `:latest` with the released tag in your `docker-compose.yml`, e.g. `ghcr.io/mbombeck/healthlog:1.3.2`. Pinning is recommended for self-hosters who want explicit control over upgrades.
If you discover a tampered or unexpected image, please report it as a vulnerability via the email above.
+62 -14
View File
@@ -1,35 +1,83 @@
services:
# Single-container default (HEALTHLOG_PROCESS_TYPE=all). Existing
# self-host deployments stay working with `docker compose up`.
# For horizontal scaling switch to the web/worker split below
# (HEALTHLOG_PROCESS_TYPE=web on this service + uncomment app-worker).
app:
# Pre-built multi-arch images are published to GHCR by
# .github/workflows/docker-publish.yml on every push to main and on
# tagged releases. Pin to a specific version in production:
# e.g. ghcr.io/mbombeck/healthlog:1.2.0
# Pinned to GHCR :1.3.3 multi-arch image (gestern released, audit-tested).
# Production on apps-01 has been 503-ing since today's deploys; rolling back
# to the last release that's verifiably running on self-host installs to
# rule out runtime / Docker-network issues. No `build:` block on purpose:
# we want Coolify to PULL the prebuilt image, not build from source.
image: ghcr.io/mbombeck/healthlog:latest
# Fallback build context: if the image is not yet in the registry,
# or you want to test local changes, run `docker compose up --build`.
build:
context: .
dockerfile: Dockerfile
container_name: healthlog-app
ports:
- "3000:3000"
environment:
# Plain ${VAR} interpolation only — no ${VAR:?error} validation
# syntax. Some hosting platforms (e.g. Coolify) parse compose files
# eagerly and store the *fallback error string* as the literal value
# when the env var is unset, which then breaks the deployment.
# Validation is enforced by docker-entrypoint.sh before the app starts.
DATABASE_URL: "postgresql://healthlog:${POSTGRES_PASSWORD}@db:5432/healthlog?schema=public"
NODE_ENV: production
SESSION_SECRET: "${SESSION_SECRET}"
ENCRYPTION_KEY: "${ENCRYPTION_KEY}"
ENCRYPTION_KEYS: "${ENCRYPTION_KEYS:-}"
ENCRYPTION_ACTIVE_KEY_ID: "${ENCRYPTION_ACTIVE_KEY_ID:-}"
API_TOKEN_HMAC_KEY: "${API_TOKEN_HMAC_KEY}"
NEXT_PUBLIC_APP_URL: "${NEXT_PUBLIC_APP_URL:-http://localhost:3000}"
APP_URL: "${APP_URL:-http://localhost:3000}"
# all = bundle web + worker in this container (default, single-host)
# web = serve HTTP only; pair with a separate app-worker service
HEALTHLOG_PROCESS_TYPE: "${HEALTHLOG_PROCESS_TYPE:-all}"
BACKUP_ENCRYPTION_KEY: "${BACKUP_ENCRYPTION_KEY:-}"
BACKUP_S3_ENDPOINT: "${BACKUP_S3_ENDPOINT:-}"
BACKUP_S3_BUCKET: "${BACKUP_S3_BUCKET:-}"
BACKUP_S3_ACCESS_KEY: "${BACKUP_S3_ACCESS_KEY:-}"
BACKUP_S3_SECRET_KEY: "${BACKUP_S3_SECRET_KEY:-}"
BACKUP_S3_REGION: "${BACKUP_S3_REGION:-auto}"
BACKUP_RETENTION_DAYS: "${BACKUP_RETENTION_DAYS:-30}"
depends_on:
db:
condition: service_healthy
restart: unless-stopped
healthcheck:
test:
[
"CMD-SHELL",
"wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/api/version || exit 1",
]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
# Optional dedicated worker container — uncomment + set
# HEALTHLOG_PROCESS_TYPE=web on `app` to split the deployment.
# See docs/self-hosting/scaling.md for the full guide.
#
# app-worker:
# image: ghcr.io/mbombeck/healthlog:latest
# build:
# context: .
# dockerfile: Dockerfile
# container_name: healthlog-worker
# environment:
# DATABASE_URL: "postgresql://healthlog:${POSTGRES_PASSWORD}@db:5432/healthlog?schema=public"
# NODE_ENV: production
# SESSION_SECRET: "${SESSION_SECRET}"
# ENCRYPTION_KEY: "${ENCRYPTION_KEY}"
# ENCRYPTION_KEYS: "${ENCRYPTION_KEYS:-}"
# ENCRYPTION_ACTIVE_KEY_ID: "${ENCRYPTION_ACTIVE_KEY_ID:-}"
# API_TOKEN_HMAC_KEY: "${API_TOKEN_HMAC_KEY}"
# APP_URL: "${APP_URL:-http://localhost:3000}"
# HEALTHLOG_PROCESS_TYPE: "worker"
# BACKUP_ENCRYPTION_KEY: "${BACKUP_ENCRYPTION_KEY:-}"
# BACKUP_S3_ENDPOINT: "${BACKUP_S3_ENDPOINT:-}"
# BACKUP_S3_BUCKET: "${BACKUP_S3_BUCKET:-}"
# BACKUP_S3_ACCESS_KEY: "${BACKUP_S3_ACCESS_KEY:-}"
# BACKUP_S3_SECRET_KEY: "${BACKUP_S3_SECRET_KEY:-}"
# BACKUP_S3_REGION: "${BACKUP_S3_REGION:-auto}"
# depends_on:
# db:
# condition: service_healthy
# restart: unless-stopped
db:
image: postgres:16-alpine
+76
View File
@@ -0,0 +1,76 @@
# HealthLog API Spec
`openapi.yaml` is the canonical OpenAPI 3.1 description for the HealthLog
Next.js API. It is the source of truth for the native iOS client's
DTO codegen.
## Preview locally
Open an interactive Redoc preview in your browser:
```bash
npx @redocly/cli preview docs/api/openapi.yaml
```
Generate static HTML for sharing:
```bash
npx @redocly/cli build-docs docs/api/openapi.yaml --output docs/api/index.html
```
## Validate
```bash
npx @redocly/cli lint docs/api/openapi.yaml
```
The spec is expected to be lint-clean (errors = 0). Some warnings are
intentional:
- `no-server-example.com` — `http://localhost:3000` is the dev server.
- `operation-2xx-response` / `operation-4xx-response` — webhook + redirect
endpoints intentionally omit one or the other.
- `no-unused-components` — `ReminderPhase` is exposed for client codegen
even though no path response references it directly.
## Layout
- **`info`** — title, version (mirrored from `package.json`), description.
- **`servers`** — production `https://healthlog.bombeck.io` plus
`http://localhost:3000` for local development.
- **`tags`** — domain groupings (`Auth`, `Measurements`, `Medications`,
`Mood`, `Dashboard`, `Insights`, `Achievements`, `DoctorReport`,
`Integrations`, `User`, `Tokens`, `Notifications`, `Onboarding`,
`Health`, `Settings`, `Analytics`, `Export`, `AuditLog`, `Feedback`,
`BugReport`, `Monitoring`, `Webhooks`, `Ingest`, `Admin`).
- **`securitySchemes`**:
- `bearerAuth` — long-lived `hlk_*` API tokens issued via `POST /api/tokens`.
Required on `/api/ingest/medication`.
- `sessionCookie` — HttpOnly `healthlog_session` cookie set by the
password and passkey login flows. Used by the web app and any iOS
client running inside a web view.
- **`components.schemas`** — DTOs for all routes plus the underlying
Prisma models (e.g. `Measurement`, `Medication`, `MedicationIntakeEvent`,
`MoodEntry`, `User`, `DashboardLayout`, `ApiEnvelope`, `ApiError`).
- **`paths`** — all routes from `src/app/api/**/route.ts`, including
the `/api/admin/*` family which is tagged `Admin` and marked internal.
## Conventions
- Successful responses use the envelope `{ data, error: null }`.
- Error responses use the envelope `{ data: null, error: '<message>' }`.
- Pagination uses `limit` + `offset` query parameters and a
`meta: { total, limit, offset }` block on the response.
- All timestamps are ISO 8601 / RFC 3339 (`format: date-time`) in UTC.
- IDs are CUIDs (opaque strings).
## Updating the spec
When adding or changing routes:
1. Update the relevant route handler under `src/app/api/**/route.ts`.
2. Mirror the change in `openapi.yaml`. Keep `operationId` camelCase and
stable — the iOS codegen keys off it.
3. Run `npx @redocly/cli lint docs/api/openapi.yaml` and resolve all
errors. Warnings are tolerated when documented above.
4. Bump `info.version` if the change is breaking for clients.
File diff suppressed because it is too large. Load diff
+43 -32
View File
@@ -1,4 +1,5 @@
# HealthLog Audit-Plan — Phasen-Übersicht
**Datum:** 2026-04-26
**Audit-Bericht:** `~/infra/reports/healthlog-audit-2026-04-26.md`
**Status:** Geplant, noch nicht in Execution
@@ -13,19 +14,19 @@
## Phasen-Sequenz (10 Phasen)
| # | Phase | Skill | Effort | Issue-Bezug |
|---|---|---|---|---|
| **1** | Setup-Notfall + Versions-Sync | `gsd:quick` | ≤4 h | #87 (Pflaster), #88 |
| **2** | Docker-Image-Pipeline (GHCR-Push + Doc-Konsolidierung) | `gsd:plan-phase` | ~1 d | #88 |
| **3** | Issue #89 — TBW + Bone Mass hardcoded | `gsd:plan-phase` | ~1 d | #89 |
| **4** | `apiClient`-Wrapper + geteilte Zod-Schemas | `gsd:research-phase` → `gsd:plan-phase` | 2-3 d | #87 (strukturell) |
| **3.5** | **Onboarding-Redesign** (2 Steps, TTFV <60s) | `gsd:research-phase` → `gsd:plan-phase` | ~2 d | #87 (strukturell) |
| **4.5** | Empty-State-Dashboard-Tiles (eine pro Type) | `gsd:plan-phase` | ~1 d | #87, #89 |
| **4.6** | „Getting Started"-Checklist auf Dashboard | `gsd:plan-phase` | ~4 h | #87 |
| **5** | Security + Reliability Hardening | `gsd:plan-phase` | 1-2 d | — |
| **6** | Test-Coverage-Boost (Auth/Passkey/Withings/pg-boss) | `gsd:plan-phase` | 1-2 d | — |
| **7** | UX/i18n/a11y Polish (Residuals aus #73) | `gsd:quick` | ≤4 h | #73 (Residuals) |
| **8** | Settings-Page-Splitting (3143 → Sub-Components) | `gsd:research-phase` → `gsd:plan-phase` | 2-3 d | — |
| # | Phase | Skill | Effort | Issue-Bezug |
| ------- | ------------------------------------------------------ | --------------------------------------- | ------ | ------------------- |
| **1** | Setup-Notfall + Versions-Sync | `gsd:quick` | ≤4 h | #87 (Pflaster), #88 |
| **2** | Docker-Image-Pipeline (GHCR-Push + Doc-Konsolidierung) | `gsd:plan-phase` | ~1 d | #88 |
| **3** | Issue #89 — TBW + Bone Mass hardcoded | `gsd:plan-phase` | ~1 d | #89 |
| **4** | `apiClient`-Wrapper + geteilte Zod-Schemas | `gsd:research-phase` → `gsd:plan-phase` | 2-3 d | #87 (strukturell) |
| **3.5** | **Onboarding-Redesign** (2 Steps, TTFV <60s) | `gsd:research-phase` → `gsd:plan-phase` | ~2 d | #87 (strukturell) |
| **4.5** | Empty-State-Dashboard-Tiles (eine pro Type) | `gsd:plan-phase` | ~1 d | #87, #89 |
| **4.6** | „Getting Started"-Checklist auf Dashboard | `gsd:plan-phase` | ~4 h | #87 |
| **5** | Security + Reliability Hardening | `gsd:plan-phase` | 1-2 d | — |
| **6** | Test-Coverage-Boost (Auth/Passkey/Withings/pg-boss) | `gsd:plan-phase` | 1-2 d | — |
| **7** | UX/i18n/a11y Polish (Residuals aus #73) | `gsd:quick` | ≤4 h | #73 (Residuals) |
| **8** | Settings-Page-Splitting (3143 → Sub-Components) | `gsd:research-phase` → `gsd:plan-phase` | 2-3 d | — |
**Reihenfolge:** 1 → 2 → 3 → **4 (apiClient)** → **3.5 (Onboarding nutzt apiClient)** → 4.5 → 4.6 → 5 → 6 → 7 → 8.
@@ -64,6 +65,7 @@
**Goal:** User-Anfrage von Manschk3rl beantworten. Blueprint = v1.2-Glucose-Migration.
**Touchpoints:**
1. `prisma/schema.prisma`: `MeasurementType` Enum + 2 neue Werte
2. `prisma/migrations/0019_*` (additive `ALTER TYPE ... ADD VALUE`)
3. `src/lib/validations/measurement.ts`: `unitMap`, `VALUE_RANGES`
@@ -94,6 +96,7 @@
**Goal:** TTFV <60s, 90% Completion (statt aktuell ~8% global).
**Neuer Flow:**
- Step 1: Welcome + Anzeigename (optional) + Einheiten + Größe (optional). Sekundärlink „Skip everything → Dashboard".
- Step 2: Multi-Select-Chips „Was willst du tracken?" (Weight/BP/Pulse/Mood/Glucose/Sleep/Steps/BodyFat/TBW/BoneMass/Medications). Pre-checked Defaults. Speichert in `User.dashboardWidgetsJson`.
- Routing nach Onboarding: direkt zum Dashboard mit „Getting Started"-Card (s. Phase 4.6).
@@ -149,6 +152,7 @@
Coverage 35% → 60% auf kritischen Pfaden.
**Top-5-Test-Suites:**
1. Session-Validation Roundtrip (`src/lib/auth/session.ts`)
2. Passkey-Register/Verify-Integration (Attestation-Flow)
3. Notification-Dispatcher Multi-Channel (TELEGRAM/NTFY/WEB_PUSH)
@@ -162,6 +166,7 @@ Coverage 35% → 60% auf kritischen Pfaden.
## Phase 7 — UX/i18n/a11y Polish (≤4 h)
**Residuals aus Issue #73 + neue Findings:**
- I18N-002: Admin-Page Hardcoded „Web Push" / „Bug Report" → `t(...)`
- I18N-003: `medication-card.tsx` `Intl.DateTimeFormat("en-CA")` und `toLocaleString("en-US")` → `useFormatters()`
- A11Y-001: `phase-config-dialog.tsx` Text-Indicator neben Farb-Punkt (WCAG 1.4.1)
@@ -184,36 +189,41 @@ Coverage 35% → 60% auf kritischen Pfaden.
## Issue-Vorschlags-Abdeckung
### Issue #87 (Onboarding-Meds verschwinden)
- **Pflaster:** Phase 1 (`.ok`-Check + Toast)
- **Strukturelle Lösung:** Phase 4 (`apiClient`) + Phase 3.5 (neuer Flow ohne Med-Wizard) + Phase 4.5 (Empty-State-Tiles, Meds direkt sichtbar) + Phase 4.6 (Checklist)
### Issue #88 (Setup holprig — 4 Hauptpunkte + 3 Verbesserungsvorschläge)
| #88 Punkt | Phase | Status |
|---|---|---|
| (a) Bundled compose ≠ docs version | Phase 2 (DOC-005) | abgedeckt |
| (b) ghcr.io 404 (kein Image gepusht) | Phase 2 (DX-001 GHA-Workflow) | abgedeckt |
| (c) `.env.example` `localhost:5432` vs Compose `db:5432` | Phase 1 (DX-003) | abgedeckt |
| (d) `expose:3000` statt `ports:3000:3000` | Phase 1 (DX-002) | abgedeckt |
| Vorschlag: GHA für Image-Push (GHCR/Docker Hub) | Phase 2 | abgedeckt |
| Vorschlag: Merge „Getting started > Installation" + „Self-Hosting > Docker" | Phase 2 (DX-004) | abgedeckt |
| Vorschlag: Same presets across compose+docs+.env.example | Phase 1 + Phase 2 | abgedeckt |
| #88 Punkt | Phase | Status |
| --------------------------------------------------------------------------- | ----------------------------- | --------- |
| (a) Bundled compose ≠ docs version | Phase 2 (DOC-005) | abgedeckt |
| (b) ghcr.io 404 (kein Image gepusht) | Phase 2 (DX-001 GHA-Workflow) | abgedeckt |
| (c) `.env.example` `localhost:5432` vs Compose `db:5432` | Phase 1 (DX-003) | abgedeckt |
| (d) `expose:3000` statt `ports:3000:3000` | Phase 1 (DX-002) | abgedeckt |
| Vorschlag: GHA für Image-Push (GHCR/Docker Hub) | Phase 2 | abgedeckt |
| Vorschlag: Merge „Getting started > Installation" + „Self-Hosting > Docker" | Phase 2 (DX-004) | abgedeckt |
| Vorschlag: Same presets across compose+docs+.env.example | Phase 1 + Phase 2 | abgedeckt |
### Issue #89 (TBW + Bone Mass)
- Phase 3 hardcoded mit Withings-Mapping. Optional `/api/ingest/measurements` für n8n in Phase 3.
### Issue #73 (UX-Liste mit 8 Punkten — CLOSED in v1.2)
| #73 Punkt | v1.2-Status | Residual? |
|---|---|---|
| 1. Passkey-Errors unklar | Resolved (Package C, typed errors) | TEST-002 (keine Tests) → Phase 6 |
| 2. Number-Formatting (DE 70,5 vs US 70.5) | Resolved (Package B, locale-aware formatters) | I18N-003 in `medication-card.tsx` → Phase 7 |
| 3. Chart-Labels „7P/30P/90P/All" | Resolved (Package A, „7 Pkt"/„7 pts") | — |
| 4. Dashboard nicht real-time | Resolved (`invalidateKeys` durchgängig) | — |
| 5. GitHub-Setup-Confusion | Resolved (Built-in Feedback in v1.2) | — |
| 6. Built-in Feedback | Done in v1.2 | — |
| 7. Admin-Save ohne Error-UX | Partial (Success-Msg ja, Error-Highlight schwach) | UX-002 → Phase 7 |
| 8. Blood-Sugar | Done in v1.2 | — |
| #73 Punkt | v1.2-Status | Residual? |
| ----------------------------------------- | ------------------------------------------------- | ------------------------------------------- |
| 1. Passkey-Errors unklar | Resolved (Package C, typed errors) | TEST-002 (keine Tests) → Phase 6 |
| 2. Number-Formatting (DE 70,5 vs US 70.5) | Resolved (Package B, locale-aware formatters) | I18N-003 in `medication-card.tsx` → Phase 7 |
| 3. Chart-Labels „7P/30P/90P/All" | Resolved (Package A, „7 Pkt"/„7 pts") | — |
| 4. Dashboard nicht real-time | Resolved (`invalidateKeys` durchgängig) | — |
| 5. GitHub-Setup-Confusion | Resolved (Built-in Feedback in v1.2) | — |
| 6. Built-in Feedback | Done in v1.2 | — |
| 7. Admin-Save ohne Error-UX | Partial (Success-Msg ja, Error-Highlight schwach) | UX-002 → Phase 7 |
| 8. Blood-Sugar | Done in v1.2 | — |
### Issue #74 (raw locale keys in UI)
Resolved in v1.2.
---
@@ -221,6 +231,7 @@ Resolved in v1.2.
## Time-to-First-Value (TTFV) Ziel
Nach Onboarding-Redesign + Empty-State-Pattern:
- Login → Dashboard mit Empty-States: 2 Klicks
- Klick auf „Add your first weight" → Form prefilled „now" → Submit: 30 Sek
- **TTFV-Ziel: <60 Sekunden** (Branchenvergleich: führende Health-Apps 30-90s)
+439
View File
@@ -0,0 +1,439 @@
# HealthLog v1.4.6 — Findings & Spec
Status: 2026-05-09 — handoff from the v1.4.5 deploy session. All findings
were produced by **four parallel audit agents** (dashboard visual, KI section,
admin panel, KI insights data window) against the v1.4.5 codebase
(`e8f4820`). A Playwright probe also visually verified the dashboard tile
strip at five viewports. **Verify each finding by reading the cited
file:line BEFORE editing — agents occasionally reference stale state.**
The single working-tree change carried over from the v1.4.5 session:
`src/components/charts/trend-card.tsx:85` — added `w-full` so the tile
component fills its grid cell. **Do not commit alone — fold into v1.4.6.**
---
## Tier 1 — Release blockers for v1.4.6
### T1 — Dashboard tile fill ✅ (already in working tree)
`src/components/charts/trend-card.tsx:85`
The TrendCard outer div had `flex h-full flex-col` but no `w-full`. As a
flex-child of a `<div className="flex">` wrapper, it took content width
instead of stretching to the grid cell. Visible on mobile-narrow (375px):
tile widths varied 122-166px in the same row. With `w-full` added, the
Playwright harness confirms the tiles fill their grid cells uniformly at
all viewports. Marc's primary complaint was "die Kacheln füllen den Platz
nicht aus" — this is exactly that.
**Verify after change:** Run the deep diagnose probe at multiple
viewports (`/tmp/v145-tile-deep-diagnose.js` from prior session if still
on disk; otherwise re-create — set `HL_SID` to a valid session and run
via `node /Users/marc/.claude/skills/playwright-skill/run.js
$probe`).
### T2 — `--muted-foreground` equals `--foreground` (no hierarchy)
`src/app/globals.css:97` (dark) and `:134` (light)
Currently `--muted-foreground: #f8f8f2;` is identical to
`--foreground: #f8f8f2;` in dark, and `#1f1f1f` in both for light. Every
secondary string (tile labels, "Ø7d:" prefixes, units, nav inactive
items) is rendered at the same brightness as primary text — visually flat.
**Fix:** dark → `--muted-foreground: #9aa3b3;` (dracula-comment).
Light → `--muted-foreground: #5b6273;`.
**Verify:** Visual diff at a few pages — dashboard, settings, admin —
should show clear emphasis between primary numbers and label/footer
text. WCAG AA contrast must still hold (≥ 4.5:1 against the surface).
### T3 — KI insights `primaryRecommendation` renders raw chart tokens
`src/components/insights/insight-advisor-card.tsx:411`
The base-system prompt (`src/lib/insights/prompts/base-system.ts:67`)
explicitly invites the LLM to embed `metric:WEIGHT` etc. tokens inside
`primaryRecommendation`. The renderer prints
`{insight.primaryRecommendation}` raw — no token-strip, no
`<InlineCharts>` parse. Result: when the model follows instructions,
the user sees literal `metric:WEIGHT` in their hero recommendation.
**Fix:** Apply the same pattern used at lines 419-423 for `summary`:
strip tokens for prose, parse and render via `<InlineCharts>`. Re-use
the existing helper.
### T4 — `aiBaseUrl` cross-provider leak (security)
`src/lib/ai/provider.ts:75, 312, 355`, `src/app/api/user/ai-provider/route.ts:54-62`
`buildUserProvider` and `resolveProviderForTest` reuse a single
`aiBaseUrl` column across providers. If a user once configured `LOCAL`
with `http://192.168.x.x/v1` and later switches to `OPENAI`, the stored
`aiBaseUrl` is still set, so `OpenAIClient` is constructed with
`baseUrl: row.aiBaseUrl ?? "https://api.openai.com/v1"` — sending the
user's OpenAI API key to their local server.
**Fix:** In the PATCH handler at `route.ts:54-62`, when `provider`
changes to anything other than `LOCAL`, wipe `aiBaseUrl`. Belt-and-
braces in `buildUserProvider`: ignore `aiBaseUrl` for `OPENAI` and
`ANTHROPIC` (use a hardcoded default). Add a unit test for
provider-switch with stale baseUrl.
### T5 — `/api/insights/generate` 502 → Cloudflare HTML rewrite
`src/app/api/insights/generate/route.ts:84-86`
Same Cloudflare-rewrites-5xx footgun the test endpoint just got fixed
for in v1.4.5. Parse failure returns `apiError("Failed to parse AI
response", 502)` → Cloudflare swaps the body for an HTML error page →
`await res.json()` in `ai-section.tsx:206-215` crashes with
`Unexpected token '<'`. The user has zero rate-limit budget left to
retry (2/hour) and sees nothing useful.
**Fix:** Map parse errors to **422** with a readable JSON error body.
Same one-liner pattern as the v1.4.5 ai/test fix (see CHANGELOG).
### T6 — Admin status-card hrefs broken
`src/components/admin/status-card-grid.tsx:147, 159, 177, 189, 210, 222`
Hrefs to non-existent routes/anchors:
- `/admin/users`, `/admin/audit-log` — sub-routes don't exist
- `/admin#integrations`, `/admin#monitoring`, `/admin#backups`,
`/admin#maintenance` — anchor IDs don't match the rendered DOM
(actual: `section-admin-services`, `section-admin-umami`, etc.)
Every "Manage users" / "View backups" CTA reloads `/admin` to top.
The `status-card-grid.test.tsx:105` only asserts `startsWith("/admin")`
— so this regression slipped through.
**Fix:** Point hrefs at the existing anchor IDs:
- Users → `/admin#section-user-management`
- Audit log → `/admin#section-login-overview`
- Integrations → `/admin#section-admin-umami`
- etc. Verify each anchor exists in the rendered DOM.
Tighten `status-card-grid.test.tsx` to assert each href anchor IS
present in the rendered admin page DOM (use `render(<AdminPage />)`
and assert `getByText(...)` for each section ID). Plain regex check is
not enough.
### T7 — Bug-report toggle has no effect (admin)
`src/app/api/bugreport/route.ts:47-49`, `src/app/bugreport/page.tsx:70`
The `bugReportEnabled === false` check lives on the legacy
`/api/bugreport` route. The actual form on `/bugreport` posts to
`/api/feedback`, which has no such gate. Result: toggling "Bug
reports off" in admin **does not actually hide or block** the form —
users still submit successfully into the local feedback inbox.
The CLAUDE.md / commit message claim ("the report button gracefully
disappears") is false.
**Fix:** Pick one approach:
- **(a)** Add `bugReportEnabled` to `/api/bugreport/status` and to
`/api/feedback` POST so the form is hidden + blocked when off.
Recommended.
- (b) Update copy to clarify the toggle only governs GitHub
publishing.
Pick (a). Update i18n copy. Add an integration test that toggles the
flag off and asserts /bugreport returns 503 OR redirects.
### T8 — "Wipe all data" deletes more than the copy says
`src/app/api/admin/data/route.ts:44-47`
The DELETE handler also nukes `apiToken`, `withingsConnection`,
`auditLog`, `authChallenge`. The UI confirmation copy
(`admin.deleteAllConfirmDescription`) and the success toast
(`admin.deletedResult`) only mention measurements/medications/
intakeEvents.
Worse: `tx.auditLog.deleteMany({})` runs in the same transaction —
**every prior security-relevant entry is gone**. For a feature whose
primary purpose is auditability, this is wrong.
**Fix:**
- **Stop deleting `AuditLog`.** Keep the audit trail. (If retention is
desired, add a separate maintenance route that prunes by age.)
- Reflect the actual scope in the response toast (the API already
returns `apiTokens`, `withingsConnections`, `authChallenges`).
- Update i18n keys `admin.deleteAllConfirmDescription` to enumerate
everything that gets cleared.
### T9 — KI per-card window 30 → 360 daily + 24 monthly (NEW SCOPE)
Affected files (one constant + one bucket-builder per file):
- `src/lib/insights/general-status.ts:15` — `GENERAL_STATUS_POINTS`
- `src/lib/insights/blood-pressure-status.ts:21` —
`BLOOD_PRESSURE_STATUS_POINTS` + `mood.slice(-30)` at line 362
- `src/lib/insights/pulse-status.ts:17` + line 199 mood-slice
- `src/lib/insights/weight-status.ts:17` + line 233 mood-slice
- `src/lib/insights/bmi-status.ts:11`
- `src/lib/insights/mood-status.ts:14` + line 283 (`recentEntries`
slice 90 — already padded × 3)
- `src/lib/insights/medication-compliance-status.ts:150` —
`rangeStart`
**Goal:** the LLM should see ~3 years of history with newer data at
higher resolution. Not raw rows — daily means for the recent window,
monthly means for the older window. Marc explicitly asked for
"mindestens 360 Tage" daily, plus "Monatswerte" for "2 oder 3 Jahre".
**Concrete shape of the prompt payload (per metric):**
```ts
{
daily: Array<{ dayOffset: number; value: number; n: number }>; // 360 items, dayOffset 0..359 (newest first)
monthly: Array<{ monthOffset: number; value: number; n: number }>; // 24 items, monthOffset 12..35 (12 months ago to 36 months ago)
}
```
So months 1-12 are sampled daily (= 360 daily buckets), months 13-36
are summarised monthly (= 24 monthly buckets). Total 384
data points per metric.
**Token budget guard:** Before sending, log the JSON-stringified
payload size. If >50 KB, fall back to 180 daily + 24 monthly. Add
this as a `LOG_INSIGHTS_PAYLOAD_SIZE` env var (default true) so we
can monitor.
**Tests:** add a test per generator that asserts the payload contains
both `daily` and `monthly` arrays with the expected lengths, and that
no raw measurement rows leak into the prompt (the audit window is
3 years; raw mode is unchanged in `features.ts`).
---
## NEW FEATURE — Chart bucketing for ranges > 1 year
Marc's request:
> "Überlege dir bei den Charts ob wir Sachen halt nicht ab einer
> gewissen Sache dann stacken in den Monaten. Wenn wir jetzt bei
> All drücken zum Beispiel, dass man dann halt ab einer Zeit von
> über einem Jahr dann halt danach tagesmonats- oder monats- oder
> wochendurchschnitte summiert und nicht dann 30.000 Punkte
> anzeigt"
**Spec:**
The chart data adapter (in `src/components/charts/` — likely
`health-chart.tsx` or a sibling) currently renders every measurement
in the selected range. For the dashboard "All" / "Alle" range filter
this can be 1000s of points and is unreadable.
Add **automatic bucketing** based on the rendered range:
- `≤ 90 days` → daily points (current behaviour)
- `91-730 days` → weekly average
- `> 730 days` → monthly average
The bucket type must be displayed in the chart header
(e.g., "Tageswerte" / "Wochendurchschnitt" / "Monatsdurchschnitt"
chip in the corner of the chart) so the user knows what they are
looking at.
The data points must keep their ISO timestamps so Recharts' x-axis
formatting works correctly. Smoothing/averaging must skip days
with `n=0` rather than count them as `0`.
**Tests:** unit-test the bucketing function with synthetic series of
varying length. Visual regression: render the chart at a 2-year range
and verify the chart shows ~24 monthly points, not 730 daily.
**Research:** the autonomous executor MUST research best practice
for time-series bucket aggregation in healthcare/Recharts dashboards
before implementing. Use Context7 or websearch for "recharts
aggregation buckets" and similar — there's a standard pattern for
this. Don't reinvent.
---
## Tier 2 — Polish (also for v1.4.6)
| ID | File | Change |
| --- | --------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| P1 | `trend-card.tsx:93,109,118,130,139` | Add `tabular-nums` to numeric spans (digits jiggle on refresh) |
| P2 | `trend-card.tsx:87,93` | Bump value `text-2xl` → `text-3xl tracking-tight`, label `text-sm font-medium` → `text-xs uppercase tracking-wide` (KPI hierarchy) |
| P3 | `trend-card.tsx:85` vs `health-chart.tsx:551` | Match padding: tile `p-3` → `p-4 md:p-6` to align with chart cards |
| P4 | `trend-card.tsx:64-69` | Trend-arrow color: keep flat at muted; up/down both `text-foreground` (neutral). Direction-as-good-or-bad is metric-specific and v1.5+ work. |
| P5 | `trend-card.tsx:103-145` | Always render avg7/avg30 chips; use `—` when null so vertical rhythm is consistent across tiles |
| P6 | `page.tsx:362` | Welcome subtitle: drop `hidden sm:block`, add `text-muted-foreground` (visible on mobile, muted) |
| P7 | `auth-shell.tsx:129` | Bottom-nav buffer: `pb-[calc(5rem+env(safe-area-inset-bottom,0px))]` → `pb-[calc(4rem+env(safe-area-inset-bottom,0px))] md:pb-0` |
| P8 | `empty-state.tsx:71-77` | TrendHint title default: add `text-muted-foreground` so the title doesn't fight chart titles after T2 |
| P9 | `getting-started-checklist.tsx:299` | Drop `font-mono` from "X von Y" / "%" — keep `tabular-nums` |
| P10 | `page.tsx:846-862` | Medications card: `rounded-lg border p-4` → `rounded-xl border p-4 md:p-6` (match other chart cards) |
| P11 | `src/lib/logging/redact.ts:22` | Add `sk-(ant-)?` regex to `redactSecrets` to scrub OpenAI/Anthropic keys centrally |
| P12 | `src/lib/idempotency.ts:227` | Extend the `hlk_` / `hlr_` exclusion to also reject bodies containing `"sk-"` or `"sk-ant-"` |
| P13 | `src/app/api/insights/generate/route.ts:18-22` | Move `checkRateLimit` BELOW the cache-return so cached hits don't burn rate-limit tokens |
| P14 | `src/lib/ai/codex-client.ts:28,34` | Copy structured-error pattern from `openai-client.ts:42-58` (httpStatus, bodyExcerpt, upstream fields) |
| P15 | `src/components/settings/ai-section.tsx:49` | Drop `gpt-5` from `MODEL_PRESETS["OPENAI"]` (not a released model). Drop `o3-mini` too unless you wire the o-series param contract (`max_completion_tokens` not `max_tokens`, no `temperature` override). |
| P16 | `src/components/settings/ai-section.tsx` (~29 strings) | i18n: pipe German strings through `t("settings.ai.…")` — add keys to `messages/en.json` + `messages/de.json` |
| P17 | `src/components/admin/feedback-inbox-section.tsx:209-222` | Replace `bg-red-500/15 text-red-400` with `bg-dracula-red/15 text-dracula-red` etc. for theme consistency |
| P18 | `src/components/admin/danger-zone-section.tsx:110` | Drop string-prefix-matches-success heuristic — track via `mutation.isSuccess` / `mutation.isError` |
| P19 | `src/components/admin/_shared.tsx:268-277,216-225` | `useSystemStatus()` and `useAdminSettings()`: surface `isError` with an inline "Failed to load settings" message instead of infinite spinner |
| P20 | `src/app/api/admin/status-overview/route.ts` | `Promise.all` → `Promise.allSettled` server-side so one failed probe doesn't blank the whole grid |
---
## Tier 3 — explicitly deferred
- **One-row scroll vs. wrap** — the dashboard audit recommended
rolling back v1.4.5's wrap behaviour to v1.4.4's horizontal-scroll-
always. Marc already chose wrap deliberately in v1.4.5
(CHANGELOG entry, commit `e8f4820`). **Do not roll back.**
- **`/admin/users`, `/admin/audit-log` sub-routes** — feature, not
bug. Anchor-link fix in T6 is enough for v1.4.6.
- **API token revoke / login block actions** in admin — feature for
v1.5.
- **"Remove saved AI key" button** — UX gap, not a bug. v1.5.
- **Feedback / mood semantic colors** — needs design pass.
---
## Verification gates (before tagging v1.4.6)
After all fixes, the autonomous executor MUST:
1. **Test suite green:** `pnpm test`, `pnpm typecheck`, `pnpm lint`,
`pnpm format:check`. Stop if any fails — never `--no-verify`.
2. **Integration tests green** (testcontainers): `pnpm test:integration`
3. **Build succeeds:** `pnpm build` (no Turbopack-specific failures)
4. **Multi-agent QA review** — dispatch in parallel and reconcile
findings into a follow-up commit if any HIGH/CRITICAL surfaces:
- **superpowers:code-reviewer** — review the v1.4.6 diff vs. main
- **Plan agent** as senior-design-reviewer — visual review of
dashboard, settings, admin at desktop + mobile viewports
- **Plan agent** as senior-security-reviewer — review T4, T5, T7,
T8, P11, P12 specifically for residual risk
- **simplify skill** — run on the v1.4.6 changed files to look for
accidental complexity
5. **Mobile QA** via Playwright — at least three viewports
(375×667, 390×844, 412×915). Probe must verify:
- No horizontal scroll on any page
- Bottom-nav doesn't overlap content
- Tile strip wraps cleanly with uniform widths per row
- Touch targets ≥ 44×44 px
- Charts render and are readable
6. **Hallucination check** — for every finding fixed, `git diff` must
show a real change at the cited file:line. Audit-agent claims must
be verified; if a finding's file:line doesn't match what's
actually there, log it in the SUMMARY and skip rather than guess.
7. **Visual regression** — Playwright screenshots of dashboard at
1280×900 and 390×844, before+after, saved to
`/tmp/v146-tiles-{desktop,mobile}-{before,after}.png`.
---
## Release & deploy
1. Bump `package.json` to `1.4.6`
2. Update `CHANGELOG.md` with a `[1.4.6] — 2026-05-09` (or `2026-05-10`
depending on completion time) block. Tone: user-facing German +
English, **no Claude mention, no "Audit-Agent"**, no internal
phase names. Group by section: "Fixed — Dashboard",
"Fixed — KI", "Fixed — Admin", "Improved — KI Insights",
"Improved — Polish".
3. Atomic commits per phase, each Co-Authored-By Claude Opus 4.7.
4. Tag `v1.4.6`, push tag.
5. Wait for GHCR build (typically 10-13 min).
6. Coolify deploy: `mcp__coolify-apps01__deploy` with
`tag_or_uuid: pg8wggwogo8c4gc4ks0kk4ss`, `force: true`.
7. Verify production:
- Poll `/api/version` until 1.4.6
- `docker inspect` running container — image digest must
differ from current.
- Marc's session: `cmox4d6fj000101p8w9ykhcnm` (still valid as of
2026-05-09; if expired, re-pull from prod DB —
`SELECT id FROM sessions WHERE user_id='cmlupy4tn000001rpzx1pxvz7'
AND expires_at > now() ORDER BY created_at DESC LIMIT 1;`)
- Hit `/api/ai/test` with fake key → still returns 422
- Hit `/api/insights/generate` with `forceRefresh=false` and a
fresh user → does not consume rate-limit token (P13 verify).
8. Mobile QA Playwright probe against production.
9. Brief Marc — German, ≤ 200 words. Hard-reload reminder.
Group by what changed perceptually ("Tile-Strip füllt jetzt aus",
"KI bekommt jetzt 3 Jahre Historie", "Charts aggregieren bei
Langzeit-Range", "Admin-Buttons springen jetzt zum richtigen
Anker", "Bug-Report-Toggle wirkt jetzt"). No "Claude", no
internal phase names.
---
## Repo housekeeping (also in scope for the v1.4.6 marathon)
### CI
- `e2e` workflow has been **failing on every commit** since at least
`cbded7c3`. Investigate (`gh run view <id> --log-failed`), root-
cause, fix the spec or the underlying app bug. Don't `.skip()` it
— Marc explicitly asked for green CI.
### GitHub Releases
GitHub Releases stop at **v1.4.1**. Backfill v1.4.2, v1.4.3, v1.4.4,
v1.4.5, v1.4.6 — each release notes pulled from the corresponding
`CHANGELOG.md` block. Tag each release `--verify-tag` so we don't
create an orphan release.
### GHCR Packages
`ghcr.io/mbombeck/healthlog` packages page may have stale tags from
the v1.4.0/v1.4.1 deploy thrash. Don't delete `v1.x.x` tagged
versions (some users may pin). Only delete explicitly _untagged_
manifests if any exist.
### Docs site (`/Users/marc/Projects/healthlog-docs`)
Astro site at `https://docs.healthlog.dev`. Latest content covers
**up to v1.2**. Bring it up to v1.4.6:
- Update version selector / Latest indicator
- Add docs pages or sections for v1.3-v1.4.6 features the user
would care about — read the CHANGELOG.md user-facing bullets
and translate into how-to docs where appropriate
- Add the v1.4.6 release entry
Read the docs repo's CLAUDE.md/CONTRIBUTING (if present) FIRST to
understand conventions. Commit + push.
### Landing site (`/Users/marc/Projects/healthlog-landing`)
Next.js site. Latest showcase mentions v1.2. Minimal updates only —
don't redesign:
- Update headline version + supported-features list if anything
v1.3-v1.4.6 is landing-page-worthy
- Update screenshots if anything visibly changed (likely the
dashboard tile strip)
Commit + push.
---
## Marc's hard rules (verbatim)
1. Niemals `--no-verify` bei git commit.
2. Niemals `--no-gpg-sign` außer explizit angefragt.
3. **Niemals force-push to main.**
4. Changelogs/Release-Notes nur user-facing — kein "Claude", keine
internen Phasennamen, keine Audit-Sprache.
5. Authorization gilt nur für den spezifischen Auftrag — wenn etwas
Größeres aufkommt, lieber fragen als entscheiden.
For this overnight session, Marc has explicitly authorized
**autonomous execution of Tier 1 + Tier 2 + the new chart bucketing
feature**, and the multi-agent QA + simplify pass. Anything that
crops up which is **not** in this document and is **larger than a
typo-level change** must be deferred to v1.5 with a note in the
SUMMARY rather than implemented.
+57
View File
@@ -0,0 +1,57 @@
# Doctor Report
The doctor report is a printable PDF summary of a user's tracked vitals,
medication compliance, glucose context, and mood over a configurable window
(default 90 days, max 365). It is intended as discussion material for a
doctor's appointment, **not** a medical diagnosis.
## Two endpoints
The same aggregation logic backs both endpoints — `collectDoctorReportData`
in `src/lib/doctor-report-data.ts` is the single source of truth — so the
JSON payload and the server-rendered PDF are guaranteed to be in sync.
### `POST /api/doctor-report` — JSON payload
Returns the aggregated `DoctorReportData` envelope. Used by the PWA's
client-side renderer (`src/lib/doctor-report-pdf.ts`, jsPDF + autotable).
Best on desktop browsers and on Android, where local generation keeps the
PDF off the wire and works offline (cached page).
- Auth: session required.
- Rate-limit: 10/h per user.
- Body: `{ days?: number }` (default 90).
- Response: `200 application/json` with envelope `{ data, error: null }`.
- Audit action: `doctor-report.generate`.
### `POST /api/doctor-report/pdf` — server-rendered PDF
Returns the ready-to-download PDF bytes. Used primarily on iOS/Safari, where
the client-side jsPDF download UX is unreliable (Safari sometimes opens the
PDF in a new tab without preserving the filename or fails the blob save).
- Auth: session required (`401` otherwise).
- Rate-limit: 10/h per user, shared with the JSON endpoint (`429` on excess).
- Body: `{ days?: number; locale?: "de" | "en" }`. Body is optional — empty
bodies are accepted.
- Locale resolution: explicit `body.locale` wins, then the
`Accept-Language` header (`de*` → `de`, anything else → `en`), then the
hard fallback `de`.
- Response: `200 application/pdf` with
`Content-Disposition: attachment; filename="healthlog-report-YYYY-MM-DD.pdf"`
and `Cache-Control: no-store`. Body is the raw PDF bytes.
- Errors: JSON envelope `{ data: null, error: "..." }` for `401` and `429`;
`500` if rendering fails.
- Audit action: `doctor-report.pdf.generate`.
## Implementation notes
- `src/lib/doctor-report-pdf-core.ts` contains the isomorphic renderer.
jsPDF runs unchanged in Node — `doc.output("arraybuffer")` returns a valid
`%PDF-` byte stream in both environments, so we deliberately avoid headless
Chromium (Puppeteer/Playwright) to keep the runtime dependency surface low.
- `src/lib/doctor-report-pdf.ts` is a thin client façade that returns the
raw `jsPDF` instance so the existing settings-page download flow (which
calls `doc.save(...)`) keeps working unchanged.
- Server-side translations come from `messages/{de,en}.json` via
`getServerTranslator()` (`src/lib/i18n/server-translator.ts`).
+232
View File
@@ -0,0 +1,232 @@
# Migrating from HealthLog 1.3 to 1.4
v1.4.0 is **drop-in for self-hosters running the GHCR image** — the
two new migrations (`0025_refresh_tokens`, `0025_user_locale_drift_fix`)
both apply on container start via `prisma migrate deploy`, and every
new env var in §Environment variables below has a safe default. This
guide documents what changes for operators and integrators, in case
you're extending the API or scaling the deployment.
## TL;DR
```bash
docker compose pull
docker compose up -d
```
That's it. The migrations are forward-compatible (`IF NOT EXISTS`
guards), no env vars are _required_ to be added, and no API contract
changes shape or status code.
## Database
- **`0025_refresh_tokens`** _(new)_ — adds the `refresh_tokens` table
for the native-client 24h access-token / refresh-token rotation
flow. Browser cookie sessions don't use it.
- **`0025_user_locale_drift_fix`** _(new)_ — `ADD COLUMN IF NOT
EXISTS users.locale TEXT` to backfill an environment that was kept
in sync via `prisma db push` rather than `migrate deploy`. No-op
on installations that were already current.
- Both migrations are idempotent. A rollback to 1.3.3 keeps the
schema usable; the dropped tables/columns are simply unused.
## Environment variables
### About surface
- **`NEXT_PUBLIC_APP_BUILD_SHA`** _(optional, new)_ — short Git SHA
exposed through `/api/version`, rendered on Settings → About.
- **`NEXT_PUBLIC_APP_BUILT_AT`** _(optional, new)_ — ISO-8601 build
timestamp.
Both are optional; without them, the About surface renders
"development" wording.
### Worker / web split
- **`HEALTHLOG_PROCESS_TYPE`** _(optional, default `all`)_ — set to
`web` to disable the pg-boss worker (jobs queue but don't run), or
`worker` to refuse all HTTP traffic with `503 + X-HealthLog-Process-Type:
worker`. Default `all` keeps the single-container behaviour.
Only relevant if you're scaling HTTP and the worker independently.
### Encryption-key versioning
- **`ENCRYPTION_KEYS`** _(optional, new)_ — JSON object mapping
versioned key IDs to 64-char hex keys, e.g. `{"v1": "...", "v2":
"..."}`. When present, ciphertexts are written with the
`<keyId>.<base64>` format and decrypted by looking up the key ID.
Existing 1.3-format ciphertexts (no key prefix) keep decrypting
with `ENCRYPTION_KEY` indefinitely.
- **`ENCRYPTION_ACTIVE_KEY_ID`** _(optional, new)_ — the key ID used
for new writes. Must be a key in `ENCRYPTION_KEYS`. Defaults to
the legacy single-key behaviour if either is unset.
- **`ENCRYPTION_KEY`** _(unchanged)_ — still honoured as a fallback
when `ENCRYPTION_KEYS` is empty. No action needed for existing
installs.
- Rotation: `pnpm tsx scripts/rotate-encryption-key.ts <newKeyId>`
re-encrypts every ciphertext under the new key without downtime.
Walk-through in `docs/ops/encryption-key-rotation.md`.
### Off-host backup target _(optional)_
If unset, the existing local `DataBackup` storage continues to work.
- **`BACKUP_S3_ENDPOINT`** — S3-compatible endpoint URL. Leave blank
to disable off-host backup.
- **`BACKUP_S3_BUCKET`** — destination bucket name.
- **`BACKUP_S3_ACCESS_KEY`**, **`BACKUP_S3_SECRET_KEY`** — IAM
credentials. Worker only requests `PutObject` + `GetObject` —
never `DeleteObject`. Retention is the bucket's lifecycle policy.
- **`BACKUP_S3_REGION`** _(default `auto`)_.
- **`BACKUP_RETENTION_DAYS`** _(default `30`)_ — surfaced in the
admin UI; the lifecycle rule on your bucket is the actual enforcer.
- **`BACKUP_ENCRYPTION_KEY`** — separate 64-char hex key for the
AES-GCM envelope of each upload. **Keep offline / in a vault.**
Lose this and the off-host backups are unreadable.
### API token hashing _(unchanged but worth re-confirming)_
- **`API_TOKEN_HMAC_KEY`** — 64-char hex string. Used to HMAC
Bearer tokens before storage so a DB leak can't be reversed via
rainbow table. Same key now also keys the new `RefreshToken` row
hashes. **Do not rotate without invalidating every native client.**
## API
### New
- **`GET /api/version`** — public, static, returns
`{ version, buildSha, builtAt, license, repository, changelog, docs }`.
No authentication.
- **`POST /api/integrations/withings/test`** — admin-only probe
against the saved Withings OAuth connection. Returns `{ ok, latency_ms }`
on success.
- **`POST /api/integrations/moodlog/test`** — admin-only HEAD
request against `https://moodlog.app/api/health`. SSRF-guarded.
- **`POST /api/notifications/web-push/test`** — sends a smoke push
notification to the current user's subscribed devices.
- **`POST /api/monitoring/glitchtip/test`** — fires a benign event
at the configured DSN.
- **`POST /api/monitoring/umami/test`** — verifies the script URL +
website ID resolve and respond with the expected payload.
- **`POST /api/admin/backup/test`** — admin-only round-trip against
the configured `BACKUP_S3_*` credentials.
- **`GET /api/admin/status-overview`** — admin-only aggregator
returning the six-card summary the new admin top strip renders in
one batched query.
- **`POST /api/auth/refresh`** — native-client refresh-token
rotation. Cookie-session browsers don't use this.
- **`POST /api/auth/refresh/revoke`** — paired logout-on-device that
flips both the refresh row and the matching `ApiToken` row to
revoked.
All new admin endpoints are hidden from anonymous traffic by the
proxy and rate-limited.
### Behaviour-only changes
- **`POST /api/ai/test`** now accepts an optional JSON body that
mirrors the AI dropdown in `/settings`. Empty body keeps the
pre-1.4 behaviour (test the saved provider). Non-empty body tests
the unsaved selection without persisting plaintext keys. The
existing rate limit (5/min) and error-leak shielding stay in place.
- **`GET /api/dashboard/summary`** localises the `streak.label` and
`greeting.salutation` fields against the user's stored locale.
- **`POST /api/insights/targets`** uses a single batched query for
per-type latest readings; returns identical payloads.
No `/api/...` route changed shape, status codes, or auth rules. iOS,
n8n, and Health Connect integrations need no change.
## UI / UX
### Dashboard
- **Tile strip is always one row.** Previously the four-to-five-tile
grid wrapped to a second row when many widgets were enabled. v1.4
uses a horizontal-scroll strip — total width caps at the chart
width below, snap points keep the touch experience deliberate. To
reduce the visible tile count, open Settings → Dashboard Layout
and toggle widgets off.
### Forms
- **Password managers no longer autofill health-data inputs.**
Browsers and LastPass / 1Password were happily filling the user's
saved account password into measurement notes, AI tokens, and
similar free-text fields. The `<Input>` primitive now defaults to
`autoComplete="off"` plus the LastPass / 1Password ignore
attributes whenever the caller doesn't pass a semantic value. Auth
and profile forms (login, register, change-email) keep autofilling
normally because they pass an explicit `autoComplete` (e.g.
`"username"`, `"email"`, `"new-password"`).
### Settings
- **AI provider connection-test honours the unsaved selection.** Pick
a provider in the dropdown and click "Verbindung testen" without
saving first — the test now runs against your in-form selection
instead of the saved provider.
### Notifications
- **Telegram, ntfy, and Web Push reminders follow your locale.**
Switching the user's locale to English now produces English
reminders; the previous build always sent German strings regardless.
Telegram callback IDs stay stable (English) so the bot dispatcher
keeps matching them.
## Medical defaults
- **Diastolic blood-pressure orange band no longer reaches 60 mmHg.**
A reading of 60 mmHg now lands in red instead of yellow. The
user-override path is intact — if you set a personal threshold via
`/settings#thresholds`, it still applies and is audit-logged.
- **Body-fat classifier mirrors the ACE table** — readings below the
ACE essential floor (M < 2 / F < 10) now classify as
"Below essential" (danger), the actual essential band (M 2–5 /
F 10–13) as warning, and the realistic healthy range
(M 14–24 / F 21–31) as the green band on the dashboard target tile.
- **Step target unified at 8 000–15 000.** The two surfaces that
previously disagreed (7 000–10 000 in `getStepsRange()` versus
8 000–15 000 in the effective-range resolver) now use the same
band, anchored on Saint-Maurice et al., JAMA 2020.
## URL changes
- **Settings has moved to per-section routes.** `/settings#integrations`
→ `/settings/integrations`, etc. The legacy `/settings#anchor` URLs
still 308-redirect to the new routes; sidebar deep-links and the
`<a href="/settings#thresholds">` patterns from 1.3 keep working.
## What landed in v1.4 vs v1.4.1
The v1.3.3 ecosystem audit identified architectural rewrites tracked
through v1.4. Status as of the v1.4.1 cycle:
| Bucket | Status |
| -------------------------------------------------------------------------------------- | ------------------- |
| Settings page split into `/settings/[section]` | ✓ shipped in v1.4.0 |
| Admin status-first card grid + aggregator endpoint | ✓ shipped in v1.4.0 |
| AI insights rework — severity hero + allowlisted inline charts | ✓ shipped in v1.4.0 |
| Multi-tenant prep (off-host backups, key versioning, worker/web split, refresh tokens) | ✓ shipped in v1.4.0 |
| Per-section admin component extraction (inner monolith → one file per panel) | ✓ shipped in v1.4.1 |
| Postgres-backed integration test suite (testcontainers) | ✓ shipped in v1.4.1 |
| Playwright + axe-core E2E foundation | ✓ shipped in v1.4.1 |
## Rollback
If 1.4.0 surfaces a regression on your install, redeploy the previous
1.3.3 image:
```bash
docker compose down
docker pull ghcr.io/mbombeck/healthlog:1.3.3
sed -i 's/healthlog:1.4.0/healthlog:1.3.3/' docker-compose.yml
docker compose up -d
```
The schema is unchanged so a rollback does not require a database
restore. File a GitHub issue with the symptom — the v1.4 cycle ran
multi-agent QA per phase, but no migration is risk-free.
+87
View File
@@ -0,0 +1,87 @@
# Off-host backup & restore (v1.4 G1)
HealthLog ships with an optional daily off-host backup that ships every
user's JSON dump, encrypted with AES-256-GCM under a SEPARATE key
(`BACKUP_ENCRYPTION_KEY`), to any S3-compatible bucket — Cloudflare R2,
AWS S3, Backblaze B2, MinIO, etc.
The backup runs at **02:30 Europe/Berlin** every day from the worker
container (queue `data-backup-offhost`). Object key layout:
```
<bucket>/YYYY-MM-DD/user-<userId>.json.enc
```
## Wire format (binary)
```
magic = "HLBK" (4 bytes, ASCII)
version = 0x01 (1 byte)
iv = 12 random bytes (AES-GCM nonce)
authTag = 16 bytes (AES-GCM tag)
ciphertext = N bytes (AES-256-GCM, key = BACKUP_ENCRYPTION_KEY)
plaintext = JSON dump (UTF-8)
```
## Required env vars
| Var | Required | Notes |
| ----------------------- | -------- | -------------------------------------------------------------------- |
| `BACKUP_ENCRYPTION_KEY` | yes | 64 hex chars or 32-byte base64. **Different from `ENCRYPTION_KEY`.** |
| `BACKUP_S3_ENDPOINT` | yes | e.g. `https://<account>.r2.cloudflarestorage.com` |
| `BACKUP_S3_BUCKET` | yes | |
| `BACKUP_S3_ACCESS_KEY` | yes | |
| `BACKUP_S3_SECRET_KEY` | yes | |
| `BACKUP_S3_REGION` | no | defaults to `auto` (Cloudflare R2) |
| `BACKUP_RETENTION_DAYS` | no | defaults to `30` |
## Bucket lifecycle (recommended)
The worker prunes objects older than `BACKUP_RETENTION_DAYS`, but the
storage provider's lifecycle rule is the canonical safety net:
```
Filter: "" (all objects)
Action: Expire after 30 days
```
For Cloudflare R2 add this from the bucket's **Settings → Lifecycle**.
## Smoke test
After deploying, hit `POST /api/admin/backup/test` (admin-only). It
performs a 1-byte PUT + GET round-trip and returns:
```json
{
"endpoint": "https://...r2.cloudflarestorage.com",
"bucket": "healthlog-backups",
"region": "auto",
"putLatencyMs": 142,
"getLatencyMs": 38,
"ok": true
}
```
The credentials are never returned.
## Restore
Pick a key (e.g. `2026-05-08/user-clx123.json.enc`) from the bucket
and run:
```bash
BACKUP_S3_ENDPOINT=https://...r2.cloudflarestorage.com \
BACKUP_S3_BUCKET=healthlog-backups \
BACKUP_S3_ACCESS_KEY=... \
BACKUP_S3_SECRET_KEY=... \
BACKUP_S3_REGION=auto \
BACKUP_ENCRYPTION_KEY=$(openssl rand -hex 32) \
pnpm tsx scripts/restore-backup.ts \
2026-05-08/user-clx123.json.enc \
/tmp/restored.json
```
The script downloads the object, decrypts it, and writes the JSON dump
to disk. Importing the JSON back into a HealthLog instance is left to
the operator (use `prisma db seed` or a custom script).
+107
View File
@@ -0,0 +1,107 @@
# Encryption-key rotation
HealthLog encrypts sensitive at-rest data (Withings tokens, AI provider keys,
notification channel configs, web-push subscription secrets, VAPID private
keys, etc.) with AES-256-GCM under a per-deployment key. v1.4 introduces a
versioned key format so the key can be rotated without downtime and without
re-encrypting every row by hand.
## Format
| Layout | Marker | Where |
| --------------- | ----------------------------------------------- | --------------------------- |
| Versioned (new) | `<keyId>.<base64(iv \|\| tag \|\| ciphertext)>` | All new writes |
| Legacy (v1.3.x) | `<base64(iv \|\| tag \|\| ciphertext)>` | Existing rows until rotated |
`<keyId>` matches `[A-Za-z0-9_-]{1,32}` and indexes into the `ENCRYPTION_KEYS`
JSON map. Decryption tries the versioned format first; if no `.` is present
or the prefix isn't a known id, the row is treated as legacy and decrypted
under `v1` (the synthetic id assigned to the existing `ENCRYPTION_KEY`).
> **Why a separate `v1` is required.** Legacy ciphertexts have no key id, so
> the only way to identify them is "no `.` in the value". If you remove the
> `v1` entry from `ENCRYPTION_KEYS` _before_ every legacy row has been
> rotated, those rows can't be decrypted any more — the active key won't
> match the original ciphertext. The decrypt path now refuses to silently
> fall back to the active key in that scenario; it throws a clear error.
## Rotating from v1.3.x to a new key
1. **Generate the new key** on a machine with `openssl`:
```
openssl rand -hex 32
```
2. **Update environment variables.** Keep the existing `ENCRYPTION_KEY` in
place — it's still needed to decrypt legacy rows during the transition:
```
ENCRYPTION_KEY="<old key, unchanged>"
ENCRYPTION_KEYS='{"v1":"<old key>","v2":"<new key>"}'
ENCRYPTION_ACTIVE_KEY_ID="v2"
```
Restart the app. New writes are now encrypted under `v2`; existing
`v1`-keyed and legacy bare rows still decrypt because the `v1` entry is
retained.
3. **Run the rotation script** to re-encrypt every existing encrypted column
under the new active key:
```
pnpm tsx scripts/rotate-encryption-key.ts v2
```
The script is idempotent — running it again is a no-op for rows already
prefixed with `v2.`. It rotates User, WithingsConnection, AppSettings,
NotificationChannel, and PushSubscription rows. The summary line at the
end shows `scanned`, `rotated`, and `errors` per table+field; treat
`errors > 0` as a hard failure and re-run after fixing the cause.
4. **Drop the old key (optional, recommended).** Once the script has run
cleanly:
```
ENCRYPTION_KEYS='{"v2":"<new key>"}'
ENCRYPTION_ACTIVE_KEY_ID="v2"
# ENCRYPTION_KEY can now be removed
```
Restart. The legacy single-key fallback is now disconnected; only `v2`
exists.
## Adding a third key (v2 → v3)
Same procedure, just shift the labels — keep `v2` in the map until the
script reports zero `v2.`-prefixed rows remaining.
```
ENCRYPTION_KEYS='{"v2":"<old>","v3":"<new>"}'
ENCRYPTION_ACTIVE_KEY_ID="v3"
pnpm tsx scripts/rotate-encryption-key.ts v3
ENCRYPTION_KEYS='{"v3":"<new>"}'
```
## Rollback
> **Important.** Once the rotation script has run, ciphertexts in the
> database start with `v2.` (or whatever the active id is). The pre-PR
> v1.3.x image cannot read that prefix — it expects bare base64 — and
> calling `decrypt()` on those rows will throw.
If you need to revert to the pre-rotation image:
- Either keep the new image. The new code reads both formats, so most
rollback scenarios don't need to undo rotation.
- Or, if you must run the old code, restore a database backup taken
_before_ the rotation script ran. There is no script to convert
`v2.`-prefixed rows back to legacy format — by design, rotation is a
forward-only operation.
This is why we recommend running the rotation in a window where you have a
fresh DB snapshot and the new image has been smoke-tested in production for
at least 24 hours.
## Troubleshooting
- `Encryption key id 'v1' is not configured` — the database still contains
`v1.`-prefixed rows but `v1` was removed from `ENCRYPTION_KEYS`. Re-add
the key, run the rotation script, then remove again.
- `Found a legacy-format ciphertext but no v1 key is configured` — same
cause for legacy bare-base64 rows. Restore `ENCRYPTION_KEY` (or add a
`v1` entry to `ENCRYPTION_KEYS`) and run the rotation script before
removing it.
- `Refusing to rotate: argv key id ... does not match the currently active
id ...` — pass the same id you set in `ENCRYPTION_ACTIVE_KEY_ID`. The
guard prevents accidental re-encryption to a non-current key.
+6
View File
@@ -0,0 +1,6 @@
-- 0025_refresh_tokens — down migration (v1.4 G4 rollback).
-- Forward-compatible: the up migration only ADDS a table, so this is a
-- safe DROP. Any in-flight refresh tokens are invalidated; native clients
-- need to re-authenticate.
DROP TABLE IF EXISTS "refresh_tokens";
+260
View File
@@ -0,0 +1,260 @@
# v1.4.x follow-up issues
Items identified by the v1.4.1 audit pass that warrant deeper work
than a hardening release should land. Each one carries enough context
that a future contributor can pick it up without re-running the audit.
## Security — deferred
### S-FOLLOW-1 — Idempotency-Key concurrent duplicate side-effects (HIGH)
Audit reference: `~/infra/reports/v14-security-audit.md` H1.
The `withIdempotency()` wrapper currently does
`findCached → handler → persistCached`. Two requests with the same
`Idempotency-Key` arriving in parallel both miss the SELECT, both
execute the handler (duplicate measurements / intake events), and
one INSERT loses on the unique constraint silently. The CLAUDE.md
contract ("retries with the same Idempotency-Key … replay the
original response — no second side-effect") is therefore violated
whenever a flaky mobile client double-sends.
**Fix shape**: acquire the slot before the handler runs.
`INSERT … ON CONFLICT DO NOTHING RETURNING id` reserving the
`(userId, key, method, path)` row with a sentinel
`responseStatus = 0`, then UPDATE on completion; concurrent losers
SELECT-loop with backoff for the row to be filled. Or wrap the
whole request in a Postgres advisory lock keyed by hash(userId, key)
via `pg_advisory_xact_lock`.
Why deferred: needs a careful integration test against the new
testcontainers suite (two concurrent inserts → exactly one handler
invocation) and a public-API contract check that the
sentinel-status row never bleeds into a 2xx replay. Fits a v1.4.2
PR scope on its own.
---
### S-FOLLOW-2 — Encryption key fallback NODE_ENV gate (MEDIUM)
Audit reference: `~/infra/reports/v14-security-audit.md` M1.
`src/lib/crypto.ts:61-74` only requires the 64-hex production key
when `process.env.NODE_ENV === "production"`. A fresh Docker run
that forgets to set `NODE_ENV=production` (raw `node …` invocations,
some operator setups) silently accepts a 32-character key and pads
it deterministically with a SHA-256 of itself, halving the entropy
and making the resulting key recoverable from any leaked partial.
**Fix shape**: invert the gate to `NODE_ENV !== "development" &&
NODE_ENV !== "test"` — fail-closed by default — or require an
explicit `ALLOW_WEAK_DEV_KEYS=1` opt-in.
Why deferred: needs a migration note for self-hosters whose `.env`
files lack `NODE_ENV` (default Coolify deploys do set it; bespoke
setups may not). Worth a release note + .env.example call-out.
---
### S-FOLLOW-3 — Refresh-token reuse-detection serialisation (MEDIUM)
Audit reference: `~/infra/reports/v14-security-audit.md` M2.
`src/lib/auth/refresh-token.ts` reuse-detection is a `findUnique`
followed by a non-transactional `findMany + updateMany`. Concurrent
first-time refreshes can briefly have both clients holding
unrevoked access tokens before the loser's are revoked, and a
parallel reuse attempt doubles the audit-log noise.
**Fix shape**: wrap the rotation in a single `prisma.$transaction`
with `Serializable` isolation, OR reorder to "claim first, mint
second": `prisma.refreshToken.update({ where: { id, usedAt: null } })`
fails atomically if the row was already consumed; only after the
update succeeds, call `issueAccessAndRefresh`.
Why deferred: needs an integration test that fires N parallel
refreshes for the same token and asserts exactly one new pair is
issued. The testcontainers suite is the right home; v1.4.2 PR.
---
### S-FOLLOW-4 — moodLog webhook HMAC lookup column (MEDIUM)
Audit reference: `~/infra/reports/v14-security-audit.md` M3.
`src/app/api/integrations/moodlog/webhook/route.ts` performs an
O(n) AES-GCM decrypt sweep across all enabled users for every
webhook hit. Side-channels: timing oracle for ordering, DoS
amplification (30/min IP rate-limit × n decrypts).
**Fix shape**: add a `moodLogWebhookSecretLookupHash` column on
`User` (HMAC-SHA-256 keyed by `API_TOKEN_HMAC_KEY`, populated
alongside the encrypted secret on every write). Webhook does a
single indexed `findUnique` on the lookup hash, bypassing the
candidate iteration. The encryption stays as defence-in-depth.
Why deferred: schema migration required. Backfill needs a one-off
script that decrypts every existing row and writes the lookup
hash. Owners need to approve the migration window. v1.4.2 or
v1.5.
---
### S-FOLLOW-5 — moodLog `readMoodLogSecret` legacy fallback (MEDIUM)
Audit reference: `~/infra/reports/v14-security-audit.md` M4.
If `decrypt()` throws inside `src/lib/moodlog-secret.ts:27-37`, the
function returns the stored value as-is. Intent: legacy plaintext
rows keep working. Side effect: an attacker with read access to
the DB obtains the ciphertext blob and can submit it as the literal
webhook secret.
**Fix shape**: detect "looks like a v1.4 envelope"
(`/^[A-Za-z0-9_-]{1,32}\..+$/`) and refuse the legacy fallback for
envelope-shaped values. Log a metric so the rotation-on-write
contract can be verified to drain.
Why deferred: needs a small data-audit run to confirm no production
row has a non-envelope value before the strict check goes live.
---
### S-FOLLOW-6 — Restore script writes decrypted JSON with default permissions (MEDIUM)
Audit reference: `~/infra/reports/v14-security-audit.md` M5.
`scripts/restore-backup.ts:73-122` writes the decrypted JSON via
`writeFileSync(out, plaintext, "utf8")` with default mode (0644
typical). The file contains decrypted PHI for the restored user.
**Fix shape**:
`writeFileSync(out, plaintext, { encoding: "utf8", mode: 0o600 })`.
Optional: warn if `out` is in a world-readable directory.
Why deferred: tiny one-line fix; bundling with the next ops-script
update keeps the PR coherent.
---
## Performance — deferred
### P-FOLLOW-1 — Recharts top-level import on `/insights` (P0)
Audit reference: `~/infra/reports/v14-performance-audit.md` P0.
`src/app/insights/page.tsx:42-50` static-imports
`{ ScatterChart, Scatter, XAxis, … } from "recharts"` even though
`HealthChart` and `MoodChart` 8 lines above are already wrapped in
`dynamic(…, { ssr: false })`. The static import pulls the whole
recharts bundle into the route's first JS payload, defeating the
dynamic split.
**Fix shape**: extract the BP-mood scatter into
`src/components/charts/bp-mood-scatter.tsx` and import it via
`dynamic(…, { ssr: false })` like the others.
Why deferred: trivial mechanical change but needs a Lighthouse
before/after to quantify the bundle-size win for the v1.4.2
release notes.
---
### P-FOLLOW-2 — `/api/insights/targets` unbounded glucose history scan (P0)
Audit reference: `~/infra/reports/v14-performance-audit.md` P0.
`src/app/api/insights/targets/route.ts:600-604` pulls every glucose
measurement the user ever logged (no `gte: thirtyDaysAgo`) and
filters in JS. Diabetic users with multi-year history will
materialise tens of thousands of rows on every targets fetch.
**Fix shape**: split into "latest per context"
(`distinct: ["glucoseContext"], orderBy: { measuredAt: "desc" }`)
and a `measuredAt: { gte: thirtyDaysAgo }` window query, mirroring
the pattern at lines 102-121 for `latestEverByType`.
Why deferred: needs a per-context test asserting the new query
returns the same payload shape. Pairs nicely with the MoodEntry
index migration (P-FOLLOW-3).
---
### P-FOLLOW-3 — `MoodEntry @@index` is on `(userId, date)` not `(userId, moodLoggedAt)` (P0)
Audit reference: `~/infra/reports/v14-performance-audit.md` P0.
`prisma/schema.prisma:438` declares `@@index([userId, date])`, but
active queries sort/filter by `moodLoggedAt`:
- `src/app/api/insights/targets/route.ts:495`
- `src/app/api/insights/comprehensive/route.ts:52`
- `src/app/api/export/route.ts:87`
The unique constraint `@@unique([userId, date, moodLoggedAt])` is
not selectable by Postgres for windowed `moodLoggedAt`-range reads.
**Fix shape**: add `@@index([userId, moodLoggedAt])` (do NOT drop
the existing index — the unique still serves the moodlog webhook
upsert path). New migration `0026_moodentry_moodloggedat_index`.
Why deferred: schema migration. Owner approval for the migration
window.
---
### P-FOLLOW-4 to -7 — P1 / P2 perf items
- N+1 count() per medication on admin reminder-check
(`src/app/api/admin/notifications/reminder-check/route.ts:84`).
Replace with `groupBy` on `medicationIntakeEvent`.
- `/api/analytics` 8-parallel full-history scan
(`src/app/api/analytics/route.ts:20-40`). Single `findMany` with
`type: { in: types }` and JS-side group.
- `/api/import` sequential `prisma.measurement.create` per row
(`src/app/api/import/route.ts:77-96`). Replace with
`createMany({ skipDuplicates: true })`.
- `pairByTimestamp` not used in `/api/analytics` BP correlation
(`src/app/api/analytics/route.ts:80-86`). Replace the quadratic
loop with the helper from `@/lib/analytics/correlations`.
---
## Test-quality polish (5 minor items)
Audit reference: `~/infra/reports/v14-test-theatre-audit.md`.
- `src/app/api/gamification/__tests__/ios-format.test.ts` L111-112:
upgrade `toBeDefined()` to shape predicates.
- `src/lib/analytics/__tests__/classifications.test.ts` L169-170:
add `toMatch(/60/)` body-content assertion to compliance-alert tests.
- `src/lib/__tests__/idempotency.test.ts` L145: upgrade bare
`toHaveBeenCalled()` to `toHaveBeenCalledWith({ where: …key… })`.
- `src/app/api/telegram/webhook/__tests__/route.test.ts` L457: same
shape — upgrade or drop.
---
## i18n hardcoded strings
Audit reference: `~/infra/reports/v14-i18n-drift-audit.md`.
35+ hardcoded strings in `src/components/settings/ai-section.tsx`
need to be moved into `messages/{en,de}.json` keys. The settings
section was extracted in #143 with strings inline as a known
follow-up; this is the cleanup PR.
Why deferred: low blast-radius (only the AI provider settings page
shows English to a German user; the dashboard, dialogs, and
critical UX paths all flow through `t(…)`). v1.4.x or v1.5.
---
## Notes
These are the items the v1.4.1 audit identified that the hardening
release deliberately did NOT take. The audit also found 0 hallucinated
medical claims, 0 i18n key-parity gaps, and the test theatre rate
across the entire codebase was low (5 minor patterns). The codebase
is in healthy shape; this list is the punch-list for the next
proactive iteration, not a backlog of bugs we know about and ignore.
+53
View File
@@ -0,0 +1,53 @@
# Scaling: web/worker container split (v1.4 G3)
The default `docker compose up` launches a single container that runs
both the Next.js HTTP server AND the pg-boss worker. That works fine
for a personal deployment but is hard to scale horizontally because
every replica also runs the full reminder/insight cron schedule.
v1.4 introduces an environment-variable gate so the same image can be
deployed in three modes:
| `HEALTHLOG_PROCESS_TYPE` | Web | Worker | Use when |
| ------------------------ | --- | ------ | ------------------------- |
| `all` (default) | ✅ | ✅ | single-host self-hosting |
| `web` | ✅ | ❌ | scaling HTTP horizontally |
| `worker` | ❌ | ✅ | dedicated job worker |
## How to split
1. Edit `docker-compose.yml`: set `HEALTHLOG_PROCESS_TYPE=web` on the
`app` service and uncomment the `app-worker` service block at the
bottom (it ships with `HEALTHLOG_PROCESS_TYPE=worker` baked in).
2. Make sure both containers see the same `DATABASE_URL`,
`ENCRYPTION_KEY` (or `ENCRYPTION_KEYS`), `API_TOKEN_HMAC_KEY`,
and `SESSION_SECRET`.
3. `docker compose up -d --build`.
Both containers connect to the same Postgres. pg-boss claims jobs
atomically via row-level locking, so it is safe to run multiple worker
replicas — they will share the load.
## Healthchecks
- `app` (web) healthcheck: `wget /api/version` every 30s.
- `app-worker` does not expose a port; its liveness is reported into
the `worker_status` table on every reminder pass and surfaced through
`/api/admin/worker-status`.
The web container does NOT depend on the worker, and vice versa, so
neither container's startup can deadlock the other. The shared
dependency is Postgres; both wait on `db: condition: service_healthy`.
## Caveats
- Background tasks that touch user-scoped Wide Events (e.g. reminder
notifications) emit telemetry from the worker; configure `LOKI_*`
env vars on the worker if you want them.
- The off-host backup job runs in the worker only. The admin
`POST /api/admin/backup/test` endpoint runs in the web container and
exercises the same S3 credentials — set `BACKUP_*` env vars on BOTH.
- The startup gate (`assertSubsystemEnabled`) refuses to boot the
reminder worker when `HEALTHLOG_PROCESS_TYPE=web`, so an accidental
cross-mode invocation aborts immediately instead of silently doubling
cron load.
File diff suppressed because it is too large. Load diff
@@ -1,359 +0,0 @@
# Codex OAuth + Medizinische Insights — Design Spec
**Datum:** 2026-04-06
**Status:** Approved
**Scope:** Replace OpenAI API-Key flow with ChatGPT Codex OAuth, improve medical insight prompts
## 1. Problem
HealthLog uses user-provided OpenAI API keys for AI-powered health insights. The API quota ran out (`insufficient_quota`), breaking all insight generation and background jobs. Users shouldn't need to manage API billing.
## 2. Solution
Replace the API-key model with **OpenAI Codex OAuth** — users authenticate with their ChatGPT Pro/Max subscription, and insights run against their flat-rate plan. An admin-level API key serves as a global fallback.
## 3. Provider Hierarchy
```
1. User has Codex OAuth connection → Codex Responses API (gpt-5.3-codex)
2. Admin key configured → Standard OpenAI Chat Completions API (configurable model)
3. Nothing configured → Local fallback texts (no-key-fallbacks.ts)
```
## 4. Architecture
### New Files
```
src/lib/ai/
provider.ts — resolveProvider(userId) → { type, client, model }
codex-oauth.ts — PKCE generation, token exchange, refresh
codex-client.ts — Codex Responses API client with auto-refresh
openai-client.ts — Standard OpenAI client (admin key, configurable base URL)
types.ts — Shared types (InsightResult, ProviderConfig, etc.)
prompts/
base-system.ts — Shared medical context, role, constraints
general-status.ts — Holistic health assessment prompt
blood-pressure.ts — ESC/ESH guideline-based BP analysis
weight.ts — BMI context, trend analysis, plateau detection
pulse.ts — Resting HR trends, stress correlation
bmi.ts — WHO classification, age-adjusted
medication-compliance.ts — Intake patterns, efficacy correlation
schema.ts — Zod schema for InsightResult validation
```
### Modified Files
```
src/app/api/auth/codex/authorize/route.ts — NEW: Start OAuth flow
src/app/api/auth/codex/callback/route.ts — NEW: Handle OAuth callback
src/app/api/auth/codex/disconnect/route.ts — NEW: Remove tokens
src/app/api/admin/ai-settings/route.ts — NEW: Admin key management
src/app/api/insights/generate/route.ts — MODIFY: Use provider abstraction
src/app/api/insights/settings/route.ts — MODIFY: Show Codex status instead of API key
src/app/api/insights/*/route.ts — MODIFY: New output schema
src/lib/insights/*.ts — MODIFY: Use new prompts + provider
src/lib/jobs/reminder-worker.ts — MODIFY: Use provider with auto-refresh
src/proxy.ts — MODIFY: Add /api/auth/codex/callback to PUBLIC_PATHS
prisma/schema.prisma — MODIFY: New fields, remove openaiKeyEncrypted
```
### Removed Files/Code
```
src/app/api/insights/settings/route.ts — OpenAI key PUT handler (replaced)
src/lib/insights/prompt.ts — Single prompt file (replaced by prompts/)
User.openaiKeyEncrypted — DB field removed
```
## 5. OAuth Flow
### Authorization (User-initiated)
```
1. User clicks "Mit ChatGPT verbinden" in Settings
2. GET /api/auth/codex/authorize
- Generate PKCE code_verifier (128 bytes, base64url)
- Compute code_challenge = SHA-256(code_verifier), base64url
- Generate random state parameter
- Store code_verifier + state in session
- Redirect to: chatgpt.com/authorize?
response_type=code
&code_challenge=<challenge>
&code_challenge_method=S256
&redirect_uri=https://healthlog.bombeck.io/api/auth/codex/callback
&state=<state>
3. User logs in at ChatGPT, grants access
4. Redirect to /api/auth/codex/callback?code=...&state=...
5. Validate state against session
6. Exchange code for tokens (POST chatgpt.com/oauth/token)
7. Encrypt access_token + refresh_token with AES-256-GCM
8. Store in User record with expiresAt
9. Redirect to /settings with success toast
```
### Token Refresh (Automatic)
```
Before every API call:
if codexTokenExpiresAt < now + 5min:
POST chatgpt.com/oauth/token (grant_type=refresh_token)
Store new encrypted tokens + new expiresAt
If refresh fails:
Increment failCount
If failCount >= 3 over 72h:
Set codexConnectionStatus = 'expired'
Fall back to admin key silently
Else:
Retry on next call
On 401 response:
Refresh token immediately, retry request once
```
### Disconnect
```
DELETE /api/auth/codex/disconnect
- Delete tokens from DB
- Revoke at OpenAI if endpoint exists
- Audit log entry
```
## 6. Database Changes
### Prisma Schema
```prisma
model User {
// REMOVE:
// openaiKeyEncrypted String?
// ADD:
codexAccessTokenEncrypted String?
codexRefreshTokenEncrypted String?
codexTokenExpiresAt DateTime?
codexConnectedAt DateTime?
codexConnectionStatus String @default("disconnected")
// Values: "connected", "disconnected", "expired"
}
model AppSettings {
// ADD:
adminAiKeyEncrypted String?
adminAiModel String @default("gpt-4o-mini")
adminAiBaseUrl String @default("https://api.openai.com/v1")
}
```
### Migration
```sql
-- Add new columns
ALTER TABLE users ADD COLUMN codex_access_token_encrypted TEXT;
ALTER TABLE users ADD COLUMN codex_refresh_token_encrypted TEXT;
ALTER TABLE users ADD COLUMN codex_token_expires_at TIMESTAMP;
ALTER TABLE users ADD COLUMN codex_connected_at TIMESTAMP;
ALTER TABLE users ADD COLUMN codex_connection_status TEXT NOT NULL DEFAULT 'disconnected';
ALTER TABLE app_settings ADD COLUMN admin_ai_key_encrypted TEXT;
ALTER TABLE app_settings ADD COLUMN admin_ai_model TEXT NOT NULL DEFAULT 'gpt-4o-mini';
ALTER TABLE app_settings ADD COLUMN admin_ai_base_url TEXT NOT NULL DEFAULT 'https://api.openai.com/v1';
-- Remove old column
ALTER TABLE users DROP COLUMN openai_key_encrypted;
```
## 7. Insight Output Schema
### New InsightResult (replaces old changed/stable/drivers/nextSteps)
```typescript
interface InsightResult {
summary: string
classification: 'optimal' | 'gut' | 'grenzwertig' | 'erhoht' | 'kritisch'
findings: {
label: string
value: string
assessment: 'positive' | 'neutral' | 'attention' | 'warning'
guideline?: string
}[]
correlations: {
factor: string
effect: string
confidence: 'hoch' | 'mittel' | 'gering'
}[]
recommendations: string[]
dataQuality: {
coverage: string
gaps: string[]
confidence: 'hoch' | 'mittel' | 'gering'
}
disclaimer: string
}
```
### Zod Validation
Every AI response is validated against a Zod schema before being stored.
Invalid responses are logged as errors and the user gets a generic fallback.
## 8. Prompt Design
### Base System Prompt
```
Du bist ein klinischer Gesundheitsanalyst mit Expertise in Innerer Medizin
und Präventivmedizin. Deine Analyse basiert auf aktuellen Leitlinien
(ESC/ESH 2023, WHO, DGE, DEGAM).
Regeln:
- Evidenzbasiert: Referenziere Grenzwerte und Leitlinien explizit
- Muster erkennen: Tageszeit, Wochentag, saisonale Trends
- Korrelationen: Wechselwirkungen zwischen Medikation und Vitalwerten benennen
- Limitationen: Klar kommunizieren was die Datenlage hergibt und was nicht
- Kein Ersatz für ärztliche Diagnose — immer explizit als Disclaimer
- Sprache: Deutsch, medizinisch präzise aber allgemeinverständlich
- Antworte ausschließlich im vorgegebenen JSON-Schema
```
### Per-Type Specialization
Each insight type adds domain-specific instructions:
- **Blood Pressure**: ESC/ESH classification (optimal <120/80, normal <130/85, ...), morning surge detection, white-coat vs. masked hypertension hints, medication correlation
- **Weight**: BMI-adjusted analysis, 7/30/90-day slope, plateau detection (±0.5kg over 14+ days), realistic goal projection
- **Pulse**: Resting HR zones (bradycardia <50, athletic 50-60, normal 60-100, tachycardia >100), trend stability, stress/recovery indicators
- **BMI**: WHO classification, waist-to-height hints if available, age context
- **Medication Compliance**: Pattern analysis (morning vs evening adherence), streak tracking, correlation with vital sign improvements
- **General Status**: Cross-domain synthesis, risk stratification, most impactful change recommendation
## 9. UI Changes
### User Settings (/settings)
Replace "OpenAI API Key" section with:
```
KI-Insights
┌─────────────────────────────────────────┐
│ Status: 🟢 Verbunden mit ChatGPT │
│ Verbunden seit: 02.04.2026 │
│ │
│ [Trennen] │
│ │
│ ℹ️ Insights werden über dein ChatGPT- │
│ Abo generiert (keine zusätzlichen │
│ Kosten). │
└─────────────────────────────────────────┘
```
Or when disconnected:
```
KI-Insights
┌─────────────────────────────────────────┐
│ Status: ⚪ Nicht verbunden │
│ │
│ [Mit ChatGPT verbinden] │
│ │
│ ℹ️ Verbinde dein ChatGPT Pro/Max-Konto │
│ um KI-gestützte Gesundheitsanalysen zu │
│ erhalten. │
└─────────────────────────────────────────┘
```
### Admin Settings (/settings — ADMIN only)
```
KI-Anbieter (Global)
┌─────────────────────────────────────────┐
│ API Key: [sk-...****________] [Testen] │
│ Modell: [gpt-4o-mini ▾] │
│ Base URL: [https://api.openai.com/v1] │
│ │
│ ℹ️ Wird als Fallback verwendet, wenn │
│ ein User keine eigene ChatGPT- │
│ Verbindung hat. │
│ │
│ [Speichern] │
└─────────────────────────────────────────┘
```
### Insights Display
Update insight cards to render the richer InsightResult:
- Classification badge with color (green/yellow/orange/red)
- Findings list with assessment icons
- Collapsible correlations section
- Data quality indicator (small badge)
- Disclaimer footer (smaller text)
## 10. Security
### OAuth Security
- PKCE (S256) prevents authorization code interception
- State parameter prevents CSRF
- Tokens encrypted at rest (AES-256-GCM via existing crypto.ts)
- Tokens never sent to client/browser
- Refresh token rotation on every refresh
- Callback route validates state against session
### Admin Key Security
- Only ADMIN role can read/write
- Key displayed masked in UI (sk-...****)
- Rate limiting on admin key usage (prevents abuse by many users)
- Encrypted at rest
### Audit Trail
- Every OAuth connect/disconnect logged
- Every insight generation logs: provider type, user, model, duration
- Admin key changes logged
### Security Audits (3x)
1. **Pre-Implementation**: OAuth flow validated against OWASP OAuth Security Checklist
2. **Post-Implementation**: Code review agent checks all new files for injection, token leaks, unencrypted secrets
3. **Post-Deploy**: Penetration test of OAuth endpoints (CSRF, token theft, callback manipulation)
## 11. Testing Strategy (TDD)
### Unit Tests (vitest)
| Suite | Coverage |
|-------|----------|
| `ai/provider.test.ts` | Provider hierarchy, correct client selection |
| `ai/codex-oauth.test.ts` | PKCE generation, token exchange, refresh, error scenarios |
| `ai/codex-client.test.ts` | Request format, auto-refresh on 401, retry with backoff |
| `ai/openai-client.test.ts` | Chat completions format, custom base URL, error handling |
| `ai/prompts/*.test.ts` | Prompt assembly per type, output schema validation with Zod |
| `insights/*.test.ts` | Updated generators with new output schema |
| `api/codex-callback.test.ts` | State validation, CSRF protection, token storage |
| `api/admin-settings.test.ts` | ADMIN-only access, key encryption, validation |
### Integration Tests
- OAuth flow end-to-end (mocked ChatGPT endpoint)
- Background job token refresh + insight generation
- Provider fallback chain under different DB states
## 12. CSP Changes
```diff
- connect-src 'self' https://api.openai.com https://wbsapi.withings.net
+ connect-src 'self' https://api.openai.com https://chatgpt.com https://wbsapi.withings.net
```
## 13. Migration Path
1. Deploy new code with both old (openaiKeyEncrypted) and new fields
2. Run migration to add new columns
3. Remove old column in follow-up migration
4. Users see "Nicht verbunden" in settings, can connect via OAuth
5. Admin can configure fallback key immediately
6. Old cached insights remain visible until refreshed
## 14. Out of Scope
- Multiple AI providers per user (only Codex OAuth OR admin key)
- Custom model selection per user (model is fixed per provider)
- Streaming responses (batch only, like current implementation)
- OpenAI API key input for individual users (removed entirely)
@@ -1,207 +0,0 @@
# Blood Glucose Tracking — Design Spec
**Datum:** 2026-04-18
**Status:** Draft
**Scope:** Add `BLOOD_GLUCOSE` as a first-class measurement type with context-aware classification (fasting / postprandial / random / bedtime), user-selectable display unit (mg/dL ↔ mmol/L), reference ranges from ADA 2024 and DGIM 2023, and full integration across form, charts, targets, doctor PDF, and insights.
## 1. Problem Statement
Users repeatedly request blood-sugar logging — most recently in **GitHub issue #73, point 8**. Diabetics, pre-diabetics, and people on metabolic-impacting medication (steroids, atypical antipsychotics) need a single place to log glucose alongside weight, BP, and pulse so the doctor PDF reflects the complete metabolic picture. Today they fall back to spreadsheets or vendor-locked apps. Adding glucose to HealthLog closes the largest remaining gap in the "vital sign" set and unlocks correlation insights (e.g. weight ↔ fasting glucose, sleep ↔ glucose variability).
## 2. Scope
In v1 we ship:
- A new `BLOOD_GLUCOSE` measurement type stored canonically in **mg/dL**.
- A required **context tag** per reading: `FASTING`, `POSTPRANDIAL`, `RANDOM`, `BEDTIME`.
- A user preference `glucoseUnit` (`MG_DL` | `MMOL_L`, default `MG_DL` for `de` users, can flip in settings) — display only; storage is canonical.
- Context-aware classification (see §4) and a target card on `/targets` for fasting glucose; secondary line for postprandial when data exists.
- Health chart with diabetes/pre-diabetes band overlays.
- Doctor PDF row + classification line.
- Insights descriptor "glucose control" (fasting avg + variability).
**Stretch (v1.1, not in this spec):** HbA1c as a separate measurement type with its own classification (<5.7% / 5.7–6.4% / ≥6.5%, ADA 2024). Schema is forward-compatible (just add another enum value).
## 3. Data Model
```prisma
enum MeasurementType {
WEIGHT
BLOOD_PRESSURE_SYS
BLOOD_PRESSURE_DIA
PULSE
BODY_FAT
SLEEP_DURATION
ACTIVITY_STEPS
BLOOD_GLUCOSE // NEW
}
enum GlucoseContext { // NEW
FASTING
POSTPRANDIAL
RANDOM
BEDTIME
@@map("glucose_context")
}
model Measurement {
// ...existing fields
glucoseContext GlucoseContext? @map("glucose_context") // NEW, only set when type = BLOOD_GLUCOSE
}
model User {
// ...existing fields
glucoseUnit String @default("MG_DL") @map("glucose_unit") // "MG_DL" | "MMOL_L"
}
```
Migration `20260418_add_blood_glucose`:
1. `ALTER TYPE measurement_type ADD VALUE 'BLOOD_GLUCOSE';`
2. `CREATE TYPE glucose_context AS ENUM (...);`
3. `ALTER TABLE measurements ADD COLUMN glucose_context glucose_context;`
4. `ALTER TABLE users ADD COLUMN glucose_unit text NOT NULL DEFAULT 'MG_DL';`
5. Add `CHECK ((type = 'BLOOD_GLUCOSE') = (glucose_context IS NOT NULL))` to enforce coupling.
The unique constraint `(userId, type, measuredAt, source)` already prevents duplicates; context is intentionally **not** part of the key — the same timestamp can't legally carry two contexts.
Storage unit is always **mg/dL** (`unit = "mg/dL"` in the row) so downstream analytics, exports, and Withings ingest don't need to branch.
## 4. Classifications (`src/lib/analytics/classifications.ts`)
New helpers, ranges from **ADA Standards of Care 2024** (Diabetes Care, Vol. 47, Suppl. 1) and **DGIM/DDG S3-Leitlinie 2023**:
```ts
export type GlucoseContext = "FASTING" | "POSTPRANDIAL" | "RANDOM" | "BEDTIME";
export interface GlucoseClassification {
category: string;
color: string;
severity: "info" | "normal" | "warning" | "danger";
}
export function classifyGlucose(
mgdl: number,
context: GlucoseContext,
): GlucoseClassification;
```
Boundaries (mg/dL — mmol/L conversion shown for traceability):
| Context | Hypo (<70 / <3.9) | Normal | Pre-diabetes | Diabetes |
|---------|-------------------|--------|--------------|----------|
| FASTING | danger | 70–99 (3.9–5.5) normal | 100–125 (5.6–6.9) warning | ≥126 (≥7.0) danger |
| POSTPRANDIAL (2h) | danger | <140 (<7.8) normal | 140–199 (7.8–11.0) warning | ≥200 (≥11.1) danger |
| RANDOM | danger | <140 normal | 140–199 warning | ≥200 danger |
| BEDTIME | danger | 90–150 normal (info if outside) | — | ≥250 danger |
Color tokens reuse the existing Dracula palette (`#50fa7b` normal, `#f1fa8c` info, `#ffb86c` / `#ff79c6` warning, `#ff5555` danger). Add `getGlucoseFastingRange()` returning `{ min: 70, max: 99 }` for the target card range bar, and `getGlucosePostprandialRange()` for the optional secondary bar.
## 5. UI
**Measurement form (`src/components/measurements/measurement-form.tsx`):**
- New entry in `MEASUREMENT_TYPES`: `{ value: "BLOOD_GLUCOSE", labelKey: "measurements.typeGlucose", unitKey: "measurements.unitGlucose", placeholder: "95" }`.
- When `type === "BLOOD_GLUCOSE"`, render an additional `<Select>` for context (4 options). Default = `FASTING` between 04:00–10:00 local time, else `RANDOM`.
- Unit suffix is resolved via `useGlucoseUnit()` hook reading `user.glucoseUnit`; the input value is converted to mg/dL before POST.
- Min 20, max 600 mg/dL (or equivalent mmol/L); inputs accept 1 decimal in mmol/L mode, integers in mg/dL mode.
**Targets page (`src/app/targets/page.tsx`):**
- New `TYPE_ICONS["BLOOD_GLUCOSE"]` = `Droplet` (Lucide), color token `text-dracula-yellow`.
- Card title "Fasting glucose" with `RangeBar` 70–99, orangeMin 100, orangeMax 125 (puts pre-diabetes in yellow zone, diabetes in red).
- If postprandial data exists in last 30 days, render a second labelled `RangeBar` below using `getGlucosePostprandialRange()`, mirroring the BP-diastolic pattern already in `TargetCard`.
- Source link → `https://diabetesjournals.org/care/issue/47/Supplement_1` (ADA 2024).
**Health chart (`src/components/charts/health-chart.tsx`):**
- New chart preset on `/charts` page: `types: ["BLOOD_GLUCOSE"]`, color `var(--dracula-yellow)`, `valueBands` for pre-diabetes (100–125) and diabetes (≥126) overlays, plus a `ReferenceLine` at 70 (hypo).
- Filter chips for context (All / Fasting / Postprandial / Random / Bedtime) — implemented as a client-side filter on the dataset, not a new API call.
- Y-axis unit follows user pref (mg/dL or mmol/L); points are converted on render.
**Insights:** Add `glucose-control.ts` insight returning `{ avgFasting, fastingInTargetPct, postprandialPeak, variability, classification }`. AI-friendly descriptor injected into the general-status prompt: `"glucose control: avg fasting {avg} mg/dL ({class}), {pct}% of fasting readings in target."`
**Doctor PDF (`src/lib/doctor-report-pdf.ts`):**
- `TYPE_LABEL_KEYS["BLOOD_GLUCOSE"] = "doctorReport.typeGlucose"`.
- `TYPE_UNIT_KEYS["BLOOD_GLUCOSE"]` resolves to user pref via a new `unitFor(type, ctx)` extension.
- Add `BLOOD_GLUCOSE` to `vitalTypes` array — table row appears automatically.
- New optional section "Glukose-Klassifikation" below the BP block: shows fasting average + classification key, plus postprandial average if `count > 0`, with i18n keys `doctorReport.glucoseClassificationTitle`, `doctorReport.glucoseFastingRow`, `doctorReport.glucosePostprandialRow`.
- Distribution table per context (n per FASTING/POSTPRANDIAL/RANDOM/BEDTIME) so the GP sees coverage.
## 6. API
No new endpoints. `POST /api/measurements` already accepts `type`, `value`, `measuredAt`, `notes` — extend the Zod schema in `src/lib/validations/measurement.ts` with optional `glucoseContext: z.enum([...])` and a refinement: `BLOOD_GLUCOSE` requires the context, other types reject it. The form sends mg/dL; conversion happens client-side.
`/api/analytics` and `/api/insights/targets` add `BLOOD_GLUCOSE` to their type whitelists and emit a `glucose` summary object with per-context aggregates when ≥1 reading exists.
`PATCH /api/auth/me` (already exists for profile updates) gains an optional `glucoseUnit` field.
## 7. Conversion Helpers
New module `src/lib/glucose.ts`:
```ts
export const GLUCOSE_FACTOR = 18.0182; // mg/dL per mmol/L (molar mass of glucose)
export function mgdlToMmol(mgdl: number): number {
return Math.round((mgdl / GLUCOSE_FACTOR) * 10) / 10; // 1 decimal
}
export function mmolToMgdl(mmol: number): number {
return Math.round(mmol * GLUCOSE_FACTOR); // integer
}
export function formatGlucose(mgdl: number, unit: GlucoseUnit, t: T): string;
```
Round-trip rule: `mmolToMgdl(mgdlToMmol(x))` must be within ±1 mg/dL of `x` for x ∈ [40, 400]. Tested in §9.
## 8. i18n Keys (`messages/de.json` + `messages/en.json`)
| Key | DE | EN |
|---|---|---|
| `measurements.typeGlucose` | Blutzucker | Blood glucose |
| `measurements.unitGlucose.mgdl` | mg/dL | mg/dL |
| `measurements.unitGlucose.mmol` | mmol/L | mmol/L |
| `measurements.glucoseContextLabel` | Kontext | Context |
| `measurements.glucoseContextFasting` | Nüchtern | Fasting |
| `measurements.glucoseContextPostprandial` | Nach dem Essen (2 h) | Post-meal (2h) |
| `measurements.glucoseContextRandom` | Zufällig | Random |
| `measurements.glucoseContextBedtime` | Vor dem Schlafen | Bedtime |
| `targets.glucoseFastingTitle` | Nüchtern-Blutzucker | Fasting glucose |
| `targets.glucosePostprandialTitle` | 2-h-Postprandial | 2h post-meal |
| `targets.glucoseSourceAda` | ADA 2024 / DGIM 2023 | ADA 2024 / DGIM 2023 |
| `classifications.glucoseHypo` | Hypoglykämie | Hypoglycemia |
| `classifications.glucoseNormal` | Normal | Normal |
| `classifications.glucosePrediabetes` | Prädiabetes | Pre-diabetes |
| `classifications.glucoseDiabetes` | Diabetes-Bereich | Diabetes range |
| `doctorReport.typeGlucose` | Blutzucker | Blood glucose |
| `doctorReport.glucoseClassificationTitle` | Glukose-Klassifikation | Glucose classification |
| `doctorReport.glucoseFastingRow` | Ø Nüchtern: {avg} {unit} — {class} | Avg fasting: {avg} {unit} — {class} |
| `doctorReport.glucosePostprandialRow` | Ø Postprandial: {avg} {unit} — {class} | Avg postprandial: {avg} {unit} — {class} |
| `settings.glucoseUnitLabel` | Blutzucker-Einheit | Blood glucose unit |
| `insights.glucoseControlTitle` | Glukose-Kontrolle | Glucose control |
## 9. Nyquist Validation
- **Unit conversion**: property test — for every integer mg/dL in [40, 400], `mmolToMgdl(mgdlToMmol(x))` differs from `x` by ≤1.
- **Classification boundaries**: assert `classifyGlucose(99, "FASTING").severity === "normal"`, `classifyGlucose(100, "FASTING").severity === "warning"`, `classifyGlucose(125, ...) === "warning"`, `classifyGlucose(126, ...) === "danger"`, `classifyGlucose(69, "FASTING").severity === "danger"` (hypo). Same for postprandial at 139/140/199/200.
- **Schema refinement**: `POST /api/measurements` with `BLOOD_GLUCOSE` and missing `glucoseContext` returns 400; with non-glucose type and a `glucoseContext` returns 400.
- **End-to-end smoke**: form submit → DB row → chart render → PDF generation; assert mg/dL stored regardless of UI unit, assert PDF row appears in `vitalRows`.
- **Doctor PDF snapshot test**: render with mixed contexts, assert classification line and per-context distribution rows.
## 10. Risks
- **Hypoglycemia notifications**: showing "severe low — seek help" on every reading <54 mg/dL could panic users with sensor noise or delayed entries. Notifications are **opt-in** via NotificationPreference (`MEASUREMENT_ANOMALY` event), default off, and the message text always ends with "HealthLog ist kein Medizinprodukt — bei akuten Symptomen 112 anrufen."
- **Unit confusion**: a user flipping the unit pref must not reinterpret historical mg/dL values as mmol/L. Migration sets a default; UI shows the unit suffix on every value.
- **Withings overlap**: Withings supports glucose via some BGM integrations. Document that Withings sync stays disabled for glucose in v1 (out of scope) to avoid double-entry races.
- **Context defaulting**: auto-selecting `FASTING` based on time of day is a heuristic; the user must always be able to override before save.
## 11. Out of Scope
- CGM (Dexcom / Libre) live integration.
- Ketone (β-hydroxybutyrate) tracking.
- Insulin dose log and bolus calculator.
- Carb counting / meal logging.
- HbA1c (deferred to v1.1, schema is ready).
## 12. Effort
**M (medium).** Roughly: schema + migration (S), classifications + conversion lib + tests (S), form/context selector + unit pref (M), targets card + chart preset (S), insight + PDF row + i18n (M), Nyquist tests (S). Estimate 1.5–2 dev-days end-to-end including review. No external dependencies, no new infra.
@@ -1,162 +0,0 @@
# Built-in Feedback System — Design Spec
**Status:** Proposed (v1.2+)
**Date:** 2026-04-18
**Owner:** TBD
**Tracking:** GitHub issue #73 (point 6)
**Effort:** M
## 1. Problem Statement
The current bug-report flow (`src/app/bugreport/page.tsx` + `src/app/api/bugreport/route.ts`) is gated on a GitHub token and repository configured by an admin. The status route returns `configured: false` when those are missing, and the UI degrades to an "ask your admin to configure GitHub" screen — so a non-technical owner of a self-hosted HealthLog instance cannot collect feedback at all without first procuring a GitHub PAT and dedicating a repo.
Verbatim from issue #73, point 6: *"Provide a simple built-in feedback system where users can submit reports without GitHub accounts, repositories, or tokens. The app should handle issue creation automatically in the background. Alternatively, provide a pre-configured default repository maintained by the developer."*
We need a feedback path that **works with zero external configuration**, while keeping the existing GitHub integration available as an **optional escalation** for instance owners who do want public issues.
## 2. Approach
- **Primary store: Postgres.** Every submission lands in a new `Feedback` table managed by Prisma. No external dependency required.
- **Admin inbox UI** at `/admin#feedback` lists, filters and triages submissions.
- **GitHub stays optional and per-item.** The admin can press "Publish to GitHub issue" on any feedback row; the existing route logic in `src/app/api/bugreport/route.ts` is refactored into a reusable helper and called from the new admin endpoint. If GitHub isn't configured, the button is hidden — the rest of the flow is unaffected.
- **v1 admin notification: email** via the existing `src/lib/notifications/dispatcher.ts` (Telegram / ntfy / web-push reuse the same dispatcher). Optional; silent if no admin channel is configured.
- **Existing `/api/bugreport` is deprecated but kept** for two release cycles so any external automation still works. Internally it forwards to the new feedback pipeline.
## 3. Data Model
New Prisma model in `prisma/schema.prisma`, additive only:
```prisma
enum FeedbackCategory { BUG FEATURE_REQUEST QUESTION OTHER }
enum FeedbackStatus { OPEN ACK RESOLVED ARCHIVED }
model Feedback {
id String @id @default(cuid())
userId String? // nullable — see auth note below
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
email String? // snapshot of submitter email at time of submission
category FeedbackCategory @default(BUG)
subject String @db.VarChar(200)
description String @db.Text // ≤ 5000 chars enforced in Zod
screenshotBase64 String? @db.Text // ≤ 5 MB; later moved to object storage
metadata Json? // { url, userAgent, locale, appVersion, viewport, timestamp }
status FeedbackStatus @default(OPEN)
adminNote String? @db.Text
gitHubIssueUrl String?
notifiedEmail Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status, createdAt])
@@index([userId])
}
```
Notes:
- `userId` is nullable to keep the door open for anonymous public-instance submissions in a later iteration; **v1 still requires auth** (`requireAuth()`), so the column will always be populated. The `email` snapshot survives user deletion for triage continuity.
- `metadata` is a free-form JSON envelope mirroring the same context we'd otherwise paste into a GitHub issue body — we capture it once at submission time so the admin doesn't have to ask "what page were you on?".
- An `AuditLog` row (`feedback.submit`, `feedback.status_change`, `feedback.publish_github`) follows the existing pattern in `src/lib/auth/audit.ts`.
## 4. API
All routes use `apiHandler` (Wide Events + x-request-id) and return the standard `{ data, error, meta }` envelope.
| Method | Path | Auth | Purpose |
|---|---|---|---|
| `POST` | `/api/feedback` | user | Submit feedback. Rate-limited 5/h per `user.id`. Zod-validated. Returns `{ id, confirmationNumber }`. |
| `GET` | `/api/admin/feedback` | admin | Paginated list. Query params: `status`, `category`, `q`, `page`, `pageSize` (default 20, cap 100). |
| `GET` | `/api/admin/feedback/:id` | admin | Single item with raw `metadata` and screenshot. |
| `PATCH`| `/api/admin/feedback/:id` | admin | Mutate `status` and/or `adminNote`. Writes audit log. |
| `POST` | `/api/admin/feedback/:id/github` | admin | Publish to GitHub. 404 if GitHub not configured. Stores `gitHubIssueUrl` on success. |
| `GET` | `/api/feedback/status` | user | Public capability probe: `{ available: true, githubAvailable: boolean }`. Replaces `/api/bugreport/status`'s "configured: false" wall. |
`apiSuccess` payloads use `confirmationNumber = id.slice(-6).toUpperCase()` so users can quote a short code without leaking the full cuid.
## 5. UI
### `/bugreport` (renamed conceptually to "Feedback", URL kept)
- **Category** dropdown (Bug / Feature request / Question / Other).
- **Subject** input (max 120 chars, required).
- **Description** textarea (10–5000 chars, existing constraint).
- **Screenshot** optional, same upload control as today, ≤ 5 MB, EXIF stripped client-side before base64 encoding.
- **Submit** — always enabled when authenticated. No more "not configured" wall.
- On success: toast "Thanks for your feedback — reference #ABC123".
### Admin section `/admin#feedback`
- Table: created, category badge, subject, status badge, submitter, actions.
- Filters: status (OPEN/ACK/RESOLVED/ARCHIVED), category, free-text search over `subject`/`description`.
- Detail dialog: full description, screenshot preview, raw `metadata` JSON, admin note textarea, action buttons **Ack** / **Resolve** / **Archive** / **Publish to GitHub** (last one only when `githubAvailable`).
- Empty states (no feedback yet, no results for filter) with translated copy.
## 6. Screenshot Handling
- **Storage:** base64 in the `screenshotBase64` column. Hard cap **5 MB per item** (matching the current `bugReportSchema.screenshot` limit) and a soft per-user **20 MB total quota** rejected at submit with a 413.
- **EXIF stripping** in the browser via canvas re-encode before upload — prevents inadvertent GPS leak.
- **PII warning** in the form copy: "Your screenshot may contain personal data. Crop it before uploading if unsure."
- **Risk acknowledged in §13** — DB bloat is the obvious downside; v2 moves blobs to object storage.
## 7. Abuse & Safety
- Rate-limit via `src/lib/rate-limit.ts`: `feedback:submit:${user.id}` → 5 per rolling hour. Admin endpoints rate-limited too (`feedback:admin:${user.id}`, 60/min).
- Description and subject are **plain text** — no HTML, no Markdown rendering on display in the admin inbox (use `<pre>` or a plain `<div>` with `whitespace-pre-wrap`). When publishing to GitHub we apply the same backtick-escape sanitization as the current bugreport route.
- Length caps enforced in Zod and at the DB level (`@db.VarChar(200)` for subject).
- **Auto-archive job** in `src/lib/jobs/`: daily pg-boss job marks `RESOLVED` items older than 180 days as `ARCHIVED`, and deletes `ARCHIVED` items older than 365 days. Configurable via `AppSettings`.
- Inbox-size soft cap (e.g., 10 000 OPEN rows) surfaces a banner in the admin UI prompting cleanup.
## 8. Migration
- Additive Prisma migration only (new enum, new table). No existing data touched.
- `/api/bugreport` route stays for **two release cycles**, internally calls the new feedback pipeline, and emits a `Deprecation` response header pointing to `/api/feedback`.
- `/api/bugreport/status` keeps responding for legacy clients but its `configured` field now means "feedback works" (always `true` for authenticated users), with `hasGithub` exposed separately.
- No data backfill required.
## 9. Notifications
- On a successful `POST /api/feedback`, dispatch through `src/lib/notifications/dispatcher.ts` to whichever admin channels are configured (email-first for v1; Telegram/ntfy/web-push fall out for free since they share the dispatcher).
- Payload: category, subject, submitter username, deep link `/admin#feedback?id=...`. **No description body** in the notification — keeps PII out of email/push transports.
- `notifiedEmail = true` after a successful send so retries don't double-fire.
- If no channel is configured, dispatch is a no-op (logged as an info annotation, not a warning).
## 10. i18n
New translation namespace `feedback.*` in `messages/en.json` and `messages/de.json`:
- Category names (`feedback.category.bug` etc.), status names, form labels, admin-inbox column headers, action button labels, empty states, toast/confirmation copy, PII warning text, deprecation banner on legacy bugreport endpoint.
- Existing `bugreport.*` keys stay until the route is removed; new UI does not depend on them.
## 11. GitHub Publish
- Refactor the GitHub-issue creation in `src/app/api/bugreport/route.ts` into `src/lib/feedback/publish-github.ts` — pure function `publishToGithub(feedback): Promise<{ issueNumber, issueUrl }>`. Reused by both the deprecated bugreport route (transparently) and the new `POST /api/admin/feedback/:id/github`.
- Idempotency: if `gitHubIssueUrl` is already set on the feedback row, return 409 with the existing URL.
- Admin can append optional context via a textarea in the publish dialog; this is prepended to the issue body and **not** stored on the feedback row.
## 12. Nyquist Validation
Acceptance scenarios that must pass before sign-off:
- Submit while logged out → 401.
- Submit 6 times in an hour → 6th returns 429 with rate-limit headers.
- Screenshot > 5 MB → 413, no DB row created.
- Cumulative user quota > 20 MB → 413 with quota-specific error code.
- Non-admin hits `/api/admin/feedback*` → 403.
- Publish-to-GitHub round-trip: feedback row gains `gitHubIssueUrl`, repeat call returns 409.
- Auto-archive job marks correct rows after fast-forwarding clock in test.
- Notification dispatch is silent when no channel configured (info event, not warning).
- Plain-text rendering verified — submitting `<script>alert(1)</script>` displays escaped in admin UI.
- Wide Event payload includes `action.name=feedback.submit`, `meta.category`, `meta.has_screenshot`.
## 13. Risks
- **DB bloat from base64 screenshots.** Mitigation: per-item + per-user quotas now, object storage migration (S3/MinIO/Coolify volume) tracked as a follow-up. Schema is forward-compatible — `screenshotBase64` becomes nullable alongside a future `screenshotKey` column.
- **Email transport coupling.** If the dispatcher fails, submission must still succeed; notifications are fire-and-forget after the DB row commits.
- **Admin-inbox UX scaling** past a few hundred items needs server-side filtering (already in the API spec).
## 14. Out of Scope
- User-to-user comment threads on feedback items.
- Voting / upvoting feedback.
- Public read-only feedback board.
- Anonymous (logged-out) submissions — schema-ready, intentionally not enabled in v1.
- Importing existing GitHub issues back into the inbox.
## 15. Effort
**M** — one Prisma migration, ~6 new API routes (most thin), one form refactor, one new admin section, refactor of existing GitHub call into a helper, dispatcher hookup, one pg-boss job, ~30 i18n keys × 2 locales.
@@ -1,227 +0,0 @@
# User-Configurable Health Thresholds — Design Spec
**Datum:** 2026-04-18
**Status:** Draft
**Scope:** Per-user override layer for the metric target ranges currently rendered on `/targets` and used across analytics.
## 1. Problem
HealthLog computes target ranges from established medical guidelines:
- Weight from BMI 18.5–24.9 (WHO) via `getWeightRange` / `buildWeightBandsFromHeight`
- BP targets age-personalized per ESC/ESH 2018 (`bp-targets.ts`, `getBpTargetsByAge`)
- Pulse from CDC/NCHS percentiles or AHA fallback (`pulse-targets.ts`)
- Body fat from ACE thresholds (`classifyBodyFat`)
- Sleep duration from AASM/SRS (`getSleepDurationRange`)
- Steps from WHO (`getStepsRange`)
These are sane defaults, but real patients receive **doctor-recommended individual targets**. A diabetic with autonomic neuropathy may have a BP target of 110/65–135/80; a marathoner may have a personal "green" pulse band of 38–55 that the AHA bands flag as bradycardia; a recovering anorexia patient may have a clinically supervised weight floor above the BMI-18.5 line. Today the UI exposes computed ranges with **no override mechanism**, and analytics, alerts, and the doctor-report PDF all silently use the guideline values.
## 2. Why (User Intent)
- Users may have doctor-recommended personalized targets that differ from the default ESC/ESH / WHO / AHA guidelines.
- Current UI shows computed defaults with no override mechanism.
- User wants a dedicated settings page where thresholds can be overridden per metric, with the default still visible for comparison and one-click reset.
## 3. Requirements
1. **Per-metric override** for the full set: `WEIGHT` (min/max), `BLOOD_PRESSURE_SYS` (low/high), `BLOOD_PRESSURE_DIA` (low/high), `PULSE` (min/max), `BODY_FAT` (min/max), `SLEEP_DURATION` (min/max in hours), `ACTIVITY_STEPS` (min/max). Reserve slots for `BLOOD_GLUCOSE_FASTING` and `BLOOD_GLUCOSE_POSTPRANDIAL` even though they are not yet measured — the schema should not need a migration when the metric ships.
2. **Optional yellow zone**: each metric may carry a separate `warnMin/warnMax` (orange band) in addition to `min/max` (green band). Falls back to the default's orange computation (currently `±30%` span via `buildTrafficRange`) if absent.
3. **Comparable to default**: every UI surface that renders a range must be able to show "Default: X / Your override: Y" — the API therefore returns `defaults`, `overrides`, and `effective` together.
4. **Reset to default per metric** — single-click, no confirm needed unless overrides existed (see §11).
5. **Source-of-truth precedence**: `effective = override ?? default(profile)`. Default still recomputes when profile (height, age, gender) changes, so users who never override see no change in behavior.
6. **Server-side validation** — overrides must satisfy `min < max`, sit within hard sanity bounds, and pass plausibility checks (out-of-normal-range warns, see §11).
7. **All consumers must use one helper** — no analytics module should call the raw default-computer directly anymore (see §6).
8. **Audit trail** for every change (medical data, see §8).
## 4. Data Model
### Decision: JSON column on `User`, not a new table
Two options were considered:
**Option A — `UserThreshold` table** (one row per user/metric):
- Pro: query-friendly, easy partial indexes, future reporting per metric.
- Con: 9+ rows per user for a feature that is read together every page load; joins on every targets fetch; migration friction when adding metrics.
**Option B — JSON column on `User`** (`thresholdsJson Json?`):
- Pro: read-with-user is one row, atomic write, schema-less for new metrics, mirrors Prisma 7 / Postgres `jsonb` strengths, validated by Zod at the application layer.
- Con: cannot index individual thresholds in the DB (acceptable — there is no use case for "find all users whose pulse cap > 110").
**Choose Option B.** A `UserSetting` model does not currently exist, so we add the column directly to `User`:
```
// prisma/schema.prisma — additive
model User {
...
thresholdsJson Json? @map("thresholds_json")
}
```
### JSON shape (TypeScript, source of truth in `src/lib/validations/thresholds.ts`)
```ts
type ThresholdOverride = {
min?: number;
max?: number;
warnMin?: number; // optional yellow zone lower bound
warnMax?: number; // optional yellow zone upper bound
updatedAt: string; // ISO-8601, set server-side on every PUT
};
type ThresholdsJson = {
WEIGHT?: ThresholdOverride;
BLOOD_PRESSURE_SYS?: ThresholdOverride;
BLOOD_PRESSURE_DIA?: ThresholdOverride;
PULSE?: ThresholdOverride;
BODY_FAT?: ThresholdOverride;
SLEEP_DURATION?: ThresholdOverride;
ACTIVITY_STEPS?: ThresholdOverride;
BLOOD_GLUCOSE_FASTING?: ThresholdOverride; // reserved
BLOOD_GLUCOSE_POSTPRANDIAL?: ThresholdOverride; // reserved
};
```
An override is "active" when `min` and `max` are both numbers; partial-only objects are rejected by Zod. Absence of a key === use default.
## 5. API
### `GET /api/user/thresholds`
Response (envelope `{ data, error, meta }`):
```
data: {
defaults: { WEIGHT: { min, max, warnMin, warnMax, source }, ... },
overrides: { PULSE: { min, max, warnMin?, warnMax?, updatedAt }, ... },
effective: { WEIGHT: { min, max, warnMin, warnMax, isOverride: false }, ... },
profile: { heightCm, age, gender }
}
```
`defaults` is always populated (even for metrics the user has not measured, so the UI can render an empty slot with the default visible). `overrides` only contains keys the user has actually customized. `effective` is the merged result every analytics consumer should think in.
### `PUT /api/user/thresholds`
Body: partial map of metric → override (Zod-validated). Replaces only the listed metrics; unspecified keys are untouched.
```
{ "PULSE": { "min": 50, "max": 95 },
"BLOOD_PRESSURE_SYS": { "min": 110, "max": 130, "warnMin": 105, "warnMax": 140 } }
```
Per-metric Zod rules (`src/lib/validations/thresholds.ts`):
- `min < max`
- if `warnMin` set: `warnMin <= min`; if `warnMax` set: `warnMax >= max`
- hard sanity bounds (reject obviously broken input):
- `WEIGHT`: 30–300 kg
- `BLOOD_PRESSURE_SYS`: 70–220 mmHg
- `BLOOD_PRESSURE_DIA`: 40–140 mmHg
- `PULSE`: 30–220 bpm
- `BODY_FAT`: 3–60 %
- `SLEEP_DURATION`: 3–14 h
- `ACTIVITY_STEPS`: 0–50000
- `BLOOD_GLUCOSE_FASTING`: 50–250 mg/dL
- `BLOOD_GLUCOSE_POSTPRANDIAL`: 70–350 mg/dL
- soft "out-of-normal-range" check returns a Zod warning (not error) — surfaced to the UI to trigger the confirm dialog (see §11).
### `DELETE /api/user/thresholds/:metric`
Removes one key from `thresholdsJson`. Returns the new `effective` block so the client can refresh without a second roundtrip.
All three routes are wrapped by `apiHandler` and `requireAuth()`, log Wide Events, and emit AuditLog entries (§8).
## 6. Integration Points
A single helper becomes the only public entry for "what is the target range":
```
src/lib/analytics/effective-range.ts
getEffectiveRange(metric, user, overrides?) → { min, max, warnMin, warnMax, source, isOverride }
```
It internally calls the existing default computers (`getWeightRange`, `getBpTargets`, `getPersonalizedPulseTarget`, `classifyBodyFat`'s table, `getSleepDurationRange`, `getStepsRange`) and applies any matching override. `source` becomes `"User override"` when `isOverride === true`, otherwise the existing guideline string.
Refactor in scope:
- `src/lib/analytics/classifications.ts` — `classifyBP`, `classifyPulse`, `classifyBodyFat`, `classifySleepDuration`, `classifySteps`, `getWeightRange`, `getPulseRange`, `getSleepDurationRange`, `getBpTargetsByAge`: keep as pure default computers, but stop being called from feature code. Add thin `classifyXWithRange(value, range)` variants that take the resolved range.
- `src/lib/analytics/value-bands.ts` — `buildTrafficRange` and `buildWeightBandsFromHeight` accept an optional `override` argument and prefer it over the BMI-derived band.
- `src/lib/analytics/pulse-targets.ts` — `getPersonalizedPulseTarget` stays as the default; new `resolvePulseTarget(user, overrides)` lives in `effective-range.ts`.
- `src/lib/analytics/bp-targets.ts` — `getBpTargets` likewise stays; `effective-range.ts` wraps it.
- `src/app/api/insights/targets/route.ts` (the API behind `/targets`) reads `thresholdsJson` once and passes it to `getEffectiveRange` per metric, then returns the `effective` block to the client. The `source` field already shown on `TargetCard` flips to "User override" automatically.
- Doctor-report PDF (`src/lib/doctor-report-pdf.ts`) and Wide-Event annotations include `isOverride` so reports clearly mark customized targets.
## 7. Migration
Additive only — single Prisma migration adds `users.thresholds_json jsonb`. Existing users get `NULL`, which `getEffectiveRange` treats as "no overrides → behave exactly as before". No data backfill, no flag rollout.
## 8. Auditing
Every `PUT` and `DELETE` writes one `AuditLog` row (model already exists at `prisma/schema.prisma:391`):
- `action`: `"thresholds.update"` or `"thresholds.reset"`
- `details` (JSON string): `{ metric, before: {min,max,warnMin,warnMax} | null, after: {...} | null, reason?: "user" | "out_of_range_confirmed" }`
- `userId`, `ipAddress`, `location` from session context (already populated by `apiHandler`).
The `before` value is read from the current `thresholdsJson` *or* the computed default (so the audit trail captures the implicit baseline a user customized away from). Reset writes `after: null`.
## 9. i18n Keys
New section under `messages/en.json` and `messages/de.json`:
| Key | EN | DE |
|---|---|---|
| `thresholds.title` | "Custom thresholds" | "Eigene Zielwerte" |
| `thresholds.intro` | "Override the medical default ranges with values your doctor recommended. Defaults stay visible for comparison and you can reset any metric to the guideline at any time." | "Ersetze die medizinischen Standardbereiche durch arztempfohlene Werte. Die Standardwerte bleiben sichtbar und du kannst jeden Wert jederzeit auf die Leitlinie zurücksetzen." |
| `thresholds.defaultLabel` | "Default" | "Standard" |
| `thresholds.overrideLabel` | "Your override" | "Eigener Wert" |
| `thresholds.enableToggle` | "Use custom value" | "Eigenen Wert verwenden" |
| `thresholds.warnZoneLabel` | "Yellow zone (optional)" | "Gelber Bereich (optional)" |
| `thresholds.minLabel` | "Min" | "Min" |
| `thresholds.maxLabel` | "Max" | "Max" |
| `thresholds.resetButton` | "Reset to default" | "Auf Standard zurücksetzen" |
| `thresholds.resetPrompt` | "Reset {metric} to the medical default?" | "{metric} auf den medizinischen Standardwert zurücksetzen?" |
| `thresholds.saved` | "Thresholds saved" | "Zielwerte gespeichert" |
| `thresholds.outOfRangeWarn` | "This value lies outside the typical medical range. Please confirm only if your doctor recommended it." | "Dieser Wert liegt außerhalb des üblichen medizinischen Bereichs. Bitte nur bestätigen, wenn dein Arzt das empfohlen hat." |
| `thresholds.notMedicalAdvice` | "HealthLog does not provide medical advice. Custom thresholds are stored as-is." | "HealthLog ersetzt keine ärztliche Beratung. Eigene Zielwerte werden unverändert gespeichert." |
| `thresholds.validation.minLessMax` | "Min must be less than max" | "Min muss kleiner als Max sein" |
| `thresholds.validation.warnEnvelope` | "Yellow zone must surround the green zone" | "Der gelbe Bereich muss den grünen Bereich umschließen" |
| `thresholds.validation.outOfBounds` | "Value outside allowed range ({min}–{max} {unit})" | "Wert außerhalb des erlaubten Bereichs ({min}–{max} {unit})" |
Existing `targets.*` keys stay; the targets page gains a small "Customize" link routing to `/settings#thresholds`.
## 10. UI
New section in the existing settings shell at `/settings#thresholds`:
- One card per metric. Header shows the metric label + icon (reuses `TYPE_ICONS` from `targets/page.tsx`).
- Body left: read-only "Default: 60 – 100 bpm" with the source citation (same external link logic as `getTargetSourceLink`).
- Body right: an "Use custom value" toggle. When on, two number inputs (min, max) plus an optional collapsible "Yellow zone" with `warnMin`/`warnMax`.
- Inline validation via React Hook Form + Zod (the same schema the server uses).
- Footer: "Reset to default" button (visible only when an override exists).
- Out-of-range soft warnings render as an inline orange callout with a "I confirm my doctor recommended this" checkbox required to enable Save.
- Persistent disclaimer banner at the top: `thresholds.notMedicalAdvice`.
## 11. Nyquist Validation (tests in `src/lib/analytics/__tests__/effective-range.test.ts` and route tests)
- `getEffectiveRange` returns the override when present and the default when absent (one test per metric).
- Boundary tests: `min === max` rejected; `warnMin === min` accepted; off-by-one above/below the hard sanity bounds rejected/accepted.
- Profile-change test: removing `heightCm` collapses the WEIGHT default to "no range", but a present override survives.
- API contract: `GET` returns `defaults`, `overrides`, `effective`, `profile`. `PUT` with partial body leaves untouched metrics intact. `DELETE :metric` removes only that key.
- AuditLog firing: spy that one row is written per `PUT`/`DELETE`, with correct `before`/`after`. Confirm `details` JSON parses.
- Out-of-range confirmation: PUT with a value flagged "out_of_normal_range" requires `?confirm=1` query param (or a body flag) — without it the route returns `400 confirmation_required`.
- i18n parity test: every new EN key has a DE counterpart (re-uses the existing parity test if present, else add).
## 12. Risks & Mitigations
- **Users set unhealthy targets.** Hard sanity bounds reject obvious nonsense; soft "out-of-normal-range" warns and demands explicit confirmation. Persistent banner ("not medical advice"). Audit trail enables forensic review.
- **Insights/AI prompts may quote a user-customized range as if it were the medical guideline.** The provider prompts must receive the `source` field and explicitly distinguish "User override" from a guideline citation. Update `src/lib/ai/prompts/*` accordingly.
- **Doctor-report PDF.** Mark each customized range with a small "(custom)" tag so a treating physician sees that the report's coloring reflects the patient's own targets, not the guideline.
- **Default drift over time.** If we update guidelines (e.g. ESC/ESH 2024), users with overrides are unaffected — but users without overrides will see their colored bands shift. Document in the changelog.
## 13. Out of Scope
- Sharing thresholds between users / household.
- Doctor export of overrides (a treating physician downloading the override JSON to reuse) — could come later as part of the doctor report.
- Time-bounded thresholds ("until 2026-12-31"), seasonal thresholds, schedule-based thresholds.
- Per-medication or per-condition threshold packs.
- Threshold suggestions from AI insights.
## 14. Effort
**M (medium).** One Prisma migration, one new helper module, three new API routes, refactoring of ~5 analytics consumers to go through `getEffectiveRange`, one new settings card, ~14 i18n keys per locale, and one new test file. No external integrations, no UI primitive work (existing shadcn `Card`/`Input`/`Switch`/`Button` cover everything). Estimated 1.5–2 focused days for a senior dev including tests and audit-log wiring.
@@ -1,117 +0,0 @@
# Dashboard Customization — Design Spec
**Status:** Proposed (v1.2+)
**Date:** 2026-04-18
**Owner:** TBD
## Why
- Users have different health focus areas. Someone not tracking blood pressure shouldn't see an empty BP card.
- Current "hide if no data" rule (`showBodyFatCard = (bf?.count ?? 0) > 0`, etc. in `src/app/page.tsx`) is implicit — users can't pin the cards they care about, and a card vanishes the moment they delete the last entry.
- This is for a v1.2+ release, not part of the current bugfix push.
## 1. Problem Statement
The dashboard at `src/app/page.tsx` renders a fixed set of trend cards and charts (weight, BP sys/dia, pulse, body fat, mood, BMI) in a hard-coded order. Visibility is implicit (derived from data presence), ordering is non-negotiable. Narrowly-focused users carry visual noise; users with strong preferences cannot reorder. We need explicit, server-persisted, per-user control over which widgets appear and in what order.
## 2. Requirements
- **Per-user widget visibility.** Each chart and trend card is a "widget" that the user can show or hide independently.
- **Widget ordering — list-based reorder with up/down buttons + numeric position.** Defended below.
- **"Reset to defaults" action** that restores the v1.0 behavior (everything visible, default order).
- **Server-persisted** so settings follow the user across devices (web + PWA on phone). localStorage only acts as an optimistic cache.
- **i18n-friendly.** Widget IDs are stable English keys; display labels resolve through existing `dashboard.*` translation keys (`messages/en.json`, `messages/de.json`).
**Reorder UX choice:** list-based (up/down arrows) over drag-and-drop. Reasons: (a) mobile-first PWA — drag-on-touch with scroll containers is fragile; (b) keyboard/screen-reader accessible by default; (c) no extra dep (`@dnd-kit` ~30 KB). DnD can be added later behind a flag.
## 3. Data Model
**Decision: extend `User` with a `dashboardWidgets Json?` column.** No new table.
```prisma
model User {
// ...
dashboardWidgets Json? @map("dashboard_widgets")
}
```
Shape (validated by Zod on read/write):
```ts
type DashboardWidgetState = {
version: 1;
widgets: Array<{ id: WidgetId; visible: boolean; order: number }>;
};
```
`WidgetId` is a string-literal union seeded from a server-side registry (`src/lib/dashboard/widgets.ts`):
`"weight" | "bp" | "pulse" | "body_fat" | "mood" | "bmi"`.
**Tradeoffs vs. dedicated `DashboardWidget` model:** JSON wins because the widget set is small (<20 forecast), state is read once per dashboard load, and we never need to query "which users hide widget X." A table would cost a migration + FK + N writes per reorder for ~6 rows, with no queryability payoff. Cost: schema drift risk — mitigated by the Zod gate and the `version` field, which lets us evolve the shape without a SQL migration.
## 4. API
Two routes under `src/app/api/dashboard/widgets/route.ts`, both wrapped in `apiHandler` and `requireAuth()`.
**`GET /api/dashboard/widgets`** — returns merged state (stored values overlaid on the registry; new widgets appear at the end with `visible: true`):
```json
{ "data": { "version": 1, "widgets": [
{ "id": "weight", "visible": true, "order": 0 },
{ "id": "mood", "visible": false, "order": 5 }
]}, "error": null, "meta": {...} }
```
**`PUT /api/dashboard/widgets`** — replaces full state. Zod schema in `src/lib/validations/dashboard.ts`:
```ts
const dashboardWidgetsSchema = z.object({
version: z.literal(1),
widgets: z.array(z.object({
id: z.enum(WIDGET_REGISTRY.map(w => w.id)),
visible: z.boolean(),
order: z.int().min(0),
})).min(1),
}).refine(s => new Set(s.widgets.map(w => w.id)).size === s.widgets.length);
```
Unknown IDs and duplicates are rejected. Mutations invalidate `queryKeys.dashboardLayout()`.
**`DELETE /api/dashboard/widgets`** — clears the column → user gets defaults again ("Reset to defaults").
## 5. UI
**Location:** new section in `/settings` named **"Dashboard"** (matches existing settings architecture: Profile, Notifications, Integrations, etc.). Plus a small **"Customize"** icon button on the dashboard header next to the "Add" dropdown that deep-links to `/settings/dashboard`.
**Flow** (settings page):
1. Header "Dashboard widgets" + subhead "Choose which cards and charts appear on your dashboard."
2. Ordered list of shadcn `Card` rows — translated label (`t("dashboard.weight")`), short description, `Switch` for visibility, `ChevronUp`/`ChevronDown` reorder buttons (disabled at list ends).
3. Sticky footer: outline "Reset to defaults" (confirm via `AlertDialog`) + primary "Save" (disabled until dirty).
4. Optimistic update via TanStack Query mutation; on error, rollback + toast.
**Dashboard read path:** `src/app/page.tsx` becomes a thin orchestrator — fetch layout via `useQuery(queryKeys.dashboardLayout())`, sort by `order`, filter `visible`, render through a `<DashboardWidget id="…" />` switch. Existing data-presence guards stay as a safety net.
## 6. Migration
- Prisma migration: `ALTER TABLE users ADD COLUMN dashboard_widgets jsonb`.
- No backfill. Null = "use defaults" = the v1.0 behavior frozen as `DEFAULT_LAYOUT` constant in `src/lib/dashboard/widgets.ts`. The `GET` endpoint synthesizes this on the fly.
- First save writes the full normalized blob.
- Existing users see no change until they open settings.
## 7. Nyquist Validation Criteria
- **Vitest:** Zod rejects duplicate IDs, unknown IDs, negative order, missing version.
- **Vitest:** layout merger appends new registry widgets with `visible: true` when user state predates them.
- **Vitest:** `DELETE` then `GET` returns `DEFAULT_LAYOUT`.
- **API integration:** unauth `PUT` → 401; valid `PUT` → 200 + persisted; payload >50 KB → 413.
- **Manual UAT:** hide BP on desktop → reload on phone PWA → BP gone. Reorder weight to last → reload → still last. Reset → defaults restored.
- **A11y:** keyboard nav on reorder buttons, `aria-label` on Up/Down, screen-reader announces position changes.
- **Perf:** dashboard initial render does not regress (React Profiler check).
## 8. Out of Scope
- Custom user-defined widgets / per-widget chart config (color, range, time window).
- Widgets for future measurement types (blood glucose, SpO2, sleep stages, steps) — added to registry when those features ship.
- Multiple dashboards or tabs; sharing layouts between users.
- Drag-and-drop reorder (revisit if list UX gets complaints).
- Per-widget grid size (1x/2x spans).
## 9. Estimated Effort
**M** — roughly 1–2 days. Schema + Zod + registry ~2h, API + tests ~3h, Settings UI ~4h, dashboard refactor ~3h, i18n + polish + UAT ~2h.
@@ -1,216 +0,0 @@
# Multi-provider AI Insights — Design Spec
**Date:** 2026-04-18
**Status:** Draft
**Scope:** Add Anthropic Claude and self-hosted (OpenAI-compatible) LLM endpoints alongside the existing OpenAI / ChatGPT Codex providers. Per-user provider choice, admin-level fallback, encrypted credential storage.
## 1. Problem
Today HealthLog can only talk to OpenAI — either the user's ChatGPT subscription via Codex OAuth, or an admin-managed OpenAI API key. That ties insight generation to a single vendor and to the public internet.
> "I'd like to pick my provider per user. OpenAI for some accounts, Claude for me, and on my home server I want to point HealthLog at a local Ollama instance — none of my BP readings should leave the LAN."
> — User intent
Consequences of the single-vendor model:
- No way to use Anthropic Claude despite better medical-reasoning answers.
- Privacy-sensitive users (BP, mood, medication) have no on-prem option.
- Vendor outages or quota exhaustion bring all insights down (already observed with OpenAI quota).
## 2. Current state
`src/lib/ai/provider.ts` exposes `resolveProvider(userId)` returning an `AIProvider`. The contract in `src/lib/ai/types.ts` is small and provider-agnostic:
```ts
export type ProviderType = "codex" | "admin-key" | "none";
export interface AIProvider {
type: ProviderType;
generateCompletion(params: CompletionParams): Promise<CompletionResult>;
}
export interface CompletionParams {
systemPrompt: string;
userPrompt: string;
temperature?: number;
maxTokens?: number;
}
export interface CompletionResult {
content: string;
tokensUsed: number | null;
model: string;
providerType: ProviderType;
}
```
Implementations: `OpenAIClient` (Chat Completions, `response_format: json_object`, captures upstream body excerpt for diagnostics), `CodexClient` (chatgpt.com Responses API with refresh-on-401). Selection is hard-coded: Codex if connected, else admin OpenAI key, else `NoProvider`.
What needs to change:
- Widen `ProviderType` to include `anthropic` and `local`.
- Add per-user provider choice (currently only Codex is per-user).
- Add per-user model + base-URL + API key fields, mirrored on `AppSettings`.
- Two new clients (`AnthropicClient`, `LocalOpenAICompatibleClient`).
- New selection function that honors user preference before admin defaults.
## 3. Target abstraction
The `AIProvider` interface stays exactly as-is. New concrete clients implement it without touching call-sites in `src/app/api/insights/*`.
```
ProviderType = "codex" | "openai" | "anthropic" | "local" | "admin-key" | "none"
```
(`"admin-key"` retained for back-compat with already-cached `InsightResult.providerType` strings.)
Selection order in `resolveProvider(userId)`:
1. **User explicit choice** (`User.aiProvider`) with valid credentials.
2. **Codex OAuth** if user picked `CHATGPT_OAUTH` and tokens are present (existing path).
3. **Admin default** (`AppSettings.adminAiProvider` + admin key) for users with no personal config.
4. **`NoProvider`** — surface a clear error to the route handler.
The function is the single boundary: every call that needs an LLM goes through it. No client is constructed elsewhere.
## 4. Data model
`User` model additions (all nullable; user keeps current behaviour until they opt in):
```prisma
aiProvider String? @map("ai_provider") // OPENAI | ANTHROPIC | LOCAL | CHATGPT_OAUTH
aiModel String? @map("ai_model") // e.g. claude-sonnet-4-6
aiBaseUrl String? @map("ai_base_url") // local endpoint
aiOpenAiKeyEncrypted String? @map("ai_openai_key_encrypted")
aiAnthropicKeyEncrypted String? @map("ai_anthropic_key_encrypted")
aiLocalKeyEncrypted String? @map("ai_local_key_encrypted") // optional bearer for local
```
`AppSettings` mirrors:
```prisma
adminAiProvider String @default("OPENAI") @map("admin_ai_provider")
adminAiAnthropicKeyEncrypted String? @map("admin_ai_anthropic_key_encrypted")
adminAiAnthropicModel String? @default("claude-sonnet-4-6") @map("admin_ai_anthropic_model")
adminAiLocalBaseUrl String? @map("admin_ai_local_base_url")
adminAiLocalKeyEncrypted String? @map("admin_ai_local_key_encrypted")
adminAiLocalModel String? @map("admin_ai_local_model")
```
The existing `adminAiKeyEncrypted` / `adminAiModel` / `adminAiBaseUrl` fields stay and represent the OpenAI configuration. One Prisma migration adds the columns; existing users have `aiProvider = NULL` and route through admin default exactly like today.
## 5. New clients
### AnthropicClient
- `POST {baseUrl}/v1/messages` with `x-api-key` and `anthropic-version: 2023-06-01`.
- `system` parameter for `params.systemPrompt`; `messages: [{ role: "user", content: params.userPrompt }]`.
- Structured JSON via **tool use**: declare a single `record_insight` tool whose `input_schema` mirrors `insightResultSchema`. Set `tool_choice: { type: "tool", name: "record_insight" }`. Read JSON from the `tool_use` block's `input` and serialize to a string for `CompletionResult.content`.
- Prompt caching: send the system prompt with `cache_control: { type: "ephemeral" }`. Insights re-run the same multi-paragraph medical preamble per metric — empirically ~90% cost reduction on cached reads.
- Tokens: sum `usage.input_tokens + usage.output_tokens` (incl. `cache_read_input_tokens`).
- Errors: capture body excerpt like `OpenAIClient` already does, scrub any `sk-ant-...` substrings before logging.
Default models (selectable in UI):
- `claude-sonnet-4-6` (default — quality / cost balance)
- `claude-opus-4-7` (highest quality)
- `claude-haiku-4-5` (cheap, fast)
### LocalOpenAICompatibleClient
- Same wire format as `OpenAIClient` (`/chat/completions`, optional `Authorization: Bearer ...` only if a key is set).
- `baseUrl` is required and user-configurable (e.g. `http://localhost:11434/v1` for Ollama, `http://lm-studio.lan:1234/v1`).
- Some local backends reject `response_format: { type: "json_object" }`. Strategy: feature-detect on first failure (HTTP 400 with body containing `response_format`), persist `User.aiLocalSupportsJsonMode = false`, and re-issue without the field. Append `"Return JSON only, no prose."` to the user prompt and run output through `jsonrepair` before `JSON.parse`.
- No-telemetry mode: never set `User-Agent` beyond `HealthLog/local`, never include user IDs in headers, do not send the request body to Wide Event annotation when provider is `local` (only `model`, `latencyMs`, `tokensUsed`).
## 6. UI — `/settings#ai`
Single dropdown `aiProvider` with options OpenAI / Anthropic Claude / Local LLM / ChatGPT (OAuth). Conditional fields:
| Provider | Visible fields |
| ------------ | --------------------------------------------------------------- |
| OpenAI | API key, model (default `gpt-4o-mini`) |
| Anthropic | API key, model dropdown (sonnet/opus/haiku) |
| Local | Base URL, model name, optional API key, "Disable JSON mode" hint |
| ChatGPT OAuth| Existing Codex connect button |
Each provider section has a **Test connection** button that POSTs to `/api/insights/test` with a tiny prompt (`"Reply with {\"ok\":true}"`), surfacing the upstream status + body excerpt on failure.
Admin settings page gains the same dropdown for the global default plus an explanatory note: *"Used only for users who have not configured their own provider."*
## 7. Backward compatibility
- Migration is additive — no column drops, no data rewrites.
- `User.aiProvider IS NULL` ⇒ behave exactly as today (Codex if connected, else admin OpenAI).
- `CompletionResult.providerType = "admin-key"` continues to be emitted by `OpenAIClient` so cached insights and analytics dashboards keep working. New clients use new strings.
- Existing tests in `src/lib/ai/__tests__` keep passing; new tests are additive.
## 8. Error handling
Each client follows the `OpenAIClient` pattern: capture up to 500 chars of the upstream body, attach `{ httpStatus, upstream, model }` to the thrown `Error`, let `apiHandler` log it as a Wide Event. Per-provider mapping in `src/lib/ai/errors.ts`:
| Upstream | User-facing message (i18n key) |
| --------------------------------------- | ----------------------------------------- |
| Anthropic 401 / `invalid_api_key` | `insights.error.anthropicInvalidKey` |
| Anthropic 429 / `rate_limit_error` | `insights.error.anthropicRateLimited` |
| Anthropic 529 / `overloaded_error` | `insights.error.anthropicOverloaded` |
| Local fetch ECONNREFUSED | `insights.error.localUnreachable` |
| Local 400 mentioning `response_format` | auto-retry once, then `insights.error.localNoJsonMode` |
| OpenAI 429 `insufficient_quota` | existing key |
## 9. Internationalisation
New keys (EN + DE) under `settings.ai.*` and `insights.error.*`:
```
settings.ai.providerLabel "AI Provider" / "KI-Anbieter"
settings.ai.providerOpenAI "OpenAI"
settings.ai.providerAnthropic "Anthropic Claude"
settings.ai.providerLocal "Local LLM"
settings.ai.providerChatGptOAuth "ChatGPT (OAuth)"
settings.ai.baseUrlLabel "Endpoint URL" / "Endpunkt-URL"
settings.ai.baseUrlHint.local "z.B. http://localhost:11434/v1"
settings.ai.modelLabel "Model" / "Modell"
settings.ai.testConnection "Test connection" / "Verbindung testen"
settings.ai.testSuccess "Connection OK ({model}, {ms} ms)"
settings.ai.testFailure "Failed: {detail}"
settings.ai.localPrivacyNote "Requests stay on your network. Bodies are not logged."
insights.error.anthropicInvalidKey "Anthropic key rejected. Check Settings → AI."
insights.error.anthropicRateLimited "Anthropic rate-limited. Try again shortly."
insights.error.anthropicOverloaded "Anthropic is overloaded. Try again in a minute."
insights.error.localUnreachable "Local LLM endpoint not reachable: {url}"
insights.error.localNoJsonMode "Local model rejected JSON mode — falling back to text parsing."
```
## 10. Security
- All keys stored via existing AES-256-GCM helpers in `src/lib/crypto.ts`. Never persist plaintext.
- Anthropic keys begin with `sk-ant-`. The Wide Event redactor (`src/lib/logging/event-builder.ts`) gets a regex `/sk-ant-[A-Za-z0-9_\-]{20,}/g` → `"sk-ant-***"` applied to all `externalCalls[].errorBody`.
- Local provider runs with `next/server` `fetch` against arbitrary URLs — validate that `aiBaseUrl` parses as `http`/`https`, reject `file://`, `data://`, and (in production) bare IPs in private ranges *unless* `LOCAL_LLM_ALLOW_PRIVATE=true` (default true on self-hosted, false on `apps-01` build).
- Test-connection endpoint is `requireAuth` and rate-limited (5 req/min/user) to prevent SSRF probing.
- Admin keys remain admin-only via `requireAdmin()`.
## 11. Nyquist validation
- **Unit (Vitest)** — one `*.test.ts` per new client, mocking `fetch`:
- `anthropic-client.test.ts`: success path returns parsed JSON from tool_use; 401 maps to error; cache_control header present.
- `local-client.test.ts`: success path; auto-retry on `response_format` 400; ECONNREFUSED handled; bearer header omitted when no key.
- **Integration** — extend `provider.test.ts`:
- User with `aiProvider = ANTHROPIC` and key → returns `AnthropicClient`.
- User with no preference falls back to admin default of each kind.
- Codex still wins when user picks `CHATGPT_OAUTH` and is connected.
- **End-to-end smoke** — manual: switch provider in `/settings#ai`, hit `/api/insights/general-status`, verify `providerType` in cached `InsightResult`.
## 12. Risks & mitigations
- **Admin changes default provider, breaks users.** Mitigated by precedence rule: any user with `aiProvider != NULL` is unaffected. Migration backfills nothing — admin only impacts opted-out users.
- **Local model returns malformed JSON.** Two-layer defence: tool-use where supported, `jsonrepair` fallback otherwise; on persistent failure surface `dataQuality.confidence = "gering"` and the raw text in a debug field.
- **Cached insight from old provider.** Cache key already includes `providerType`; switching provider invalidates naturally on next generate.
- **Anthropic prompt-caching cost regression** if system prompt is rebuilt per request without cache_control. Mitigation: emit a Wide Event metric `ai.cache_read_tokens` and add a Grafana panel.
## 13. Out of scope
- Vendor-specific features beyond JSON output (Anthropic citations, OpenAI function-calling tool registries, Google Gemini).
- Streaming responses to the UI (insights are batch-generated).
- Multi-region routing or load-balancing across providers.
- Per-metric provider override (one provider per user, applied to all metrics).
- Cost dashboards beyond the existing `tokensUsed` field.
## 14. Effort
**M–L.** ~2 days dev:
- 0.5d schema migration + `resolveProvider` rewrite + types.
- 0.5d `AnthropicClient` (tool-use, caching, errors).
- 0.5d `LocalOpenAICompatibleClient` + JSON-mode feature detection + jsonrepair.
- 0.25d settings UI + test-connection endpoint.
- 0.25d i18n + tests + docs.
+464
View File
@@ -0,0 +1,464 @@
# HealthLog UI Guidelines
> Single source of truth for visual & interaction patterns in the HealthLog
> PWA. Consult this document before adding a new component, page, or
> setting. If a pattern below doesn't fit your case, propose an addition
> here in the same PR.
Last updated: 2026-05-08 (v1.4 cycle)
---
## 1. Foundations
### 1.1 Theme
HealthLog is a Dracula-themed app with a light counterpart (Alucard). Both
themes share the same six-color signal palette (purple, pink, green, cyan,
orange, red, yellow, lavender), exposed as CSS variables and as Tailwind
`dracula-*` color tokens. Use the semantic tokens (`--primary`,
`--destructive`, `--card`, …) for chrome and the `--dracula-*` tokens for
data viz only.
| Surface | Token |
| ---------------------------- | ------------------ |
| App background | `--background` |
| Card / popover surface | `--card` |
| Primary action | `--primary` |
| Destructive action | `--destructive` |
| Secondary / muted background | `--secondary` |
| Border | `--border` |
| Focus ring | `--ring` |
| Chart series 1–5 | `--chart-1`…`-5` |
| Brand signal: purple | `--dracula-purple` |
| Brand signal: green (good) | `--dracula-green` |
| Brand signal: orange (warn) | `--dracula-orange` |
| Brand signal: red (alert) | `--dracula-red` |
| Brand signal: cyan (info) | `--dracula-cyan` |
Dark mode is the default. The theme switcher in the sidebar three-dot menu
toggles `.dark` / `.light` on `:root`. Always test both modes before
shipping a component.
### 1.2 Spacing
Tailwind's default 4-pixel scale. Reach for these stops first:
| Use | Token | Pixels |
| ------------------------------------ | ------------- | ------ |
| Inline gap between icon and label | `gap-1` | 4 |
| Tight stack inside a card | `gap-2` | 8 |
| Default form-field stack | `space-y-3` | 12 |
| Section block (heading + body) | `space-y-4` | 16 |
| Page section separator | `space-y-6` | 24 |
| Dashboard tile-row gap, hero spacing | `gap-4` | 16 |
| Page-edge padding (mobile / desktop) | `px-4 / px-6` | |
Avoid arbitrary `space-y-5`, `gap-3.5`, etc. unless aligning to a fixed
external constraint.
### 1.3 Typography
The app uses the Tailwind defaults inherited via shadcn/ui. Reach for
these classes first:
| Use | Class |
| ---------------------------------- | --------------------------------------- |
| Page title (h1) | `text-2xl font-semibold tracking-tight` |
| Section heading (h2) | `text-lg font-semibold` |
| Card title | `text-base font-medium` |
| Body text | `text-sm` |
| Muted helper / metadata | `text-sm text-muted-foreground` |
| Microcopy / footer | `text-xs text-muted-foreground` |
| Tabular data (latency, large nums) | `font-mono tabular-nums` |
Never use `font-bold` for emphasis — bump to `font-semibold` instead and
let the design system breathe.
### 1.4 Radius and elevation
`--radius: 0.625rem` (10px) is the default. The Tailwind tokens
`rounded-md` (`--radius-md` ≈ 8px) and `rounded-lg` (`--radius` ≈ 10px)
cover 95% of cases. Use `rounded-2xl` only for hero cards or large
modals.
Elevation is achieved via `shadow-xs`, `shadow-sm`, or
`shadow-[0_1px_0_rgba(255,255,255,0.04)]` for very subtle separators on
dark backgrounds. Avoid Tailwind's heavy shadows (`shadow-lg`+) in chrome.
---
## 2. Components
This section is **prescriptive, not exhaustive**. Each entry pairs a
Radix/shadcn primitive with the cases it's the right answer for and the
cases where another primitive should win.
### 2.1 Buttons
Every button comes from `@/components/ui/button`. Pick by purpose:
| Purpose | Variant | Size |
| ----------------------------------------------------- | ------------- | ----------------- |
| Primary action on a page or dialog | `default` | `default` |
| Cancel, dismiss, secondary action next to primary | `outline` | `default` |
| Tertiary in-row action (edit, copy, snooze) | `ghost` | `sm` |
| Destructive (delete, revoke, disconnect) | `destructive` | `default` or `sm` |
| Icon-only action (toolbar, chart toggles, table rows) | `ghost` | `icon-sm` |
| Inline link to external help docs | `link` | `sm` |
| Toolbar / table row action when space is tight | `outline` | `xs` |
**Position rules.**
- Forms (full-width or modal): primary on the **right**, cancel/back on
the **left**. Match shadcn dialog defaults.
- Settings sections: a single sticky `Save` button at the **right end**
of the section header _or_ per-row inline `Save`. Never both in the
same section.
- Test buttons (Telegram test, AI ping, etc.): immediately to the right
of the related Save / Apply control. Never below — keeps the eye
motion horizontal.
- Destructive actions live in their own confirm dialog with a
type-to-confirm input when the action is irreversible (delete account,
revoke API token, drop user data).
**Disabled state.** A disabled button must always come with a tooltip or
helper text explaining why. Silent disabled buttons frustrate users.
### 2.2 Inputs and forms
We use `react-hook-form` + Zod schemas from `src/lib/validations/`.
Wrap inputs in `<label>` (visible) plus `aria-describedby` for helper text.
- **Text / number / email**: `<Input>`. Always set `autocomplete`. See §4.4.
- **Multi-line**: shadcn `<Textarea>` (add via `pnpm dlx shadcn@latest add textarea` if missing).
- **Single choice ≤4 options**: prefer `<RadioGroup>` or a SegmentedControl
pattern using `<Tabs>`. Never use `<Select>` for tiny enums — extra clicks.
- **Single choice 5–15 options**: `<Select>` (shadcn).
- **Single choice >15 options**: `<Combobox>` (shadcn `<Command>` + `<Popover>`)
with type-ahead.
- **Multi choice**: `<Checkbox>` group, or shadcn `<MultiSelect>` when 7+ choices.
- **Date / datetime**: shadcn `<Calendar>` inside a `<Popover>`. Always
label the timezone (Europe/Berlin).
- **Numeric range**: `<Slider>` only when the value space is continuous
_and_ the user routinely sweeps. For most thresholds and counts, an
`<Input type="number">` plus stepper buttons is faster and less
fiddly. See §6 (Slider audit) for the migration path.
- **Boolean toggle**: `<Switch>` for "applies immediately" settings (e.g.
enable Telegram). `<Checkbox>` for "applies on Save" lists (e.g. select
measurements to export).
**Validation.** Inline messages below the field. Field gets
`aria-invalid="true"` and a destructive border. Form-level errors land
in a `<Alert variant="destructive">` at the top. Never show a toast for
a field-level error.
### 2.3 Dialog vs Sheet vs Drawer
The wrong choice here is the most visible UX mistake we make. Pick by
intent, not by hand-feel.
| Use | Primitive | Rationale |
| --------------------------------------------------------------- | ------------------------------ | ---------------------------------------------------------------- |
| Confirm a destructive or irreversible action | `Dialog` (`AlertDialog` shape) | Modal blocks accidental dismiss; user must read. |
| Inspect a single item's details with no edit | `Dialog` | Centered, focused, dismissible. |
| Edit a single item with ≤6 fields | `Dialog` | Tight focus, quick save. |
| Edit a single item with 7+ fields, or with deep sub-sections | `Sheet` (right) | Wide working surface, scrollable, doesn't crop on tall content. |
| Mobile bottom-up sub-action (filter, sort, multi-select picker) | `Sheet` (bottom) | Thumb-friendly, dismiss by swipe. |
| Multi-step wizard | Full route | Page transitions communicate progress better than a stuck modal. |
| Side-by-side compare or trend deep-dive | Full route | Charts need the viewport. |
Never nest Dialogs. If a Dialog needs another modal action, escalate the
inner step to a separate route or use shadcn's `<AlertDialog>` chained on
top of a `<Sheet>`.
### 2.4 Cards and lists
- **Card**: a self-contained unit of content. Use `<Card>` from
shadcn. Header, body, footer follow Pretendard-style hierarchy.
- **Tile**: a _small_ card showing a single metric on the dashboard.
See §3.2 for the one-row constraint. Tiles must include current value,
unit, optional sparkline, and a single tap-target area.
- **List row**: `<div className="flex items-center justify-between gap-3 px-3 py-2 hover:bg-accent/40 rounded-md">` — left side has icon + primary
- meta, right side has trailing controls. Never put primary actions in
the right-side ellipsis menu — only secondary or destructive.
- **Empty state**: every list and tile has an explicit empty-state
component with an icon, a one-sentence explanation, and a single
primary action ("Add measurement", "Connect Withings"). See §5.
### 2.5 Tables
`<Table>` (shadcn) for tabular data only — measurements list, audit log,
admin views. Default to `<Card>` + list rows for everything else;
tables on mobile collapse poorly. When you must use a table on mobile,
swap to a vertical list at `sm:` breakpoint.
### 2.6 Status indicators
Use the same color taxonomy across the app:
| State | Color token | Examples |
| --------- | -------------------- | ---------------------------------------------- |
| Good | `--dracula-green` | Connected, in target, healthy |
| Info | `--dracula-cyan` | Up-to-date, neutral signal |
| Caution | `--dracula-orange` | Mildly out of range, expiring token, slow sync |
| Alert | `--dracula-red` | Out of range, sync failed, critical |
| Pending | `--muted-foreground` | Loading, indeterminate |
| Highlight | `--dracula-purple` | New / featured / promoted |
Show a colored dot **plus** a label. Never rely on color alone.
---
## 3. Layouts
### 3.1 Page shell
Every authenticated page is wrapped by `<TopBar>` + `<SidebarNav>` (left)
- `<BottomNav>` (mobile). Don't reinvent these on a per-page basis.
* Mobile breakpoint: `<sm` shows bottom-nav, hides sidebar.
* `sm`–`md`: sidebar in icons-only mode.
* `md+`: sidebar full mode unless user has collapsed it
(`localStorage:healthlog-sidebar-collapsed`).
Page content goes in `<main className="mx-auto max-w-5xl px-4 sm:px-6 py-6 space-y-6">` unless the page is a full-bleed dashboard or settings shell.
### 3.2 Dashboard tiles — one-row rule
Tiles at the top of the dashboard live in a **single row**. Width caps
at the chart's width directly below. **Never** wrap to a second row.
If the user enables more tiles than fit, the dashboard surfaces a banner
inviting them to deactivate or reorder via Settings → Dashboard. The
layout editor in Settings shows a live preview of the constraint and
flags overflow before save.
CSS: `grid-cols-N` where N = active tile count, capped by
`Math.min(activeTiles, maxTilesForViewport(width))`. Drag-reorder via
`@dnd-kit/sortable`. Toggle-show via the layout editor only — not in the
tile itself, to avoid accidental hide.
### 3.3 Settings — `/settings/[section]`
The 1.4 release splits the legacy 3150-LOC settings monolith into one
route per top-level section:
- `/settings/account` — profile, password, passkeys, account deletion.
- `/settings/integrations` — Withings, moodLog, Telegram, ntfy, Web Push.
- `/settings/notifications` — channel matrix + quiet hours + reminders.
- `/settings/dashboard` — tile layout editor, default ranges, threshold
overrides.
- `/settings/ai` — AI provider selection + connection + test.
- `/settings/api` — API tokens for headless ingest + Bearer issuance.
- `/settings/advanced` — exports, imports, danger zone.
- `/settings/about` — version, build, license, links to docs/changelog.
Each section's component lives at `src/components/settings/<section>.tsx`
and stays under 400 LOC. Cross-section search lives in the section
sidebar header.
The legacy `/settings` route 301-redirects to `/settings/account`.
### 3.4 Admin — `/admin/[section]`
Same shape as Settings, with the right-hand sidebar listing:
- Users
- Integrations status
- Monitoring (Wide Events, GlitchTip, Umami)
- Backups
- Maintenance
- Audit log
Status badges use the §2.6 color taxonomy. Each section opens to a card
grid, never a long fluid list.
---
## 4. Patterns
### 4.1 Loading states
- **Page-level**: a `<Skeleton>` matching the final layout. Never show a
full-screen spinner. Mount the skeleton inside the same `<main>` shell
the loaded page renders into.
- **Inline**: button loading state shows a spinner + label change
("Saving…", "Testing…"). Disable other form controls while pending.
- **Long-running**: progress bar with a remaining-step count; for
background tasks (export, backup), surface in a toast that links to
the operation log instead of blocking the UI.
Skeletons must respect `prefers-reduced-motion: reduce`.
### 4.2 Empty states
A reusable `<EmptyState>` shape:
```tsx
<EmptyState
icon={<Plus className="size-6" />}
title={t("empty.measurements.title")}
description={t("empty.measurements.description")}
action={<Button>{t("empty.measurements.add")}</Button>}
/>
```
Every list, every chart, every dashboard tile that can be empty has an
explicit, translated empty-state. No "No data" placeholder strings.
### 4.3 Error states
- **Form errors**: inline below the offending field; aggregate at the
top of the form for global errors (e.g. "Server unreachable").
- **Page errors**: Next.js `error.tsx` boundaries with a friendly
recovery action ("Retry", "Go home"). Send the error to GlitchTip
through the existing client.
- **Network errors during save**: `<Alert variant="destructive">` at
the top of the form _plus_ a retry button. Never silently fail.
### 4.4 Forms and password managers
- Measurement, medication, and mood form fields use
`autocomplete="off"` on every input that browsers keep guessing wrong.
- API-token inputs and AI-provider key inputs use
`autocomplete="new-password"` so the browser doesn't autofill the
user's account password.
- Honey-pot fields (offscreen `<input>` with `autocomplete="email"` and
`tabIndex={-1}`) sit at the top of measurement forms to catch
aggressive autofill.
- Forms always include a hidden `<input type="text" name="username"
autocomplete="username" value={user.email} readOnly tabIndex={-1}>`
inside password change and passkey screens so password managers
attribute saved credentials correctly.
### 4.5 Test buttons (every integration)
Each configurable integration ships a Test button next to its Save
control. Behavior:
- **Trigger**: ad-hoc API call to the integration with a small,
identifiable payload ("HealthLog test ping").
- **State**: button shows a spinner with localized "Testing…" label.
Result lands inline in a `<StatusCallout>` directly below — green for
success with the relevant detail (last sync, message ID, response
status) or red for the error (sanitized message — never leak the
upstream URL or API key).
- **History**: each test writes a row to a tiny per-integration log
visible in the section. Last 5 tests, timestamp + result.
- **Coverage**: required for Telegram, ntfy, Web Push, AI provider,
Withings, moodLog, GlitchTip, Umami, email (when wired), backup
target. Headless E2E asserts presence + working call for each.
### 4.6 Server actions and mutations
We use `react-hook-form` + TanStack Query mutations, never raw fetch
calls in components. Every mutation:
1. Optimistically updates the cache with a rollback handler.
2. Toast on success ("Saved" — translated) and on error (with retry).
3. Re-validates queries it touches.
For multi-step flows, write a typed `apiClient` helper in
`src/lib/api/clients/` rather than re-doing fetch in three places.
### 4.7 Inline help
A small `?` `<Tooltip>` next to the label is the default for non-trivial
fields (e.g. "Why does this default to ESH 2023?"). Tooltips must be
keyboard-accessible (Radix handles this) and translated.
For long explanations, link out to `https://docs.healthlog.dev/...` —
never embed multi-paragraph help inline.
---
## 5. Accessibility
We aim for **WCAG 2.1 AA**, with the following non-negotiables:
- Color contrast ≥ 4.5:1 for normal text, 3:1 for large text and UI
components. Verified by `@axe-core/playwright` in CI on every page.
- Touch targets ≥ 44×44 CSS px on bottom-nav and primary action buttons
(WCAG 2.5.5).
- Focus is always visible: shadcn's `focus-visible:ring` is preserved on
every customized component.
- Every interactive element has an accessible name. Icon-only buttons
get `<span className="sr-only">` labels and `aria-label`.
- `prefers-reduced-motion: reduce` disables animations beyond a 200 ms
fade.
- Charts include text-equivalent summaries (`aria-describedby` linked to
a `<p className="sr-only">` summary) and exposed data via the visible
legend / table view toggle.
CI fails on any `serious` or `critical` axe-core violation.
---
## 6. Slider audit and migration
Sliders look pretty but are a poor fit for most numeric inputs because
they sacrifice precision. The 1.4 audit catalogues every `<Slider>` in
the codebase and migrates the inappropriate ones:
| Setting | Current control | Better control |
| ----------------------------------- | --------------- | ------------------------------- |
| Mood intensity (0–10) | Slider | SegmentedControl |
| Notification quiet-hours range | Two sliders | Two `<Input type="time">` |
| Threshold-override numeric values | Slider | `<Input type="number">` + steps |
| AI insight depth (1–5 levels) | Slider | `<RadioGroup>` cards |
| Export retention days (7/30/90/365) | Slider | `<Select>` with preset options |
A slider stays appropriate for "scrub through history" or "set chart
zoom range" — operations where the value is continuous and live preview
is the point.
---
## 7. Internationalization
All user-facing strings come from `messages/en.json` (default) and
`messages/de.json`. Use `useTranslations()` on the client and
`getServerTranslator()` (`src/lib/i18n/server-translator.ts`) on the
server. Numbers, dates, currencies, and units always go through
`useFormatters()` — never hand-roll `Intl.NumberFormat(...)` with a
fixed locale.
Server-rendered content (PDFs, notifications, server-issued category
labels) must use the locale of the user the content is for, not the
locale of the request that triggered the job.
---
## 8. Motion
Default transitions: `transition-all duration-150 ease-out`. Heavier
animations (insight cards, dialog enter/exit) use the shadcn defaults.
Reduce-motion always wins.
Loading skeletons pulse via Tailwind `animate-pulse`. Don't introduce
custom keyframes without adding a `prefers-reduced-motion` fallback.
---
## 9. Component checklist (for new features)
Before opening a PR for a new component or page, walk through:
- [ ] Uses tokens from §1, not hard-coded hex/rgba.
- [ ] Buttons follow §2.1 variant + position rules.
- [ ] Inputs follow §2.2; correct `autocomplete`; honey-pots where needed.
- [ ] Dialog vs Sheet vs Drawer per §2.3.
- [ ] Loading, empty, and error states from §4.1–4.3 implemented.
- [ ] All strings via `t()`; numbers via formatters.
- [ ] Mobile viewport tested (Chrome DevTools 375×667 and 414×896).
- [ ] Light + Dark mode tested.
- [ ] Keyboard navigation: Tab order, Enter to submit, Esc to dismiss.
- [ ] axe-core clean (no serious/critical).
- [ ] Headless screenshot test added if UI is non-trivial.
---
_Maintained as part of the v1.4 release cycle. Propose changes alongside
the code that motivates them._
+44
View File
@@ -0,0 +1,44 @@
import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";
/**
* Accessibility regression gate — fails the build if any WCAG 2.1 AA
* violation lands in the `serious` or `critical` bucket on a public
* surface. Authenticated pages are out of scope here (need seeded
* data); they're covered by the per-flow specs that already touch
* those routes.
*/
test.describe("axe-core public surfaces", () => {
for (const path of ["/auth/login"]) {
test(`${path} has no serious or critical a11y violations`, async ({
page,
}) => {
await page.goto(path);
// Wait briefly for any reduced-motion fades to settle.
await page.waitForLoadState("networkidle");
const results = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();
const blocking = results.violations.filter(
(v) => v.impact === "serious" || v.impact === "critical",
);
if (blocking.length > 0) {
// Pretty-print so failures are actionable in CI logs.
console.log(
"axe violations:\n" +
blocking
.map(
(v) =>
` - [${v.impact}] ${v.id}: ${v.help}\n ${v.nodes.length} node(s)\n ${v.helpUrl}`,
)
.join("\n"),
);
}
expect(blocking).toHaveLength(0);
});
}
});
+34
View File
@@ -0,0 +1,34 @@
import { expect, test } from "@playwright/test";
/**
* The proxy at src/proxy.ts is the single gate enforcing auth on every
* non-public path. A regression that accidentally adds a route to
* PUBLIC_PATHS, or breaks the redirect, would expose unauthenticated
* surfaces. Verify a few representative routes round-trip to /auth/login.
*/
test.describe("proxy auth gate", () => {
for (const path of [
"/",
"/dashboard",
"/medications",
"/admin",
"/insights",
]) {
test(`${path} redirects to /auth/login when no session`, async ({
page,
}) => {
const response = await page.goto(path, { waitUntil: "domcontentloaded" });
expect(response).not.toBeNull();
// Either the proxy hands back a 307/308, or the navigation lands
// at /auth/login after redirect; both are acceptable proof.
expect(page.url()).toMatch(/\/auth\/login(\?|$)/);
});
}
test("public paths are reachable without a session", async ({ page }) => {
// /auth/login is in PUBLIC_PATHS — must be served, not bounced anywhere.
await page.goto("/auth/login");
expect(page.url()).toMatch(/\/auth\/login(\?|$)/);
await expect(page.locator("body")).toBeVisible();
});
});
+54
View File
@@ -0,0 +1,54 @@
import { expect, test } from "@playwright/test";
/**
* Locale-key drift smoke check — switch the cookie between EN and DE
* and assert the login page (the only public surface that's translated)
* never shows raw i18n keys like `auth.welcomeBack`.
*
* Drift catches: a typo'd t("foo.bar") with no key in either locale
* file would make the raw key surface in the DOM. messages/de.json and
* messages/en.json key parity is enforced by unit tests; this is the
* runtime cousin.
*/
test.describe("locale switch", () => {
for (const locale of ["en", "de"] as const) {
test(`renders /auth/login in ${locale} without raw i18n keys`, async ({
page,
context,
}) => {
// Cookies must be addressable to a real origin — `page.url()` on
// a fresh context returns `about:blank`, which Playwright rejects.
// Anchor at the configured base URL explicitly.
const baseURL =
test.info().project.use.baseURL ?? "http://localhost:3000";
await context.addCookies([
{
name: "healthlog_locale",
value: locale,
url: baseURL,
},
]);
await page.goto("/auth/login");
const bodyText = await page.locator("body").innerText();
// i18n keys look like `section.subkey` — letters, numbers,
// underscores, dots — and are never wrapped in normal sentences,
// so a regex match anywhere in body text is a sign the lookup
// fell through.
const rawKeyPattern = /\b[a-z]+(?:[A-Z][a-z]+)?\.[a-z][A-Za-z0-9_.]+\b/;
const match = bodyText.match(rawKeyPattern);
// Whitelist: filenames or version strings can look like keys,
// skip those by requiring at least one camelCase segment.
if (match) {
const candidate = match[0];
const looksLikeKey = /\.[a-z][A-Z]/.test(candidate);
expect(
looksLikeKey,
`Possible raw i18n key in ${locale}: ${candidate}`,
).toBe(false);
}
});
}
});
+65
View File
@@ -0,0 +1,65 @@
import { expect, test } from "@playwright/test";
/**
* Smoke checks on the login surface — must render, must wire the
* password-manager autofill hints (autoComplete="username" /
* "current-password"), must let the user submit.
*
* These do NOT log in (no DB seed required); the password-flow
* roundtrip is exercised by the integration test in
* tests/integration/auth-flow.test.ts.
*/
async function openPasswordForm(page: import("@playwright/test").Page) {
await page.goto("/auth/login");
// The login page defaults to passkey mode; the password form is
// mounted only after clicking the "Login with password" / "Mit
// Passwort anmelden" outline button.
await page
.getByRole("button", { name: /login with password|mit passwort/i })
.click();
}
test.describe("login page", () => {
test("renders username + password inputs with the right autoComplete", async ({
page,
}) => {
await openPasswordForm(page);
const username = page
.getByLabel(/username|benutzername|email|e-mail/i)
.first();
await expect(username).toBeVisible();
await expect(username).toHaveAttribute("autoComplete", /username|email/);
const password = page.locator('input[type="password"]').first();
await expect(password).toBeVisible();
await expect(password).toHaveAttribute("autoComplete", "current-password");
});
test("rejects an obviously-wrong credential pair", async ({ page }) => {
await openPasswordForm(page);
await page
.getByLabel(/username|benutzername|email|e-mail/i)
.first()
.fill("nobody-here");
await page.locator('input[type="password"]').first().fill("not-the-pw");
// Intercept the API call so the test does not depend on a real
// backend rejecting the credentials. The login form's error
// surfacing is what we actually want to prove here.
await page.route("**/api/auth/login", (route) =>
route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({ error: "Invalid credentials" }),
}),
);
await page.getByRole("button", { name: /login|anmelden|sign in/i }).click();
await expect(
page.getByText(/invalid credentials|ungültig|falsch/i),
).toBeVisible({ timeout: 5_000 });
});
});
+30
View File
@@ -0,0 +1,30 @@
import { expect, test } from "@playwright/test";
/**
* /api/version is the public, unauthenticated endpoint that drives
* the Settings → About surface and acts as the container's healthcheck
* target. If this breaks, every deployed image's healthcheck fails and
* Coolify pulls the container out of rotation.
*/
test.describe("public version endpoint", () => {
test("returns the running version + license", async ({ request }) => {
const res = await request.get("/api/version");
expect(res.status()).toBe(200);
const json = await res.json();
expect(json.data).toBeDefined();
expect(typeof json.data.version).toBe("string");
expect(json.data.version).toMatch(/^\d+\.\d+\.\d+/);
expect(json.data.license).toBeDefined();
});
test("does not require authentication", async ({ request }) => {
// No cookie, no Authorization header — must still respond 200 so
// the docker healthcheck and the in-app "check for updates" button
// both work without a session.
const res = await request.get("/api/version", {
headers: { "X-Client-Type": "anonymous" },
});
expect(res.status()).toBe(200);
});
});
+237 -7
View File
@@ -62,7 +62,7 @@
"colMin": "Min",
"colMax": "Max",
"colN": "n",
"bpClassificationTitle": "Blutdruck — ESC/ESH-Klassifikation (2018)",
"bpClassificationTitle": "Blutdruck — ESH-Klassifikation (2023)",
"avgBp": "Durchschnittlicher Blutdruck",
"classification": "Klassifikation",
"bmiTitle": "Body-Mass-Index (BMI)",
@@ -91,6 +91,7 @@
"typeSteps": "Aktivität (Schritte)",
"typeTotalBodyWater": "Gesamtkörperwasser",
"typeBoneMass": "Knochenmasse",
"typeOxygenSaturation": "Sauerstoffsättigung",
"unitSteps": "Schritte",
"bmiUnderweight": "Untergewicht",
"bmiNormal": "Normalgewicht",
@@ -193,6 +194,8 @@
},
"welcomeBack": "{greeting}, willkommen zurück.",
"welcomeBackWithName": "{greeting} {name}, willkommen zurück.",
"greetingSalutation": "Hi, {name}",
"streakLabel": "Tage in Folge",
"weight": "Gewicht",
"bloodPressure": "Blutdruck",
"bloodPressureSys": "Blutdruck (Sys)",
@@ -249,6 +252,7 @@
"typeBloodGlucose": "Blutzucker",
"typeTotalBodyWater": "Gesamtkörperwasser",
"typeBoneMass": "Knochenmasse",
"typeOxygenSaturation": "Sauerstoffsättigung",
"glucoseContext": "Kontext",
"glucoseContextFasting": "Nüchtern (≥8 h ohne Nahrung)",
"glucoseContextPostprandial": "Postprandial (2 h nach dem Essen)",
@@ -528,6 +532,8 @@
"noData": "Keine Daten im gewählten Zeitraum",
"movingAverage": "Gleitender Durchschnitt",
"target": "Zielbereich",
"bucketWeekly": "Wochendurchschnitt",
"bucketMonthly": "Monatsdurchschnitt",
"points7Label": "7 Pkt",
"points7Title": "Letzte 7 Messpunkte",
"points30Label": "30 Pkt",
@@ -538,6 +544,9 @@
"pointsAllTitle": "Alle verfügbaren Messpunkte",
"systolic": "Systolisch",
"diastolic": "Diastolisch",
"bodyWater": "Körperwasser",
"boneMass": "Knochenmasse",
"spo2": "SpO₂",
"movingAverage7d": "7T-Schnitt",
"avg7dShort": "7T",
"avg30dShort": "30T",
@@ -595,6 +604,10 @@
"refreshAnalysis": "Analyse aktualisieren",
"keyTakeaway": "Das Wichtigste",
"findingsTitle": "Befunde",
"heroFindingPositive": "Wichtigster Befund",
"heroFindingNeutral": "Wichtigster Befund",
"heroFindingAttention": "Beachten",
"heroFindingWarning": "Wichtigster Alarm",
"correlationsTitle": "Zusammenhänge",
"recommendationsTitle": "Empfehlungen",
"dataFoundation": "Datengrundlage",
@@ -623,7 +636,7 @@
"inTargetRange": "im Zielbereich",
"bpTarget": "Ziel: {range}",
"noDobSet": "Geburtsdatum nicht hinterlegt.",
"avg30dEsc": "30T-Durchschnitt · ESC/ESH 2018",
"avg30dEsc": "30T-Durchschnitt · ESH 2023",
"whoClassification": "WHO-Klassifikation",
"correlations": "Korrelationen",
"weightVsBp": "Gewicht vs. Blutdruck (Systolisch)",
@@ -760,6 +773,9 @@
"metricGlucosePostprandial": "Glukose — postprandial",
"metricGlucoseRandom": "Glukose — beliebig",
"metricGlucoseBedtime": "Glukose — vor dem Schlafen",
"metricBodyWater": "Körperwasser",
"metricBoneMass": "Knochenmasse",
"metricOxygenSaturation": "Sauerstoffsättigung (SpO₂)",
"unsetExplanation": "Kein eigener Wert — berechneter Default aktiv.",
"loadError": "Zielwerte konnten nicht geladen werden"
},
@@ -786,11 +802,123 @@
"errorMedication": "Das Medikament „{name}\" konnte nicht angelegt werden. Bitte erneut versuchen oder später unter „Medikamente\" hinzufügen.",
"errorTargets": "Deine Zielwerte konnten nicht gespeichert werden. Bitte erneut versuchen.",
"errorGeneric": "Beim Abschluss des Setups ist etwas schiefgelaufen. Bitte erneut versuchen.",
"medScheduleHint": "Wir legen ein Standard-Erinnerungsfenster an (08:00–09:00 täglich). Du kannst es jederzeit unter „Medikamente\" anpassen."
"medScheduleHint": "Wir legen ein Standard-Erinnerungsfenster an (08:00–09:00 täglich). Du kannst es jederzeit unter „Medikamente\" anpassen.",
"v2": {
"title": "Willkommen bei HealthLog",
"subtitle": "Drei schnelle Schritte. Alles weitere kannst du später in den Einstellungen erledigen.",
"stepOf": "Schritt {current} von {total}",
"back": "Zurück",
"continue": "Weiter",
"finish": "Setup abschließen",
"skipStep": "Schritt überspringen — du kannst das Setup später in den Einstellungen abschließen.",
"step1": {
"title": "Über dich",
"description": "Wird für Blutdruck-Zielwerte, BMI und passende Erinnerungen genutzt.",
"displayNameLabel": "Anzeigename",
"displayNameHint": "Erscheint in der Dashboard-Begrüßung und in Berichten.",
"languageLabel": "Sprache",
"languageHint": "Wird für App, PDFs und Benachrichtigungen verwendet."
},
"step2": {
"title": "Lege deine erste Messung an",
"description": "Gewicht, Blutdruck oder Puls — was du gerade zur Hand hast. Schon eine Messung lässt das Dashboard lebendig wirken.",
"added": "Top — weitere Messungen kannst du jederzeit über das Dashboard hinzufügen.",
"skipHint": "Keine Messung zur Hand? Überspringe und logge eine, wenn du soweit bist."
},
"step3": {
"title": "Wähle einen Benachrichtigungs-Kanal",
"description": "Wir senden nur, was du auch willst. Du kannst weitere Kanäle und Events später feinjustieren.",
"channelTelegramTitle": "Telegram-Bot",
"channelTelegramHint": "Erinnerungen mit Snooze-Buttons direkt in Telegram.",
"channelWebPushTitle": "Web Push",
"channelWebPushHint": "Native Browser-Benachrichtigungen auf diesem Gerät.",
"channelNtfyTitle": "ntfy.sh",
"channelNtfyHint": "Open-Source-Push an Handy oder Desktop, ohne Account.",
"channelNoneTitle": "Vorerst überspringen",
"channelNoneHint": "Ich richte Benachrichtigungen später in den Einstellungen ein.",
"openSettings": "In den Einstellungen fortsetzen"
},
"doneToast": "Alles eingerichtet — willkommen an Bord."
}
},
"gettingStarted": {
"title": "Erste Schritte",
"subtitle": "Quick Wins, damit du das Beste aus HealthLog herausholst.",
"progress": "{done} von {total} erledigt",
"dismiss": "Ausblenden",
"dismissAll": "Checkliste ausblenden",
"dismissTooltip": "Diesen Punkt ausblenden",
"restoreHint": "Du kannst die Checkliste in den Einstellungen wiederherstellen.",
"items": {
"profileTitle": "Profil einrichten",
"profileDescription": "Größe, Geburtsdatum und Geschlecht schalten personalisierte Zielwerte frei.",
"profileCta": "Profil öffnen",
"measurementTitle": "Erste Messung anlegen",
"measurementDescription": "Eine Messung reicht, um deine erste Trend-Kachel zu zeichnen.",
"measurementCta": "Messung hinzufügen",
"medicationTitle": "Medikament hinzufügen",
"medicationDescription": "Verfolge Compliance und erhalte Erinnerungen genau dann, wenn du sie brauchst.",
"medicationCta": "Medikament hinzufügen",
"withingsTitle": "Withings verbinden (optional)",
"withingsDescription": "Synchronisiere Gewicht, Blutdruck und Puls automatisch aus deinen Withings-Geräten.",
"withingsCta": "Verbinden",
"notificationsTitle": "Benachrichtigungen einrichten",
"notificationsDescription": "Wähle, wie HealthLog dich erinnert — Telegram, ntfy oder Web Push.",
"notificationsCta": "Benachrichtigungen öffnen"
}
},
"trendHints": {
"title": "Erster Trend nach 5 Messungen",
"remainingOne": "Noch 1 Messung bis zum Trend.",
"remainingMany": "Noch {count} Messungen bis zum Trend."
},
"settings": {
"title": "Einstellungen",
"subtitle": "Profil, Sicherheit & Verbindungen",
"shell": {
"sectionsNav": "Einstellungs-Bereiche"
},
"sections": {
"account": {
"title": "Konto",
"description": "Profil, Anmeldemethoden und Konto-Löschung."
},
"integrations": {
"title": "Integrationen",
"description": "Withings, moodLog und weitere verbundene Dienste."
},
"notifications": {
"title": "Benachrichtigungen",
"description": "Telegram, ntfy, Web Push."
},
"dashboard": {
"title": "Dashboard",
"description": "Kachel-Layout und Reihenfolge."
},
"thresholds": {
"title": "Persönliche Zielwerte",
"description": "Eigene Zielbereiche für jede Metrik."
},
"ai": {
"title": "KI-Auswertungen",
"description": "Provider, Modell, API-Key."
},
"api": {
"title": "API & Tokens",
"description": "Bearer-Tokens für Drittanbieter-Clients."
},
"advanced": {
"title": "Erweitert",
"description": "Export, Import, Daten löschen."
},
"about": {
"title": "Über",
"description": "Version, Lizenz, Links."
},
"placeholder": {
"coming_soon": "Wird mit dem nächsten Release-Update hierher umziehen."
}
},
"sectionsTitle": "Settings",
"categoryGeneral": "Allgemein",
"categoryNotifications": "Benachrichtigungen",
@@ -897,6 +1025,46 @@
"regenerateSuccess": "Reports wurden neu generiert",
"regenerateRateLimit": "Bitte warte — maximal 2 Analysen pro Stunde.",
"lastGeneratedAt": "Zuletzt generiert",
"ai": {
"title": "KI-Insights",
"description": "Anbieter, Modell, Key.",
"chatgptConnectedBadge": "ChatGPT verbunden",
"adminAiActiveBadge": "Admin-KI aktiv",
"connectionExpiredBadge": "Verbindung abgelaufen",
"chatgptConnectedHeading": "ChatGPT verbunden",
"chatgptConnectedBody": "Insights werden über dein ChatGPT-Abo generiert — keine zusätzlichen Kosten.",
"connectedSince": "Verbunden seit {when}.",
"disconnect": "Trennen",
"connectChatgptHeading": "Mit ChatGPT verbinden",
"connectChatgptBody": "Verbinde dein ChatGPT Pro/Max-Konto um KI-gestützte Gesundheitsanalysen basierend auf aktuellen medizinischen Leitlinien zu erhalten. Keine zusätzlichen API-Kosten.",
"connectChatgptCta": "Mit ChatGPT verbinden",
"oauthNotConfigured": "ChatGPT-OAuth ist auf dieser Instanz nicht konfiguriert — nutze stattdessen einen eigenen API-Key unten.",
"adminFallback": "Alternativ nutzt HealthLog den vom Administrator konfigurierten KI-Anbieter.",
"personalProviderHeading": "KI-Provider (persönlich)",
"personalProviderBody": "Eigener KI-Anbieter überschreibt die Admin-Einstellung. Leer lassen für Standard. Für ChatGPT-Pro/Max-OAuth nutze den Verbinden-Button oben.",
"providerLabel": "Provider",
"providerOptionDefault": "— Standard (Admin/Codex) —",
"providerOptionOpenai": "OpenAI (API-Key)",
"providerOptionAnthropic": "Anthropic (Claude)",
"providerOptionLocal": "Lokal (OpenAI-kompatibel)",
"modelLabel": "Modell",
"modelOptionDefault": "— Standard —",
"modelOptionCustom": "Eigenes…",
"customModelLabel": "Eigener Modellname",
"openaiKeyLabel": "OpenAI API-Key",
"openaiKeyHelp": "Eigener Key — keine Verbindung zu deinem ChatGPT-Abo. Für das Abo nutze den Verbinden-Button oben.",
"anthropicKeyLabel": "Anthropic API-Key",
"baseUrlLabel": "Base URL",
"localKeyLabel": "API-Key (optional)",
"savedPreview": "(gespeichert {preview})",
"savedShort": "(gespeichert)",
"saveCta": "Speichern",
"testCta": "Verbindung testen",
"saved": "Gespeichert",
"saveFailed": "Speichern fehlgeschlagen",
"testFailed": "Test fehlgeschlagen",
"errorGeneric": "Fehler"
},
"withings": "Withings",
"withingsDescription": "Verbinde deine Withings-Waage und Blutdruckmessgeräte.",
"withingsCredentials": "API-Zugangsdaten",
@@ -1031,7 +1199,47 @@
"moodLogSyncResult": "{count} Einträge synchronisiert",
"moodLogSyncFailed": "Sync fehlgeschlagen",
"moodLogSaved": "moodLog-Verbindung gespeichert",
"moodLogDisconnected": "moodLog getrennt"
"moodLogDisconnected": "moodLog getrennt",
"about": {
"version": "App-Version",
"gitSha": "Build",
"builtAt": "Erstellt {time}",
"license": "Lizenz",
"repository": "Quellcode",
"changelog": "Changelog",
"docs": "Dokumentation",
"linksHeading": "Quellen & Dokumentation",
"updatesHeading": "Updates",
"checkUpdates": "Jetzt prüfen",
"checking": "Prüfe…",
"lastChecked": "Zuletzt geprüft: {time}",
"lastCheckedNever": "Noch nicht geprüft",
"upToDate": "Du nutzt die aktuelle Version",
"newerAvailable": "Neue Version: {tag}",
"checkFailed": "Konnte GitHub nicht erreichen — versuche es später erneut"
},
"testConnection": {
"test": "Verbindung testen",
"testing": "Teste…",
"ok": "Verbunden (Latenz {latency} ms)",
"errors": {
"credentials_rejected": "Zugangsdaten abgelehnt — Token prüfen",
"rate_limited": "Vom Upstream rate-limitiert",
"timeout": "Request hat das Zeitlimit überschritten",
"upstream_error": "Upstream hat einen Fehler gemeldet",
"connection_failed": "Verbindung fehlgeschlagen",
"not_configured": "Nicht konfiguriert",
"url_not_public": "URL ist kein öffentlicher Endpunkt",
"url_invalid": "URL ist ungültig",
"redirected": "Endpunkt leitet weiter — URL prüfen",
"endpoint_not_found": "Endpunkt unter der URL nicht gefunden",
"credentials_unreadable": "Zugangsdaten nicht entschlüsselbar",
"upstream_invalid_json": "Upstream-Antwort ist kein JSON",
"vapid_not_configured": "Web Push nicht konfiguriert (VAPID-Keys fehlen)",
"rate_limited_self": "Zu viele Test-Anfragen",
"generic": "Test fehlgeschlagen"
}
}
},
"admin": {
"title": "Administration",
@@ -1051,6 +1259,8 @@
"activeTokens": "Aktive API-Tokens",
"activeSessions": "Aktive Sessions",
"loadingStatus": "Lade Status...",
"systemStatusLoadError": "System-Status konnte nicht geladen werden. Bitte die Seite neu laden.",
"adminSettingsLoadError": "Admin-Einstellungen konnten nicht geladen werden. Schalter unten zeigen evtl. nicht den Live-Zustand — Seite neu laden.",
"workerStatus": "Job-Worker",
"workerRunning": "Aktiv",
"workerStopped": "Gestoppt",
@@ -1107,6 +1317,8 @@
"monitoringSave": "Monitoring speichern",
"bugReportGithub": "Bugreport (GitHub Issues)",
"bugReportGithubDescription": "Hinterlege GitHub-Issue-Token und Repository für eingehende Bugreports.",
"bugReportEnabledLabel": "Bug-Reports aktiviert",
"bugReportEnabledDescription": "Wenn deaktiviert, ist der Report-Button im Frontend nicht mehr sichtbar — Token und Repository bleiben gespeichert.",
"bugReportRepo": "Repository",
"bugReportRepoPlaceholder": "owner/repo",
"bugReportToken": "Issue-Token",
@@ -1158,10 +1370,10 @@
"deleteAllData": "Alle Daten global löschen",
"deleteAllDescription": "Löscht alle Mess-, Medikamenten-, Integrations- und Protokoll-Daten aller Benutzer. Benutzerkonten bleiben bestehen.",
"deleteAllConfirm": "Alle Daten wirklich löschen?",
"deleteAllConfirmDescription": "Dieser Vorgang löscht alle Messwerte, Medikamente, Einnahmen und Integrationsdaten aller Benutzer unwiderruflich. Benutzerkonten bleiben erhalten.",
"deleteAllConfirmDescription": "Dieser Vorgang löscht alle Messwerte, Medikamente, Einnahmen, API-Tokens, Withings-Verbindungen und offene Authentifizierungs-Challenges aller Benutzer unwiderruflich. Benutzerkonten und das Audit-Log bleiben erhalten, damit der Löschvorgang selbst nachvollziehbar bleibt.",
"deleteButton": "Alle Daten löschen",
"finalDelete": "Endgültig löschen",
"deletedResult": "Gelöscht: {measurements} Messwerte, {medications} Medikamente, {intakeEvents} Einnahmen",
"deletedResult": "Gelöscht: {measurements} Messwerte, {medications} Medikamente, {intakeEvents} Einnahmen, {apiTokens} API-Tokens, {withingsConnections} Withings-Verbindungen, {authChallenges} Auth-Challenges",
"authRegister": "Registrierung",
"authLogin": "Anmeldung",
"authLoginPasskey": "Passkey-Anmeldung",
@@ -1441,7 +1653,10 @@
"categoryOther": "Sonstiges Feedback",
"bugTitlePlaceholder": "Kurze Zusammenfassung (optional)",
"githubEscalationNote": "Dein Feedback wird lokal gespeichert. Wenn der Administrator die GitHub-Integration aktiviert, kann es auf Wunsch als öffentliches Issue veröffentlicht werden.",
"internalOnlyNote": "Dein Feedback wird lokal gespeichert und ist nur für den Administrator dieses Servers sichtbar."
"internalOnlyNote": "Dein Feedback wird lokal gespeichert und ist nur für den Administrator dieses Servers sichtbar.",
"disabledTitle": "Bug-Reports sind deaktiviert",
"disabledBody": "Der Administrator hat Bug-Reports auf diesem Server deaktiviert.",
"disabledError": "Bug-Reports sind vom Administrator deaktiviert."
},
"notifications": {
"title": "Benachrichtigungen",
@@ -1621,5 +1836,20 @@
"medicationNotFoundExact": "Medikament nicht gefunden. Bitte Namen exakt wie in HealthLog senden.",
"helpHeader": "<b>Verfügbare Befehle:</b>",
"helpBody": "/help — Diese Hilfe anzeigen\n/add — Einnahme erfassen\n/start — Bot starten\n\n<b>Textbefehle:</b>\ngenommen &lt;Name&gt; — Einnahme bestätigen\n\n<b>Über die Buttons in Erinnerungen:</b>\n• Genommen — Einnahme bestätigen\n• 🕐 1h / 🕐 3h — Erinnerung verschieben\n• ⏭ Überspringen — Einnahme überspringen\n• ✓ Bestätigen — Verpasste Einnahme bestätigen"
},
"medicationReminders": {
"phaseGreenTitle": "🟢 Erinnerung: {medName}",
"phaseGreenMessage": "🟢 Erinnerung:\n<b>{medName}</b> ({doseInfo}, {timeWindow})\nZeitfenster endet in {minutes} Min.",
"phaseYellowTitle": "🟡 Bald fällig: {medName}",
"phaseYellowMessage": "🟡 Bald fällig:\n<b>{medName}</b> ({doseInfo}, {timeWindow})\nNoch {minutes} Min. Zeit.",
"phaseOrangeTitle": "🟠 Überfällig: {medName}",
"phaseOrangeMessage": "🟠 Überfällig:\n<b>{medName}</b> ({doseInfo}, {timeWindow})\nSeit {minutes} Min. überfällig.",
"phaseRedTitle": "🔴 Verpasst: {medName}",
"phaseRedMessage": "🔴 Verpasst:\n<b>{medName}</b> ({doseInfo}, {timeWindow})\nAls verpasst markiert.",
"buttonTaken": "Genommen",
"buttonConfirm": "✓ Bestätigen",
"buttonSnoozeOneHour": "🕐 1h",
"buttonSnoozeThreeHours": "🕐 3h",
"buttonSkip": "⏭ Überspringen"
}
}
+237 -7
View File
@@ -62,7 +62,7 @@
"colMin": "Min",
"colMax": "Max",
"colN": "n",
"bpClassificationTitle": "Blood pressure — ESC/ESH classification (2018)",
"bpClassificationTitle": "Blood pressure — ESH classification (2023)",
"avgBp": "Average blood pressure",
"classification": "Classification",
"bmiTitle": "Body Mass Index (BMI)",
@@ -91,6 +91,7 @@
"typeSteps": "Activity (steps)",
"typeTotalBodyWater": "Total body water",
"typeBoneMass": "Bone mass",
"typeOxygenSaturation": "Oxygen saturation",
"unitSteps": "steps",
"bmiUnderweight": "Underweight",
"bmiNormal": "Normal weight",
@@ -193,6 +194,8 @@
},
"welcomeBack": "{greeting}, welcome back.",
"welcomeBackWithName": "{greeting} {name}, welcome back.",
"greetingSalutation": "Hi, {name}",
"streakLabel": "days in a row",
"weight": "Weight",
"bloodPressure": "Blood Pressure",
"bloodPressureSys": "Blood Pressure (Sys)",
@@ -249,6 +252,7 @@
"typeBloodGlucose": "Blood glucose",
"typeTotalBodyWater": "Total body water",
"typeBoneMass": "Bone mass",
"typeOxygenSaturation": "Oxygen saturation",
"glucoseContext": "Context",
"glucoseContextFasting": "Fasting (≥8h without food)",
"glucoseContextPostprandial": "Post-meal (2h after eating)",
@@ -528,6 +532,8 @@
"noData": "No data in selected time range",
"movingAverage": "Moving average",
"target": "Target range",
"bucketWeekly": "Weekly avg",
"bucketMonthly": "Monthly avg",
"points7Label": "7 pts",
"points7Title": "Last 7 data points",
"points30Label": "30 pts",
@@ -538,6 +544,9 @@
"pointsAllTitle": "All available data points",
"systolic": "Systolic",
"diastolic": "Diastolic",
"bodyWater": "Body water",
"boneMass": "Bone mass",
"spo2": "SpO₂",
"movingAverage7d": "7d avg",
"avg7dShort": "7d",
"avg30dShort": "30d",
@@ -595,6 +604,10 @@
"refreshAnalysis": "Refresh analysis",
"keyTakeaway": "Key Takeaway",
"findingsTitle": "Findings",
"heroFindingPositive": "Top finding",
"heroFindingNeutral": "Top finding",
"heroFindingAttention": "Worth attention",
"heroFindingWarning": "Top alert",
"correlationsTitle": "Correlations",
"recommendationsTitle": "Recommendations",
"dataFoundation": "Data coverage",
@@ -623,7 +636,7 @@
"inTargetRange": "in target range",
"bpTarget": "Target: {range}",
"noDobSet": "Date of birth not set.",
"avg30dEsc": "30d average · ESC/ESH 2018",
"avg30dEsc": "30d average · ESH 2023",
"whoClassification": "WHO Classification",
"correlations": "Correlations",
"weightVsBp": "Weight vs. Blood Pressure (Systolic)",
@@ -760,6 +773,9 @@
"metricGlucosePostprandial": "Glucose — post-meal",
"metricGlucoseRandom": "Glucose — random",
"metricGlucoseBedtime": "Glucose — bedtime",
"metricBodyWater": "Body water",
"metricBoneMass": "Bone mass",
"metricOxygenSaturation": "Oxygen saturation (SpO₂)",
"unsetExplanation": "No override — using computed default.",
"loadError": "Could not load thresholds"
},
@@ -786,11 +802,123 @@
"errorMedication": "Could not add the medication \"{name}\". Please try again or add it later in Medications.",
"errorTargets": "Could not save your target values. Please try again.",
"errorGeneric": "Something went wrong while finishing setup. Please try again.",
"medScheduleHint": "We'll add a default reminder window (08:00–09:00 daily). You can adjust it anytime in Medications."
"medScheduleHint": "We'll add a default reminder window (08:00–09:00 daily). You can adjust it anytime in Medications.",
"v2": {
"title": "Welcome to HealthLog",
"subtitle": "Three quick steps. You can finish anything later from Settings.",
"stepOf": "Step {current} of {total}",
"back": "Back",
"continue": "Continue",
"finish": "Finish setup",
"skipStep": "Skip this step — you can finish setup later from Settings.",
"step1": {
"title": "About you",
"description": "Used for blood-pressure targets, BMI, and the right reminders.",
"displayNameLabel": "Display name",
"displayNameHint": "Shown on the dashboard greeting and in reports.",
"languageLabel": "Language",
"languageHint": "We'll use this for the app, PDFs, and notifications."
},
"step2": {
"title": "Log your first measurement",
"description": "Weight, blood pressure, or pulse — whatever you have right now. The dashboard comes alive with one entry.",
"added": "Got it — you can add more anytime from the dashboard.",
"skipHint": "No measurement at hand? Skip and log one when you're ready."
},
"step3": {
"title": "Pick one notification channel",
"description": "We'll only send what you ask for. You can add more channels or fine-tune events later.",
"channelTelegramTitle": "Telegram bot",
"channelTelegramHint": "Reminders with snooze buttons inside Telegram.",
"channelWebPushTitle": "Web Push",
"channelWebPushHint": "Native browser notifications on this device.",
"channelNtfyTitle": "ntfy.sh",
"channelNtfyHint": "Open-source push to phone or desktop, no account.",
"channelNoneTitle": "Skip for now",
"channelNoneHint": "I'll set up notifications later from Settings.",
"openSettings": "Continue in Settings"
},
"doneToast": "You're all set — welcome aboard."
}
},
"gettingStarted": {
"title": "Getting started",
"subtitle": "Quick wins to get the most out of HealthLog.",
"progress": "{done} of {total} done",
"dismiss": "Dismiss",
"dismissAll": "Hide checklist",
"dismissTooltip": "Hide this item",
"restoreHint": "You can restore the checklist from Settings.",
"items": {
"profileTitle": "Set your profile",
"profileDescription": "Height, date of birth and gender unlock personalised targets.",
"profileCta": "Open profile",
"measurementTitle": "Log your first measurement",
"measurementDescription": "One reading is enough to draw your first trend tile.",
"measurementCta": "Add measurement",
"medicationTitle": "Add a medication",
"medicationDescription": "Track compliance and get reminders right when you need them.",
"medicationCta": "Add medication",
"withingsTitle": "Connect Withings (optional)",
"withingsDescription": "Auto-sync weight, BP and pulse from your Withings devices.",
"withingsCta": "Connect",
"notificationsTitle": "Configure notifications",
"notificationsDescription": "Choose how HealthLog should remind you — Telegram, ntfy or Web Push.",
"notificationsCta": "Open notifications"
}
},
"trendHints": {
"title": "First trend after 5 readings",
"remainingOne": "1 more reading to go.",
"remainingMany": "{count} more readings to go."
},
"settings": {
"title": "Settings",
"subtitle": "Profile, Security & Connections",
"shell": {
"sectionsNav": "Settings sections"
},
"sections": {
"account": {
"title": "Account",
"description": "Profile, sign-in methods, and account deletion."
},
"integrations": {
"title": "Integrations",
"description": "Withings, moodLog, and other connected services."
},
"notifications": {
"title": "Notifications",
"description": "Telegram, ntfy, Web Push."
},
"dashboard": {
"title": "Dashboard",
"description": "Tile layout and order."
},
"thresholds": {
"title": "Personal targets",
"description": "Override target ranges for every metric."
},
"ai": {
"title": "AI Insights",
"description": "Provider, model, key."
},
"api": {
"title": "API & Tokens",
"description": "Bearer tokens for headless clients."
},
"advanced": {
"title": "Advanced",
"description": "Export, import, danger zone."
},
"about": {
"title": "About",
"description": "Version, license, links."
},
"placeholder": {
"coming_soon": "Moving here with the next release update."
}
},
"sectionsTitle": "Settings",
"categoryGeneral": "General",
"categoryNotifications": "Notifications",
@@ -897,6 +1025,46 @@
"regenerateSuccess": "Reports regenerated successfully",
"regenerateRateLimit": "Please wait — max 2 analyses per hour.",
"lastGeneratedAt": "Last generated",
"ai": {
"title": "AI Insights",
"description": "Provider, model, key.",
"chatgptConnectedBadge": "ChatGPT connected",
"adminAiActiveBadge": "Admin AI active",
"connectionExpiredBadge": "Connection expired",
"chatgptConnectedHeading": "ChatGPT connected",
"chatgptConnectedBody": "Insights are generated through your ChatGPT subscription — no extra cost.",
"connectedSince": "Connected since {when}.",
"disconnect": "Disconnect",
"connectChatgptHeading": "Connect with ChatGPT",
"connectChatgptBody": "Link your ChatGPT Pro/Max account to get AI-powered health analyses based on current medical guidelines. No additional API costs.",
"connectChatgptCta": "Connect with ChatGPT",
"oauthNotConfigured": "ChatGPT OAuth is not configured on this instance — use your own API key below instead.",
"adminFallback": "Alternatively, HealthLog will use the AI provider configured by the administrator.",
"personalProviderHeading": "AI provider (personal)",
"personalProviderBody": "Your own AI provider overrides the admin setting. Leave blank for the default. For ChatGPT Pro/Max OAuth use the connect button above.",
"providerLabel": "Provider",
"providerOptionDefault": "— Default (Admin/Codex) —",
"providerOptionOpenai": "OpenAI (API key)",
"providerOptionAnthropic": "Anthropic (Claude)",
"providerOptionLocal": "Local (OpenAI-compatible)",
"modelLabel": "Model",
"modelOptionDefault": "— Default —",
"modelOptionCustom": "Custom…",
"customModelLabel": "Custom model name",
"openaiKeyLabel": "OpenAI API key",
"openaiKeyHelp": "Your own key — not connected to your ChatGPT subscription. For the subscription use the connect button above.",
"anthropicKeyLabel": "Anthropic API key",
"baseUrlLabel": "Base URL",
"localKeyLabel": "API key (optional)",
"savedPreview": "(saved {preview})",
"savedShort": "(saved)",
"saveCta": "Save",
"testCta": "Test connection",
"saved": "Saved",
"saveFailed": "Save failed",
"testFailed": "Test failed",
"errorGeneric": "Error"
},
"withings": "Withings",
"withingsDescription": "Connect your Withings scale and blood pressure monitors.",
"withingsCredentials": "API Credentials",
@@ -1031,7 +1199,47 @@
"moodLogSyncResult": "{count} entries synchronized",
"moodLogSyncFailed": "Sync failed",
"moodLogSaved": "moodLog connection saved",
"moodLogDisconnected": "moodLog disconnected"
"moodLogDisconnected": "moodLog disconnected",
"about": {
"version": "App version",
"gitSha": "Build",
"builtAt": "Built {time}",
"license": "License",
"repository": "Source code",
"changelog": "Changelog",
"docs": "Documentation",
"linksHeading": "Sources & docs",
"updatesHeading": "Updates",
"checkUpdates": "Check now",
"checking": "Checking…",
"lastChecked": "Last checked: {time}",
"lastCheckedNever": "Not checked yet",
"upToDate": "You are up to date",
"newerAvailable": "Newer version: {tag}",
"checkFailed": "Could not reach GitHub — try again later"
},
"testConnection": {
"test": "Test connection",
"testing": "Testing…",
"ok": "Connected (latency {latency} ms)",
"errors": {
"credentials_rejected": "Credentials rejected — check your token",
"rate_limited": "Rate-limited by the upstream",
"timeout": "Request timed out",
"upstream_error": "Upstream returned an error",
"connection_failed": "Connection failed",
"not_configured": "Not configured",
"url_not_public": "URL is not a public endpoint",
"url_invalid": "URL is invalid",
"redirected": "Endpoint redirects — check the URL",
"endpoint_not_found": "Endpoint not found at the URL",
"credentials_unreadable": "Credentials cannot be decrypted",
"upstream_invalid_json": "Upstream sent invalid JSON",
"vapid_not_configured": "Web Push not configured (VAPID keys missing)",
"rate_limited_self": "Too many test requests",
"generic": "Test failed"
}
}
},
"admin": {
"title": "Administration",
@@ -1051,6 +1259,8 @@
"activeTokens": "Active API Tokens",
"activeSessions": "Active Sessions",
"loadingStatus": "Loading status...",
"systemStatusLoadError": "Failed to load system status. Try refreshing the page.",
"adminSettingsLoadError": "Failed to load admin settings. Toggles below may not reflect the live state — refresh the page.",
"workerStatus": "Job Worker",
"workerRunning": "Running",
"workerStopped": "Stopped",
@@ -1107,6 +1317,8 @@
"monitoringSave": "Save monitoring",
"bugReportGithub": "Bug report (GitHub Issues)",
"bugReportGithubDescription": "Configure GitHub issue token and repository for incoming bug reports.",
"bugReportEnabledLabel": "Bug reports enabled",
"bugReportEnabledDescription": "When off, the in-app report button hides — token and repository stay configured.",
"bugReportRepo": "Repository",
"bugReportRepoPlaceholder": "owner/repo",
"bugReportToken": "Issue token",
@@ -1158,10 +1370,10 @@
"deleteAllData": "Delete all data globally",
"deleteAllDescription": "Deletes all measurement, medication, integration, and log data for all users. User accounts will be preserved.",
"deleteAllConfirm": "Really delete all data?",
"deleteAllConfirmDescription": "This will permanently delete all measurements, medications, intake records, and integration data for all users. User accounts will be preserved.",
"deleteAllConfirmDescription": "This will permanently delete all measurements, medications, intake events, API tokens, Withings connections, and any open authentication challenges for all users. User accounts and the audit log are preserved so the wipe itself remains traceable.",
"deleteButton": "Delete all data",
"finalDelete": "Delete permanently",
"deletedResult": "Deleted: {measurements} measurements, {medications} medications, {intakeEvents} intakes",
"deletedResult": "Deleted: {measurements} measurements, {medications} medications, {intakeEvents} intakes, {apiTokens} API tokens, {withingsConnections} Withings connections, {authChallenges} auth challenges",
"authRegister": "Registration",
"authLogin": "Login",
"authLoginPasskey": "Passkey login",
@@ -1441,7 +1653,10 @@
"categoryOther": "Other feedback",
"bugTitlePlaceholder": "Short summary (optional)",
"githubEscalationNote": "Your feedback is stored locally. If the admin enables GitHub integration, it can be promoted to a public issue on request.",
"internalOnlyNote": "Your feedback is stored locally and only visible to the administrator of this server."
"internalOnlyNote": "Your feedback is stored locally and only visible to the administrator of this server.",
"disabledTitle": "Bug reports are turned off",
"disabledBody": "The administrator has disabled bug reports on this server.",
"disabledError": "Bug reports are disabled by the administrator."
},
"notifications": {
"title": "Notifications",
@@ -1621,5 +1836,20 @@
"medicationNotFoundExact": "Medication not found. Please send the name exactly as in HealthLog.",
"helpHeader": "<b>Available commands:</b>",
"helpBody": "/help — show this help\n/add — record an intake\n/start — start the bot\n\n<b>Text commands:</b>\ntaken &lt;name&gt; — confirm an intake\n\n<b>Buttons in reminders:</b>\n• Taken — confirm intake\n• 🕐 1h / 🕐 3h — postpone reminder\n• ⏭ Skip — skip intake\n• ✓ Confirm — confirm a missed intake"
},
"medicationReminders": {
"phaseGreenTitle": "🟢 Reminder: {medName}",
"phaseGreenMessage": "🟢 Reminder:\n<b>{medName}</b> ({doseInfo}, {timeWindow})\nWindow closes in {minutes} min.",
"phaseYellowTitle": "🟡 Due soon: {medName}",
"phaseYellowMessage": "🟡 Due soon:\n<b>{medName}</b> ({doseInfo}, {timeWindow})\n{minutes} min left.",
"phaseOrangeTitle": "🟠 Overdue: {medName}",
"phaseOrangeMessage": "🟠 Overdue:\n<b>{medName}</b> ({doseInfo}, {timeWindow})\nOverdue by {minutes} min.",
"phaseRedTitle": "🔴 Missed: {medName}",
"phaseRedMessage": "🔴 Missed:\n<b>{medName}</b> ({doseInfo}, {timeWindow})\nMarked as missed.",
"buttonTaken": "Taken",
"buttonConfirm": "✓ Confirm",
"buttonSnoozeOneHour": "🕐 1h",
"buttonSnoozeThreeHours": "🕐 3h",
"buttonSkip": "⏭ Skip"
}
}
+19 -9
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.3.2",
"version": "1.4.6",
"private": true,
"packageManager": "pnpm@10.31.0",
"scripts": {
@@ -17,50 +17,60 @@
"format:check": "prettier --check .",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
"test:watch": "vitest",
"test:integration": "vitest run --config vitest.integration.config.mts",
"e2e": "playwright test",
"e2e:ui": "playwright test --ui"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1045.0",
"@hookform/resolvers": "^5.2.2",
"@node-rs/argon2": "^2.0.2",
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.0",
"@tanstack/react-query": "^5.100.5",
"@tanstack/react-query": "^5.100.9",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dotenv": "^17.4.2",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.7",
"lucide-react": "^1.11.0",
"lucide-react": "^1.14.0",
"next": "16.2.4",
"next-themes": "^0.4.6",
"pg-boss": "^12.18.1",
"pg-boss": "^12.18.2",
"prisma": "^7.8.0",
"radix-ui": "^1.4.3",
"react": "19.2.5",
"react-dom": "19.2.5",
"react-hook-form": "^7.74.0",
"react-hook-form": "^7.75.0",
"recharts": "^3.8.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"web-push": "^3.6.7",
"zod": "^4.3.6",
"zod": "^4.4.2",
"zxcvbn-typescript": "^5.0.1"
},
"devDependencies": {
"@axe-core/playwright": "^4.11.3",
"@playwright/test": "^1.59.1",
"@tailwindcss/postcss": "^4",
"@testcontainers/postgresql": "^11.14.0",
"@types/node": "^25",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/web-push": "^3.6.4",
"@vitejs/plugin-react": "^5.1.4",
"@vitest/coverage-v8": "^4.1.5",
"eslint": "^9",
"eslint-config-next": "16.2.4",
"pdf-parse": "^2.4.5",
"prettier": "^3.8.3",
"prettier-plugin-tailwindcss": "^0.7.3",
"shadcn": "^4.5.0",
"prettier-plugin-tailwindcss": "^0.8.0",
"shadcn": "^4.6.0",
"tailwindcss": "^4",
"testcontainers": "^11.14.0",
"tw-animate-css": "^1.4.0",
"typescript": "^6",
"vitest": "^4.1.5"
+69
View File
@@ -0,0 +1,69 @@
import { defineConfig, devices } from "@playwright/test";
/**
* Playwright configuration for the HealthLog E2E suite.
*
* The suite covers the smoke-level user paths (auth redirect, login
* form, public version endpoint, locale switch, axe-core) without
* needing seeded data — every spec either runs against an unauthed
* surface or uses route interception to stub out the API. Specs that
* need a logged-in user are kept narrow and flagged in their describe
* block; CI runs them against a worker that seeds a deterministic test
* user on startup.
*
* To run locally: `pnpm dlx playwright install --with-deps chromium`
* once, then `pnpm e2e`.
*/
export default defineConfig({
testDir: "./e2e",
timeout: 30_000,
expect: { timeout: 5_000 },
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
workers: process.env.CI ? 2 : undefined,
reporter: process.env.CI
? [["github"], ["html", { open: "never" }]]
: [["list"], ["html", { open: "never" }]],
use: {
baseURL: process.env.E2E_BASE_URL ?? "http://localhost:3000",
trace: "retain-on-failure",
screenshot: "only-on-failure",
video: "retain-on-failure",
},
projects: [
{
name: "chromium-desktop",
use: {
...devices["Desktop Chrome"],
viewport: { width: 1280, height: 720 },
},
},
{
name: "chromium-mobile",
use: {
// Pixel 5 — Chromium-based mobile profile so CI only needs
// `playwright install chromium` instead of also pulling
// webkit. iPhone-13 is intentionally avoided here; the
// mobile-Safari smoke is exercised by the iOS app suite.
...devices["Pixel 5"],
},
},
],
// Spin up the production build for E2E. `pnpm build` is run
// separately by CI before the suite — locally, set E2E_SKIP_WEB_SERVER=1
// to point at an already-running dev server.
webServer: process.env.E2E_SKIP_WEB_SERVER
? undefined
: {
command: "pnpm exec next start --port 3000",
url: "http://localhost:3000/api/version",
timeout: 60_000,
reuseExistingServer: !process.env.CI,
stdout: "ignore",
stderr: "pipe",
},
});
+2594 -133
View File
File diff suppressed because it is too large. Load diff
@@ -4,9 +4,11 @@
-- Both are reported by Withings body-composition scales and can also be ingested
-- via the API by external pipelines (e.g. n8n + Health Connect).
--
-- Storage units (canonical):
-- TOTAL_BODY_WATER: percent of body weight (%)
-- BONE_MASS: kilograms (kg)
-- Storage units (canonical, both kg — matches VALUE_RANGES in
-- src/lib/validations/measurement.ts and Withings client type-77/type-88
-- mapping). Adult plausibility ranges: water 30–55 kg, bone 2.5–4.5 kg.
-- TOTAL_BODY_WATER: kilograms (kg) — total body water content
-- BONE_MASS: kilograms (kg) — DEXA-equivalent bone mineral content
--
-- Both are unconditional value types — no companion enum (unlike BLOOD_GLUCOSE,
-- which carries a glucose_context). The migration is purely additive.
@@ -0,0 +1,55 @@
-- v1.3: iOS adapter endpoints + idempotency cache + device registration
--
-- Purely additive:
-- • users.display_name, users.healthkit_config_json, users.healthkit_last_synced_at
-- • new tables: idempotency_keys, devices
-- ── users new columns ─────────────────────────────────────────────────
ALTER TABLE "users"
ADD COLUMN "display_name" TEXT,
ADD COLUMN "healthkit_config_json" JSONB,
ADD COLUMN "healthkit_last_synced_at" TIMESTAMP(3);
-- ── idempotency_keys ──────────────────────────────────────────────────
CREATE TABLE "idempotency_keys" (
"id" TEXT PRIMARY KEY,
"user_id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"method" TEXT NOT NULL,
"path" TEXT NOT NULL,
"response_status" INTEGER NOT NULL,
"response_body" TEXT NOT NULL,
"expires_at" TIMESTAMP(3) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "idempotency_keys_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE
);
CREATE UNIQUE INDEX "idempotency_keys_user_id_key_method_path_key"
ON "idempotency_keys" ("user_id", "key", "method", "path");
CREATE INDEX "idempotency_keys_expires_at_idx"
ON "idempotency_keys" ("expires_at");
-- ── devices ───────────────────────────────────────────────────────────
CREATE TABLE "devices" (
"id" TEXT PRIMARY KEY,
"user_id" TEXT NOT NULL,
"platform" TEXT NOT NULL,
"token" TEXT NOT NULL,
"bundle_id" TEXT NOT NULL,
"locale" TEXT,
"app_version" TEXT,
"model" TEXT,
"last_seen" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "devices_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE
);
CREATE UNIQUE INDEX "devices_token_key" ON "devices" ("token");
CREATE INDEX "devices_user_id_idx" ON "devices" ("user_id");
@@ -0,0 +1,12 @@
-- v1.3.3: Add OXYGEN_SATURATION (SpO2) measurement type
--
-- Closes #109 feature request. Single-value type (no per-context split like
-- BLOOD_GLUCOSE — context goes into the free-text `notes` field).
--
-- Storage unit: percent (0..100). Plausibility range 50..100 in
-- src/lib/validations/measurement.ts (anything below 50% is incompatible with
-- sustained life and almost certainly a faulty sensor).
--
-- Withings devices that support SpO2 (e.g. ScanWatch) report measure type 54.
-- See src/lib/withings/client.ts MEASURE_TYPE_MAP.
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'OXYGEN_SATURATION';
@@ -0,0 +1,47 @@
-- 0025_refresh_tokens
-- Native-client short-lived access + rotating refresh tokens (v1.4 G4).
-- Forward-compatible: this migration only ADDS a table. No DROP COLUMN, no
-- type changes on existing tables. To roll back, the down migration in
-- docs/ops/migrations/0025-down.sql DROPs the table.
--
-- All statements use IF NOT EXISTS so a partial-deploy retry is a no-op
-- instead of "relation already exists". Wrapped in a single transaction
-- so a mid-statement failure leaves no half-built table behind.
BEGIN;
CREATE TABLE IF NOT EXISTS "refresh_tokens" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"token_hash" TEXT NOT NULL,
"device_id" TEXT,
"access_token_hash" TEXT,
"expires_at" TIMESTAMP(3) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"revoked_at" TIMESTAMP(3),
"used_at" TIMESTAMP(3),
"replaced_by_id" TEXT,
"user_agent" TEXT,
"ip_address" TEXT,
CONSTRAINT "refresh_tokens_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "refresh_tokens_token_hash_key" ON "refresh_tokens"("token_hash");
CREATE INDEX IF NOT EXISTS "refresh_tokens_user_id_idx" ON "refresh_tokens"("user_id");
CREATE INDEX IF NOT EXISTS "refresh_tokens_user_id_device_id_idx" ON "refresh_tokens"("user_id", "device_id");
CREATE INDEX IF NOT EXISTS "refresh_tokens_expires_at_idx" ON "refresh_tokens"("expires_at");
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE constraint_name = 'refresh_tokens_user_id_fkey'
) THEN
ALTER TABLE "refresh_tokens"
ADD CONSTRAINT "refresh_tokens_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
COMMIT;
@@ -0,0 +1,14 @@
-- 0025_user_locale_drift_fix
-- The `users.locale` column has been on `prisma/schema.prisma` since the
-- locale-aware reminder work in v1.3 but never landed in the migration
-- history (it must have been applied via `prisma db push` to dev / prod
-- but not committed as a migration). The schema drift is invisible until
-- a fresh database is built from migrations alone — at which point the
-- Prisma client tries to read `users.locale` and Postgres reports
-- `column users.locale does not exist`.
--
-- This migration backfills the column with `IF NOT EXISTS` so it is a
-- safe no-op against any database that was kept in sync via `db push`,
-- and a clean add against any environment built strictly from this
-- migrations directory (CI test containers, fresh deploys, etc.).
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "locale" TEXT;
@@ -0,0 +1,14 @@
-- v1.4.3: per-user OpenAI API key.
--
-- Until this release the schema stored Anthropic and Local-provider API
-- keys but no field for the OPENAI provider, even though OPENAI was a
-- valid value in `users.ai_provider`. Users who picked it had no place
-- in the UI to drop their key and the runtime fell through to whichever
-- admin-level fallback was configured. This column is the missing slot.
--
-- AES-256-GCM encrypted at the application layer (`src/lib/crypto.ts`)
-- before the value lands on disk. Plaintext keys never touch Postgres
-- backups.
ALTER TABLE "users"
ADD COLUMN "ai_openai_key_encrypted" TEXT;
@@ -0,0 +1,16 @@
-- v1.4.3: explicit on/off switch for the bug-report surface.
--
-- Until this release the bug-report feature was implicitly gated on
-- "is the GitHub token + repo configured?". That coupling meant an
-- admin who wanted to *temporarily* hide the bug-report button (e.g.
-- during a deploy freeze) had to clear the encrypted token, which
-- forced a re-entry on resume — destructive for a reversible
-- intent.
--
-- Default `true` so existing instances with credentials configured
-- keep the feature visible after the upgrade. Admins who want it off
-- flip the switch in `/admin/integrations` (or whichever section
-- mounts the bug-report card).
ALTER TABLE "app_settings"
ADD COLUMN "bug_report_enabled" BOOLEAN NOT NULL DEFAULT TRUE;
+109
View File
@@ -70,10 +70,23 @@ model User {
aiBaseUrl String? @map("ai_base_url")
aiAnthropicKeyEncrypted String? @map("ai_anthropic_key_encrypted")
aiLocalKeyEncrypted String? @map("ai_local_key_encrypted")
// v1.4.3: per-user OpenAI API key for the OPENAI provider — previously
// the schema only stored Anthropic and Local keys, leaving OPENAI without
// a credential surface. Encrypted with the active ENCRYPTION_KEYS entry
// before storage.
aiOpenaiKeyEncrypted String? @map("ai_openai_key_encrypted")
// Onboarding
onboardingCompletedAt DateTime? @map("onboarding_completed_at")
// HealthKit (iOS) integration config — list of per-metric direction toggles.
// Shape: { entries: [{ id, kind, direction, enabled }], lastSyncedAt }
healthKitConfigJson Json? @map("healthkit_config_json")
healthKitLastSyncedAt DateTime? @map("healthkit_last_synced_at")
// Display name (optional override of username for greetings).
displayName String? @map("display_name")
// Relations
passkeys Passkey[]
sessions Session[]
@@ -82,6 +95,7 @@ model User {
medications Medication[]
intakeEvents MedicationIntakeEvent[]
apiTokens ApiToken[]
refreshTokens RefreshToken[]
auditLogs AuditLog[]
withingsConnection WithingsConnection?
moodEntries MoodEntry[]
@@ -90,6 +104,8 @@ model User {
dataBackups DataBackup[]
achievements UserAchievement[]
feedback Feedback[]
idempotencyKeys IdempotencyKey[]
devices Device[]
@@map("users")
}
@@ -152,6 +168,7 @@ enum MeasurementType {
BLOOD_GLUCOSE // Canonical storage: mg/dL. Display unit (mg/dL or mmol/L) is user preference.
TOTAL_BODY_WATER // Canonical storage: kilograms (kg of water). Matches Withings' hydration measure (type 77) and Health Connect's TotalBodyWaterRecord. Display can compute % from same-day weight if desired.
BONE_MASS // Canonical storage: kilograms (kg). Matches Withings' bone mass measure (type 88).
OXYGEN_SATURATION // Canonical storage: percent (0..100). Withings ScanWatch reports type 54. Health Connect's OxygenSaturationRecord uses Percentage (already 0..100). HealthKit uses HKQuantityTypeIdentifierOxygenSaturation as a fraction (0..1) — iOS DTO must multiply by 100 before submitting.
@@map("measurement_type")
}
@@ -346,6 +363,47 @@ model ApiToken {
@@map("api_tokens")
}
// ─── Refresh Tokens (native client short-lived access + rotating refresh) ─
model RefreshToken {
id String @id @default(cuid())
userId String @map("user_id")
tokenHash String @unique @map("token_hash")
/// Optional device fingerprint — links the refresh token to a specific
/// `Device` row when the caller advertised one (X-Device-Id header). Used
/// to revoke all tokens issued for a stolen/lost device.
deviceId String? @map("device_id")
/// HMAC-SHA-256 (matching `ApiToken.tokenHash` format, keyed by
/// `API_TOKEN_HMAC_KEY`) of the access token issued in the same response.
/// When the refresh token is rotated we revoke the matching access token
/// via `ApiToken.tokenHash`. Null if the access token was issued
/// out-of-band.
accessTokenHash String? @map("access_token_hash")
expiresAt DateTime @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
revokedAt DateTime? @map("revoked_at")
/// When this token was used (one-time-use). Set during rotation; a second
/// use of the same token is a hard fail and triggers reuse-detection.
usedAt DateTime? @map("used_at")
/// Pointer to the row that replaced this one during rotation. Helps audit
/// trail and reuse-detection ("this token was already exchanged into X").
replacedById String? @map("replaced_by_id")
userAgent String? @map("user_agent")
ipAddress String? @map("ip_address")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
/// Composite index — every reuse-detection / device-scoped lookup filters
/// on (userId, deviceId), so a single-column device_id index would never
/// be used while costing writes. Multiple live rows per (userId, deviceId)
/// are intentional: rotated rows linger in `revokedAt != null` state for
/// audit and reuse-detection.
@@index([userId, deviceId])
@@index([expiresAt])
@@map("refresh_tokens")
}
// ─── Withings ──────────────────────────────────────────────
model WithingsConnection {
@@ -408,6 +466,11 @@ model AppSettings {
glitchtipEnvironment String? @map("glitchtip_environment")
githubIssueTokenEncrypted String? @map("github_issue_token_encrypted")
githubIssueRepo String? @map("github_issue_repo")
// v1.4.3: explicit toggle so an admin can hide the bug-report
// surface without nuking the token+repo config. Default true so
// existing instances with credentials don't accidentally lose
// the feature on upgrade.
bugReportEnabled Boolean @default(true) @map("bug_report_enabled")
// Medication reminder thresholds (minutes after windowEnd)
reminderLateMinutes Int @default(120) @map("reminder_late_minutes")
@@ -559,6 +622,52 @@ model RateLimit {
@@map("rate_limits")
}
// ─── Idempotency Keys (mobile-client replay protection) ──
/// Caches the response of a POST/PUT/PATCH/DELETE request keyed by
/// (userId, key, method, path) so retries within the TTL replay the
/// original outcome instead of producing a duplicate side-effect.
model IdempotencyKey {
id String @id @default(cuid())
userId String @map("user_id")
key String
method String
path String
responseStatus Int @map("response_status")
responseBody String @map("response_body") // serialized JSON envelope
expiresAt DateTime @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, key, method, path])
@@index([expiresAt])
@@map("idempotency_keys")
}
// ─── Devices (APNs / mobile push targets) ────────────────
/// Registered native-client device for push delivery. Token is unique
/// across users; re-registering on a different account moves ownership.
model Device {
id String @id @default(cuid())
userId String @map("user_id")
platform String // "ios"
token String @unique
bundleId String @map("bundle_id")
locale String?
appVersion String? @map("app_version")
model String?
lastSeen DateTime @default(now()) @map("last_seen")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("devices")
}
// ─── User Achievements ───────────────────────────────────────
model UserAchievement {
+1 -3
View File
@@ -56,9 +56,7 @@ self.addEventListener("fetch", (event) => {
}
// Static files (fonts, icons, images) — cache-first with long TTL
if (
/\.(png|svg|jpg|jpeg|gif|webp|ico|woff2?|ttf|eot)$/i.test(url.pathname)
) {
if (/\.(png|svg|jpg|jpeg|gif|webp|ico|woff2?|ttf|eot)$/i.test(url.pathname)) {
event.respondWith(cacheFirst(request, STATIC_CACHE));
return;
}
+128
View File
@@ -0,0 +1,128 @@
/**
* scripts/restore-backup.ts <s3-key> [output-file]
*
* Downloads a single off-host backup object, decrypts it with
* `BACKUP_ENCRYPTION_KEY`, and writes the JSON dump to disk.
*
* Wire format (binary):
* magic(4)="HLBK" || version(1)=0x01 || iv(12) || tag(16) || ciphertext
*
* Usage:
* BACKUP_S3_ENDPOINT=... \
* BACKUP_S3_BUCKET=... \
* BACKUP_S3_ACCESS_KEY=... \
* BACKUP_S3_SECRET_KEY=... \
* BACKUP_S3_REGION=auto \
* BACKUP_ENCRYPTION_KEY=<hex64> \
* pnpm tsx scripts/restore-backup.ts 2026-05-08/user-clx123.json.enc /tmp/restored.json
*/
import "dotenv/config";
import { writeFileSync } from "node:fs";
import {
loadOffhostConfig,
getS3Client,
decryptBackup,
OffhostBackupNotConfiguredError,
} from "@/lib/jobs/offhost-backup";
/**
* Schema-validate the decrypted JSON before writing it to disk. The wire
* format from `runOffhostBackup` always carries `userId` and the four data
* arrays — anything else means the operator may be looking at a tampered or
* mis-targeted object. We reject rather than silently writing a body that a
* later import script would trust.
*/
function validateBackupShape(payload: unknown): {
exportedAt: string;
userId: string;
measurements: unknown[];
medications: unknown[];
intakeEvents: unknown[];
moodEntries: unknown[];
} {
if (!payload || typeof payload !== "object") {
throw new Error("backup payload is not a JSON object");
}
const p = payload as Record<string, unknown>;
const required = [
"exportedAt",
"userId",
"measurements",
"medications",
"intakeEvents",
"moodEntries",
] as const;
for (const k of required) {
if (!(k in p)) throw new Error(`backup is missing required field: ${k}`);
}
if (typeof p.exportedAt !== "string")
throw new Error("backup.exportedAt must be string");
if (typeof p.userId !== "string" || p.userId.length === 0)
throw new Error("backup.userId must be a non-empty string");
for (const k of [
"measurements",
"medications",
"intakeEvents",
"moodEntries",
] as const) {
if (!Array.isArray(p[k])) throw new Error(`backup.${k} must be an array`);
}
return p as ReturnType<typeof validateBackupShape>;
}
async function main() {
const key = process.argv[2];
const out = process.argv[3] ?? "./restored.json";
// Optional positional arg: --user-id=<id> requires the dump's `userId`
// field to match. Defends against an operator pointing the script at the
// wrong S3 key (e.g. another tenant's dump under a similar path) and
// silently importing the wrong user's data downstream.
const expectedUserId = process.argv
.find((a) => a.startsWith("--user-id="))
?.slice("--user-id=".length);
if (!key) {
console.error(
"Usage: pnpm tsx scripts/restore-backup.ts <s3-key> [output-file] [--user-id=<id>]",
);
process.exit(1);
}
const cfg = loadOffhostConfig();
if (!cfg) {
throw new OffhostBackupNotConfiguredError(
"Off-host backup is not configured. Set BACKUP_* env vars.",
);
}
const s3 = await getS3Client(cfg);
console.log(
`Downloading s3://${cfg.bucket}/${key} from ${cfg.endpoint} (region=${cfg.region})`,
);
const ciphertext = await s3.getObject(key);
const plaintext = decryptBackup(ciphertext, cfg.encryptionKey);
let parsed: unknown;
try {
parsed = JSON.parse(plaintext);
} catch (err) {
throw new Error(
`Decrypted payload is not valid JSON: ${(err as Error).message}`,
);
}
const body = validateBackupShape(parsed);
if (expectedUserId && body.userId !== expectedUserId) {
throw new Error(
`Refusing to write: backup.userId='${body.userId}' does not match --user-id='${expectedUserId}'`,
);
}
writeFileSync(out, plaintext, "utf8");
console.log(
`Restored userId=${body.userId} (${plaintext.length} bytes) -> ${out}`,
);
}
main().catch((err) => {
console.error("Restore failed:", err);
process.exit(2);
});
+246
View File
@@ -0,0 +1,246 @@
/**
* scripts/rotate-encryption-key.ts <new-key-id>
*
* Re-encrypts every encrypted column in the database with the currently
* active key (`ENCRYPTION_ACTIVE_KEY_ID`). Idempotent: rows whose ciphertext
* already carries the active key id prefix are skipped.
*
* Usage:
* ENCRYPTION_KEYS='{"v1":"<old>","v2":"<new>"}' \
* ENCRYPTION_ACTIVE_KEY_ID=v2 \
* pnpm tsx scripts/rotate-encryption-key.ts v2
*/
import "dotenv/config";
import { PrismaClient } from "@/generated/prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
import { decrypt, encrypt, extractKeyId, getActiveKeyId } from "@/lib/crypto";
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
console.error("DATABASE_URL must be set");
process.exit(1);
}
const targetKeyId = process.argv[2] ?? getActiveKeyId();
if (targetKeyId !== getActiveKeyId()) {
console.error(
`Refusing to rotate: argv key id '${targetKeyId}' does not match the ` +
`currently active id '${getActiveKeyId()}'. Set ENCRYPTION_ACTIVE_KEY_ID first.`,
);
process.exit(2);
}
const prisma = new PrismaClient({
adapter: new PrismaPg({ connectionString: DATABASE_URL }),
});
interface RotationResult {
table: string;
field: string;
scanned: number;
rotated: number;
errors: number;
}
function shouldRotate(value: string | null): boolean {
if (!value) return false;
const id = extractKeyId(value);
// null = legacy/unversioned (rotate); else rotate if not already on active.
return id !== getActiveKeyId();
}
async function rotateField<T extends { id: string }>(
table: string,
field: string,
rows: T[],
fieldGetter: (row: T) => string | null,
updater: (id: string, ciphertext: string) => Promise<void>,
): Promise<RotationResult> {
const result: RotationResult = {
table,
field,
scanned: rows.length,
rotated: 0,
errors: 0,
};
for (const row of rows) {
const v = fieldGetter(row);
if (!shouldRotate(v)) continue;
try {
const re = encrypt(decrypt(v as string));
await updater(row.id, re);
result.rotated++;
} catch (err) {
result.errors++;
console.error(
`[${table}.${field}] row ${row.id}: ${(err as Error).message}`,
);
}
}
return result;
}
async function main() {
console.log(`Rotating encrypted rows to active key id: ${targetKeyId}`);
const results: RotationResult[] = [];
// ───── User table ─────
const users = await prisma.user.findMany({
select: {
id: true,
codexAccessTokenEncrypted: true,
codexRefreshTokenEncrypted: true,
aiAnthropicKeyEncrypted: true,
aiLocalKeyEncrypted: true,
moodLogWebhookSecret: true,
telegramBotToken: true,
moodLogUrlEncrypted: true,
moodLogApiKeyEncrypted: true,
withingsClientIdEncrypted: true,
withingsClientSecretEncrypted: true,
},
});
const userFields: Array<keyof (typeof users)[number]> = [
"codexAccessTokenEncrypted",
"codexRefreshTokenEncrypted",
"aiAnthropicKeyEncrypted",
"aiLocalKeyEncrypted",
"moodLogWebhookSecret",
"telegramBotToken",
"moodLogUrlEncrypted",
"moodLogApiKeyEncrypted",
"withingsClientIdEncrypted",
"withingsClientSecretEncrypted",
];
for (const field of userFields) {
const r = await rotateField(
"User",
field as string,
users,
(u) => (u as Record<string, unknown>)[field as string] as string | null,
async (id, ciphertext) => {
await prisma.user.update({
where: { id },
data: { [field]: ciphertext } as Record<string, string>,
});
},
);
results.push(r);
}
// ───── WithingsConnection table (accessToken / refreshToken) ─────
const withings = await prisma.withingsConnection.findMany({
select: { id: true, accessToken: true, refreshToken: true },
});
for (const field of ["accessToken", "refreshToken"] as const) {
const r = await rotateField(
"WithingsConnection",
field,
withings,
(w) => w[field],
async (id, ciphertext) => {
await prisma.withingsConnection.update({
where: { id },
data: { [field]: ciphertext } as Record<string, string>,
});
},
);
results.push(r);
}
// ───── AppSettings table (singleton typically) ─────
const settings = await prisma.appSettings.findMany({
select: {
id: true,
adminAiKeyEncrypted: true,
webPushVapidPrivateKeyEncrypted: true,
githubIssueTokenEncrypted: true,
},
});
const appFields = [
"adminAiKeyEncrypted",
"webPushVapidPrivateKeyEncrypted",
"githubIssueTokenEncrypted",
] as const;
for (const field of appFields) {
const r = await rotateField(
"AppSettings",
field,
settings,
(s) => s[field],
async (id, ciphertext) => {
await prisma.appSettings.update({
where: { id },
data: { [field]: ciphertext } as Record<string, string>,
});
},
);
results.push(r);
}
// ───── NotificationChannel.config ─────
// Channel config (Telegram chat id, ntfy topic, etc.) is encrypted JSON.
// Skipping these on rotation would leave channels permanently undecryptable
// once the operator drops `v1` from the key map.
const channels = await prisma.notificationChannel.findMany({
select: { id: true, config: true },
});
results.push(
await rotateField(
"NotificationChannel",
"config",
channels,
(c) => c.config,
async (id, ciphertext) => {
await prisma.notificationChannel.update({
where: { id },
data: { config: ciphertext },
});
},
),
);
// ───── PushSubscription.{p256dh, auth} ─────
// Web-push routing secrets — without these, the push endpoint is reachable
// but the browser ignores the message (auth tag mismatch).
const subs = await prisma.pushSubscription.findMany({
select: { id: true, p256dh: true, auth: true },
});
for (const field of ["p256dh", "auth"] as const) {
results.push(
await rotateField(
"PushSubscription",
field,
subs,
(s) => s[field],
async (id, ciphertext) => {
await prisma.pushSubscription.update({
where: { id },
data: { [field]: ciphertext } as Record<string, string>,
});
},
),
);
}
console.log("\n=== Rotation summary ===");
let totalRotated = 0;
let totalErrors = 0;
for (const r of results) {
console.log(
`${r.table}.${r.field}: scanned=${r.scanned} rotated=${r.rotated} errors=${r.errors}`,
);
totalRotated += r.rotated;
totalErrors += r.errors;
}
console.log(`\nTOTAL rotated=${totalRotated} errors=${totalErrors}`);
await prisma.$disconnect();
process.exit(totalErrors > 0 ? 3 : 0);
}
main().catch(async (err) => {
console.error("Rotation failed:", err);
await prisma.$disconnect();
process.exit(1);
});
+57 -27
View File
@@ -36,7 +36,12 @@ function cuid(): string {
function daysAgo(n: number): Date {
const d = new Date();
d.setDate(d.getDate() - n);
d.setHours(7 + Math.floor(Math.random() * 3), Math.floor(Math.random() * 60), 0, 0);
d.setHours(
7 + Math.floor(Math.random() * 3),
Math.floor(Math.random() * 60),
0,
0,
);
return d;
}
@@ -45,7 +50,12 @@ function formatDate(d: Date): string {
}
// Smooth random walk with mean reversion
function randomWalk(start: number, target: number, days: number, volatility: number): number[] {
function randomWalk(
start: number,
target: number,
days: number,
volatility: number,
): number[] {
const values: number[] = [start];
for (let i = 1; i < days; i++) {
const prev = values[i - 1];
@@ -88,12 +98,24 @@ async function seed() {
// argon2id hash of "demo123demo123" — pre-computed
// We'll use a bcrypt-compatible approach: register via API later
// For now, insert with a placeholder and we'll set it via the app
const passwordHash = "$argon2id$v=19$m=65536,t=3,p=4$Kips6OxPAl0vmspO9SoKZQ$oX9gLgwHVnnENCqBloyM13ewuqmhPnw8EpLoemS3MNI";
const passwordHash =
"$argon2id$v=19$m=65536,t=3,p=4$Kips6OxPAl0vmspO9SoKZQ$oX9gLgwHVnnENCqBloyM13ewuqmhPnw8EpLoemS3MNI";
await client.query(
`INSERT INTO users (id, username, email, password_hash, role, height_cm, date_of_birth, gender, timezone, locale, onboarding_completed_at, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW(), NOW())`,
[userId, "demo", "demo@healthlog.app", passwordHash, "ADMIN", 182.0, "1990-05-15", "MALE", "Europe/Berlin", "en"]
[
userId,
"demo",
"demo@healthlog.app",
passwordHash,
"ADMIN",
182.0,
"1990-05-15",
"MALE",
"Europe/Berlin",
"en",
],
);
// ── Measurements (90 days) ────────────────
@@ -122,26 +144,26 @@ async function seed() {
await client.query(
`INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at)
VALUES ($1, $2, 'WEIGHT', $3, 'kg', 'MANUAL', $4, $4, $4)`,
[cuid(), userId, weights[i], date]
[cuid(), userId, weights[i], date],
);
// Blood pressure (daily)
await client.query(
`INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at)
VALUES ($1, $2, 'BLOOD_PRESSURE_SYS', $3, 'mmHg', 'MANUAL', $4, $4, $4)`,
[cuid(), userId, Math.round(sysBP[i]), date]
[cuid(), userId, Math.round(sysBP[i]), date],
);
await client.query(
`INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at)
VALUES ($1, $2, 'BLOOD_PRESSURE_DIA', $3, 'mmHg', 'MANUAL', $4, $4, $4)`,
[cuid(), userId, Math.round(diaBP[i]), date]
[cuid(), userId, Math.round(diaBP[i]), date],
);
// Pulse (daily)
await client.query(
`INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at)
VALUES ($1, $2, 'PULSE', $3, 'bpm', 'MANUAL', $4, $4, $4)`,
[cuid(), userId, Math.round(pulse[i]), date]
[cuid(), userId, Math.round(pulse[i]), date],
);
// Body fat (every 2-3 days)
@@ -149,7 +171,7 @@ async function seed() {
await client.query(
`INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at)
VALUES ($1, $2, 'BODY_FAT', $3, '%', 'MANUAL', $4, $4, $4)`,
[cuid(), userId, bodyFat[i], date]
[cuid(), userId, bodyFat[i], date],
);
}
@@ -157,14 +179,14 @@ async function seed() {
await client.query(
`INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at)
VALUES ($1, $2, 'SLEEP_DURATION', $3, 'hours', 'MANUAL', $4, $4, $4)`,
[cuid(), userId, Math.round(sleep[i] * 10) / 10, date]
[cuid(), userId, Math.round(sleep[i] * 10) / 10, date],
);
// Steps (daily)
await client.query(
`INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at)
VALUES ($1, $2, 'ACTIVITY_STEPS', $3, 'steps', 'MANUAL', $4, $4, $4)`,
[cuid(), userId, Math.round(steps[i]), date]
[cuid(), userId, Math.round(steps[i]), date],
);
}
@@ -176,13 +198,13 @@ async function seed() {
await client.query(
`INSERT INTO medications (id, user_id, name, dose, active, notifications_enabled, created_at, updated_at)
VALUES ($1, $2, 'Ramipril', '5mg', true, true, $3, $3)`,
[med1Id, userId, daysAgo(120)]
[med1Id, userId, daysAgo(120)],
);
const sched1Id = cuid();
await client.query(
`INSERT INTO medication_schedules (id, medication_id, window_start, window_end, label)
VALUES ($1, $2, '08:00', '10:00', 'Morning')`,
[sched1Id, med1Id]
[sched1Id, med1Id],
);
// Medication 2: Vitamin D3
@@ -190,13 +212,13 @@ async function seed() {
await client.query(
`INSERT INTO medications (id, user_id, name, dose, active, notifications_enabled, created_at, updated_at)
VALUES ($1, $2, 'Vitamin D3', '2000 IE', true, true, $3, $3)`,
[med2Id, userId, daysAgo(90)]
[med2Id, userId, daysAgo(90)],
);
const sched2Id = cuid();
await client.query(
`INSERT INTO medication_schedules (id, medication_id, window_start, window_end, label)
VALUES ($1, $2, '08:00', '10:00', 'Morning')`,
[sched2Id, med2Id]
[sched2Id, med2Id],
);
// Medication 3: Magnesium (evening)
@@ -204,13 +226,13 @@ async function seed() {
await client.query(
`INSERT INTO medications (id, user_id, name, dose, active, notifications_enabled, created_at, updated_at)
VALUES ($1, $2, 'Magnesium', '400mg', true, true, $3, $3)`,
[med3Id, userId, daysAgo(60)]
[med3Id, userId, daysAgo(60)],
);
const sched3Id = cuid();
await client.query(
`INSERT INTO medication_schedules (id, medication_id, window_start, window_end, label)
VALUES ($1, $2, '20:00', '22:00', 'Evening')`,
[sched3Id, med3Id]
[sched3Id, med3Id],
);
// ── Medication Intake Events ──────────────
@@ -227,13 +249,13 @@ async function seed() {
await client.query(
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at)
VALUES ($1, $2, $3, $4, $5, false, 'WEB', $5)`,
[cuid(), userId, med1Id, date, takenTime]
[cuid(), userId, med1Id, date, takenTime],
);
} else {
await client.query(
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at)
VALUES ($1, $2, $3, $4, NULL, true, 'WEB', $4)`,
[cuid(), userId, med1Id, date]
[cuid(), userId, med1Id, date],
);
}
@@ -245,13 +267,13 @@ async function seed() {
await client.query(
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at)
VALUES ($1, $2, $3, $4, $5, false, 'WEB', $5)`,
[cuid(), userId, med2Id, date, takenTime]
[cuid(), userId, med2Id, date, takenTime],
);
} else {
await client.query(
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at)
VALUES ($1, $2, $3, $4, NULL, true, 'WEB', $4)`,
[cuid(), userId, med2Id, date]
[cuid(), userId, med2Id, date],
);
}
@@ -264,13 +286,13 @@ async function seed() {
await client.query(
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at)
VALUES ($1, $2, $3, $4, $5, false, 'WEB', $5)`,
[cuid(), userId, med3Id, date, takenTime]
[cuid(), userId, med3Id, date, takenTime],
);
} else {
await client.query(
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at)
VALUES ($1, $2, $3, $4, NULL, true, 'WEB', $4)`,
[cuid(), userId, med3Id, date]
[cuid(), userId, med3Id, date],
);
}
}
@@ -305,7 +327,15 @@ async function seed() {
await client.query(
`INSERT INTO mood_entries (id, user_id, date, mood, score, tags, source, mood_logged_at, synced_at, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, 'WEB', $7, $7, $7, $7)`,
[cuid(), userId, formatDate(date), mood, rawScore, JSON.stringify(tags), loggedAt]
[
cuid(),
userId,
formatDate(date),
mood,
rawScore,
JSON.stringify(tags),
loggedAt,
],
);
}
@@ -332,7 +362,7 @@ async function seed() {
await client.query(
`INSERT INTO user_achievements (id, user_id, achievement_id, unlocked_at, created_at)
VALUES ($1, $2, $3, $4, $4)`,
[cuid(), userId, ach.id, unlocked]
[cuid(), userId, ach.id, unlocked],
);
}
@@ -341,7 +371,7 @@ async function seed() {
await client.query(
`INSERT INTO app_settings (id, registration_enabled, default_locale)
VALUES ('singleton', false, 'en')
ON CONFLICT (id) DO UPDATE SET registration_enabled = false, default_locale = 'en'`
ON CONFLICT (id) DO UPDATE SET registration_enabled = false, default_locale = 'en'`,
);
// ── Audit Log (some login entries) ──────
@@ -351,7 +381,7 @@ async function seed() {
await client.query(
`INSERT INTO audit_logs (id, user_id, action, details, ip_address, created_at)
VALUES ($1, $2, 'auth.login', '{"method":"password"}', '203.0.113.42', $3)`,
[cuid(), userId, loginDate]
[cuid(), userId, loginDate],
);
}
+32 -2639
View File
File diff suppressed because it is too large. Load diff
+36
View File
@@ -0,0 +1,36 @@
/**
* POST /api/admin/backup/test — admin-only smoke test for the off-host
* backup target. Issues a 1-byte PUT + GET roundtrip and returns the
* endpoint, bucket, region, and per-call latency. Credentials are NEVER
* returned; only the endpoint host + bucket name are surfaced.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { apiSuccess, apiError } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import {
runOffhostRoundtripTest,
OffhostBackupNotConfiguredError,
} from "@/lib/jobs/offhost-backup";
export const POST = apiHandler(async (request: NextRequest) => {
void request;
await requireAdmin();
try {
const report = await runOffhostRoundtripTest();
annotate({
action: { name: "admin.backup.test" },
meta: {
ok: report.ok,
put_latency_ms: report.putLatencyMs,
get_latency_ms: report.getLatencyMs,
},
});
return apiSuccess(report);
} catch (err) {
if (err instanceof OffhostBackupNotConfiguredError) {
return apiError(err.message, 400);
}
throw err;
}
});
+11 -2
View File
@@ -37,13 +37,23 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
return apiError("Confirmation missing", 422);
}
// Audit-log the *intent* before the transaction begins so the trail
// describing "an admin is about to wipe data" survives even if the
// operation crashes midway. AuditLog rows are intentionally NOT
// deleted as part of the wipe — the entire point of an audit trail
// is to outlive the data it documents.
await auditLog("admin.data.clear.start", {
userId: user.id,
ipAddress: getClientIp(request),
details: { confirm: "DELETE ALL" },
});
const result = await prisma.$transaction(async (tx) => {
const measurements = await tx.measurement.deleteMany({});
const intakeEvents = await tx.medicationIntakeEvent.deleteMany({});
const medications = await tx.medication.deleteMany({});
const apiTokens = await tx.apiToken.deleteMany({});
const withingsConnections = await tx.withingsConnection.deleteMany({});
const auditLogs = await tx.auditLog.deleteMany({});
const authChallenges = await tx.authChallenge.deleteMany({});
await tx.user.updateMany({
@@ -69,7 +79,6 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
medications: medications.count,
apiTokens: apiTokens.count,
withingsConnections: withingsConnections.count,
auditLogs: auditLogs.count,
authChallenges: authChallenges.count,
};
});
@@ -37,10 +37,7 @@ export const POST = apiHandler(
if (!feedback) return apiError("Feedback not found", 404);
if (feedback.gitHubIssueUrl) {
return apiError(
`Already published: ${feedback.gitHubIssueUrl}`,
409,
);
return apiError(`Already published: ${feedback.gitHubIssueUrl}`, 409);
}
// Atomic claim to prevent concurrent admin clicks from creating two
+6 -1
View File
@@ -3,7 +3,12 @@
* DELETE /api/admin/feedback/[id] — archive (soft: sets status to ARCHIVED).
*/
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { apiError, apiSuccess, safeJson, getClientIp } from "@/lib/api-response";
import {
apiError,
apiSuccess,
safeJson,
getClientIp,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
@@ -30,7 +30,14 @@ export const POST = apiHandler(async () => {
});
if (!delivery.ok) {
getEvent()?.addWarning("Glitchtip test event rejected: " + delivery.method + " " + delivery.status + " " + delivery.details);
getEvent()?.addWarning(
"Glitchtip test event rejected: " +
delivery.method +
" " +
delivery.status +
" " +
delivery.details,
);
return apiError(
`Glitchtip test event rejected (HTTP ${delivery.status ?? 502})`,
502,
@@ -54,10 +54,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("Umami is disabled", 422);
}
if (!settings.umamiScriptUrl || !settings.umamiWebsiteId) {
return apiError(
"Umami script URL and website ID must be configured",
422,
);
return apiError("Umami script URL and website ID must be configured", 422);
}
const targetUrls = resolveUmamiSendUrls(settings.umamiScriptUrl);
@@ -116,8 +113,5 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
getEvent()?.addWarning("Umami test event rejected: " + lastStatus);
return apiError(
`Umami test event rejected (HTTP ${lastStatus})`,
502,
);
return apiError(`Umami test event rejected (HTTP ${lastStatus})`, 502);
});
@@ -158,7 +158,9 @@ export const POST = apiHandler(async () => {
notificationSent = true;
notificationsSent++;
} catch (err) {
getEvent()?.addWarning("Notification failed for " + med.name + ": " + err);
getEvent()?.addWarning(
"Notification failed for " + med.name + ": " + err,
);
}
}
@@ -0,0 +1,437 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
appSettings: {
findUnique: vi.fn(),
upsert: vi.fn(),
},
},
}));
vi.mock("@/lib/api-handler", async () => {
const actual =
await vi.importActual<typeof import("@/lib/api-handler")>(
"@/lib/api-handler",
);
return {
...actual,
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
h: T,
): T => h,
requireAdmin: vi.fn(),
};
});
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: vi.fn(() => null),
}));
vi.mock("@/lib/crypto", () => ({
encrypt: vi.fn((value: string) => `enc(${value})`),
decrypt: vi.fn((value: string) => value.replace(/^enc\(|\)$/g, "")),
}));
import { GET, PUT } from "../route";
import { prisma } from "@/lib/db";
import { requireAdmin, HttpError } from "@/lib/api-handler";
import { encrypt } from "@/lib/crypto";
import { auditLog } from "@/lib/auth/audit";
const ADMIN_CTX = {
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: "admin-1",
username: "admin",
role: "ADMIN",
} as never,
};
function jsonReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/admin/settings", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
});
describe("GET /api/admin/settings", () => {
it("rejects with 401 when no session", async () => {
vi.mocked(requireAdmin).mockRejectedValue(
new HttpError(401, "Not authenticated"),
);
await expect(GET()).rejects.toThrow("Not authenticated");
});
it("rejects with 403 for non-admin", async () => {
vi.mocked(requireAdmin).mockRejectedValue(
new HttpError(403, "Admin access required"),
);
await expect(GET()).rejects.toThrow("Admin access required");
});
it("reads the singleton row (regression: not the default-typo)", async () => {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null);
await GET();
expect(prisma.appSettings.findUnique).toHaveBeenCalledTimes(1);
const args = vi.mocked(prisma.appSettings.findUnique).mock.calls[0]?.[0];
// Must be "singleton" — the test-endpoints PR previously typo'd this as "default".
expect(args).toEqual({ where: { id: "singleton" } });
});
it("returns sane defaults when no settings row exists", async () => {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null);
const res = await GET();
expect(res.status).toBe(200);
const body = (await res.json()) as { data: Record<string, unknown> };
expect(body.data.registrationEnabled).toBe(true);
expect(body.data.defaultLocale).toBe("de");
expect(body.data.webPushVapidConfigured).toBe(false);
expect(body.data.bugReportConfigured).toBe(false);
expect(body.data.glitchtipEnabled).toBe(false);
});
it("never leaks the encrypted private key fields", async () => {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
id: "singleton",
registrationEnabled: false,
defaultLocale: "en",
telegramGlobal: true,
ntfyGlobal: true,
webPushGlobal: true,
webPushVapidPublicKey: "pub",
webPushVapidSubject: "mailto:a@b.com",
webPushVapidPrivateKeyEncrypted: "secret-blob",
apiGlobal: true,
umamiEnabled: false,
umamiScriptUrl: null,
umamiWebsiteId: null,
glitchtipEnabled: false,
glitchtipDsn: null,
glitchtipEnvironment: "production",
githubIssueRepo: "owner/repo",
githubIssueTokenEncrypted: "token-blob",
reminderLateMinutes: 120,
reminderMissedMinutes: 240,
moodLogGlobal: true,
} as never);
const res = await GET();
const body = (await res.json()) as { data: Record<string, unknown> };
// The two configured-flags must be true …
expect(body.data.webPushVapidConfigured).toBe(true);
expect(body.data.bugReportConfigured).toBe(true);
// … but raw encrypted blobs must never reach the client.
const serialized = JSON.stringify(body.data);
expect(serialized).not.toContain("secret-blob");
expect(serialized).not.toContain("token-blob");
expect(body.data).not.toHaveProperty("webPushVapidPrivateKeyEncrypted");
expect(body.data).not.toHaveProperty("githubIssueTokenEncrypted");
});
});
describe("PUT /api/admin/settings", () => {
it("rejects with 403 for non-admin", async () => {
vi.mocked(requireAdmin).mockRejectedValue(
new HttpError(403, "Admin access required"),
);
await expect(PUT(jsonReq({}))).rejects.toThrow("Admin access required");
});
it("returns 422 for an unknown field (strict schema)", async () => {
const res = await PUT(jsonReq({ totallyBogus: true }));
expect(res.status).toBe(422);
expect(prisma.appSettings.upsert).not.toHaveBeenCalled();
});
it("returns 422 for invalid Glitchtip DSN URL", async () => {
const res = await PUT(jsonReq({ glitchtipDsn: "not a url" }));
expect(res.status).toBe(422);
});
it("returns 422 when the body has no actionable fields", async () => {
const res = await PUT(jsonReq({}));
expect(res.status).toBe(422);
const body = (await res.json()) as { error: string };
expect(body.error).toBe("No valid fields");
});
it("encrypts the VAPID private key before persisting", async () => {
vi.mocked(prisma.appSettings.upsert).mockResolvedValue({
id: "singleton",
registrationEnabled: true,
defaultLocale: "en",
telegramGlobal: true,
ntfyGlobal: true,
webPushGlobal: true,
webPushVapidPublicKey: null,
webPushVapidSubject: null,
webPushVapidPrivateKeyEncrypted: "enc(plain-private)",
apiGlobal: true,
umamiEnabled: false,
umamiScriptUrl: null,
umamiWebsiteId: null,
glitchtipEnabled: false,
glitchtipDsn: null,
glitchtipEnvironment: "production",
githubIssueRepo: null,
githubIssueTokenEncrypted: null,
reminderLateMinutes: 120,
reminderMissedMinutes: 240,
moodLogGlobal: true,
} as never);
const res = await PUT(jsonReq({ webPushVapidPrivateKey: "plain-private" }));
expect(res.status).toBe(200);
expect(encrypt).toHaveBeenCalledWith("plain-private");
const upsertArgs = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0];
expect(upsertArgs?.where).toEqual({ id: "singleton" });
expect(upsertArgs?.update).toEqual({
webPushVapidPrivateKeyEncrypted: "enc(plain-private)",
});
// Audit detail must NOT carry the plaintext private key.
expect(auditLog).toHaveBeenCalled();
const auditDetails = vi.mocked(auditLog).mock.calls[0]?.[1]
?.details as Record<string, unknown>;
expect(JSON.stringify(auditDetails)).not.toContain("plain-private");
expect(auditDetails.webPushVapidPrivateKeyUpdated).toBe(true);
});
it("clears the encrypted private key when clear flag is set", async () => {
vi.mocked(prisma.appSettings.upsert).mockResolvedValue({
id: "singleton",
registrationEnabled: true,
defaultLocale: "en",
telegramGlobal: true,
ntfyGlobal: true,
webPushGlobal: true,
webPushVapidPublicKey: null,
webPushVapidSubject: null,
webPushVapidPrivateKeyEncrypted: null,
apiGlobal: true,
umamiEnabled: false,
umamiScriptUrl: null,
umamiWebsiteId: null,
glitchtipEnabled: false,
glitchtipDsn: null,
glitchtipEnvironment: null,
githubIssueRepo: null,
githubIssueTokenEncrypted: null,
reminderLateMinutes: 120,
reminderMissedMinutes: 240,
moodLogGlobal: true,
} as never);
const res = await PUT(jsonReq({ clearWebPushVapidPrivateKey: true }));
expect(res.status).toBe(200);
const args = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0];
expect(args?.update).toEqual({ webPushVapidPrivateKeyEncrypted: null });
});
it("normalises a Umami URL with no path to /script.js", async () => {
vi.mocked(prisma.appSettings.upsert).mockResolvedValue({
id: "singleton",
registrationEnabled: true,
defaultLocale: "en",
telegramGlobal: true,
ntfyGlobal: true,
webPushGlobal: true,
webPushVapidPublicKey: null,
webPushVapidSubject: null,
webPushVapidPrivateKeyEncrypted: null,
apiGlobal: true,
umamiEnabled: false,
umamiScriptUrl: "https://analytics.example.com/script.js",
umamiWebsiteId: null,
glitchtipEnabled: false,
glitchtipDsn: null,
glitchtipEnvironment: null,
githubIssueRepo: null,
githubIssueTokenEncrypted: null,
reminderLateMinutes: 120,
reminderMissedMinutes: 240,
moodLogGlobal: true,
} as never);
const res = await PUT(
jsonReq({ umamiScriptUrl: "https://analytics.example.com" }),
);
expect(res.status).toBe(200);
const args = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0];
expect(args?.update).toEqual({
umamiScriptUrl: "https://analytics.example.com/script.js",
});
});
it("normalises and persists every Zod-allowed field in a single PUT", async () => {
vi.mocked(prisma.appSettings.upsert).mockResolvedValue({
id: "singleton",
registrationEnabled: false,
defaultLocale: "en",
telegramGlobal: false,
ntfyGlobal: false,
webPushGlobal: false,
webPushVapidPublicKey: "vapid-pub",
webPushVapidSubject: "mailto:ops@healthlog.dev",
webPushVapidPrivateKeyEncrypted: "enc(secret)",
apiGlobal: false,
umamiEnabled: true,
umamiScriptUrl: "https://analytics.example.com/custom.js",
umamiWebsiteId: "site-uuid",
glitchtipEnabled: true,
glitchtipDsn: "https://abc@glitchtip.example.com/1",
glitchtipEnvironment: "staging",
githubIssueRepo: "owner/repo",
githubIssueTokenEncrypted: "enc(gh-token)",
reminderLateMinutes: 30,
reminderMissedMinutes: 90,
moodLogGlobal: false,
} as never);
const res = await PUT(
jsonReq({
registrationEnabled: false,
defaultLocale: "en",
telegramGlobal: false,
ntfyGlobal: false,
webPushGlobal: false,
apiGlobal: false,
umamiEnabled: true,
moodLogGlobal: false,
glitchtipEnabled: true,
webPushVapidPublicKey: "vapid-pub",
webPushVapidSubject: "mailto:ops@healthlog.dev",
webPushVapidPrivateKey: "secret",
umamiScriptUrl: "https://analytics.example.com/custom.js",
umamiWebsiteId: "site-uuid",
glitchtipDsn: "https://abc@glitchtip.example.com/1",
glitchtipEnvironment: "staging",
bugReportRepo: "owner/repo",
bugReportToken: "gh-token",
reminderLateMinutes: 30,
reminderMissedMinutes: 90,
}),
);
expect(res.status).toBe(200);
const upsert = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0];
expect(upsert?.update).toMatchObject({
registrationEnabled: false,
defaultLocale: "en",
telegramGlobal: false,
apiGlobal: false,
glitchtipEnabled: true,
umamiEnabled: true,
umamiScriptUrl: "https://analytics.example.com/custom.js",
umamiWebsiteId: "site-uuid",
glitchtipDsn: "https://abc@glitchtip.example.com/1",
glitchtipEnvironment: "staging",
githubIssueRepo: "owner/repo",
githubIssueTokenEncrypted: "enc(gh-token)",
webPushVapidPublicKey: "vapid-pub",
webPushVapidSubject: "mailto:ops@healthlog.dev",
webPushVapidPrivateKeyEncrypted: "enc(secret)",
reminderLateMinutes: 30,
reminderMissedMinutes: 90,
});
expect(encrypt).toHaveBeenCalledWith("secret");
expect(encrypt).toHaveBeenCalledWith("gh-token");
});
it("clears nullable string fields when an empty string is submitted", async () => {
vi.mocked(prisma.appSettings.upsert).mockResolvedValue({
id: "singleton",
registrationEnabled: true,
defaultLocale: "en",
telegramGlobal: true,
ntfyGlobal: true,
webPushGlobal: true,
webPushVapidPublicKey: null,
webPushVapidSubject: null,
webPushVapidPrivateKeyEncrypted: null,
apiGlobal: true,
umamiEnabled: false,
umamiScriptUrl: null,
umamiWebsiteId: null,
glitchtipEnabled: false,
glitchtipDsn: null,
glitchtipEnvironment: null,
githubIssueRepo: null,
githubIssueTokenEncrypted: null,
reminderLateMinutes: 120,
reminderMissedMinutes: 240,
moodLogGlobal: true,
} as never);
await PUT(
jsonReq({
webPushVapidPublicKey: "",
webPushVapidSubject: "",
umamiScriptUrl: "",
umamiWebsiteId: "",
glitchtipDsn: "",
glitchtipEnvironment: "",
bugReportRepo: "",
}),
);
const upsert = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0];
expect(upsert?.update).toEqual({
webPushVapidPublicKey: null,
webPushVapidSubject: null,
umamiScriptUrl: null,
umamiWebsiteId: null,
glitchtipDsn: null,
glitchtipEnvironment: null,
githubIssueRepo: null,
});
});
it("clears the encrypted bug-report token when clear flag is set", async () => {
vi.mocked(prisma.appSettings.upsert).mockResolvedValue({
id: "singleton",
registrationEnabled: true,
defaultLocale: "en",
telegramGlobal: true,
ntfyGlobal: true,
webPushGlobal: true,
webPushVapidPublicKey: null,
webPushVapidSubject: null,
webPushVapidPrivateKeyEncrypted: null,
apiGlobal: true,
umamiEnabled: false,
umamiScriptUrl: null,
umamiWebsiteId: null,
glitchtipEnabled: false,
glitchtipDsn: null,
glitchtipEnvironment: null,
githubIssueRepo: null,
githubIssueTokenEncrypted: null,
reminderLateMinutes: 120,
reminderMissedMinutes: 240,
moodLogGlobal: true,
} as never);
await PUT(jsonReq({ clearBugReportToken: true }));
const upsert = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0];
expect(upsert?.update).toEqual({ githubIssueTokenEncrypted: null });
});
it("returns 415 when content-type is not JSON", async () => {
const r = new NextRequest("http://localhost/api/admin/settings", {
method: "PUT",
headers: { "content-type": "text/plain" },
body: "{}",
});
const res = await PUT(r);
expect(res.status).toBe(415);
});
});
+14 -7
View File
@@ -1,7 +1,12 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
import {
apiSuccess,
apiError,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { encrypt } from "@/lib/crypto";
import { adminSettingsSchema } from "@/lib/validations/admin";
@@ -27,8 +32,8 @@ export const GET = apiHandler(async () => {
webPushVapidSubject: settings?.webPushVapidSubject ?? null,
webPushVapidConfigured: Boolean(
settings?.webPushVapidPublicKey &&
settings?.webPushVapidPrivateKeyEncrypted &&
settings?.webPushVapidSubject,
settings?.webPushVapidPrivateKeyEncrypted &&
settings?.webPushVapidSubject,
),
apiGlobal: settings?.apiGlobal ?? true,
umamiEnabled: settings?.umamiEnabled ?? false,
@@ -41,6 +46,7 @@ export const GET = apiHandler(async () => {
bugReportConfigured: Boolean(
settings?.githubIssueRepo && settings?.githubIssueTokenEncrypted,
),
bugReportEnabled: settings?.bugReportEnabled ?? true,
reminderLateMinutes: settings?.reminderLateMinutes ?? 120,
reminderMissedMinutes: settings?.reminderMissedMinutes ?? 240,
moodLogGlobal: settings?.moodLogGlobal ?? true,
@@ -73,6 +79,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
"umamiEnabled",
"glitchtipEnabled",
"moodLogGlobal",
"bugReportEnabled",
] as const;
for (const field of booleanFields) {
if (data[field] !== undefined) {
@@ -205,8 +212,8 @@ export const PUT = apiHandler(async (request: NextRequest) => {
webPushVapidSubject: settings.webPushVapidSubject,
webPushVapidConfigured: Boolean(
settings.webPushVapidPublicKey &&
settings.webPushVapidPrivateKeyEncrypted &&
settings.webPushVapidSubject,
settings.webPushVapidPrivateKeyEncrypted &&
settings.webPushVapidSubject,
),
apiGlobal: settings.apiGlobal,
umamiEnabled: settings.umamiEnabled,
@@ -214,12 +221,12 @@ export const PUT = apiHandler(async (request: NextRequest) => {
umamiWebsiteId: settings.umamiWebsiteId,
glitchtipEnabled: settings.glitchtipEnabled,
glitchtipDsn: settings.glitchtipDsn,
glitchtipEnvironment:
settings.glitchtipEnvironment ?? "production",
glitchtipEnvironment: settings.glitchtipEnvironment ?? "production",
bugReportRepo: settings.githubIssueRepo,
bugReportConfigured: Boolean(
settings.githubIssueRepo && settings.githubIssueTokenEncrypted,
),
bugReportEnabled: settings.bugReportEnabled,
reminderLateMinutes: settings.reminderLateMinutes,
reminderMissedMinutes: settings.reminderMissedMinutes,
moodLogGlobal: settings.moodLogGlobal,
@@ -0,0 +1,191 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
user: { count: vi.fn() },
withingsConnection: { count: vi.fn() },
notificationChannel: { count: vi.fn() },
pushSubscription: { count: vi.fn() },
appSettings: { findUnique: vi.fn() },
auditLog: { findFirst: vi.fn(), count: vi.fn() },
dataBackup: { findFirst: vi.fn(), findMany: vi.fn() },
},
}));
vi.mock("@/lib/api-handler", async () => {
const actual =
await vi.importActual<typeof import("@/lib/api-handler")>(
"@/lib/api-handler",
);
return {
...actual,
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
h: T,
): T => h,
requireAdmin: vi.fn().mockResolvedValue(undefined),
};
});
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
vi.mock("@/lib/jobs/worker-status", () => ({
getWorkerStatus: vi.fn(() => ({
running: true,
startedAt: new Date(Date.now() - 3_600_000).toISOString(),
lastHeartbeat: null,
lastReminderCheck: null,
lastWithingsSync: null,
lastInsightsRun: null,
jobsProcessed: 0,
errors: 0,
})),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
const mocks = prisma as unknown as {
user: { count: ReturnType<typeof vi.fn> };
withingsConnection: { count: ReturnType<typeof vi.fn> };
notificationChannel: { count: ReturnType<typeof vi.fn> };
pushSubscription: { count: ReturnType<typeof vi.fn> };
appSettings: { findUnique: ReturnType<typeof vi.fn> };
auditLog: {
findFirst: ReturnType<typeof vi.fn>;
count: ReturnType<typeof vi.fn>;
};
dataBackup: {
findFirst: ReturnType<typeof vi.fn>;
findMany: ReturnType<typeof vi.fn>;
};
};
describe("GET /api/admin/status-overview", () => {
beforeEach(() => {
vi.clearAllMocks();
// default returns
mocks.user.count.mockResolvedValue(0);
mocks.withingsConnection.count.mockResolvedValue(0);
mocks.notificationChannel.count.mockResolvedValue(0);
mocks.pushSubscription.count.mockResolvedValue(0);
mocks.appSettings.findUnique.mockResolvedValue(null);
mocks.auditLog.findFirst.mockResolvedValue(null);
mocks.auditLog.count.mockResolvedValue(0);
mocks.dataBackup.findFirst.mockResolvedValue(null);
mocks.dataBackup.findMany.mockResolvedValue([]);
});
it("returns the 6-section overview shape", async () => {
const res = await GET();
const body = (await res.json()) as { data: Record<string, unknown> };
expect(body.data).toHaveProperty("users");
expect(body.data).toHaveProperty("integrations");
expect(body.data).toHaveProperty("monitoring");
expect(body.data).toHaveProperty("backups");
expect(body.data).toHaveProperty("maintenance");
expect(body.data).toHaveProperty("auditLog");
});
it("batches DB calls in a single Promise.allSettled (no N+1)", async () => {
await GET();
// 3 user.count + 1 withings + 1 ntfy notifications + 1 webPush + 3
// moodLog/telegram done via user.count → expect user.count called 4 times
// (total, admins, newThisWeek, moodLog, telegram)
expect(mocks.user.count).toHaveBeenCalledTimes(5);
expect(mocks.withingsConnection.count).toHaveBeenCalledTimes(1);
expect(mocks.notificationChannel.count).toHaveBeenCalledTimes(1);
expect(mocks.pushSubscription.count).toHaveBeenCalledTimes(1);
// The route does not loop — every aggregate is a single query.
expect(mocks.dataBackup.findMany).toHaveBeenCalledTimes(1);
});
it("P20: a single failed probe still returns the rest of the grid", async () => {
// Simulate a transient DB failure on the withings count probe only.
// Without Promise.allSettled the whole route would 500 and the entire
// admin overview grid would go blank — with allSettled the failure
// is isolated to its card (severity → "alert", value → 0) and the
// other 5 cards still render.
mocks.withingsConnection.count.mockRejectedValueOnce(
new Error("simulated db timeout"),
);
const res = await GET();
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
users: { severity: string };
integrations: { severity: string; withings: number };
backups: { severity: string };
};
};
expect(body.data.integrations.severity).toBe("alert");
expect(body.data.integrations.withings).toBe(0);
// Untouched cards keep their normal severity.
expect(body.data.users.severity).toBe("good");
});
it("flags backups as alert when none exist", async () => {
mocks.dataBackup.findFirst.mockResolvedValue(null);
const res = await GET();
const body = (await res.json()) as {
data: { backups: { severity: string } };
};
expect(body.data.backups.severity).toBe("alert");
});
it("flags backups as good when fresh (<8d)", async () => {
mocks.dataBackup.findFirst.mockResolvedValue({
createdAt: new Date(Date.now() - 24 * 60 * 60 * 1000),
});
const res = await GET();
const body = (await res.json()) as {
data: { backups: { severity: string } };
};
expect(body.data.backups.severity).toBe("good");
});
it("flags monitoring as alert on a recent error (<24h)", async () => {
mocks.auditLog.findFirst.mockImplementation(
({ where }: { where: { action: unknown } }) => {
const action = where.action as { startsWith?: string } | string;
if (
typeof action === "object" &&
action.startsWith === "system.error"
) {
return Promise.resolve({ createdAt: new Date(Date.now() - 60_000) });
}
return Promise.resolve(null);
},
);
mocks.appSettings.findUnique.mockResolvedValue({
glitchtipEnabled: true,
umamiEnabled: true,
});
const res = await GET();
const body = (await res.json()) as {
data: { monitoring: { severity: string } };
};
expect(body.data.monitoring.severity).toBe("alert");
});
it("flags maintenance as alert when worker is stopped", async () => {
const wsModule = await import("@/lib/jobs/worker-status");
vi.mocked(wsModule.getWorkerStatus).mockReturnValue({
running: false,
startedAt: null,
lastHeartbeat: null,
lastReminderCheck: null,
lastWithingsSync: null,
lastInsightsRun: null,
jobsProcessed: 0,
errors: 0,
});
const res = await GET();
const body = (await res.json()) as {
data: { maintenance: { severity: string; workerRunning: boolean } };
};
expect(body.data.maintenance.severity).toBe("alert");
expect(body.data.maintenance.workerRunning).toBe(false);
});
});
+318
View File
@@ -0,0 +1,318 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { getWorkerStatus } from "@/lib/jobs/worker-status";
/**
* Severity tokens map onto §2.6 status taxonomy of `docs/ui-guidelines.md`.
* Returned as plain strings so the client renders dots + labels — not RAW
* colours — and never leans on color alone for state.
*/
export type StatusSeverity = "good" | "info" | "caution" | "alert" | "pending";
export interface StatusOverview {
users: {
severity: StatusSeverity;
total: number;
admins: number;
newThisWeek: number;
};
integrations: {
severity: StatusSeverity;
withings: number;
moodLog: number;
telegram: number;
ntfy: number;
webPush: number;
};
monitoring: {
severity: StatusSeverity;
glitchtipEnabled: boolean;
umamiEnabled: boolean;
wideEventsEnabled: boolean;
lastErrorAt: string | null;
};
backups: {
severity: StatusSeverity;
lastBackupAt: string | null;
backedUpUsers: number;
retentionDays: number;
};
maintenance: {
severity: StatusSeverity;
workerRunning: boolean;
workerUptimeSeconds: number | null;
lastIdempotencyCleanup: string | null;
lastAuditLogCleanup: string | null;
};
auditLog: {
severity: StatusSeverity;
eventsLast30d: number;
lastLoginAt: string | null;
};
}
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
function severityForBackup(lastBackupAt: Date | null): StatusSeverity {
if (!lastBackupAt) return "alert";
const ageMs = Date.now() - lastBackupAt.getTime();
if (ageMs > 14 * ONE_DAY_MS) return "alert";
if (ageMs > 8 * ONE_DAY_MS) return "caution";
return "good";
}
function severityForMaintenance(workerRunning: boolean): StatusSeverity {
return workerRunning ? "good" : "alert";
}
function severityForMonitoring(
glitchtipEnabled: boolean,
umamiEnabled: boolean,
recentErrorAt: Date | null,
): StatusSeverity {
if (recentErrorAt && Date.now() - recentErrorAt.getTime() < ONE_DAY_MS) {
return "alert";
}
if (glitchtipEnabled && umamiEnabled) return "good";
if (glitchtipEnabled || umamiEnabled) return "caution";
return "info";
}
function severityForIntegrations(totalConfigured: number): StatusSeverity {
if (totalConfigured === 0) return "info";
return "good";
}
export const GET = apiHandler(async () => {
await requireAdmin();
annotate({ action: { name: "admin.status-overview" } });
const now = new Date();
const sevenDaysAgo = new Date(now.getTime() - 7 * ONE_DAY_MS);
const thirtyDaysAgo = new Date(now.getTime() - 30 * ONE_DAY_MS);
// P20: switch from Promise.all to Promise.allSettled so a single
// failed probe (e.g. a missing index, a transient timeout, an
// unavailable model) doesn't blank the whole admin overview grid.
// Each probe is labelled so we can map a rejection back to the
// overview field it was meant to populate, then surface the failed
// labels in a Wide Event annotation for ops triage.
const probes = [
["userTotal", () => prisma.user.count()],
["userAdmins", () => prisma.user.count({ where: { role: "ADMIN" } })],
[
"userNewThisWeek",
() => prisma.user.count({ where: { createdAt: { gte: sevenDaysAgo } } }),
],
["withingsCount", () => prisma.withingsConnection.count()],
[
"moodLogCount",
() => prisma.user.count({ where: { moodLogEnabled: true } }),
],
[
"telegramCount",
() => prisma.user.count({ where: { telegramEnabled: true } }),
],
[
"ntfyCount",
() =>
prisma.notificationChannel.count({
where: { type: "NTFY", enabled: true },
}),
],
["webPushCount", () => prisma.pushSubscription.count()],
[
"appSettings",
() => prisma.appSettings.findUnique({ where: { id: "singleton" } }),
],
[
"lastErrorEntry",
() =>
prisma.auditLog.findFirst({
where: { action: { startsWith: "system.error" } },
orderBy: { createdAt: "desc" },
select: { createdAt: true },
}),
],
[
"latestBackup",
() =>
prisma.dataBackup.findFirst({
orderBy: { createdAt: "desc" },
select: { createdAt: true },
}),
],
[
"backedUpUsers",
() =>
prisma.dataBackup
.findMany({ select: { userId: true }, distinct: ["userId"] })
.then((rows) => rows.length),
],
[
"last30dEvents",
() =>
prisma.auditLog.count({ where: { createdAt: { gte: thirtyDaysAgo } } }),
],
[
"lastLoginEntry",
() =>
prisma.auditLog.findFirst({
where: { action: "auth.login" },
orderBy: { createdAt: "desc" },
select: { createdAt: true },
}),
],
[
"lastIdempotencyCleanup",
() =>
prisma.auditLog.findFirst({
where: { action: "system.cleanup.idempotency" },
orderBy: { createdAt: "desc" },
select: { createdAt: true },
}),
],
[
"lastAuditLogCleanup",
() =>
prisma.auditLog.findFirst({
where: { action: "system.cleanup.audit-log" },
orderBy: { createdAt: "desc" },
select: { createdAt: true },
}),
],
] as const;
const settled = await Promise.allSettled(probes.map(([, run]) => run()));
const failed: string[] = [];
const results: Record<string, unknown> = {};
settled.forEach((r, i) => {
const label = probes[i][0];
if (r.status === "fulfilled") {
results[label] = r.value;
} else {
failed.push(label);
results[label] = null;
}
});
// Pull each probe out in the same shape the overview expects. `?? 0`
// turns a null (failed-count probe) into a renderable zero so the
// grid tiles still draw something instead of going blank.
const userTotal = (results.userTotal as number | null) ?? 0;
const userAdmins = (results.userAdmins as number | null) ?? 0;
const userNewThisWeek = (results.userNewThisWeek as number | null) ?? 0;
const withingsCount = (results.withingsCount as number | null) ?? 0;
const moodLogCount = (results.moodLogCount as number | null) ?? 0;
const telegramCount = (results.telegramCount as number | null) ?? 0;
const ntfyCount = (results.ntfyCount as number | null) ?? 0;
const webPushCount = (results.webPushCount as number | null) ?? 0;
const appSettings = results.appSettings as {
glitchtipEnabled: boolean;
umamiEnabled: boolean;
} | null;
const lastErrorEntry = results.lastErrorEntry as { createdAt: Date } | null;
const latestBackup = results.latestBackup as { createdAt: Date } | null;
const backedUpUsers = (results.backedUpUsers as number | null) ?? 0;
const last30dEvents = (results.last30dEvents as number | null) ?? 0;
const lastLoginEntry = results.lastLoginEntry as { createdAt: Date } | null;
const lastIdempotencyCleanup = results.lastIdempotencyCleanup as {
createdAt: Date;
} | null;
const lastAuditLogCleanup = results.lastAuditLogCleanup as {
createdAt: Date;
} | null;
if (failed.length > 0) {
annotate({ meta: { statusOverviewProbeFailures: failed } });
}
const worker = getWorkerStatus();
const workerUptimeSeconds = worker.startedAt
? Math.max(
0,
Math.floor(
(now.getTime() - new Date(worker.startedAt).getTime()) / 1000,
),
)
: null;
const integrationsTotal =
withingsCount + moodLogCount + telegramCount + ntfyCount + webPushCount;
// Map probe-name → which overview card depends on it. If any probe
// for a card failed, force the card's severity to "alert" so the UI
// surfaces the failure instead of showing zeros as if they were real.
const failedSet = new Set(failed);
const anyFailed = (...labels: string[]) =>
labels.some((l) => failedSet.has(l));
const overview: StatusOverview = {
users: {
severity: anyFailed("userTotal", "userAdmins", "userNewThisWeek")
? "alert"
: "good",
total: userTotal,
admins: userAdmins,
newThisWeek: userNewThisWeek,
},
integrations: {
severity: anyFailed(
"withingsCount",
"moodLogCount",
"telegramCount",
"ntfyCount",
"webPushCount",
)
? "alert"
: severityForIntegrations(integrationsTotal),
withings: withingsCount,
moodLog: moodLogCount,
telegram: telegramCount,
ntfy: ntfyCount,
webPush: webPushCount,
},
monitoring: {
severity: anyFailed("appSettings", "lastErrorEntry")
? "alert"
: severityForMonitoring(
Boolean(appSettings?.glitchtipEnabled),
Boolean(appSettings?.umamiEnabled),
lastErrorEntry?.createdAt ?? null,
),
glitchtipEnabled: Boolean(appSettings?.glitchtipEnabled),
umamiEnabled: Boolean(appSettings?.umamiEnabled),
wideEventsEnabled: true,
lastErrorAt: lastErrorEntry?.createdAt?.toISOString() ?? null,
},
backups: {
severity: anyFailed("latestBackup", "backedUpUsers")
? "alert"
: severityForBackup(latestBackup?.createdAt ?? null),
lastBackupAt: latestBackup?.createdAt?.toISOString() ?? null,
backedUpUsers,
retentionDays: 90,
},
maintenance: {
severity: anyFailed("lastIdempotencyCleanup", "lastAuditLogCleanup")
? "alert"
: severityForMaintenance(worker.running),
workerRunning: worker.running,
workerUptimeSeconds,
lastIdempotencyCleanup:
lastIdempotencyCleanup?.createdAt?.toISOString() ?? null,
lastAuditLogCleanup:
lastAuditLogCleanup?.createdAt?.toISOString() ?? null,
},
auditLog: {
severity: anyFailed("last30dEvents", "lastLoginEntry") ? "alert" : "info",
eventsLast30d: last30dEvents,
lastLoginAt: lastLoginEntry?.createdAt?.toISOString() ?? null,
},
};
return apiSuccess(overview);
});
@@ -0,0 +1,172 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
user: { count: vi.fn() },
measurement: { count: vi.fn() },
medication: { count: vi.fn() },
medicationIntakeEvent: { count: vi.fn() },
apiToken: { count: vi.fn() },
session: { count: vi.fn() },
appSettings: { findUnique: vi.fn() },
},
}));
vi.mock("@/lib/api-handler", async () => {
const actual =
await vi.importActual<typeof import("@/lib/api-handler")>(
"@/lib/api-handler",
);
return {
...actual,
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
h: T,
): T => h,
requireAdmin: vi.fn(),
};
});
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: vi.fn(() => null),
}));
vi.mock("@/lib/jobs/worker-status", () => ({
getWorkerStatus: vi.fn(() => ({
running: true,
startedAt: new Date(Date.now() - 60_000).toISOString(),
lastHeartbeat: null,
lastReminderCheck: null,
lastWithingsSync: null,
lastInsightsRun: null,
jobsProcessed: 0,
errors: 0,
})),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { requireAdmin, HttpError } from "@/lib/api-handler";
const ADMIN_CTX = {
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: "admin-1",
username: "admin",
role: "ADMIN",
} as never,
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
vi.mocked(prisma.user.count).mockResolvedValue(0);
vi.mocked(prisma.measurement.count).mockResolvedValue(0);
vi.mocked(prisma.medication.count).mockResolvedValue(0);
vi.mocked(prisma.medicationIntakeEvent.count).mockResolvedValue(0);
vi.mocked(prisma.apiToken.count).mockResolvedValue(0);
vi.mocked(prisma.session.count).mockResolvedValue(0);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null);
});
describe("GET /api/admin/status (integration health summary)", () => {
it("rejects non-admin callers with 403", async () => {
vi.mocked(requireAdmin).mockRejectedValue(
new HttpError(403, "Admin access required"),
);
await expect(GET()).rejects.toThrow("Admin access required");
});
it("returns null integration entries when no app settings exist", async () => {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null);
const res = await GET();
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { integrations: Record<string, unknown> };
};
expect(body.data.integrations).toEqual({
umami: null,
glitchtip: null,
webPush: null,
bugReport: null,
});
});
it("aggregates configured + enabled flags for umami / glitchtip / web-push / bugReport", async () => {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
umamiScriptUrl: "https://x/script.js",
umamiWebsiteId: "site-1",
umamiEnabled: true,
glitchtipDsn: "https://abc@x/123",
glitchtipEnabled: false,
webPushVapidPublicKey: "pub",
webPushVapidPrivateKeyEncrypted: "secret-blob",
webPushVapidSubject: "mailto:a@b.com",
githubIssueRepo: "owner/repo",
githubIssueTokenEncrypted: "token-blob",
} as never);
const res = await GET();
const body = (await res.json()) as {
data: { integrations: Record<string, Record<string, unknown> | null> };
};
expect(body.data.integrations.umami).toEqual({
configured: true,
enabled: true,
});
// glitchtip is configured but the global toggle is off
expect(body.data.integrations.glitchtip).toEqual({
configured: true,
enabled: false,
});
expect(body.data.integrations.webPush).toEqual({ configured: true });
expect(body.data.integrations.bugReport).toEqual({ configured: true });
// Regression: encrypted blobs MUST never appear in the response envelope.
const serialized = JSON.stringify(body);
expect(serialized).not.toContain("secret-blob");
expect(serialized).not.toContain("token-blob");
});
it("does not mark webPush configured when only the public key is present", async () => {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
webPushVapidPublicKey: "pub",
webPushVapidPrivateKeyEncrypted: null,
webPushVapidSubject: null,
} as never);
const res = await GET();
const body = (await res.json()) as {
data: { integrations: { webPush: unknown } };
};
expect(body.data.integrations.webPush).toBeNull();
});
it("returns aggregate counts and the worker status block", async () => {
vi.mocked(prisma.user.count).mockResolvedValue(7);
vi.mocked(prisma.measurement.count).mockResolvedValue(123);
vi.mocked(prisma.apiToken.count).mockResolvedValue(2);
const res = await GET();
const body = (await res.json()) as {
data: {
counts: Record<string, number>;
worker: { running: boolean };
database: string;
};
};
expect(body.data.counts).toEqual({
users: 7,
measurements: 123,
medications: 0,
intakeEvents: 0,
activeTokens: 2,
activeSessions: 0,
});
expect(body.data.worker.running).toBe(true);
expect(body.data.database).toBe("connected");
// Regression: only NON-revoked tokens are counted as "active".
const tokenCallArgs = vi.mocked(prisma.apiToken.count).mock.calls[0]?.[0];
expect(tokenCallArgs).toEqual({ where: { revoked: false } });
});
});
@@ -0,0 +1,208 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(),
count: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("@/lib/api-handler", async () => {
const actual =
await vi.importActual<typeof import("@/lib/api-handler")>(
"@/lib/api-handler",
);
return {
...actual,
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
h: T,
): T => h,
requireAdmin: vi.fn(),
};
});
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: vi.fn(() => null),
}));
import { PUT } from "../route";
import { prisma } from "@/lib/db";
import { requireAdmin, HttpError } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
const ADMIN_CTX = {
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: "admin-1",
username: "admin",
role: "ADMIN",
} as never,
};
function jsonReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/admin/users/u1", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
function params(id: string) {
return { params: Promise.resolve({ id }) };
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
});
describe("PUT /api/admin/users/[id]", () => {
it("rejects with 401 when unauthenticated", async () => {
vi.mocked(requireAdmin).mockRejectedValue(
new HttpError(401, "Not authenticated"),
);
await expect(
PUT(jsonReq({ username: "marc" }), params("u1")),
).rejects.toThrow("Not authenticated");
});
it("rejects with 403 when caller is not admin", async () => {
vi.mocked(requireAdmin).mockRejectedValue(
new HttpError(403, "Admin access required"),
);
await expect(
PUT(jsonReq({ username: "marc" }), params("u1")),
).rejects.toThrow("Admin access required");
});
it("returns 422 when payload fails Zod validation (short username)", async () => {
const res = await PUT(jsonReq({ username: "ab" }), params("u1"));
expect(res.status).toBe(422);
expect(vi.mocked(prisma.user.findUnique)).not.toHaveBeenCalled();
});
it("returns 422 when role is unknown", async () => {
const res = await PUT(jsonReq({ role: "SUPERUSER" }), params("u1"));
expect(res.status).toBe(422);
});
it("returns 404 when target user does not exist", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
const res = await PUT(jsonReq({ username: "marc" }), params("u-missing"));
expect(res.status).toBe(404);
expect(vi.mocked(prisma.user.update)).not.toHaveBeenCalled();
});
it("forbids demoting the last remaining admin", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
id: "u1",
role: "ADMIN",
} as never);
vi.mocked(prisma.user.count).mockResolvedValue(1);
const res = await PUT(jsonReq({ role: "USER" }), params("u1"));
expect(res.status).toBe(400);
const body = (await res.json()) as { error: string };
expect(body.error).toMatch(/last admin/i);
expect(vi.mocked(prisma.user.update)).not.toHaveBeenCalled();
});
it("allows demoting an admin while another admin exists", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
id: "u1",
role: "ADMIN",
} as never);
vi.mocked(prisma.user.count).mockResolvedValue(2);
vi.mocked(prisma.user.update).mockResolvedValue({
id: "u1",
username: "alice",
email: "a@b.com",
role: "USER",
createdAt: new Date("2024-01-01T00:00:00Z"),
} as never);
const res = await PUT(jsonReq({ role: "USER" }), params("u1"));
expect(res.status).toBe(200);
const updateArgs = vi.mocked(prisma.user.update).mock.calls[0]?.[0];
expect(updateArgs?.where).toEqual({ id: "u1" });
expect(updateArgs?.data).toEqual({ role: "USER" });
});
it("persists username + email + role and writes an audit-log entry", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
id: "u1",
role: "USER",
} as never);
const updated = {
id: "u1",
username: "renamed",
email: "new@example.com",
role: "ADMIN" as const,
createdAt: new Date("2024-01-01T00:00:00Z"),
};
vi.mocked(prisma.user.update).mockResolvedValue(updated as never);
const res = await PUT(
jsonReq({
username: "renamed",
email: "new@example.com",
role: "ADMIN",
}),
params("u1"),
);
expect(res.status).toBe(200);
const body = (await res.json()) as { data: Record<string, unknown> };
expect(body.data.username).toBe("renamed");
expect(body.data.role).toBe("ADMIN");
expect(body.data).not.toHaveProperty("passwordHash");
const updateArgs = vi.mocked(prisma.user.update).mock.calls[0]?.[0];
expect(updateArgs?.data).toEqual({
username: "renamed",
email: "new@example.com",
role: "ADMIN",
});
expect(auditLog).toHaveBeenCalledWith(
"admin.user.update",
expect.objectContaining({
userId: "admin-1",
details: expect.objectContaining({ targetUserId: "u1" }),
}),
);
});
it("supports clearing email by passing null", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
id: "u1",
role: "USER",
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({
id: "u1",
username: "alice",
email: null,
role: "USER",
createdAt: new Date(),
} as never);
const res = await PUT(jsonReq({ email: null }), params("u1"));
expect(res.status).toBe(200);
const updateArgs = vi.mocked(prisma.user.update).mock.calls[0]?.[0];
expect(updateArgs?.data).toEqual({ email: null });
});
it("returns 415 when content-type is not JSON", async () => {
const r = new NextRequest("http://localhost/api/admin/users/u1", {
method: "PUT",
headers: { "content-type": "text/plain" },
body: "{}",
});
const res = await PUT(r, params("u1"));
expect(res.status).toBe(415);
});
});
@@ -2,69 +2,82 @@ import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { hashPassword, checkPasswordStrength } from "@/lib/auth/password";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
import {
apiSuccess,
apiError,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { destroyAllSessions } from "@/lib/auth/session";
import { NextRequest, NextResponse } from "next/server";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
export const POST = apiHandler(async (
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) => {
const ip = getClientIp(request);
const rl = await checkRateLimit(`admin-reset-pw:${ip}`, 10, 60 * 1000);
if (!rl.allowed) {
return NextResponse.json(
{ data: null, error: "Rate limit exceeded" },
{ status: 429, headers: rateLimitHeaders(rl) },
);
}
export const POST = apiHandler(
async (
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) => {
const ip = getClientIp(request);
const rl = await checkRateLimit(`admin-reset-pw:${ip}`, 10, 60 * 1000);
if (!rl.allowed) {
return NextResponse.json(
{ data: null, error: "Rate limit exceeded" },
{ status: 429, headers: rateLimitHeaders(rl) },
);
}
const { user } = await requireAdmin();
const { user } = await requireAdmin();
const { id } = await params;
annotate({ action: { name: "admin.users.reset-password", entity_type: "user", entity_id: id } });
const { id } = await params;
annotate({
action: {
name: "admin.users.reset-password",
entity_type: "user",
entity_id: id,
},
});
const { data: body, error: jsonError } = await safeJson(request);
const { data: body, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
const { password } = body as { password?: string };
if (jsonError) return jsonError;
const { password } = body as { password?: string };
if (!password || typeof password !== "string") {
return apiError("Password required", 422);
}
if (!password || typeof password !== "string") {
return apiError("Password required", 422);
}
const target = await prisma.user.findUnique({ where: { id } });
if (!target) return apiError("User not found", 404);
const target = await prisma.user.findUnique({ where: { id } });
if (!target) return apiError("User not found", 404);
const locale = await resolveServerLocale({
request,
userLocale: target.locale ?? null,
});
const strength = checkPasswordStrength(password, [target.username], locale);
if (!strength.isAcceptable) {
return apiError(
strength.feedback[0] || "Password too weak (score < 3)",
422,
);
}
const locale = await resolveServerLocale({
request,
userLocale: target.locale ?? null,
});
const strength = checkPasswordStrength(password, [target.username], locale);
if (!strength.isAcceptable) {
return apiError(
strength.feedback[0] || "Password too weak (score < 3)",
422,
);
}
const passwordHash = await hashPassword(password);
await prisma.user.update({
where: { id },
data: { passwordHash },
});
const passwordHash = await hashPassword(password);
await prisma.user.update({
where: { id },
data: { passwordHash },
});
// Invalidate all sessions of the target user
await destroyAllSessions(id);
// Invalidate all sessions of the target user
await destroyAllSessions(id);
await auditLog("admin.user.reset-password", {
userId: user.id,
ipAddress: getClientIp(request),
details: { targetUserId: id },
});
await auditLog("admin.user.reset-password", {
userId: user.id,
ipAddress: getClientIp(request),
details: { targetUserId: id },
});
return apiSuccess({ success: true });
});
return apiSuccess({ success: true });
},
);
+61 -48
View File
@@ -1,7 +1,12 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
import {
apiSuccess,
apiError,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
@@ -12,57 +17,65 @@ const updateUserSchema = z.object({
role: z.enum(["ADMIN", "USER"]).optional(),
});
export const PUT = apiHandler(async (
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) => {
const { user } = await requireAdmin();
export const PUT = apiHandler(
async (
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) => {
const { user } = await requireAdmin();
const { id } = await params;
annotate({ action: { name: "admin.users.update", entity_type: "user", entity_id: id } });
const { id } = await params;
annotate({
action: {
name: "admin.users.update",
entity_type: "user",
entity_id: id,
},
});
const { data: body, error: jsonError } = await safeJson(request);
const { data: body, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
const parsed = updateUserSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
}
const target = await prisma.user.findUnique({ where: { id } });
if (!target) return apiError("User not found", 404);
// Prevent removing the last admin
if (parsed.data.role === "USER" && target.role === "ADMIN") {
const adminCount = await prisma.user.count({ where: { role: "ADMIN" } });
if (adminCount <= 1) {
return apiError("The last admin cannot be demoted", 400);
if (jsonError) return jsonError;
const parsed = updateUserSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
}
}
const updatedUser = await prisma.user.update({
where: { id },
data: {
...(parsed.data.username !== undefined && {
username: parsed.data.username,
}),
...(parsed.data.email !== undefined && { email: parsed.data.email }),
...(parsed.data.role !== undefined && { role: parsed.data.role }),
},
select: {
id: true,
username: true,
email: true,
role: true,
createdAt: true,
},
});
const target = await prisma.user.findUnique({ where: { id } });
if (!target) return apiError("User not found", 404);
await auditLog("admin.user.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: { targetUserId: id, changes: parsed.data },
});
// Prevent removing the last admin
if (parsed.data.role === "USER" && target.role === "ADMIN") {
const adminCount = await prisma.user.count({ where: { role: "ADMIN" } });
if (adminCount <= 1) {
return apiError("The last admin cannot be demoted", 400);
}
}
return apiSuccess(updatedUser);
});
const updatedUser = await prisma.user.update({
where: { id },
data: {
...(parsed.data.username !== undefined && {
username: parsed.data.username,
}),
...(parsed.data.email !== undefined && { email: parsed.data.email }),
...(parsed.data.role !== undefined && { role: parsed.data.role }),
},
select: {
id: true,
username: true,
email: true,
role: true,
createdAt: true,
},
});
await auditLog("admin.user.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: { targetUserId: id, changes: parsed.data },
});
return apiSuccess(updatedUser);
},
);
@@ -0,0 +1,119 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
user: { findMany: vi.fn() },
},
}));
vi.mock("@/lib/api-handler", async () => {
const actual =
await vi.importActual<typeof import("@/lib/api-handler")>(
"@/lib/api-handler",
);
return {
...actual,
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
h: T,
): T => h,
requireAdmin: vi.fn(),
};
});
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: vi.fn(() => null),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { requireAdmin, HttpError } from "@/lib/api-handler";
const mockUserFindMany = vi.mocked(prisma.user.findMany);
const mockRequireAdmin = vi.mocked(requireAdmin);
describe("GET /api/admin/users", () => {
beforeEach(() => {
vi.clearAllMocks();
mockRequireAdmin.mockResolvedValue({
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: "admin-1",
username: "admin",
role: "ADMIN",
} as never,
});
mockUserFindMany.mockResolvedValue([] as never);
});
it("rejects with 401 when no session", async () => {
mockRequireAdmin.mockRejectedValue(new HttpError(401, "Not authenticated"));
await expect(GET()).rejects.toThrow("Not authenticated");
});
it("rejects with 403 when caller is not admin", async () => {
mockRequireAdmin.mockRejectedValue(
new HttpError(403, "Admin access required"),
);
await expect(GET()).rejects.toThrow("Admin access required");
});
it("returns users sorted by createdAt ascending with passkey counts", async () => {
const created = new Date("2024-01-01T00:00:00Z");
mockUserFindMany.mockResolvedValue([
{
id: "u1",
username: "alice",
email: "alice@example.com",
role: "ADMIN",
createdAt: created,
_count: { passkeys: 2 },
},
{
id: "u2",
username: "bob",
email: null,
role: "USER",
createdAt: created,
_count: { passkeys: 0 },
},
] as never);
const res = await GET();
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: Array<Record<string, unknown>>;
};
expect(body.data).toHaveLength(2);
expect(body.data[0]).toEqual({
id: "u1",
username: "alice",
email: "alice@example.com",
role: "ADMIN",
createdAt: created.toISOString(),
passkeyCount: 2,
});
expect(body.data[1].passkeyCount).toBe(0);
expect(body.data[1].email).toBeNull();
});
it("queries Prisma with the safe select shape (no password / sensitive cols)", async () => {
await GET();
expect(mockUserFindMany).toHaveBeenCalledTimes(1);
const args = mockUserFindMany.mock.calls[0]?.[0] as {
select: Record<string, unknown>;
orderBy: { createdAt: "asc" | "desc" };
};
// Regression: the route must NOT pull password hash / token columns.
expect(args.select).toEqual({
id: true,
username: true,
email: true,
role: true,
createdAt: true,
_count: { select: { passkeys: true } },
});
expect(args.select).not.toHaveProperty("passwordHash");
expect(args.orderBy).toEqual({ createdAt: "asc" });
});
});
+206
View File
@@ -0,0 +1,206 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// Mocks must be hoisted before importing the route.
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(async () => ({
user: { id: "u-1" },
session: { id: "s-1" },
})),
}));
vi.mock("@/lib/ai/provider", async () => {
const actual =
await vi.importActual<typeof import("@/lib/ai/provider")>(
"@/lib/ai/provider",
);
return {
...actual,
resolveProviderForTest: vi.fn(),
};
});
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(async () => ({ allowed: true })),
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
import { POST } from "../route";
import { resolveProviderForTest } from "@/lib/ai/provider";
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(resolveProviderForTest).mockReset();
});
interface ApiErrorEnvelope {
data: null;
error: string;
}
interface ApiSuccessEnvelope<T> {
data: T;
error: null;
}
function emptyRequest(): Request {
return new Request("http://localhost/api/ai/test", {
method: "POST",
headers: { "content-length": "0" },
});
}
function jsonRequest(body: unknown): Request {
const text = JSON.stringify(body);
return new Request("http://localhost/api/ai/test", {
method: "POST",
headers: {
"content-type": "application/json",
"content-length": String(text.length),
},
body: text,
});
}
// V3 audit: /api/ai/test was returning provider err.message + bodyExcerpt
// directly to the client, leaking provider URLs / partial keys / internal
// headers. Server now logs full details via annotate() and responds with
// a categorised, generic message.
describe("POST /api/ai/test — provider error leak guard", () => {
function makeProviderThatThrows(
err: Error & { httpStatus?: number; bodyExcerpt?: string },
) {
vi.mocked(resolveProviderForTest).mockResolvedValue({
type: "openai",
generateCompletion: vi.fn(async () => {
throw err;
}),
} as never);
}
it("does not echo provider err.message back to the client (HIGH coverage gap)", async () => {
const err = Object.assign(
new Error("OpenAI 401 from https://api.openai.com/v1 sk-leaked-key"),
{
httpStatus: 401 as const,
bodyExcerpt: '{"error":"invalid api key sk-secret"}',
},
);
makeProviderThatThrows(err);
const response = await POST(emptyRequest() as never);
const body = (await response.json()) as ApiErrorEnvelope;
// v1.4.5: credential failures map to 422 (not 502) so Cloudflare
// doesn't replace our JSON body with its HTML error page — that
// rewrite was the root cause of the "Unexpected token '<'…" client
// crash Marc hit when typing a bad OpenAI key.
expect(response.status).toBe(422);
expect(body.error ?? "").not.toMatch(/sk-/);
expect(body.error ?? "").not.toMatch(/api\.openai\.com/);
expect(body.error ?? "").not.toMatch(/invalid api key/i);
expect(body.error).toBe("Provider rejected the credentials");
});
it("returns the 429-categorised message when the provider rate-limits", async () => {
makeProviderThatThrows(
Object.assign(new Error("429 from openai"), { httpStatus: 429 as const }),
);
const response = await POST(emptyRequest() as never);
// v1.4.5: rate-limit passes through as 429 (was 502 in v1.4.4) so
// the React Query mutation can read the JSON body instead of
// tripping over Cloudflare's HTML 502.
expect(response.status).toBe(429);
expect(((await response.json()) as ApiErrorEnvelope).error).toBe(
"Provider rate-limited the request",
);
});
it("returns the 5xx-categorised message when the provider has a server error", async () => {
makeProviderThatThrows(
Object.assign(new Error("503 upstream"), { httpStatus: 503 as const }),
);
const response = await POST(emptyRequest() as never);
expect(((await response.json()) as ApiErrorEnvelope).error).toBe(
"Provider returned a server error",
);
});
it("returns the unknown-error fallback otherwise", async () => {
makeProviderThatThrows(new Error("ECONNRESET"));
const response = await POST(emptyRequest() as never);
expect(((await response.json()) as ApiErrorEnvelope).error).toBe(
"Provider connection failed",
);
});
});
// v1.4 fix: the dropdown in /settings was not honoured — the test always
// ran against the SAVED provider (Marc reported "wirft was Komisches raus").
// The route now accepts a JSON override body and forwards the user's
// unsaved selection to resolveProviderForTest().
describe("POST /api/ai/test — dropdown-aware override", () => {
function makeProviderThatSucceeds(model: string) {
vi.mocked(resolveProviderForTest).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async () => ({
content: '{"ok":true}',
providerType: "anthropic",
model,
tokensUsed: 5,
})),
} as never);
}
it("forwards a dropdown override to resolveProviderForTest", async () => {
makeProviderThatSucceeds("claude-3-5-sonnet-latest");
const response = await POST(
jsonRequest({
provider: "ANTHROPIC",
model: "claude-3-5-sonnet-latest",
}) as never,
);
expect(response.status).toBe(200);
expect(resolveProviderForTest).toHaveBeenCalledWith("u-1", {
provider: "ANTHROPIC",
model: "claude-3-5-sonnet-latest",
});
const body = (await response.json()) as ApiSuccessEnvelope<{
providerType: string;
model: string;
}>;
expect(body.data.providerType).toBe("anthropic");
expect(body.data.model).toBe("claude-3-5-sonnet-latest");
});
it("rejects unknown provider values with 422", async () => {
const response = await POST(
jsonRequest({ provider: "MALICIOUS" }) as never,
);
expect(response.status).toBe(422);
expect(resolveProviderForTest).not.toHaveBeenCalled();
});
it("uses the persisted config when the body is empty", async () => {
makeProviderThatSucceeds("claude-3-5-sonnet-latest");
const response = await POST(emptyRequest() as never);
expect(response.status).toBe(200);
expect(resolveProviderForTest).toHaveBeenCalledWith("u-1", {});
});
it("surfaces config errors as the same status as the resolver throws", async () => {
const { AITestConfigError } = await import("@/lib/ai/provider");
vi.mocked(resolveProviderForTest).mockRejectedValueOnce(
new AITestConfigError(422, "Anthropic API key not configured"),
);
const response = await POST(
jsonRequest({ provider: "ANTHROPIC" }) as never,
);
expect(response.status).toBe(422);
const body = (await response.json()) as ApiErrorEnvelope;
expect(body.error).toBe("Anthropic API key not configured");
});
});
+83 -8
View File
@@ -1,19 +1,63 @@
import type { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { resolveProvider } from "@/lib/ai/provider";
import { apiSuccess, apiError } from "@/lib/api-response";
import {
resolveProviderForTest,
AITestConfigError,
type AITestOverride,
} from "@/lib/ai/provider";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
export const dynamic = "force-dynamic";
export const POST = apiHandler(async () => {
const overrideSchema = z
.object({
provider: z
.enum(["OPENAI", "ANTHROPIC", "LOCAL", "CHATGPT_OAUTH"])
.optional()
.nullable(),
model: z.string().min(1).max(120).optional().nullable(),
baseUrl: z.string().url().max(2048).optional().nullable(),
anthropicKey: z.string().min(1).max(500).optional().nullable(),
localKey: z.string().min(1).max(500).optional().nullable(),
openaiKey: z.string().min(1).max(500).optional().nullable(),
})
.strict();
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "ai.test" } });
const rl = await checkRateLimit(`ai-test:${user.id}`, 5, 60_000);
if (!rl.allowed) return apiError("Too many test requests", 429);
const provider = await resolveProvider(user.id);
// Body is optional. Empty body → behaves like before (test the saved
// config). Non-empty body → tests the unsaved selection without
// mutating the user row. Plaintext keys never persist.
let override: AITestOverride = {};
const contentLength = Number(request.headers.get("content-length") ?? "0");
if (contentLength > 0) {
const { data, error } = await safeJson<unknown>(request);
if (error) return error;
const parsed = overrideSchema.safeParse(data);
if (!parsed.success) {
return apiError("Invalid override payload", 422);
}
override = parsed.data;
}
let provider;
try {
provider = await resolveProviderForTest(user.id, override);
} catch (e) {
if (e instanceof AITestConfigError) {
return apiError(e.message, e.status);
}
throw e;
}
if (provider.type === "none") {
return apiError("No AI provider configured", 422);
}
@@ -34,10 +78,41 @@ export const POST = apiHandler(async () => {
sample: result.content.slice(0, 200),
});
} catch (e) {
// V3 audit: do not return provider error message + bodyExcerpt to the
// client (leaks provider URL / partial keys / internal headers).
// Log full details server-side for the operator and respond with a
// categorised, generic message.
const err = e as Error & { httpStatus?: number; bodyExcerpt?: string };
return apiError(
`${err.message}${err.bodyExcerpt ? ` — ${err.bodyExcerpt.slice(0, 200)}` : ""}`,
502,
);
annotate({
meta: {
ai_test_error: err.message.slice(0, 500),
ai_test_status: err.httpStatus ?? null,
ai_test_body_excerpt: err.bodyExcerpt?.slice(0, 500) ?? null,
ai_test_provider: provider.type,
},
});
// Map upstream status to a *client-side* status code that won't be
// rewritten by Cloudflare. 5xx from origin causes Cloudflare to swap
// the body for its own HTML error page — `await res.json()` in the
// browser then crashes with `Unexpected token '<', "<!DOCTYPE "`,
// which is exactly how Marc encountered this when typing a bad
// OpenAI key. 4xx codes pass through untouched.
const status = err.httpStatus ?? 0;
let outboundStatus: number;
let safeMessage: string;
if (status === 401 || status === 403) {
outboundStatus = 422;
safeMessage = "Provider rejected the credentials";
} else if (status === 429) {
outboundStatus = 429;
safeMessage = "Provider rate-limited the request";
} else if (status >= 500) {
outboundStatus = 502;
safeMessage = "Provider returned a server error";
} else {
outboundStatus = 422;
safeMessage = "Provider connection failed";
}
return apiError(safeMessage, outboundStatus);
}
});
+4 -10
View File
@@ -5,6 +5,7 @@ import { apiSuccess } from "@/lib/api-response";
import { summarize, type DataPoint } from "@/lib/analytics/trends";
import { getBpTargets } from "@/lib/analytics/bp-targets";
import type { MeasurementType } from "@/generated/prisma/client";
import { measurementTypeEnum } from "@/lib/validations/measurement";
export const dynamic = "force-dynamic";
@@ -12,16 +13,9 @@ export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "analytics.get" } });
const types: MeasurementType[] = [
"WEIGHT",
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",
"PULSE",
"BODY_FAT",
"SLEEP_DURATION",
"ACTIVITY_STEPS",
"BLOOD_GLUCOSE",
];
// Derived from canonical enum so a new measurement type is auto-summarised
// by /api/analytics (V3 audit: enum drift cousins).
const types = [...measurementTypeEnum.options] as MeasurementType[];
const measurementsByType = await Promise.all(
types.map((type) =>
+28 -5
View File
@@ -5,6 +5,8 @@ import {
generatePKCE,
generateState,
buildAuthorizationUrl,
CodexOAuthNotConfiguredError,
isCodexOAuthConfigured,
} from "@/lib/ai/codex-oauth";
import { cookies } from "next/headers";
import { annotate } from "@/lib/logging/context";
@@ -12,6 +14,17 @@ import { annotate } from "@/lib/logging/context";
export const GET = apiHandler(async (_request: NextRequest) => {
const { user } = await requireAuth();
// Surface configuration errors as 503 instead of silently redirecting
// to a dead chatgpt.com URL. The v1.4.2 build dropped the `client_id`
// out of the authorize URL — the user landed on the chatgpt.com login
// page, no callback ever fired, and the connect button "did nothing".
if (!isCodexOAuthConfigured()) {
throw new HttpError(
503,
"Codex OAuth is not configured on this instance — set CODEX_OAUTH_CLIENT_ID",
);
}
const rl = await checkRateLimit(`codex-authorize:${user.id}`, 5, 60_000);
if (!rl.allowed) throw new HttpError(429, "Too many requests");
@@ -35,11 +48,21 @@ export const GET = apiHandler(async (_request: NextRequest) => {
cookieStore.set("codex_verifier", verifier, cookieOptions);
cookieStore.set("codex_state", state, cookieOptions);
const authUrl = buildAuthorizationUrl({
codeChallenge: challenge,
state,
redirectUri,
});
let authUrl: string;
try {
authUrl = buildAuthorizationUrl({
codeChallenge: challenge,
state,
redirectUri,
});
} catch (err) {
// Race between the env check above and the URL build — extremely
// unlikely but the typed error path is cheap insurance.
if (err instanceof CodexOAuthNotConfiguredError) {
throw new HttpError(503, err.message);
}
throw err;
}
annotate({ action: { name: "codex.oauth.authorize" } });
return NextResponse.redirect(authUrl);
+11 -9
View File
@@ -14,7 +14,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
const rl = await checkRateLimit(`codex-callback:${user.id}`, 10, 60_000);
if (!rl.allowed) {
const appUrl = process.env.APP_URL || process.env.NEXT_PUBLIC_APP_URL;
return NextResponse.redirect(`${appUrl}/settings?codex_error=rate_limited`);
return NextResponse.redirect(
`${appUrl}/settings/integrations?codex_error=rate_limited`,
);
}
const url = new URL(request.url);
@@ -29,13 +31,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
if (error) {
annotate({ meta: { oauth_error: error } });
return NextResponse.redirect(
`${appUrl}/settings?codex_error=${encodeURIComponent(error)}`,
`${appUrl}/settings/integrations?codex_error=${encodeURIComponent(error)}`,
);
}
if (!code || !state) {
return NextResponse.redirect(
`${appUrl}/settings?codex_error=missing_params`,
`${appUrl}/settings/integrations?codex_error=missing_params`,
);
}
@@ -48,7 +50,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
if (!storedState || !storedVerifier || state !== storedState) {
return NextResponse.redirect(
`${appUrl}/settings?codex_error=invalid_state`,
`${appUrl}/settings/integrations?codex_error=invalid_state`,
);
}
@@ -70,9 +72,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
data: {
codexAccessTokenEncrypted: encrypted.accessEncrypted,
codexRefreshTokenEncrypted: encrypted.refreshEncrypted,
codexTokenExpiresAt: new Date(
Date.now() + tokens.expires_in * 1000,
),
codexTokenExpiresAt: new Date(Date.now() + tokens.expires_in * 1000),
codexConnectedAt: new Date(),
codexConnectionStatus: "connected",
insightsCachedAt: null,
@@ -86,12 +86,14 @@ export const GET = apiHandler(async (request: NextRequest) => {
});
annotate({ action: { name: "codex.oauth.callback.success" } });
return NextResponse.redirect(`${appUrl}/settings?codex_connected=true`);
return NextResponse.redirect(
`${appUrl}/settings/integrations?codex_connected=true`,
);
} catch (err) {
const message = err instanceof Error ? err.message : "unknown";
annotate({ meta: { codex_token_error: message } });
return NextResponse.redirect(
`${appUrl}/settings?codex_error=token_exchange_failed`,
`${appUrl}/settings/integrations?codex_error=token_exchange_failed`,
);
}
});
@@ -0,0 +1,150 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
// --- Mocks must come before importing the route. ---
vi.mock("@/lib/db", () => ({
prisma: {
user: { findFirst: vi.fn() },
apiToken: { create: vi.fn() },
refreshToken: { create: vi.fn() },
},
}));
vi.mock("@/lib/auth/password", () => ({
verifyPassword: vi.fn(),
}));
vi.mock("@/lib/auth/session", () => ({
createSession: vi.fn().mockResolvedValue("session-id"),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi
.fn()
.mockResolvedValue({ allowed: true, remaining: 5, reset: 0 }),
rateLimitHeaders: vi.fn(() => ({})),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/hmac", () => ({
hashToken: vi.fn((raw: string) => `hashed:${raw}`),
}));
vi.mock("@/lib/logging/transports", () => ({
emitIfSampled: vi.fn(),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { verifyPassword } from "@/lib/auth/password";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { ensureDbCompatibility } from "@/lib/db-compat";
import { auditLog } from "@/lib/auth/audit";
import { createSession } from "@/lib/auth/session";
const FAKE_USER = {
id: "user-1",
username: "marc",
email: "marc@example.com",
passwordHash: "$argon2id$...",
};
function makeRequest(headers: Record<string, string> = {}): NextRequest {
return new NextRequest("http://localhost/api/auth/login", {
method: "POST",
headers: {
"content-type": "application/json",
...headers,
},
body: JSON.stringify({
email: "marc@example.com",
password: "supersecret",
}),
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.user.findFirst).mockResolvedValue(FAKE_USER as never);
vi.mocked(verifyPassword).mockResolvedValue(true);
vi.mocked(prisma.apiToken.create).mockResolvedValue({ id: "tok-1" } as never);
vi.mocked(prisma.refreshToken.create).mockResolvedValue({
id: "rt-1",
} as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 5,
reset: 0,
} as never);
vi.mocked(rateLimitHeaders).mockReturnValue({} as never);
vi.mocked(ensureDbCompatibility).mockResolvedValue(undefined);
vi.mocked(auditLog).mockResolvedValue(undefined as never);
vi.mocked(createSession).mockResolvedValue("session-id");
});
describe("POST /api/auth/login — native token issuance", () => {
it("issues a Bearer token when X-Client-Type: native is set", async () => {
const original = process.env.API_TOKEN_HMAC_KEY;
process.env.API_TOKEN_HMAC_KEY = "test-key";
try {
const res = await POST(makeRequest({ "x-client-type": "native" }));
const body = (await res.json()) as {
data: { user: { id: string }; token?: string; tokenExpiresAt?: string };
};
expect(res.status).toBe(200);
expect(body.data.user.id).toBe("user-1");
expect(body.data.token).toMatch(/^hlk_[a-f0-9]{64}$/);
expect(body.data.tokenExpiresAt).toEqual(expect.any(String));
expect(prisma.apiToken.create).toHaveBeenCalledTimes(1);
const createArgs = vi.mocked(prisma.apiToken.create).mock.calls[0][0];
expect(createArgs.data.tokenHash).toBe(`hashed:${body.data.token}`);
} finally {
process.env.API_TOKEN_HMAC_KEY = original;
}
});
it("does NOT issue a token without the native header", async () => {
const res = await POST(makeRequest());
const body = (await res.json()) as {
data: { user: { id: string }; token?: string };
};
expect(res.status).toBe(200);
expect(body.data.user.id).toBe("user-1");
expect(body.data.token).toBeUndefined();
expect(prisma.apiToken.create).not.toHaveBeenCalled();
});
it("issues a token when User-Agent starts with HealthLog-iOS", async () => {
const original = process.env.API_TOKEN_HMAC_KEY;
process.env.API_TOKEN_HMAC_KEY = "test-key";
try {
const res = await POST(
makeRequest({ "user-agent": "HealthLog-iOS/1.0 (iPhone)" }),
);
const body = (await res.json()) as { data: { token?: string } };
expect(res.status).toBe(200);
expect(body.data.token).toMatch(/^hlk_/);
const createArgs = vi.mocked(prisma.apiToken.create).mock.calls[0][0];
expect(createArgs.data.tokenHash).toBe(`hashed:${body.data.token}`);
} finally {
process.env.API_TOKEN_HMAC_KEY = original;
}
});
});
+77 -1
View File
@@ -3,12 +3,23 @@ import { loginPasswordSchema } from "@/lib/validations/auth";
import { verifyPassword } from "@/lib/auth/password";
import { createSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
import {
apiSuccess,
apiError,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { ensureDbCompatibility } from "@/lib/db-compat";
import { NextRequest, NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { issueApiToken, isNativeClientRequest } from "@/lib/auth/issue-token";
import {
resolveTokenPolicy,
shouldIssueBearerToken,
} from "@/lib/auth/native-client";
import { issueAccessAndRefresh } from "@/lib/auth/refresh-token";
export const POST = apiHandler(async (request: NextRequest) => {
const ip = getClientIp(request) ?? "unknown";
@@ -74,6 +85,71 @@ export const POST = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "auth.login.password" } });
// v1.4 G4: Native callers (iOS, n8n, Health-Connect, unrecognised UAs)
// get a 24h access token + 60d rotating refresh token. Web UAs keep the
// legacy 90d Bearer (issued only when X-Client-Type: native is set —
// browser flows continue to use the session cookie).
if (
shouldIssueBearerToken(request.headers) ||
isNativeClientRequest(request.headers)
) {
const policy = resolveTokenPolicy(request.headers);
const deviceId = request.headers.get("x-device-id");
if (policy.refreshTokenDays !== null) {
const bundle = await issueAccessAndRefresh({
userId: user.id,
policy,
deviceId,
userAgent: ua,
ipAddress: ip,
source: "login.password",
});
await auditLog("auth.token.autoissue.native", {
userId: user.id,
ipAddress: ip,
details: { source: "login.password", policy: "native" },
});
annotate({
action: { name: "auth.token.autoissue.native" },
meta: { token_policy: "native" },
});
return apiSuccess({
user: { id: user.id, username: user.username },
token: bundle.accessToken,
tokenExpiresAt: bundle.accessTokenExpiresAt.toISOString(),
refreshToken: bundle.refreshToken,
refreshTokenExpiresAt: bundle.refreshTokenExpiresAt.toISOString(),
});
}
// Web policy with explicit X-Client-Type:native (legacy iOS auto-login)
const issued = await issueApiToken({
userId: user.id,
name: `web auto-login ${new Date().toISOString()}`,
permissions: ["*"],
expiresInDays: policy.accessTokenDays,
});
await auditLog("auth.token.autoissue.native", {
userId: user.id,
ipAddress: ip,
details: {
tokenId: issued.tokenId,
source: "login.password",
policy: "web",
},
});
annotate({
action: { name: "auth.token.autoissue.native" },
meta: { token_policy: "web" },
});
return apiSuccess({
user: { id: user.id, username: user.username },
token: issued.token,
tokenExpiresAt: issued.expiresAt.toISOString(),
});
}
return apiSuccess({
user: { id: user.id, username: user.username },
});
@@ -0,0 +1,126 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn() },
apiToken: { create: vi.fn() },
refreshToken: { create: vi.fn() },
},
}));
vi.mock("@/lib/auth/passkey", () => ({
verifyAuthentication: vi.fn(),
}));
vi.mock("@/lib/auth/session", () => ({
createSession: vi.fn().mockResolvedValue("session-id"),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/hmac", () => ({
hashToken: vi.fn((raw: string) => `hashed:${raw}`),
}));
vi.mock("@/lib/logging/transports", () => ({
emitIfSampled: vi.fn(),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { verifyAuthentication } from "@/lib/auth/passkey";
import { checkRateLimit } from "@/lib/rate-limit";
const FAKE_USER = {
id: "user-1",
username: "marc",
email: "marc@example.com",
};
function makeRequest(headers: Record<string, string> = {}): NextRequest {
return new NextRequest("http://localhost/api/auth/passkey/login-verify", {
method: "POST",
headers: { "content-type": "application/json", ...headers },
body: JSON.stringify({
challengeId: "ch-1",
credential: { id: "cred-1" },
}),
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.user.findUnique).mockResolvedValue(FAKE_USER as never);
vi.mocked(prisma.apiToken.create).mockResolvedValue({ id: "tok-1" } as never);
vi.mocked(prisma.refreshToken.create).mockResolvedValue({
id: "rt-1",
} as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
reset: 0,
} as never);
vi.mocked(verifyAuthentication).mockResolvedValue({
verification: { verified: true },
passkey: { userId: "user-1" },
} as never);
});
describe("POST /api/auth/passkey/login-verify — native token issuance", () => {
it("issues a Bearer token when X-Client-Type: native is set", async () => {
const original = process.env.API_TOKEN_HMAC_KEY;
process.env.API_TOKEN_HMAC_KEY = "test-key";
try {
const res = await POST(makeRequest({ "x-client-type": "native" }));
const body = (await res.json()) as {
data: { token?: string; tokenExpiresAt?: string };
};
expect(res.status).toBe(200);
expect(body.data.token).toMatch(/^hlk_[a-f0-9]{64}$/);
expect(body.data.tokenExpiresAt).toEqual(expect.any(String));
expect(prisma.apiToken.create).toHaveBeenCalledTimes(1);
const createArgs = vi.mocked(prisma.apiToken.create).mock.calls[0][0];
expect(createArgs.data.tokenHash).toBe(`hashed:${body.data.token}`);
} finally {
process.env.API_TOKEN_HMAC_KEY = original;
}
});
it("does NOT issue a token without the native header", async () => {
const res = await POST(makeRequest());
const body = (await res.json()) as { data: { token?: string } };
expect(res.status).toBe(200);
expect(body.data.token).toBeUndefined();
expect(prisma.apiToken.create).not.toHaveBeenCalled();
});
it("returns 401 when verification fails", async () => {
vi.mocked(verifyAuthentication).mockResolvedValue({
verification: { verified: false },
passkey: { userId: "user-1" },
} as never);
const res = await POST(makeRequest({ "x-client-type": "native" }));
expect(res.status).toBe(401);
expect(prisma.apiToken.create).not.toHaveBeenCalled();
});
});
+79 -3
View File
@@ -1,24 +1,40 @@
import { verifyAuthentication } from "@/lib/auth/passkey";
import { createSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
import {
apiSuccess,
apiError,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { prisma } from "@/lib/db";
import { ensureDbCompatibility } from "@/lib/db-compat";
import { checkRateLimit } from "@/lib/rate-limit";
import { NextRequest } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { issueApiToken, isNativeClientRequest } from "@/lib/auth/issue-token";
import {
resolveTokenPolicy,
shouldIssueBearerToken,
} from "@/lib/auth/native-client";
import { issueAccessAndRefresh } from "@/lib/auth/refresh-token";
export const POST = apiHandler(async (request: NextRequest) => {
await ensureDbCompatibility();
const ip = getClientIp(request);
const rl = await checkRateLimit(`auth:passkey-verify:${ip}`, 10, 15 * 60 * 1000);
const rl = await checkRateLimit(
`auth:passkey-verify:${ip}`,
10,
15 * 60 * 1000,
);
if (!rl.allowed) {
return apiError("Too many attempts. Please wait 15 minutes.", 429);
}
const { data: body, error: jsonError } = await safeJson<Record<string, unknown>>(request);
const { data: body, error: jsonError } =
await safeJson<Record<string, unknown>>(request);
if (jsonError) return jsonError;
const challengeId = body.challengeId as string | undefined;
@@ -59,6 +75,66 @@ export const POST = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "auth.login.passkey" } });
if (
shouldIssueBearerToken(request.headers) ||
isNativeClientRequest(request.headers)
) {
const policy = resolveTokenPolicy(request.headers);
const deviceId = request.headers.get("x-device-id");
if (policy.refreshTokenDays !== null) {
const bundle = await issueAccessAndRefresh({
userId: user.id,
policy,
deviceId,
userAgent: ua,
ipAddress: ip ?? null,
source: "login.passkey",
});
await auditLog("auth.token.autoissue.native", {
userId: user.id,
ipAddress: ip,
details: { source: "login.passkey", policy: "native" },
});
annotate({
action: { name: "auth.token.autoissue.native" },
meta: { token_policy: "native" },
});
return apiSuccess({
user: { id: user.id, username: user.username },
token: bundle.accessToken,
tokenExpiresAt: bundle.accessTokenExpiresAt.toISOString(),
refreshToken: bundle.refreshToken,
refreshTokenExpiresAt: bundle.refreshTokenExpiresAt.toISOString(),
});
}
const issued = await issueApiToken({
userId: user.id,
name: `web auto-login ${new Date().toISOString()}`,
permissions: ["*"],
expiresInDays: policy.accessTokenDays,
});
await auditLog("auth.token.autoissue.native", {
userId: user.id,
ipAddress: ip,
details: {
tokenId: issued.tokenId,
source: "login.passkey",
policy: "web",
},
});
annotate({
action: { name: "auth.token.autoissue.native" },
meta: { token_policy: "web" },
});
return apiSuccess({
user: { id: user.id, username: user.username },
token: issued.token,
tokenExpiresAt: issued.expiresAt.toISOString(),
});
}
return apiSuccess({
user: { id: user.id, username: user.username },
});
@@ -1,7 +1,12 @@
import { prisma } from "@/lib/db";
import { verifyRegistration } from "@/lib/auth/passkey";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
import {
apiSuccess,
apiError,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
@@ -9,7 +14,8 @@ import { annotate } from "@/lib/logging/context";
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson<Record<string, unknown>>(request);
const { data: body, error: jsonError } =
await safeJson<Record<string, unknown>>(request);
if (jsonError) return jsonError;
const challengeId = body.challengeId as string | undefined;
@@ -35,7 +41,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
counter: BigInt(registrationInfo.credential.counter),
credentialDeviceType: registrationInfo.credentialDeviceType,
credentialBackedUp: registrationInfo.credentialBackedUp,
transports: ((credential.response as Record<string, unknown> | undefined)?.transports as string[]) ?? [],
transports:
((credential.response as Record<string, unknown> | undefined)
?.transports as string[]) ?? [],
},
});
+34 -32
View File
@@ -5,44 +5,46 @@ import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
export const DELETE = apiHandler(async (
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) => {
const { user } = await requireAuth();
export const DELETE = apiHandler(
async (
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) => {
const { user } = await requireAuth();
const { id } = await params;
const { id } = await params;
const passkey = await prisma.passkey.findUnique({
where: { id },
});
const passkey = await prisma.passkey.findUnique({
where: { id },
});
if (!passkey || passkey.userId !== user.id) {
return apiError("Passkey not found", 404);
}
if (!passkey || passkey.userId !== user.id) {
return apiError("Passkey not found", 404);
}
// Check: at least 1 auth method must remain
const passkeyCount = await prisma.passkey.count({
where: { userId: user.id },
});
const hasPassword = !!user.passwordHash;
// Check: at least 1 auth method must remain
const passkeyCount = await prisma.passkey.count({
where: { userId: user.id },
});
const hasPassword = !!user.passwordHash;
if (passkeyCount <= 1 && !hasPassword) {
return apiError(
"Cannot delete — at least one authentication method must remain",
400,
);
}
if (passkeyCount <= 1 && !hasPassword) {
return apiError(
"Cannot delete — at least one authentication method must remain",
400,
);
}
await prisma.passkey.delete({ where: { id } });
await prisma.passkey.delete({ where: { id } });
await auditLog("auth.passkey.delete", {
userId: user.id,
ipAddress: getClientIp(request),
details: { passkeyId: id, passkeyName: passkey.name },
});
await auditLog("auth.passkey.delete", {
userId: user.id,
ipAddress: getClientIp(request),
details: { passkeyId: id, passkeyName: passkey.name },
});
annotate({ action: { name: "auth.passkey.delete" } });
annotate({ action: { name: "auth.passkey.delete" } });
return apiSuccess({ success: true });
});
return apiSuccess({ success: true });
},
);
+9 -13
View File
@@ -7,7 +7,12 @@ import {
} from "@/lib/auth/password";
import { changePasswordSchema } from "@/lib/validations/auth";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
import {
apiSuccess,
apiError,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
@@ -23,10 +28,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
15 * 60 * 1000,
);
if (!rl.allowed) {
return apiError(
"Too many attempts. Please wait 15 minutes.",
429,
);
return apiError("Too many attempts. Please wait 15 minutes.", 429);
}
const { data: body, error: jsonError } = await safeJson(request);
@@ -42,19 +44,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("No password set for this account", 400);
}
const currentValid = await verifyPassword(
user.passwordHash,
currentPassword,
);
const currentValid = await verifyPassword(user.passwordHash, currentPassword);
if (!currentValid) {
return apiError("Current password is incorrect", 401);
}
if (currentPassword === newPassword) {
return apiError(
"New password must differ from current password",
422,
);
return apiError("New password must differ from current password", 422);
}
const locale = await resolveServerLocale({
+18 -45
View File
@@ -1,62 +1,35 @@
import { prisma } from "@/lib/db";
import { profileSchema } from "@/lib/validations/auth";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
import {
apiSuccess,
apiError,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { applyProfileUpdate } from "@/lib/auth/profile-update";
export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
const parsed = profileSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
const result = await applyProfileUpdate(user.id, body, getClientIp(request));
if (!result.ok) {
return apiError(result.message, result.status);
}
const data = parsed.data;
const normalizedEmail = data.email ? data.email.trim().toLowerCase() : null;
if (data.email !== undefined && normalizedEmail) {
const existingUser = await prisma.user.findUnique({
where: { email: normalizedEmail },
select: { id: true },
});
if (existingUser && existingUser.id !== user.id) {
return apiError("Email already in use", 409);
}
}
const updatedUser = await prisma.user.update({
where: { id: user.id },
data: {
...(data.email !== undefined ? { email: normalizedEmail } : {}),
heightCm: data.heightCm ?? null,
dateOfBirth: data.dateOfBirth ? new Date(data.dateOfBirth) : null,
...(data.gender !== undefined ? { gender: data.gender } : {}),
},
});
await auditLog("profile.update", {
userId: updatedUser.id,
ipAddress: getClientIp(request),
});
annotate({ action: { name: "auth.profile.update" } });
return apiSuccess({
id: updatedUser.id,
username: updatedUser.username,
email: updatedUser.email,
role: updatedUser.role,
heightCm: updatedUser.heightCm,
dateOfBirth: updatedUser.dateOfBirth,
gender: updatedUser.gender,
timezone: updatedUser.timezone,
id: result.user.id,
username: result.user.username,
email: result.user.email,
role: result.user.role,
heightCm: result.user.heightCm,
dateOfBirth: result.user.dateOfBirth,
gender: result.user.gender,
timezone: result.user.timezone,
});
});
+101
View File
@@ -0,0 +1,101 @@
/**
* POST /api/auth/refresh — exchange a one-time-use refresh token for a new
* access + refresh pair. Audited on every call (success + failure).
*/
import { NextRequest } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import {
apiSuccess,
apiError,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import { resolveTokenPolicy } from "@/lib/auth/native-client";
import {
rotateRefreshToken,
revokeRefreshToken,
} from "@/lib/auth/refresh-token";
export const POST = apiHandler(async (request: NextRequest) => {
const ip = getClientIp(request) ?? "unknown";
const rl = await checkRateLimit(`auth:refresh:${ip}`, 60, 15 * 60 * 1000);
if (!rl.allowed) {
return apiError("Too many refresh attempts. Please retry later.", 429);
}
const { data: body, error: jsonError } = await safeJson<{
refreshToken?: string;
revoke?: boolean;
}>(request);
if (jsonError) return jsonError;
if (!body.refreshToken || typeof body.refreshToken !== "string") {
return apiError("refreshToken required", 422);
}
// logout-on-device path: client wants to invalidate the refresh token.
if (body.revoke) {
const ok = await revokeRefreshToken(body.refreshToken);
annotate({
action: { name: "auth.token.refresh.revoke" },
meta: { refresh_revoked: ok },
});
await auditLog("auth.token.refresh.revoke", {
ipAddress: ip,
details: { ok },
});
return apiSuccess({ revoked: ok });
}
const policy = resolveTokenPolicy(request.headers);
// Refresh always uses the native policy: a refresh token only exists for
// native callers, and we never want to upgrade it back to a 90d token.
if (policy.refreshTokenDays === null) {
policy.refreshTokenDays = 60;
policy.accessTokenDays = 1;
}
const result = await rotateRefreshToken({
refreshToken: body.refreshToken,
policy,
deviceId: request.headers.get("x-device-id"),
userAgent: request.headers.get("user-agent"),
ipAddress: ip,
});
if (!result.ok) {
await auditLog("auth.token.refresh.failed", {
ipAddress: ip,
details: { reason: result.reason },
});
annotate({
action: { name: "auth.token.refresh.failed" },
meta: { refresh_failure: result.reason },
});
if (result.reason === "already_used") {
return apiError(
"Refresh token reuse detected — please log in again.",
401,
);
}
return apiError("Invalid refresh token", 401);
}
await auditLog("auth.token.refresh", {
ipAddress: ip,
details: { policy: policy.policy },
});
annotate({
action: { name: "auth.token.refresh" },
meta: { token_policy: policy.policy },
});
return apiSuccess({
token: result.bundle.accessToken,
tokenExpiresAt: result.bundle.accessTokenExpiresAt.toISOString(),
refreshToken: result.bundle.refreshToken,
refreshTokenExpiresAt: result.bundle.refreshTokenExpiresAt.toISOString(),
});
});
+7 -3
View File
@@ -3,7 +3,12 @@ import { registerSchema } from "@/lib/validations/auth";
import { hashPassword, checkPasswordStrength } from "@/lib/auth/password";
import { createSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
import {
apiSuccess,
apiError,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { ensureDbCompatibility } from "@/lib/db-compat";
import { NextRequest, NextResponse } from "next/server";
@@ -18,8 +23,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
return NextResponse.json(
{
data: null,
error:
"Too many registration attempts. Please try again later.",
error: "Too many registration attempts. Please try again later.",
},
{ status: 429, headers: rateLimitHeaders(rl) },
);
+20 -6
View File
@@ -1,6 +1,11 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
import {
apiSuccess,
apiError,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
@@ -17,7 +22,10 @@ const bugReportSchema = z.object({
screenshot: z
.string()
.max(7_000_000, "Screenshot too large (max 5 MB)")
.regex(SCREENSHOT_DATA_URL, "Screenshot must be a base64-encoded PNG/JPEG/WEBP/GIF data URL")
.regex(
SCREENSHOT_DATA_URL,
"Screenshot must be a base64-encoded PNG/JPEG/WEBP/GIF data URL",
)
.optional(),
});
@@ -36,9 +44,18 @@ export const POST = apiHandler(async (request: NextRequest) => {
select: {
githubIssueTokenEncrypted: true,
githubIssueRepo: true,
bugReportEnabled: true,
},
});
// v1.4.3: explicit toggle. When the admin disables bug reports we
// return 503 immediately — the client already renders a "not
// configured" UI for that status, so the report button gracefully
// disappears without needing additional client-side wiring.
if (appSettings && appSettings.bugReportEnabled === false) {
return apiError("Bug reports are disabled by the administrator", 503);
}
let configuredToken: string | null = null;
if (appSettings?.githubIssueTokenEncrypted) {
try {
@@ -83,10 +100,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
const safeUsername = user.username.replace(/[`*_~<>\[\]|]/g, "");
const safeDescription = description
.replace(/<[^>]*>/g, "") // strip HTML tags
.replace(
/\]\(\s*(javascript|data|vbscript|file):/gi,
"](about:blank#",
) // neutralise dangerous markdown link schemes
.replace(/\]\(\s*(javascript|data|vbscript|file):/gi, "](about:blank#") // neutralise dangerous markdown link schemes
.replace(/```/g, "\\`\\`\\`")
.slice(0, 5000);
+10 -8
View File
@@ -18,18 +18,18 @@ export const GET = apiHandler(async () => {
// Light rate limit — the endpoint is cheap, but no reason to let a logged-in
// client hammer Postgres in a loop.
const rl = await checkRateLimit(
`bugreport-status:${user.id}`,
30,
60 * 1000,
);
const rl = await checkRateLimit(`bugreport-status:${user.id}`, 30, 60 * 1000);
if (!rl.allowed) {
return apiError("Rate limit exceeded", 429);
}
const appSettings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
select: { githubIssueTokenEncrypted: true, githubIssueRepo: true },
select: {
githubIssueTokenEncrypted: true,
githubIssueRepo: true,
bugReportEnabled: true,
},
});
const hasToken = Boolean(
@@ -39,11 +39,13 @@ export const GET = apiHandler(async () => {
appSettings?.githubIssueRepo || process.env.GITHUB_ISSUE_REPO,
);
const configured = hasToken && hasRepo;
// Default ON when the column has never been written.
const enabled = appSettings?.bugReportEnabled !== false;
annotate({
action: { name: "bugreport.status" },
meta: { configured, has_token: hasToken, has_repo: hasRepo },
meta: { configured, enabled, has_token: hasToken, has_repo: hasRepo },
});
return apiSuccess({ configured, isAdmin: user.role === "ADMIN" });
return apiSuccess({ configured, enabled, isAdmin: user.role === "ADMIN" });
});
@@ -0,0 +1,114 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
measurement: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({
getSession: vi.fn(),
}));
vi.mock("@/lib/logging/transports", () => ({
emitIfSampled: vi.fn(),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: "user-1",
username: "marc",
role: "USER" as const,
displayName: null,
},
};
// `apiHandler` always reads `request.url` — even when the inner handler
// ignores it — so we hand it a NextRequest and bypass the inner-handler
// arity check via a cast.
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
function makeReq(): NextRequest {
return new NextRequest("http://localhost/api/dashboard/summary");
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
});
describe("GET /api/dashboard/summary", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await callGet(makeReq());
expect(res.status).toBe(401);
});
it("returns the aggregated payload with empty data", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await callGet(makeReq());
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
greeting: { salutation: string; date: string };
streak: { currentDays: number; longest: number; label: string };
compliance: { scheduledToday: number; takenToday: number };
metrics: Array<{ id: string; kind: string; sparkline: number[] }>;
};
};
expect(body.data.greeting.salutation).toBe("Hi, marc");
expect(body.data.streak.currentDays).toBe(0);
expect(body.data.compliance.scheduledToday).toBe(0);
expect(body.data.metrics).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: "weight" }),
expect.objectContaining({ id: "bp" }),
expect.objectContaining({ id: "pulse" }),
]),
);
});
it("computes intake compliance for today", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockImplementation(((
args: unknown,
) => {
const a = args as { where: { OR?: unknown } };
if (!a.where.OR) {
return Promise.resolve([
{ id: "e1", takenAt: new Date(), skipped: false },
{ id: "e2", takenAt: null, skipped: false },
]) as never;
}
return Promise.resolve([]) as never;
}) as never);
const res = await callGet(makeReq());
const body = (await res.json()) as {
data: { compliance: { scheduledToday: number; takenToday: number } };
};
expect(body.data.compliance.scheduledToday).toBe(2);
expect(body.data.compliance.takenToday).toBe(1);
});
});
+362
View File
@@ -0,0 +1,362 @@
/**
* GET /api/dashboard/summary
*
* Aggregator endpoint for the iOS DashboardSummary view. Combines
* greeting, intake-day streaks, today's medication compliance, the
* highlighted insight, and per-metric latest+sparkline+trend.
*
* The shape is fixed for the iOS client and intentionally normalised —
* `kind` is iOS-friendly (camelCase), unlike the canonical Prisma enum
* (BLOOD_PRESSURE_SYS etc.).
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import type { MeasurementType } from "@/generated/prisma/client";
import { measurementTypeEnum } from "@/lib/validations/measurement";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { defaultLocale, type Locale } from "@/lib/i18n/config";
const SPARK_DAYS = 7;
const STREAK_WINDOW_DAYS = 365;
type MetricKind =
| "weight"
| "bloodPressure"
| "pulse"
| "bodyFat"
| "glucose"
| "sleep"
| "steps"
| "totalBodyWater"
| "boneMass"
| "oxygenSaturation";
interface MetricCard {
id: string;
kind: MetricKind;
title: string;
latestValue: number | null;
secondaryValue: number | null;
unit: string;
trend: "up" | "down" | "flat" | "unknown";
sparkline: number[];
updatedAt: string | null;
}
const METRIC_TITLES: Record<MetricKind, string> = {
weight: "Gewicht",
bloodPressure: "Blutdruck",
pulse: "Puls",
bodyFat: "Körperfett",
glucose: "Blutzucker",
sleep: "Schlaf",
steps: "Schritte",
totalBodyWater: "Gesamtkörperwasser",
boneMass: "Knochenmasse",
oxygenSaturation: "Sauerstoffsättigung",
};
const METRIC_UNITS: Record<MetricKind, string> = {
weight: "kg",
bloodPressure: "mmHg",
pulse: "bpm",
bodyFat: "%",
glucose: "mg/dL",
sleep: "h",
steps: "Schritte",
totalBodyWater: "kg",
boneMass: "kg",
oxygenSaturation: "%",
};
function trendOf(values: number[]): MetricCard["trend"] {
if (values.length < 2) return "unknown";
const first = values[0];
const last = values[values.length - 1];
const delta = last - first;
const epsilon = Math.max(1, Math.abs(first) * 0.01);
if (Math.abs(delta) < epsilon) return "flat";
return delta > 0 ? "up" : "down";
}
function startOfDayBerlin(date: Date): Date {
// Compute midnight in Europe/Berlin → UTC ms.
const berlin = new Intl.DateTimeFormat("en-US", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(date);
const y = berlin.find((p) => p.type === "year")?.value ?? "1970";
const m = berlin.find((p) => p.type === "month")?.value ?? "01";
const d = berlin.find((p) => p.type === "day")?.value ?? "01";
return new Date(`${y}-${m}-${d}T00:00:00.000Z`);
}
function berlinDayKey(date: Date): string {
return new Intl.DateTimeFormat("sv-SE", {
timeZone: "Europe/Berlin",
}).format(date);
}
interface StreakInfo {
currentDays: number;
longest: number;
}
/** Compute the current logging-day streak (days where any measurement or
* intake event was recorded, in Berlin time) plus the longest streak in
* the last `STREAK_WINDOW_DAYS` days. */
function computeStreak(activityDays: Set<string>): StreakInfo {
if (activityDays.size === 0) return { currentDays: 0, longest: 0 };
const sorted = [...activityDays].sort();
let longest = 1;
let run = 1;
for (let i = 1; i < sorted.length; i++) {
const prev = new Date(`${sorted[i - 1]}T00:00:00.000Z`).getTime();
const cur = new Date(`${sorted[i]}T00:00:00.000Z`).getTime();
if (cur - prev === 86_400_000) {
run += 1;
longest = Math.max(longest, run);
} else {
run = 1;
}
}
// Current streak: walk back from today (Berlin).
const todayKey = berlinDayKey(new Date());
let currentDays = 0;
let cursor = new Date(`${todayKey}T00:00:00.000Z`);
// Allow yesterday's last day to count if today not yet logged.
if (!activityDays.has(berlinDayKey(cursor))) {
cursor = new Date(cursor.getTime() - 86_400_000);
}
while (activityDays.has(berlinDayKey(cursor))) {
currentDays += 1;
cursor = new Date(cursor.getTime() - 86_400_000);
}
return { currentDays, longest };
}
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "dashboard.summary" } });
const now = new Date();
const sevenDaysAgo = new Date(now.getTime() - SPARK_DAYS * 86_400_000);
const streakWindowStart = new Date(
now.getTime() - STREAK_WINDOW_DAYS * 86_400_000,
);
const todayStart = startOfDayBerlin(now);
const todayEnd = new Date(todayStart.getTime() + 86_400_000);
// Derived from canonical enum so a new measurement type is auto-included
// (V3 audit: enum drift cousins). Per-kind display blocks below decide
// which types render as MetricCards.
const measurementTypes = [
...measurementTypeEnum.options,
] as MeasurementType[];
const [recentMeasurements, todaysIntakes, streakActivity] = await Promise.all(
[
prisma.measurement.findMany({
where: {
userId: user.id,
type: { in: measurementTypes },
measuredAt: { gte: sevenDaysAgo },
},
orderBy: { measuredAt: "asc" },
select: { type: true, value: true, measuredAt: true },
}),
prisma.medicationIntakeEvent.findMany({
where: {
userId: user.id,
scheduledFor: { gte: todayStart, lt: todayEnd },
},
select: { id: true, takenAt: true, skipped: true },
}),
prisma.medicationIntakeEvent.findMany({
where: {
userId: user.id,
scheduledFor: { gte: streakWindowStart },
OR: [{ takenAt: { not: null } }, { skipped: true }],
},
select: { takenAt: true, scheduledFor: true },
}),
],
);
const activityDays = new Set<string>();
for (const m of recentMeasurements)
activityDays.add(berlinDayKey(m.measuredAt));
// Pull a wider measurement window for the streak so it isn't capped by
// SPARK_DAYS — but only if the user has any data at all.
if (recentMeasurements.length > 0) {
const wider = await prisma.measurement.findMany({
where: {
userId: user.id,
measuredAt: { gte: streakWindowStart },
},
select: { measuredAt: true },
});
for (const m of wider) activityDays.add(berlinDayKey(m.measuredAt));
}
for (const e of streakActivity) {
activityDays.add(berlinDayKey(e.takenAt ?? e.scheduledFor));
}
const streak = computeStreak(activityDays);
// Per-type latest + sparkline.
const byType = new Map<MeasurementType, { value: number; at: Date }[]>();
for (const m of recentMeasurements) {
const list = byType.get(m.type) ?? [];
list.push({ value: m.value, at: m.measuredAt });
byType.set(m.type, list);
}
function latestOf(type: MeasurementType): { value: number; at: Date } | null {
const list = byType.get(type);
if (!list || list.length === 0) return null;
return list[list.length - 1];
}
function sparkOf(type: MeasurementType): number[] {
const list = byType.get(type);
if (!list) return [];
return list.map((p) => p.value);
}
const metrics: MetricCard[] = [];
// Weight
{
const latest = latestOf("WEIGHT");
const spark = sparkOf("WEIGHT");
metrics.push({
id: "weight",
kind: "weight",
title: METRIC_TITLES.weight,
latestValue: latest?.value ?? null,
secondaryValue: null,
unit: METRIC_UNITS.weight,
trend: trendOf(spark),
sparkline: spark,
updatedAt: latest?.at?.toISOString() ?? null,
});
}
// Blood pressure (paired sys/dia)
{
const sysList = byType.get("BLOOD_PRESSURE_SYS") ?? [];
const diaList = byType.get("BLOOD_PRESSURE_DIA") ?? [];
const latestSys = sysList[sysList.length - 1] ?? null;
const latestDia = diaList[diaList.length - 1] ?? null;
metrics.push({
id: "bp",
kind: "bloodPressure",
title: METRIC_TITLES.bloodPressure,
latestValue: latestSys?.value ?? null,
secondaryValue: latestDia?.value ?? null,
unit: METRIC_UNITS.bloodPressure,
trend: trendOf(sysList.map((p) => p.value)),
sparkline: sysList.map((p) => p.value),
updatedAt:
latestSys?.at?.toISOString() ?? latestDia?.at?.toISOString() ?? null,
});
}
// Pulse
{
const latest = latestOf("PULSE");
const spark = sparkOf("PULSE");
metrics.push({
id: "pulse",
kind: "pulse",
title: METRIC_TITLES.pulse,
latestValue: latest?.value ?? null,
secondaryValue: null,
unit: METRIC_UNITS.pulse,
trend: trendOf(spark),
sparkline: spark,
updatedAt: latest?.at?.toISOString() ?? null,
});
}
// Body fat
{
const latest = latestOf("BODY_FAT");
const spark = sparkOf("BODY_FAT");
if (latest) {
metrics.push({
id: "bodyFat",
kind: "bodyFat",
title: METRIC_TITLES.bodyFat,
latestValue: latest.value,
secondaryValue: null,
unit: METRIC_UNITS.bodyFat,
trend: trendOf(spark),
sparkline: spark,
updatedAt: latest.at.toISOString(),
});
}
}
// Optional cards — only emitted if the user has data for that type.
for (const [type, kind] of [
["BLOOD_GLUCOSE", "glucose"],
["SLEEP_DURATION", "sleep"],
["ACTIVITY_STEPS", "steps"],
["TOTAL_BODY_WATER", "totalBodyWater"],
["BONE_MASS", "boneMass"],
["OXYGEN_SATURATION", "oxygenSaturation"],
] as const) {
const latest = latestOf(type);
if (!latest) continue;
const spark = sparkOf(type);
metrics.push({
id: kind,
kind,
title: METRIC_TITLES[kind],
latestValue: latest.value,
secondaryValue: null,
unit: METRIC_UNITS[kind],
trend: trendOf(spark),
sparkline: spark,
updatedAt: latest.at.toISOString(),
});
}
const scheduledToday = todaysIntakes.length;
const takenToday = todaysIntakes.filter(
(e) => e.takenAt !== null && !e.skipped,
).length;
const greetingName = user.displayName ?? user.username;
const locale: Locale =
user.locale === "de" || user.locale === "en" ? user.locale : defaultLocale;
const t = getServerTranslator(locale).t;
return apiSuccess({
greeting: {
salutation: t("dashboard.greetingSalutation", { name: greetingName }),
date: now.toISOString(),
},
streak: {
currentDays: streak.currentDays,
longest: streak.longest,
label: t("dashboard.streakLabel"),
},
compliance: {
scheduledToday,
takenToday,
},
highlightInsight: null,
metrics,
lastUpdated: now.toISOString(),
});
});
+1
View File
@@ -32,6 +32,7 @@ const widgetIdEnum = z.enum([
"totalBodyWater",
"boneMass",
"bpInTarget",
"oxygenSaturation",
]);
const layoutSchema = z.object({
+122
View File
@@ -0,0 +1,122 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
device: {
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
};
function req(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/devices", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.device.findUnique).mockResolvedValue(null);
vi.mocked(prisma.device.create).mockResolvedValue({ id: "dev-1" } as never);
vi.mocked(prisma.device.update).mockResolvedValue({ id: "dev-1" } as never);
});
describe("POST /api/devices", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await POST(
req({
token: "abcd1234efgh5678",
bundleId: "io.healthlog.app",
}),
);
expect(res.status).toBe(401);
});
it("returns 422 for missing token", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await POST(req({ bundleId: "io.healthlog.app" }));
expect(res.status).toBe(422);
});
it("creates a new device row when token is unknown", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await POST(
req({
token: "abcd1234efgh5678",
bundleId: "io.healthlog.app",
locale: "de-DE",
appVersion: "1.0.0",
model: "iPhone15,2",
}),
);
expect(res.status).toBe(201);
expect(prisma.device.create).toHaveBeenCalledTimes(1);
expect(prisma.device.update).not.toHaveBeenCalled();
});
it("rejects cross-user re-registration with 409", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.device.findUnique).mockResolvedValue({
id: "dev-1",
userId: "other-user",
token: "abcd1234efgh5678",
} as never);
const res = await POST(
req({ token: "abcd1234efgh5678", bundleId: "io.healthlog.app" }),
);
expect(res.status).toBe(409);
expect(prisma.device.update).not.toHaveBeenCalled();
expect(prisma.device.create).not.toHaveBeenCalled();
});
it("updates in place when same user re-registers their own token", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.device.findUnique).mockResolvedValue({
id: "dev-1",
userId: "user-1",
token: "abcd1234efgh5678",
} as never);
const res = await POST(
req({ token: "abcd1234efgh5678", bundleId: "io.healthlog.app" }),
);
expect(res.status).toBe(201);
expect(prisma.device.update).toHaveBeenCalledTimes(1);
expect(prisma.device.create).not.toHaveBeenCalled();
});
});
+99
View File
@@ -0,0 +1,99 @@
/**
* POST /api/devices
*
* APNs device registration. Native clients call this on login and on
* APNs token rotation. We upsert by `token`; sending the same token on a
* different account simply transfers ownership.
*
* No APNs send-side logic lives here — that ships in Phase 8.
*/
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess, safeJson } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
const deviceSchema = z.object({
token: z
.string()
.min(8)
.max(512)
.regex(/^[A-Za-z0-9+/=._:-]+$/, "Invalid token format"),
bundleId: z.string().min(1).max(128),
locale: z.string().min(2).max(16).optional(),
appVersion: z.string().min(1).max(32).optional(),
model: z.string().min(1).max(64).optional(),
});
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { data: body, error } = await safeJson(request);
if (error) return error;
const parsed = deviceSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
}
const { token, bundleId, locale, appVersion, model } = parsed.data;
const existing = await prisma.device.findUnique({ where: { token } });
let id: string;
if (existing) {
// Cross-user-hijack guard: a device-token belongs to exactly one user.
// Re-registering the same token under a different account is rejected
// — APNs tokens aren't a secret, so trusting the wire input would let
// anyone who learns/guesses a token redirect another user's pushes.
if (existing.userId !== user.id) {
await auditLog("device.register.denied", {
userId: user.id,
details: { reason: "token_owned_by_other_user", deviceId: existing.id },
});
return apiError(
"Device token already registered to another account",
409,
);
}
const updated = await prisma.device.update({
where: { token },
data: {
platform: "ios",
bundleId,
locale: locale ?? null,
appVersion: appVersion ?? null,
model: model ?? null,
lastSeen: new Date(),
},
select: { id: true },
});
id = updated.id;
} else {
const created = await prisma.device.create({
data: {
userId: user.id,
platform: "ios",
token,
bundleId,
locale: locale ?? null,
appVersion: appVersion ?? null,
model: model ?? null,
},
select: { id: true },
});
id = created.id;
}
await auditLog("devices.register", {
userId: user.id,
details: { deviceId: id, bundleId, model: model ?? null },
});
annotate({
action: { name: "devices.register", entity_type: "device", entity_id: id },
});
return apiSuccess({ id }, 201);
});
@@ -0,0 +1,186 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
// --- Mocks must come before importing the route. ---
vi.mock("@/lib/db", () => ({
prisma: {
measurement: { findMany: vi.fn() },
medication: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn() },
user: { findUnique: vi.fn() },
auditLog: { create: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({
getSession: vi.fn(),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn(),
}));
vi.mock("@/lib/geo", () => ({
lookupIpLocation: vi.fn(),
}));
vi.mock("@/lib/analytics/effective-range", () => ({
getEffectiveRange: vi.fn(() => ({ range: null })),
}));
vi.mock("@/lib/logging/transports", () => ({
emitIfSampled: vi.fn(),
}));
import { POST } from "../pdf/route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { auditLog } from "@/lib/auth/audit";
function makeRequest(
body?: Record<string, unknown>,
headers: Record<string, string> = {},
): NextRequest {
const baseHeaders: Record<string, string> = {
"content-type": "application/json",
...headers,
};
return new NextRequest("http://localhost/api/doctor-report/pdf", {
method: "POST",
headers: baseHeaders,
body: body === undefined ? undefined : JSON.stringify(body),
});
}
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3600_000) },
user: { id: "user-1", role: "USER" as const },
};
function setEmptyDataMocks() {
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
username: "marc",
dateOfBirth: null,
gender: null,
heightCm: null,
glucoseUnit: null,
thresholdsJson: null,
} as never);
}
describe("POST /api/doctor-report/pdf", () => {
beforeEach(() => {
vi.resetAllMocks();
});
it("returns 401 when not authenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await POST(makeRequest({ days: 90 }));
expect(res.status).toBe(401);
const json = (await res.json()) as { error: string };
expect(json.error).toBe("Not authenticated");
});
it("returns 429 when rate limit exceeded", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
remaining: 0,
resetAt: Date.now() + 3600_000,
});
const res = await POST(makeRequest({ days: 90 }));
expect(res.status).toBe(429);
const json = (await res.json()) as { error: string };
expect(json.error).toMatch(/Maximum/);
});
it("returns 200 application/pdf with PDF bytes on happy path", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
resetAt: Date.now() + 3600_000,
});
vi.mocked(auditLog).mockResolvedValue(undefined as never);
setEmptyDataMocks();
const res = await POST(
makeRequest({ days: 30, locale: "de" }, { "accept-language": "de-DE" }),
);
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toBe("application/pdf");
expect(res.headers.get("cache-control")).toBe("no-store");
const disposition = res.headers.get("content-disposition") ?? "";
expect(disposition).toMatch(/attachment;\s*filename="healthlog-report-/);
expect(disposition).toMatch(/\.pdf"/);
const buf = Buffer.from(await res.arrayBuffer());
expect(buf.byteLength).toBeGreaterThan(1024);
expect(buf.slice(0, 5).toString("utf8")).toBe("%PDF-");
expect(auditLog).toHaveBeenCalledWith(
"doctor-report.pdf.generate",
expect.objectContaining({
userId: "user-1",
details: expect.objectContaining({ days: 30, locale: "de" }),
}),
);
});
it("falls back to default days=90 when body omits it", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
resetAt: Date.now() + 3600_000,
});
vi.mocked(auditLog).mockResolvedValue(undefined as never);
setEmptyDataMocks();
const res = await POST(makeRequest({}));
expect(res.status).toBe(200);
expect(auditLog).toHaveBeenCalledWith(
"doctor-report.pdf.generate",
expect.objectContaining({
details: expect.objectContaining({ days: 90 }),
}),
);
});
it("uses Accept-Language to pick the locale when body omits it", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
resetAt: Date.now() + 3600_000,
});
vi.mocked(auditLog).mockResolvedValue(undefined as never);
setEmptyDataMocks();
const res = await POST(
makeRequest({ days: 7 }, { "accept-language": "en-US,en;q=0.9" }),
);
expect(res.status).toBe(200);
expect(auditLog).toHaveBeenCalledWith(
"doctor-report.pdf.generate",
expect.objectContaining({
details: expect.objectContaining({ locale: "en" }),
}),
);
});
});
+114
View File
@@ -0,0 +1,114 @@
import { NextRequest, NextResponse } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { apiError, getClientIp } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import {
collectDoctorReportData,
normaliseDays,
} from "@/lib/doctor-report-data";
import { renderDoctorReportPdfBytes } from "@/lib/doctor-report-pdf-core";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { parseLocaleFromAcceptLanguage } from "@/lib/format-locale";
import { locales, type Locale } from "@/lib/i18n/config";
/**
* Server-rendered PDF doctor report.
*
* Mirrors `/api/doctor-report` (which returns JSON for client-side rendering)
* but produces the finished PDF bytes server-side. Useful on iOS / Safari where
* the client-side jsPDF download UX is unreliable.
*
* Auth, rate-limit (10/h), and audit-log policy match the JSON endpoint.
*/
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "doctor-report.pdf.generate" } });
const rl = await checkRateLimit(
`doctor-report:${user.id}`,
10,
60 * 60 * 1000,
);
if (!rl.allowed) {
return apiError("Maximum 10 reports per hour", 429);
}
// Body is optional — JSON is allowed but not required.
const body = await readOptionalJsonBody(request);
const days = normaliseDays(body?.days);
const locale = resolveLocale(
body?.locale,
request.headers.get("accept-language"),
);
const data = await collectDoctorReportData(user.id, days);
const { t } = getServerTranslator(locale);
const pdfBytes = renderDoctorReportPdfBytes(data, { t, locale });
await auditLog("doctor-report.pdf.generate", {
userId: user.id,
ipAddress: getClientIp(request),
details: { days, locale },
});
annotate({ meta: { report_days: days, locale, bytes: pdfBytes.byteLength } });
const filename = `healthlog-report-${new Date().toISOString().slice(0, 10)}.pdf`;
// NextResponse accepts a BodyInit; copy to a fresh ArrayBuffer to avoid
// SharedArrayBuffer typing surprises with Uint8Array.
const buffer = pdfBytes.slice().buffer;
return new NextResponse(buffer, {
status: 200,
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="${filename}"`,
"Content-Length": String(pdfBytes.byteLength),
"Cache-Control": "no-store",
},
});
});
interface PdfRequestBody {
days?: unknown;
locale?: unknown;
}
/**
* Best-effort JSON body parse. Returns `null` if the request had no body or
* a non-JSON content type — both are acceptable for this endpoint.
*/
async function readOptionalJsonBody(
request: NextRequest,
): Promise<PdfRequestBody | null> {
const contentType = request.headers.get("content-type") ?? "";
if (!contentType.includes("application/json")) return null;
try {
return (await request.json()) as PdfRequestBody;
} catch {
return null;
}
}
/**
* Resolve the active locale: explicit body override wins, then a parsed
* `Accept-Language`, then a hard fallback to German (the primary HealthLog
* locale for the medical context).
*/
function resolveLocale(
bodyLocale: unknown,
acceptLanguage: string | null,
): Locale {
if (
typeof bodyLocale === "string" &&
(locales as readonly string[]).includes(bodyLocale)
) {
return bodyLocale as Locale;
}
if (!acceptLanguage) return "de";
// parseLocaleFromAcceptLanguage returns "de" for de-* headers, otherwise
// "en" — matching the broader app's i18n behaviour for present headers.
return parseLocaleFromAcceptLanguage(acceptLanguage);
}
+18 -191
View File
@@ -1,16 +1,18 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
import {
apiSuccess,
apiError,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { NextRequest } from "next/server";
import {
getEffectiveRange,
type ThresholdOverridesJson,
} from "@/lib/analytics/effective-range";
import { resolveGlucoseUnit, thresholdMetricForContext } from "@/lib/glucose";
import type { GlucoseContext } from "@/generated/prisma/client";
collectDoctorReportData,
normaliseDays,
} from "@/lib/doctor-report-data";
/**
* Collect data for doctor report PDF generation (client-side).
@@ -20,203 +22,28 @@ export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "doctor-report.generate" } });
const rl = await checkRateLimit(`doctor-report:${user.id}`, 10, 60 * 60 * 1000);
const rl = await checkRateLimit(
`doctor-report:${user.id}`,
10,
60 * 60 * 1000,
);
if (!rl.allowed) {
return apiError("Maximum 10 reports per hour", 429);
}
const { data: body, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
const rawDays = (body as Record<string, unknown>)?.days;
const days =
typeof rawDays === "number" && Number.isInteger(rawDays) && rawDays >= 1 && rawDays <= 365
? rawDays
: 90;
const since = new Date();
since.setDate(since.getDate() - days);
const userId = user.id;
const [measurements, medications, intakeEvents, moodEntries, userProfile] =
await Promise.all([
prisma.measurement.findMany({
where: { userId, measuredAt: { gte: since } },
orderBy: { measuredAt: "asc" },
}),
prisma.medication.findMany({
where: { userId, active: true },
include: { schedules: true },
}),
prisma.medicationIntakeEvent.findMany({
where: { userId, scheduledFor: { gte: since } },
include: { medication: { select: { name: true } } },
orderBy: { scheduledFor: "asc" },
}),
prisma.moodEntry.findMany({
where: { userId, moodLoggedAt: { gte: since } },
orderBy: { moodLoggedAt: "asc" },
}),
prisma.user.findUnique({
where: { id: userId },
select: {
username: true,
dateOfBirth: true,
gender: true,
heightCm: true,
glucoseUnit: true,
thresholdsJson: true,
},
}),
]);
// Group measurements by type
const byType: Record<
string,
Array<{ value: number; measuredAt: string }>
> = {};
for (const m of measurements) {
if (!byType[m.type]) byType[m.type] = [];
byType[m.type].push({
value: m.value,
measuredAt: m.measuredAt.toISOString(),
});
}
// Calculate stats per type
const stats: Record<
string,
{ avg: number; min: number; max: number; count: number; latest: number }
> = {};
for (const [type, entries] of Object.entries(byType)) {
const values = entries.map((e) => e.value);
stats[type] = {
avg: values.reduce((a, b) => a + b, 0) / values.length,
min: Math.min(...values),
max: Math.max(...values),
count: values.length,
latest: values[values.length - 1],
};
}
// Medication compliance
const complianceByMed: Record<
string,
{ total: number; taken: number; skipped: number; missed: number }
> = {};
for (const event of intakeEvents) {
const name = event.medication.name;
if (!complianceByMed[name]) {
complianceByMed[name] = { total: 0, taken: 0, skipped: 0, missed: 0 };
}
complianceByMed[name].total++;
if (event.takenAt) {
complianceByMed[name].taken++;
} else if (event.skipped) {
complianceByMed[name].skipped++;
} else {
complianceByMed[name].missed++;
}
}
// Mood summary
const moodScores = moodEntries.map((e) => e.score);
const moodSummary =
moodScores.length > 0
? {
avg: moodScores.reduce((a, b) => a + b, 0) / moodScores.length,
min: Math.min(...moodScores),
max: Math.max(...moodScores),
count: moodScores.length,
distribution: {
1: moodScores.filter((s) => s === 1).length,
2: moodScores.filter((s) => s === 2).length,
3: moodScores.filter((s) => s === 3).length,
4: moodScores.filter((s) => s === 4).length,
5: moodScores.filter((s) => s === 5).length,
},
}
: null;
// BMI
const weightStats = stats.WEIGHT;
const bmi =
weightStats && userProfile?.heightCm
? weightStats.latest / (userProfile.heightCm / 100) ** 2
: null;
const days = normaliseDays((body as Record<string, unknown>)?.days);
const data = await collectDoctorReportData(user.id, days);
await auditLog("doctor-report.generate", {
userId,
userId: user.id,
ipAddress: getClientIp(request),
details: { days },
});
annotate({ meta: { report_days: days } });
// Per-context glucose stats (canonical mg/dL) and effective ranges.
const glucoseContexts: GlucoseContext[] = [
"FASTING",
"POSTPRANDIAL",
"RANDOM",
"BEDTIME",
];
const glucoseStats: Record<
string,
{ avg: number; min: number; max: number; count: number; latest: number }
> = {};
const glucoseRanges: Record<string, { min: number; max: number }> = {};
const glucoseRows = measurements.filter((m) => m.type === "BLOOD_GLUCOSE");
const overrides = (userProfile?.thresholdsJson ?? null) as ThresholdOverridesJson | null;
const profileForRange = {
heightCm: userProfile?.heightCm ?? null,
dateOfBirth: userProfile?.dateOfBirth ?? null,
gender: userProfile?.gender ?? null,
};
for (const ctx of glucoseContexts) {
const rows = glucoseRows.filter((m) => m.glucoseContext === ctx);
if (rows.length === 0) continue;
const values = rows.map((r) => r.value);
glucoseStats[ctx] = {
avg: values.reduce((a, b) => a + b, 0) / values.length,
min: Math.min(...values),
max: Math.max(...values),
count: values.length,
latest: values[values.length - 1],
};
const eff = getEffectiveRange(
thresholdMetricForContext(ctx),
profileForRange,
overrides,
);
if (eff.range) {
glucoseRanges[ctx] = { min: eff.range.greenMin, max: eff.range.greenMax };
}
}
return apiSuccess({
period: { days, since: since.toISOString() },
patient: {
username: userProfile?.username ?? null,
dateOfBirth: userProfile?.dateOfBirth ?? null,
gender: userProfile?.gender ?? null,
heightCm: userProfile?.heightCm ?? null,
},
measurements: byType,
stats,
glucoseStats,
glucoseRanges,
glucoseUnit: resolveGlucoseUnit(userProfile?.glucoseUnit ?? null),
bmi: bmi ? Math.round(bmi * 10) / 10 : null,
compliance: complianceByMed,
medications: medications.map((m) => ({
name: m.name,
dose: m.dose,
schedules: m.schedules.map((s) => ({
windowStart: s.windowStart,
windowEnd: s.windowEnd,
label: s.label,
})),
})),
mood: moodSummary,
});
return apiSuccess(data);
});
Loaded 100 of 395 files, more files were not shown because too many files have changed in this diff. Show more