Compare commits

..
25 Commits
Author SHA1 Message Date
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
109 changed files with 12688 additions and 3589 deletions

No files matched your search

+7
View File
@@ -39,3 +39,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 = []
+24 -15
View File
@@ -6,21 +6,21 @@ 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.3.3 — Pulse oximetry (SpO₂) as a first-class measurement type, layered on top of v1.3.2 body composition (TBW + Bone Mass). SSRF-hardened outbound fetches (now also covers Web-Push endpoint + Bearer-scope wildcard handling + IP-geolocation HTTPS-only), GHCR multi-arch images (`linux/amd64` + `linux/arm64`) with SLSA provenance + SBOM, pg-boss graceful SIGTERM drain + audit-log retention purge (GDPR Art. 5(1)(e)), blocking TypeScript CI, locale-integrity test guard. moodLog webhook secret now AES-GCM encrypted at rest. See GitHub Releases + CHANGELOG.md for the full feature timeline (v1.0 → v1.3).
## 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 |
@@ -76,10 +76,10 @@ 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 # All settings (8 top-level sections, ~3150 lines — split tracked for 1.4.0)
│ ├── 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 +93,9 @@ 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/ # Settings-page section components
│ └── monitoring/ # Umami, GlitchTip bootstrap
├── lib/
│ ├── db.ts # Prisma client singleton
@@ -109,6 +111,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 +126,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 (25 models)
└── migrations/ # Migration files (0001–0024; latest: oxygen_saturation)
prisma.config.ts # Prisma config (DB URL lives here, NOT in schema)
public/
├── sw.js # Service worker (Web Push + offline caching)
@@ -167,15 +176,15 @@ These are hard-won lessons. Ignoring them will cause errors:
### Settings Page
- One large file (~2100 lines), 13 sections. Sidebar switches to "settings mode" showing section shortcuts.
- One large file (~3150 lines), 8 top-level sections. Sidebar switches to "settings mode" showing section shortcuts. Splitting into per-section files is tracked for 1.4.0 — until then, ESLint `react-hooks/set-state-in-effect` stays non-blocking because of the long-standing violations in this file.
- 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`.
- Top-level section IDs: `section-allgemein`, `section-sicherheit`, `section-benachrichtigungen`, `section-personalization`, `section-integration`, `section-api`, `section-export`, `section-danger-zone`. Sub-anchors inside those sections include `profil`, `passwort`, `passkeys`, `telegram`, `ntfy`, `web-push`, `insights`, `dashboard-layout`, `thresholds`, `withings`, `moodlog`, `api-tokens`, `api-endpoints`.
### 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 +201,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 +216,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`.
25 models: `User`, `Passkey`, `Session`, `AuthChallenge`, `Measurement`, `Medication`, `MedicationSchedule`, `MedicationIntakeEvent`, `ReminderPhaseConfig`, `TelegramReminderMessage`, `TelegramScheduledDeletion`, `ApiToken`, `WithingsConnection`, `MoodEntry`, `AppSettings`, `Feedback`, `AuditLog`, `NotificationChannel`, `NotificationPreference`, `PushSubscription`, `DataBackup`, `UserAchievement`, `RateLimit`, `Device`, `IdempotencyKey`.
## When Making Changes
+131
View File
@@ -1,5 +1,136 @@
# Changelog
## [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
+15 -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 (25 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
@@ -81,12 +81,12 @@ 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 (25 models)
- `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 +107,12 @@ 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`
## 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
+109 -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,9 @@ 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.
---
@@ -102,7 +102,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 (25 models) |
| Job Queue | pg-boss 12 (reminders, insights, backups) |
| UI | shadcn/ui, Tailwind CSS 4, Radix UI, Lucide Icons |
| Charts | Recharts 3 |
@@ -163,30 +163,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 +216,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 +275,83 @@ 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 |
</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.
+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
+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`).
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.
+8
View File
@@ -91,6 +91,7 @@
"typeSteps": "Aktivität (Schritte)",
"typeTotalBodyWater": "Gesamtkörperwasser",
"typeBoneMass": "Knochenmasse",
"typeOxygenSaturation": "Sauerstoffsättigung",
"unitSteps": "Schritte",
"bmiUnderweight": "Untergewicht",
"bmiNormal": "Normalgewicht",
@@ -249,6 +250,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)",
@@ -538,6 +540,9 @@
"pointsAllTitle": "Alle verfügbaren Messpunkte",
"systolic": "Systolisch",
"diastolic": "Diastolisch",
"bodyWater": "Körperwasser",
"boneMass": "Knochenmasse",
"spo2": "SpO₂",
"movingAverage7d": "7T-Schnitt",
"avg7dShort": "7T",
"avg30dShort": "30T",
@@ -760,6 +765,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"
},
+8
View File
@@ -91,6 +91,7 @@
"typeSteps": "Activity (steps)",
"typeTotalBodyWater": "Total body water",
"typeBoneMass": "Bone mass",
"typeOxygenSaturation": "Oxygen saturation",
"unitSteps": "steps",
"bmiUnderweight": "Underweight",
"bmiNormal": "Normal weight",
@@ -249,6 +250,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)",
@@ -538,6 +540,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",
@@ -760,6 +765,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"
},
+9 -8
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.3.2",
"version": "1.3.3",
"private": true,
"packageManager": "pnpm@10.31.0",
"scripts": {
@@ -26,26 +26,26 @@
"@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": {
@@ -57,9 +57,10 @@
"@vitejs/plugin-react": "^5.1.4",
"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",
"tw-animate-css": "^1.4.0",
"typescript": "^6",
+272 -116
View File
@@ -10,7 +10,7 @@ importers:
dependencies:
'@hookform/resolvers':
specifier: ^5.2.2
version: 5.2.2(react-hook-form@7.74.0(react@19.2.5))
version: 5.2.2(react-hook-form@7.75.0(react@19.2.5))
'@node-rs/argon2':
specifier: ^2.0.2
version: 2.0.2
@@ -27,8 +27,8 @@ importers:
specifier: ^13.3.0
version: 13.3.0
'@tanstack/react-query':
specifier: ^5.100.5
version: 5.100.5(react@19.2.5)
specifier: ^5.100.9
version: 5.100.9(react@19.2.5)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -45,8 +45,8 @@ importers:
specifier: ^5.0.7
version: 5.0.7(jspdf@4.2.1)
lucide-react:
specifier: ^1.11.0
version: 1.11.0(react@19.2.5)
specifier: ^1.14.0
version: 1.14.0(react@19.2.5)
next:
specifier: 16.2.4
version: 16.2.4(@babel/core@7.29.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
@@ -54,8 +54,8 @@ importers:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
pg-boss:
specifier: ^12.18.1
version: 12.18.1
specifier: ^12.18.2
version: 12.18.2
prisma:
specifier: ^7.8.0
version: 7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.3)
@@ -69,8 +69,8 @@ importers:
specifier: 19.2.5
version: 19.2.5(react@19.2.5)
react-hook-form:
specifier: ^7.74.0
version: 7.74.0(react@19.2.5)
specifier: ^7.75.0
version: 7.75.0(react@19.2.5)
recharts:
specifier: ^3.8.1
version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react-is@16.13.1)(react@19.2.5)(redux@5.0.1)
@@ -84,8 +84,8 @@ importers:
specifier: ^3.6.7
version: 3.6.7
zod:
specifier: ^4.3.6
version: 4.3.6
specifier: ^4.4.2
version: 4.4.2
zxcvbn-typescript:
specifier: ^5.0.1
version: 5.0.1
@@ -114,15 +114,18 @@ importers:
eslint-config-next:
specifier: 16.2.4
version: 16.2.4(@typescript-eslint/parser@8.58.2(eslint@9.39.3(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.3(jiti@2.6.1))(typescript@6.0.3)
pdf-parse:
specifier: ^2.4.5
version: 2.4.5
prettier:
specifier: ^3.8.3
version: 3.8.3
prettier-plugin-tailwindcss:
specifier: ^0.7.3
version: 0.7.3(prettier@3.8.3)
specifier: ^0.8.0
version: 0.8.0(prettier@3.8.3)
shadcn:
specifier: ^4.5.0
version: 4.5.0(@types/node@25.6.0)(typescript@6.0.3)
specifier: ^4.6.0
version: 4.6.0(@types/node@25.6.0)(typescript@6.0.3)
tailwindcss:
specifier: ^4
version: 4.2.4
@@ -134,7 +137,7 @@ importers:
version: 6.0.3
vitest:
specifier: ^4.1.5
version: 4.1.5(@types/node@25.6.0)(msw@2.13.6(@types/node@25.6.0)(typescript@6.0.3))(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0))
version: 4.1.5(@types/node@25.6.0)(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0))
packages:
@@ -146,8 +149,8 @@ packages:
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
engines: {node: '>=6.9.0'}
'@babel/compat-data@7.29.0':
resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==}
'@babel/compat-data@7.29.3':
resolution: {integrity: sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==}
engines: {node: '>=6.9.0'}
'@babel/core@7.29.0':
@@ -166,8 +169,8 @@ packages:
resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==}
engines: {node: '>=6.9.0'}
'@babel/helper-create-class-features-plugin@7.28.6':
resolution: {integrity: sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==}
'@babel/helper-create-class-features-plugin@7.29.3':
resolution: {integrity: sha512-RpLYy2sb51oNLjuu1iD3bwBqCBWUzjO0ocp+iaCP/lJtb2CPLcnC2Fftw+4sAzaMELGeWTgExSKADbdo0GFVzA==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0
@@ -224,8 +227,8 @@ packages:
resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==}
engines: {node: '>=6.9.0'}
'@babel/parser@7.29.2':
resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==}
'@babel/parser@7.29.3':
resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==}
engines: {node: '>=6.0.0'}
hasBin: true
@@ -287,8 +290,8 @@ packages:
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'}
'@dotenvx/dotenvx@1.63.0':
resolution: {integrity: sha512-jjkmzIRu19uH78AjFInqfcALehbDCZZ7M09hurVawyqNxtOXEg2LR73L59y4QnzfYDEzjbhVzGAd2uDHu0D1aQ==}
'@dotenvx/dotenvx@1.64.0':
resolution: {integrity: sha512-6+xRpZaWuHXEqnhBjae+VmQI9Uaqw5Uzu/ScpO+W7ww9Zp3lHSNBoNjFcUxhrCyc7pRGQzyDjhKzloqrPHERiQ==}
hasBin: true
'@ecies/ciphers@0.2.6':
@@ -788,10 +791,79 @@ packages:
'@cfworker/json-schema':
optional: true
'@mswjs/interceptors@0.41.6':
resolution: {integrity: sha512-qmDvJIjcNsZ6tXWy2G9yuCgMPTTn35GMA3dPpSLm7QJVpbQzYdw0ALy1bKoivXnEM3U93/OrK+/M719b+fg84Q==}
'@mswjs/interceptors@0.41.8':
resolution: {integrity: sha512-pRLMNKTSGRoLq+KnEB/7OY5vijw1XmcheAAOiv6pj7W1FG32kAGqj1C/RK/cqxRGr1Fh+zBi8sDur8kj3EQv6A==}
engines: {node: '>=18'}
'@napi-rs/canvas-android-arm64@0.1.80':
resolution: {integrity: sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [android]
'@napi-rs/canvas-darwin-arm64@0.1.80':
resolution: {integrity: sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@napi-rs/canvas-darwin-x64@0.1.80':
resolution: {integrity: sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.80':
resolution: {integrity: sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@napi-rs/canvas-linux-arm64-gnu@0.1.80':
resolution: {integrity: sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-arm64-musl@0.1.80':
resolution: {integrity: sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@napi-rs/canvas-linux-riscv64-gnu@0.1.80':
resolution: {integrity: sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-x64-gnu@0.1.80':
resolution: {integrity: sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-x64-musl@0.1.80':
resolution: {integrity: sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@napi-rs/canvas-win32-x64-msvc@0.1.80':
resolution: {integrity: sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@napi-rs/canvas@0.1.80':
resolution: {integrity: sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==}
engines: {node: '>= 10'}
'@napi-rs/wasm-runtime@0.2.12':
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
@@ -2044,11 +2116,11 @@ packages:
'@tailwindcss/postcss@4.2.4':
resolution: {integrity: sha512-wgAVj6nUWAolAu8YFvzT2cTBIElWHkjZwFYovF+xsqKsW2ADxM/X2opxj5NsF/qVccAOjRNe8X2IdPzMsWyHTg==}
'@tanstack/query-core@5.100.5':
resolution: {integrity: sha512-t20KrhKkf0HXzqQkPbJ5erhFesup68BAbwFgYmTrS7bxMF7O5MdmL8jUkik4thsG7Hg00fblz30h6yF1d5TxGg==}
'@tanstack/query-core@5.100.9':
resolution: {integrity: sha512-SJSFw1S8+kQ0+knv/XGfrbocWoAlT7vDKsSImtLx3ZPQmEcR46hkDjLSvynSy25N8Ms4tIEini1FuBd5k7IscQ==}
'@tanstack/react-query@5.100.5':
resolution: {integrity: sha512-aNwj1mi2v2bQ9IxkyR1grLOUkv3BYWoykHy9KDyLNbjC3tsahbOHJibK+Wjtr1wRhG59/AvJhiJG5OlthaCgJA==}
'@tanstack/react-query@5.100.9':
resolution: {integrity: sha512-Oa44XkaI3kCNN6ME0KByU3xT3SEUNOMfZpHxL6+wFoTm+OeUFYHKdeYVe0aOXlRDm/f15sgLwEt2HDorIdW8+A==}
peerDependencies:
react: ^18 || ^19
@@ -2488,8 +2560,8 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
baseline-browser-mapping@2.10.23:
resolution: {integrity: sha512-xwVXGqevyKPsiuQdLj+dZMVjidjJV508TBqexND5HrF89cGdCYCJFB3qhcxRHSeMctdCfbR1jrxBajhDy7o29g==}
baseline-browser-mapping@2.10.27:
resolution: {integrity: sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==}
engines: {node: '>=6.0.0'}
hasBin: true
@@ -2870,8 +2942,8 @@ packages:
effect@3.20.0:
resolution: {integrity: sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==}
electron-to-chromium@1.5.344:
resolution: {integrity: sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==}
electron-to-chromium@1.5.349:
resolution: {integrity: sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A==}
emoji-regex@10.6.0:
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
@@ -3385,6 +3457,10 @@ packages:
resolution: {integrity: sha512-qM0jDhFEaCBb4TxoW7f53Qrpv9RBiayUHo0S52JudprkhvpjIrGoU1mnnr29Fvd1U335ZFPZQY1wlkqgfGXyLg==}
engines: {node: '>=16.9.0'}
hono@4.12.16:
resolution: {integrity: sha512-jN0ZewiNAWSe5khM3EyCmBb250+b40wWbwNILNfEvq84VREWwOIkuUsFONk/3i3nqkz7Oe1PcpM2mwQEK2L9Kg==}
engines: {node: '>=16.9.0'}
html2canvas@1.4.1:
resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==}
engines: {node: '>=8.0.0'}
@@ -3647,8 +3723,8 @@ packages:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
jose@6.2.2:
resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==}
jose@6.2.3:
resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -3834,8 +3910,8 @@ packages:
resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==}
engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'}
lucide-react@1.11.0:
resolution: {integrity: sha512-UOhjdztXCgdBReRcIhsvz2siIBogfv/lhJEIViCpLt924dO+GDms9T7DNoucI23s6kEPpe988m5N0D2ajnzb2g==}
lucide-react@1.14.0:
resolution: {integrity: sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
@@ -3904,8 +3980,8 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
msw@2.13.6:
resolution: {integrity: sha512-GAJbQy8Ra/Ydjt0Hb2MGT2qhzd83J3+QZMHdH85uW7r/XkKc846+Ma2PLif5hGvTm5Yqa+wkcstpim0WeLZU9g==}
msw@2.14.2:
resolution: {integrity: sha512-D2bTe0tpuf9nw4DA39wFaqUD/hRPKj0DKpo2lAqu+A47Ifg4+h0hbfn6QxVOsiUY2uhgEN6TTpGSHDsc+ysYNg==}
engines: {node: '>=18'}
hasBin: true
peerDependencies:
@@ -3931,6 +4007,11 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
nanoid@3.3.12:
resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
napi-postinstall@0.3.4:
resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
@@ -4128,14 +4209,23 @@ packages:
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
pdf-parse@2.4.5:
resolution: {integrity: sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==}
engines: {node: '>=20.16.0 <21 || >=22.3.0'}
hasBin: true
pdfjs-dist@5.4.296:
resolution: {integrity: sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==}
engines: {node: '>=20.16.0 || >=22.3.0'}
perfect-debounce@2.1.0:
resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==}
performance-now@2.1.0:
resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==}
pg-boss@12.18.1:
resolution: {integrity: sha512-eh7rRSTxUbPguKNDChWunKd//t4CuPFLqg9mL93sq9fai4YiGMe2HrSDqGF8gQ1Kn7VMxnB5IGRkPGDoMm4yrg==}
pg-boss@12.18.2:
resolution: {integrity: sha512-06kXeWvVWY+BUNsOt2me1okg6NXx2DBnAQHTurA9jtrvbAO9qUOSE3/0ERERQDrokI+FREFM2Twha+JbrFT/8Q==}
engines: {node: '>=22.12.0'}
hasBin: true
@@ -4207,6 +4297,10 @@ packages:
resolution: {integrity: sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==}
engines: {node: ^10 || ^12 || >=14}
postcss@8.5.13:
resolution: {integrity: sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==}
engines: {node: ^10 || ^12 || >=14}
postgres-array@2.0.0:
resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==}
engines: {node: '>=4'}
@@ -4239,8 +4333,8 @@ packages:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
prettier-plugin-tailwindcss@0.7.3:
resolution: {integrity: sha512-lckXaWWdo2ZVXoMoUO3WIBiz9hVY+YBEh1gYyMFfrWP9WZW/wpFXQKizHx7WrFQFMkcG0bGShdpp531X1n+qpg==}
prettier-plugin-tailwindcss@0.8.0:
resolution: {integrity: sha512-V8ITGH87yuBDF6JpEZTOVlUz/saAwqb8f3HRgUj8Lh+tGCcrmorhsLpYqzygwFwK0PE2Ib6Mv3M7T/uE2tZV1g==}
engines: {node: '>=20.19'}
peerDependencies:
'@ianvs/prettier-plugin-sort-imports': '*'
@@ -4383,8 +4477,8 @@ packages:
peerDependencies:
react: ^19.2.5
react-hook-form@7.74.0:
resolution: {integrity: sha512-yR6wHr99p9wFv686jhRWVSFhUvDvNbdUf2dKlbno8/VKOCuoNobDGC6S+M2dua9A9Yo8vpcrp8assIYbsZCQ9g==}
react-hook-form@7.75.0:
resolution: {integrity: sha512-Ovv94H+0p3sJ7B9B5QxPuCP1u8V/cHuVGyH55cSwodYDtoJwK+fqk3vjfIgSX59I2U/bU4z0nRJ9HMLpNiWEmw==}
engines: {node: '>=18.0.0'}
peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19
@@ -4514,8 +4608,8 @@ packages:
resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==}
engines: {node: '>= 4'}
rettime@0.11.8:
resolution: {integrity: sha512-0fERGXktJTyJ+h8fBEiPxHPEFOu0h15JY7JtwrOVqR5K+vb99ho6IyOo7ekLS3h4sJCzIDy4VWKIbZUfe9njmg==}
rettime@0.11.10:
resolution: {integrity: sha512-zzgUfAF20wZtfDs72M15qiX6EutHxgEZ1PAEJUsWQsOi4aOdcK8BJ3/WSDHBhJ7P27fQjd6iOE1+CWIO8t3XOA==}
reusify@1.1.0:
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
@@ -4604,8 +4698,8 @@ packages:
setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
shadcn@4.5.0:
resolution: {integrity: sha512-ZpNOz7IMI5aezbMEWNxBvl2aJ1ek6NuAMqpL/FUnk5IuRxERl8ohYEnqqAmhPOcur8RbGuCoqTZLQ3Oi4Xkf8A==}
shadcn@4.6.0:
resolution: {integrity: sha512-4XeMwFf8ZZxmqQQp+U+Nsq2M+cY4Da8Joo/EaMdHVc4uVuWSTJoeidlZ3gDjyxXCjYB1FLcxYwR4lYQAH8emOg==}
hasBin: true
sharp@0.34.5:
@@ -4821,11 +4915,11 @@ packages:
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
engines: {node: '>=14.0.0'}
tldts-core@7.0.28:
resolution: {integrity: sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==}
tldts-core@7.0.30:
resolution: {integrity: sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==}
tldts@7.0.28:
resolution: {integrity: sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==}
tldts@7.0.30:
resolution: {integrity: sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==}
hasBin: true
to-regex-range@5.0.1:
@@ -5152,8 +5246,8 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
yocto-spinner@1.1.0:
resolution: {integrity: sha512-/BY0AUXnS7IKO354uLLA2eRcWiqDifEbd6unXCsOxkFDAkhgUL3PH9X2bFoaU0YchnDXsF+iKleeTLJGckbXfA==}
yocto-spinner@1.2.0:
resolution: {integrity: sha512-Yw0hUB6UA3o4YUgKy3oSe9a4cxoaZ9sBfYDw+JSxo6Id0KoJGoxzPA24qqUXYKBWABs/zDSGTz9kww7t3F0XGw==}
engines: {node: '>=18.19'}
yoctocolors@2.1.2:
@@ -5177,8 +5271,8 @@ packages:
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
zod@4.3.6:
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
zod@4.4.2:
resolution: {integrity: sha512-IynmDyxsEsb9RKzO3J9+4SxXnl2FTFSzNBaKKaMV6tsSk0rw9gYw9gs+JFCq/qk2LCZ78KDwyj+Z289TijSkUw==}
zxcvbn-typescript@5.0.1:
resolution: {integrity: sha512-If2a6jdm278dfWmn6NL7oC1lNYRHOthBw0C78LsKvtAHHFNsMudvFhDn2fF/b74txWtSosJJiuJ7jBPn/xP6HQ==}
@@ -5193,7 +5287,7 @@ snapshots:
js-tokens: 4.0.0
picocolors: 1.1.1
'@babel/compat-data@7.29.0': {}
'@babel/compat-data@7.29.3': {}
'@babel/core@7.29.0':
dependencies:
@@ -5202,7 +5296,7 @@ snapshots:
'@babel/helper-compilation-targets': 7.28.6
'@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0)
'@babel/helpers': 7.29.2
'@babel/parser': 7.29.2
'@babel/parser': 7.29.3
'@babel/template': 7.28.6
'@babel/traverse': 7.29.0
'@babel/types': 7.29.0
@@ -5217,7 +5311,7 @@ snapshots:
'@babel/generator@7.29.1':
dependencies:
'@babel/parser': 7.29.2
'@babel/parser': 7.29.3
'@babel/types': 7.29.0
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31
@@ -5229,13 +5323,13 @@ snapshots:
'@babel/helper-compilation-targets@7.28.6':
dependencies:
'@babel/compat-data': 7.29.0
'@babel/compat-data': 7.29.3
'@babel/helper-validator-option': 7.27.1
browserslist: 4.28.2
lru-cache: 5.1.1
semver: 6.3.1
'@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.29.0)':
'@babel/helper-create-class-features-plugin@7.29.3(@babel/core@7.29.0)':
dependencies:
'@babel/core': 7.29.0
'@babel/helper-annotate-as-pure': 7.27.3
@@ -5306,7 +5400,7 @@ snapshots:
'@babel/template': 7.28.6
'@babel/types': 7.29.0
'@babel/parser@7.29.2':
'@babel/parser@7.29.3':
dependencies:
'@babel/types': 7.29.0
@@ -5342,7 +5436,7 @@ snapshots:
dependencies:
'@babel/core': 7.29.0
'@babel/helper-annotate-as-pure': 7.27.3
'@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0)
'@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.0)
'@babel/helper-plugin-utils': 7.28.6
'@babel/helper-skip-transparent-expression-wrappers': 7.27.1
'@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0)
@@ -5365,7 +5459,7 @@ snapshots:
'@babel/template@7.28.6':
dependencies:
'@babel/code-frame': 7.29.0
'@babel/parser': 7.29.2
'@babel/parser': 7.29.3
'@babel/types': 7.29.0
'@babel/traverse@7.29.0':
@@ -5373,7 +5467,7 @@ snapshots:
'@babel/code-frame': 7.29.0
'@babel/generator': 7.29.1
'@babel/helper-globals': 7.28.0
'@babel/parser': 7.29.2
'@babel/parser': 7.29.3
'@babel/template': 7.28.6
'@babel/types': 7.29.0
debug: 4.4.3
@@ -5385,7 +5479,7 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
'@dotenvx/dotenvx@1.63.0':
'@dotenvx/dotenvx@1.64.0':
dependencies:
commander: 11.1.0
dotenv: 17.4.2
@@ -5396,7 +5490,7 @@ snapshots:
object-treeify: 1.1.33
picomatch: 4.0.4
which: 4.0.0
yocto-spinner: 1.1.0
yocto-spinner: 1.2.0
'@ecies/ciphers@0.2.6(@noble/ciphers@1.3.0)':
dependencies:
@@ -5580,14 +5674,14 @@ snapshots:
dependencies:
hono: 4.12.15
'@hono/node-server@1.19.14(hono@4.12.15)':
'@hono/node-server@1.19.14(hono@4.12.16)':
dependencies:
hono: 4.12.15
hono: 4.12.16
'@hookform/resolvers@5.2.2(react-hook-form@7.74.0(react@19.2.5))':
'@hookform/resolvers@5.2.2(react-hook-form@7.75.0(react@19.2.5))':
dependencies:
'@standard-schema/utils': 0.3.0
react-hook-form: 7.74.0(react@19.2.5)
react-hook-form: 7.75.0(react@19.2.5)
'@humanfs/core@0.19.1': {}
@@ -5749,7 +5843,7 @@ snapshots:
'@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)':
dependencies:
'@hono/node-server': 1.19.14(hono@4.12.15)
'@hono/node-server': 1.19.14(hono@4.12.16)
ajv: 8.20.0
ajv-formats: 3.0.1(ajv@8.20.0)
content-type: 1.0.5
@@ -5759,8 +5853,8 @@ snapshots:
eventsource-parser: 3.0.8
express: 5.2.1
express-rate-limit: 8.4.1(express@5.2.1)
hono: 4.12.15
jose: 6.2.2
hono: 4.12.16
jose: 6.2.3
json-schema-typed: 8.0.2
pkce-challenge: 5.0.1
raw-body: 3.0.2
@@ -5769,7 +5863,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@mswjs/interceptors@0.41.6':
'@mswjs/interceptors@0.41.8':
dependencies:
'@open-draft/deferred-promise': 2.2.0
'@open-draft/logger': 0.3.0
@@ -5778,6 +5872,49 @@ snapshots:
outvariant: 1.4.3
strict-event-emitter: 0.5.1
'@napi-rs/canvas-android-arm64@0.1.80':
optional: true
'@napi-rs/canvas-darwin-arm64@0.1.80':
optional: true
'@napi-rs/canvas-darwin-x64@0.1.80':
optional: true
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.80':
optional: true
'@napi-rs/canvas-linux-arm64-gnu@0.1.80':
optional: true
'@napi-rs/canvas-linux-arm64-musl@0.1.80':
optional: true
'@napi-rs/canvas-linux-riscv64-gnu@0.1.80':
optional: true
'@napi-rs/canvas-linux-x64-gnu@0.1.80':
optional: true
'@napi-rs/canvas-linux-x64-musl@0.1.80':
optional: true
'@napi-rs/canvas-win32-x64-msvc@0.1.80':
optional: true
'@napi-rs/canvas@0.1.80':
optionalDependencies:
'@napi-rs/canvas-android-arm64': 0.1.80
'@napi-rs/canvas-darwin-arm64': 0.1.80
'@napi-rs/canvas-darwin-x64': 0.1.80
'@napi-rs/canvas-linux-arm-gnueabihf': 0.1.80
'@napi-rs/canvas-linux-arm64-gnu': 0.1.80
'@napi-rs/canvas-linux-arm64-musl': 0.1.80
'@napi-rs/canvas-linux-riscv64-gnu': 0.1.80
'@napi-rs/canvas-linux-x64-gnu': 0.1.80
'@napi-rs/canvas-linux-x64-musl': 0.1.80
'@napi-rs/canvas-win32-x64-msvc': 0.1.80
'@napi-rs/wasm-runtime@0.2.12':
dependencies:
'@emnapi/core': 1.8.1
@@ -7036,11 +7173,11 @@ snapshots:
postcss: 8.5.12
tailwindcss: 4.2.4
'@tanstack/query-core@5.100.5': {}
'@tanstack/query-core@5.100.9': {}
'@tanstack/react-query@5.100.5(react@19.2.5)':
'@tanstack/react-query@5.100.9(react@19.2.5)':
dependencies:
'@tanstack/query-core': 5.100.5
'@tanstack/query-core': 5.100.9
react: 19.2.5
'@ts-morph/common@0.27.0':
@@ -7056,7 +7193,7 @@ snapshots:
'@types/babel__core@7.20.5':
dependencies:
'@babel/parser': 7.29.2
'@babel/parser': 7.29.3
'@babel/types': 7.29.0
'@types/babel__generator': 7.27.0
'@types/babel__template': 7.4.4
@@ -7068,7 +7205,7 @@ snapshots:
'@types/babel__template@7.4.4':
dependencies:
'@babel/parser': 7.29.2
'@babel/parser': 7.29.3
'@babel/types': 7.29.0
'@types/babel__traverse@7.28.0':
@@ -7323,13 +7460,13 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.1.0
'@vitest/mocker@4.1.5(msw@2.13.6(@types/node@25.6.0)(typescript@6.0.3))(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0))':
'@vitest/mocker@4.1.5(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0))':
dependencies:
'@vitest/spy': 4.1.5
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
msw: 2.13.6(@types/node@25.6.0)(typescript@6.0.3)
msw: 2.14.2(@types/node@25.6.0)(typescript@6.0.3)
vite: 7.3.1(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)
'@vitest/pretty-format@4.1.5':
@@ -7512,7 +7649,7 @@ snapshots:
baseline-browser-mapping@2.10.20: {}
baseline-browser-mapping@2.10.23: {}
baseline-browser-mapping@2.10.27: {}
better-result@2.8.2: {}
@@ -7552,9 +7689,9 @@ snapshots:
browserslist@4.28.2:
dependencies:
baseline-browser-mapping: 2.10.23
baseline-browser-mapping: 2.10.27
caniuse-lite: 1.0.30001791
electron-to-chromium: 1.5.344
electron-to-chromium: 1.5.349
node-releases: 2.0.38
update-browserslist-db: 1.2.3(browserslist@4.28.2)
@@ -7867,7 +8004,7 @@ snapshots:
'@standard-schema/spec': 1.1.0
fast-check: 3.23.2
electron-to-chromium@1.5.344: {}
electron-to-chromium@1.5.349: {}
emoji-regex@10.6.0: {}
@@ -8137,11 +8274,11 @@ snapshots:
eslint-plugin-react-hooks@7.1.1(eslint@9.39.3(jiti@2.6.1)):
dependencies:
'@babel/core': 7.29.0
'@babel/parser': 7.29.2
'@babel/parser': 7.29.3
eslint: 9.39.3(jiti@2.6.1)
hermes-parser: 0.25.1
zod: 4.3.6
zod-validation-error: 4.0.2(zod@4.3.6)
zod: 4.4.2
zod-validation-error: 4.0.2(zod@4.4.2)
transitivePeerDependencies:
- supports-color
@@ -8574,6 +8711,8 @@ snapshots:
hono@4.12.15: {}
hono@4.12.16: {}
html2canvas@1.4.1:
dependencies:
css-line-break: 2.1.0
@@ -8805,7 +8944,7 @@ snapshots:
jiti@2.6.1: {}
jose@6.2.2: {}
jose@6.2.3: {}
js-tokens@4.0.0: {}
@@ -8965,7 +9104,7 @@ snapshots:
lru.min@1.1.4: {}
lucide-react@1.11.0(react@19.2.5):
lucide-react@1.14.0(react@19.2.5):
dependencies:
react: 19.2.5
@@ -9018,10 +9157,10 @@ snapshots:
ms@2.1.3: {}
msw@2.13.6(@types/node@25.6.0)(typescript@6.0.3):
msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3):
dependencies:
'@inquirer/confirm': 6.0.12(@types/node@25.6.0)
'@mswjs/interceptors': 0.41.6
'@mswjs/interceptors': 0.41.8
'@open-draft/deferred-promise': 3.0.0
'@types/statuses': 2.0.6
cookie: 1.1.1
@@ -9031,7 +9170,7 @@ snapshots:
outvariant: 1.4.3
path-to-regexp: 6.3.0
picocolors: 1.1.1
rettime: 0.11.8
rettime: 0.11.10
statuses: 2.0.2
strict-event-emitter: 0.5.1
tough-cookie: 6.0.1
@@ -9063,6 +9202,8 @@ snapshots:
nanoid@3.3.11: {}
nanoid@3.3.12: {}
napi-postinstall@0.3.4: {}
natural-compare@1.4.0: {}
@@ -9269,12 +9410,21 @@ snapshots:
pathe@2.0.3: {}
pdf-parse@2.4.5:
dependencies:
'@napi-rs/canvas': 0.1.80
pdfjs-dist: 5.4.296
pdfjs-dist@5.4.296:
optionalDependencies:
'@napi-rs/canvas': 0.1.80
perfect-debounce@2.1.0: {}
performance-now@2.1.0:
optional: true
pg-boss@12.18.1:
pg-boss@12.18.2:
dependencies:
cron-parser: 5.5.0
pg: 8.20.0
@@ -9350,6 +9500,12 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
postcss@8.5.13:
dependencies:
nanoid: 3.3.12
picocolors: 1.1.1
source-map-js: 1.2.1
postgres-array@2.0.0: {}
postgres-array@3.0.4: {}
@@ -9368,7 +9524,7 @@ snapshots:
prelude-ls@1.2.1: {}
prettier-plugin-tailwindcss@0.7.3(prettier@3.8.3):
prettier-plugin-tailwindcss@0.8.0(prettier@3.8.3):
dependencies:
prettier: 3.8.3
@@ -9520,7 +9676,7 @@ snapshots:
react: 19.2.5
scheduler: 0.27.0
react-hook-form@7.74.0(react@19.2.5):
react-hook-form@7.75.0(react@19.2.5):
dependencies:
react: 19.2.5
@@ -9655,7 +9811,7 @@ snapshots:
retry@0.12.0: {}
rettime@0.11.8: {}
rettime@0.11.10: {}
reusify@1.1.0: {}
@@ -9796,13 +9952,13 @@ snapshots:
setprototypeof@1.2.0: {}
shadcn@4.5.0(@types/node@25.6.0)(typescript@6.0.3):
shadcn@4.6.0(@types/node@25.6.0)(typescript@6.0.3):
dependencies:
'@babel/core': 7.29.0
'@babel/parser': 7.29.2
'@babel/parser': 7.29.3
'@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0)
'@babel/preset-typescript': 7.28.5(@babel/core@7.29.0)
'@dotenvx/dotenvx': 1.63.0
'@dotenvx/dotenvx': 1.64.0
'@modelcontextprotocol/sdk': 1.29.0(zod@3.25.76)
'@types/validate-npm-package-name': 4.0.2
browserslist: 4.28.2
@@ -9817,11 +9973,11 @@ snapshots:
fuzzysort: 3.1.0
https-proxy-agent: 7.0.6
kleur: 4.1.5
msw: 2.13.6(@types/node@25.6.0)(typescript@6.0.3)
msw: 2.14.2(@types/node@25.6.0)(typescript@6.0.3)
node-fetch: 3.3.2
open: 11.0.0
ora: 8.2.0
postcss: 8.5.12
postcss: 8.5.13
postcss-selector-parser: 7.1.1
prompts: 2.4.2
recast: 0.23.11
@@ -10074,11 +10230,11 @@ snapshots:
tinyrainbow@3.1.0: {}
tldts-core@7.0.28: {}
tldts-core@7.0.30: {}
tldts@7.0.28:
tldts@7.0.30:
dependencies:
tldts-core: 7.0.28
tldts-core: 7.0.30
to-regex-range@5.0.1:
dependencies:
@@ -10088,7 +10244,7 @@ snapshots:
tough-cookie@6.0.1:
dependencies:
tldts: 7.0.28
tldts: 7.0.30
ts-api-utils@2.5.0(typescript@6.0.3):
dependencies:
@@ -10289,7 +10445,7 @@ snapshots:
esbuild: 0.27.7
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
postcss: 8.5.12
postcss: 8.5.13
rollup: 4.60.2
tinyglobby: 0.2.16
optionalDependencies:
@@ -10298,10 +10454,10 @@ snapshots:
jiti: 2.6.1
lightningcss: 1.32.0
vitest@4.1.5(@types/node@25.6.0)(msw@2.13.6(@types/node@25.6.0)(typescript@6.0.3))(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)):
vitest@4.1.5(@types/node@25.6.0)(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)):
dependencies:
'@vitest/expect': 4.1.5
'@vitest/mocker': 4.1.5(msw@2.13.6(@types/node@25.6.0)(typescript@6.0.3))(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0))
'@vitest/mocker': 4.1.5(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0))
'@vitest/pretty-format': 4.1.5
'@vitest/runner': 4.1.5
'@vitest/snapshot': 4.1.5
@@ -10426,7 +10582,7 @@ snapshots:
yocto-queue@0.1.0: {}
yocto-spinner@1.1.0:
yocto-spinner@1.2.0:
dependencies:
yoctocolors: 2.1.2
@@ -10441,12 +10597,12 @@ snapshots:
dependencies:
zod: 3.25.76
zod-validation-error@4.0.2(zod@4.3.6):
zod-validation-error@4.0.2(zod@4.4.2):
dependencies:
zod: 4.3.6
zod: 4.4.2
zod@3.25.76: {}
zod@4.3.6: {}
zod@4.4.2: {}
zxcvbn-typescript@5.0.1: {}
@@ -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';
+57
View File
@@ -74,6 +74,14 @@ model User {
// 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[]
@@ -90,6 +98,8 @@ model User {
dataBackups DataBackup[]
achievements UserAchievement[]
feedback Feedback[]
idempotencyKeys IdempotencyKey[]
devices Device[]
@@map("users")
}
@@ -152,6 +162,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")
}
@@ -559,6 +570,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 {
@@ -0,0 +1,94 @@
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", () => ({
resolveProvider: 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 { resolveProvider } from "@/lib/ai/provider";
beforeEach(() => {
vi.resetAllMocks();
});
interface ApiErrorEnvelope {
data: null;
error: string;
}
// 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(resolveProvider).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();
const body = (await response.json()) as ApiErrorEnvelope;
expect(response.status).toBe(502);
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();
expect(response.status).toBe(502);
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();
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();
expect(((await response.json()) as ApiErrorEnvelope).error).toBe(
"Provider connection failed",
);
});
});
+22 -4
View File
@@ -34,10 +34,28 @@ 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,
},
});
const status = err.httpStatus ?? 0;
const safeMessage =
status === 401 || status === 403
? "Provider rejected the credentials"
: status === 429
? "Provider rate-limited the request"
: status >= 500
? "Provider returned a server error"
: "Provider connection failed";
return apiError(safeMessage, 502);
}
});
+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) =>
@@ -0,0 +1,133 @@
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() },
},
}));
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(() => "deadbeef"),
}));
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(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);
} 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_/);
} finally {
process.env.API_TOKEN_HMAC_KEY = original;
}
});
});
+24
View File
@@ -9,6 +9,7 @@ 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";
export const POST = apiHandler(async (request: NextRequest) => {
const ip = getClientIp(request) ?? "unknown";
@@ -74,6 +75,29 @@ export const POST = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "auth.login.password" } });
// Native clients (iOS) additionally receive a long-lived Bearer token in
// the response so they don't need to juggle the cookie jar.
if (isNativeClientRequest(request.headers)) {
const issued = await issueApiToken({
userId: user.id,
name: `iOS auto-login ${new Date().toISOString()}`,
permissions: ["*"],
expiresInDays: 90,
});
await auditLog("auth.token.autoissue.native", {
userId: user.id,
ipAddress: ip,
details: { tokenId: issued.tokenId, source: "login.password" },
});
annotate({ action: { name: "auth.token.autoissue.native" } });
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,116 @@
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() },
},
}));
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(() => "deadbeef"),
}));
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(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);
} 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();
});
});
@@ -8,6 +8,7 @@ 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";
export const POST = apiHandler(async (request: NextRequest) => {
await ensureDbCompatibility();
@@ -59,6 +60,27 @@ export const POST = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "auth.login.passkey" } });
if (isNativeClientRequest(request.headers)) {
const issued = await issueApiToken({
userId: user.id,
name: `iOS auto-login ${new Date().toISOString()}`,
permissions: ["*"],
expiresInDays: 90,
});
await auditLog("auth.token.autoissue.native", {
userId: user.id,
ipAddress: ip,
details: { tokenId: issued.tokenId, source: "login.passkey" },
});
annotate({ action: { name: "auth.token.autoissue.native" } });
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 },
});
+12 -44
View File
@@ -1,62 +1,30 @@
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 { 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,
});
});
@@ -0,0 +1,108 @@
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);
});
});
+355
View File
@@ -0,0 +1,355 @@
/**
* 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";
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;
return apiSuccess({
greeting: {
salutation: `Hi, ${greetingName}`,
date: now.toISOString(),
},
streak: {
currentDays: streak.currentDays,
longest: streak.longest,
label: "Tage in Folge",
},
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({
+118
View File
@@ -0,0 +1,118 @@
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();
});
});
+96
View File
@@ -0,0 +1,96 @@
/**
* 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);
});
@@ -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() },
medication: { findMany: vi.fn() },
passkey: { findMany: vi.fn() },
auditLog: { findMany: vi.fn() },
userAchievement: {
findMany: vi.fn(),
createMany: 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,
createdAt: new Date("2026-03-01T00:00:00.000Z"),
heightCm: null,
locale: "en",
},
};
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.passkey.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.userAchievement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.userAchievement.createMany).mockResolvedValue({
count: 0,
} as never);
});
describe("GET /api/gamification/achievements?format=ios", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await GET(
new NextRequest(
"http://localhost/api/gamification/achievements?format=ios",
),
);
expect(res.status).toBe(401);
});
it("returns a flat iOS-shaped achievement array", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await GET(
new NextRequest(
"http://localhost/api/gamification/achievements?format=ios",
),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: Array<{
id: string;
key: string;
title: string;
description: string;
iconName: string;
unlocked: boolean;
unlockedAt: string | null;
progress: number;
}>;
};
expect(Array.isArray(body.data)).toBe(true);
expect(body.data.length).toBeGreaterThan(0);
const first = body.data[0];
expect(first.id).toEqual(expect.any(String));
expect(first.key).toEqual(expect.any(String));
expect(first.title).toEqual(expect.any(String));
expect(first.iconName).toEqual(expect.any(String));
expect(first.progress).toBeGreaterThanOrEqual(0);
expect(first.progress).toBeLessThanOrEqual(1);
});
it("falls back to the legacy wrapped shape when no format is given", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await GET(
new NextRequest("http://localhost/api/gamification/achievements"),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { achievements: unknown[]; summary: unknown };
};
expect(body.data.achievements).toBeDefined();
expect(body.data.summary).toBeDefined();
});
});
+40 -2
View File
@@ -15,6 +15,20 @@ import {
classifyPulse,
} from "@/lib/analytics/classifications";
import { classifyIntakeTiming } from "@/lib/analytics/compliance";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import type { NextRequest } from "next/server";
interface IosAchievement {
id: string;
key: string;
title: string;
description: string;
iconName: string;
unlocked: boolean;
unlockedAt: string | null;
progress: number;
}
export const dynamic = "force-dynamic";
@@ -431,9 +445,14 @@ function getCompliance80DaySeries(
return toDaySeries(greenDays);
}
export const GET = apiHandler(async () => {
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "gamification.achievements" } });
const formatParam = request.nextUrl.searchParams.get("format");
const isIosFormat = formatParam === "ios";
annotate({
action: { name: "gamification.achievements" },
meta: { format: isIosFormat ? "ios" : "default" },
});
const now = new Date();
const userId = user.id;
@@ -742,5 +761,24 @@ export const GET = apiHandler(async () => {
});
}
if (isIosFormat) {
const locale = await resolveServerLocale({
request,
userLocale: user.locale,
});
const t = getServerTranslator(locale);
const ios: IosAchievement[] = result.achievements.map((a) => ({
id: a.id,
key: a.id,
title: t.t(a.titleKey),
description: t.t(a.descriptionKey),
iconName: a.icon,
unlocked: a.unlocked,
unlockedAt: a.completedAt,
progress: Math.max(0, Math.min(1, a.progressPercent / 100)),
}));
return apiSuccess(ios);
}
return apiSuccess(result);
});
@@ -0,0 +1,85 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
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/db", () => ({
prisma: {
measurement: { create: vi.fn() },
moodEntry: { create: vi.fn() },
},
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
import { NextRequest } from "next/server";
import { POST } from "../route";
import { checkRateLimit } from "@/lib/rate-limit";
beforeEach(() => {
vi.resetAllMocks();
});
interface ApiErrorEnvelope {
data: null;
error: string;
}
// V3 audit: /api/import POST had no rate-limit. Bulk-injection vector
// (max:10000 records per call). Now capped at 5/hour/user.
describe("POST /api/import — rate-limit guard", () => {
it("returns 429 when the user has exhausted the 5/hour quota (HIGH coverage gap)", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
remaining: 0,
resetAt: new Date(Date.now() + 1000),
} as never);
const response = await POST(
new NextRequest("http://localhost/api/import", {
method: "POST",
body: JSON.stringify({ measurements: [] }),
headers: { "content-type": "application/json" },
}),
);
expect(response.status).toBe(429);
const body = (await response.json()) as ApiErrorEnvelope;
expect(body.error).toMatch(/per hour/i);
expect(checkRateLimit).toHaveBeenCalledWith(
expect.stringContaining("import:u-1"),
5,
60 * 60 * 1000,
);
});
it("processes the request when within the quota", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 4,
resetAt: new Date(Date.now() + 1000),
} as never);
const response = await POST(
new NextRequest("http://localhost/api/import", {
method: "POST",
body: JSON.stringify({ measurements: [] }),
headers: { "content-type": "application/json" },
}),
);
expect(response.status).toBeLessThan(400);
});
});
+21 -11
View File
@@ -2,26 +2,27 @@ 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, 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 { z } from "zod/v4";
import { validateMeasurementRange } from "@/lib/validations/measurement";
import {
validateMeasurementRange,
measurementTypeEnum,
glucoseContextEnum,
} from "@/lib/validations/measurement";
// Derived from canonical enum so round-trip export → import covers every
// type. Previous hardcoded subset silently dropped 4 of 11 types
// (V3 audit: enum drift cousins).
const measurementSchema = z
.object({
type: z.enum([
"WEIGHT",
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",
"PULSE",
"BODY_FAT",
"SLEEP_DURATION",
"ACTIVITY_STEPS",
]),
type: measurementTypeEnum,
value: z.number(),
unit: z.string(),
measuredAt: z.string().datetime(),
glucoseContext: glucoseContextEnum.optional(),
source: z.string().optional(),
notes: z.string().optional(),
})
@@ -51,6 +52,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "import.upload" } });
// V3 audit: bulk-injection vector unchecked. 5/hour matches the export
// limit (10/hour) but is tighter because import writes have a higher
// blast radius (DB writes vs. read-only export).
const rl = await checkRateLimit(`import:${user.id}`, 5, 60 * 60 * 1000);
if (!rl.allowed) {
return apiError("Maximum 5 imports per hour", 429);
}
const { data: body, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
@@ -76,6 +85,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
source: "IMPORT",
measuredAt: new Date(m.measuredAt),
notes: m.notes || null,
glucoseContext: m.glucoseContext ?? null,
},
});
stats.measurements++;
+8 -2
View File
@@ -62,7 +62,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("Token expired", 401);
}
if (!apiToken.permissions.includes("medication:ingest")) {
if (
!apiToken.permissions.includes("*") &&
!apiToken.permissions.includes("medication:ingest")
) {
return apiError("Insufficient permissions", 403);
}
@@ -113,7 +116,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
const medicationScope = `medication:${medication.id}:ingest`;
if (!apiToken.permissions.includes(medicationScope)) {
if (
!apiToken.permissions.includes("*") &&
!apiToken.permissions.includes(medicationScope)
) {
return apiError("API endpoint for this medication is disabled", 403);
}
@@ -0,0 +1,102 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn() },
measurement: { findMany: vi.fn() },
medication: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: 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 { 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 },
};
beforeEach(() => {
vi.resetAllMocks();
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.user.findUnique).mockResolvedValue({
heightCm: null,
dateOfBirth: null,
aiProvider: null,
} as never);
});
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
function makeReq(): NextRequest {
return new NextRequest("http://localhost/api/insights/cards");
}
describe("GET /api/insights/cards", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await callGet(makeReq());
expect(res.status).toBe(401);
});
it("returns an empty array when no data is available", 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: unknown[] };
expect(Array.isArray(body.data)).toBe(true);
expect(body.data).toHaveLength(0);
});
it("emits cards when measurements trigger alerts", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// Provide enough sys + dia measurements to trigger danger-level alert.
const now = new Date();
const measurements: Array<{
type: string;
value: number;
measuredAt: Date;
}> = [];
for (let i = 0; i < 10; i++) {
const at = new Date(now.getTime() - i * 86_400_000);
measurements.push({ type: "BLOOD_PRESSURE_SYS", value: 180, measuredAt: at });
measurements.push({ type: "BLOOD_PRESSURE_DIA", value: 110, measuredAt: at });
}
vi.mocked(prisma.measurement.findMany).mockResolvedValue(measurements as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
heightCm: null,
dateOfBirth: null,
aiProvider: "ANTHROPIC",
} as never);
const res = await callGet(makeReq());
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: Array<{ severity: string; provider: string; title: string }>;
};
expect(body.data.length).toBeGreaterThan(0);
expect(body.data[0].provider).toBe("anthropic");
expect(body.data.some((c) => c.severity === "alert")).toBe(true);
});
});
+196
View File
@@ -0,0 +1,196 @@
/**
* GET /api/insights/cards — iOS adapter over `/api/insights/comprehensive`.
*
* Reuses the same data sources (measurements, mood, intake events) and the
* shared `generateAlerts()` rule engine, then re-shapes each `HealthAlert`
* to the iOS Insight model (id, title, summary, severity, recommendations,
* provider).
*/
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 { summarize, type DataPoint } from "@/lib/analytics/trends";
import { getBpTargets } from "@/lib/analytics/bp-targets";
import {
generateAlerts,
type HealthAlert,
} from "@/lib/analytics/classifications";
import { pairByTimestamp } from "@/lib/analytics/correlations";
import type { MeasurementType } from "@/generated/prisma/client";
type Severity = "alert" | "caution" | "info" | "good";
function toSeverity(level: HealthAlert["level"]): Severity {
switch (level) {
case "danger":
return "alert";
case "warning":
return "caution";
case "success":
return "good";
default:
return "info";
}
}
interface InsightRecommendation {
id: string;
label: string;
actionURL: string | null;
}
interface InsightCard {
id: string;
title: string;
summary: string;
body: string | null;
severity: Severity;
recommendations: InsightRecommendation[];
generatedAt: string;
provider: string;
}
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "insights.cards" } });
const ninetyDaysAgo = new Date(Date.now() - 90 * 86_400_000);
const [dbUser, allMeasurements, medications] = await Promise.all([
prisma.user.findUnique({
where: { id: user.id },
select: { heightCm: true, dateOfBirth: true, aiProvider: true },
}),
prisma.measurement.findMany({
where: {
userId: user.id,
type: {
in: [
"WEIGHT",
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",
"PULSE",
],
},
measuredAt: { gte: ninetyDaysAgo },
},
orderBy: { measuredAt: "asc" },
select: { type: true, value: true, measuredAt: true },
}),
prisma.medication.findMany({
where: { userId: user.id, active: true },
include: { schedules: true },
}),
]);
const byType = (t: MeasurementType): DataPoint[] =>
allMeasurements
.filter((m) => m.type === t)
.map((m) => ({ date: m.measuredAt, value: m.value }));
const summaries: Partial<Record<MeasurementType, ReturnType<typeof summarize>>> = {};
for (const t of [
"WEIGHT",
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",
"PULSE",
] as MeasurementType[]) {
const data = byType(t);
if (data.length > 0) summaries[t] = summarize(data);
}
let bmi: number | null = null;
if (dbUser?.heightCm && summaries.WEIGHT?.latest) {
const heightM = dbUser.heightCm / 100;
bmi =
Math.round((summaries.WEIGHT.latest / (heightM * heightM)) * 10) / 10;
}
let bpPctInTarget: number | null = null;
const bpTargets = getBpTargets(dbUser?.dateOfBirth ?? null);
if (bpTargets) {
const sysData = byType("BLOOD_PRESSURE_SYS");
const diaData = byType("BLOOD_PRESSURE_DIA");
const pairs = pairByTimestamp(sysData, diaData, 5 * 60_000);
if (pairs.length > 0) {
const inTarget = pairs.filter(
(p) =>
p.a >= bpTargets.sysLow &&
p.a <= bpTargets.sysHigh &&
p.b >= bpTargets.diaLow &&
p.b <= bpTargets.diaHigh,
).length;
bpPctInTarget = Math.round((inTarget / pairs.length) * 100);
}
}
// Lightweight medication compliance for alert input.
const medicationCompliance: Array<{
name: string;
compliance7: number;
compliance30: number;
}> = [];
// Single batched query — bucket by medicationId in JS (fix N+1).
const activeMeds = medications.filter((m) => m.schedules.length > 0);
if (activeMeds.length > 0) {
const allEvents = await prisma.medicationIntakeEvent.findMany({
where: {
userId: user.id,
medicationId: { in: activeMeds.map((m) => m.id) },
scheduledFor: { gte: new Date(Date.now() - 30 * 86_400_000) },
},
select: { medicationId: true, takenAt: true, skipped: true, scheduledFor: true },
});
const eventsByMed = new Map<string, typeof allEvents>();
for (const e of allEvents) {
const arr = eventsByMed.get(e.medicationId) ?? [];
arr.push(e);
eventsByMed.set(e.medicationId, arr);
}
const cutoff7 = Date.now() - 7 * 86_400_000;
for (const med of activeMeds) {
const events = eventsByMed.get(med.id) ?? [];
const takenLast7 = events.filter(
(e) => e.takenAt !== null && !e.skipped && e.scheduledFor.getTime() >= cutoff7,
).length;
const taken30 = events.filter((e) => e.takenAt !== null && !e.skipped).length;
const expected7 = med.schedules.length * 7;
const expected30 = med.schedules.length * 30;
medicationCompliance.push({
name: med.name,
compliance7: expected7 > 0 ? Math.round((takenLast7 / expected7) * 100) : 0,
compliance30: expected30 > 0 ? Math.round((taken30 / expected30) * 100) : 0,
});
}
}
const alerts = generateAlerts({
bmi,
bpAvgSys: summaries.BLOOD_PRESSURE_SYS?.avg30 ?? null,
bpAvgDia: summaries.BLOOD_PRESSURE_DIA?.avg30 ?? null,
bpPctInTarget,
weightSlope30: summaries.WEIGHT?.slope30?.slope ?? null,
pulseAvg30: summaries.PULSE?.avg30 ?? null,
pulseAnomalyCount: summaries.PULSE?.anomalyCount,
medications: medicationCompliance,
});
const provider = dbUser?.aiProvider?.toLowerCase() ?? "claude";
const generatedAt = new Date().toISOString();
const cards: InsightCard[] = alerts.map((alert, idx) => ({
id: `alert-${idx + 1}`,
title: alert.title,
summary: alert.message,
body: null,
severity: toSeverity(alert.level),
recommendations: [],
generatedAt,
provider,
}));
return apiSuccess(cards);
});
+4 -8
View File
@@ -18,6 +18,7 @@ import { getMedicationCategories } from "@/lib/medication-category";
import type { MeasurementType } from "@/generated/prisma/client";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { measurementTypeEnum } from "@/lib/validations/measurement";
export const dynamic = "force-dynamic";
@@ -36,14 +37,9 @@ export const GET = apiHandler(async () => {
},
});
// Fetch all measurements (90 days)
const types: MeasurementType[] = [
"WEIGHT",
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",
"PULSE",
"BODY_FAT",
];
// Derived from canonical enum so adding a new measurement type does not
// require touching this file (V3 audit finding: enum drift cousins).
const types = [...measurementTypeEnum.options] as MeasurementType[];
const allMeasurements = await prisma.measurement.findMany({
where: { userId, type: { in: types }, measuredAt: { gte: ninetyDaysAgo } },
@@ -0,0 +1,66 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: { auditLog: { create: 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 { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
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 },
};
beforeEach(() => {
vi.resetAllMocks();
});
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
function makeReq(): NextRequest {
return new NextRequest("http://localhost/api/insights/correlations");
}
describe("GET /api/insights/correlations", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await callGet(makeReq());
expect(res.status).toBe(401);
});
it("returns an empty array as the placeholder", 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: unknown[] };
expect(body.data).toEqual([]);
});
it("writes the empty-state audit log", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
await callGet(makeReq());
expect(auditLog).toHaveBeenCalledWith(
"insights.correlations.empty",
expect.objectContaining({ userId: "user-1" }),
);
});
});
@@ -0,0 +1,26 @@
/**
* GET /api/insights/correlations — placeholder iOS endpoint.
*
* The full correlations engine is part of `/api/insights/comprehensive`
* and is intentionally not re-exposed yet — iOS only needs a stable
* shape to render the empty state. We return `[]` and emit an audit log
* so the gap is visible until Phase 7 fills it in.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "insights.correlations" } });
await auditLog("insights.correlations.empty", {
userId: user.id,
details: { reason: "phase7_pending" },
});
return apiSuccess([]);
});
@@ -0,0 +1,112 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: 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 { GET, PATCH } 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 },
};
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.user.findUnique).mockResolvedValue({
healthKitConfigJson: null,
healthKitLastSyncedAt: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
});
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
function makeGetReq(): NextRequest {
return new NextRequest("http://localhost/api/integrations/healthkit");
}
describe("GET /api/integrations/healthkit", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await callGet(makeGetReq());
expect(res.status).toBe(401);
});
it("returns the default entries when nothing is stored", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await callGet(makeGetReq());
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { entries: Array<{ id: string; direction: string }> };
};
expect(body.data.entries.length).toBeGreaterThan(0);
const bodyMass = body.data.entries.find((e) => e.id === "bodyMass");
expect(bodyMass?.direction).toBe("bidirectional");
});
});
describe("PATCH /api/integrations/healthkit", () => {
function req(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/integrations/healthkit", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await PATCH(req({ entries: [] }));
expect(res.status).toBe(401);
});
it("returns 422 for invalid direction", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await PATCH(
req({ entries: [{ id: "bodyMass", direction: "FOO" }] }),
);
expect(res.status).toBe(422);
});
it("updates known entries and silently ignores unknown ids", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await PATCH(
req({
entries: [
{ id: "bodyMass", direction: "readOnly" },
{ id: "totallyUnknown", direction: "bidirectional" },
],
}),
);
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledTimes(1);
const updateArgs = vi.mocked(prisma.user.update).mock.calls[0][0] as unknown as {
data: { healthKitConfigJson: { entries: Array<{ id: string }> } };
};
const ids = updateArgs.data.healthKitConfigJson.entries.map((e) => e.id);
expect(ids).toContain("bodyMass");
expect(ids).not.toContain("totallyUnknown");
});
});
+185
View File
@@ -0,0 +1,185 @@
/**
* HealthKit (iOS) integration config.
*
* Persisted on `User.healthKitConfigJson` as `{ entries: [...] }`.
* Each entry: `{ id, kind, direction, enabled }` where `direction` is one
* of `bidirectional | readOnly | writeOnly`.
*
* GET → resolved config (with default entries when nothing is stored).
* PATCH → merge by `id`; unknown ids are silently ignored.
*/
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 { Prisma } from "@/generated/prisma/client";
import { auditLog } from "@/lib/auth/audit";
export const directionEnum = z.enum([
"bidirectional",
"readOnly",
"writeOnly",
"disabled",
]);
const entrySchema = z.object({
id: z.string().min(1).max(64),
kind: z.string().min(1).max(64).optional(),
direction: directionEnum,
enabled: z.boolean().optional(),
});
const patchSchema = z.object({
entries: z.array(entrySchema).max(50),
});
export interface HealthKitEntry {
id: string;
kind: string;
direction: z.infer<typeof directionEnum>;
enabled: boolean;
}
export const DEFAULT_HEALTHKIT_ENTRIES: HealthKitEntry[] = [
{ id: "bodyMass", kind: "bodyMass", direction: "bidirectional", enabled: true },
{ id: "bp", kind: "bloodPressure", direction: "bidirectional", enabled: true },
{ id: "glucose", kind: "bloodGlucose", direction: "bidirectional", enabled: true },
{ id: "heartRate", kind: "heartRate", direction: "readOnly", enabled: true },
{ id: "stepCount", kind: "stepCount", direction: "readOnly", enabled: true },
{ id: "sleep", kind: "sleepAnalysis", direction: "readOnly", enabled: true },
];
interface StoredConfig {
entries: HealthKitEntry[];
}
function parseStored(json: unknown): StoredConfig | null {
if (!json || typeof json !== "object") return null;
const obj = json as Record<string, unknown>;
const entries = obj.entries;
if (!Array.isArray(entries)) return null;
const out: HealthKitEntry[] = [];
for (const raw of entries) {
if (!raw || typeof raw !== "object") continue;
const e = raw as Record<string, unknown>;
const id = typeof e.id === "string" ? e.id : null;
const kind = typeof e.kind === "string" ? e.kind : id;
const directionRaw = typeof e.direction === "string" ? e.direction : null;
if (!id || !kind || !directionRaw) continue;
const dir = directionEnum.safeParse(directionRaw);
if (!dir.success) continue;
out.push({
id,
kind,
direction: dir.data,
enabled: typeof e.enabled === "boolean" ? e.enabled : true,
});
}
return { entries: out };
}
function mergeWithDefaults(stored: StoredConfig | null): HealthKitEntry[] {
const map = new Map<string, HealthKitEntry>();
for (const def of DEFAULT_HEALTHKIT_ENTRIES) {
map.set(def.id, { ...def });
}
if (stored) {
for (const entry of stored.entries) {
const existing = map.get(entry.id);
if (existing) {
map.set(entry.id, { ...existing, ...entry });
} else {
// Surface custom keys the user has added.
map.set(entry.id, { ...entry, enabled: entry.enabled ?? true });
}
}
}
return [...map.values()];
}
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "integrations.healthkit.get" } });
const row = await prisma.user.findUnique({
where: { id: user.id },
select: { healthKitConfigJson: true, healthKitLastSyncedAt: true },
});
const stored = parseStored(row?.healthKitConfigJson ?? null);
const entries = mergeWithDefaults(stored);
return apiSuccess({
entries,
lastSyncedAt: row?.healthKitLastSyncedAt?.toISOString() ?? null,
});
});
export const PATCH = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { data: body, error } = await safeJson(request);
if (error) return error;
const parsed = patchSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
}
const row = await prisma.user.findUnique({
where: { id: user.id },
select: { healthKitConfigJson: true },
});
const stored = parseStored(row?.healthKitConfigJson ?? null);
// Resolve known ids set: default ids + already-stored ids.
const knownIds = new Set<string>(
DEFAULT_HEALTHKIT_ENTRIES.map((e) => e.id),
);
if (stored) for (const e of stored.entries) knownIds.add(e.id);
// Merge by id; unknown ids are silently ignored per spec.
const map = new Map<string, HealthKitEntry>();
if (stored) for (const e of stored.entries) map.set(e.id, e);
for (const update of parsed.data.entries) {
if (!knownIds.has(update.id)) continue;
const existing =
map.get(update.id) ??
DEFAULT_HEALTHKIT_ENTRIES.find((d) => d.id === update.id);
map.set(update.id, {
id: update.id,
kind: update.kind ?? existing?.kind ?? update.id,
direction: update.direction,
enabled: update.enabled ?? existing?.enabled ?? true,
});
}
const updatedConfig: StoredConfig = { entries: [...map.values()] };
await prisma.user.update({
where: { id: user.id },
data: {
healthKitConfigJson: updatedConfig as unknown as Prisma.InputJsonValue,
},
});
await auditLog("integrations.healthkit.update", {
userId: user.id,
details: { count: updatedConfig.entries.length },
});
annotate({
action: { name: "integrations.healthkit.update" },
meta: { count: updatedConfig.entries.length },
});
// Return resolved config (defaults merged) so the client always sees a
// complete entries list.
const merged = mergeWithDefaults(updatedConfig);
return apiSuccess({
entries: merged,
lastSyncedAt: null,
});
});
@@ -2,6 +2,7 @@ import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { readMoodLogSecret } from "@/lib/moodlog-secret";
export const dynamic = "force-dynamic";
@@ -23,11 +24,15 @@ export const GET = apiHandler(async () => {
where: { userId: user.id },
});
// V3 audit STILL-V2-C-2: stored secret is now AES-GCM encrypted at rest.
// Decrypt for the user's settings page; legacy plaintext is also handled.
const webhookSecret = readMoodLogSecret(dbUser?.moodLogWebhookSecret ?? null);
return apiSuccess({
configured: Boolean(dbUser?.moodLogUrlEncrypted),
enabled: dbUser?.moodLogEnabled ?? false,
lastSyncedAt: dbUser?.moodLogLastSyncedAt ?? null,
entryCount,
webhookSecret: dbUser?.moodLogWebhookSecret ?? null,
webhookSecret,
});
});
@@ -5,6 +5,7 @@ import { apiError } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { getClientIp } from "@/lib/api-response";
import { moodLogWebhookPayloadSchema } from "@/lib/validations/moodlog";
import { readMoodLogSecret } from "@/lib/moodlog-secret";
import { apiHandler } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
@@ -44,10 +45,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
select: { id: true, moodLogWebhookSecret: true },
});
// V3 audit STILL-V2-C-2: stored secret is now encrypted at rest. Decrypt
// each candidate before the timing-safe compare. `readMoodLogSecret`
// also tolerates legacy plaintext rows during the transition window.
const receivedBuf = Buffer.from(webhookSecret, "utf8");
const user = candidates.find((c) => {
if (!c.moodLogWebhookSecret) return false;
const expectedBuf = Buffer.from(c.moodLogWebhookSecret, "utf8");
const expected = readMoodLogSecret(c.moodLogWebhookSecret);
if (!expected) return false;
const expectedBuf = Buffer.from(expected, "utf8");
if (expectedBuf.length !== receivedBuf.length) return false;
return timingSafeEqual(expectedBuf, receivedBuf);
});
+15 -4
View File
@@ -2,13 +2,19 @@ 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 {
createMeasurementSchema,
createBatchMeasurementSchema,
listMeasurementsSchema,
getUnitForType,
} from "@/lib/validations/measurement";
import { withIdempotency } from "@/lib/idempotency";
import { NextRequest } from "next/server";
import type {
MeasurementType,
@@ -59,7 +65,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
});
});
export const POST = apiHandler(async (request: NextRequest) => {
export const POST = apiHandler(withIdempotency<[NextRequest]>(postMeasurement));
async function postMeasurement(request: NextRequest) {
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson(request);
@@ -138,7 +146,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
});
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") {
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
err.code === "P2002"
) {
return apiError("A measurement with this data already exists", 409);
}
throw err;
@@ -159,4 +170,4 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
return apiSuccess(measurement, 201);
});
}
@@ -0,0 +1,101 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: { measurement: { 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 },
};
function req(query: string): NextRequest {
return new NextRequest(`http://localhost/api/measurements/series?${query}`);
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
});
describe("GET /api/measurements/series", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await GET(req("kind=weight"));
expect(res.status).toBe(401);
});
it("returns 422 for invalid kind", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await GET(req("kind=garbage"));
expect(res.status).toBe(422);
});
it("returns paired BP series with sys + dia", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const t = new Date("2026-05-01T10:00:00.000Z");
const findManyMock = vi.mocked(prisma.measurement.findMany);
findManyMock.mockImplementation(((args: unknown) => {
const a = args as { where: { type: string } };
if (a.where.type === "BLOOD_PRESSURE_SYS") {
return Promise.resolve([
{ id: "s1", value: 126, measuredAt: t },
]) as never;
}
if (a.where.type === "BLOOD_PRESSURE_DIA") {
return Promise.resolve([
{
id: "d1",
value: 82,
measuredAt: new Date(t.getTime() + 60_000),
},
]) as never;
}
return Promise.resolve([]) as never;
}) as never);
const res = await GET(req("kind=bloodPressure&days=30"));
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
kind: string;
points: Array<{ value: number; secondary: number | null }>;
stats: { count: number };
};
};
expect(body.data.kind).toBe("bloodPressure");
expect(body.data.points).toHaveLength(1);
expect(body.data.points[0].value).toBe(126);
expect(body.data.points[0].secondary).toBe(82);
expect(body.data.stats.count).toBe(1);
});
it("returns single-value series for kind=weight", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ id: "w1", value: 78.4, measuredAt: new Date() },
] as never);
const res = await GET(req("kind=weight&days=7"));
const body = (await res.json()) as {
data: { points: Array<{ secondary: number | null }> };
};
expect(body.data.points[0].secondary).toBeNull();
});
});
+160
View File
@@ -0,0 +1,160 @@
/**
* GET /api/measurements/series?kind=&days=
*
* iOS-friendly time-series adapter. Maps the camelCase `kind` to the
* canonical Prisma `MeasurementType`(s) and returns
* { kind, points: [{ id, at, value, secondary }], stats }
*
* The `kind=bloodPressure` case pairs systolic + diastolic by
* `measuredAt` so iOS can render the dual-line chart with one fetch.
*/
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { summarize, type DataPoint } from "@/lib/analytics/trends";
import type { MeasurementType } from "@/generated/prisma/client";
const kindEnum = z.enum([
"weight",
"bloodPressure",
"pulse",
"bodyFat",
"glucose",
"sleep",
"steps",
"totalBodyWater",
"boneMass",
"oxygenSaturation",
]);
const querySchema = z.object({
kind: kindEnum,
days: z.coerce.number().int().min(1).max(365).optional().default(30),
});
const KIND_TO_TYPE: Record<z.infer<typeof kindEnum>, MeasurementType> = {
weight: "WEIGHT",
bloodPressure: "BLOOD_PRESSURE_SYS",
pulse: "PULSE",
bodyFat: "BODY_FAT",
glucose: "BLOOD_GLUCOSE",
sleep: "SLEEP_DURATION",
steps: "ACTIVITY_STEPS",
totalBodyWater: "TOTAL_BODY_WATER",
boneMass: "BONE_MASS",
oxygenSaturation: "OXYGEN_SATURATION",
};
interface SeriesPoint {
id: string;
at: string;
value: number;
secondary: number | null;
}
function stdDev(values: number[]): number {
if (values.length < 2) return 0;
const mean = values.reduce((sum, v) => sum + v, 0) / values.length;
const variance =
values.reduce((sum, v) => sum + (v - mean) ** 2, 0) / values.length;
return Math.round(Math.sqrt(variance) * 100) / 100;
}
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const parsed = querySchema.safeParse(
Object.fromEntries(request.nextUrl.searchParams),
);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
}
const { kind, days } = parsed.data;
const since = new Date(Date.now() - days * 86_400_000);
let points: SeriesPoint[] = [];
if (kind === "bloodPressure") {
const [sys, dia] = await Promise.all([
prisma.measurement.findMany({
where: {
userId: user.id,
type: "BLOOD_PRESSURE_SYS",
measuredAt: { gte: since },
},
orderBy: { measuredAt: "asc" },
select: { id: true, value: true, measuredAt: true },
}),
prisma.measurement.findMany({
where: {
userId: user.id,
type: "BLOOD_PRESSURE_DIA",
measuredAt: { gte: since },
},
orderBy: { measuredAt: "asc" },
select: { id: true, value: true, measuredAt: true },
}),
]);
// Pair by closest timestamp within ±5 minutes.
const PAIR_WINDOW_MS = 5 * 60_000;
points = sys.map((s) => {
let bestDia: { value: number; dt: number } | null = null;
for (const d of dia) {
const dt = Math.abs(d.measuredAt.getTime() - s.measuredAt.getTime());
if (dt > PAIR_WINDOW_MS) continue;
if (!bestDia || dt < bestDia.dt) {
bestDia = { value: d.value, dt };
}
}
return {
id: s.id,
at: s.measuredAt.toISOString(),
value: s.value,
secondary: bestDia?.value ?? null,
};
});
} else {
const type = KIND_TO_TYPE[kind];
const rows = await prisma.measurement.findMany({
where: { userId: user.id, type, measuredAt: { gte: since } },
orderBy: { measuredAt: "asc" },
select: { id: true, value: true, measuredAt: true },
});
points = rows.map((r) => ({
id: r.id,
at: r.measuredAt.toISOString(),
value: r.value,
secondary: null,
}));
}
const dataPoints: DataPoint[] = points.map((p) => ({
date: new Date(p.at),
value: p.value,
}));
const summary = dataPoints.length > 0 ? summarize(dataPoints) : null;
const values = dataPoints.map((p) => p.value);
annotate({
action: { name: "measurements.series" },
meta: { kind, days, count: points.length },
});
return apiSuccess({
kind,
points,
stats: summary
? {
mean: summary.mean,
min: summary.min,
max: summary.max,
stdDev: stdDev(values),
count: summary.count,
}
: { mean: 0, min: 0, max: 0, stdDev: 0, count: 0 },
});
});
+44 -32
View File
@@ -2,16 +2,26 @@ 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 {
intakeSchema,
listIntakeEventsSchema,
} from "@/lib/validations/medication";
import { withIdempotency } from "@/lib/idempotency";
import { NextRequest } from "next/server";
type RouteParams = { params: Promise<{ id: string }> };
export const POST = apiHandler(async (request: NextRequest, { params }: RouteParams) => {
export const POST = apiHandler(
withIdempotency<[NextRequest, RouteParams]>(postIntake),
);
async function postIntake(request: NextRequest, { params }: RouteParams) {
const { user } = await requireAuth();
const { id } = await params;
@@ -100,40 +110,42 @@ export const POST = apiHandler(async (request: NextRequest, { params }: RoutePar
});
return apiSuccess(event, 201);
});
}
export const GET = apiHandler(async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
export const GET = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const medication = await prisma.medication.findUnique({ where: { id } });
if (!medication || medication.userId !== user.id) {
return apiError("Medication not found", 404);
}
const { id } = await params;
const medication = await prisma.medication.findUnique({ where: { id } });
if (!medication || medication.userId !== user.id) {
return apiError("Medication not found", 404);
}
const searchParams = Object.fromEntries(request.nextUrl.searchParams);
const parsed = listIntakeEventsSchema.safeParse(searchParams);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
}
const searchParams = Object.fromEntries(request.nextUrl.searchParams);
const parsed = listIntakeEventsSchema.safeParse(searchParams);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
}
const { limit, offset, sortBy, sortDir } = parsed.data;
const where = { medicationId: id, userId: user.id };
const { limit, offset, sortBy, sortDir } = parsed.data;
const where = { medicationId: id, userId: user.id };
const [events, total] = await Promise.all([
prisma.medicationIntakeEvent.findMany({
where,
orderBy: { [sortBy]: sortDir },
take: limit,
skip: offset,
}),
prisma.medicationIntakeEvent.count({ where }),
]);
const [events, total] = await Promise.all([
prisma.medicationIntakeEvent.findMany({
where,
orderBy: { [sortBy]: sortDir },
take: limit,
skip: offset,
}),
prisma.medicationIntakeEvent.count({ where }),
]);
annotate({
action: { name: "medication.intake.list" },
meta: { medication_id: id, total },
});
annotate({
action: { name: "medication.intake.list" },
meta: { medication_id: id, total },
});
return apiSuccess({ events, meta: { total, limit, offset } });
});
return apiSuccess({ events, meta: { total, limit, offset } });
},
);
@@ -0,0 +1,160 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
medicationIntakeEvent: {
findMany: vi.fn(),
findUnique: vi.fn(),
update: vi.fn(),
},
medication: { update: vi.fn() },
$transaction: 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 { GET, 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 },
};
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([] as never);
});
describe("GET /api/medications/intake", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await GET(
new NextRequest("http://localhost/api/medications/intake?scope=today"),
);
expect(res.status).toBe(401);
});
it("returns 422 for invalid scope", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await GET(
new NextRequest("http://localhost/api/medications/intake?scope=junk"),
);
expect(res.status).toBe(422);
});
it("returns today's events as a flat array", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
{
id: "e1",
medicationId: "m1",
scheduledFor: new Date(),
takenAt: null,
skipped: false,
medication: { id: "m1", snoozedUntil: null },
},
] as never);
const res = await GET(
new NextRequest("http://localhost/api/medications/intake?scope=today"),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: Array<{ id: string; status: string }>;
};
expect(body.data).toHaveLength(1);
expect(body.data[0].status).toBe("pending");
});
it("returns compliance buckets for the last N days", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
{
scheduledFor: new Date(),
takenAt: new Date(),
skipped: false,
},
] as never);
const res = await GET(
new NextRequest(
"http://localhost/api/medications/intake?scope=compliance&days=7",
),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: Array<{ date: string; scheduled: number; taken: number }>;
};
expect(body.data.length).toBe(7);
});
});
describe("POST /api/medications/intake", () => {
function req(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/medications/intake", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await POST(req({ intakeId: "e1", status: "taken" }));
expect(res.status).toBe(401);
});
it("returns 404 when the event isn't owned by the user", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medicationIntakeEvent.findUnique).mockResolvedValue({
id: "e1",
userId: "someone-else",
medicationId: "m1",
} as never);
const res = await POST(req({ intakeId: "e1", status: "taken" }));
expect(res.status).toBe(404);
});
it("returns 422 for invalid status", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await POST(req({ intakeId: "e1", status: "broken" }));
expect(res.status).toBe(422);
});
it("marks event as skipped", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medicationIntakeEvent.findUnique).mockResolvedValue({
id: "e1",
userId: "user-1",
medicationId: "m1",
} as never);
vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValue({
id: "e1",
skipped: true,
takenAt: null,
} as never);
const res = await POST(req({ intakeId: "e1", status: "skipped" }));
expect(res.status).toBe(200);
expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledWith({
where: { id: "e1" },
data: { takenAt: null, skipped: true },
});
});
});
+201
View File
@@ -0,0 +1,201 @@
/**
* Top-level medication-intake aggregator + writer.
*
* GET /api/medications/intake?scope=today
* → array of today's intake events for the user (across medications).
*
* GET /api/medications/intake?scope=compliance&days=N
* → per-day { date, scheduled, taken } for the last N days.
*
* POST /api/medications/intake
* Body: { intakeId, status: "taken" | "skipped" | "snoozed", takenAt?, snoozedUntil? }
* Updates the named MedicationIntakeEvent and returns the updated row.
*/
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp, safeJson } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
const querySchema = z.object({
scope: z.enum(["today", "compliance"]),
days: z.coerce.number().int().min(1).max(365).optional().default(30),
});
const updateSchema = z.object({
intakeId: z.string().min(1),
status: z.enum(["taken", "skipped", "snoozed"]),
takenAt: z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))
.optional(),
snoozedUntil: z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))
.optional(),
});
function startOfDayBerlin(date: Date): Date {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(date);
const y = parts.find((p) => p.type === "year")?.value ?? "1970";
const m = parts.find((p) => p.type === "month")?.value ?? "01";
const d = parts.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);
}
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const parsed = querySchema.safeParse(
Object.fromEntries(request.nextUrl.searchParams),
);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
}
const { scope, days } = parsed.data;
if (scope === "today") {
const todayStart = startOfDayBerlin(new Date());
const todayEnd = new Date(todayStart.getTime() + 86_400_000);
const events = await prisma.medicationIntakeEvent.findMany({
where: {
userId: user.id,
scheduledFor: { gte: todayStart, lt: todayEnd },
},
orderBy: { scheduledFor: "asc" },
include: { medication: { select: { id: true, snoozedUntil: true } } },
});
annotate({
action: { name: "medications.intake.today" },
meta: { count: events.length },
});
return apiSuccess(
events.map((e) => ({
id: e.id,
medicationId: e.medicationId,
scheduledAt: e.scheduledFor.toISOString(),
takenAt: e.takenAt?.toISOString() ?? null,
status: e.skipped
? "skipped"
: e.takenAt
? "taken"
: e.medication.snoozedUntil &&
e.medication.snoozedUntil > new Date()
? "snoozed"
: "pending",
snoozedUntil: e.medication.snoozedUntil?.toISOString() ?? null,
})),
);
}
// compliance: per-day scheduled vs taken for the last N days
const start = new Date(Date.now() - days * 86_400_000);
const events = await prisma.medicationIntakeEvent.findMany({
where: { userId: user.id, scheduledFor: { gte: start } },
select: { scheduledFor: true, takenAt: true, skipped: true },
});
const buckets = new Map<string, { scheduled: number; taken: number }>();
for (let i = 0; i < days; i++) {
const d = new Date(Date.now() - i * 86_400_000);
buckets.set(berlinDayKey(d), { scheduled: 0, taken: 0 });
}
for (const e of events) {
const key = berlinDayKey(e.scheduledFor);
const bucket = buckets.get(key);
if (!bucket) continue;
bucket.scheduled += 1;
if (e.takenAt && !e.skipped) bucket.taken += 1;
}
const result = [...buckets.entries()]
.map(([date, v]) => ({ date, scheduled: v.scheduled, taken: v.taken }))
.sort((a, b) => a.date.localeCompare(b.date));
annotate({
action: { name: "medications.intake.compliance" },
meta: { days, count: result.length },
});
return apiSuccess(result);
});
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { data: body, error } = await safeJson(request);
if (error) return error;
const parsed = updateSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
}
const { intakeId, status, takenAt, snoozedUntil } = parsed.data;
const existing = await prisma.medicationIntakeEvent.findUnique({
where: { id: intakeId },
});
if (!existing || existing.userId !== user.id) {
return apiError("Intake event not found", 404);
}
let updated;
if (status === "taken") {
[updated] = await prisma.$transaction([
prisma.medicationIntakeEvent.update({
where: { id: intakeId },
data: { takenAt: takenAt ?? new Date(), skipped: false },
}),
prisma.medication.update({
where: { id: existing.medicationId },
data: { snoozedUntil: null },
}),
]);
} else if (status === "skipped") {
updated = await prisma.medicationIntakeEvent.update({
where: { id: intakeId },
data: { takenAt: null, skipped: true },
});
} else {
// snoozed: snoozedUntil lives on the Medication row.
const until =
snoozedUntil ?? new Date(Date.now() + 30 * 60_000); // default +30min
await prisma.medication.update({
where: { id: existing.medicationId },
data: { snoozedUntil: until },
});
updated = await prisma.medicationIntakeEvent.findUnique({
where: { id: intakeId },
});
}
await auditLog("medications.intake.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: { intakeId, status },
});
annotate({
action: { name: "medications.intake.update" },
meta: { intakeId, status },
});
return apiSuccess(updated);
});
+24 -10
View File
@@ -1,6 +1,11 @@
import { prisma } from "@/lib/db";
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 {
createMoodEntrySchema,
listMoodEntriesSchema,
@@ -10,6 +15,7 @@ import { NextRequest } from "next/server";
import { Prisma } from "@/generated/prisma/client";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { withIdempotency } from "@/lib/idempotency";
function toBerlinDate(date: Date): string {
return new Intl.DateTimeFormat("sv-SE", {
@@ -60,7 +66,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
prisma.moodEntry.count({ where }),
]);
annotate({ action: { name: "mood-entries.list" }, meta: { total, limit, offset } });
annotate({
action: { name: "mood-entries.list" },
meta: { total, limit, offset },
});
const entriesWithParsedTags = entries.map((e) => ({
...e,
@@ -73,7 +82,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
});
});
export const POST = apiHandler(async (request: NextRequest) => {
export const POST = apiHandler(withIdempotency<[NextRequest]>(postMoodEntry));
async function postMoodEntry(request: NextRequest) {
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson(request);
@@ -107,16 +118,19 @@ export const POST = apiHandler(async (request: NextRequest) => {
details: { moodEntryId: entry.id, mood },
});
annotate({ action: { name: "mood-entries.create" }, meta: { moodEntryId: entry.id, mood } });
annotate({
action: { name: "mood-entries.create" },
meta: { moodEntryId: entry.id, mood },
});
return apiSuccess({ ...entry, tags: parseTags(entry.tags) }, 201);
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") {
return apiError(
"A mood entry with this data already exists",
409,
);
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
err.code === "P2002"
) {
return apiError("A mood entry with this data already exists", 409);
}
throw err;
}
});
}
+12 -2
View File
@@ -44,7 +44,9 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
const userId = user.id;
const username = user.username;
// Log before deletion (userId will be SetNull in audit_logs after cascade)
// Log BEFORE deletion. The audit row's userId is SetNull post-cascade
// (per schema), but we then immediately purge it below for GDPR Art. 17
// erasure completeness — see comment further down.
await auditLog("user.account.delete", {
userId,
ipAddress: getClientIp(request),
@@ -54,7 +56,15 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
// Destroy all sessions first
await destroyAllSessions(userId);
// Delete user — all related data is removed via onDelete: Cascade
// Audit V3 NEW-V3-2 / GDPR Art. 17 fix: Feedback and AuditLog rows have
// `onDelete: SetNull` in the schema, which keeps PII (free-text symptom
// descriptions, IP addresses, login city geo) attached to the record after
// the user is deleted. We explicitly purge them inside the same logical
// operation so account deletion is genuinely complete erasure.
await prisma.feedback.deleteMany({ where: { userId } });
await prisma.auditLog.deleteMany({ where: { userId } });
// Delete user — all other related data is removed via onDelete: Cascade
await prisma.user.delete({ where: { id: userId } });
annotate({
+12 -3
View File
@@ -3,6 +3,10 @@ import { randomBytes } from "node:crypto";
import { prisma } from "@/lib/db";
import { apiSuccess, apiError } from "@/lib/api-response";
import { encrypt } from "@/lib/crypto";
import {
encryptMoodLogSecret,
readMoodLogSecret,
} from "@/lib/moodlog-secret";
import { moodLogCredentialsSchema } from "@/lib/validations/moodlog";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
@@ -26,14 +30,16 @@ export const PUT = apiHandler(async (request: NextRequest) => {
const { url, apiKey } = parsed.data;
// Generate webhook secret if not yet set
// Generate webhook secret if not yet set; otherwise reuse the existing
// one (decrypted from at-rest storage; legacy plaintext is also handled).
const existing = await prisma.user.findUnique({
where: { id: user.id },
select: { moodLogWebhookSecret: true },
});
const webhookSecret =
existing?.moodLogWebhookSecret ?? `mb_${randomBytes(32).toString("hex")}`;
readMoodLogSecret(existing?.moodLogWebhookSecret ?? null) ??
`mb_${randomBytes(32).toString("hex")}`;
await prisma.user.update({
where: { id: user.id },
@@ -41,7 +47,10 @@ export const PUT = apiHandler(async (request: NextRequest) => {
moodLogUrlEncrypted: encrypt(url),
moodLogApiKeyEncrypted: encrypt(apiKey),
moodLogEnabled: true,
moodLogWebhookSecret: webhookSecret,
// V3 audit STILL-V2-C-2: encrypt at rest with AES-256-GCM. A legacy
// plaintext value (rare) is rotated to the encrypted form on this
// write transparently.
moodLogWebhookSecret: encryptMoodLogSecret(webhookSecret),
},
});
@@ -0,0 +1,131 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: 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 { GET, PATCH } 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 },
};
beforeEach(() => {
vi.resetAllMocks();
});
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
function makeGetReq(): NextRequest {
return new NextRequest("http://localhost/api/user/profile");
}
describe("GET /api/user/profile", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await callGet(makeGetReq());
expect(res.status).toBe(401);
});
it("returns flattened iOS-style fields", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
username: "marc",
displayName: "Marc B.",
email: "marc@example.com",
dateOfBirth: new Date("1985-03-12T00:00:00.000Z"),
gender: "MALE",
heightCm: 180,
locale: "de",
timezone: "Europe/Berlin",
} as never);
const res = await callGet(makeGetReq());
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
username: string;
displayName: string | null;
heightCm: number;
locale: string | null;
};
};
expect(body.data.username).toBe("marc");
expect(body.data.displayName).toBe("Marc B.");
expect(body.data.heightCm).toBe(180);
expect(body.data.locale).toBe("de");
});
});
describe("PATCH /api/user/profile", () => {
function req(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/user/profile", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await PATCH(req({ displayName: "Test" }));
expect(res.status).toBe(401);
});
it("returns 422 for invalid heightCm", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await PATCH(req({ heightCm: 9999 }));
expect(res.status).toBe(422);
});
it("persists displayName + locale via shared helper", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
vi.mocked(prisma.user.update).mockResolvedValue({
id: "user-1",
username: "marc",
displayName: "Marc B.",
email: null,
role: "USER",
heightCm: null,
dateOfBirth: null,
gender: null,
timezone: "Europe/Berlin",
locale: "de",
} as never);
const res = await PATCH(req({ displayName: "Marc B.", locale: "de" }));
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "user-1" },
data: expect.objectContaining({
displayName: "Marc B.",
locale: "de",
}),
}),
);
});
});
+71
View File
@@ -0,0 +1,71 @@
/**
* iOS-friendly user profile endpoint.
*
* GET → flattened profile fields (camelCase) for the native client.
* Aliased over the same data exposed by `/api/auth/me`.
* PATCH → delegates to the shared `applyProfileUpdate` helper used by
* `/api/auth/profile` PUT — same validation, same audit log.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp, safeJson } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { applyProfileUpdate } from "@/lib/auth/profile-update";
import { prisma } from "@/lib/db";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "user.profile.get" } });
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: {
username: true,
displayName: true,
email: true,
dateOfBirth: true,
gender: true,
heightCm: true,
locale: true,
timezone: true,
},
});
return apiSuccess({
username: dbUser?.username ?? user.username,
displayName: dbUser?.displayName ?? null,
email: dbUser?.email ?? null,
dateOfBirth: dbUser?.dateOfBirth?.toISOString() ?? null,
gender: dbUser?.gender ?? null,
heightCm: dbUser?.heightCm ?? null,
locale: dbUser?.locale ?? null,
timezone: dbUser?.timezone ?? "Europe/Berlin",
});
});
export const PATCH = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { data: body, error } = await safeJson(request);
if (error) return error;
const result = await applyProfileUpdate(user.id, body, getClientIp(request));
if (!result.ok) {
return apiError(result.message, result.status);
}
annotate({ action: { name: "user.profile.update" } });
return apiSuccess({
username: result.user.username,
displayName: result.user.displayName,
email: result.user.email,
dateOfBirth: result.user.dateOfBirth?.toISOString() ?? null,
gender: result.user.gender,
heightCm: result.user.heightCm,
locale: result.user.locale,
timezone: result.user.timezone,
});
});
+21 -1
View File
@@ -7,14 +7,34 @@ import { NextRequest, NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
/**
* Audit C-3 / phase P2: Withings legacy callback URLs include the shared
* secret as a `?secret=…` query param, which leaks into reverse-proxy
* access logs and any error-tracking pipeline that captures `request.url`
* (see `reportToGlitchtip`). New deployments should configure Withings to
* send the secret via the `X-Withings-Webhook-Secret` header instead.
*
* The query-param fallback stays for backwards compatibility with existing
* Withings configurations; remove once all integrators have migrated.
*/
function hasValidWebhookSecret(request: NextRequest): boolean {
const expected = process.env.WITHINGS_WEBHOOK_SECRET;
if (!expected) {
getEvent()?.addWarning("WITHINGS_WEBHOOK_SECRET not configured");
return false;
}
const received = request.nextUrl.searchParams.get("secret");
const fromHeader = request.headers.get("x-withings-webhook-secret");
const fromQuery = request.nextUrl.searchParams.get("secret");
const received = fromHeader ?? fromQuery;
if (!received) return false;
if (!fromHeader && fromQuery) {
getEvent()?.addWarning(
"withings webhook secret received via legacy URL query — migrate to X-Withings-Webhook-Secret header",
);
}
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(received, "utf8");
if (a.length !== b.length) return false;
+3
View File
@@ -106,6 +106,9 @@ const BASE_TYPE_LABEL_KEYS: Record<string, string> = {
SLEEP_DURATION: "charts.sleep",
ACTIVITY_STEPS: "charts.steps",
BLOOD_GLUCOSE: "measurements.typeBloodGlucose",
TOTAL_BODY_WATER: "charts.bodyWater",
BONE_MASS: "charts.boneMass",
OXYGEN_SATURATION: "charts.spo2",
};
function getTypeLabel(
@@ -0,0 +1,43 @@
import { describe, it, expect } from "vitest";
import { measurementTypeEnum } from "@/lib/validations/measurement";
import {
MEASUREMENT_TYPE_LABEL_KEYS,
MEASUREMENT_TYPE_ICONS,
MEASUREMENT_TYPE_COLORS,
} from "../measurement-list-meta";
import enMessages from "../../../../messages/en.json";
import deMessages from "../../../../messages/de.json";
/**
* Issue #109 root cause: measurement-list maps drifted from the canonical
* measurementTypeEnum. New enum values silently fell through to the raw
* string fallback. These tests fail fast if a new enum value is added
* without a corresponding entry in every list-UI map.
*/
describe("measurement-list-meta", () => {
const allTypes = [...measurementTypeEnum.options].sort();
it("MEASUREMENT_TYPE_LABEL_KEYS covers every measurement type", () => {
expect(Object.keys(MEASUREMENT_TYPE_LABEL_KEYS).sort()).toEqual(allTypes);
});
it("MEASUREMENT_TYPE_ICONS covers every measurement type", () => {
expect(Object.keys(MEASUREMENT_TYPE_ICONS).sort()).toEqual(allTypes);
});
it("MEASUREMENT_TYPE_COLORS covers every measurement type", () => {
expect(Object.keys(MEASUREMENT_TYPE_COLORS).sort()).toEqual(allTypes);
});
it("every label key resolves in both English and German locales", () => {
type Bag = { measurements?: Record<string, string> };
const en = (enMessages as Bag).measurements ?? {};
const de = (deMessages as Bag).measurements ?? {};
for (const [type, key] of Object.entries(MEASUREMENT_TYPE_LABEL_KEYS)) {
const leaf = key.replace(/^measurements\./, "");
expect(en[leaf], `EN missing ${key} for ${type}`).toBeTruthy();
expect(de[leaf], `DE missing ${key} for ${type}`).toBeTruthy();
}
});
});
@@ -79,6 +79,12 @@ const MEASUREMENT_TYPES = [
unit: "kg",
placeholder: "3.2",
},
{
value: "OXYGEN_SATURATION",
labelKey: "measurements.typeOxygenSaturation",
unit: "%",
placeholder: "98",
},
] as const;
const GLUCOSE_CONTEXTS = [
@@ -0,0 +1,70 @@
/**
* Per-measurement-type display metadata for the measurements list view.
*
* Extracted into its own module so we can:
* 1. Run a coverage test that asserts every Zod-enum measurement type has
* an entry in every map (so future enum additions can't silently fall
* through to the raw-string fallback — which was the root cause of
* issue #109).
* 2. Reuse the same icon/color set in adjacent surfaces (mobile list,
* edit dialog) without re-declaring it.
*
* Lead-architect note: this is the first step toward a single
* `metrics.json` manifest the entire ecosystem derives from. Today this
* module covers list-UI; phase P5 will subsume it into a cross-repo
* manifest.
*/
import {
Scale,
Heart,
Activity,
Droplets,
Droplet,
Moon,
Footprints,
Bone,
Wind,
type LucideIcon,
} from "lucide-react";
export const MEASUREMENT_TYPE_LABEL_KEYS: Record<string, string> = {
WEIGHT: "measurements.typeWeight",
BLOOD_PRESSURE_SYS: "measurements.typeBpSys",
BLOOD_PRESSURE_DIA: "measurements.typeBpDia",
PULSE: "measurements.typePulse",
BODY_FAT: "measurements.typeBodyFat",
SLEEP_DURATION: "measurements.typeSleep",
ACTIVITY_STEPS: "measurements.typeSteps",
BLOOD_GLUCOSE: "measurements.typeBloodGlucose",
TOTAL_BODY_WATER: "measurements.typeTotalBodyWater",
BONE_MASS: "measurements.typeBoneMass",
OXYGEN_SATURATION: "measurements.typeOxygenSaturation",
};
export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
WEIGHT: Scale,
BLOOD_PRESSURE_SYS: Heart,
BLOOD_PRESSURE_DIA: Heart,
PULSE: Activity,
BODY_FAT: Droplets,
SLEEP_DURATION: Moon,
ACTIVITY_STEPS: Footprints,
BLOOD_GLUCOSE: Droplet,
TOTAL_BODY_WATER: Droplet,
BONE_MASS: Bone,
OXYGEN_SATURATION: Wind,
};
export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
WEIGHT: "bg-chart-1/20 text-chart-1",
BLOOD_PRESSURE_SYS: "bg-chart-3/20 text-chart-3",
BLOOD_PRESSURE_DIA: "bg-chart-3/20 text-chart-3",
PULSE: "bg-chart-5/20 text-chart-5",
BODY_FAT: "bg-chart-4/20 text-chart-4",
SLEEP_DURATION: "bg-chart-2/20 text-chart-2",
ACTIVITY_STEPS: "bg-chart-2/20 text-chart-2",
BLOOD_GLUCOSE: "bg-chart-3/20 text-chart-3",
TOTAL_BODY_WATER: "bg-chart-2/20 text-chart-2",
BONE_MASS: "bg-chart-4/20 text-chart-4",
OXYGEN_SATURATION: "bg-chart-5/20 text-chart-5",
};
@@ -54,50 +54,16 @@ import {
ArrowDown,
ArrowUpDown,
MoreHorizontal,
Scale,
Heart,
Activity,
Droplets,
Moon,
Footprints,
} from "lucide-react";
import { useState } from "react";
import { formatDateTime } from "@/lib/format";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { invalidateKeys, measurementDependentKeys } from "@/lib/query-keys";
const TYPE_LABEL_KEYS: Record<string, string> = {
WEIGHT: "measurements.typeWeight",
BLOOD_PRESSURE_SYS: "measurements.typeBpSys",
BLOOD_PRESSURE_DIA: "measurements.typeBpDia",
PULSE: "measurements.typePulse",
BODY_FAT: "measurements.typeBodyFat",
SLEEP_DURATION: "measurements.typeSleep",
ACTIVITY_STEPS: "measurements.typeSteps",
};
const TYPE_ICONS: Record<
string,
React.ComponentType<{ className?: string }>
> = {
WEIGHT: Scale,
BLOOD_PRESSURE_SYS: Heart,
BLOOD_PRESSURE_DIA: Heart,
PULSE: Activity,
BODY_FAT: Droplets,
SLEEP_DURATION: Moon,
ACTIVITY_STEPS: Footprints,
};
const TYPE_COLORS: Record<string, string> = {
WEIGHT: "bg-chart-1/20 text-chart-1",
BLOOD_PRESSURE_SYS: "bg-chart-3/20 text-chart-3",
BLOOD_PRESSURE_DIA: "bg-chart-3/20 text-chart-3",
PULSE: "bg-chart-5/20 text-chart-5",
BODY_FAT: "bg-chart-4/20 text-chart-4",
SLEEP_DURATION: "bg-chart-2/20 text-chart-2",
ACTIVITY_STEPS: "bg-chart-2/20 text-chart-2",
};
import {
MEASUREMENT_TYPE_LABEL_KEYS as TYPE_LABEL_KEYS,
MEASUREMENT_TYPE_ICONS as TYPE_ICONS,
MEASUREMENT_TYPE_COLORS as TYPE_COLORS,
} from "./measurement-list-meta";
interface Measurement {
id: string;
@@ -32,6 +32,7 @@ const WIDGET_LABEL_KEYS: Record<DashboardWidgetId, string> = {
totalBodyWater: "measurements.typeTotalBodyWater",
boneMass: "measurements.typeBoneMass",
bpInTarget: "dashboard.bpInTarget",
oxygenSaturation: "measurements.typeOxygenSaturation",
};
export function DashboardLayoutSection({ id }: { id: string }) {
@@ -27,12 +27,15 @@ const METRIC_ORDER: ThresholdMetric[] = [
"BLOOD_PRESSURE_DIA",
"PULSE",
"BODY_FAT",
"TOTAL_BODY_WATER",
"BONE_MASS",
"SLEEP_DURATION",
"ACTIVITY_STEPS",
"BLOOD_GLUCOSE_FASTING",
"BLOOD_GLUCOSE_POSTPRANDIAL",
"BLOOD_GLUCOSE_RANDOM",
"BLOOD_GLUCOSE_BEDTIME",
"OXYGEN_SATURATION",
];
const METRIC_LABEL_KEYS: Record<ThresholdMetric, string> = {
@@ -47,6 +50,9 @@ const METRIC_LABEL_KEYS: Record<ThresholdMetric, string> = {
BLOOD_GLUCOSE_POSTPRANDIAL: "thresholds.metricGlucosePostprandial",
BLOOD_GLUCOSE_RANDOM: "thresholds.metricGlucoseRandom",
BLOOD_GLUCOSE_BEDTIME: "thresholds.metricGlucoseBedtime",
TOTAL_BODY_WATER: "thresholds.metricBodyWater",
BONE_MASS: "thresholds.metricBoneMass",
OXYGEN_SATURATION: "thresholds.metricOxygenSaturation",
};
export function ThresholdsSection({ id }: { id: string }) {
@@ -0,0 +1,283 @@
import { describe, it, expect } from "vitest";
import { PDFParse } from "pdf-parse";
import {
buildDoctorReportPdfDocument,
renderDoctorReportPdfBytes,
DOCTOR_REPORT_VITAL_TYPES,
DOCTOR_REPORT_TYPE_LABEL_KEYS,
DOCTOR_REPORT_TYPE_UNIT_KEYS,
} from "../doctor-report-pdf-core";
import { measurementTypeEnum } from "../validations/measurement";
import type { DoctorReportData } from "../doctor-report-data";
import { getServerTranslator } from "../i18n/server-translator";
async function extractText(bytes: Uint8Array): Promise<string> {
const parser = new PDFParse({ data: bytes });
try {
const result = await parser.getText();
return result.text;
} finally {
await parser.destroy();
}
}
const FIXED_NOW = new Date("2026-05-03T12:00:00.000Z");
function makeData(overrides?: Partial<DoctorReportData>): DoctorReportData {
return {
period: { days: 90, since: "2026-02-02T00:00:00.000Z" },
patient: {
username: "marc",
dateOfBirth: "1985-06-15T00:00:00.000Z",
gender: "MALE",
heightCm: 182,
},
measurements: {
WEIGHT: [
{ value: 80, measuredAt: "2026-02-10T08:00:00.000Z" },
{ value: 79.5, measuredAt: "2026-04-30T08:00:00.000Z" },
],
},
stats: {
WEIGHT: { avg: 79.75, min: 79.5, max: 80, count: 2, latest: 79.5 },
BLOOD_PRESSURE_SYS: {
avg: 122,
min: 118,
max: 128,
count: 5,
latest: 120,
},
BLOOD_PRESSURE_DIA: {
avg: 78,
min: 72,
max: 82,
count: 5,
latest: 78,
},
PULSE: { avg: 65, min: 58, max: 72, count: 5, latest: 64 },
},
glucoseStats: {
FASTING: { avg: 92, min: 85, max: 100, count: 4, latest: 90 },
},
glucoseRanges: { FASTING: { min: 70, max: 99 } },
glucoseUnit: "mg/dL",
bmi: 24.1,
compliance: {
Ramipril: { total: 90, taken: 85, skipped: 3, missed: 2 },
},
medications: [
{
name: "Ramipril",
dose: "5mg",
schedules: [
{ windowStart: "08:00", windowEnd: "09:00", label: "Morning" },
],
},
],
mood: {
avg: 3.6,
min: 2,
max: 5,
count: 30,
distribution: { 1: 0, 2: 3, 3: 9, 4: 14, 5: 4 },
},
...overrides,
};
}
describe("renderDoctorReportPdfBytes", () => {
it("returns a Uint8Array starting with the %PDF- header", () => {
const { t } = getServerTranslator("de");
const bytes = renderDoctorReportPdfBytes(makeData(), {
t,
locale: "de",
now: FIXED_NOW,
});
expect(bytes).toBeInstanceOf(Uint8Array);
const header = String.fromCharCode(...bytes.slice(0, 5));
expect(header).toBe("%PDF-");
});
it("produces a non-trivial document (> 1 KB)", () => {
const { t } = getServerTranslator("de");
const bytes = renderDoctorReportPdfBytes(makeData(), {
t,
locale: "de",
now: FIXED_NOW,
});
expect(bytes.byteLength).toBeGreaterThan(1024);
});
it("is structurally deterministic for the same input + fixed timestamp", () => {
// jsPDF embeds a per-invocation file-id in the trailer (random UUID-like
// hex). The visible content is fully determined by the inputs, so we
// assert byte-length equality + identical content sections instead of a
// strict bytewise compare.
const { t } = getServerTranslator("de");
const a = renderDoctorReportPdfBytes(makeData(), {
t,
locale: "de",
now: FIXED_NOW,
});
const b = renderDoctorReportPdfBytes(makeData(), {
t,
locale: "de",
now: FIXED_NOW,
});
expect(a.byteLength).toBe(b.byteLength);
// Compare the first 95% of the document — file-id and trailer differ.
const cmp = Math.floor(a.byteLength * 0.95);
expect(
Buffer.from(a.slice(0, cmp)).equals(Buffer.from(b.slice(0, cmp))),
).toBe(true);
});
it("renders both DE and EN locales without errors", () => {
const de = renderDoctorReportPdfBytes(makeData(), {
t: getServerTranslator("de").t,
locale: "de",
now: FIXED_NOW,
});
const en = renderDoctorReportPdfBytes(makeData(), {
t: getServerTranslator("en").t,
locale: "en",
now: FIXED_NOW,
});
expect(de.byteLength).toBeGreaterThan(1024);
expect(en.byteLength).toBeGreaterThan(1024);
});
it("handles empty stats / no mood without throwing", () => {
const empty = makeData({
stats: {},
glucoseStats: {},
glucoseRanges: {},
compliance: {},
mood: null,
bmi: null,
});
const bytes = renderDoctorReportPdfBytes(empty, {
t: getServerTranslator("de").t,
locale: "de",
now: FIXED_NOW,
});
const header = String.fromCharCode(...bytes.slice(0, 5));
expect(header).toBe("%PDF-");
});
});
describe("buildDoctorReportPdfDocument", () => {
it("returns a jsPDF doc with at least one page", () => {
const doc = buildDoctorReportPdfDocument(makeData(), {
t: getServerTranslator("de").t,
locale: "de",
now: FIXED_NOW,
});
expect(doc.getNumberOfPages()).toBeGreaterThanOrEqual(1);
});
});
// Audit-2026-05-07 / phase P0 / closes audit C-9: server-side PDF used a
// stale type map and silently dropped body composition while the browser
// PDF rendered them. These tests pin the contract.
describe("doctor-report-pdf-core type-map coverage", () => {
it("exposes body composition (TOTAL_BODY_WATER, BONE_MASS) as vital types", () => {
expect(DOCTOR_REPORT_VITAL_TYPES).toContain("TOTAL_BODY_WATER");
expect(DOCTOR_REPORT_VITAL_TYPES).toContain("BONE_MASS");
});
it("provides a label key + unit for every vital type", () => {
for (const type of DOCTOR_REPORT_VITAL_TYPES) {
expect(DOCTOR_REPORT_TYPE_LABEL_KEYS[type]).toBeTruthy();
const unit = DOCTOR_REPORT_TYPE_UNIT_KEYS[type];
expect(
unit === null || (typeof unit === "string" && unit.length > 0),
).toBe(true);
}
});
it("vital types are a subset of the canonical measurement enum", () => {
const enumSet = new Set<string>(measurementTypeEnum.options);
for (const type of DOCTOR_REPORT_VITAL_TYPES) {
expect(enumSet.has(type), `${type} not in measurementTypeEnum`).toBe(
true,
);
}
});
it("renders body composition rows with their German labels in the document text", async () => {
const data = makeData({
stats: {
WEIGHT: { avg: 80, min: 79, max: 81, count: 5, latest: 80 },
TOTAL_BODY_WATER: {
avg: 42,
min: 40,
max: 44,
count: 5,
latest: 42,
},
BONE_MASS: { avg: 3.2, min: 3.1, max: 3.3, count: 5, latest: 3.2 },
},
});
const bytes = renderDoctorReportPdfBytes(data, {
t: getServerTranslator("de").t,
locale: "de",
now: FIXED_NOW,
});
const text = await extractText(bytes);
expect(text).toContain("Gesamtkörperwasser");
expect(text).toContain("Knochenmasse");
expect(text).toContain("42,0");
expect(text).toContain("3,2");
});
it("renders an OXYGEN_SATURATION row with the SpO2 label when stats are supplied", async () => {
const data = makeData({
stats: {
WEIGHT: { avg: 80, min: 79, max: 81, count: 5, latest: 80 },
OXYGEN_SATURATION: {
avg: 97.4,
min: 95,
max: 99,
count: 12,
latest: 98,
},
},
});
const bytes = renderDoctorReportPdfBytes(data, {
t: getServerTranslator("de").t,
locale: "de",
now: FIXED_NOW,
});
const text = await extractText(bytes);
expect(text).toContain("Sauerstoffsättigung");
expect(text).toContain("97,4");
expect(text).toContain("%");
});
it("renders body composition rows in English when locale is en", async () => {
const data = makeData({
stats: {
WEIGHT: { avg: 80, min: 79, max: 81, count: 5, latest: 80 },
TOTAL_BODY_WATER: { avg: 42, min: 40, max: 44, count: 5, latest: 42 },
BONE_MASS: { avg: 3.2, min: 3.1, max: 3.3, count: 5, latest: 3.2 },
OXYGEN_SATURATION: {
avg: 97.4,
min: 95,
max: 99,
count: 12,
latest: 98,
},
},
});
const bytes = renderDoctorReportPdfBytes(data, {
t: getServerTranslator("en").t,
locale: "en",
now: FIXED_NOW,
});
const text = await extractText(bytes);
expect(text).toContain("Total body water");
expect(text).toContain("Bone mass");
expect(text).toContain("Oxygen saturation");
});
});
+20
View File
@@ -66,6 +66,26 @@ describe("formatMeasurementsForExport", () => {
measuredAt: "2025-01-15T08:00:00.000Z",
source: "MANUAL",
notes: "",
glucoseContext: "",
});
});
it("round-trips glucoseContext on BLOOD_GLUCOSE rows", () => {
const measurements = [
{
type: "BLOOD_GLUCOSE",
value: 92,
unit: "mg/dL",
measuredAt: new Date("2025-01-15T08:00:00Z"),
source: "MANUAL",
notes: null,
glucoseContext: "FASTING",
},
];
const result = formatMeasurementsForExport(measurements);
expect(result[0]).toMatchObject({
type: "BLOOD_GLUCOSE",
glucoseContext: "FASTING",
});
});
});
+89
View File
@@ -0,0 +1,89 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
const ORIGINAL_ENV = { ...process.env };
beforeEach(() => {
process.env = { ...ORIGINAL_ENV };
delete process.env.IP_GEO_LOOKUP_URL;
delete process.env.IP_GEO_LOOKUP_DISABLED;
vi.resetModules();
});
afterEach(() => {
process.env = ORIGINAL_ENV;
vi.restoreAllMocks();
});
// V3 audit: /api/auth events were leaking IP + timestamp via plaintext
// HTTP to ip-api.com (GDPR Art. 32 + Art. 44). The lookup helper now
// (a) only egresses over HTTPS, (b) supports an opt-out env, and
// (c) accepts both ipwho.is and ip-api.com pro response shapes.
describe("lookupIpLocation IP-geolocation HTTPS guard", () => {
it("returns null for private addresses without making any request", async () => {
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
const { lookupIpLocation } = await import("../geo");
expect(await lookupIpLocation("10.0.0.5")).toBeNull();
expect(await lookupIpLocation("127.0.0.1")).toBeNull();
expect(await lookupIpLocation("::1")).toBeNull();
expect(await lookupIpLocation(null)).toBeNull();
expect(fetchSpy).not.toHaveBeenCalled();
});
it("returns null when IP_GEO_LOOKUP_DISABLED=1", async () => {
process.env.IP_GEO_LOOKUP_DISABLED = "1";
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
const { lookupIpLocation } = await import("../geo");
expect(await lookupIpLocation("8.8.8.8")).toBeNull();
expect(fetchSpy).not.toHaveBeenCalled();
});
it("uses HTTPS by default (ipwho.is)", async () => {
const fetchSpy = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true, city: "Berlin", country_code: "DE" }),
});
vi.stubGlobal("fetch", fetchSpy);
const { lookupIpLocation } = await import("../geo");
expect(await lookupIpLocation("8.8.8.8")).toBe("Berlin, DE");
const url = fetchSpy.mock.calls[0]?.[0] as string;
expect(url.startsWith("https://")).toBe(true);
});
it("refuses to call non-HTTPS configured providers", async () => {
process.env.IP_GEO_LOOKUP_URL = "http://ip-api.com/json";
const fetchSpy = vi.fn().mockResolvedValue({
ok: false,
json: async () => ({}),
});
vi.stubGlobal("fetch", fetchSpy);
const { lookupIpLocation } = await import("../geo");
const result = await lookupIpLocation("8.8.8.8");
expect(result).toBeNull();
const url = fetchSpy.mock.calls[0]?.[0] as string;
// Even when an operator misconfigures HTTP, the helper rewrites the
// URL to a deliberately invalid HTTPS URL so the egress is HTTPS-only.
expect(url.startsWith("https://")).toBe(true);
});
it("accepts the ip-api pro response shape (status:'success', countryCode)", async () => {
process.env.IP_GEO_LOOKUP_URL = "https://pro.ip-api.com/json";
const fetchSpy = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
status: "success",
city: "Hamburg",
countryCode: "DE",
}),
});
vi.stubGlobal("fetch", fetchSpy);
const { lookupIpLocation } = await import("../geo");
expect(await lookupIpLocation("8.8.8.8")).toBe("Hamburg, DE");
});
});
+99
View File
@@ -0,0 +1,99 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { getClientIp } from "../api-response";
const ORIGINAL_ENV = process.env.TRUST_PROXY_HOPS;
beforeEach(() => {
delete process.env.TRUST_PROXY_HOPS;
});
afterEach(() => {
if (ORIGINAL_ENV === undefined) delete process.env.TRUST_PROXY_HOPS;
else process.env.TRUST_PROXY_HOPS = ORIGINAL_ENV;
});
function makeRequest(headers: Record<string, string>): Request {
return new Request("https://example.com/", { headers });
}
// V3 audit: client-supplied X-Forwarded-For was being trusted blindly,
// allowing a per-request `XFF: 1.2.3.<rand>` to rotate the bucket of any
// per-IP rate-limit. The new contract reads XFF right-to-left with a
// configurable number of trusted hops (Express semantics).
describe("getClientIp trusted-proxy semantics (V3 audit)", () => {
it("defaults to 1 trusted hop — returns the rightmost XFF entry", () => {
const ip = getClientIp(
makeRequest({ "x-forwarded-for": "9.9.9.9, 1.2.3.4, 5.6.7.8" }),
);
expect(ip).toBe("5.6.7.8");
});
it("with TRUST_PROXY_HOPS=2 returns the second-from-rightmost", () => {
process.env.TRUST_PROXY_HOPS = "2";
const ip = getClientIp(
makeRequest({ "x-forwarded-for": "9.9.9.9, 1.2.3.4, 5.6.7.8" }),
);
expect(ip).toBe("1.2.3.4");
});
it("with TRUST_PROXY_HOPS=2 and a chain shorter than 2 returns null (fixes review M-3)", () => {
process.env.TRUST_PROXY_HOPS = "2";
// Misconfigured deployment: claims 2 trusted hops but only 1 proxy is
// in the chain. Falling back to the leftmost (attacker-controlled)
// entry would re-introduce XFF rotation. Refuse the chain entirely.
const ip = getClientIp(makeRequest({ "x-forwarded-for": "5.6.7.8" }));
expect(ip).toBeNull();
});
it("throws at boot when TRUST_PROXY_HOPS is unparseable (fixes review L-1)", () => {
process.env.TRUST_PROXY_HOPS = "garbage";
expect(() =>
getClientIp(makeRequest({ "x-forwarded-for": "5.6.7.8" })),
).toThrow(/TRUST_PROXY_HOPS/);
});
it("with TRUST_PROXY_HOPS=0 ignores XFF entirely", () => {
process.env.TRUST_PROXY_HOPS = "0";
const ip = getClientIp(
makeRequest({
"x-forwarded-for": "9.9.9.9, 1.2.3.4",
"x-real-ip": "8.8.8.8",
}),
);
expect(ip).toBe("8.8.8.8");
});
it("with TRUST_PROXY_HOPS=0 and no x-real-ip returns null", () => {
process.env.TRUST_PROXY_HOPS = "0";
const ip = getClientIp(
makeRequest({ "x-forwarded-for": "9.9.9.9, 1.2.3.4" }),
);
expect(ip).toBeNull();
});
it("XFF rotation attack — caller cannot bypass per-IP rate-limit by changing leftmost entry", () => {
// Attacker sends successive requests with rotating leftmost XFF; the
// proxy still appends the real client IP at the end, so the helper
// returns the same IP regardless of attacker chosen XFF.
const a = getClientIp(
makeRequest({ "x-forwarded-for": "1.1.1.1, 203.0.113.5" }),
);
const b = getClientIp(
makeRequest({ "x-forwarded-for": "8.8.8.8, 203.0.113.5" }),
);
expect(a).toBe("203.0.113.5");
expect(b).toBe("203.0.113.5");
});
it("rejects malformed entries in the chain", () => {
const ip = getClientIp(
makeRequest({ "x-forwarded-for": "garbage,, 5.6.7.8" }),
);
expect(ip).toBe("5.6.7.8");
});
it("falls back to x-real-ip if XFF missing", () => {
const ip = getClientIp(makeRequest({ "x-real-ip": "5.6.7.8" }));
expect(ip).toBe("5.6.7.8");
});
});
+312
View File
@@ -0,0 +1,312 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest, NextResponse } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
idempotencyKey: {
findUnique: vi.fn(),
create: vi.fn(),
delete: vi.fn(),
},
apiToken: {
findUnique: vi.fn(),
},
},
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
vi.mock("@/lib/auth/session", () => ({
getSession: vi.fn(),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(),
}));
vi.mock("@/lib/auth/hmac", () => ({
hashToken: vi.fn((raw: string) => `hashed:${raw}`),
}));
import {
withIdempotency,
defaultUserIdResolver,
isCachableStatus,
} from "../idempotency";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { headers } from "next/headers";
function makeRequest(
method: string,
headers: Record<string, string> = {},
): NextRequest {
return new NextRequest("http://localhost/api/example", {
method,
headers: { "content-type": "application/json", ...headers },
body: method === "GET" ? undefined : JSON.stringify({ ok: true }),
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.idempotencyKey.findUnique).mockResolvedValue(null);
vi.mocked(prisma.idempotencyKey.create).mockResolvedValue({} as never);
});
describe("withIdempotency", () => {
it("passes through when no Idempotency-Key header is present", async () => {
const handler = vi.fn(async () =>
NextResponse.json({ data: { ok: true }, error: null }, { status: 201 }),
);
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
const res = await wrapped(makeRequest("POST"));
expect(res.status).toBe(201);
expect(handler).toHaveBeenCalledTimes(1);
expect(prisma.idempotencyKey.findUnique).not.toHaveBeenCalled();
expect(prisma.idempotencyKey.create).not.toHaveBeenCalled();
});
it("caches the response and replays it on the second call", async () => {
let callCount = 0;
const handler = vi.fn(async () => {
callCount += 1;
return NextResponse.json(
{ data: { result: callCount }, error: null },
{ status: 201 },
);
});
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
// First call: nothing cached → handler runs → response stored.
const req1 = makeRequest("POST", { "idempotency-key": "abc-12345678" });
const res1 = await wrapped(req1);
const body1 = await res1.json();
expect(res1.status).toBe(201);
expect(body1).toEqual({ data: { result: 1 }, error: null });
expect(handler).toHaveBeenCalledTimes(1);
expect(prisma.idempotencyKey.create).toHaveBeenCalledTimes(1);
// Capture the persisted body for replay.
const persistedBody = (
vi.mocked(prisma.idempotencyKey.create).mock.calls[0][0] as {
data: { responseBody: string; responseStatus: number };
}
).data;
// Second call: cache hit returns persisted envelope.
vi.mocked(prisma.idempotencyKey.findUnique).mockResolvedValueOnce({
id: "idem-1",
userId: "u-1",
key: "abc-12345678",
method: "POST",
path: "/api/example",
responseStatus: persistedBody.responseStatus,
responseBody: persistedBody.responseBody,
expiresAt: new Date(Date.now() + 86_400_000),
createdAt: new Date(),
} as never);
const req2 = makeRequest("POST", { "idempotency-key": "abc-12345678" });
const res2 = await wrapped(req2);
expect(res2.headers.get("X-Idempotent-Replay")).toBe("true");
expect(res2.status).toBe(201);
const body2 = await res2.json();
expect(body2).toEqual({ data: { result: 1 }, error: null });
expect(handler).toHaveBeenCalledTimes(1); // not called again
});
it("ignores expired cache rows and re-runs the handler", async () => {
const handler = vi.fn(async () =>
NextResponse.json({ data: "fresh", error: null }, { status: 200 }),
);
vi.mocked(prisma.idempotencyKey.findUnique).mockResolvedValueOnce({
id: "idem-old",
userId: "u-1",
key: "abc-12345678",
method: "POST",
path: "/api/example",
responseStatus: 200,
responseBody: '{"data":"stale","error":null}',
expiresAt: new Date(Date.now() - 1000),
createdAt: new Date(),
} as never);
vi.mocked(prisma.idempotencyKey.delete).mockResolvedValue({} as never);
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
const res = await wrapped(
makeRequest("POST", { "idempotency-key": "abc-12345678" }),
);
const body = await res.json();
expect(handler).toHaveBeenCalledTimes(1);
expect(body).toEqual({ data: "fresh", error: null });
expect(prisma.idempotencyKey.delete).toHaveBeenCalled();
});
it("ignores malformed Idempotency-Key headers", async () => {
const handler = vi.fn(async () =>
NextResponse.json({ data: "ok", error: null }),
);
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
await wrapped(makeRequest("POST", { "idempotency-key": "!!" }));
expect(handler).toHaveBeenCalledTimes(1);
expect(prisma.idempotencyKey.findUnique).not.toHaveBeenCalled();
});
it("does nothing for GET requests", async () => {
const handler = vi.fn(async () =>
NextResponse.json({ data: "ok", error: null }),
);
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
await wrapped(makeRequest("GET", { "idempotency-key": "abc-12345678" }));
expect(prisma.idempotencyKey.findUnique).not.toHaveBeenCalled();
});
it("defaults to the cookie session when no resolver is given", async () => {
vi.mocked(getSession).mockResolvedValue({
session: { id: "s-1", expiresAt: new Date(Date.now() + 60_000) },
user: { id: "u-default" },
} as never);
const handler = vi.fn(async () =>
NextResponse.json({ data: "ok", error: null }, { status: 201 }),
);
const wrapped = withIdempotency<[NextRequest]>(handler);
await wrapped(makeRequest("POST", { "idempotency-key": "abc-12345678" }));
expect(getSession).toHaveBeenCalledTimes(1);
expect(prisma.idempotencyKey.create).toHaveBeenCalledTimes(1);
const persisted = vi.mocked(prisma.idempotencyKey.create).mock
.calls[0][0] as { data: { userId: string } };
expect(persisted.data.userId).toBe("u-default");
});
it("skips caching when the default resolver finds no session", async () => {
vi.mocked(getSession).mockResolvedValue(null);
vi.mocked(headers).mockResolvedValue({
get: vi.fn().mockReturnValue(null),
} as unknown as ReturnType<typeof headers> extends Promise<infer T>
? T
: never);
const handler = vi.fn(async () =>
NextResponse.json({ data: "ok", error: null }, { status: 201 }),
);
const wrapped = withIdempotency<[NextRequest]>(handler);
await wrapped(makeRequest("POST", { "idempotency-key": "abc-12345678" }));
expect(handler).toHaveBeenCalledTimes(1);
expect(prisma.idempotencyKey.findUnique).not.toHaveBeenCalled();
expect(prisma.idempotencyKey.create).not.toHaveBeenCalled();
});
});
// Audit C-4 / phase P2: defaultUserIdResolver must support both cookie
// sessions AND Bearer tokens. Without the Bearer fallback, idempotency
// silently turned off for the iOS / external-ingest paths it was built for.
describe("defaultUserIdResolver (audit C-4)", () => {
function mockHeader(value: string | null) {
vi.mocked(headers).mockResolvedValue({
get: vi.fn().mockImplementation((name: string) =>
name.toLowerCase() === "authorization" ? value : null,
),
} as unknown as ReturnType<typeof headers> extends Promise<infer T>
? T
: never);
}
it("returns the session user id when a cookie session is present", async () => {
vi.mocked(getSession).mockResolvedValue({
session: { id: "s-1" },
user: { id: "u-cookie", role: "USER" },
} as Awaited<ReturnType<typeof getSession>>);
mockHeader(null);
expect(await defaultUserIdResolver()).toBe("u-cookie");
});
it("falls back to Bearer-token resolution when no cookie session", async () => {
vi.mocked(getSession).mockResolvedValue(null);
mockHeader("Bearer hlk_abcdef");
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
userId: "u-bearer",
revoked: false,
expiresAt: null,
} as never);
expect(await defaultUserIdResolver()).toBe("u-bearer");
// V3 audit: assert the where-clause used the hashed token, not the
// raw bearer. The hashToken mock returns "hashed:<raw>" — the lookup
// MUST be against that, otherwise we are storing recoverable secrets.
expect(prisma.apiToken.findUnique).toHaveBeenCalledWith(
expect.objectContaining({
where: { tokenHash: "hashed:hlk_abcdef" },
}),
);
});
it("rejects revoked Bearer tokens", async () => {
vi.mocked(getSession).mockResolvedValue(null);
mockHeader("Bearer hlk_abcdef");
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
userId: "u-bearer",
revoked: true,
expiresAt: null,
} as never);
expect(await defaultUserIdResolver()).toBeNull();
});
it("rejects expired Bearer tokens", async () => {
vi.mocked(getSession).mockResolvedValue(null);
mockHeader("Bearer hlk_abcdef");
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
userId: "u-bearer",
revoked: false,
expiresAt: new Date(Date.now() - 1000),
} as never);
expect(await defaultUserIdResolver()).toBeNull();
});
it("returns null when no auth method is provided", async () => {
vi.mocked(getSession).mockResolvedValue(null);
mockHeader(null);
expect(await defaultUserIdResolver()).toBeNull();
});
});
// V3 audit STILL-V2-NEW: the cachable-status filter (do-not-cache for
// 401/403/408/429/5xx) had zero tests, so a regression that re-cached an
// expired bearer token's 401 would have been silent.
describe("isCachableStatus do-not-cache rules (V3 audit)", () => {
it("caches 2xx success responses", () => {
expect(isCachableStatus(200)).toBe(true);
expect(isCachableStatus(201)).toBe(true);
expect(isCachableStatus(204)).toBe(true);
});
it("caches 4xx validation responses (so retries don't re-execute side-effects)", () => {
expect(isCachableStatus(400)).toBe(true);
expect(isCachableStatus(404)).toBe(true);
expect(isCachableStatus(409)).toBe(true);
expect(isCachableStatus(422)).toBe(true);
});
it("does NOT cache 401 — the token may have been refreshed between attempts", () => {
expect(isCachableStatus(401)).toBe(false);
});
it("does NOT cache 403 — authorization can change between attempts", () => {
expect(isCachableStatus(403)).toBe(false);
});
it("does NOT cache 408 — caller-side timeout deserves a fresh attempt", () => {
expect(isCachableStatus(408)).toBe(false);
});
it("does NOT cache 429 — caller deserves a fresh window-check on retry", () => {
expect(isCachableStatus(429)).toBe(false);
});
it("does NOT cache any 5xx — server fault must not lock the user out", () => {
expect(isCachableStatus(500)).toBe(false);
expect(isCachableStatus(502)).toBe(false);
expect(isCachableStatus(503)).toBe(false);
expect(isCachableStatus(504)).toBe(false);
});
});
@@ -0,0 +1,75 @@
import { describe, it, expect } from "vitest";
import { measurementTypeEnum } from "@/lib/validations/measurement";
import {
DOCTOR_REPORT_VITAL_TYPES,
DOCTOR_REPORT_TYPE_LABEL_KEYS,
DOCTOR_REPORT_TYPE_UNIT_KEYS,
} from "@/lib/doctor-report-pdf-core";
// Single source of truth for which measurement types exist.
// V3 audit "enum drift cousins": 7 module-level hardcoded arrays were
// silently dropping new types (SpO2, TBW, BoneMass, BloodGlucose) from
// dashboard / analytics / AI insights / iOS adapters / import.
//
// All ingest, analytics and reporting paths are now derived from
// `measurementTypeEnum.options`, so adding a new type only needs touching
// the enum. This test asserts that contract.
const EXPECTED_TYPES = [
"WEIGHT",
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",
"PULSE",
"BODY_FAT",
"SLEEP_DURATION",
"ACTIVITY_STEPS",
"BLOOD_GLUCOSE",
"TOTAL_BODY_WATER",
"BONE_MASS",
"OXYGEN_SATURATION",
] as const;
describe("measurementTypeEnum coverage", () => {
it("exposes the 11 canonical measurement types", () => {
expect([...measurementTypeEnum.options].sort()).toEqual(
[...EXPECTED_TYPES].sort(),
);
});
// Documented exclusions from the doctor-report main vitals table:
// - BLOOD_GLUCOSE renders through the per-context `glucoseStats` section
// - SLEEP_DURATION + ACTIVITY_STEPS are intentionally omitted from the
// clinical PDF (lifestyle, not a vital sign — see source comment).
// Updates to this set MUST be paired with a comment in
// doctor-report-pdf-core.ts so the rationale stays discoverable.
const PDF_VITAL_EXCLUSIONS = new Set([
"BLOOD_GLUCOSE",
"SLEEP_DURATION",
"ACTIVITY_STEPS",
]);
it("doctor-report PDF vital types cover the canonical enum minus documented exclusions", () => {
const expected = measurementTypeEnum.options.filter(
(t) => !PDF_VITAL_EXCLUSIONS.has(t),
);
expect([...DOCTOR_REPORT_VITAL_TYPES].sort()).toEqual([...expected].sort());
});
it("doctor-report PDF has a label key for every renderable type", () => {
for (const type of DOCTOR_REPORT_VITAL_TYPES) {
expect(
DOCTOR_REPORT_TYPE_LABEL_KEYS[type],
`missing label key for ${type}`,
).toBeTruthy();
}
});
it("doctor-report PDF has a unit key for every renderable type", () => {
for (const type of DOCTOR_REPORT_VITAL_TYPES) {
const unit = DOCTOR_REPORT_TYPE_UNIT_KEYS[type];
expect(
unit === null || (typeof unit === "string" && unit.length > 0),
`missing unit for ${type}`,
).toBe(true);
}
});
});
+87
View File
@@ -0,0 +1,87 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import {
encryptMoodLogSecret,
readMoodLogSecret,
isLegacyPlaintext,
rotateLegacyMoodLogSecrets,
} from "../moodlog-secret";
const ORIGINAL_ENCRYPTION_KEY = process.env.ENCRYPTION_KEY;
beforeEach(() => {
process.env.ENCRYPTION_KEY = "a".repeat(64);
});
afterEach(() => {
if (ORIGINAL_ENCRYPTION_KEY === undefined) delete process.env.ENCRYPTION_KEY;
else process.env.ENCRYPTION_KEY = ORIGINAL_ENCRYPTION_KEY;
});
// V3 audit STILL-V2-C-2: mood_log_webhook_secret was stored in plaintext.
// The new contract encrypts at rest with AES-256-GCM and tolerates legacy
// plaintext rows during the transition window.
describe("moodLog secret encrypt-at-rest", () => {
it("encrypts and decrypts a generated secret round-trip", () => {
const plaintext = "mb_" + "f".repeat(64);
const encrypted = encryptMoodLogSecret(plaintext);
expect(encrypted).not.toBe(plaintext);
expect(readMoodLogSecret(encrypted)).toBe(plaintext);
});
it("treats unrecognised values as legacy plaintext (transitional grace)", () => {
expect(readMoodLogSecret("mb_legacyplaintextsecret")).toBe(
"mb_legacyplaintextsecret",
);
});
it("returns null for null/empty stored values", () => {
expect(readMoodLogSecret(null)).toBeNull();
expect(readMoodLogSecret("")).toBeNull();
});
it("isLegacyPlaintext detects rotation status correctly", () => {
const enc = encryptMoodLogSecret("mb_test");
expect(isLegacyPlaintext(enc)).toBe(false);
expect(isLegacyPlaintext("mb_test")).toBe(true);
expect(isLegacyPlaintext(null)).toBe(false);
});
it("rotateLegacyMoodLogSecrets encrypts only legacy rows and is idempotent", async () => {
const enc = encryptMoodLogSecret("mb_alreadyencrypted");
const rows = [
{ id: "u1", moodLogWebhookSecret: "mb_legacy1" },
{ id: "u2", moodLogWebhookSecret: enc },
{ id: "u3", moodLogWebhookSecret: "mb_legacy3" },
{ id: "u4", moodLogWebhookSecret: null },
];
const updates: Array<{ id: string; encrypted: string }> = [];
const rotated = await rotateLegacyMoodLogSecrets({
findLegacy: async () => rows,
rotate: async (id, encrypted) => {
updates.push({ id, encrypted });
},
});
expect(rotated).toBe(2);
expect(updates.map((u) => u.id).sort()).toEqual(["u1", "u3"]);
for (const update of updates) {
// Each rotation must produce a value that decrypts back to the
// original legacy plaintext — no data loss.
const original = rows.find((r) => r.id === update.id)!.moodLogWebhookSecret;
expect(readMoodLogSecret(update.encrypted)).toBe(original);
}
// Re-running on the now-encrypted store rotates nothing.
const next = await rotateLegacyMoodLogSecrets({
findLegacy: async () => [
{ id: "u1", moodLogWebhookSecret: updates[0].encrypted },
{ id: "u2", moodLogWebhookSecret: enc },
],
rotate: async () => {
throw new Error("should not be called");
},
});
expect(next).toBe(0);
});
});
@@ -0,0 +1,224 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// --- Mocks must be hoisted before importing the module under test. ---
vi.mock("@/lib/db", () => ({
prisma: {
apiToken: {
findUnique: vi.fn(),
update: vi.fn(),
},
user: {
findUnique: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({
getSession: vi.fn(),
}));
vi.mock("@/lib/auth/hmac", () => ({
hashToken: vi.fn(),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({
emitIfSampled: vi.fn(),
}));
const headersGet = vi.fn<(name: string) => string | null>();
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: headersGet })),
cookies: vi.fn(async () => ({ get: () => undefined, set: () => {}, delete: () => {} })),
}));
// --- Imports use the mocked modules above. ---
import { requireAuth, HttpError } from "../api-handler";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { hashToken } from "@/lib/auth/hmac";
import { auditLog } from "@/lib/auth/audit";
const FAKE_HASH = "deadbeefcafef00d";
const RAW_TOKEN = "hlk_" + "a".repeat(64);
const FAKE_USER = {
id: "user-1",
role: "USER" as const,
username: "marc",
email: "marc@example.com",
};
function setBearerHeader(value: string | null): void {
headersGet.mockReset();
headersGet.mockImplementation((name: string) =>
name.toLowerCase() === "authorization" ? value : null,
);
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(hashToken).mockReturnValue(FAKE_HASH);
vi.mocked(prisma.apiToken.update).mockResolvedValue({} as never);
vi.mocked(auditLog).mockResolvedValue(undefined as never);
});
describe("requireAuth — Bearer token path", () => {
it("authenticates a valid Bearer token and returns AuthContext", async () => {
vi.mocked(getSession).mockResolvedValue(null);
setBearerHeader(`Bearer ${RAW_TOKEN}`);
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
id: "token-1",
userId: "user-1",
permissions: ["medication:ingest"],
revoked: false,
expiresAt,
} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue(FAKE_USER as never);
const ctx = await requireAuth();
expect(hashToken).toHaveBeenCalledWith(RAW_TOKEN);
// V3 audit: pin the where-clause shape — a regression that switches
// back to raw-token comparison (where: { tokenHash: RAW_TOKEN }) would
// be a CRITICAL leak of stored hashes. Tests must enforce the hash.
expect(prisma.apiToken.findUnique).toHaveBeenCalledWith(
expect.objectContaining({ where: { tokenHash: FAKE_HASH } }),
);
expect(ctx.user.id).toBe("user-1");
expect(ctx.session.id).toBe("token-1");
expect(ctx.session.expiresAt).toEqual(expiresAt);
// lastUsedAt refresh is fire-and-forget but should still have been triggered.
expect(prisma.apiToken.update).toHaveBeenCalledWith({
where: { id: "token-1" },
data: { lastUsedAt: expect.any(Date) },
});
expect(auditLog).toHaveBeenCalledWith(
"auth.bearer.success",
expect.objectContaining({ userId: "user-1" }),
);
});
it("rejects a revoked token with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
setBearerHeader(`Bearer ${RAW_TOKEN}`);
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
id: "token-2",
userId: "user-1",
permissions: [],
revoked: true,
expiresAt: null,
} as never);
await expect(requireAuth()).rejects.toMatchObject({
statusCode: 401,
} satisfies Partial<HttpError>);
expect(auditLog).toHaveBeenCalledWith(
"auth.bearer.failure",
expect.objectContaining({
details: expect.objectContaining({ reason: "revoked" }),
}),
);
expect(prisma.user.findUnique).not.toHaveBeenCalled();
});
it("rejects an expired token with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
setBearerHeader(`Bearer ${RAW_TOKEN}`);
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
id: "token-3",
userId: "user-1",
permissions: [],
revoked: false,
expiresAt: new Date(Date.now() - 60_000),
} as never);
await expect(requireAuth()).rejects.toMatchObject({ statusCode: 401 });
expect(auditLog).toHaveBeenCalledWith(
"auth.bearer.failure",
expect.objectContaining({
details: expect.objectContaining({ reason: "expired" }),
}),
);
});
it("rejects an unknown token with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
setBearerHeader(`Bearer ${RAW_TOKEN}`);
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue(null);
await expect(requireAuth()).rejects.toMatchObject({ statusCode: 401 });
expect(auditLog).toHaveBeenCalledWith(
"auth.bearer.failure",
expect.objectContaining({
details: expect.objectContaining({ reason: "unknown_token" }),
}),
);
});
it("rejects a Bearer token missing the requested permission with 403", async () => {
vi.mocked(getSession).mockResolvedValue(null);
setBearerHeader(`Bearer ${RAW_TOKEN}`);
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
id: "token-4",
userId: "user-1",
permissions: ["something:else"],
revoked: false,
expiresAt: null,
} as never);
await expect(requireAuth("medication:ingest")).rejects.toMatchObject({
statusCode: 403,
});
expect(auditLog).toHaveBeenCalledWith(
"auth.bearer.failure",
expect.objectContaining({
details: expect.objectContaining({
reason: "insufficient_permissions",
required: "medication:ingest",
}),
}),
);
expect(prisma.user.findUnique).not.toHaveBeenCalled();
});
it("returns 401 when neither cookie nor Bearer is present", async () => {
vi.mocked(getSession).mockResolvedValue(null);
setBearerHeader(null);
await expect(requireAuth()).rejects.toMatchObject({ statusCode: 401 });
expect(prisma.apiToken.findUnique).not.toHaveBeenCalled();
});
});
describe("requireAuth — cookie path remains intact", () => {
it("returns the session payload without consulting the Bearer header", async () => {
const cookieSession = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3600_000) },
user: { ...FAKE_USER, role: "USER" as const },
};
vi.mocked(getSession).mockResolvedValue(cookieSession as never);
// Even if a Bearer header is present, the cookie wins (existing behaviour).
setBearerHeader(`Bearer ${RAW_TOKEN}`);
const ctx = await requireAuth("medication:ingest");
expect(ctx).toEqual(cookieSession);
// Cookie short-circuits before any token logic runs.
expect(prisma.apiToken.findUnique).not.toHaveBeenCalled();
expect(hashToken).not.toHaveBeenCalled();
expect(auditLog).not.toHaveBeenCalled();
});
});
@@ -88,6 +88,108 @@ describe("getAllEffectiveRanges", () => {
const keys = Object.keys(ranges);
expect(keys).toContain("WEIGHT");
expect(keys).toContain("BLOOD_GLUCOSE_FASTING");
expect(keys.length).toBeGreaterThanOrEqual(11);
expect(keys).toContain("TOTAL_BODY_WATER");
expect(keys).toContain("BONE_MASS");
expect(keys).toContain("OXYGEN_SATURATION");
expect(keys.length).toBeGreaterThanOrEqual(14);
});
});
// Audit-2026-05-07 / phase P0 / closes audit C-15: TOTAL_BODY_WATER and
// BONE_MASS lacked threshold definitions. Severity logic returned `nominal`
// for any value, so users would see "all healthy" regardless of input.
describe("body composition thresholds", () => {
it("TOTAL_BODY_WATER has a sensible non-null green band", () => {
const result = getEffectiveRange("TOTAL_BODY_WATER", baseProfile, null);
expect(result.range).not.toBeNull();
expect(result.range!.greenMin).toBeGreaterThan(0);
expect(result.range!.greenMax).toBeGreaterThan(result.range!.greenMin);
expect(result.range!.orangeMin).toBeLessThan(result.range!.greenMin);
expect(result.range!.orangeMax).toBeGreaterThan(result.range!.greenMax);
});
it("BONE_MASS has a sensible non-null green band", () => {
const result = getEffectiveRange("BONE_MASS", baseProfile, null);
expect(result.range).not.toBeNull();
expect(result.range!.greenMin).toBeGreaterThan(0);
expect(result.range!.greenMax).toBeGreaterThan(result.range!.greenMin);
});
it("respects user override for body water", () => {
const overrides: ThresholdOverridesJson = {
TOTAL_BODY_WATER: { min: 30, max: 45 },
};
const result = getEffectiveRange(
"TOTAL_BODY_WATER",
baseProfile,
overrides,
);
expect(result.isOverride).toBe(true);
expect(result.range!.greenMin).toBe(30);
expect(result.range!.greenMax).toBe(45);
});
it("METRIC_BOUNDS for body composition match VALUE_RANGES in validation", () => {
expect(METRIC_BOUNDS.TOTAL_BODY_WATER).toEqual({
min: 5,
max: 100,
unit: "kg",
});
expect(METRIC_BOUNDS.BONE_MASS).toEqual({ min: 0.5, max: 8, unit: "kg" });
});
});
// Audit-2026-05-07 / v1.3.3: SpO2 (pulse oximetry) added as a single-value
// metric with lower-only severity (saturation cannot exceed 100% by definition).
// Defaults follow BTS Guideline 2017 (target 94–98%) and ATS clinical practice
// (≥95% at rest is healthy). Below 92% triggers orange band; below 88% in
// real-world clinical literature is the ER threshold.
describe("OXYGEN_SATURATION thresholds", () => {
it("has a non-null green band centred on the BTS healthy range", () => {
const result = getEffectiveRange("OXYGEN_SATURATION", baseProfile, null);
expect(result.range).not.toBeNull();
expect(result.range!.greenMin).toBe(95);
expect(result.range!.greenMax).toBe(100);
});
it("collapses upper orange wing to greenMax (no upper concern)", () => {
const result = getEffectiveRange("OXYGEN_SATURATION", baseProfile, null);
expect(result.range!.orangeMax).toBe(result.range!.greenMax);
});
it("respects user override (e.g. COPD baseline 88-92)", () => {
const overrides: ThresholdOverridesJson = {
OXYGEN_SATURATION: { min: 88, max: 92 },
};
const result = getEffectiveRange(
"OXYGEN_SATURATION",
baseProfile,
overrides,
);
expect(result.isOverride).toBe(true);
expect(result.range!.greenMin).toBe(88);
expect(result.range!.greenMax).toBe(92);
});
it("clamps override orangeMax to physiological 100% (no impossible saturations)", () => {
// Without the bounds clamp this used to emit orangeMax = 100.75.
const overrides: ThresholdOverridesJson = {
OXYGEN_SATURATION: { min: 95, max: 100 },
};
const result = getEffectiveRange(
"OXYGEN_SATURATION",
baseProfile,
overrides,
);
expect(result.range!.orangeMax).toBeLessThanOrEqual(100);
expect(result.range!.orangeMin).toBeGreaterThanOrEqual(50);
});
it("METRIC_BOUNDS plausibility floor is 50% (incompatible-with-life below)", () => {
expect(METRIC_BOUNDS.OXYGEN_SATURATION).toEqual({
min: 50,
max: 100,
unit: "%",
});
});
});
+50 -6
View File
@@ -37,7 +37,10 @@ export type ThresholdMetric =
| "BLOOD_GLUCOSE_FASTING"
| "BLOOD_GLUCOSE_POSTPRANDIAL"
| "BLOOD_GLUCOSE_RANDOM"
| "BLOOD_GLUCOSE_BEDTIME";
| "BLOOD_GLUCOSE_BEDTIME"
| "TOTAL_BODY_WATER"
| "BONE_MASS"
| "OXYGEN_SATURATION";
export interface ThresholdOverride {
min: number;
@@ -79,6 +82,14 @@ export const METRIC_BOUNDS: Record<
BLOOD_GLUCOSE_POSTPRANDIAL: { min: 40, max: 500, unit: "mg/dL" },
BLOOD_GLUCOSE_RANDOM: { min: 40, max: 500, unit: "mg/dL" },
BLOOD_GLUCOSE_BEDTIME: { min: 40, max: 400, unit: "mg/dL" },
// Body composition (Withings type 77 / 88, stored canonically in kg).
// Bounds match VALUE_RANGES in src/lib/validations/measurement.ts.
TOTAL_BODY_WATER: { min: 5, max: 100, unit: "kg" },
BONE_MASS: { min: 0.5, max: 8, unit: "kg" },
// Pulse oximetry (Withings ScanWatch reports type 54). Plausibility floor
// 50% to allow truly critical readings to be logged; saturation cannot
// exceed 100% by physical definition.
OXYGEN_SATURATION: { min: 50, max: 100, unit: "%" },
};
/**
@@ -163,8 +174,11 @@ function defaultRange(
// AASM: 7–9h for adults; warning yellow either side.
return { greenMin: 7, greenMax: 9, orangeMin: 6, orangeMax: 10 };
case "ACTIVITY_STEPS":
// WHO: ≥8000 steps/day; no upper bound in reality, but orange over 25k
// for edge detection.
// ≥8000 steps/day per Saint-Maurice et al., JAMA 2020 (mortality
// plateau 8000–12000 steps). WHO 2020 PA guidelines publish minutes
// per week (150–300 min moderate / 75–150 min vigorous) — *not* a
// step quota. No upper bound in reality, orange over 25k caps edge
// detection.
return { greenMin: 8000, greenMax: 15000, orangeMin: 5000, orangeMax: 25000 };
case "BLOOD_GLUCOSE_FASTING":
return glucoseRange(GLUCOSE_DEFAULTS.FASTING, 125); // pre-diabetes upper bound
@@ -177,6 +191,33 @@ function defaultRange(
GLUCOSE_DEFAULTS.BEDTIME,
GLUCOSE_DEFAULTS.BEDTIME.max + 30,
);
case "TOTAL_BODY_WATER":
// Adult total body water typically ~50% of body weight in kg
// (Watson formula / ICRP Reference Man: ~42 L male, ~30 L female).
// Without per-user weight context the gender-neutral band is wide
// by design; users tighten via threshold override.
return { greenMin: 28, greenMax: 50, orangeMin: 22, orangeMax: 55 };
case "BONE_MASS":
// Bioimpedance-estimated bone mass from a Withings-class scale —
// NOT DEXA-comparable (BIA-derived values typically run 5–7% below
// DEXA bone-mineral-content). Adult BIA-typical 2.0–4.0 kg (women
// slightly lower than men). Wide orange band before surfacing as a
// concern given BIA's intrinsic noise.
return { greenMin: 2.0, greenMax: 4.0, orangeMin: 1.5, orangeMax: 5.0 };
case "OXYGEN_SATURATION":
// Conservative band aligned with consumer pulse-oximeter consensus
// (≥95% normal at rest); slightly tighter than the BTS Emergency
// Oxygen Guideline 2017 explicit treatment target of 94–98%, looser
// than the WHO ≥90% acute-care floor. Lower-only concern: ≤92% is
// the NICE NG115 escalation threshold ("call provider"); ≤88%
// (BMJ panel / FDA pulse-oximeter labelling) is the ER threshold.
// Saturation is bounded above by 100% physically, so we collapse
// the upper orange wing onto greenMax — severity logic only fires
// for hypoxemia.
// COPD / chronic-respiratory-failure / high-altitude users
// typically run 88–92% and should personalise via the
// threshold-override UI (saved in User.thresholdsJson).
return { greenMin: 95, greenMax: 100, orangeMin: 92, orangeMax: 100 };
}
}
@@ -221,7 +262,10 @@ export function getEffectiveRange(
return { range: fallback, isOverride: false, default: fallback, bounds };
}
// User override replaces the green band. Orange wings stretch 10% below/above.
// User override replaces the green band. Orange wings stretch 15% below/above,
// clamped to the metric's physiological bounds — without the clamp, an
// override like SpO2 {95,100} would emit orangeMax = 100.75 (impossible
// saturation) and BODY_FAT {2,80} would emit orangeMin = -9.7.
const span = Math.max(1, override.max - override.min);
const orangeWidth = span * 0.15;
@@ -229,8 +273,8 @@ export function getEffectiveRange(
range: {
greenMin: override.min,
greenMax: override.max,
orangeMin: override.min - orangeWidth,
orangeMax: override.max + orangeWidth,
orangeMin: Math.max(bounds.min, override.min - orangeWidth),
orangeMax: Math.min(bounds.max, override.max + orangeWidth),
},
isOverride: true,
default: fallback,
+199 -26
View File
@@ -1,8 +1,13 @@
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import type { User } from "@/generated/prisma/client";
import { WideEventBuilder } from "./logging/event-builder";
import { eventStorage, getEvent } from "./logging/context";
import { emitIfSampled } from "./logging/transports";
import { getSession } from "./auth/session";
import { hashToken } from "./auth/hmac";
import { prisma } from "./db";
import { auditLog } from "./auth/audit";
/**
* Custom error class for HTTP errors with status codes.
@@ -98,17 +103,187 @@ export function apiHandler<T extends (...args: any[]) => Promise<Response>>(
}
/**
* Require an authenticated session. Throws HttpError(401) if not authenticated.
* Authenticated request context. Returned by both session-cookie and Bearer-token
* authentication paths. The `session.id` is the session-record id for cookie auth
* and the `ApiToken` id for bearer auth — callers must not assume the id refers
* to a Session row.
*/
export type AuthContext = {
session: { id: string; expiresAt: Date };
user: User;
};
/**
* Require an authenticated request. Throws HttpError(401) / HttpError(403) on failure.
*
* Auth precedence (cookie-first, never both):
* 1. Valid session cookie → cookie path (existing behaviour, requiredPermission ignored).
* 2. No cookie + `Authorization: Bearer hlk_<...>` → API token path.
* 3. Neither → 401.
*
* @param requiredPermission Optional permission scope. Only enforced for Bearer
* auth — cookie sessions always pass (full user access). When set and missing
* from `ApiToken.permissions`, throws HttpError(403).
*/
export async function requireAuth(
requiredPermission?: string,
): Promise<AuthContext> {
// 1. Session cookie path — unchanged.
const sessionData = await getSession();
if (sessionData) {
const evt = getEvent();
if (evt) {
evt.setAuth({
user_id: sessionData.user.id,
user_role: sessionData.user.role,
auth_method: "session",
});
}
return sessionData;
}
// 2. Bearer-token path.
// `headers()` is only valid inside a Next.js request scope. Outside one
// (e.g. during direct unit tests of legacy routes that pre-date Bearer auth)
// we treat the absence of a header context as "no Bearer present" and fall
// through to the unauthenticated case below.
let authHeader: string | null = null;
try {
const headerList = await headers();
authHeader = headerList.get("authorization");
} catch {
authHeader = null;
}
if (authHeader?.startsWith("Bearer ")) {
return await authenticateBearer(authHeader.slice(7), requiredPermission);
}
// 3. No credentials.
throw new HttpError(401, "Not authenticated");
}
/**
* Authenticate a raw Bearer token against `ApiToken`.
* Annotates the Wide Event with `auth_method: "api_key"` and writes audit-log
* entries for both success and failure.
*/
async function authenticateBearer(
rawToken: string,
requiredPermission: string | undefined,
): Promise<AuthContext> {
const tokenHashValue = hashToken(rawToken);
const apiToken = await prisma.apiToken.findUnique({
where: { tokenHash: tokenHashValue },
select: {
id: true,
userId: true,
permissions: true,
revoked: true,
expiresAt: true,
},
});
if (!apiToken) {
auditLog("auth.bearer.failure", {
details: { reason: "unknown_token" },
}).catch(() => {});
throw new HttpError(401, "Invalid token");
}
if (apiToken.revoked) {
auditLog("auth.bearer.failure", {
userId: apiToken.userId,
details: { reason: "revoked", tokenId: apiToken.id },
}).catch(() => {});
throw new HttpError(401, "Invalid token");
}
if (apiToken.expiresAt && apiToken.expiresAt <= new Date()) {
auditLog("auth.bearer.failure", {
userId: apiToken.userId,
details: { reason: "expired", tokenId: apiToken.id },
}).catch(() => {});
throw new HttpError(401, "Token expired");
}
// Audit V3 NEW-V3-1 fix: `["*"]` is a real wildcard — it grants the
// session-equivalent scope (the iOS app receives this on login). Without
// the wildcard branch, EVERY future requireAuth("scope:name") call would
// 403 every iOS-issued token because string-literal `.includes("*"...)`
// never matches. Worse: 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"), the wildcard handling here keeps
// the iOS app working while narrower-scoped tokens (e.g. ["medication:
// ingest"]) get correctly 403'd.
if (
requiredPermission &&
!apiToken.permissions.includes("*") &&
!apiToken.permissions.includes(requiredPermission)
) {
auditLog("auth.bearer.failure", {
userId: apiToken.userId,
details: {
reason: "insufficient_permissions",
tokenId: apiToken.id,
required: requiredPermission,
},
}).catch(() => {});
throw new HttpError(403, "Insufficient permissions");
}
const user = await prisma.user.findUnique({
where: { id: apiToken.userId },
});
if (!user) {
auditLog("auth.bearer.failure", {
userId: apiToken.userId,
details: { reason: "user_missing", tokenId: apiToken.id },
}).catch(() => {});
throw new HttpError(401, "Invalid token");
}
// Fire-and-forget: refresh lastUsedAt without blocking the request.
prisma.apiToken
.update({
where: { id: apiToken.id },
data: { lastUsedAt: new Date() },
})
.catch(() => {});
auditLog("auth.bearer.success", {
userId: user.id,
details: { tokenId: apiToken.id },
}).catch(() => {});
const evt = getEvent();
if (evt) {
evt.setAuth({
user_id: user.id,
user_role: user.role,
auth_method: "bearer",
});
}
// Use the token expiry as the session expiry; fall back to a 30-day window if
// the token has no fixed expiry so the contract `{ expiresAt: Date }` holds.
const expiresAt =
apiToken.expiresAt ?? new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
return {
session: { id: apiToken.id, expiresAt },
user,
};
}
/**
* Require an authenticated admin user. Throws HttpError(401) or HttpError(403).
* Cookie-only — Bearer tokens never elevate to admin (security boundary).
* Automatically annotates the Wide Event with auth context.
*/
export async function requireAuth(): Promise<{
session: { id: string; expiresAt: Date };
user: Awaited<ReturnType<typeof getSession>> extends infer R
? R extends { user: infer U }
? U
: never
: never;
}> {
export async function requireAdmin(): Promise<AuthContext> {
const sessionData = await getSession();
if (!sessionData) throw new HttpError(401, "Not authenticated");
@@ -121,22 +296,6 @@ export async function requireAuth(): Promise<{
});
}
return sessionData;
}
/**
* Require an authenticated admin user. Throws HttpError(401) or HttpError(403).
* Automatically annotates the Wide Event with auth context.
*/
export async function requireAdmin(): Promise<{
session: { id: string; expiresAt: Date };
user: Awaited<ReturnType<typeof getSession>> extends infer R
? R extends { user: infer U }
? U
: never
: never;
}> {
const sessionData = await requireAuth();
if (sessionData.user.role !== "ADMIN") {
throw new HttpError(403, "Admin access required");
}
@@ -168,6 +327,20 @@ async function reportToGlitchtip(
// Skip expected errors from bot scanners (malformed JSON bodies)
if (err instanceof SyntaxError) return;
// Audit H-B7 / phase P2: strip the query string before forwarding to
// GlitchTip. Withings legacy callbacks ship `?secret=…` (see C-3) and
// OAuth callbacks ship `?code=…&state=…`; if any of those error we
// don't want their secrets in someone's incident UI.
let scrubbedUrl = request.url;
try {
const u = new URL(request.url);
u.search = "";
scrubbedUrl = u.toString();
} catch {
// Invalid URL — fall through with the raw value (only happens in
// degenerate test fixtures).
}
await sendGlitchtipEvent({
dsn: settings.glitchtipDsn,
input: {
@@ -176,7 +349,7 @@ async function reportToGlitchtip(
level: "error",
type: err.name || "Error",
stack: err.stack,
url: request.url,
url: scrubbedUrl,
sourceTag: "healthlog-api-handler",
requestId: evt.getRequestId(),
},
+57 -3
View File
@@ -27,8 +27,62 @@ export async function safeJson<T = unknown>(
}
}
/**
* Resolve the real client IP from a request, respecting trusted-proxy
* configuration. V3 audit: previously took the leftmost XFF entry blindly,
* letting a client rotate `X-Forwarded-For: 1.2.3.4` per request to defeat
* IP-based rate-limits.
*
* Trust model (`TRUST_PROXY_HOPS` env):
* - "0" → ignore XFF entirely, fall back to x-real-ip / null
* (use this if HealthLog is internet-facing without a
* reverse proxy you control)
* - "1" (default)→ trust exactly one hop (typical Coolify / Caddy /
* Cloudflare-Tunnel single-proxy deployment); read the
* rightmost XFF entry which is the IP your proxy
* observed when the request arrived.
* - "N" (>1) → trust N hops; read the Nth-from-rightmost XFF entry.
*
* Returns the resolved IP or null when no trusted source is available.
*/
function looksLikeIp(s: string): boolean {
return /^[0-9a-fA-F.:]+$/.test(s) && s.length >= 3 && s.length <= 45;
}
function parseTrustProxyHops(raw: string | undefined): number {
if (raw === undefined) return 1;
const trimmed = raw.trim();
if (!/^\d+$/.test(trimmed)) {
// Reject explicitly-invalid values so an operator typo doesn't silently
// switch a real-proxy deployment to "no XFF trust" mode (review
// finding L-1: TRUST_PROXY_HOPS=garbage degraded to hops=0 silently).
throw new Error(
`TRUST_PROXY_HOPS must be a non-negative integer, got: ${JSON.stringify(raw)}`,
);
}
return parseInt(trimmed, 10);
}
export function getClientIp(request: Request): string | null {
const forwarded = request.headers.get("x-forwarded-for");
if (forwarded) return forwarded.split(",")[0].trim();
return request.headers.get("x-real-ip");
const hops = parseTrustProxyHops(process.env.TRUST_PROXY_HOPS);
if (hops > 0) {
const forwarded = request.headers.get("x-forwarded-for");
if (forwarded) {
const chain = forwarded
.split(",")
.map((s) => s.trim())
.filter(looksLikeIp);
// Review finding M-3: when the chain is shorter than the configured
// hops count, the operator misconfigured TRUST_PROXY_HOPS or a
// proxy was bypassed. Falling back to the leftmost (now
// attacker-controlled) entry would re-introduce the very rotation
// attack TRUST_PROXY_HOPS was meant to close. Refuse to read XFF.
if (chain.length >= hops) {
return chain[chain.length - hops];
}
}
}
const realIp = request.headers.get("x-real-ip");
return realIp && looksLikeIp(realIp) ? realIp : null;
}
+75
View File
@@ -0,0 +1,75 @@
/**
* Helper for issuing an `ApiToken` for a user — currently used by the
* native-client auto-login flow (login + passkey login-verify) so the iOS
* app can talk to bearer-protected routes without juggling the session
* cookie. The raw `hlk_<hex>` value is returned exactly once.
*/
import { randomBytes } from "node:crypto";
import { prisma } from "@/lib/db";
import { hashToken } from "@/lib/auth/hmac";
export interface IssuedToken {
token: string;
expiresAt: Date;
tokenId: string;
name: string;
}
export interface IssueTokenOptions {
userId: string;
/** Token row name (also surfaced to the user under /api/tokens). */
name: string;
/** Default permissions. `["*"]` = unrestricted (matches cookie scope). */
permissions?: string[];
/** Lifetime in days. */
expiresInDays?: number;
}
/**
* Generate a new `hlk_<64 hex>` token, store its hash, and return the raw
* value to the caller. The plaintext token is never persisted and must
* never be logged.
*/
export async function issueApiToken(
opts: IssueTokenOptions,
): Promise<IssuedToken> {
const rawToken = `hlk_${randomBytes(32).toString("hex")}`;
const tokenHashValue = hashToken(rawToken);
const expiresAt = new Date(
Date.now() + (opts.expiresInDays ?? 90) * 24 * 60 * 60 * 1000,
);
const created = await prisma.apiToken.create({
data: {
userId: opts.userId,
name: opts.name,
tokenHash: tokenHashValue,
permissions: opts.permissions ?? ["*"],
expiresAt,
},
select: { id: true },
});
return {
token: rawToken,
expiresAt,
tokenId: created.id,
name: opts.name,
};
}
/**
* Detect whether the request is from a native client and should receive a
* bearer token in the login response. Two opt-in signals:
* - `X-Client-Type: native`
* - `User-Agent: HealthLog-iOS/...`
*/
export function isNativeClientRequest(headers: Headers): boolean {
const xClient = headers.get("x-client-type");
if (xClient && xClient.toLowerCase() === "native") return true;
const ua = headers.get("user-agent") ?? "";
if (ua.startsWith("HealthLog-iOS")) return true;
return false;
}
+114
View File
@@ -0,0 +1,114 @@
/**
* Shared profile-update logic. The web client hits `/api/auth/profile` PUT
* and the iOS client hits `/api/user/profile` PATCH — both funnel through
* `applyProfileUpdate` so we don't fork the validation/audit path.
*/
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { profileSchema } from "@/lib/validations/auth";
import { z } from "zod/v4";
const extendedProfileSchema = profileSchema.extend({
displayName: z.string().min(1).max(80).nullable().optional(),
locale: z.enum(["de", "en"]).nullable().optional(),
timezone: z.string().min(1).max(64).optional(),
});
export type ApplyProfileInput = z.infer<typeof extendedProfileSchema>;
export interface ApplyProfileResult {
ok: true;
user: {
id: string;
username: string;
displayName: string | null;
email: string | null;
role: string;
heightCm: number | null;
dateOfBirth: Date | null;
gender: string | null;
timezone: string;
locale: string | null;
};
}
export interface ApplyProfileError {
ok: false;
status: number;
message: string;
}
/**
* Validate and persist profile updates for `userId`. Returns either an
* `ApplyProfileResult` on success or `ApplyProfileError` with a status
* code so callers can shape the wire response themselves.
*/
export async function applyProfileUpdate(
userId: string,
body: unknown,
ipAddress?: string | null,
): Promise<ApplyProfileResult | ApplyProfileError> {
const parsed = extendedProfileSchema.safeParse(body);
if (!parsed.success) {
return {
ok: false,
status: 422,
message: parsed.error.issues[0]?.message ?? "Validation error",
};
}
const data = parsed.data;
const normalizedEmail = data.email ? data.email.trim().toLowerCase() : null;
if (data.email !== undefined && normalizedEmail) {
const existing = await prisma.user.findUnique({
where: { email: normalizedEmail },
select: { id: true },
});
if (existing && existing.id !== userId) {
return { ok: false, status: 409, message: "Email already in use" };
}
}
const updates: Record<string, unknown> = {};
if (data.email !== undefined) updates.email = normalizedEmail;
if (data.heightCm !== undefined) updates.heightCm = data.heightCm ?? null;
if (data.dateOfBirth !== undefined) {
updates.dateOfBirth = data.dateOfBirth ? new Date(data.dateOfBirth) : null;
}
if (data.gender !== undefined) updates.gender = data.gender;
if (data.displayName !== undefined) {
updates.displayName =
data.displayName === null || data.displayName === ""
? null
: data.displayName.trim();
}
if (data.locale !== undefined) updates.locale = data.locale;
if (data.timezone !== undefined) updates.timezone = data.timezone;
const updatedUser = await prisma.user.update({
where: { id: userId },
data: updates,
});
await auditLog("profile.update", {
userId: updatedUser.id,
ipAddress: ipAddress ?? null,
});
return {
ok: true,
user: {
id: updatedUser.id,
username: updatedUser.username,
displayName: updatedUser.displayName,
email: updatedUser.email,
role: updatedUser.role,
heightCm: updatedUser.heightCm,
dateOfBirth: updatedUser.dateOfBirth,
gender: updatedUser.gender,
timezone: updatedUser.timezone,
locale: updatedUser.locale,
},
};
}
+3 -1
View File
@@ -17,7 +17,8 @@ export type DashboardWidgetId =
| "glucose"
| "totalBodyWater"
| "boneMass"
| "bpInTarget";
| "bpInTarget"
| "oxygenSaturation";
export interface DashboardWidgetConfig {
id: DashboardWidgetId;
@@ -52,6 +53,7 @@ export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayout = {
{ id: "glucose", visible: false, order: 9 },
{ id: "totalBodyWater", visible: false, order: 10 },
{ id: "boneMass", visible: false, order: 11 },
{ id: "oxygenSaturation", visible: false, order: 12 },
],
};
+269
View File
@@ -0,0 +1,269 @@
/**
* Server-side aggregator for doctor-report data.
*
* Single source of truth for the aggregated payload consumed by both
* `/api/doctor-report` (JSON, client renders PDF) and
* `/api/doctor-report/pdf` (server-rendered PDF). Keeps the two endpoints
* structurally identical so visual parity between client- and server-rendered
* PDFs is guaranteed by construction, not by drift-prone copy-paste.
*/
import { prisma } from "@/lib/db";
import {
getEffectiveRange,
type ThresholdOverridesJson,
} from "@/lib/analytics/effective-range";
import {
resolveGlucoseUnit,
thresholdMetricForContext,
type GlucoseUnit,
} from "@/lib/glucose";
import type { GlucoseContext } from "@/generated/prisma/client";
export interface DoctorReportStats {
avg: number;
min: number;
max: number;
count: number;
latest: number;
}
export interface DoctorReportCompliance {
total: number;
taken: number;
skipped: number;
missed: number;
}
export interface DoctorReportMood {
avg: number;
min: number;
max: number;
count: number;
distribution: Record<number, number>;
}
export interface DoctorReportData {
period: { days: number; since: string };
patient: {
username: string | null;
dateOfBirth: string | null;
gender: string | null;
heightCm: number | null;
};
measurements: Record<string, Array<{ value: number; measuredAt: string }>>;
stats: Record<string, DoctorReportStats>;
glucoseStats: Record<string, DoctorReportStats>;
glucoseRanges: Record<string, { min: number; max: number }>;
glucoseUnit: GlucoseUnit;
bmi: number | null;
compliance: Record<string, DoctorReportCompliance>;
medications: Array<{
name: string;
dose: string;
schedules: Array<{
windowStart: string;
windowEnd: string;
label: string | null;
}>;
}>;
mood: DoctorReportMood | null;
}
const GLUCOSE_CONTEXTS: GlucoseContext[] = [
"FASTING",
"POSTPRANDIAL",
"RANDOM",
"BEDTIME",
];
/**
* Validate and normalise the requested reporting window.
* Accepts an unknown value (typically `body.days`); falls back to 90.
*/
export function normaliseDays(value: unknown, fallback = 90): number {
if (
typeof value === "number" &&
Number.isInteger(value) &&
value >= 1 &&
value <= 365
) {
return value;
}
return fallback;
}
/**
* Aggregate the doctor-report payload for a user over the last `days` days.
* Pure data assembly — no auth, no rate-limit, no audit. Idempotent.
*/
export async function collectDoctorReportData(
userId: string,
days: number,
): Promise<DoctorReportData> {
const since = new Date();
since.setDate(since.getDate() - days);
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(),
});
}
// Per-type stats.
const stats: Record<string, DoctorReportStats> = {};
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 compliance: Record<string, DoctorReportCompliance> = {};
for (const event of intakeEvents) {
const name = event.medication.name;
if (!compliance[name]) {
compliance[name] = { total: 0, taken: 0, skipped: 0, missed: 0 };
}
compliance[name].total++;
if (event.takenAt) {
compliance[name].taken++;
} else if (event.skipped) {
compliance[name].skipped++;
} else {
compliance[name].missed++;
}
}
// Mood summary.
const moodScores = moodEntries.map((e) => e.score);
const mood: DoctorReportMood | null =
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 from latest weight + profile height.
const weightStats = stats.WEIGHT;
const bmiRaw =
weightStats && userProfile?.heightCm
? weightStats.latest / (userProfile.heightCm / 100) ** 2
: null;
const bmi = bmiRaw !== null ? Math.round(bmiRaw * 10) / 10 : null;
// Per-context glucose stats + effective ranges (canonical mg/dL).
const glucoseStats: Record<string, DoctorReportStats> = {};
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 GLUCOSE_CONTEXTS) {
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 {
period: { days, since: since.toISOString() },
patient: {
username: userProfile?.username ?? null,
dateOfBirth: userProfile?.dateOfBirth
? userProfile.dateOfBirth.toISOString()
: null,
gender: userProfile?.gender ?? null,
heightCm: userProfile?.heightCm ?? null,
},
measurements: byType,
stats,
glucoseStats,
glucoseRanges,
glucoseUnit: resolveGlucoseUnit(userProfile?.glucoseUnit ?? null),
bmi,
compliance,
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,
};
}
+529
View File
@@ -0,0 +1,529 @@
/**
* Isomorphic PDF renderer for the doctor report.
*
* Runs identically in the browser (settings page download) and in Node
* (server-rendered `/api/doctor-report/pdf` endpoint). All locale-sensitive
* strings and number/date formatting are driven by the injected
* `{ t, locale }` so DE and EN output match the user's UI language.
*
* jsPDF is fully isomorphic: `doc.output("arraybuffer")` returns a valid
* `%PDF-` byte stream in both environments.
*/
import { jsPDF } from "jspdf";
import autoTable from "jspdf-autotable";
import { makeFormatters } from "./format-locale";
import type { Locale } from "./i18n/config";
import { convertGlucose, resolveGlucoseUnit } from "./glucose";
import type { DoctorReportData } from "./doctor-report-data";
type T = (key: string, params?: Record<string, string | number>) => string;
export interface DoctorReportRenderOptions {
t: T;
locale: Locale;
/**
* Optional fixed timestamp for "createdOn"/footer. Useful for deterministic
* tests; defaults to `new Date()`.
*/
now?: Date;
}
/**
* Per-vital label keys. Exported for coverage tests (issue #109 / phase P0)
* so a future enum addition is caught by a unit test rather than reaching
* production as a raw enum string in the PDF.
*/
export const DOCTOR_REPORT_TYPE_LABEL_KEYS: Record<string, string> = {
WEIGHT: "doctorReport.typeWeight",
BLOOD_PRESSURE_SYS: "doctorReport.typeBpSys",
BLOOD_PRESSURE_DIA: "doctorReport.typeBpDia",
PULSE: "doctorReport.typePulse",
BODY_FAT: "doctorReport.typeBodyFat",
SLEEP_DURATION: "doctorReport.typeSleep",
ACTIVITY_STEPS: "doctorReport.typeSteps",
TOTAL_BODY_WATER: "doctorReport.typeTotalBodyWater",
BONE_MASS: "doctorReport.typeBoneMass",
OXYGEN_SATURATION: "doctorReport.typeOxygenSaturation",
};
export const DOCTOR_REPORT_TYPE_UNIT_KEYS: Record<string, string | null> = {
WEIGHT: "kg",
BLOOD_PRESSURE_SYS: "mmHg",
BLOOD_PRESSURE_DIA: "mmHg",
PULSE: "bpm",
BODY_FAT: "%",
SLEEP_DURATION: "h",
ACTIVITY_STEPS: null, // translated unit
TOTAL_BODY_WATER: "kg",
BONE_MASS: "kg",
OXYGEN_SATURATION: "%",
};
/**
* Vital types rendered in the main vitals table. Body composition
* (TOTAL_BODY_WATER, BONE_MASS) ships alongside body fat — Withings
* smart scales report all three together. SpO2 (Withings ScanWatch type
* 54, HealthKit, n8n / Health Connect) is rendered last in the same
* table for clinical readability. Glucose ships separately via
* per-context `glucoseStats`. Sleep + activity are intentionally
* excluded from a clinical-focused report.
*/
export const DOCTOR_REPORT_VITAL_TYPES = [
"WEIGHT",
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",
"PULSE",
"BODY_FAT",
"TOTAL_BODY_WATER",
"BONE_MASS",
"OXYGEN_SATURATION",
] as const;
const MOOD_LABEL_KEYS: Record<number, string> = {
1: "doctorReport.moodAwful",
2: "doctorReport.moodBad",
3: "doctorReport.moodNeutral",
4: "doctorReport.moodGood",
5: "doctorReport.moodGreat",
};
const GLUCOSE_LABEL_KEYS = {
FASTING: "doctorReport.typeGlucoseFasting",
POSTPRANDIAL: "doctorReport.typeGlucosePostprandial",
RANDOM: "doctorReport.typeGlucoseRandom",
BEDTIME: "doctorReport.typeGlucoseBedtime",
} as const;
const DEFAULT_GLUCOSE_RANGES = {
FASTING: { min: 70, max: 99 },
POSTPRANDIAL: { min: 70, max: 140 },
RANDOM: { min: 70, max: 140 },
BEDTIME: { min: 90, max: 150 },
} as const;
const GLUCOSE_CONTEXTS: Array<keyof typeof GLUCOSE_LABEL_KEYS> = [
"FASTING",
"POSTPRANDIAL",
"RANDOM",
"BEDTIME",
];
function getBmiClassificationKey(bmi: number): string {
if (bmi < 18.5) return "doctorReport.bmiUnderweight";
if (bmi < 25) return "doctorReport.bmiNormal";
if (bmi < 30) return "doctorReport.bmiOverweight";
if (bmi < 35) return "doctorReport.bmiObeseGrade1";
if (bmi < 40) return "doctorReport.bmiObeseGrade2";
return "doctorReport.bmiObeseGrade3";
}
function getBpClassificationKey(sys: number, dia: number): string {
if (sys < 120 && dia < 80) return "doctorReport.bpOptimal";
if (sys < 130 && dia < 85) return "doctorReport.bpNormal";
if (sys < 140 && dia < 90) return "doctorReport.bpHighNormal";
if (sys < 160 && dia < 100) return "doctorReport.bpHypertensionGrade1";
if (sys < 180 && dia < 110) return "doctorReport.bpHypertensionGrade2";
return "doctorReport.bpHypertensionGrade3";
}
/**
* Render the doctor report into a `jsPDF` instance.
*
* Used internally by both the client wrapper (which calls `.save()` on the
* returned doc) and the server renderer (which calls `.output("arraybuffer")`
* via `renderDoctorReportPdfBytes`).
*/
export function buildDoctorReportPdfDocument(
data: DoctorReportData,
options: DoctorReportRenderOptions,
): jsPDF {
const { t, locale, now = new Date() } = options;
const formatters = makeFormatters(locale);
const num = (value: number, decimals = 1) =>
formatters.number(value, decimals);
const fmtDate = (iso: string) => formatters.date(iso);
const unitFor = (type: string): string => {
const staticUnit = DOCTOR_REPORT_TYPE_UNIT_KEYS[type];
if (staticUnit === null && type === "ACTIVITY_STEPS") {
return t("doctorReport.unitSteps");
}
return staticUnit ?? "";
};
const doc = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4" });
const pageWidth = doc.internal.pageSize.getWidth();
const margin = 20;
let y = margin;
doc.setFontSize(22);
doc.setFont("helvetica", "bold");
doc.setTextColor(30, 30, 30);
doc.text(t("doctorReport.title"), margin, y);
y += 8;
doc.setFontSize(10);
doc.setFont("helvetica", "normal");
doc.setTextColor(100, 100, 100);
doc.text(t("doctorReport.subtitle"), margin, y);
y += 6;
doc.setDrawColor(200, 200, 200);
doc.setLineWidth(0.5);
doc.line(margin, y, pageWidth - margin, y);
y += 8;
doc.setFontSize(9);
doc.setTextColor(80, 80, 80);
const patientInfo: string[] = [];
if (data.patient.username) {
patientInfo.push(`${t("doctorReport.patient")}: ${data.patient.username}`);
}
if (data.patient.dateOfBirth) {
patientInfo.push(
`${t("doctorReport.dateOfBirth")}: ${fmtDate(data.patient.dateOfBirth)}`,
);
}
if (data.patient.gender) {
const genderKeys: Record<string, string> = {
MALE: "doctorReport.genderMale",
FEMALE: "doctorReport.genderFemale",
};
const genderKey =
genderKeys[data.patient.gender] ?? "doctorReport.genderOther";
patientInfo.push(`${t("doctorReport.gender")}: ${t(genderKey)}`);
}
if (data.patient.heightCm) {
patientInfo.push(
`${t("doctorReport.height")}: ${data.patient.heightCm} cm`,
);
}
patientInfo.push(
`${t("doctorReport.period")}: ${fmtDate(data.period.since)} — ${fmtDate(now.toISOString())}`,
);
patientInfo.push(
`${t("doctorReport.createdOn")}: ${fmtDate(now.toISOString())}`,
);
for (const line of patientInfo) {
doc.text(line, margin, y);
y += 4.5;
}
y += 4;
doc.setFontSize(14);
doc.setFont("helvetica", "bold");
doc.setTextColor(30, 30, 30);
doc.text(t("doctorReport.vitalsTitle"), margin, y);
y += 6;
const vitalRows: string[][] = [];
for (const type of DOCTOR_REPORT_VITAL_TYPES) {
const s = data.stats[type];
if (!s) continue;
const unit = unitFor(type);
vitalRows.push([
t(DOCTOR_REPORT_TYPE_LABEL_KEYS[type] ?? ""),
`${num(s.latest)} ${unit}`.trim(),
`${num(s.avg)} ${unit}`.trim(),
num(s.min),
num(s.max),
String(s.count),
]);
}
// Per-context glucose rows. Values stored canonically in mg/dL — convert to
// the user's display unit. Reference ranges come from the server-side
// `getEffectiveRange()` (already baked into `data.glucoseRanges`).
const glucoseUnit = resolveGlucoseUnit(data.glucoseUnit ?? null);
for (const ctx of GLUCOSE_CONTEXTS) {
const s = data.glucoseStats?.[ctx];
if (!s) continue;
const conv = (v: number) => convertGlucose(v, glucoseUnit);
vitalRows.push([
t(GLUCOSE_LABEL_KEYS[ctx]),
`${num(conv(s.latest))} ${glucoseUnit}`.trim(),
`${num(conv(s.avg))} ${glucoseUnit}`.trim(),
num(conv(s.min)),
num(conv(s.max)),
String(s.count),
]);
}
if (vitalRows.length > 0) {
autoTable(doc, {
startY: y,
head: [
[
t("doctorReport.colParameter"),
t("doctorReport.colCurrent"),
t("doctorReport.colAvgPeriod"),
t("doctorReport.colMin"),
t("doctorReport.colMax"),
t("doctorReport.colN"),
],
],
body: vitalRows,
theme: "grid",
styles: {
fontSize: 9,
cellPadding: 3,
textColor: [30, 30, 30],
lineColor: [200, 200, 200],
lineWidth: 0.3,
},
headStyles: {
fillColor: [245, 245, 245],
textColor: [30, 30, 30],
fontStyle: "bold",
},
alternateRowStyles: { fillColor: [252, 252, 252] },
margin: { left: margin, right: margin },
});
y =
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
.finalY + 8;
}
const sysStat = data.stats.BLOOD_PRESSURE_SYS;
const diaStat = data.stats.BLOOD_PRESSURE_DIA;
if (sysStat && diaStat) {
doc.setFontSize(12);
doc.setFont("helvetica", "bold");
doc.text(t("doctorReport.bpClassificationTitle"), margin, y);
y += 5;
doc.setFontSize(9);
doc.setFont("helvetica", "normal");
const bpClass = t(getBpClassificationKey(sysStat.avg, diaStat.avg));
doc.text(
`${t("doctorReport.avgBp")}: ${num(sysStat.avg, 0)}/${num(diaStat.avg, 0)} mmHg — ${t("doctorReport.classification")}: ${bpClass}`,
margin,
y,
);
y += 8;
}
if (data.bmi) {
doc.setFontSize(12);
doc.setFont("helvetica", "bold");
doc.text(t("doctorReport.bmiTitle"), margin, y);
y += 5;
doc.setFontSize(9);
doc.setFont("helvetica", "normal");
doc.text(
t("doctorReport.bmiRow", {
bmi: num(data.bmi, 1),
class: t(getBmiClassificationKey(data.bmi)),
}),
margin,
y,
);
y += 8;
}
// Glucose classification — one line per logged context.
const loggedGlucose = GLUCOSE_CONTEXTS.filter(
(ctx) => data.glucoseStats?.[ctx],
);
if (loggedGlucose.length > 0) {
if (y > 240) {
doc.addPage();
y = margin;
}
doc.setFontSize(12);
doc.setFont("helvetica", "bold");
doc.text(t("doctorReport.glucoseClassificationTitle"), margin, y);
y += 5;
doc.setFontSize(9);
doc.setFont("helvetica", "normal");
for (const ctx of loggedGlucose) {
const s = data.glucoseStats[ctx];
if (!s) continue;
const range = data.glucoseRanges?.[ctx] ?? DEFAULT_GLUCOSE_RANGES[ctx];
const conv = (v: number) => convertGlucose(v, glucoseUnit);
const inRange = s.avg >= range.min && s.avg <= range.max;
const classKey = inRange
? "doctorReport.glucoseInTarget"
: s.avg < range.min
? "doctorReport.glucoseBelowTarget"
: "doctorReport.glucoseAboveTarget";
doc.text(
t("doctorReport.glucoseRow", {
label: t(GLUCOSE_LABEL_KEYS[ctx]),
avg: num(conv(s.avg), 1),
unit: glucoseUnit,
rangeMin: num(conv(range.min)),
rangeMax: num(conv(range.max)),
class: t(classKey),
}),
margin,
y,
);
y += 5;
}
y += 3;
}
const complianceEntries = Object.entries(data.compliance);
if (complianceEntries.length > 0) {
if (y > 240) {
doc.addPage();
y = margin;
}
doc.setFontSize(14);
doc.setFont("helvetica", "bold");
doc.text(t("doctorReport.complianceTitle"), margin, y);
y += 6;
const compRows = complianceEntries.map(([name, c]) => {
const rate = c.total > 0 ? `${num((c.taken / c.total) * 100, 1)}%` : "—";
return [
name,
String(c.taken),
String(c.skipped),
String(c.missed),
String(c.total),
rate,
];
});
autoTable(doc, {
startY: y,
head: [
[
t("doctorReport.colMedication"),
t("doctorReport.colTaken"),
t("doctorReport.colSkipped"),
t("doctorReport.colMissed"),
t("doctorReport.colTotal"),
t("doctorReport.colComplianceRate"),
],
],
body: compRows,
theme: "grid",
styles: {
fontSize: 9,
cellPadding: 3,
textColor: [30, 30, 30],
lineColor: [200, 200, 200],
lineWidth: 0.3,
},
headStyles: {
fillColor: [245, 245, 245],
textColor: [30, 30, 30],
fontStyle: "bold",
},
alternateRowStyles: { fillColor: [252, 252, 252] },
margin: { left: margin, right: margin },
});
y =
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
.finalY + 8;
}
if (data.mood) {
if (y > 240) {
doc.addPage();
y = margin;
}
doc.setFontSize(14);
doc.setFont("helvetica", "bold");
doc.text(t("doctorReport.moodTitle"), margin, y);
y += 6;
doc.setFontSize(9);
doc.setFont("helvetica", "normal");
doc.text(
t("doctorReport.moodSummary", {
avg: num(data.mood.avg, 1),
count: data.mood.count,
min: num(data.mood.min, 0),
max: num(data.mood.max, 0),
}),
margin,
y,
);
y += 6;
const distRows = Object.entries(data.mood.distribution).map(
([score, count]) => [
t(MOOD_LABEL_KEYS[Number(score)] ?? "doctorReport.moodNeutral"),
String(count),
data.mood && data.mood.count > 0
? `${num((count / data.mood.count) * 100, 1)}%`
: "0%",
],
);
autoTable(doc, {
startY: y,
head: [
[
t("doctorReport.colMood"),
t("doctorReport.colCount"),
t("doctorReport.colShare"),
],
],
body: distRows,
theme: "grid",
styles: {
fontSize: 9,
cellPadding: 3,
textColor: [30, 30, 30],
lineColor: [200, 200, 200],
lineWidth: 0.3,
},
headStyles: {
fillColor: [245, 245, 245],
textColor: [30, 30, 30],
fontStyle: "bold",
},
margin: { left: margin, right: margin },
});
y =
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
.finalY + 8;
}
const addFooter = (pageDoc: jsPDF) => {
const pageHeight = pageDoc.internal.pageSize.getHeight();
pageDoc.setFontSize(7);
pageDoc.setFont("helvetica", "italic");
pageDoc.setTextColor(140, 140, 140);
pageDoc.text(t("doctorReport.footerDisclaimer1"), margin, pageHeight - 14);
pageDoc.text(t("doctorReport.footerDisclaimer2"), margin, pageHeight - 10);
pageDoc.text(
t("doctorReport.footerSource", {
timestamp: formatters.dateTime(now),
}),
margin,
pageHeight - 6,
);
};
const totalPages = doc.getNumberOfPages();
for (let i = 1; i <= totalPages; i++) {
doc.setPage(i);
addFooter(doc);
}
return doc;
}
/**
* Render the doctor report and return the PDF as a `Uint8Array`.
* Isomorphic — works in browser and Node.
*/
export function renderDoctorReportPdfBytes(
data: DoctorReportData,
options: DoctorReportRenderOptions,
): Uint8Array {
const doc = buildDoctorReportPdfDocument(data, options);
const ab = doc.output("arraybuffer");
return new Uint8Array(ab);
}
+3
View File
@@ -77,6 +77,7 @@ const TYPE_LABEL_KEYS: Record<string, string> = {
ACTIVITY_STEPS: "doctorReport.typeSteps",
TOTAL_BODY_WATER: "doctorReport.typeTotalBodyWater",
BONE_MASS: "doctorReport.typeBoneMass",
OXYGEN_SATURATION: "doctorReport.typeOxygenSaturation",
};
const TYPE_UNIT_KEYS: Record<string, string | null> = {
@@ -89,6 +90,7 @@ const TYPE_UNIT_KEYS: Record<string, string | null> = {
ACTIVITY_STEPS: null, // translated unit
TOTAL_BODY_WATER: "kg",
BONE_MASS: "kg",
OXYGEN_SATURATION: "%",
};
const MOOD_LABEL_KEYS: Record<number, string> = {
@@ -211,6 +213,7 @@ export function generateDoctorReportPDF(
"BODY_FAT",
"TOTAL_BODY_WATER",
"BONE_MASS",
"OXYGEN_SATURATION",
];
for (const type of vitalTypes) {
+2
View File
@@ -43,6 +43,7 @@ export function formatMeasurementsForExport(
measuredAt: Date;
source: string;
notes: string | null;
glucoseContext?: string | null;
}>,
): ExportableRecord[] {
return measurements.map((m) => ({
@@ -52,6 +53,7 @@ export function formatMeasurementsForExport(
measuredAt: m.measuredAt.toISOString(),
source: m.source,
notes: m.notes ?? "",
glucoseContext: m.glucoseContext ?? "",
}));
}
+52 -14
View File
@@ -1,36 +1,74 @@
/**
* IP geolocation lookup using ip-api.com (free, no API key needed).
* Returns "City, CC" string or null on failure.
* Only used for audit log enrichment — fire-and-forget, non-blocking.
* IP geolocation lookup for audit-log enrichment.
*
* Default provider is ipwho.is (HTTPS, free, no key). Both the response
* shape from ipwho.is and the fallback ip-api.com pro endpoint are
* accepted, so swapping providers via IP_GEO_LOOKUP_URL only requires
* matching one of those response shapes.
*
* Setting IP_GEO_LOOKUP_DISABLED=1 disables lookup entirely — used by
* deployments that do not want any IP egress to a third-party service
* (V3 audit: GDPR Art. 32 + Art. 44, plaintext HTTP IP egress).
*/
interface IpApiResponse {
status: "success" | "fail";
interface IpwhoIsResponse {
success?: boolean;
city?: string;
country_code?: string;
}
interface IpApiProResponse {
status?: "success" | "fail";
city?: string;
countryCode?: string;
}
type GeoResponse = IpwhoIsResponse & IpApiProResponse;
const PRIVATE_IP =
/^(127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|::1|fc|fd|fe80|localhost|unknown)/;
const DEFAULT_GEO_URL = "https://ipwho.is";
function buildLookupUrl(ip: string): string {
const base = (process.env.IP_GEO_LOOKUP_URL ?? DEFAULT_GEO_URL).replace(
/\/+$/,
"",
);
if (!base.startsWith("https://")) {
// V3 audit: never leak audit-event IPs over plaintext HTTP. Reject any
// configuration that would do so by upgrading to https; if the operator
// has explicitly opted into HTTP via env, we still refuse and return a
// dummy URL the parser will fail on.
return `https://invalid.invalid/refused-non-https/${encodeURIComponent(ip)}`;
}
return `${base}/${encodeURIComponent(ip)}`;
}
export async function lookupIpLocation(
ip: string | null,
): Promise<string | null> {
if (!ip || PRIVATE_IP.test(ip)) return null;
if (process.env.IP_GEO_LOOKUP_DISABLED === "1") return null;
try {
const res = await fetch(
`http://ip-api.com/json/${encodeURIComponent(ip)}?fields=status,city,countryCode`,
{ signal: AbortSignal.timeout(3000) },
);
const res = await fetch(buildLookupUrl(ip), {
signal: AbortSignal.timeout(3000),
});
if (!res.ok) return null;
const data = (await res.json()) as IpApiResponse;
if (data.status !== "success" || !data.city || !data.countryCode)
return null;
const data = (await res.json()) as GeoResponse;
const ok =
data.success === true ||
data.status === "success" ||
(data.city && (data.country_code ?? data.countryCode));
if (!ok) return null;
return `${data.city}, ${data.countryCode}`;
const city = data.city;
const country = data.country_code ?? data.countryCode;
if (!city || !country) return null;
return `${city}, ${country}`;
} catch {
return null;
}
+231
View File
@@ -0,0 +1,231 @@
/**
* Idempotency-Key support for write endpoints (POST/PUT/PATCH/DELETE).
*
* Mobile clients send `Idempotency-Key: <uuid>`. The first request runs
* the handler normally and the response (status + body) is cached. Any
* retry within the TTL (24h) for the same `(userId, key, method, path)`
* tuple returns the cached envelope as a 200 (carrying the original
* status inside the JSON if needed) — no second side-effect.
*/
import type { NextRequest } from "next/server";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { hashToken } from "@/lib/auth/hmac";
import { annotate } from "@/lib/logging/context";
const TTL_MS = 24 * 60 * 60 * 1000;
const SUPPORTED_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
const KEY_REGEX = /^[A-Za-z0-9_\-:.]{8,128}$/;
export interface IdempotencyContext {
userId: string;
key: string;
method: string;
path: string;
}
function getIdempotencyKey(request: Request | NextRequest): string | null {
const raw = request.headers.get("idempotency-key");
if (!raw) return null;
const trimmed = raw.trim();
if (!KEY_REGEX.test(trimmed)) return null;
return trimmed;
}
/**
* Look up a cached response for a (userId, key, method, path) tuple.
* Returns the cached NextResponse or null.
*/
async function findCached(
ctx: IdempotencyContext,
): Promise<NextResponse | null> {
const row = await prisma.idempotencyKey.findUnique({
where: {
userId_key_method_path: {
userId: ctx.userId,
key: ctx.key,
method: ctx.method,
path: ctx.path,
},
},
});
if (!row) return null;
if (row.expiresAt <= new Date()) {
// Stale — purge and fall through.
await prisma.idempotencyKey
.delete({ where: { id: row.id } })
.catch(() => {});
return null;
}
let parsed: unknown = null;
try {
parsed = JSON.parse(row.responseBody);
} catch {
parsed = null;
}
annotate({
action: { name: "idempotency.replay" },
meta: { method: ctx.method, path: ctx.path },
});
// Replay original status to keep client behaviour byte-identical.
return NextResponse.json(parsed, {
status: row.responseStatus,
headers: {
"X-Idempotent-Replay": "true",
},
});
}
async function persistCached(
ctx: IdempotencyContext,
response: Response,
preReadBody?: string,
): Promise<void> {
const body =
preReadBody ??
(await response
.clone()
.text()
.catch(() => ""));
await prisma.idempotencyKey
.create({
data: {
userId: ctx.userId,
key: ctx.key,
method: ctx.method,
path: ctx.path,
responseStatus: response.status,
responseBody: body,
expiresAt: new Date(Date.now() + TTL_MS),
},
})
.catch(() => {
// Concurrent insert with the same key — the other writer wins.
// Ignore because a future replay will hit their cached value.
});
}
/**
* Whether a response with the given HTTP status should be cached for
* idempotent replay.
*
* Cached: any 2xx/3xx, plus 4xx-validation (so the same broken request
* doesn't re-execute side-effects). Specifically NOT cached:
* 401 — token may have expired between the original call and the retry
* 403 — likewise authorization can change
* 408 — caller timed out, retry deserves a fresh attempt
* 429 — caller hit a rate-limit, retry deserves a fresh window check
* 5xx — server fault, retry must not be locked into a bogus result
*
* Exported so the do-not-cache contract is unit-tested independently of
* the database-backed wrapper.
*/
export function isCachableStatus(status: number): boolean {
if (status < 400) return true;
if (status >= 500) return false;
if (status === 401 || status === 403 || status === 408 || status === 429) {
return false;
}
return true;
}
/**
* Default resolver: cookie session first, then Bearer token. The Bearer
* fallback is what makes idempotency actually fire for native iOS / n8n
* /external clients — without it, every Bearer-authed retry was running
* the handler again and creating duplicate measurements (audit C-4).
*
* Exported for unit testing; production callers should let
* `withIdempotency()` pick this up automatically via its default arg.
*/
export async function defaultUserIdResolver(): Promise<string | null> {
const session = await getSession().catch(() => null);
if (session) return session.user.id;
let authHeader: string | null = null;
try {
const headerList = await headers();
authHeader = headerList.get("authorization");
} catch {
authHeader = null;
}
if (!authHeader?.startsWith("Bearer ")) return null;
const tokenHashValue = hashToken(authHeader.slice(7));
const apiToken = await prisma.apiToken
.findUnique({
where: { tokenHash: tokenHashValue },
select: { userId: true, revoked: true, expiresAt: true },
})
.catch(() => null);
if (!apiToken || apiToken.revoked) return null;
if (apiToken.expiresAt && apiToken.expiresAt <= new Date()) return null;
return apiToken.userId;
}
/**
* Wrap a write handler so a repeat call with the same `Idempotency-Key`
* (and same userId/method/path) returns the originally cached response.
*
* The wrapped handler is responsible for authentication itself — this
* helper only triggers for methods in {POST, PUT, PATCH, DELETE} and only
* once `userIdResolver` returns a non-null value. The default resolver
* supports both cookie sessions and Bearer-token clients; pass a custom
* resolver only for routes that authenticate via something exotic.
*
* No-op when the header is missing or the value is malformed.
*/
export function withIdempotency<
Args extends [Request | NextRequest, ...unknown[]],
>(
handler: (...args: Args) => Promise<Response>,
userIdResolver: (...args: Args) => Promise<string | null> = defaultUserIdResolver,
): (...args: Args) => Promise<Response> {
return async (...args: Args): Promise<Response> => {
const request = args[0];
if (!SUPPORTED_METHODS.has(request.method)) {
return handler(...args);
}
const key = getIdempotencyKey(request);
if (!key) return handler(...args);
const userId = await userIdResolver(...args);
if (!userId) return handler(...args);
const url = new URL(request.url);
const ctx: IdempotencyContext = {
userId,
key,
method: request.method,
path: url.pathname,
};
const cached = await findCached(ctx);
if (cached) return cached;
const response = await handler(...args);
if (isCachableStatus(response.status)) {
// Defence-in-depth: never persist a body that carries a freshly-issued
// bearer token. Auth routes shouldn't be wrapped in withIdempotency to
// begin with, but if a future caller forgets, we refuse to leak.
const cloned = response.clone();
const text = await cloned.text();
if (!text.includes("hlk_")) {
await persistCached(ctx, response, text);
}
}
return response;
};
}
+4 -9
View File
@@ -3,6 +3,7 @@ import { resolveProvider } from "@/lib/ai/provider";
import { getGeneralStatusSystemPrompt, getGeneralStatusUserPrompt } from "@/lib/ai/prompts/general-status";
import { getBpTargets } from "@/lib/analytics/bp-targets";
import { getNoKeyGeneralStatusText } from "@/lib/insights/no-key-fallbacks";
import { measurementTypeEnum } from "@/lib/validations/measurement";
const GENERAL_STATUS_POINTS = 30;
@@ -15,15 +16,9 @@ const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", {
type SupportedLocale = "de" | "en";
const MEASUREMENT_TYPES = [
"WEIGHT",
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",
"PULSE",
"BODY_FAT",
"SLEEP_DURATION",
"ACTIVITY_STEPS",
] as const;
// Derived from canonical enum so a new measurement type is auto-included
// in the AI general-status fetch (V3 audit: enum drift cousins).
const MEASUREMENT_TYPES = measurementTypeEnum.options;
function toBerlinDayKey(date: Date): string {
const parts = BERLIN_DAY_FORMATTER.formatToParts(date);
@@ -0,0 +1,80 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { PrismaClient } from "@/generated/prisma/client";
import {
cleanupOldAuditLogs,
getAuditLogRetentionDays,
DEFAULT_AUDIT_LOG_RETENTION_DAYS,
} from "../audit-log-cleanup";
function makePrismaMock(deletedCount: number) {
return {
auditLog: {
deleteMany: vi.fn().mockResolvedValue({ count: deletedCount }),
},
} as unknown as PrismaClient;
}
const ORIGINAL_ENV = { ...process.env };
beforeEach(() => {
process.env = { ...ORIGINAL_ENV };
delete process.env.AUDIT_LOG_RETENTION_DAYS;
});
afterEach(() => {
process.env = ORIGINAL_ENV;
vi.restoreAllMocks();
vi.useRealTimers();
});
describe("getAuditLogRetentionDays", () => {
it("returns 365 when env is unset", () => {
expect(getAuditLogRetentionDays()).toBe(DEFAULT_AUDIT_LOG_RETENTION_DAYS);
});
it("respects a valid override", () => {
process.env.AUDIT_LOG_RETENTION_DAYS = "180";
expect(getAuditLogRetentionDays()).toBe(180);
});
it("ignores nonsensical values (NaN, 0, negative)", () => {
process.env.AUDIT_LOG_RETENTION_DAYS = "0";
expect(getAuditLogRetentionDays()).toBe(DEFAULT_AUDIT_LOG_RETENTION_DAYS);
process.env.AUDIT_LOG_RETENTION_DAYS = "-100";
expect(getAuditLogRetentionDays()).toBe(DEFAULT_AUDIT_LOG_RETENTION_DAYS);
process.env.AUDIT_LOG_RETENTION_DAYS = "not a number";
expect(getAuditLogRetentionDays()).toBe(DEFAULT_AUDIT_LOG_RETENTION_DAYS);
});
it("rejects too-short retention (< 7 days) to guard misconfigs", () => {
process.env.AUDIT_LOG_RETENTION_DAYS = "3";
expect(getAuditLogRetentionDays()).toBe(DEFAULT_AUDIT_LOG_RETENTION_DAYS);
});
});
describe("cleanupOldAuditLogs", () => {
it("deletes audit log rows older than 365 days by default", async () => {
const prisma = makePrismaMock(42);
const now = new Date("2026-05-04T00:00:00Z");
const deleted = await cleanupOldAuditLogs(prisma, now);
expect(deleted).toBe(42);
expect(prisma.auditLog.deleteMany).toHaveBeenCalledTimes(1);
const cutoff = new Date(now.getTime() - 365 * 86_400_000);
expect(prisma.auditLog.deleteMany).toHaveBeenCalledWith({
where: { createdAt: { lt: cutoff } },
});
});
it("uses the configured retention", async () => {
process.env.AUDIT_LOG_RETENTION_DAYS = "90";
const prisma = makePrismaMock(7);
const now = new Date("2026-05-04T00:00:00Z");
await cleanupOldAuditLogs(prisma, now);
const cutoff = new Date(now.getTime() - 90 * 86_400_000);
expect(prisma.auditLog.deleteMany).toHaveBeenCalledWith({
where: { createdAt: { lt: cutoff } },
});
});
});
@@ -0,0 +1,47 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { PrismaClient } from "@/generated/prisma/client";
import { cleanupExpiredIdempotencyKeys } from "../idempotency-cleanup";
function makePrismaMock(deletedCount: number) {
return {
idempotencyKey: {
deleteMany: vi.fn().mockResolvedValue({ count: deletedCount }),
},
} as unknown as PrismaClient;
}
describe("cleanupExpiredIdempotencyKeys", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-04T03:00:00Z"));
});
it("deletes all rows whose expires_at is in the past and returns the count", async () => {
const prisma = makePrismaMock(100);
const deleted = await cleanupExpiredIdempotencyKeys(prisma);
expect(deleted).toBe(100);
expect(prisma.idempotencyKey.deleteMany).toHaveBeenCalledTimes(1);
expect(prisma.idempotencyKey.deleteMany).toHaveBeenCalledWith({
where: { expiresAt: { lt: new Date("2026-05-04T03:00:00Z") } },
});
});
it("returns 0 when no rows are expired", async () => {
const prisma = makePrismaMock(0);
const deleted = await cleanupExpiredIdempotencyKeys(prisma);
expect(deleted).toBe(0);
});
it("propagates errors from prisma so the handler can warn", async () => {
const prisma = {
idempotencyKey: {
deleteMany: vi.fn().mockRejectedValue(new Error("connection lost")),
},
} as unknown as PrismaClient;
await expect(cleanupExpiredIdempotencyKeys(prisma)).rejects.toThrow(
"connection lost",
);
});
});
+40
View File
@@ -0,0 +1,40 @@
/**
* Daily cleanup for the `audit_logs` table.
*
* V3 audit (GDPR Art. 5(1)(e) "storage limitation"): audit log accumulates
* IP + city + login events forever. Without retention, a self-hosted
* deployment is non-compliant with the principle that personal data must
* not be stored "longer than is necessary".
*
* Default retention is 365 days (configurable via AUDIT_LOG_RETENTION_DAYS
* env). Rows older than the cutoff are deleted in a single bulk
* `deleteMany`; runs daily via pg-boss.
*/
import type { PrismaClient } from "@/generated/prisma/client";
export const DEFAULT_AUDIT_LOG_RETENTION_DAYS = 365;
export function getAuditLogRetentionDays(): number {
const raw = process.env.AUDIT_LOG_RETENTION_DAYS;
if (raw === undefined) return DEFAULT_AUDIT_LOG_RETENTION_DAYS;
const parsed = parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
return DEFAULT_AUDIT_LOG_RETENTION_DAYS;
}
// Refuse very-short retention windows accidentally set to seconds — we
// don't want a misconfig nuking a fresh audit table.
if (parsed < 7) return DEFAULT_AUDIT_LOG_RETENTION_DAYS;
return parsed;
}
export async function cleanupOldAuditLogs(
prisma: PrismaClient,
now: Date = new Date(),
): Promise<number> {
const days = getAuditLogRetentionDays();
const cutoff = new Date(now.getTime() - days * 86_400_000);
const { count } = await prisma.auditLog.deleteMany({
where: { createdAt: { lt: cutoff } },
});
return count;
}
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff. Show more