Compare commits

..
141 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
Marc-André Bombeck c584ead9c8 chore(release): 1.3.2
Hotfix release closing #108 — the dashboard glucose tiles rendered the
raw i18n key, and the bug-report screenshot upload promised a feature
that never reached the published GitHub issue.
2026-04-28 19:46:29 +02:00
Marc-André Bombeck 492748f105 fix(bugreport): remove screenshot upload, drop misleading attached note
The bug-report form accepted an image attachment that was stored in the
local DB but never reached the published GitHub issue — GitHub does not
accept inline base64 data URIs in issue bodies and offers no public API
to attach images to an issue programmatically. The publisher only
appended a placeholder line ("**Screenshot:** A screenshot was attached
in the original report.") which was visibly misleading to anyone
reading the issue (#108).

- Drop the file input, preview, validation, and unused icons from
  src/app/bugreport/page.tsx.
- Remove hasScreenshot from FeedbackInput and the corresponding line
  from publish-github.ts so feedback promotion no longer adds the
  misleading note (applies to legacy rows whose screenshots are still
  stored locally).
- Update the admin feedback→GitHub call site to match the new shape.

The screenshotBase64 column, the screenshot ingest path on /api/feedback,
and the admin-side preview of previously-submitted screenshots are
unchanged — existing reports keep their attachments locally.
2026-04-28 19:46:25 +02:00
Marc-André Bombeck 0a77558fd0 fix(i18n): merge duplicate targets blocks, add locale integrity test
Both messages/en.json and messages/de.json had two top-level "targets"
blocks. JSON.parse silently keeps the last occurrence, and that block
was missing glucoseFasting/Postprandial/Random/Bedtime — so glucose
tiles on the dashboard rendered the raw key "targets.glucoseFasting"
instead of the translated label (#108).

Collapsed the duplicate into a single block, dropped a duplicate
bugreport.bugTitlePlaceholder shadowed within the same namespace, and
filled in dashboard.sleep / dashboard.steps which were falling back to
hard-coded English.

New test src/lib/__tests__/i18n-locale-integrity.test.ts walks the raw
JSON to detect duplicate keys at any depth and verifies en/de key
parity, so this class of silent shadowing fails the build going forward.
2026-04-28 19:46:16 +02:00
Marc-André Bombeck 79764d3467 fix(compose): replace ${VAR:?required} with entrypoint validation (1.3.1)
The ${VAR:?error} syntax in docker-compose.yml broke Coolify-style
deploys: that platform parses the compose file eagerly and stores the
fallback error string as the env-var value when the var is unset, which
then propagates into DATABASE_URL and breaks the running app with
P1000 auth failures.

Switched to plain ${VAR} interpolation. Validation moves into
docker-entrypoint.sh, which checks DATABASE_URL, SESSION_SECRET,
ENCRYPTION_KEY, API_TOKEN_HMAC_KEY before connecting to the database
and exits with a deploy-platform-agnostic error listing the unset vars.
2026-04-27 20:19:30 +02:00
Marc-André Bombeck 0e510944ce ci: keep ESLint non-blocking pending settings-page split
The pre-existing settings-page monolith has multiple set-state-in-effect
patterns that can only be cleaned up properly when the 2100-line file
gets broken into per-section sub-components (planned for 1.4.0). Until
then, blocking lint would just gate every PR. TypeScript strict checks
remain blocking.
2026-04-27 00:28:28 +02:00
Marc-André Bombeck 05142841fa fix(lint): suppress react-hooks/set-state-in-effect on pre-existing sync patterns
CI now blocks on ESLint, which surfaced two pre-existing useEffect
sync-from-server patterns. Annotated with targeted eslint-disable
comments and a note that the proper fix lives in 1.4.0 alongside the
settings-page split — refactoring derivation here would touch a
2100-line file.
2026-04-27 00:28:28 +02:00
Marc-André Bombeck 53235a0fb7 fix(security): close SSRF bypasses via hex/decimal IPv4 + IPv4-mapped IPv6
Security review of #106 surfaced two HIGH-severity gaps in the SSRF
guard. Both are documented bypass classes with public CVE precedent
(github.com/projectdiscovery/nuclei-templates' SSRF templates list
hex and IPv4-mapped IPv6 explicitly).

- Hex-notation IPv4: 'http://0x7f.0.0.1' / 'http://0x7f000001' /
  'http://0xa.0.0.1' all parse to internal addresses depending on
  the runtime's URL semantics. Reject the raw form before parsing.
- Decimal-notation IPv4: 'http://2130706433' is 127.0.0.1, etc.
  Reject any pure-numeric host of plausible IP length.
- IPv4-mapped IPv6: '[::ffff:127.0.0.1]' and the hex variant
  '[::ffff:7f00:1]' both target loopback. Match either form and
  apply the private-IPv4 check to the embedded address.
- '[::]' (IPv6 unspecified, alias for 0.0.0.0) blocked alongside
  '[::1]'.

Refactored the per-octet private-range checks into isPrivateIpv4()
since both the IPv4 and the IPv4-mapped-IPv6 paths now use them.

Test suite extended with regression cases for hex, decimal, and
IPv4-mapped IPv6. Now 208/208 passing.
2026-04-27 00:28:28 +02:00
Marc-André Bombeck 846eda80fa chore(release): 1.3.0
Bumps version, drafts CHANGELOG body, and adds total-body-water /
bone-mass mention to README's Key Features list. Tag will be created
post-merge to trigger the GHCR multi-arch build.
2026-04-27 00:28:28 +02:00
Marc-André Bombeck ef3e37b7da test(security): add SSRF guard tests + measurement validation tests
- New unit tests for isPublicUrl covering loopback, link-local, RFC1918,
  CGNAT, IPv6 ranges, and the leading-zero IPv4 octal-bypass regression
  (URL parser silently turns '010.0.0.1' into '8.0.0.1' — caught now).
- Strengthened isPublicUrl: pre-URL leading-zero check on the raw input,
  bracket-aware IPv6, deny on malformed IPv4 quads instead of falling
  through to 'public' (better safe than sorry on user-supplied URLs).
- New unit tests for measurement validation covering the new
  TOTAL_BODY_WATER and BONE_MASS types: ranges, glucoseContext rejection
  on non-glucose types, and unrecognised type rejection.
2026-04-27 00:28:28 +02:00
Marc-André Bombeck 3de2e37ed5 fix(ux): touch targets, a11y on phase config, admin label i18n
UX/a11y residuals from 2026-04-26 audit:

- bottom-nav: touch targets sized to WCAG 2.5.5 minimum (44x44 CSS px)
  via min-h-11 min-w-11 on each Link. Nav height bumped to h-16 to
  accommodate. Visual icon stays at 20px so design doesn't shift.
- phase-config-dialog: decorative coloured dot now aria-hidden because
  the redundant text label ("Green"/"Yellow"/...) already conveys the
  phase. Screen readers no longer announce a meaningless 'image' node.
- admin: 'Web Push' and 'Bug Report' labels were hard-coded English in
  StatusItem and SettingsToggle calls; now go through t() with new
  admin.integrationWebPush / admin.integrationBugReport keys (en + de).
- medication-card: false-positive en-CA / en-US locale audit findings
  documented inline — those are intentional locale-independent helpers
  (en-CA gives YYYY-MM-DD for day-comparison, en-US gives a parseable
  string for the toBerlinDate timezone hack), not user-facing display.
2026-04-27 00:28:28 +02:00
Marc-André Bombeck 908532be73 feat(security): SSRF strict IPv4, PAT logging redaction, threshold rate-limit, N+1 fix, pg-boss SIGTERM, blocking CI
Security hardening from 2026-04-26 audit:

- SEC-002: isPublicUrl() now uses a strict IPv4 parser that rejects
  leading-zero forms like 010.0.0.1, which historically slipped past
  the h.startsWith('10.') check. Also covers CGNAT (100.64/10),
  IPv6 unique-local (fc00::/7), and additional metadata-service edge
  cases. Documented as input-time only (DNS rebinding still possible
  at fetch time).
- SEC-004: GitHub PAT is now redacted from logged error bodies in
  publish-github.ts before flowing to Wide Events / Loki. Adds an
  escapeRegex helper for safe RegExp construction.
- SEC-006: PUT /api/user/thresholds now goes through checkRateLimit
  (30 writes / 5 min, per-user) — audit-log was already there but
  unbounded. Makes audit-log enumeration unattractive.
- PERF-001: insights/comprehensive route now batches medication-intake
  events into a single 'medicationId IN (...)' query and groups in
  memory instead of N+1 per medication. Latency was scaling poorly
  for users with many active meds.
- REL-003: pg-boss now installs SIGTERM/SIGINT handlers that call
  boss.stop({ graceful: true, timeout: 30s }) so in-flight handlers
  drain on docker stop / coolify redeploy instead of being killed.
- LOG-001: Replaced console.log/error in reminder-worker with a
  workerLog helper. Lifecycle errors still go to stderr (only audience
  outside any handler context); routine init messages are silent
  because pg-boss own events + Wide Events from handlers carry the
  state.
- CI: ESLint and TypeScript checks are now blocking — continue-on-error
  removed. Tests already were. Required pre-existing 'any' types in
  api-handler.ts to be properly justified with eslint-disable comments
  (Next.js variadic handler signature; the bound type still constrains
  return to Promise<Response>).
2026-04-27 00:28:28 +02:00
Marc-André Bombeck 11ed6faedd feat(measurements): add total body water and bone mass types (#89)
- Adds TOTAL_BODY_WATER and BONE_MASS to MeasurementType enum
- Migration 0022 (purely additive ALTER TYPE ... ADD VALUE)
- Both stored canonically in kilograms (matches Withings hydration/bone mass measures and Health Connect TotalBodyWaterRecord/BoneMassRecord)
- Validation, formatters, i18n keys (en + de) for measurement form and doctor-report PDF
- Dashboard widgets registered (default-invisible, opt-in via settings)
- Withings client maps measure types 77 (hydration) and 88 (bone mass)
- Doctor report PDF includes both new types in vital-signs table when data exists
2026-04-27 00:28:28 +02:00
Marc-André Bombeck 59448d473c chore(release): 1.2.1 2026-04-27 00:08:55 +02:00
Marc-André BombeckandClaude Opus 4.7 75d142df25 feat(ci): publish multi-arch Docker image to GHCR (#88)
Issue #88's biggest blocker was that docs and README pointed at
ghcr.io/mbombeck/healthlog:latest, but no workflow ever pushed an
image — every self-hoster trying to follow the docs hit "manifest
unknown".

This adds .github/workflows/docker-publish.yml. The workflow:

- Triggers on push to main, on v* tags, on PRs (build only, no push),
  and via workflow_dispatch
- Builds linux/amd64 + linux/arm64 multi-arch images via buildx
- Tags with `latest` (main only), semver (`1.2.0`, `1.2`), branch name,
  PR number, and short SHA — using docker/metadata-action defaults
- Attaches SLSA build provenance and an SBOM to every image
- Caches build layers in GitHub Actions cache for fast iterations
- Uses concurrency group + cancel-in-progress so superseded runs stop

The bundled docker-compose.yml now references
`ghcr.io/mbombeck/healthlog:latest` as the primary `image:` and keeps
`build:` as a fallback so contributors can still
`docker compose up --build` against their working tree.

SECURITY.md gains a Supply Chain section explaining where images come
from, how to verify provenance and SBOMs, and how to pin a specific
version. README's Quick Start updated to reflect that no build is
needed for self-hosters.

The first run of this workflow on main will publish the image; until
then `docker compose up` will fall back to the `build:` block.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 23:38:08 +02:00
Marc-André BombeckandClaude Opus 4.7 5e721e0791 fix(onboarding): surface errors and supply default schedule (#87)
Issue #87 (kevinpapst): medications added during onboarding were
never visible afterwards. Root cause: the createMedication schema
requires schedules.min(1) ("Mindestens ein Zeitfenster"), but the
onboarding wizard sent schedules:[]. The 422 was silently swallowed
and the user was redirected to the dashboard as if everything had
worked.

This patch is the minimal-impact fix. The proper fix lives in Phase
3.5 of the audit plan, which replaces the wizard entirely with a
dashboard-first empty-state flow. Until then:

- finishOnboarding() wraps each call in a try/catch, checks
  response.ok explicitly, and surfaces a sonner toast on failure.
  setSaving(false) on error so the user can retry instead of being
  stuck on a spinner.
- Each medication is sent with a default schedule (08:00-09:00) so
  it is actually persisted. A new medScheduleHint i18n key is shown
  under the meds list while the user is in the wizard, so the
  default schedule is not surprising.
- Errors are translated per stage (errorProfile, errorMedication
  with {name}, errorTargets) plus an errorGeneric fallback. en.json
  and de.json kept symmetric.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 23:22:10 +02:00
Marc-André BombeckandClaude Opus 4.7 be7df4bc17 chore: bump to 1.2.0 and sync model count to 23
package.json was still on 1.1.0 even though CHANGELOG and the released
features (Custom Thresholds, Glucose, Dashboard Customization,
Built-in Feedback, Multi-Provider AI) shipped as 1.2.0 on 2026-04-18.

CLAUDE.md and AGENTS.md said 21 / 22 models in different places; the
schema actually has 23. Feedback (added in v1.2 for the in-app
bug-report flow) was missing from the model list in AGENTS.md, and
AGENTS.md still claimed status "Feature-complete (v1.0.0)".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 23:22:10 +02:00
Marc-André BombeckandClaude Opus 4.7 85f9a07daf fix(setup): make docker compose work out of the box (#88)
Issue #88 (kevinpapst): the bundled docker-compose.yml didn't actually
work. Three concrete blockers:

- expose:3000 → ports:"3000:3000" so the host can reach the app
- POSTGRES_PASSWORD was hard-coded to "healthlog" — now required via
  env var, and DATABASE_URL inside the compose service interpolates
  the same variable so the two cannot drift apart
- .env.example DATABASE_URL pointed at localhost:5432, which never
  resolves inside the app container; replaced with db:5432 plus a
  note explaining the local-dev variant

.env.example is also rewritten as proper documentation: each variable
explains what it does, why it matters, and how to generate it
(openssl rand -hex 32 for the three 32-byte secrets, openssl rand
-base64 24 for the Postgres password). Required vs optional are now
clearly grouped.

README Quick Start gives a realistic time estimate, generates the four
secrets in one block straight into .env, and points reverse-proxy
users at the docs site.

Pre-built GHCR images and a docs-vs-repo single-source-of-truth for
docker-compose are tracked separately in Phase 2 of the audit plan
(see docs/audit/2026-04-26-PLAN.md).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 23:22:10 +02:00
Marc-André BombeckandClaude Opus 4.7 9d0983dc22 docs(audit): add 2026-04-26 full-audit plan
Captures the eight-phase remediation plan synthesized from the
2026-04-26 multi-agent audit covering Issues #87, #88, #89 and the
broader codebase health (security, perf, reliability, i18n, a11y,
test coverage, docs consistency).

Plan reorders the original sequence to do the typed apiClient refactor
(Phase 4) BEFORE the onboarding redesign (Phase 3.5), so the new flow
inherits the .ok-checked client wrapper and the silent-failure class
of bugs (root cause of #87) is structurally eliminated rather than
re-introduced.

Custom measurement types are explicitly deferred to v1.5+ via the
Cronometer pattern when user demand crosses a threshold; v1.3 ships
TBW + Bone Mass hardcoded (#89, Phase 3) following the v1.2 glucose
blueprint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 23:22:10 +02:00
dependabot[bot] 068c40270f build(deps): bump lucide-react from 1.8.0 to 1.11.0 (#101)
Bumps [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) from 1.8.0 to 1.11.0.
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.11.0/packages/lucide-react)

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-version: 1.11.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-04-26 16:02:51 +00:00
dependabot[bot] 8258f33d02 build(deps): bump pg-boss from 12.15.0 to 12.18.1 (#100)
Bumps [pg-boss](https://github.com/timgit/pg-boss) from 12.15.0 to 12.18.1.
- [Release notes](https://github.com/timgit/pg-boss/releases)
- [Commits](https://github.com/timgit/pg-boss/compare/12.15.0...12.18.1)

---
updated-dependencies:
- dependency-name: pg-boss
  dependency-version: 12.18.1
  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-04-26 16:02:00 +00:00
dependabot[bot] 37af3e9e50 build(deps): bump @tanstack/react-query from 5.99.2 to 5.100.5 (#99)
Bumps [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) from 5.99.2 to 5.100.5.
- [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.5/packages/react-query)

---
updated-dependencies:
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.100.5
  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-04-26 16:00:52 +00:00
dependabot[bot] 0081134cd7 build(deps): bump @prisma/client from 7.7.0 to 7.8.0 (#97)
Bumps [@prisma/client](https://github.com/prisma/prisma/tree/HEAD/packages/client) from 7.7.0 to 7.8.0.
- [Release notes](https://github.com/prisma/prisma/releases)
- [Commits](https://github.com/prisma/prisma/commits/7.8.0/packages/client)

---
updated-dependencies:
- dependency-name: "@prisma/client"
  dependency-version: 7.8.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-04-26 16:00:31 +00:00
dependabot[bot] 50e23a38a1 build(deps-dev): bump shadcn from 4.3.0 to 4.5.0 (#93)
Bumps [shadcn](https://github.com/shadcn-ui/ui/tree/HEAD/packages/shadcn) from 4.3.0 to 4.5.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.5.0/packages/shadcn)

---
updated-dependencies:
- dependency-name: shadcn
  dependency-version: 4.5.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-04-26 15:59:01 +00:00
dependabot[bot] 6fab09041a build(deps-dev): bump @tailwindcss/postcss from 4.2.2 to 4.2.4 (#94)
Bumps [@tailwindcss/postcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-postcss) from 4.2.2 to 4.2.4.
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.2.4/packages/@tailwindcss-postcss)

---
updated-dependencies:
- dependency-name: "@tailwindcss/postcss"
  dependency-version: 4.2.4
  dependency-type: direct:development
  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-04-26 15:58:42 +00:00
dependabot[bot] bfb346db33 build(deps): bump react-hook-form from 7.72.1 to 7.74.0 (#98)
Bumps [react-hook-form](https://github.com/react-hook-form/react-hook-form) from 7.72.1 to 7.74.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.72.1...v7.74.0)

---
updated-dependencies:
- dependency-name: react-hook-form
  dependency-version: 7.74.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-04-26 15:58:05 +00:00
dependabot[bot] 46aa01bb8a build(deps-dev): bump prettier-plugin-tailwindcss from 0.7.2 to 0.7.3 (#96)
Bumps [prettier-plugin-tailwindcss](https://github.com/tailwindlabs/prettier-plugin-tailwindcss) from 0.7.2 to 0.7.3.
- [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.2...v0.7.3)

---
updated-dependencies:
- dependency-name: prettier-plugin-tailwindcss
  dependency-version: 0.7.3
  dependency-type: direct:development
  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-04-26 15:56:53 +00:00
dependabot[bot] 922648d375 build(deps-dev): bump tailwindcss from 4.2.2 to 4.2.4 (#95)
Bumps [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) from 4.2.2 to 4.2.4.
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.2.4/packages/tailwindcss)

---
updated-dependencies:
- dependency-name: tailwindcss
  dependency-version: 4.2.4
  dependency-type: direct:development
  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-04-26 15:56:32 +00:00
dependabot[bot] 4655a1b030 build(deps-dev): bump vitest from 4.1.4 to 4.1.5 (#92)
Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 4.1.4 to 4.1.5.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.5/packages/vitest)

---
updated-dependencies:
- dependency-name: vitest
  dependency-version: 4.1.5
  dependency-type: direct:development
  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-04-26 15:55:15 +00:00
dependabot[bot] 3cfde9fa7a build(deps): bump prisma from 7.7.0 to 7.8.0 (#91)
Bumps [prisma](https://github.com/prisma/prisma/tree/HEAD/packages/cli) from 7.7.0 to 7.8.0.
- [Release notes](https://github.com/prisma/prisma/releases)
- [Commits](https://github.com/prisma/prisma/commits/7.8.0/packages/cli)

---
updated-dependencies:
- dependency-name: prisma
  dependency-version: 7.8.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-04-26 15:54:45 +00:00
dependabot[bot] d7cd2bc3e6 build(deps): bump @prisma/adapter-pg from 7.7.0 to 7.8.0 (#90)
Bumps [@prisma/adapter-pg](https://github.com/prisma/prisma/tree/HEAD/packages/adapter-pg) from 7.7.0 to 7.8.0.
- [Release notes](https://github.com/prisma/prisma/releases)
- [Commits](https://github.com/prisma/prisma/commits/7.8.0/packages/adapter-pg)

---
updated-dependencies:
- dependency-name: "@prisma/adapter-pg"
  dependency-version: 7.8.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-04-26 15:53:53 +00:00
dependabot[bot] 38bfb0520d build(deps): bump react-dom from 19.2.4 to 19.2.5 (#85)
Bumps [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom) from 19.2.4 to 19.2.5.
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.5/packages/react-dom)

---
updated-dependencies:
- dependency-name: react-dom
  dependency-version: 19.2.5
  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-04-19 16:03:03 +00:00
dependabot[bot] 588b7c66c1 build(deps-dev): bump typescript from 6.0.2 to 6.0.3 (#86)
Bumps [typescript](https://github.com/microsoft/TypeScript) from 6.0.2 to 6.0.3.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v6.0.2...v6.0.3)

---
updated-dependencies:
- dependency-name: typescript
  dependency-version: 6.0.3
  dependency-type: direct:development
  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-04-19 16:00:44 +00:00
dependabot[bot] 6a159e7ac3 build(deps): bump dotenv from 17.4.1 to 17.4.2 (#83)
Bumps [dotenv](https://github.com/motdotla/dotenv) from 17.4.1 to 17.4.2.
- [Changelog](https://github.com/motdotla/dotenv/blob/master/CHANGELOG.md)
- [Commits](https://github.com/motdotla/dotenv/compare/v17.4.1...v17.4.2)

---
updated-dependencies:
- dependency-name: dotenv
  dependency-version: 17.4.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-04-19 15:59:25 +00:00
dependabot[bot] 1c13a16d49 build(deps): bump lucide-react from 1.7.0 to 1.8.0 (#82)
Bumps [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) from 1.7.0 to 1.8.0.
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.8.0/packages/lucide-react)

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-version: 1.8.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-04-19 15:58:48 +00:00
dependabot[bot] f4c983c8ba build(deps-dev): bump shadcn from 4.2.0 to 4.3.0 (#81)
Bumps [shadcn](https://github.com/shadcn-ui/ui/tree/HEAD/packages/shadcn) from 4.2.0 to 4.3.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.3.0/packages/shadcn)

---
updated-dependencies:
- dependency-name: shadcn
  dependency-version: 4.3.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-04-19 15:58:42 +00:00
dependabot[bot] 784b62b656 build(deps): bump @tanstack/react-query from 5.99.0 to 5.99.2 (#84)
Bumps [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) from 5.99.0 to 5.99.2.
- [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.99.2/packages/react-query)

---
updated-dependencies:
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.99.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-04-19 15:57:16 +00:00
dependabot[bot] adcacfd1a4 build(deps-dev): bump prettier from 3.8.2 to 3.8.3 (#80)
Bumps [prettier](https://github.com/prettier/prettier) from 3.8.2 to 3.8.3.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.8.2...3.8.3)

---
updated-dependencies:
- dependency-name: prettier
  dependency-version: 3.8.3
  dependency-type: direct:development
  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-04-19 15:56:33 +00:00
dependabot[bot] fd6170618a build(deps): bump next from 16.2.3 to 16.2.4 (#79)
Bumps [next](https://github.com/vercel/next.js) from 16.2.3 to 16.2.4.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v16.2.3...v16.2.4)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.2.4
  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-04-19 15:54:23 +00:00
dependabot[bot] 2e8279a2fd build(deps-dev): bump eslint-config-next from 16.2.3 to 16.2.4 (#78)
Bumps [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) from 16.2.3 to 16.2.4.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v16.2.4/packages/eslint-config-next)

---
updated-dependencies:
- dependency-name: eslint-config-next
  dependency-version: 16.2.4
  dependency-type: direct:development
  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-04-19 15:53:47 +00:00
8533755d8d release(v1.2): i18n, thresholds, glucose, dashboard customization, feedback, multi-provider AI (#77)
* fix(i18n,ux): package A — missing keys, chart labels, query invalidation, admin error UX

Addresses bugs surfaced in issues #73 and #74 plus drift found during audit.

- i18n: add 21 missing translation keys (targets.* namespace was only
  partially populated after the English-first migration; common.reset and
  settings.configured were referenced but absent), plus common helpers
  (copy, copyDetails, retry, reportIssue) and admin.settingsSaveError.
- charts: rename points7Label/30Label/90Label from cryptic "7P/30P/90P"
  to "7 pts / 30 pts / 90 pts" in EN and "7 Pkt / 30 Pkt / 90 Pkt" in DE.
- queries: add src/lib/query-keys.ts as a single source of truth with
  measurement/mood/medication-dependent key sets. Wire them into
  measurement-form/list, mood-form/list, medication-form/card,
  intake-history-list, intake-timeline, medications page, and Withings
  sync in settings. Fixes dashboard not auto-refreshing after adding a
  measurement (old code only invalidated ["measurements"], not
  ["analytics"] / ["insights"] / ["insights","targets"]).
- admin: useUpdateSettings + updateUser mutations now surface real error
  messages via sonner toasts and invoke getApiErrorMessage() so saves
  fail loudly instead of silently. Success also gets a toast.

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

* refactor(i18n): package B — locale-aware formatters throughout

Removes the hard-coded de-DE locale that lingered from the German-first era
and makes every number, date, and time locale-aware. English stays the
default per user preference; German remains selectable.

New:
- src/lib/format-locale.ts exposes makeFormatters(locale) + a cached
  Europe/Berlin timezone. Covers number, integer, percent, date, dateShort,
  dateTime, time, dateWithWeekday, monthShort. Also exports
  parseLocaleFromAcceptLanguage() for server code.
- useFormatters() hook in @/lib/i18n/context resolves from the active
  locale, memoized per render.

Migrated (all Intl.NumberFormat("de-DE") / toLocaleString("de-DE") /
Intl.DateTimeFormat("de-DE") call sites):
- src/app/page.tsx (dashboard)
- src/app/insights/page.tsx
- src/app/admin/page.tsx
- src/app/settings/page.tsx (Withings last-sync, doctor-report caller)
- src/components/charts/trend-card.tsx (+ i18n "7d"/"30d" short labels)
- src/components/charts/health-chart.tsx
- src/components/medications/medication-card.tsx
- src/components/medications/medication-form.tsx
- src/components/measurements/measurement-list.tsx
- src/components/mood/mood-list.tsx
- src/components/insights/insight-status-card.tsx (also removed 5 German
  hard-coded strings with TODO: i18n markers — now translated)
- src/components/insights/insight-advisor-card.tsx (same — 10+ strings)

Server: reminder-check / reminder-worker / umami-test switched from
"de-DE" to neutral en-GB/en-US where the string is machine-compared rather
than user-facing. Bug-report GitHub issue titles use ISO-8601 UTC now —
the repo is English-speaking.

Next.js metadata:
- OpenGraph locale from "de_DE" → "en_US" with "de_DE" as
  alternateLocale.
- Root layout now reads healthlog-locale cookie (falls back to
  Accept-Language) and sets <html lang> server-side, avoiding an
  EN-flashes-then-DE hydration gap and fixing SEO/a11y.
- I18nProvider mirrors locale to a 1-year SameSite=Lax cookie so the next
  SSR pass is correct.

Doctor-report PDF:
- generateDoctorReportPDF(data, { t, locale }) now accepts injected
  translator + locale. All section titles, column headers, BMI / BP
  classifications, mood labels, and the footer disclaimer are
  i18n-driven. File name slug switches between gesundheitsbericht / health-report.
- 40+ new doctorReport.* keys in EN + DE.

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

* feat(ux): package C — typed passkey errors, bug-report gating, error boundary

Addresses issue #73 points 1, 5, 6 and lays groundwork for copy-paste
error reporting requested during review.

Passkey registration / login (#73.1):
- src/lib/passkey-errors.ts maps DOMException.name + SimpleWebAuthn code
  to specific i18n keys instead of swallowing every failure as
  "cancelled". Distinguishes NotAllowed/InvalidState/NotSupported/
  Security/Abort errors and falls back to a diagnostic message.
- settings passkey + login passkey flows both use describePasskeyError
  now, so origin mismatches and previous-registration collisions show
  actionable copy.
- 5 new passkey.* i18n keys in EN + DE.

Bug report (#73.5):
- New GET /api/bugreport/status reports {configured, hasToken, hasRepo,
  isAdmin}.
- /bugreport page now queries status first. When not configured it shows
  a notice (different copy for admins vs. regular users) with a direct
  link to the admin panel. Form is hidden, not silently broken.
- When POST /api/bugreport can't create issues it returns 503 instead
  of 500 with the same user-facing message.
- 4 new bugreport.* i18n keys.

Global error UX:
- src/components/error-details.tsx — reusable panel with retry + copy
  diagnostics (message, digest, stack (trimmed), URL, user-agent,
  request-id, locale, timestamp) + report-issue link. Clipboard fallback
  prompt covers insecure-context cases.
- src/app/error.tsx uses it.
- src/app/global-error.tsx is provider-free (no i18n dependency) since
  it replaces the root layout on catastrophic failures — hardcoded
  English + Dracula palette so it still looks like HealthLog.
- 2 new errorBoundary.* keys and 4 common.* keys (copy, copyDetails,
  retry, reportIssue).

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

* refactor(query-keys): collapse repeated invalidation blocks into invalidateKeys helper

Eleven mutation handlers repeated `Promise.all(keys.map(k => invalidate({queryKey: k})))`
verbatim. Adds `invalidateKeys(qc, keys)` to the existing query-keys factory and
adopts it everywhere, including replacing the local `invalidateMedicationRelated`
helper that medication-form.tsx had grown. Also fixes the import-sandwich that
helper introduced.

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

* refactor: small simplifications in i18n/UX changes

- passkey-errors: drop the typeof-name dance now that error.name is already string after the instanceof Error narrow
- doctor-report-pdf: replace nested gender ternary with a small lookup map; clarify unitFor's null-as-translation-marker convention
- dashboard page: route the analytics queries through the new queryKeys factory so future invalidation stays in one place
- health-chart: add missing fmt dependency to trendInfo useMemo (introduced when formatSigned was switched to the locale-aware formatter)

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

* fix(ai): capture upstream response body in OpenAI errors

A production insights burst at 12:19 UTC failed with "OpenAI request
failed (500)". The 500 was the upstream OpenAI HTTP status (confirmed
via logs), but we had no way to tell from logs whether it was an
OpenAI incident, a model-not-found, or a response_format rejection
because the response body was thrown away.

Now the error message includes the first 500 chars of the response body
and structured fields (httpStatus, upstream, model) that Wide Event
logging can attach. No behavior change for the happy path.

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

* fix(review): address code-review + security findings

Review round produced five real issues — fix them all, plus three nits
that were cheap enough to bundle.

Must-fix:
- security: error-details.tsx + global-error.tsx were copying
  window.location.href into the clipboard, which on OAuth callback
  routes (/api/withings/*, password reset) would leak code/state
  tokens into GitHub issues. Strip to pathname + searchParamCount.
- fe: query-keys.invalidateKeys now uses Promise.allSettled, so a
  transient invalidate failure doesn't abort the chain and no
  unhandled rejection leaks from void invalidateKeys(...) call sites.
- fe: resolveInitialLocale in app/layout.tsx wraps cookies()/headers()
  in try/catch — a dynamic-server error in the root layout now
  degrades to the "en" default instead of crashing into global-error.
- be: reminder-worker + reminder-check parseTimeToMinutes normalize
  "24:00" → "00:00" (some ICU builds emit that for midnight under
  en-GB). Prevents a 1440-minute comparison against schedule windows.
- security: openai-client error body excerpt redacts sk-* / Bearer
  tokens before logging, and moves the excerpt off Error.message into
  a structured field (bodyExcerpt) so log transports can filter it.
- i18n: /api/withings/connect German error message → English.
- i18n: /insights/page.tsx hardcoded "Fehler beim Laden" → t(...)

Hardening:
- /api/bugreport/status collapses to {configured, isAdmin} and adds
  a 30/min rate limit per user. Client interface updated.
- i18n/context.tsx: I18nProvider's initializer now reads the cookie
  (matches SSR) before falling back to localStorage or browser
  detection — eliminates the locale-flash hydration class.
- format.ts legacy helpers also read cookie first, matching SSR.

Tests:
- new src/lib/__tests__/format-locale.test.ts — 9 cases covering
  makeFormatters, parseLocaleFromAcceptLanguage, resolveIntlLocale
- new passkey-errors.test.ts — 8 cases covering every DOMException
  name and SimpleWebAuthn code we map
- new query-keys.test.ts — bundles + invalidateKeys allSettled
  semantics (incl. partial-failure test)

118 → 142 tests. All pass.

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

* feat(v1.2): thresholds, glucose, dashboard customization, built-in feedback, multi-provider AI (#76)

* build(deps): bump @prisma/adapter-pg from 7.6.0 to 7.7.0 (#60)

Bumps [@prisma/adapter-pg](https://github.com/prisma/prisma/tree/HEAD/packages/adapter-pg) from 7.6.0 to 7.7.0.
- [Release notes](https://github.com/prisma/prisma/releases)
- [Commits](https://github.com/prisma/prisma/commits/7.7.0/packages/adapter-pg)

---
updated-dependencies:
- dependency-name: "@prisma/adapter-pg"
  dependency-version: 7.7.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>

* build(deps): bump prisma from 7.6.0 to 7.7.0 (#61)

Bumps [prisma](https://github.com/prisma/prisma/tree/HEAD/packages/cli) from 7.6.0 to 7.7.0.
- [Release notes](https://github.com/prisma/prisma/releases)
- [Commits](https://github.com/prisma/prisma/commits/7.7.0/packages/cli)

---
updated-dependencies:
- dependency-name: prisma
  dependency-version: 7.7.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>

* build(deps-dev): bump eslint-config-next from 16.2.2 to 16.2.3 (#62)

Bumps [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) from 16.2.2 to 16.2.3.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v16.2.3/packages/eslint-config-next)

---
updated-dependencies:
- dependency-name: eslint-config-next
  dependency-version: 16.2.3
  dependency-type: direct:development
  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>

* build(deps): bump next from 16.2.2 to 16.2.3 (#65)

Bumps [next](https://github.com/vercel/next.js) from 16.2.2 to 16.2.3.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v16.2.2...v16.2.3)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.2.3
  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>

* build(deps-dev): bump @types/node from 25.5.2 to 25.6.0 (#66)

Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 25.5.2 to 25.6.0.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 25.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>

* build(deps): bump dotenv from 17.4.0 to 17.4.1 (#67)

Bumps [dotenv](https://github.com/motdotla/dotenv) from 17.4.0 to 17.4.1.
- [Changelog](https://github.com/motdotla/dotenv/blob/master/CHANGELOG.md)
- [Commits](https://github.com/motdotla/dotenv/compare/v17.4.0...v17.4.1)

---
updated-dependencies:
- dependency-name: dotenv
  dependency-version: 17.4.1
  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>

* build(deps): bump @tanstack/react-query from 5.96.2 to 5.99.0 (#70)

Bumps [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) from 5.96.2 to 5.99.0.
- [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.99.0/packages/react-query)

---
updated-dependencies:
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.99.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>

* build(deps-dev): bump typescript from 5.9.3 to 6.0.2 (#64)

Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.9.3 to 6.0.2.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.9.3...v6.0.2)

---
updated-dependencies:
- dependency-name: typescript
  dependency-version: 6.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* build(deps-dev): bump prettier from 3.8.1 to 3.8.2 (#68)

Bumps [prettier](https://github.com/prettier/prettier) from 3.8.1 to 3.8.2.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.8.1...3.8.2)

---
updated-dependencies:
- dependency-name: prettier
  dependency-version: 3.8.2
  dependency-type: direct:development
  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>

* build(deps): bump react from 19.2.4 to 19.2.5 (#63)

Bumps [react](https://github.com/facebook/react/tree/HEAD/packages/react) from 19.2.4 to 19.2.5.
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.5/packages/react)

---
updated-dependencies:
- dependency-name: react
  dependency-version: 19.2.5
  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>

* build(deps-dev): bump vitest from 4.1.2 to 4.1.4 (#69)

Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 4.1.2 to 4.1.4.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.4/packages/vitest)

---
updated-dependencies:
- dependency-name: vitest
  dependency-version: 4.1.4
  dependency-type: direct:development
  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>

* build(deps): bump @prisma/client from 7.6.0 to 7.7.0 (#71)

Bumps [@prisma/client](https://github.com/prisma/prisma/tree/HEAD/packages/client) from 7.6.0 to 7.7.0.
- [Release notes](https://github.com/prisma/prisma/releases)
- [Commits](https://github.com/prisma/prisma/commits/7.7.0/packages/client)

---
updated-dependencies:
- dependency-name: "@prisma/client"
  dependency-version: 7.7.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>

* build(deps-dev): bump shadcn from 4.1.2 to 4.2.0 (#72)

Bumps [shadcn](https://github.com/shadcn-ui/ui/tree/HEAD/packages/shadcn) from 4.1.2 to 4.2.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.2.0/packages/shadcn)

---
updated-dependencies:
- dependency-name: shadcn
  dependency-version: 4.2.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>

* feat(thresholds): foundation + settings UI (v1.2)

Introduces per-user health threshold overrides with an auto-default /
user-override pattern. Default computation stays evidence-based (WHO,
ESC/ESH 2018, AHA, AASM, ADA 2024, DGIM/DDG S3). Overrides live in
User.thresholdsJson; any missing metric falls through to the default so
new users need zero configuration.

Schema (migration 0021):
- New enums: glucose_context (FASTING/POSTPRANDIAL/RANDOM/BEDTIME),
  feedback_category, feedback_status.
- measurement_type gains BLOOD_GLUCOSE; Measurement gets optional
  glucose_context (enforced via CHECK constraint — only set for glucose).
- User gains thresholds_json, dashboard_widgets_json, glucose_unit,
  ai_provider, ai_model, ai_base_url, ai_anthropic_key_encrypted,
  ai_local_key_encrypted (stubs for the upcoming features).
- New Feedback table for the built-in feedback system (next commit).

Backend:
- src/lib/analytics/effective-range.ts is the single source of truth:
  getEffectiveRange(metric, profile, overrides) returns the resolved range
  + the pristine default + physiological bounds. Every chart, band,
  insight prompt, and doctor-report section should call this.
- Supports 11 metrics incl. four glucose contexts with ADA/DGIM ranges.
- src/lib/validations/thresholds.ts — Zod schema with per-metric bounds
  and min<max refinement.
- GET/PUT/DELETE /api/user/thresholds with full audit logging (before/
  after) so medical-ish data mutations are traceable.

UI:
- src/components/settings/thresholds-section.tsx — clean per-metric row
  with Auto/Override Switch, inline min/max, reset-per-row + reset-all,
  and a "this looks outside guideline ranges" warning banner that only
  shows when the override is >30% off the default.
- New Personalization section in /settings.
- 25 new i18n keys (EN + DE) in a `thresholds.*` namespace.

Tests: 8 new cases covering default computation, override application,
missing profile gracefully returning null, glucose-context defaults, and
per-metric bounds. 150/150 tests pass.

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

* feat(glucose): wire BLOOD_GLUCOSE + context through the write path

Extends measurement validation and the POST /api/measurements route to
accept blood-glucose samples with a required `glucoseContext`
(FASTING / POSTPRANDIAL / RANDOM / BEDTIME).

- Zod schema adds BLOOD_GLUCOSE to the type enum and glucoseContextEnum.
- refine(): BLOOD_GLUCOSE requires a context; every other type must not
  set one — mirrored by the Postgres CHECK constraint from migration 0021
  so callers bypassing validation still can't write inconsistent rows.
- VALUE_RANGES gains BLOOD_GLUCOSE (20-800 mg/dL) — covers severe hypo
  through severe hyperglycemia.
- getUnitForType(BLOOD_GLUCOSE) returns "mg/dL" (canonical storage).
  Display in mmol/L is a UI-level conversion per user preference.
- Error messages translated to English (removed two lingering German
  refine messages while here).

Measurement form + charts + targets UI follow in the next commits.

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

* feat(i18n): localize AI insight prompts (en/de)

Each prompt builder (base-system, blood-pressure, bmi, general-status,
medication-compliance, mood, pulse, weight) now accepts a Locale and
returns the system + user prompt either fully in English or fully in
German. Stable contract keys (classification enum optimal/gut/grenzwertig
/erhoht/kritisch, confidence niedrig/mittel/hoch) are preserved verbatim
so the response parser keeps working — only the natural-language sections
are translated. The prompt explicitly tells the model to emit
classificationLabel / summary / recommendations in the user's language.

Wires the locale through every status generator (general / blood-pressure
/ bmi / weight / pulse / mood / medication-compliance) into the prompt
calls and adds a small server-side resolver
(src/lib/i18n/server-locale.ts: explicit override > healthlog-locale or
userLocale cookie > User.locale > Accept-Language header > default
English) used by all /api/insights/*-status routes plus
/api/insights/generate. The auditLog cache key already contains the
locale suffix, so cached entries stay disjoint per language.

The reminder worker continues to fall back to "de" when User.locale is
null, matching its prior behaviour.

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

* feat(glucose): UI + unit conversion helpers

- src/lib/glucose.ts — mg/dL ↔ mmol/L conversion (factor 18.0182 per
  DGIM/DDG), resolveGlucoseUnit() defaulting to mg/dL,
  thresholdMetricForContext() mapping each context to the matching
  effective-range key.
- Measurement form gains a "Blood glucose" option (mg/dL) with a
  context selector (fasting / postprandial / random / bedtime). The
  glucoseContext field is only sent for BLOOD_GLUCOSE — the Zod
  refinement and the Postgres CHECK constraint both enforce that.
- 10 new i18n keys per locale (measurements.typeBloodGlucose,
  glucoseContext*, glucoseUnit, glucoseUnitHint).
- 6 new tests covering conversion roundtrip, unit resolution defaults,
  and context → threshold-metric mapping.

Charts, target cards, and doctor-PDF row for glucose follow in the
next commits.

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

* feat(feedback): built-in feedback system (no GitHub required)

Implements the feedback-system design from the v1.2 spec. The submit
button on /bugreport now always works — even when no admin has
configured a GitHub repository. Feedback is stored in the Feedback
table (migration 0021) with an admin inbox for triage.

Endpoints:
- POST /api/feedback — user submits. Auth-gated, rate-limited 5/hour.
  Category (BUG / FEATURE_REQUEST / QUESTION / OTHER), subject,
  description, optional screenshot (≤5 MB, base64), optional metadata
  (locale, URL path, userAgent).
- GET /api/feedback — user's own history (paginated).
- GET /api/admin/feedback — admin inbox with status filter and
  counts-by-status for tab badges.
- PATCH /api/admin/feedback/[id] — update status / admin note.
- DELETE /api/admin/feedback/[id] — soft-archive.

Every mutation writes to AuditLog with before/after details.

UI:
- /bugreport rewritten: category dropdown, optional subject, always
  submits. Informational banner explains whether the server has GitHub
  escalation available or keeps feedback internal-only.
- Legacy "not configured" blocker is gone — the form is never hidden.
- 8 new bugreport.* i18n keys per locale.

The old POST /api/bugreport route and /api/bugreport/status endpoint
are left intact for backwards compatibility; they'll be removed in
v1.3 once no one depends on them.

Admin inbox UI lands in the next commit alongside the dashboard and
multi-provider work.

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

* feat(telegram): localize bot UI per User.locale

The Telegram webhook now resolves the user's locale (User.locale, falling
back to "de" so existing bots stay German) and runs every reply, inline-
keyboard label and callback toast through a small server-side translator.

Adds src/lib/i18n/server-translator.ts (getServerTranslator) — reads the
same messages/{en,de}.json bundles the client uses, with the same
"missing key falls back to English, then to the raw key" semantics, and
the same {param} interpolation. Importing the client useTranslations()
hook into a server route would have pulled React in, so this mirrors
just the parts the bot needs.

Adds the telegram.* namespace to messages/en.json and messages/de.json
covering: button labels, callback toasts, the /help body, /add prompt,
medication-not-found / inactive errors, snooze/skip/confirm confirmations
and the cancel button. The "genommen" / "taken" intake keyword and the
"hi/hallo/hey/moin/hello" greeting list are accepted regardless of UI
locale to keep backwards compatibility for existing users.

Also adds 6 vitest cases for getServerTranslator (basic resolution,
locale switching, parameter interpolation, English fallback, raw-key
fallback, exposed locale).

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

* feat(dashboard): user-customizable widget layout

Implements the dashboard customization spec: users choose which widgets
appear on / and in what order. Auto-default keeps the current v1.1
behavior so nothing changes without opt-in.

Backend:
- src/lib/dashboard-layout.ts — single source of truth with
  DEFAULT_DASHBOARD_LAYOUT (10 widgets incl. the new sleep/steps/glucose
  ones that were previously hard-conditional), a version field for
  future schema evolution, and resolveDashboardLayout() that merges
  persisted state with defaults so new widgets auto-appear (invisible
  by default) when the schema bumps.
- GET / PUT / DELETE /api/dashboard/widgets with strict Zod validation
  (version literal, widget-id enum, order range).
- Persisted in User.dashboardWidgetsJson (column added in 0021).

UI:
- src/components/settings/dashboard-layout-section.tsx — mounted under
  the new Personalization section alongside Thresholds. Local edit
  buffer so toggling/reordering doesn't fire a network call per click;
  single "Save" commits. Each widget has Up/Down buttons + visibility
  toggle, plus a reset-to-defaults button at the section header.
- 11 new dashboard.* i18n keys per locale.

Dashboard page integration is staged — widgets currently still render
per the old data-conditional rules. Wiring the saved layout through
/app/page.tsx is a follow-up so this commit stays focused on the
plumbing.

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

* feat(auth): locale-aware password-strength feedback

Completes the zxcvbn i18n migration by wiring the new
getZxcvbnTranslations(locale) helper into the client PasswordStrength
component and removing the obsolete zxcvbn-de helper.

- src/components/ui/password-strength.tsx reads the active locale from
  useTranslations() and memoizes the translator per locale — so
  changing language instantly retranslates open warnings/suggestions.
- src/lib/zxcvbn-de.ts deleted. Every consumer now routes through
  zxcvbn-i18n.ts which carries both EN and DE mappings.
- server-side checkPasswordStrength (already refactored earlier in this
  session) continues to accept locale; clients can pass it from the
  session.

No test impact — 162 pass.

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

* fix(dashboard): replace setState-in-effect with draft-overlay pattern

The dashboard-layout-section.tsx initial implementation synced server
state into local state via useEffect, which trips the project's strict
react-hooks/set-state-in-effect rule. Rewrote using a nullable draft
pattern: `layout` is derived as `draft ?? remote`, and only user edits
set the draft. Save/Reset clear the draft so the server copy wins
again. Cancel also clears instead of re-seeding from server.

No behavior change, no new tests — the component contract stays the
same. Lint back to baseline (2 errors / 7 warnings, both pre-existing).

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

* docs: v1.2 README + CHANGELOG (thresholds, glucose, dashboard, feedback, multi-provider AI)

- CHANGELOG 1.2.0 entry covering 5 new features + locale polish
- README Key Features expanded: glucose, custom thresholds, dashboard
  customization, multi-provider AI, built-in feedback
- Doctor Report description updated for locale-aware output

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

* feat(targets): glucose cards per context with effective range + unit conversion

Render one target card per logged glucose context (FASTING, POSTPRANDIAL,
RANDOM, BEDTIME) on /targets, sourcing the threshold band from
getEffectiveRange() so user overrides apply consistently. The targets API
returns glucose values canonical mg/dL; the page converts to the user's
glucoseUnit preference via convertGlucose() before display.

- Extend /api/insights/targets to include per-context glucose summaries
  (latest, 30-day average, range, classification) and surface glucoseUnit
  in the profile envelope.
- Expose glucoseUnit on /api/auth/me and AuthUser type.
- Targets page resolves the i18n label key, applies unit conversion to
  current/avg/range, and uses Droplet iconography.
- Add targets.glucoseFasting/Postprandial/Random/Bedtime keys to en + de.

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

* fix(security): address H1 (CSP nonce), H3 (SSRF), M1+M2 (XSS)

From the v1.2 full-repo security audit:

- **H1 / CWE-79** src/proxy.ts: CSP nonce was base64 of the UUID *string*
  form, which gives ~122 bits and a structured pattern (hyphens at fixed
  offsets). Replaced with 16 random bytes → proper 128-bit nonce.
- **H3 / CWE-918** src/app/api/admin/ai-settings/route.ts: the admin AI
  base URL only required "https://". A compromised admin cookie could
  point it at attacker.example and exfiltrate the bearer key on the
  next insight call. Now validates with URL parser, requires https://,
  and only accepts hosts on a small allowlist (OpenAI, Anthropic,
  Google AI, Mistral, Groq, OpenRouter). Ops can extend via
  `ADMIN_AI_BASE_URL_ALLOWLIST` env var.
- **M1 / CWE-79 (stored, off-platform)** src/app/api/bugreport/route.ts:
  bug-report description flowed to GitHub with only backtick/asterisk
  stripping. Now strips HTML tags entirely, neutralises dangerous
  markdown link schemes (javascript:/data:/vbscript:/file:), and wraps
  the whole body in a fenced code block so residual markdown renders
  literally.
- **M2 / CWE-79** Zod schemas for feedback + bugreport screenshot now
  require a base64 PNG/JPEG/WEBP/GIF data URL via regex. SVG
  deliberately excluded — it can carry inline JS that would execute
  when the admin inbox renders the `<img>` preview.

Medium rate-limiter semantics (audit M3) left as-is: the fixed-window
behaviour is acceptable for a single-tenant PWA and matches what ops
actually want. Remaining M4–M6 + L1–L7 are tracked for v1.3.

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

* feat(dashboard): glucose TrendCards + chart label support

Render one TrendCard per logged glucose context on the dashboard when
the user has the "glucose" widget enabled in their layout. Cards
respect the user's glucoseUnit preference via convertGlucose(); data
sources from the existing /api/analytics endpoint, which now emits a
glucoseByContext map alongside the summaries object.

- /api/analytics: include BLOOD_GLUCOSE in the type whitelist (so
  total counts include glucose) and add glucoseByContext containing a
  summarize() result per context.
- HealthChart: wire BLOOD_GLUCOSE into BASE_TYPE_LABEL_KEYS so the
  legend shows the localized "Blood glucose" label when the chart is
  invoked for that type.
- Dashboard page: fetch the dashboard layout, gate glucose cards on
  widget visibility AND data presence, render Droplet-icon TrendCards.

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

* fix(i18n): bugreport.subtitle no longer promises a GitHub issue

The visual QA pass flagged that bugreport.subtitle still said "a
GitHub issue will be created automatically" even though the banner
below the heading correctly explains the submission is stored locally
(built-in feedback system, GitHub is only an optional escalation).
Neutral copy now that matches the actual behaviour in both locales.

* feat(ai): user-level provider selection + test endpoint + UI

Adds the missing pieces for the multi-provider AI MVP that pair with
the AnthropicClient and LocalOpenAICompatibleClient already on disk:

* /api/user/ai-provider (GET + PATCH): user can pick OPENAI /
  ANTHROPIC / LOCAL / CHATGPT_OAUTH and store provider, model, baseUrl
  and per-provider API keys. Anthropic + local keys go through the
  existing AES-256-GCM crypto helper. Provider value is whitelisted.

* /api/ai/test (POST): rate-limited (5/min/user) round-trip that
  resolves the user's provider and asks for a tiny JSON ack so the UI
  surfaces real "does this config work?" feedback instead of a blind
  Save.

* settings/page.tsx: appends a per-user override card under the
  existing AI Insights section. Provider dropdown drives conditional
  fields (Anthropic key for ANTHROPIC, baseUrl + optional bearer for
  LOCAL, model for all), then Save / Test buttons.

* provider.test.ts: extended to cover the four new cases (ANTHROPIC
  with key, ANTHROPIC fallback, LOCAL, OPENAI routes through admin
  key, CHATGPT_OAUTH routes through Codex). Switched to
  resetAllMocks() in beforeEach to flush mockResolvedValueOnce queues
  between cases — the prior clearAllMocks() leaked them.

* local-client.test.ts: full happy / no-auth / bearer-auth / structured
  error / empty-content coverage mirroring the openai-client suite.

typecheck + tests green; lint stays at the pre-existing 2 errors / 7
warnings baseline (no new findings introduced).

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

* chore: integrate late agent additions (glucose PDF row + i18n)

* test(glucose): cover display-unit conversion edge cases

Lock in the precision contract for the targets/dashboard/PDF surfaces:
canonical mg/dL stays as integers, mmol/L collapses to 1-decimal, and
the ADA diabetes thresholds (70/99/126) round-trip to their published
SI values (3.9 / 5.5 / 7.0 mmol/L).

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

* fix(i18n): eliminate hydration flash by threading SSR-resolved locale to I18nProvider

Layout already resolves the initial locale from cookie + Accept-Language and
sets <html lang>. Until now the client I18nProvider re-detected locale at
mount via getSavedLocale() / detectSystemLocale(), causing a flash where
"Loading…" (EN) rendered first, then flipped to "Laden…" (DE) after
hydration. Pass initialLocale down layout -> Providers -> I18nProvider so
useState seeds the same locale the server used.

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

* fix(admin/feedback): return 409 when GitHub issue already published

Spec requires 409 Conflict (not 200 with flag) when the feedback row
already has gitHubIssueUrl set. Drops the alreadyPublished response
field in favour of a clear error status.

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

* fix(crypto,dashboard): L4 hard-fail short ENCRYPTION_KEY in prod + dashboard WIP from parallel agent

* refactor(effective-range): collapse duplicated glucose case arms

Extract glucoseRange() helper and toDate() normalizer. Use METRIC_BOUNDS
keys as the source of truth for getAllEffectiveRanges() metric list so
additions stay in one place.

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

* refactor(dashboard-layout-section): drop redundant setLayout alias

Remove the trivial setLayout wrapper and hoist the per-row label key
lookup so it runs once per widget instead of twice (span + aria-label).

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

* fix(security): MED-1 SSRF on user AI baseUrl + MED-2 publish-to-GitHub race

Final-audit blockers before v1.2 merge.

MED-1 — SSRF via user-level LOCAL AI baseUrl
Authenticated user could set aiBaseUrl to http://169.254.169.254/,
http://localhost:9090/metrics, or any internal host and get response
bodies echoed back via the bodyExcerpt error field. Route the value
through the existing isPublicUrl() helper (exported from
lib/validations/notifications.ts). Ops can opt-in to private hosts on a
specific instance via ALLOW_LOCAL_AI_PRIVATE_HOSTS=true — that's how
self-hosted Ollama / LM Studio stays usable without exposing the SSRF
surface by default.

MED-2 — Race in POST /api/admin/feedback/[id]/github
Two concurrent admin clicks both saw gitHubIssueUrl=null and both
created duplicate GitHub issues. Atomic claim now: a single
updateMany({ where: { id, gitHubIssueUrl: null }, data: PENDING }) —
only the caller whose count=1 proceeds. PENDING is released on failure
so retries work.

Tests + typecheck green. Baseline lint unchanged.

* fix: i18n completeness + UX polish (closes #73, #74) (#75)

* fix(i18n,ux): package A — missing keys, chart labels, query invalidation, admin error UX

Addresses bugs surfaced in issues #73 and #74 plus drift found during audit.

- i18n: add 21 missing translation keys (targets.* namespace was only
  partially populated after the English-first migration; common.reset and
  settings.configured were referenced but absent), plus common helpers
  (copy, copyDetails, retry, reportIssue) and admin.settingsSaveError.
- charts: rename points7Label/30Label/90Label from cryptic "7P/30P/90P"
  to "7 pts / 30 pts / 90 pts" in EN and "7 Pkt / 30 Pkt / 90 Pkt" in DE.
- queries: add src/lib/query-keys.ts as a single source of truth with
  measurement/mood/medication-dependent key sets. Wire them into
  measurement-form/list, mood-form/list, medication-form/card,
  intake-history-list, intake-timeline, medications page, and Withings
  sync in settings. Fixes dashboard not auto-refreshing after adding a
  measurement (old code only invalidated ["measurements"], not
  ["analytics"] / ["insights"] / ["insights","targets"]).
- admin: useUpdateSettings + updateUser mutations now surface real error
  messages via sonner toasts and invoke getApiErrorMessage() so saves
  fail loudly instead of silently. Success also gets a toast.

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

* refactor(i18n): package B — locale-aware formatters throughout

Removes the hard-coded de-DE locale that lingered from the German-first era
and makes every number, date, and time locale-aware. English stays the
default per user preference; German remains selectable.

New:
- src/lib/format-locale.ts exposes makeFormatters(locale) + a cached
  Europe/Berlin timezone. Covers number, integer, percent, date, dateShort,
  dateTime, time, dateWithWeekday, monthShort. Also exports
  parseLocaleFromAcceptLanguage() for server code.
- useFormatters() hook in @/lib/i18n/context resolves from the active
  locale, memoized per render.

Migrated (all Intl.NumberFormat("de-DE") / toLocaleString("de-DE") /
Intl.DateTimeFormat("de-DE") call sites):
- src/app/page.tsx (dashboard)
- src/app/insights/page.tsx
- src/app/admin/page.tsx
- src/app/settings/page.tsx (Withings last-sync, doctor-report caller)
- src/components/charts/trend-card.tsx (+ i18n "7d"/"30d" short labels)
- src/components/charts/health-chart.tsx
- src/components/medications/medication-card.tsx
- src/components/medications/medication-form.tsx
- src/components/measurements/measurement-list.tsx
- src/components/mood/mood-list.tsx
- src/components/insights/insight-status-card.tsx (also removed 5 German
  hard-coded strings with TODO: i18n markers — now translated)
- src/components/insights/insight-advisor-card.tsx (same — 10+ strings)

Server: reminder-check / reminder-worker / umami-test switched from
"de-DE" to neutral en-GB/en-US where the string is machine-compared rather
than user-facing. Bug-report GitHub issue titles use ISO-8601 UTC now —
the repo is English-speaking.

Next.js metadata:
- OpenGraph locale from "de_DE" → "en_US" with "de_DE" as
  alternateLocale.
- Root layout now reads healthlog-locale cookie (falls back to
  Accept-Language) and sets <html lang> server-side, avoiding an
  EN-flashes-then-DE hydration gap and fixing SEO/a11y.
- I18nProvider mirrors locale to a 1-year SameSite=Lax cookie so the next
  SSR pass is correct.

Doctor-report PDF:
- generateDoctorReportPDF(data, { t, locale }) now accepts injected
  translator + locale. All section titles, column headers, BMI / BP
  classifications, mood labels, and the footer disclaimer are
  i18n-driven. File name slug switches between gesundheitsbericht / health-report.
- 40+ new doctorReport.* keys in EN + DE.

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

* feat(ux): package C — typed passkey errors, bug-report gating, error boundary

Addresses issue #73 points 1, 5, 6 and lays groundwork for copy-paste
error reporting requested during review.

Passkey registration / login (#73.1):
- src/lib/passkey-errors.ts maps DOMException.name + SimpleWebAuthn code
  to specific i18n keys instead of swallowing every failure as
  "cancelled". Distinguishes NotAllowed/InvalidState/NotSupported/
  Security/Abort errors and falls back to a diagnostic message.
- settings passkey + login passkey flows both use describePasskeyError
  now, so origin mismatches and previous-registration collisions show
  actionable copy.
- 5 new passkey.* i18n keys in EN + DE.

Bug report (#73.5):
- New GET /api/bugreport/status reports {configured, hasToken, hasRepo,
  isAdmin}.
- /bugreport page now queries status first. When not configured it shows
  a notice (different copy for admins vs. regular users) with a direct
  link to the admin panel. Form is hidden, not silently broken.
- When POST /api/bugreport can't create issues it returns 503 instead
  of 500 with the same user-facing message.
- 4 new bugreport.* i18n keys.

Global error UX:
- src/components/error-details.tsx — reusable panel with retry + copy
  diagnostics (message, digest, stack (trimmed), URL, user-agent,
  request-id, locale, timestamp) + report-issue link. Clipboard fallback
  prompt covers insecure-context cases.
- src/app/error.tsx uses it.
- src/app/global-error.tsx is provider-free (no i18n dependency) since
  it replaces the root layout on catastrophic failures — hardcoded
  English + Dracula palette so it still looks like HealthLog.
- 2 new errorBoundary.* keys and 4 common.* keys (copy, copyDetails,
  retry, reportIssue).

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

* refactor(query-keys): collapse repeated invalidation blocks into invalidateKeys helper

Eleven mutation handlers repeated `Promise.all(keys.map(k => invalidate({queryKey: k})))`
verbatim. Adds `invalidateKeys(qc, keys)` to the existing query-keys factory and
adopts it everywhere, including replacing the local `invalidateMedicationRelated`
helper that medication-form.tsx had grown. Also fixes the import-sandwich that
helper introduced.

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

* refactor: small simplifications in i18n/UX changes

- passkey-errors: drop the typeof-name dance now that error.name is already string after the instanceof Error narrow
- doctor-report-pdf: replace nested gender ternary with a small lookup map; clarify unitFor's null-as-translation-marker convention
- dashboard page: route the analytics queries through the new queryKeys factory so future invalidation stays in one place
- health-chart: add missing fmt dependency to trendInfo useMemo (introduced when formatSigned was switched to the locale-aware formatter)

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

* fix(ai): capture upstream response body in OpenAI errors

A production insights burst at 12:19 UTC failed with "OpenAI request
failed (500)". The 500 was the upstream OpenAI HTTP status (confirmed
via logs), but we had no way to tell from logs whether it was an
OpenAI incident, a model-not-found, or a response_format rejection
because the response body was thrown away.

Now the error message includes the first 500 chars of the response body
and structured fields (httpStatus, upstream, model) that Wide Event
logging can attach. No behavior change for the happy path.

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

* fix(review): address code-review + security findings

Review round produced five real issues — fix them all, plus three nits
that were cheap enough to bundle.

Must-fix:
- security: error-details.tsx + global-error.tsx were copying
  window.location.href into the clipboard, which on OAuth callback
  routes (/api/withings/*, password reset) would leak code/state
  tokens into GitHub issues. Strip to pathname + searchParamCount.
- fe: query-keys.invalidateKeys now uses Promise.allSettled, so a
  transient invalidate failure doesn't abort the chain and no
  unhandled rejection leaks from void invalidateKeys(...) call sites.
- fe: resolveInitialLocale in app/layout.tsx wraps cookies()/headers()
  in try/catch — a dynamic-server error in the root layout now
  degrades to the "en" default instead of crashing into global-error.
- be: reminder-worker + reminder-check parseTimeToMinutes normalize
  "24:00" → "00:00" (some ICU builds emit that for midnight under
  en-GB). Prevents a 1440-minute comparison against schedule windows.
- security: openai-client error body excerpt redacts sk-* / Bearer
  tokens before logging, and moves the excerpt off Error.message into
  a structured field (bodyExcerpt) so log transports can filter it.
- i18n: /api/withings/connect German error message → English.
- i18n: /insights/page.tsx hardcoded "Fehler beim Laden" → t(...)

Hardening:
- /api/bugreport/status collapses to {configured, isAdmin} and adds
  a 30/min rate limit per user. Client interface updated.
- i18n/context.tsx: I18nProvider's initializer now reads the cookie
  (matches SSR) before falling back to localStorage or browser
  detection — eliminates the locale-flash hydration class.
- format.ts legacy helpers also read cookie first, matching SSR.

Tests:
- new src/lib/__tests__/format-locale.test.ts — 9 cases covering
  makeFormatters, parseLocaleFromAcceptLanguage, resolveIntlLocale
- new passkey-errors.test.ts — 8 cases covering every DOMException
  name and SimpleWebAuthn code we map
- new query-keys.test.ts — bundles + invalidateKeys allSettled
  semantics (incl. partial-failure test)

118 → 142 tests. All pass.

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

---------

Co-authored-by: Marc-André Bombeck <marc@bombeck.io>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: restore Link import lost in merge

* fix(bugreport): remove stale loading + not-configured blocks lost in merge

The v1.2 built-in feedback refactor made status informational only — form
always renders. Main's merge re-introduced the pre-refactor blocker blocks
referencing undefined statusLoading/AlertCircle and broke the build.

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

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Marc-André Bombeck <marc@bombeck.io>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Marc-André Bombeck <marc@bombeck.io>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-18 22:20:12 +02:00
9b59390224 fix: i18n completeness + UX polish (closes #73, #74) (#75)
* fix(i18n,ux): package A — missing keys, chart labels, query invalidation, admin error UX

Addresses bugs surfaced in issues #73 and #74 plus drift found during audit.

- i18n: add 21 missing translation keys (targets.* namespace was only
  partially populated after the English-first migration; common.reset and
  settings.configured were referenced but absent), plus common helpers
  (copy, copyDetails, retry, reportIssue) and admin.settingsSaveError.
- charts: rename points7Label/30Label/90Label from cryptic "7P/30P/90P"
  to "7 pts / 30 pts / 90 pts" in EN and "7 Pkt / 30 Pkt / 90 Pkt" in DE.
- queries: add src/lib/query-keys.ts as a single source of truth with
  measurement/mood/medication-dependent key sets. Wire them into
  measurement-form/list, mood-form/list, medication-form/card,
  intake-history-list, intake-timeline, medications page, and Withings
  sync in settings. Fixes dashboard not auto-refreshing after adding a
  measurement (old code only invalidated ["measurements"], not
  ["analytics"] / ["insights"] / ["insights","targets"]).
- admin: useUpdateSettings + updateUser mutations now surface real error
  messages via sonner toasts and invoke getApiErrorMessage() so saves
  fail loudly instead of silently. Success also gets a toast.

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

* refactor(i18n): package B — locale-aware formatters throughout

Removes the hard-coded de-DE locale that lingered from the German-first era
and makes every number, date, and time locale-aware. English stays the
default per user preference; German remains selectable.

New:
- src/lib/format-locale.ts exposes makeFormatters(locale) + a cached
  Europe/Berlin timezone. Covers number, integer, percent, date, dateShort,
  dateTime, time, dateWithWeekday, monthShort. Also exports
  parseLocaleFromAcceptLanguage() for server code.
- useFormatters() hook in @/lib/i18n/context resolves from the active
  locale, memoized per render.

Migrated (all Intl.NumberFormat("de-DE") / toLocaleString("de-DE") /
Intl.DateTimeFormat("de-DE") call sites):
- src/app/page.tsx (dashboard)
- src/app/insights/page.tsx
- src/app/admin/page.tsx
- src/app/settings/page.tsx (Withings last-sync, doctor-report caller)
- src/components/charts/trend-card.tsx (+ i18n "7d"/"30d" short labels)
- src/components/charts/health-chart.tsx
- src/components/medications/medication-card.tsx
- src/components/medications/medication-form.tsx
- src/components/measurements/measurement-list.tsx
- src/components/mood/mood-list.tsx
- src/components/insights/insight-status-card.tsx (also removed 5 German
  hard-coded strings with TODO: i18n markers — now translated)
- src/components/insights/insight-advisor-card.tsx (same — 10+ strings)

Server: reminder-check / reminder-worker / umami-test switched from
"de-DE" to neutral en-GB/en-US where the string is machine-compared rather
than user-facing. Bug-report GitHub issue titles use ISO-8601 UTC now —
the repo is English-speaking.

Next.js metadata:
- OpenGraph locale from "de_DE" → "en_US" with "de_DE" as
  alternateLocale.
- Root layout now reads healthlog-locale cookie (falls back to
  Accept-Language) and sets <html lang> server-side, avoiding an
  EN-flashes-then-DE hydration gap and fixing SEO/a11y.
- I18nProvider mirrors locale to a 1-year SameSite=Lax cookie so the next
  SSR pass is correct.

Doctor-report PDF:
- generateDoctorReportPDF(data, { t, locale }) now accepts injected
  translator + locale. All section titles, column headers, BMI / BP
  classifications, mood labels, and the footer disclaimer are
  i18n-driven. File name slug switches between gesundheitsbericht / health-report.
- 40+ new doctorReport.* keys in EN + DE.

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

* feat(ux): package C — typed passkey errors, bug-report gating, error boundary

Addresses issue #73 points 1, 5, 6 and lays groundwork for copy-paste
error reporting requested during review.

Passkey registration / login (#73.1):
- src/lib/passkey-errors.ts maps DOMException.name + SimpleWebAuthn code
  to specific i18n keys instead of swallowing every failure as
  "cancelled". Distinguishes NotAllowed/InvalidState/NotSupported/
  Security/Abort errors and falls back to a diagnostic message.
- settings passkey + login passkey flows both use describePasskeyError
  now, so origin mismatches and previous-registration collisions show
  actionable copy.
- 5 new passkey.* i18n keys in EN + DE.

Bug report (#73.5):
- New GET /api/bugreport/status reports {configured, hasToken, hasRepo,
  isAdmin}.
- /bugreport page now queries status first. When not configured it shows
  a notice (different copy for admins vs. regular users) with a direct
  link to the admin panel. Form is hidden, not silently broken.
- When POST /api/bugreport can't create issues it returns 503 instead
  of 500 with the same user-facing message.
- 4 new bugreport.* i18n keys.

Global error UX:
- src/components/error-details.tsx — reusable panel with retry + copy
  diagnostics (message, digest, stack (trimmed), URL, user-agent,
  request-id, locale, timestamp) + report-issue link. Clipboard fallback
  prompt covers insecure-context cases.
- src/app/error.tsx uses it.
- src/app/global-error.tsx is provider-free (no i18n dependency) since
  it replaces the root layout on catastrophic failures — hardcoded
  English + Dracula palette so it still looks like HealthLog.
- 2 new errorBoundary.* keys and 4 common.* keys (copy, copyDetails,
  retry, reportIssue).

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

* refactor(query-keys): collapse repeated invalidation blocks into invalidateKeys helper

Eleven mutation handlers repeated `Promise.all(keys.map(k => invalidate({queryKey: k})))`
verbatim. Adds `invalidateKeys(qc, keys)` to the existing query-keys factory and
adopts it everywhere, including replacing the local `invalidateMedicationRelated`
helper that medication-form.tsx had grown. Also fixes the import-sandwich that
helper introduced.

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

* refactor: small simplifications in i18n/UX changes

- passkey-errors: drop the typeof-name dance now that error.name is already string after the instanceof Error narrow
- doctor-report-pdf: replace nested gender ternary with a small lookup map; clarify unitFor's null-as-translation-marker convention
- dashboard page: route the analytics queries through the new queryKeys factory so future invalidation stays in one place
- health-chart: add missing fmt dependency to trendInfo useMemo (introduced when formatSigned was switched to the locale-aware formatter)

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

* fix(ai): capture upstream response body in OpenAI errors

A production insights burst at 12:19 UTC failed with "OpenAI request
failed (500)". The 500 was the upstream OpenAI HTTP status (confirmed
via logs), but we had no way to tell from logs whether it was an
OpenAI incident, a model-not-found, or a response_format rejection
because the response body was thrown away.

Now the error message includes the first 500 chars of the response body
and structured fields (httpStatus, upstream, model) that Wide Event
logging can attach. No behavior change for the happy path.

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

* fix(review): address code-review + security findings

Review round produced five real issues — fix them all, plus three nits
that were cheap enough to bundle.

Must-fix:
- security: error-details.tsx + global-error.tsx were copying
  window.location.href into the clipboard, which on OAuth callback
  routes (/api/withings/*, password reset) would leak code/state
  tokens into GitHub issues. Strip to pathname + searchParamCount.
- fe: query-keys.invalidateKeys now uses Promise.allSettled, so a
  transient invalidate failure doesn't abort the chain and no
  unhandled rejection leaks from void invalidateKeys(...) call sites.
- fe: resolveInitialLocale in app/layout.tsx wraps cookies()/headers()
  in try/catch — a dynamic-server error in the root layout now
  degrades to the "en" default instead of crashing into global-error.
- be: reminder-worker + reminder-check parseTimeToMinutes normalize
  "24:00" → "00:00" (some ICU builds emit that for midnight under
  en-GB). Prevents a 1440-minute comparison against schedule windows.
- security: openai-client error body excerpt redacts sk-* / Bearer
  tokens before logging, and moves the excerpt off Error.message into
  a structured field (bodyExcerpt) so log transports can filter it.
- i18n: /api/withings/connect German error message → English.
- i18n: /insights/page.tsx hardcoded "Fehler beim Laden" → t(...)

Hardening:
- /api/bugreport/status collapses to {configured, isAdmin} and adds
  a 30/min rate limit per user. Client interface updated.
- i18n/context.tsx: I18nProvider's initializer now reads the cookie
  (matches SSR) before falling back to localStorage or browser
  detection — eliminates the locale-flash hydration class.
- format.ts legacy helpers also read cookie first, matching SSR.

Tests:
- new src/lib/__tests__/format-locale.test.ts — 9 cases covering
  makeFormatters, parseLocaleFromAcceptLanguage, resolveIntlLocale
- new passkey-errors.test.ts — 8 cases covering every DOMException
  name and SimpleWebAuthn code we map
- new query-keys.test.ts — bundles + invalidateKeys allSettled
  semantics (incl. partial-failure test)

118 → 142 tests. All pass.

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

---------

Co-authored-by: Marc-André Bombeck <marc@bombeck.io>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:15:43 +02:00
dependabot[bot] cc273216be build(deps-dev): bump shadcn from 4.1.2 to 4.2.0 (#72)
Bumps [shadcn](https://github.com/shadcn-ui/ui/tree/HEAD/packages/shadcn) from 4.1.2 to 4.2.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.2.0/packages/shadcn)

---
updated-dependencies:
- dependency-name: shadcn
  dependency-version: 4.2.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-04-12 16:04:48 +00:00
dependabot[bot] b15438db99 build(deps): bump @prisma/client from 7.6.0 to 7.7.0 (#71)
Bumps [@prisma/client](https://github.com/prisma/prisma/tree/HEAD/packages/client) from 7.6.0 to 7.7.0.
- [Release notes](https://github.com/prisma/prisma/releases)
- [Commits](https://github.com/prisma/prisma/commits/7.7.0/packages/client)

---
updated-dependencies:
- dependency-name: "@prisma/client"
  dependency-version: 7.7.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-04-12 16:03:04 +00:00
dependabot[bot] 26697c20c3 build(deps-dev): bump vitest from 4.1.2 to 4.1.4 (#69)
Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 4.1.2 to 4.1.4.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.4/packages/vitest)

---
updated-dependencies:
- dependency-name: vitest
  dependency-version: 4.1.4
  dependency-type: direct:development
  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-04-12 16:02:25 +00:00
dependabot[bot] 448c37d232 build(deps): bump react from 19.2.4 to 19.2.5 (#63)
Bumps [react](https://github.com/facebook/react/tree/HEAD/packages/react) from 19.2.4 to 19.2.5.
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.5/packages/react)

---
updated-dependencies:
- dependency-name: react
  dependency-version: 19.2.5
  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-04-12 16:00:37 +00:00
dependabot[bot] a8478990f8 build(deps-dev): bump prettier from 3.8.1 to 3.8.2 (#68)
Bumps [prettier](https://github.com/prettier/prettier) from 3.8.1 to 3.8.2.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.8.1...3.8.2)

---
updated-dependencies:
- dependency-name: prettier
  dependency-version: 3.8.2
  dependency-type: direct:development
  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-04-12 15:59:21 +00:00
dependabot[bot] 02e8553fb9 build(deps-dev): bump typescript from 5.9.3 to 6.0.2 (#64)
Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.9.3 to 6.0.2.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.9.3...v6.0.2)

---
updated-dependencies:
- dependency-name: typescript
  dependency-version: 6.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-12 15:58:47 +00:00
dependabot[bot] 15e2862434 build(deps): bump @tanstack/react-query from 5.96.2 to 5.99.0 (#70)
Bumps [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) from 5.96.2 to 5.99.0.
- [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.99.0/packages/react-query)

---
updated-dependencies:
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.99.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-04-12 15:58:36 +00:00
dependabot[bot] 0e521b6fb9 build(deps): bump dotenv from 17.4.0 to 17.4.1 (#67)
Bumps [dotenv](https://github.com/motdotla/dotenv) from 17.4.0 to 17.4.1.
- [Changelog](https://github.com/motdotla/dotenv/blob/master/CHANGELOG.md)
- [Commits](https://github.com/motdotla/dotenv/compare/v17.4.0...v17.4.1)

---
updated-dependencies:
- dependency-name: dotenv
  dependency-version: 17.4.1
  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-04-12 15:57:15 +00:00
dependabot[bot] 533486d44e build(deps-dev): bump @types/node from 25.5.2 to 25.6.0 (#66)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 25.5.2 to 25.6.0.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 25.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-04-12 15:56:50 +00:00
dependabot[bot] 7b4ad10905 build(deps): bump next from 16.2.2 to 16.2.3 (#65)
Bumps [next](https://github.com/vercel/next.js) from 16.2.2 to 16.2.3.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v16.2.2...v16.2.3)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.2.3
  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-04-12 15:56:28 +00:00
dependabot[bot] b3d5f43a1f build(deps-dev): bump eslint-config-next from 16.2.2 to 16.2.3 (#62)
Bumps [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) from 16.2.2 to 16.2.3.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v16.2.3/packages/eslint-config-next)

---
updated-dependencies:
- dependency-name: eslint-config-next
  dependency-version: 16.2.3
  dependency-type: direct:development
  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-04-12 15:55:04 +00:00
dependabot[bot] 7555fe5b35 build(deps): bump prisma from 7.6.0 to 7.7.0 (#61)
Bumps [prisma](https://github.com/prisma/prisma/tree/HEAD/packages/cli) from 7.6.0 to 7.7.0.
- [Release notes](https://github.com/prisma/prisma/releases)
- [Commits](https://github.com/prisma/prisma/commits/7.7.0/packages/cli)

---
updated-dependencies:
- dependency-name: prisma
  dependency-version: 7.7.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-04-12 15:54:21 +00:00
dependabot[bot] 75e3d686b5 build(deps): bump @prisma/adapter-pg from 7.6.0 to 7.7.0 (#60)
Bumps [@prisma/adapter-pg](https://github.com/prisma/prisma/tree/HEAD/packages/adapter-pg) from 7.6.0 to 7.7.0.
- [Release notes](https://github.com/prisma/prisma/releases)
- [Commits](https://github.com/prisma/prisma/commits/7.7.0/packages/adapter-pg)

---
updated-dependencies:
- dependency-name: "@prisma/adapter-pg"
  dependency-version: 7.7.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-04-12 15:53:53 +00:00
Marc-André BombeckandClaude Opus 4.6 dc8942a220 release: v1.1.0 — AI Insights Overhaul
- ChatGPT proxy integration (no API billing)
- 7 medical insight prompts (ESC/ESH, WHO, DGE, DEGAM)
- 10+ new computed health metrics (RPP, seasonal BP, body composition, sleep/activity)
- 5 cross-metric correlations
- New InsightStatusCard + InsightAdvisorCard UI components
- Provider abstraction with automatic failover
- 3 security audits applied
- Rate limit restored to 2/hour

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 13:55:03 +02:00
Marc-André BombeckandClaude Opus 4.6 9932f88666 refactor(ui): simplify and polish insight card components
Replace hardcoded hex color values with Dracula CSS var tokens
(text-dracula-purple, text-dracula-green, etc.) for theme consistency.
Add prefers-reduced-motion support for insight animations, aria-expanded
on the data quality toggle, and extract nested ternary into lookup map.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 13:37:47 +02:00
Marc-André BombeckandClaude Opus 4.6 a4c8f28ecb feat(ui): integrate InsightStatusCard into insights page
Replace 7 inline status text sections (general, blood pressure, weight,
pulse, mood, medication compliance, BMI) with the new InsightStatusCard
component. This adds consistent Card-based rendering with loading states,
provider detection, cache indicators, and timestamps.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 13:34:27 +02:00
Marc-André BombeckandClaude Opus 4.6 eaca8674f5 feat(ui): add premium InsightAdvisorCard and InsightStatusCard components
New insight card components for the health advisor feature:
- InsightAdvisorCard: full structured display with primary recommendation,
  findings, correlations, recommendations, and collapsible data quality
- InsightStatusCard: compact per-metric status card with classification indicator
- Subtle fade-in animation for insight cards in globals.css

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 13:30:53 +02:00
Marc-André Bombeck c730845775 chore: temporarily increase insight rate limit to 10/h for testing 2026-04-06 13:23:52 +02:00
Marc-André BombeckandClaude Opus 4.6 3bb3837938 feat(insights): comprehensive upgrade — RPP, seasonal BP, body composition, sleep/activity, fitness ladder, clinical milestones
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 12:56:12 +02:00
Marc-André BombeckandClaude Opus 4.6 7b5fabe46c fix(insights): QA fixes — paired BP targets, stdDev, pulse pressure, personal tone, schema enrichment
- Fix pctInTarget to use paired-reading logic (both sys AND dia must be in range simultaneously)
- Add sdSys30/sdDia30 (BP standard deviation) and pulsePressure30 (arterial stiffness marker)
- Add mood scale descriptor for AI context
- Extend medication data with compliance90, 30-day streak window, and category field
- Rewrite base system prompt: personal advisor tone, second person, positive-first, "One Thing" pattern
- Add conditional correlation instructions to all domain prompts (only mention when |r| > 0.4)
- Enrich InsightResult schema with insightType, classificationLabel, primaryRecommendation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 12:48:28 +02:00
Marc-André BombeckandClaude Opus 4.6 64c72d241c feat(insights): enrich features with correlations, mood, historical comparisons and improve prompts
Add 90-day averages, all-time stats, mood data, cross-metric Pearson correlations,
historical comparisons (7d vs previous 30d baseline), and age/gender context to the
AI feature extraction. Update all prompts with correlation analysis instructions,
clinical significance thresholds, and temporal layer comparisons.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 12:37:39 +02:00
Marc-André BombeckandClaude Opus 4.6 360a11523e feat(ui): replace API key input with ChatGPT OAuth connect button
Settings now shows "Mit ChatGPT verbinden" button instead of API key
input field. Shows connection status, connected-since date, and
disconnect option. Handles OAuth callback query params with toast.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 11:56:35 +02:00
Marc-André BombeckandClaude Opus 4.6 d3149170cd security: fix rate limiting, error message leaks, decrypt crash, key preview
- H1: Add checkRateLimit to OAuth authorize/callback/disconnect + admin AI settings
- H2: Fix decrypt("") crash in provider.ts onTokenRefresh by using decrypt() directly
- H3: Truncate error messages in codex-oauth, codex-client, openai-client to prevent leaking upstream response bodies
- M5: Fix keyPreview to show last 4 chars of decrypted key instead of ciphertext prefix

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 00:47:05 +02:00
Marc-André BombeckandClaude Opus 4.6 50911ad801 chore: cleanup legacy OpenAI references, update CSP, verify build
- Add https://chatgpt.com to connect-src CSP directive for Codex OAuth token exchange
- Fix eslint no-explicit-any errors in codex-client.ts and provider.test.ts
- Fix unused variable warnings in authorize route
- All 118 tests pass, build succeeds

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 00:42:46 +02:00
Marc-André BombeckandClaude Opus 4.6 5a71f9b36a refactor(insights): use provider abstraction in all 7 insight generators
Replace direct OpenAI API calls with resolveProvider() in all insight
generators (general, blood-pressure, weight, pulse, bmi, medication-
compliance, mood). Each generator now uses domain-specific prompts from
@/lib/ai/prompts/ and the AIProvider.generateCompletion() interface.
hasKey -> hasProvider in return signatures and all UI consumers.
Remove openaiKeyEncrypted references from data reset routes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 00:42:46 +02:00
Marc-André BombeckandClaude Opus 4.6 cd8508b6ad feat(prompts): add medical insight prompts with ESC/ESH/WHO/DGE guidelines
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 00:42:46 +02:00
Marc-André BombeckandClaude Opus 4.6 249a2c74ba refactor(insights): use provider abstraction in settings and generate routes
Replace direct OpenAI API calls with resolveProvider() abstraction.
Settings route now returns codexStatus/codexConnectedAt/hasAdminKey
instead of hasKey. Generate route validates AI response with
insightResultSchema and logs providerType/model. Also fixes
AppSettings lookup id from 1 to "singleton" in provider.ts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 00:42:46 +02:00
Marc-André BombeckandClaude Opus 4.6 fde3a0941a feat(oauth): add Codex OAuth authorize/callback/disconnect routes and admin AI settings
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 00:42:46 +02:00
Marc-André BombeckandClaude Opus 4.6 401e78e01c feat(ai): add provider resolution with codex → admin-key → none hierarchy
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 00:42:46 +02:00
Marc-André BombeckandClaude Opus 4.6 0f2d258f26 feat(ai): add Codex client with auto-refresh on 401
TDD implementation of CodexClient that calls the Codex responses
endpoint with OAuth Bearer token and automatically retries once
after refreshing the token on 401 responses.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 00:42:46 +02:00
Marc-André BombeckandClaude Opus 4.6 80ad0b8851 feat(ai): add Codex OAuth PKCE utilities and token management
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 00:42:46 +02:00
Marc-André BombeckandClaude Opus 4.6 88db398aca feat(ai): add OpenAI client with custom base URL support
TDD implementation of OpenAIClient that wraps the OpenAI chat completions
API with configurable base URL (supports OpenRouter and other compatible APIs).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 00:42:46 +02:00
Marc-André BombeckandClaude Opus 4.6 f18ded4860 feat(ai): add AI provider types and InsightResult Zod schema
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 00:42:46 +02:00
Marc-André BombeckandClaude Opus 4.6 3982cfa082 feat(db): add Codex OAuth tokens and admin AI settings, remove openaiKeyEncrypted
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 00:42:46 +02:00
Marc-André BombeckandClaude Opus 4.6 e71162e5f2 docs: add Codex OAuth + medical insights implementation plan
18 tasks across 7 phases: schema, AI provider abstraction, OAuth routes,
medical prompts, generator refactor, cleanup, deploy + security audit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 00:42:46 +02:00
Marc-André BombeckandClaude Opus 4.6 8ec66e4e21 docs: add Codex OAuth + medical insights design spec
Replace OpenAI API-key model with ChatGPT Codex OAuth authentication
and improve medical insight prompts with guideline-based analysis.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 00:42:46 +02:00
dependabot[bot] 1eff57c05a build(deps-dev): bump shadcn from 4.1.1 to 4.1.2 (#56)
Bumps [shadcn](https://github.com/shadcn-ui/ui/tree/HEAD/packages/shadcn) from 4.1.1 to 4.1.2.
- [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.1.2/packages/shadcn)

---
updated-dependencies:
- dependency-name: shadcn
  dependency-version: 4.1.2
  dependency-type: direct:development
  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-04-05 15:58:57 +00:00
dependabot[bot] 5d36012694 build(deps): bump pg-boss from 12.14.0 to 12.15.0 (#59)
Bumps [pg-boss](https://github.com/timgit/pg-boss) from 12.14.0 to 12.15.0.
- [Release notes](https://github.com/timgit/pg-boss/releases)
- [Commits](https://github.com/timgit/pg-boss/compare/12.14.0...12.15.0)

---
updated-dependencies:
- dependency-name: pg-boss
  dependency-version: 12.15.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-04-05 15:57:49 +00:00
dependabot[bot] 470a19e47a build(deps): bump @tanstack/react-query from 5.95.2 to 5.96.2 (#58)
Bumps [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) from 5.95.2 to 5.96.2.
- [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.96.2/packages/react-query)

---
updated-dependencies:
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.96.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-04-05 15:57:10 +00:00
dependabot[bot] 8be1af7b4b build(deps-dev): bump @types/node from 25.5.0 to 25.5.2 (#57)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 25.5.0 to 25.5.2.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 25.5.2
  dependency-type: direct:development
  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-04-05 15:56:46 +00:00
dependabot[bot] cb97004932 build(deps): bump next from 16.2.1 to 16.2.2 (#55)
Bumps [next](https://github.com/vercel/next.js) from 16.2.1 to 16.2.2.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v16.2.1...v16.2.2)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.2.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-04-05 15:55:28 +00:00
dependabot[bot] 3e0d189eaf build(deps-dev): bump eslint-config-next from 16.2.1 to 16.2.2 (#54)
Bumps [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) from 16.2.1 to 16.2.2.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v16.2.2/packages/eslint-config-next)

---
updated-dependencies:
- dependency-name: eslint-config-next
  dependency-version: 16.2.2
  dependency-type: direct:development
  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-04-05 15:54:48 +00:00
dependabot[bot] 4e89234fc1 build(deps): bump dotenv from 17.3.1 to 17.4.0 (#53)
Bumps [dotenv](https://github.com/motdotla/dotenv) from 17.3.1 to 17.4.0.
- [Changelog](https://github.com/motdotla/dotenv/blob/master/CHANGELOG.md)
- [Commits](https://github.com/motdotla/dotenv/compare/v17.3.1...v17.4.0)

---
updated-dependencies:
- dependency-name: dotenv
  dependency-version: 17.4.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-04-05 15:54:12 +00:00
dependabot[bot] b8e1bf1954 build(deps): bump react-hook-form from 7.72.0 to 7.72.1 (#52)
Bumps [react-hook-form](https://github.com/react-hook-form/react-hook-form) from 7.72.0 to 7.72.1.
- [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.72.0...v7.72.1)

---
updated-dependencies:
- dependency-name: react-hook-form
  dependency-version: 7.72.1
  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-04-05 15:53:48 +00:00
dependabot[bot] a64a773b1e build(deps-dev): bump vitest from 4.1.0 to 4.1.2 (#51)
Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 4.1.0 to 4.1.2.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.2/packages/vitest)

---
updated-dependencies:
- dependency-name: vitest
  dependency-version: 4.1.2
  dependency-type: direct:development
  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-03-29 16:00:59 +00:00
dependabot[bot] 80cb8a1fc9 build(deps): bump @prisma/client from 7.5.0 to 7.6.0 (#49)
Bumps [@prisma/client](https://github.com/prisma/prisma/tree/HEAD/packages/client) from 7.5.0 to 7.6.0.
- [Release notes](https://github.com/prisma/prisma/releases)
- [Commits](https://github.com/prisma/prisma/commits/7.6.0/packages/client)

---
updated-dependencies:
- dependency-name: "@prisma/client"
  dependency-version: 7.6.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-03-29 16:00:58 +00:00
dependabot[bot] c9e3d268f1 build(deps): bump @prisma/adapter-pg from 7.5.0 to 7.6.0 (#50)
Bumps [@prisma/adapter-pg](https://github.com/prisma/prisma/tree/HEAD/packages/adapter-pg) from 7.5.0 to 7.6.0.
- [Release notes](https://github.com/prisma/prisma/releases)
- [Commits](https://github.com/prisma/prisma/commits/7.6.0/packages/adapter-pg)

---
updated-dependencies:
- dependency-name: "@prisma/adapter-pg"
  dependency-version: 7.6.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-03-29 15:59:31 +00:00
dependabot[bot] a358e6f9e3 build(deps): bump react-hook-form from 7.71.2 to 7.72.0 (#41)
Bumps [react-hook-form](https://github.com/react-hook-form/react-hook-form) from 7.71.2 to 7.72.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.71.2...v7.72.0)

---
updated-dependencies:
- dependency-name: react-hook-form
  dependency-version: 7.72.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-03-29 15:58:42 +00:00
dependabot[bot] fbb6cbbca1 build(deps-dev): bump shadcn from 4.0.8 to 4.1.1 (#46)
Bumps [shadcn](https://github.com/shadcn-ui/ui/tree/HEAD/packages/shadcn) from 4.0.8 to 4.1.1.
- [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.1.1/packages/shadcn)

---
updated-dependencies:
- dependency-name: shadcn
  dependency-version: 4.1.1
  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-03-29 15:57:17 +00:00
dependabot[bot] b339f95b7b build(deps): bump recharts from 3.8.0 to 3.8.1 (#48)
Bumps [recharts](https://github.com/recharts/recharts) from 3.8.0 to 3.8.1.
- [Release notes](https://github.com/recharts/recharts/releases)
- [Changelog](https://github.com/recharts/recharts/blob/main/CHANGELOG.md)
- [Commits](https://github.com/recharts/recharts/compare/v3.8.0...v3.8.1)

---
updated-dependencies:
- dependency-name: recharts
  dependency-version: 3.8.1
  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-03-29 15:56:33 +00:00
dependabot[bot] bb5c5dbd8d build(deps-dev): bump tailwindcss from 4.2.0 to 4.2.2 (#42)
Bumps [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) from 4.2.0 to 4.2.2.
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.2.2/packages/tailwindcss)

---
updated-dependencies:
- dependency-name: tailwindcss
  dependency-version: 4.2.2
  dependency-type: direct:development
  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-03-29 15:56:25 +00:00
dependabot[bot] e7eea8090e build(deps): bump lucide-react from 0.577.0 to 1.7.0 (#47)
Bumps [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) from 0.577.0 to 1.7.0.
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.7.0/packages/lucide-react)

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-version: 1.7.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-29 15:55:57 +00:00
dependabot[bot] d21edd5cc4 build(deps): bump @tanstack/react-query from 5.94.5 to 5.95.2 (#45)
Bumps [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) from 5.94.5 to 5.95.2.
- [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.95.2/packages/react-query)

---
updated-dependencies:
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.95.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-03-29 15:55:07 +00:00
dependabot[bot] 66ec83ac77 build(deps): bump prisma from 7.5.0 to 7.6.0 (#44)
Bumps [prisma](https://github.com/prisma/prisma/tree/HEAD/packages/cli) from 7.5.0 to 7.6.0.
- [Release notes](https://github.com/prisma/prisma/releases)
- [Commits](https://github.com/prisma/prisma/commits/7.6.0/packages/cli)

---
updated-dependencies:
- dependency-name: prisma
  dependency-version: 7.6.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-03-29 15:54:20 +00:00
dependabot[bot] 71a12f1347 build(deps): bump @tanstack/react-query from 5.90.21 to 5.94.5 (#40)
Bumps [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) from 5.90.21 to 5.94.5.
- [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.94.5/packages/react-query)

---
updated-dependencies:
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.94.5
  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-03-22 15:56:25 +00:00
dependabot[bot] d434c9b838 build(deps): bump next from 16.1.6 to 16.2.1 (#39)
Bumps [next](https://github.com/vercel/next.js) from 16.1.6 to 16.2.1.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v16.1.6...v16.2.1)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.2.1
  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-03-22 15:56:02 +00:00
dependabot[bot] a1c2357baa build(deps-dev): bump eslint-config-next from 16.1.6 to 16.2.1 (#38)
Bumps [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) from 16.1.6 to 16.2.1.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v16.2.1/packages/eslint-config-next)

---
updated-dependencies:
- dependency-name: eslint-config-next
  dependency-version: 16.2.1
  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-03-22 15:55:31 +00:00
dependabot[bot] 4e47aa6045 build(deps): bump jspdf from 4.2.0 to 4.2.1 (#37)
Bumps [jspdf](https://github.com/parallax/jsPDF) from 4.2.0 to 4.2.1.
- [Release notes](https://github.com/parallax/jsPDF/releases)
- [Changelog](https://github.com/parallax/jsPDF/blob/master/RELEASE.md)
- [Commits](https://github.com/parallax/jsPDF/compare/v4.2.0...v4.2.1)

---
updated-dependencies:
- dependency-name: jspdf
  dependency-version: 4.2.1
  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-03-22 15:54:58 +00:00
dependabot[bot] d5987a2e21 build(deps): bump recharts from 3.7.0 to 3.8.0 (#36)
Bumps [recharts](https://github.com/recharts/recharts) from 3.7.0 to 3.8.0.
- [Release notes](https://github.com/recharts/recharts/releases)
- [Changelog](https://github.com/recharts/recharts/blob/main/CHANGELOG.md)
- [Commits](https://github.com/recharts/recharts/compare/v3.7.0...v3.8.0)

---
updated-dependencies:
- dependency-name: recharts
  dependency-version: 3.8.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-03-22 15:54:17 +00:00
dependabot[bot] a4879989d4 build(deps-dev): bump @tailwindcss/postcss from 4.2.1 to 4.2.2 (#35)
Bumps [@tailwindcss/postcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-postcss) from 4.2.1 to 4.2.2.
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.2.2/packages/@tailwindcss-postcss)

---
updated-dependencies:
- dependency-name: "@tailwindcss/postcss"
  dependency-version: 4.2.2
  dependency-type: direct:development
  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-03-22 15:53:51 +00:00
dependabot[bot] 3214e28737 build(deps): bump @simplewebauthn/browser from 13.2.2 to 13.3.0 (#30)
Bumps [@simplewebauthn/browser](https://github.com/MasterKale/SimpleWebAuthn/tree/HEAD/packages/browser) from 13.2.2 to 13.3.0.
- [Release notes](https://github.com/MasterKale/SimpleWebAuthn/releases)
- [Changelog](https://github.com/MasterKale/SimpleWebAuthn/blob/master/CHANGELOG.md)
- [Commits](https://github.com/MasterKale/SimpleWebAuthn/commits/v13.3.0/packages/browser)

---
updated-dependencies:
- dependency-name: "@simplewebauthn/browser"
  dependency-version: 13.3.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-03-15 16:04:33 +00:00
dependabot[bot] 6e2ab06bca build(deps): bump @prisma/adapter-pg from 7.4.2 to 7.5.0 (#34)
Bumps [@prisma/adapter-pg](https://github.com/prisma/prisma/tree/HEAD/packages/adapter-pg) from 7.4.2 to 7.5.0.
- [Release notes](https://github.com/prisma/prisma/releases)
- [Commits](https://github.com/prisma/prisma/commits/7.5.0/packages/adapter-pg)

---
updated-dependencies:
- dependency-name: "@prisma/adapter-pg"
  dependency-version: 7.5.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-03-15 16:03:52 +00:00
dependabot[bot] fe5ba735c8 build(deps): bump prisma from 7.4.2 to 7.5.0 (#32)
Bumps [prisma](https://github.com/prisma/prisma/tree/HEAD/packages/cli) from 7.4.2 to 7.5.0.
- [Release notes](https://github.com/prisma/prisma/releases)
- [Commits](https://github.com/prisma/prisma/commits/7.5.0/packages/cli)

---
updated-dependencies:
- dependency-name: prisma
  dependency-version: 7.5.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-03-15 16:02:25 +00:00
dependabot[bot] ccadace560 build(deps): bump react-hook-form from 7.71.1 to 7.71.2 (#33)
Bumps [react-hook-form](https://github.com/react-hook-form/react-hook-form) from 7.71.1 to 7.71.2.
- [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.71.1...v7.71.2)

---
updated-dependencies:
- dependency-name: react-hook-form
  dependency-version: 7.71.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-03-15 16:02:24 +00:00
dependabot[bot] 4d325df3af build(deps-dev): bump @types/node from 20.19.33 to 25.5.0 (#29)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 20.19.33 to 25.5.0.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 25.5.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-15 16:01:42 +00:00
dependabot[bot] 73045b60f0 build(deps-dev): bump vitest from 4.0.18 to 4.1.0 (#31)
Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 4.0.18 to 4.1.0.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.0/packages/vitest)

---
updated-dependencies:
- dependency-name: vitest
  dependency-version: 4.1.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-03-15 16:00:14 +00:00
dependabot[bot] 03da63ad84 build(deps): bump react-dom from 19.2.3 to 19.2.4 (#28)
Bumps [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom) from 19.2.3 to 19.2.4.
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.4/packages/react-dom)

---
updated-dependencies:
- dependency-name: react-dom
  dependency-version: 19.2.4
  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-03-15 15:58:21 +00:00
dependabot[bot] 15f8fccb30 build(deps-dev): bump prettier-plugin-tailwindcss from 0.6.14 to 0.7.2 (#25)
Bumps [prettier-plugin-tailwindcss](https://github.com/tailwindlabs/prettier-plugin-tailwindcss) from 0.6.14 to 0.7.2.
- [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.6.14...v0.7.2)

---
updated-dependencies:
- dependency-name: prettier-plugin-tailwindcss
  dependency-version: 0.7.2
  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-03-15 15:58:05 +00:00
dependabot[bot] a21cc77db5 build(deps): bump lucide-react from 0.575.0 to 0.577.0 (#27)
Bumps [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) from 0.575.0 to 0.577.0.
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/0.577.0/packages/lucide-react)

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-version: 0.577.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-03-15 15:56:30 +00:00
dependabot[bot] d6671a741d build(deps): bump @prisma/client from 7.4.2 to 7.5.0 (#26)
Bumps [@prisma/client](https://github.com/prisma/prisma/tree/HEAD/packages/client) from 7.4.2 to 7.5.0.
- [Release notes](https://github.com/prisma/prisma/releases)
- [Commits](https://github.com/prisma/prisma/commits/7.5.0/packages/client)

---
updated-dependencies:
- dependency-name: "@prisma/client"
  dependency-version: 7.5.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-03-15 15:56:03 +00:00
dependabot[bot] edc15307f4 build(deps-dev): bump shadcn from 3.8.5 to 4.0.8 (#24)
Bumps [shadcn](https://github.com/shadcn-ui/ui/tree/HEAD/packages/shadcn) from 3.8.5 to 4.0.8.
- [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.0.8/packages/shadcn)

---
updated-dependencies:
- dependency-name: shadcn
  dependency-version: 4.0.8
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-15 15:54:58 +00:00
dependabot[bot] 342af0ecc2 build(deps): bump pg-boss from 12.13.0 to 12.14.0 (#23)
Bumps [pg-boss](https://github.com/timgit/pg-boss) from 12.13.0 to 12.14.0.
- [Release notes](https://github.com/timgit/pg-boss/releases)
- [Commits](https://github.com/timgit/pg-boss/compare/12.13.0...12.14.0)

---
updated-dependencies:
- dependency-name: pg-boss
  dependency-version: 12.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-03-15 15:54:27 +00:00
dependabot[bot] 48253313d5 build(deps): bump @simplewebauthn/server from 13.2.3 to 13.3.0 (#22)
Bumps [@simplewebauthn/server](https://github.com/MasterKale/SimpleWebAuthn/tree/HEAD/packages/server) from 13.2.3 to 13.3.0.
- [Release notes](https://github.com/MasterKale/SimpleWebAuthn/releases)
- [Changelog](https://github.com/MasterKale/SimpleWebAuthn/blob/master/CHANGELOG.md)
- [Commits](https://github.com/MasterKale/SimpleWebAuthn/commits/v13.3.0/packages/server)

---
updated-dependencies:
- dependency-name: "@simplewebauthn/server"
  dependency-version: 13.3.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-03-15 15:54:04 +00:00
dependabot[bot] 5971f860a4 build(deps-dev): bump @tailwindcss/postcss from 4.2.0 to 4.2.1 (#21)
Bumps [@tailwindcss/postcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-postcss) from 4.2.0 to 4.2.1.
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.2.1/packages/@tailwindcss-postcss)

---
updated-dependencies:
- dependency-name: "@tailwindcss/postcss"
  dependency-version: 4.2.1
  dependency-type: direct:development
  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-03-15 15:53:38 +00:00
Marc-André Bombeck 0c0c345e82 i18n: migrate from German-first to English-first default locale
Switch defaultLocale from "de" to "en" and make English the code-level
language throughout. All ~145 hardcoded German strings translated:

- API error messages (52 routes) now return English
- Zod validation messages in English
- Classification/analytics functions return English categories
- Notification type labels in English
- Component strings use i18n t() calls (heatmap, mood chart,
  medication form, intake history, password strength, insights card)
- ~50 new i18n keys in both de.json and en.json

Route migration:
- /stimmung → /mood, /zielwerte → /targets (new pages)
- 301 redirects in proxy.ts for legacy URLs
- Sidebar + bottom nav updated

Bump version to 1.0.1.
2026-03-12 21:31:48 +01:00
262 changed files with 26980 additions and 4857 deletions

No files matched your search

+92 -23
View File
@@ -1,37 +1,106 @@
# Database
DATABASE_URL="postgresql://healthlog:healthlog@localhost:5432/healthlog?schema=public"
# =============================================================================
# HealthLog — Environment Configuration
# =============================================================================
# Copy this file to .env and fill in the values.
# $ cp .env.example .env
#
# Required variables: DATABASE_URL or POSTGRES_PASSWORD, SESSION_SECRET,
# ENCRYPTION_KEY, API_TOKEN_HMAC_KEY
#
# Generate the three secrets with:
# $ openssl rand -hex 32 # any UNIX shell
# $ node -e 'console.log(require("crypto").randomBytes(32).toString("hex"))'
#
# All secrets must be 64-character hex strings (= 32 bytes).
# =============================================================================
# App
# -----------------------------------------------------------------------------
# Database
# -----------------------------------------------------------------------------
# When using docker-compose.yml: the app container reaches Postgres via the
# service name "db", so the hostname is "db" — NOT "localhost".
# When developing without Docker (`pnpm dev` against a host-installed Postgres):
# change the hostname to "localhost" and adjust the credentials.
DATABASE_URL="postgresql://healthlog:CHANGE-ME@db:5432/healthlog?schema=public"
# Postgres password — used both by the db service AND by DATABASE_URL above.
# Pick a strong random password, e.g. `openssl rand -base64 24`.
POSTGRES_PASSWORD="CHANGE-ME"
# -----------------------------------------------------------------------------
# App URL
# -----------------------------------------------------------------------------
# The public URL where users reach the app. Behind a reverse proxy with TLS,
# this looks like https://healthlog.example.com. For local dev, leave default.
NEXT_PUBLIC_APP_URL="http://localhost:3000"
APP_URL="http://localhost:3000"
# Node environment: "development" for `pnpm dev`, "production" for Docker.
NODE_ENV="development"
# Session
SESSION_SECRET="change-me-to-a-random-64-char-hex-string"
# Encryption (for tokens, sensitive notes)
ENCRYPTION_KEY="change-me-to-a-random-64-char-hex-string"
# -----------------------------------------------------------------------------
# Required Secrets — generate with `openssl rand -hex 32`
# -----------------------------------------------------------------------------
# Signs session cookies. Rotating it logs out everyone.
SESSION_SECRET="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
# HMAC key for API token hashing
API_TOKEN_HMAC_KEY="change-me-to-a-random-64-char-hex-string"
# AES-256-GCM key for encrypting Withings tokens, GitHub PATs, AI provider
# keys, and VAPID private keys at rest. Rotate carefully — old ciphertexts
# need re-encryption.
ENCRYPTION_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
# Withings OAuth (M5)
# HMAC-SHA256 key for hashing external-API Bearer tokens before storing them
# in the database. Rotating it invalidates all issued tokens.
API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
# -----------------------------------------------------------------------------
# Withings OAuth (optional — for automatic scale/BP/activity sync)
# -----------------------------------------------------------------------------
# Register an app at https://developer.withings.com/ and paste the credentials
# here. Per-user OAuth credentials can also be configured in Settings.
# WITHINGS_CLIENT_ID=""
# WITHINGS_CLIENT_SECRET=""
# WITHINGS_REDIRECT_URI=""
# WITHINGS_WEBHOOK_SECRET="" # random secret for webhook callback URL hardening
# WITHINGS_REDIRECT_URI="https://healthlog.example.com/api/withings/callback"
# WITHINGS_WEBHOOK_SECRET="" # any random string — used to harden the webhook URL
# Telegram webhook hardening (optional but recommended)
# -----------------------------------------------------------------------------
# Telegram (optional — webhook hardening)
# -----------------------------------------------------------------------------
# Set to verify incoming Telegram updates. The bot token itself is configured
# per-user in Settings (encrypted at rest).
# TELEGRAM_WEBHOOK_SECRET=""
# OpenAI (optional, user-provided keys stored encrypted)
# No server-side key needed - users bring their own
# Structured Logging (Wide Events)
# LOG_LEVEL="info" # debug | info | warn | error
# LOG_SAMPLE_RATE="1.0" # 0.0–1.0, fraction of successful requests to log
# LOG_SLOW_THRESHOLD_MS="3000" # Requests slower than this are always logged
# LOG_INCLUDE_STACK="true" # Include stack traces in error events
# LOKI_ENDPOINT="" # Optional: Loki Push API (e.g. http://loki:3100)
# LOKI_USERNAME="" # Optional: Loki basic auth username
# LOKI_PASSWORD="" # Optional: Loki basic auth password
# -----------------------------------------------------------------------------
# AI Provider (optional)
# -----------------------------------------------------------------------------
# Users bring their own API keys via Settings. No server-side key needed by
# default. The admin can configure a fallback key in the Admin Panel.
# -----------------------------------------------------------------------------
# Structured Logging — Wide Events (optional, sensible defaults)
# -----------------------------------------------------------------------------
# Levels: debug | info | warn | error
# LOG_LEVEL="info"
# Sample rate for successful requests (0.0 = sample nothing, 1.0 = log all).
# Errors and slow requests are ALWAYS logged regardless of this setting.
# LOG_SAMPLE_RATE="1.0"
# Slow-request threshold in milliseconds — anything slower is always kept.
# LOG_SLOW_THRESHOLD_MS="3000"
# Include stack traces on error events. Disable in environments where logs
# leave the host unencrypted.
# LOG_INCLUDE_STACK="true"
# Optional: ship Wide Events to Loki. Leave blank for stdout-only logging.
# LOKI_ENDPOINT="http://loki:3100"
# LOKI_USERNAME=""
# LOKI_PASSWORD=""
+78
View File
@@ -0,0 +1,78 @@
name: Build & Publish Docker Image
on:
push:
branches: [main]
tags: ["v*"]
pull_request:
branches: [main]
workflow_dispatch:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
# Cancel in-progress runs when a new commit is pushed to the same ref.
concurrency:
group: docker-publish-${{ github.ref }}
cancel-in-progress: true
jobs:
build-and-push:
name: Build & push multi-arch image
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU (multi-arch)
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# Skip login on PRs from forks — they don't have package:write.
# Image is built but not pushed in that case.
- name: Log in to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Compute image tags & labels
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable={{is_default_branch}}
type=sha,prefix=sha-,format=short
labels: |
org.opencontainers.image.title=HealthLog
org.opencontainers.image.description=Self-hosted health-tracking PWA
org.opencontainers.image.licenses=AGPL-3.0-only
org.opencontainers.image.source=https://github.com/${{ github.repository }}
- name: Build & push image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: true
sbom: true
+3 -1
View File
@@ -30,11 +30,13 @@ jobs:
- name: ESLint
run: pnpm lint
# Non-blocking on the existing settings page monolith — its set-state-
# in-effect violations need the 1.4.0 settings-split refactor to fix
# cleanly. Will flip to blocking once settings/page.tsx is broken up.
continue-on-error: true
- name: TypeScript strict check
run: pnpm typecheck
continue-on-error: true
- name: Unit tests
run: pnpm test
+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 = []
+28 -18
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**: Feature-complete (v1.0.0). 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,9 +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)
│ ├── zielwerte/page.tsx # Target values dashboard
│ └── api/ # ~60 API route files (see docs/api.md)
│ ├── 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/ # 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
@@ -92,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
@@ -108,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
@@ -116,8 +126,8 @@ messages/
├── de.json # German translations (primary UI language)
└── en.json # English translations
prisma/
├── schema.prisma # Database schema (22 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)
@@ -128,10 +138,10 @@ docs.healthlog.dev # External documentation site
## Key Conventions
- **UI language is German**. All user-facing text in German. Code, comments, docs in English.
- **Default locale is English**. All code-level strings in English. UI text via i18n (`t()` calls) with German + English translations.
- **i18n**: Use `useTranslations()` hook → `t("section.key")`. Supports parameter interpolation: `t("key", { count: 5 })`. Messages in `messages/de.json` + `messages/en.json`.
- **API response format**: Always `{ data, error, meta }` via `apiSuccess(data)` / `apiError(message, status)` from `src/lib/api-response.ts`.
- **Auth check pattern**: `const session = await getSession(); if (!session) return apiError("Nicht angemeldet", 401);`
- **Auth check pattern**: `const session = await getSession(); if (!session) return apiError("Not authenticated", 401);`
- **Timezone**: `Europe/Berlin` for display, UTC in database.
- **Encryption**: Sensitive data (Withings tokens, API keys, VAPID private keys) encrypted with AES-256-GCM via `src/lib/crypto.ts`.
- **Dracula colors**: Use CSS variables `var(--dracula-purple)`, `var(--dracula-cyan)`, `var(--dracula-green)`, `var(--dracula-orange)`, `var(--dracula-pink)`, `var(--dracula-red)`, `var(--dracula-yellow)`, `var(--dracula-fg)`, `var(--dracula-comment)` for chart/graph elements.
@@ -166,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
@@ -191,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.
@@ -206,7 +216,7 @@ These are hard-won lessons. Ignoring them will cause errors:
## Database Models (Prisma)
22 models: `User`, `Passkey`, `Session`, `AuthChallenge`, `Measurement`, `Medication`, `MedicationSchedule`, `MedicationIntakeEvent`, `ReminderPhaseConfig`, `TelegramReminderMessage`, `TelegramScheduledDeletion`, `ApiToken`, `WithingsConnection`, `MoodEntry`, `AppSettings`, `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
+386
View File
@@ -0,0 +1,386 @@
# 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
- **Glucose tiles on the dashboard rendered the raw i18n key
`targets.glucoseFasting` instead of the translated label** (closes
#108). Both `messages/en.json` and `messages/de.json` had two
top-level `targets` blocks; `JSON.parse` silently keeps the last
occurrence, and that block was missing the four glucose labels. The
duplicate is now collapsed into a single block. A duplicate
`bugreport.bugTitlePlaceholder` shadowed inside `bugreport` was
cleaned up too. Two further keys (`dashboard.sleep`,
`dashboard.steps`) were missing from both locales and were falling
back to hard-coded English; both are now translated.
- **New i18n locale-integrity test**
(`src/lib/__tests__/i18n-locale-integrity.test.ts`) fails the build
on duplicate keys at any nesting depth and on key drift between
`en` and `de` — closes the structural gap that let the duplicate
`targets` block ship in the first place.
### Changed
- **Screenshot upload removed from the bug-report form** (also part
of #108). The form previously accepted an image attachment that
was stored in the local DB but never reached the published GitHub
issue — GitHub does not accept inline base64 data URIs in issue
bodies and offers no public API to attach images to an issue
programmatically. Rather than ship misleading
"a screenshot was attached" placeholder text in the resulting
issue, the upload UI is now gone and the placeholder note is no
longer added when promoting feedback. The `screenshotBase64`
column and the admin-side preview of previously-submitted
screenshots are unchanged — existing reports keep their
attachments locally. We plan to revisit a real screenshot pipeline
in a future release.
## [1.3.1] — 2026-04-27
### Fixed
- **Compose env-var validation no longer breaks Coolify-style deploys.**
`docker-compose.yml` previously used `${VAR:?required}` shell-parameter
syntax for the four secrets and `POSTGRES_PASSWORD`. Some hosting
platforms (Coolify in particular) parse compose files eagerly and
store the _fallback error string_ (`"POSTGRES_PASSWORD is required"`)
as the literal env-var value when `POSTGRES_PASSWORD` was unset,
which then collided with `DATABASE_URL` and broke the running app
with `P1000: Authentication failed`. Compose now uses plain `${VAR}`
interpolation; validation moved into `docker-entrypoint.sh`, which
fails fast with a clear stderr message listing the unset variables.
### Notes
If you upgraded an existing Compose stack from 1.2.x → 1.3.0 and hit
the `POSTGRES_PASSWORD is required` literal-as-value bug, set
`POSTGRES_PASSWORD` in your environment to whatever your existing
Postgres data volume was originally initialised with (likely
`healthlog` if you started from a pre-1.2.1 release), then redeploy.
Postgres only honours `POSTGRES_PASSWORD` on first volume init — the
existing user keeps the original password regardless of env changes.
## [1.3.0] — 2026-04-27
### Added — Body composition + targeted hardening
- **Total body water and bone mass as measurement types** (closes #89). New
enum values `TOTAL_BODY_WATER` and `BONE_MASS`, both stored canonically
in kilograms (matches Withings hydration/bone-mass measures and Health
Connect's `TotalBodyWaterRecord` / `BoneMassRecord`). Migration is
purely additive (`ALTER TYPE ... ADD VALUE`) — safe to apply against
any 1.2.x database without downtime.
- **Withings sync picks both up automatically.** The Withings client now
maps measure type `77` (hydration / water mass) and `88` (bone mass).
Anyone with a Withings Body+ scale and an active connection will see
the new metrics flowing in on the next sync without any extra config.
- **Doctor-report PDF includes both new types** in the vital-signs table
when data exists, with locale-aware labels in English and German.
- **Dashboard widgets registered for both** (default-invisible — opt in
via Settings → Dashboard layout).
### Security
- **SSRF guard hardened** (`isPublicUrl`). The previous implementation
used `parseInt` with permissive prefix checks like `h.startsWith("10.")`
which let `010.0.0.1` slip through — and worse, the WHATWG URL parser
silently normalises `010.0.0.1` to `8.0.0.1` (octal interpretation), a
real bypass on naive checks. The new guard adds a pre-URL leading-zero
check on the raw input, a strict IPv4 parser, and proper IPv6
bracket / loopback / link-local handling. Now blocks `127.0.0.0/8`,
`0.0.0.0/8`, `10.0.0.0/8`, `169.254.0.0/16`, `172.16.0.0/12`,
`192.168.0.0/16`, `100.64.0.0/10` (CGNAT), `::1`, `fe80::/10`,
`fc00::/7`. Comprehensive regression tests included.
- **GitHub PAT redacted from logged error bodies.** When a feedback
escalation to GitHub fails, the response body was being passed
verbatim into `getEvent()?.addWarning(...)` — flowing to Loki. The
body is now stripped of the configured token before logging.
- **Per-user threshold writes are rate-limited** (30 writes / 5 min) to
make audit-log enumeration unattractive. Audit-logging itself was
already there.
### Performance
- **N+1 in `/api/insights/comprehensive` fixed.** The medication-compliance
loop hit Postgres once per active medication. It now batches into a
single `medicationId IN (...)` query and groups in memory. Latency
improvement scales linearly with the user's active medication count.
### Reliability
- **pg-boss draining on SIGTERM/SIGINT.** `docker stop`, Coolify redeploys,
and Kubernetes pod terminations now trigger `boss.stop({ graceful: true,
timeout: 30s })` so in-flight handlers finish instead of being killed.
Previously, pending handlers could be lost or replayed on restart.
- **CI now blocks on TypeScript strict checks.** `continue-on-error: true`
removed for typecheck. Tests already were blocking. ESLint stays
non-blocking for now — the existing `settings/page.tsx` monolith has
long-standing `react-hooks/set-state-in-effect` violations whose proper
fix lives in the 1.4.0 settings-split refactor; a blocking lint here
would just gate every PR until that refactor lands. Required cleaning
up two `any` types in `api-handler.ts` (justified inline — Next.js
variadic handler signature constrained by `Promise<Response>` return).
### Polish
- **Bottom-nav touch targets** sized to WCAG 2.5.5 minimum (44×44 CSS px).
Visual icon stays at 20 px so the design doesn't shift.
- **Phase-config dialog** marks the decorative coloured dot `aria-hidden`
because the redundant text label already conveys the phase to screen
readers. No more meaningless `image` node announcements.
- **Admin-status labels** for "Web Push" and "Bug Report" now go through
`t()` (new `admin.integrationWebPush` / `admin.integrationBugReport`
keys in en + de). They were the last hard-coded English strings on
the admin page.
### What's _not_ in this release (tracked for later)
- **Onboarding redesign** (dashboard-first empty-state flow + persistent
Getting Started checklist) and the **typed `apiClient` wrapper** that
underpins it are tracked for a focused 1.4.0 cycle. The 1.2.1 patch
already closed the acute symptoms of #87 (silent-failure toast +
default schedule), so the redesign is now a proper UI investment
rather than a bug-fix.
- **Withings sync is mapping both new measures**, but **a dedicated
Bearer-auth ingest endpoint for external pipelines** (n8n + Health
Connect, requested in #89) ships in 1.4.0 alongside the API-token
flow.
## [1.2.1] — 2026-04-27
### Fixed
- **Onboarding**: Medications added during onboarding are now actually persisted (closes #87). The wizard previously sent an empty `schedules: []` array, the server-side validation rejected it with a 422, the client never checked `response.ok`, and the user was redirected to the dashboard as if everything had worked. Onboarding now wraps each step in `try/catch`, surfaces failures via toast, and attaches a default reminder window (`08:00–09:00 daily`) so the medication actually persists. A hint under the medication list explains the default.
- **Docker setup** (closes #88):
- `docker-compose.yml` now uses `ports: "3000:3000"` (was `expose: "3000"`, which made the app unreachable from the host).
- `POSTGRES_PASSWORD` is a single env var that both the Postgres service and `DATABASE_URL` interpolate, so they cannot drift apart.
- `.env.example` now points at the in-container hostname `db:5432` (was `localhost:5432`, which never resolves inside the app container).
- **Documentation**:
- `package.json` synced to 1.2.0 (was lagging on 1.1.0).
- `CLAUDE.md` and `AGENTS.md` corrected to 23 models (the `Feedback` model added in v1.2 was missing from the count).
- `README.md` Quick Start gives a realistic time estimate, generates the four secrets in one block straight into `.env`, and points reverse-proxy users at the docs.
### Added — Tooling & Supply Chain
- **Pre-built multi-arch images on GHCR**: `.github/workflows/docker-publish.yml` now builds `linux/amd64` + `linux/arm64` images on every push to `main` and on every `v*` tag, publishing to `ghcr.io/mbombeck/healthlog`. Self-hosters no longer need a build toolchain — `docker compose pull && docker compose up -d` is enough. The bundled `docker-compose.yml` references the published image with a `build:` block as fallback for contributors.
- **Supply-chain attestations**: each published image carries a SLSA build provenance statement and a Software Bill of Materials. `SECURITY.md` documents how to verify them and how to pin a specific version.
- **Documentation single source of truth**: `getting-started/installation.mdx` is now the canonical setup guide (mirrors the bundled `docker-compose.yml`); `self-hosting/docker.mdx` slimmed to image internals + ops notes only. The landing page's Quick Start terminal block now includes the secrets-generation step (was missing).
### Notes
This is a patch release that closes the install/onboarding friction reported in #87 and #88. The bigger user-facing changes (additional measurement types like total body water and bone mass per #89, full onboarding redesign, typed API client) are tracked for `1.3.0`.
## [1.2.0] — 2026-04-18
### Added — Personalization, Glucose & Multi-Provider AI
- **Per-user custom thresholds**: Override the computed default ranges (BP, BMI, glucose, pulse) with values from your clinician. Audit-logged with previous/new values and timestamps. Doctor Report PDF flags custom ranges and prints both your target and the standard guideline value.
- **Blood glucose tracking**: New metric with `fasting`, `postprandial`, `random`, and `bedtime` contexts. Display unit switch between mg/dL and mmol/L (lossless conversion). Context-aware classification per ADA 2024 / DGIM. Per-context charting on dashboard and Doctor Report PDF.
- **Dashboard customization**: Show/hide and drag-to-reorder every dashboard widget. Per-user preference, reset-to-defaults button. Layout persists across the same user on the same device.
- **Built-in feedback system**: New in-app Send Feedback flow (Bug / Feature / Question / Other) with anonymized system info attachment. Stored in HealthLog's own database — no GitHub config required. Optional `Escalate to GitHub` button for admins who configure a PAT.
- **Multi-provider AI insights**: Provider abstraction extended with **Anthropic Claude** and **local OpenAI-compatible endpoints** (Ollama, LM Studio, vLLM, LiteLLM) alongside OpenAI. Per-user provider selection. Local endpoints keep all health data on your network.
- **Locale-aware UI polish (English-first)**: Numbers, dates, glucose units, BP, weight, and BMI all formatted via `useFormatters()` from the active locale. Doctor Report PDF and AI insight prompts now respect locale end-to-end (no hand-rolled `Intl.*` with fixed locales).
### Changed
- Reference range computation extracted into a dedicated `src/lib/health/thresholds.ts` module with computed defaults and override resolution.
- AI provider routing reworked to dispatch by `provider` field on the user record; OpenAI remains the default for legacy users.
- Dashboard route renders widgets from `UserDashboardLayout` model when present, otherwise falls back to the default order.
- Doctor Report PDF: locale-aware headers, glucose section, custom-range badges.
### Security
- GitHub PAT for feedback escalation stored AES-256-GCM encrypted in the database (never as env var).
- Local AI endpoint URLs validated against SSRF (no localhost/RFC1918 unless explicitly allowed by admin).
- Custom threshold writes rate-limited and audit-logged with IP.
## [1.1.0] — 2026-04-06
### Added — AI Insights Overhaul
- **ChatGPT Proxy Integration**: Insights now run through a local openai-oauth proxy using your ChatGPT subscription — no separate API billing required
- **Admin AI Fallback**: Admins can configure a global API key (OpenAI/OpenRouter) as fallback for users without their own connection
- **Provider Abstraction**: New `src/lib/ai/` module with pluggable providers (Codex OAuth, Admin Key, None) and automatic failover
- **Medical Insight Prompts**: 7 specialized prompts based on ESC/ESH 2023, WHO, DGE, and DEGAM guidelines
- Blood Pressure: ESC/ESH classification, morning risk ladder (J-HOP), pulse pressure, seasonal variation
- Weight: 5%/10% milestone recognition, plateau detection, body composition divergence
- Pulse: Fitness interpretation ladder, 80-100 bpm elevated-risk band, rate-pressure product
- BMI: Age-adjusted DEGAM classification for 65+
- Medication Compliance: Chronotherapy hints, mood-adherence risk prediction, 90-day tracking
- General Status: Cross-domain synthesis with cardiovascular risk stratification
- Mood: Bidirectional correlations with vitals and adherence
- **Enriched Feature Extraction**:
- Sleep duration and activity steps (previously ignored)
- Rate-Pressure Product (pulse × systolic BP, myocardial demand indicator)
- Body composition divergence flag (weight stable + body fat rising)
- Mood-adherence risk predictor
- Seasonal BP variation (winter vs summer, requires >180 days data)
- BP standard deviation (sdSys30/sdDia30) as variability risk marker
- Pulse pressure (arterial stiffness marker)
- 5 cross-metric Pearson correlations (weight↔BP, pulse↔BP, mood↔pulse, mood↔BP, mood↔weight)
- 90-day averages and all-time statistics for all metrics
- Historical comparison (current 7d vs previous 30d baseline)
- **New UI Components**:
- `InsightStatusCard`: Compact per-metric status card with classification indicator and fade-in animation
- `InsightAdvisorCard`: Premium structured card with findings, correlations, recommendations (ready for integration)
- **OAuth Routes**: `/api/auth/codex/authorize`, `/callback`, `/disconnect` for ChatGPT connection
- **Admin AI Settings**: `/api/admin/ai-settings` for global API key management
### Changed
- Insight prompts now use personal advisor tone ("dein Blutdruck") with positive-first pattern
- Reasoning scaffold in system prompt (What changed? → Why? → What to do?)
- Conditional correlation instructions (only mention when |r| > 0.4)
- InsightResult schema enriched with `insightType`, `primaryRecommendation`, `classificationLabel`
- BP target calculation now uses paired readings (both sys AND dia must be in range simultaneously)
- Medication streak tracking extended from 7-day to 30-day window
- CSP updated to allow `chatgpt.com` for OAuth flow
### Security
- Rate limiting on all OAuth and admin endpoints
- PKCE (S256) + state parameter for OAuth CSRF protection
- Encrypted token storage at rest (AES-256-GCM)
- Error messages truncated to prevent upstream response body leaks
- Admin key preview shows last 4 chars of decrypted key (not ciphertext prefix)
- `prefers-reduced-motion` support for insight card animations
### Removed
- `openaiKeyEncrypted` field from User model (replaced by provider abstraction)
- Direct OpenAI API calls from insight generators (now routed through provider)
- Legacy API key input in settings UI (replaced by ChatGPT connect button)
## [1.0.1] — Previous release
+118
View File
@@ -0,0 +1,118 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project
HealthLog — a personal health-tracking PWA (weight, blood pressure, pulse, mood, medication compliance) with Withings integration, moodLog.app sync, Dracula-themed UI, mobile-first design, offline caching, and doctor report PDF export.
## Commands
```bash
# Development
pnpm dev # Start dev server (http://localhost:3000)
pnpm build # Production build
pnpm lint # ESLint
pnpm format # Prettier format
pnpm format:check # Prettier check
pnpm typecheck # TypeScript strict check
# Tests
pnpm test # Run all tests (vitest)
pnpm test:watch # Watch mode
# Database
pnpm db:generate # Generate Prisma client
pnpm db:migrate # Create & apply migration (dev)
pnpm db:migrate:deploy # Apply migrations (production)
pnpm db:push # Push schema without migration (prototyping)
pnpm db:studio # Prisma Studio GUI
# Docker
docker compose up -d # Start app + postgres
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 (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`.
- **Zod v4** (`zod/v4`) for all input validation (API routes and forms).
- **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.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).
- **Proxy route protection** — `src/proxy.ts` checks session cookie, enforces auth on all non-public paths. Next.js 16 uses proxy.ts (not middleware.ts).
## Key Conventions
- Default locale is **English**. All code-level strings in English. UI text via i18n `t()` with German + English translations.
- 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 HMAC-SHA256 keyed by `API_TOKEN_HMAC_KEY`).
- Rate limiting (in-memory sliding window) on auth and external-facing endpoints.
## File Layout
- `src/app/` — pages and API routes (App Router)
- `src/components/ui/` — shadcn/ui primitives
- `src/components/layout/` — shell (sidebar-nav, topbar, mobile-nav, bottom-nav)
- `src/components/medications/` — medication form, card, timeline
- `src/components/measurements/` — measurement form, list
- `src/components/mood/` — mood form, mood list
- `src/components/charts/` — Recharts wrappers
- `src/lib/logging/` — Wide Events structured logging (types, config, event-builder, context, sampler, transports, background)
- `src/lib/api-handler.ts` — apiHandler wrapper, requireAuth(), requireAdmin(), HttpError
- `src/lib/` — server utilities (db, crypto, auth, analytics, export, rate-limit, gravatar)
- `src/lib/export.ts` — CSV/JSON export formatting (measurements, medications, mood)
- `src/lib/doctor-report-pdf.ts` — client-side PDF generation (jsPDF, European medical format)
- `src/lib/withings/` — Withings OAuth client and sync service
- `src/lib/notifications/` — dispatcher, types, senders (telegram, ntfy, web-push)
- `src/lib/jobs/` — pg-boss reminder worker (reminders, insights, data backups)
- `src/lib/analytics/` — trend calculations, compliance, correlations
- `src/lib/i18n/` — i18n context, config, locale detection
- `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 (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/` — 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
- Import Prisma client from `@/generated/prisma/client` (not `@/generated/prisma`)
- Prisma 7 does NOT support `url = env("...")` in schema.prisma — use `prisma.config.ts`
- Next.js 16 uses `proxy.ts` (not `middleware.ts`) — having both causes build error
- SimpleWebAuthn v13 has no `@simplewebauthn/server/script/deps` — define Transport type inline
- `zxcvbn-typescript` uses default export only (no named `zxcvbnAsync`/`zxcvbnOptions`)
- pg-boss v12: `{ PgBoss }` named import, `localConcurrency` instead of `teamSize`, handler receives `Job<T>[]` array
- **i18n**: All UI text uses `useTranslations()` hook with `t("section.key")`. Messages in `messages/en.json` (English, default) + `messages/de.json` (German, user-selectable). Keys missing in the active locale fall back to English; missing from both surfaces the raw key — the `src/lib/__tests__/i18n*.test.ts` suite guards against that. Numbers/dates go through `useFormatters()` — never hand-roll `Intl.*` with a fixed locale.
- **Notifications**: 3 channels (Telegram, ntfy, Web Push). Dispatcher at `src/lib/notifications/dispatcher.ts`. Opt-out model (all events enabled by default)
- **Gravatar**: Server-side SHA-256 hash via `src/lib/gravatar.ts`. URL returned in `/api/auth/me` response as `gravatarUrl` field
- **Sidebar**: Collapsible (localStorage-persisted). Settings mode with section shortcuts. User section at bottom with three-dot dropdown menu
- **ESLint**: Strict `react-hooks/set-state-in-effect` rule — avoid `setState` inside `useEffect`. Use lazy `useState()` initializer for localStorage reads
- **Proxy route protection**: `src/proxy.ts` checks `healthlog_session` cookie; PUBLIC_PATHS array bypasses auth. Security headers (CSP, HSTS, X-Frame-Options) applied to all responses
- **PWA safe area**: Root layout has `viewportFit: "cover"`. Bottom-nav uses `pb-[env(safe-area-inset-bottom)]` for iOS safe area
- **Doctor report PDF**: Client-side via `src/lib/doctor-report-pdf.ts` (jsPDF). Data from `POST /api/doctor-report`. European medical format, German terminology
- **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
+192 -93
View File
@@ -37,7 +37,11 @@ Most health apps lock your data behind proprietary clouds, push subscriptions, a
## Key Features
**Health Metrics** -- Track weight, blood pressure, pulse, body fat, sleep, and steps with interactive trend charts, moving averages, and traffic-light ranges based on ESC/ESH 2018 guidelines.
**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.
**Customizable Dashboard** -- Show, hide, and drag-to-reorder every widget. Per-user layout with reset-to-defaults.
**Mood Logging** -- 5-point scale with tags, notes, and trend analytics. Syncs automatically from moodLog.app via webhook.
@@ -45,37 +49,42 @@ Most health apps lock your data behind proprietary clouds, push subscriptions, a
**Withings Integration** -- OAuth2 device sync for scales, blood pressure monitors, and activity trackers with automatic deduplication.
**AI-Powered Insights** -- Bring your own OpenAI API key. Personalized analysis across blood pressure, weight, pulse, mood, BMI, and medication adherence. Cached daily to minimize API usage.
**Multi-Provider AI Insights** -- Pick OpenAI, Anthropic Claude, or any local OpenAI-compatible endpoint (Ollama, LM Studio, vLLM). BYOK or admin-shared key. Cached daily. Local endpoints keep all data on your network.
**Doctor Report PDF Export** -- Generate professional medical reports client-side. European format with vital sign summaries, BP/BMI classification, compliance rates, and optional AI analysis.
**Doctor Report PDF Export** -- Generate professional medical reports client-side. Locale-aware (English/German), with vital sign summaries, BP/BMI/glucose classification, compliance rates, custom-threshold badges, and optional AI analysis.
**Built-in Feedback** -- Send bug reports and feature requests from inside the app. Stored in your HealthLog database — no GitHub config required. Optional GitHub escalation for admins.
**PWA with Offline Support** -- Installable on iOS and Android. Service worker with intelligent caching strategies for reliable offline access.
**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** -- Full German and English UI with 1000+ translation keys. Browser-based locale 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.
---
## Quick Start
Plan ~5 minutes for a working install. The bundled `docker-compose.yml` pulls a pre-built multi-arch image (`linux/amd64` + `linux/arm64`) from [GitHub Container Registry](https://github.com/MBombeck/HealthLog/pkgs/container/healthlog); no build step required for self-hosters. Contributors who want to test local changes can `docker compose up --build`.
```bash
git clone https://github.com/MBombeck/HealthLog.git
cd HealthLog
cp .env.example .env
```
Generate the required secrets:
Generate the four required secrets and paste them into `.env`:
```bash
openssl rand -hex 32 # SESSION_SECRET
openssl rand -hex 32 # ENCRYPTION_KEY
openssl rand -hex 32 # API_TOKEN_HMAC_KEY
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
```
Add them to `.env`, then:
Then bring the stack up:
```bash
docker compose up -d
@@ -83,25 +92,27 @@ docker compose up -d
Open **http://localhost:3000**. The first registered user becomes admin.
> Behind a reverse proxy (Caddy / Traefik / Nginx) for TLS, set `NEXT_PUBLIC_APP_URL` and `APP_URL` to your public URL in `.env` before starting. See [Self-Hosting → Reverse Proxy](https://docs.healthlog.dev/self-hosting/reverse-proxy/) for examples.
---
## Tech Stack
| Layer | Technology |
|-------|-----------|
| Framework | Next.js 16 (App Router, React Server Components) |
| Language | TypeScript (strict mode) |
| Database | PostgreSQL 16 + Prisma 7 (22 models) |
| Job Queue | pg-boss 12 (reminders, insights, backups) |
| UI | shadcn/ui, Tailwind CSS 4, Radix UI, Lucide Icons |
| Charts | Recharts 3 |
| Data Fetching | TanStack Query 5 |
| Forms | React Hook Form 7 + Zod 4 |
| Auth | SimpleWebAuthn 13, Argon2id |
| Notifications | Telegram Bot API, ntfy, Web Push (VAPID) |
| PDF | jsPDF (client-side generation) |
| Testing | Vitest 4 |
| Deployment | Docker (multi-stage Alpine) |
| Layer | Technology |
| ------------- | ------------------------------------------------- |
| Framework | Next.js 16 (App Router, React Server Components) |
| Language | TypeScript (strict mode) |
| 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 |
| Data Fetching | TanStack Query 5 |
| Forms | React Hook Form 7 + Zod 4 |
| Auth | SimpleWebAuthn 13, Argon2id |
| Notifications | Telegram Bot API, ntfy, Web Push (VAPID) |
| PDF | jsPDF (client-side generation) |
| Testing | Vitest 4 |
| Deployment | Docker (multi-stage Alpine) |
---
@@ -124,23 +135,24 @@ HealthLog is designed for people who take data ownership seriously.
### Required
| Variable | Description |
|----------|-------------|
| `DATABASE_URL` | PostgreSQL connection string |
| `SESSION_SECRET` | 64-char hex string for session signing |
| `ENCRYPTION_KEY` | 64-char hex string for AES-256-GCM |
| `API_TOKEN_HMAC_KEY` | 64-char hex string for API token hashing |
| Variable | Description |
| -------------------- | ------------------------------------------------------------- |
| `POSTGRES_PASSWORD` | Password for the bundled Postgres service (Docker Compose) |
| `DATABASE_URL` | PostgreSQL connection string (uses `POSTGRES_PASSWORD` above) |
| `SESSION_SECRET` | 64-char hex string for session signing |
| `ENCRYPTION_KEY` | 64-char hex string for AES-256-GCM |
| `API_TOKEN_HMAC_KEY` | 64-char hex string for API token hashing |
### Optional
| Variable | Description |
|----------|-------------|
| `NEXT_PUBLIC_APP_URL` | Public-facing URL (default: `http://localhost:3000`) |
| `WITHINGS_CLIENT_ID` | Withings OAuth2 client ID |
| `WITHINGS_CLIENT_SECRET` | Withings OAuth2 client secret |
| `WITHINGS_REDIRECT_URI` | OAuth callback URL |
| `WITHINGS_WEBHOOK_SECRET` | Webhook URL hardening secret |
| `TELEGRAM_WEBHOOK_SECRET` | Telegram bot webhook secret |
| Variable | Description |
| ------------------------- | ---------------------------------------------------- |
| `NEXT_PUBLIC_APP_URL` | Public-facing URL (default: `http://localhost:3000`) |
| `WITHINGS_CLIENT_ID` | Withings OAuth2 client ID |
| `WITHINGS_CLIENT_SECRET` | Withings OAuth2 client secret |
| `WITHINGS_REDIRECT_URI` | OAuth callback URL |
| `WITHINGS_WEBHOOK_SECRET` | Webhook URL hardening secret |
| `TELEGRAM_WEBHOOK_SECRET` | Telegram bot webhook secret |
Telegram bot token, ntfy settings, Web Push VAPID keys, Umami, and GlitchTip URLs are configured in the **Admin Panel** and stored encrypted in the database.
@@ -151,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
@@ -184,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
---
@@ -199,60 +232,126 @@ All mutations require authentication via session cookie. External ingest uses Be
<details>
<summary><strong>Health Data</strong></summary>
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/measurements` | List measurements (paginated, filterable) |
| `POST` | `/api/measurements` | Create measurement |
| `DELETE` | `/api/measurements/:id` | Delete measurement |
| `GET` | `/api/analytics` | Trend summaries (7d/30d) |
| `GET` | `/api/export` | Export as CSV or JSON |
| `POST` | `/api/import` | Import from JSON |
| `POST` | `/api/doctor-report` | Aggregated data for PDF |
| Method | Endpoint | Description |
| -------- | ----------------------- | ----------------------------------------- |
| `GET` | `/api/measurements` | List measurements (paginated, filterable) |
| `POST` | `/api/measurements` | Create measurement |
| `DELETE` | `/api/measurements/:id` | Delete measurement |
| `GET` | `/api/analytics` | Trend summaries (7d/30d) |
| `GET` | `/api/export` | Export as CSV or JSON |
| `POST` | `/api/import` | Import from JSON |
| `POST` | `/api/doctor-report` | Aggregated data for PDF |
</details>
<details>
<summary><strong>Mood</strong></summary>
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/mood-entries` | List mood entries |
| `POST` | `/api/mood-entries` | Create mood entry |
| `DELETE` | `/api/mood-entries/:id` | Delete mood entry |
| `GET` | `/api/mood/analytics` | Mood trend analytics |
| `POST` | `/api/integrations/moodlog/webhook` | moodLog.app webhook |
| Method | Endpoint | Description |
| -------- | ----------------------------------- | -------------------- |
| `GET` | `/api/mood-entries` | List mood entries |
| `POST` | `/api/mood-entries` | Create mood entry |
| `DELETE` | `/api/mood-entries/:id` | Delete mood entry |
| `GET` | `/api/mood/analytics` | Mood trend analytics |
| `POST` | `/api/integrations/moodlog/webhook` | moodLog.app webhook |
</details>
<details>
<summary><strong>Medications</strong></summary>
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/medications` | List all medications |
| `POST` | `/api/medications` | Create medication |
| `PUT` | `/api/medications/:id` | Update medication |
| `DELETE` | `/api/medications/:id` | Delete medication |
| `POST` | `/api/medications/:id/intake` | Log intake event |
| `GET` | `/api/medications/:id/compliance` | Compliance stats |
| `POST` | `/api/ingest/medication` | External intake (Bearer) |
| Method | Endpoint | Description |
| -------- | --------------------------------- | ------------------------ |
| `GET` | `/api/medications` | List all medications |
| `POST` | `/api/medications` | Create medication |
| `PUT` | `/api/medications/:id` | Update medication |
| `DELETE` | `/api/medications/:id` | Delete medication |
| `POST` | `/api/medications/:id/intake` | Log intake event |
| `GET` | `/api/medications/:id/compliance` | Compliance stats |
| `POST` | `/api/ingest/medication` | External intake (Bearer) |
</details>
<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>
@@ -260,16 +359,16 @@ All mutations require authentication via session cookie. External ingest uses Be
## Integrations
| Integration | Setup | Purpose |
|-------------|-------|---------|
| **Withings** | Env vars | Auto-sync weight, BP, and activity |
| **Telegram** | Admin Panel | Medication reminders with inline buttons |
| **ntfy** | User Settings | Self-hosted push notifications |
| **Web Push** | Admin Panel | Browser-native VAPID notifications |
| **OpenAI** | User Settings | AI health insights (BYOK) |
| **moodLog.app** | User Settings | Mood tracking sync |
| **Umami** | Admin Panel | Privacy-friendly analytics |
| **GlitchTip** | Admin Panel | Sentry-compatible error tracking |
| Integration | Setup | Purpose |
| --------------- | ------------- | ---------------------------------------- |
| **Withings** | Env vars | Auto-sync weight, BP, and activity |
| **Telegram** | Admin Panel | Medication reminders with inline buttons |
| **ntfy** | User Settings | Self-hosted push notifications |
| **Web Push** | Admin Panel | Browser-native VAPID notifications |
| **OpenAI** | User Settings | AI health insights (BYOK) |
| **moodLog.app** | User Settings | Mood tracking sync |
| **Umami** | Admin Panel | Privacy-friendly analytics |
| **GlitchTip** | Admin Panel | Sentry-compatible error tracking |
---
@@ -318,7 +417,7 @@ Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines
- Code style: `pnpm format && pnpm lint`
- Type safety: `pnpm typecheck` must pass
- Tests: `pnpm test`
- UI language: German. Code, comments, and commits: English.
- UI language: English by default, German selectable per user. Code, comments, and commits: English.
---
+15 -1
View File
@@ -5,7 +5,7 @@
Only the latest release on the `main` branch is actively supported with security updates.
| Version | Supported |
|---------|-----------|
| ------- | --------- |
| Latest | Yes |
| Older | No |
@@ -59,3 +59,17 @@ HealthLog is designed with security as a core principle:
- **Proxy-level route protection** via `proxy.ts` (session cookie validation on all non-public paths)
For more details, see the [security documentation](https://docs.healthlog.dev/security/overview/).
## Supply Chain
HealthLog Docker images are built and published from this repository's CI:
- **Source**: [`.github/workflows/docker-publish.yml`](.github/workflows/docker-publish.yml) — runs on every push to `main` and every `v*` tag
- **Registry**: [`ghcr.io/mbombeck/healthlog`](https://github.com/MBombeck/HealthLog/pkgs/container/healthlog)
- **Architectures**: `linux/amd64` and `linux/arm64`
- **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.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.
+19 -7
View File
@@ -1,17 +1,29 @@
services:
app:
# Pre-built multi-arch images are published to GHCR by
# .github/workflows/docker-publish.yml on every push to main and on
# tagged releases. Pin to a specific version in production:
# e.g. ghcr.io/mbombeck/healthlog:1.2.0
image: ghcr.io/mbombeck/healthlog:latest
# Fallback build context: if the image is not yet in the registry,
# or you want to test local changes, run `docker compose up --build`.
build:
context: .
dockerfile: Dockerfile
container_name: healthlog-app
expose:
- "3000"
ports:
- "3000:3000"
environment:
DATABASE_URL: "postgresql://healthlog:healthlog@db:5432/healthlog?schema=public"
# Plain ${VAR} interpolation only — no ${VAR:?error} validation
# syntax. Some hosting platforms (e.g. Coolify) parse compose files
# eagerly and store the *fallback error string* as the literal value
# when the env var is unset, which then breaks the deployment.
# Validation is enforced by docker-entrypoint.sh before the app starts.
DATABASE_URL: "postgresql://healthlog:${POSTGRES_PASSWORD}@db:5432/healthlog?schema=public"
NODE_ENV: production
SESSION_SECRET: "${SESSION_SECRET:?SESSION_SECRET is required}"
ENCRYPTION_KEY: "${ENCRYPTION_KEY:?ENCRYPTION_KEY is required}"
API_TOKEN_HMAC_KEY: "${API_TOKEN_HMAC_KEY:?API_TOKEN_HMAC_KEY is required}"
SESSION_SECRET: "${SESSION_SECRET}"
ENCRYPTION_KEY: "${ENCRYPTION_KEY}"
API_TOKEN_HMAC_KEY: "${API_TOKEN_HMAC_KEY}"
NEXT_PUBLIC_APP_URL: "${NEXT_PUBLIC_APP_URL:-http://localhost:3000}"
APP_URL: "${APP_URL:-http://localhost:3000}"
depends_on:
@@ -24,7 +36,7 @@ services:
container_name: healthlog-db
environment:
POSTGRES_USER: healthlog
POSTGRES_PASSWORD: healthlog
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"
POSTGRES_DB: healthlog
volumes:
- pgdata:/var/lib/postgresql/data
+21 -1
View File
@@ -1,6 +1,26 @@
#!/bin/sh
set -e
# ── Validate required env vars ───────────────────────────────
# We do this in the entrypoint instead of in docker-compose.yml's
# environment block (which used to use the ${VAR:?error} syntax)
# because some hosting platforms parse compose files eagerly and
# end up storing the fallback error string as the literal value.
# Validating here guarantees a clear, deploy-platform-agnostic
# failure message before we touch the database.
missing=""
for var in DATABASE_URL SESSION_SECRET ENCRYPTION_KEY API_TOKEN_HMAC_KEY; do
eval "value=\$$var"
if [ -z "$value" ]; then
missing="$missing $var"
fi
done
if [ -n "$missing" ]; then
echo "HealthLog: ERROR — required env vars not set:$missing" >&2
echo "HealthLog: see .env.example for the full list and how to generate each value." >&2
exit 1
fi
# ── Wait for PostgreSQL ──────────────────────────────────────
MAX_RETRIES=30
RETRY=0
@@ -15,7 +35,7 @@ until node -e "
" 2>/dev/null; do
RETRY=$((RETRY + 1))
if [ "$RETRY" -ge "$MAX_RETRIES" ]; then
echo "HealthLog: ERROR — Database not reachable after ${MAX_RETRIES}s, aborting."
echo "HealthLog: ERROR — Database not reachable after ${MAX_RETRIES}s, aborting." >&2
exit 1
fi
sleep 1
+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
+235
View File
@@ -0,0 +1,235 @@
# HealthLog Audit-Plan — Phasen-Übersicht
**Datum:** 2026-04-26
**Audit-Bericht:** `~/infra/reports/healthlog-audit-2026-04-26.md`
**Status:** Geplant, noch nicht in Execution
## Strategische Decisions
1. **Onboarding-Redesign:** 4-Step-Wizard → 2-Step + Empty-State-Dashboard + Getting-Started-Card.
2. **Custom-Measurement-Types:** Pragmatic Hardcoding (jetzt #89), Cronometer-Pattern als v1.5+-Roadmap.
3. **`apiClient`-Refactor vor Onboarding-Redesign**, damit neues Onboarding nicht denselben Silent-Failure-Bug reproduziert.
---
## Phasen-Sequenz (10 Phasen)
| # | Phase | Skill | Effort | Issue-Bezug |
|---|---|---|---|---|
| **1** | Setup-Notfall + Versions-Sync | `gsd:quick` | ≤4 h | #87 (Pflaster), #88 |
| **2** | Docker-Image-Pipeline (GHCR-Push + Doc-Konsolidierung) | `gsd:plan-phase` | ~1 d | #88 |
| **3** | Issue #89 — TBW + Bone Mass hardcoded | `gsd:plan-phase` | ~1 d | #89 |
| **4** | `apiClient`-Wrapper + geteilte Zod-Schemas | `gsd:research-phase` → `gsd:plan-phase` | 2-3 d | #87 (strukturell) |
| **3.5** | **Onboarding-Redesign** (2 Steps, TTFV <60s) | `gsd:research-phase` → `gsd:plan-phase` | ~2 d | #87 (strukturell) |
| **4.5** | Empty-State-Dashboard-Tiles (eine pro Type) | `gsd:plan-phase` | ~1 d | #87, #89 |
| **4.6** | „Getting Started"-Checklist auf Dashboard | `gsd:plan-phase` | ~4 h | #87 |
| **5** | Security + Reliability Hardening | `gsd:plan-phase` | 1-2 d | — |
| **6** | Test-Coverage-Boost (Auth/Passkey/Withings/pg-boss) | `gsd:plan-phase` | 1-2 d | — |
| **7** | UX/i18n/a11y Polish (Residuals aus #73) | `gsd:quick` | ≤4 h | #73 (Residuals) |
| **8** | Settings-Page-Splitting (3143 → Sub-Components) | `gsd:research-phase` → `gsd:plan-phase` | 2-3 d | — |
**Reihenfolge:** 1 → 2 → 3 → **4 (apiClient)** → **3.5 (Onboarding nutzt apiClient)** → 4.5 → 4.6 → 5 → 6 → 7 → 8.
---
## Phase 1 — Setup-Notfall (≤4 h)
**Goal:** Self-Hoster kann mit `git clone && docker compose up -d` tatsächlich starten + Issue #87 stoppt zu blocken.
- `docker-compose.yml`: `expose:3000` → `ports: "3000:3000"` (DX-002)
- `docker-compose.yml`: `POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:?required}"` (SEC-003)
- `.env.example`: `localhost:5432` → `db:5432` (DX-003)
- `package.json`: `"version": "1.2.0"` (DOC-007)
- `CLAUDE.md` + `AGENTS.md`: 23 Models statt 21/22 (DOC-001)
- `src/app/onboarding/page.tsx`: `await fetch(...)` mit `.ok`-Check + `toast.error()` (DX-005 Pflaster)
- README „minutes"-Claim entschärfen (DX-006)
**Verification:** Frischer Clone + neue VM → `cp .env.example .env && set secrets && docker compose up -d` → App auf `http://localhost:3000` erreichbar in <5 min.
---
## Phase 2 — Docker-Image-Pipeline (~1 d)
**Goal:** Issue #88 vollständig schließen. Pre-built Image auf GHCR. Docs konsolidiert.
- `.github/workflows/docker-publish.yml`: Multi-Arch (amd64+arm64), Trigger auf Tag-Push + main, GHCR-Push mit GITHUB_TOKEN
- `healthlog-docs/getting-started/installation.mdx` → Verweis auf `self-hosting/docker.mdx` statt Duplikat (DX-004)
- Repo-`docker-compose.yml` vs Docs-`docker-compose.yml`: eine Variante als Single-Source-of-Truth (Compose verwendet `image:` aus GHCR; Doc-Snippet wird daraus generiert)
- `SECURITY.md` + Image-Pull-Instructions
- `healthlog-landing` prüfen ob „Quick Start" aktualisiert werden muss
---
## Phase 3 — Issue #89: TBW + Bone Mass (~1 d)
**Goal:** User-Anfrage von Manschk3rl beantworten. Blueprint = v1.2-Glucose-Migration.
**Touchpoints:**
1. `prisma/schema.prisma`: `MeasurementType` Enum + 2 neue Werte
2. `prisma/migrations/0019_*` (additive `ALTER TYPE ... ADD VALUE`)
3. `src/lib/validations/measurement.ts`: `unitMap`, `VALUE_RANGES`
4. `src/lib/format.ts` + i18n-Keys (4-6 neue Keys)
5. `src/lib/doctor-report-pdf.ts`: `TYPE_LABEL_KEYS`, `TYPE_UNIT_KEYS`, `vitalTypes`
6. `src/components/measurements/measurement-form.tsx`: `MEASUREMENT_TYPES` Array
7. `src/lib/withings/client.ts` + `sync.ts`: Mapping aus Withings `body_composition`
8. `src/app/api/dashboard/widgets/route.ts` + `DEFAULT_DASHBOARD_LAYOUT`: 2 neue Widget-IDs
9. healthlog-docs `health-metrics.mdx` aktualisieren
10. Optional: `/api/ingest/measurements` Bearer-Endpoint für n8n (analog `/api/ingest/medication`)
---
## Phase 4 — `apiClient`-Wrapper (2-3 d)
**Goal:** Silent-Failure-Pattern strukturell eliminieren. Voraussetzung für 3.5.
- `src/lib/api-client.ts`: `apiClient.{get,post,put,delete}<T>(path, body?, schema?) → Result<T, ApiError>`
- Auto-`.ok`-Check + Toast bei Fehler (mit i18n-Mapping)
- Geteilte Zod-Schemas: Validations-Module in `src/lib/validations/` werden client-importable
- Migration: alle ~50 `fetch(...)`-Aufrufe in `src/app/` und `src/components/` → `apiClient.*`
- Tests für `apiClient` (Mock-Fetch, Error-Mapping, Schema-Validation)
---
## Phase 3.5 — Onboarding-Redesign (~2 d)
**Goal:** TTFV <60s, 90% Completion (statt aktuell ~8% global).
**Neuer Flow:**
- Step 1: Welcome + Anzeigename (optional) + Einheiten + Größe (optional). Sekundärlink „Skip everything → Dashboard".
- Step 2: Multi-Select-Chips „Was willst du tracken?" (Weight/BP/Pulse/Mood/Glucose/Sleep/Steps/BodyFat/TBW/BoneMass/Medications). Pre-checked Defaults. Speichert in `User.dashboardWidgetsJson`.
- Routing nach Onboarding: direkt zum Dashboard mit „Getting Started"-Card (s. Phase 4.6).
**Entfällt ersatzlos:** Targets-Step, Notifications-Teaser-Step, Front-loaded-Med-Wizard.
**Permissions deferred:** Notifications nur bei „Add medication → Reminder ein", PWA-Install nur bei Day-2-Return-Visit oder nach 3. Eintrag.
---
## Phase 4.5 — Empty-State-Dashboard-Tiles (~1 d)
**Goal:** Mealie/Tandoor/Linear-Pattern. Jede Metric-Tile hat Empty-State mit „Add your first X"-CTA.
- `src/components/dashboard/EmptyStateTile.tsx` als wiederverwendbare Komponente
- Pro `MeasurementType`: eigene Empty-State-Card (Icon + Headline + CTA-Button → opens MeasurementForm prefilled)
- `Medications` analog: Empty-State „Add your first medication" → opens MedicationForm mit Default-Schedule (08:00–09:00 daily)
- Dashboard-Routing: User mit `dashboardWidgetsJson` ohne Daten sieht Empty-States, nicht leeres Grid
---
## Phase 4.6 — „Getting Started"-Checklist (~4 h)
**Goal:** Linear-Pattern. Persistent dismissible Card auf Dashboard.
- `User.gettingStartedDismissedAt: DateTime?` (nullable, NULL = noch sichtbar)
- 4 Items mit Live-Status:
- ☐ Log your first measurement (✓ wenn ≥1 Measurement existiert)
- ☐ Add a medication (✓ wenn ≥1 Medication existiert)
- ☐ Set a target value (✓ wenn `thresholdsJson` non-empty)
- ☐ Install HealthLog as App (✓ wenn `beforeinstallprompt` accepted, sonst nur sichtbar bei PWA-fähigen Browsern)
- „Dismiss"-Button → setzt Timestamp, Card verschwindet
- Vollständig durchgeklickt → Card verschwindet automatisch
---
## Phase 5 — Security + Reliability Hardening (1-2 d)
- SEC-002: SSRF-IPv4-Parser-Härtung (führende Nullen)
- SEC-004: GitHub-PAT-Logging-Sanitization (Sensitive-Field-Filter)
- SEC-005: AES-256-GCM Key-Rotation-Doc + optional `keyVersion`
- SEC-006: Custom-Thresholds-Rate-Limit
- PERF-001: N+1 in `insights/comprehensive/route.ts:219` per `groupBy` auflösen
- REL-002: Onboarding-Error-Handling über Pflaster hinaus (via apiClient aus Phase 4)
- REL-003: pg-boss `process.on('SIGTERM', ...)` für graceful shutdown
- LOG-001: 6× `console.log()` in `src/lib/jobs/reminder-worker.ts` → Wide-Event-Annotations
- CI: ESLint+Typecheck `continue-on-error: true` → blocking
---
## Phase 6 — Test-Coverage-Boost (1-2 d)
Coverage 35% → 60% auf kritischen Pfaden.
**Top-5-Test-Suites:**
1. Session-Validation Roundtrip (`src/lib/auth/session.ts`)
2. Passkey-Register/Verify-Integration (Attestation-Flow)
3. Notification-Dispatcher Multi-Channel (TELEGRAM/NTFY/WEB_PUSH)
4. pg-boss Reminder-Worker (Queue-Init, Job-Execution, Cron, Retry)
5. Withings-OAuth + Sync (`exchangeCode` → `refresh` → `fetchMeasurements`)
**Plus:** TEST-006 Custom-Thresholds-Resolution, TEST-007 AI-Provider-SSRF.
---
## Phase 7 — UX/i18n/a11y Polish (≤4 h)
**Residuals aus Issue #73 + neue Findings:**
- I18N-002: Admin-Page Hardcoded „Web Push" / „Bug Report" → `t(...)`
- I18N-003: `medication-card.tsx` `Intl.DateTimeFormat("en-CA")` und `toLocaleString("en-US")` → `useFormatters()`
- A11Y-001: `phase-config-dialog.tsx` Text-Indicator neben Farb-Punkt (WCAG 1.4.1)
- A11Y-002: Admin StatusItem `aria-label` verifizieren
- UX-002: Admin-Save-Error-UX (Field-Highlight bei Fehler)
- UX-003: Bottom-Nav-Touch-Targets `p-2.5` → `p-3` (≥44×44 px)
---
## Phase 8 — Settings-Page-Splitting (2-3 d)
**Goal:** 3143 Zeilen → Sub-Components mit klarer Boundary.
- Research-Phase: Sub-Routes (`/settings/profile`, `/settings/notifications`, …) vs Sub-Components in einer Page?
- Plan-Phase: Migrationsstrategie ohne URL-Bruch
- Insights-Page (1750 Zeilen) folgt analogem Pattern
---
## Issue-Vorschlags-Abdeckung
### Issue #87 (Onboarding-Meds verschwinden)
- **Pflaster:** Phase 1 (`.ok`-Check + Toast)
- **Strukturelle Lösung:** Phase 4 (`apiClient`) + Phase 3.5 (neuer Flow ohne Med-Wizard) + Phase 4.5 (Empty-State-Tiles, Meds direkt sichtbar) + Phase 4.6 (Checklist)
### Issue #88 (Setup holprig — 4 Hauptpunkte + 3 Verbesserungsvorschläge)
| #88 Punkt | Phase | Status |
|---|---|---|
| (a) Bundled compose ≠ docs version | Phase 2 (DOC-005) | abgedeckt |
| (b) ghcr.io 404 (kein Image gepusht) | Phase 2 (DX-001 GHA-Workflow) | abgedeckt |
| (c) `.env.example` `localhost:5432` vs Compose `db:5432` | Phase 1 (DX-003) | abgedeckt |
| (d) `expose:3000` statt `ports:3000:3000` | Phase 1 (DX-002) | abgedeckt |
| Vorschlag: GHA für Image-Push (GHCR/Docker Hub) | Phase 2 | abgedeckt |
| Vorschlag: Merge „Getting started > Installation" + „Self-Hosting > Docker" | Phase 2 (DX-004) | abgedeckt |
| Vorschlag: Same presets across compose+docs+.env.example | Phase 1 + Phase 2 | abgedeckt |
### Issue #89 (TBW + Bone Mass)
- Phase 3 hardcoded mit Withings-Mapping. Optional `/api/ingest/measurements` für n8n in Phase 3.
### Issue #73 (UX-Liste mit 8 Punkten — CLOSED in v1.2)
| #73 Punkt | v1.2-Status | Residual? |
|---|---|---|
| 1. Passkey-Errors unklar | Resolved (Package C, typed errors) | TEST-002 (keine Tests) → Phase 6 |
| 2. Number-Formatting (DE 70,5 vs US 70.5) | Resolved (Package B, locale-aware formatters) | I18N-003 in `medication-card.tsx` → Phase 7 |
| 3. Chart-Labels „7P/30P/90P/All" | Resolved (Package A, „7 Pkt"/„7 pts") | — |
| 4. Dashboard nicht real-time | Resolved (`invalidateKeys` durchgängig) | — |
| 5. GitHub-Setup-Confusion | Resolved (Built-in Feedback in v1.2) | — |
| 6. Built-in Feedback | Done in v1.2 | — |
| 7. Admin-Save ohne Error-UX | Partial (Success-Msg ja, Error-Highlight schwach) | UX-002 → Phase 7 |
| 8. Blood-Sugar | Done in v1.2 | — |
### Issue #74 (raw locale keys in UI)
Resolved in v1.2.
---
## Time-to-First-Value (TTFV) Ziel
Nach Onboarding-Redesign + Empty-State-Pattern:
- Login → Dashboard mit Empty-States: 2 Klicks
- Klick auf „Add your first weight" → Form prefilled „now" → Submit: 30 Sek
- **TTFV-Ziel: <60 Sekunden** (Branchenvergleich: führende Health-Apps 30-90s)
## Anti-Patterns explizit vermieden
- Permissions-Cold-Prompt (Notifications/PWA) auf Day 1
- Front-loaded Targets vor erster Messung
- Multi-Step-Wizard mit identischem Skip-Button
- `fetch()` ohne `.ok`-Check (apiClient löst strukturell)
- Mega-Page-Pattern (Phase 8 löst Settings 3143 Z.)
- Custom-Measurement-Types vorzeitig (40-60h Wartung/Jahr für 50% Feature-Loss)
+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`).
+476 -55
View File
@@ -36,7 +36,93 @@
"replace": "Ersetzen",
"add": "Hinzufügen",
"import": "Importieren",
"test": "Test"
"test": "Test",
"reset": "Zurücksetzen",
"copy": "Kopieren",
"copyDetails": "Details kopieren",
"retry": "Erneut versuchen",
"reportIssue": "Fehler melden"
},
"doctorReport": {
"title": "Gesundheitsbericht",
"subtitle": "HealthLog — Persönlicher Gesundheitsbericht",
"patient": "Patient",
"dateOfBirth": "Geburtsdatum",
"gender": "Geschlecht",
"genderMale": "Männlich",
"genderFemale": "Weiblich",
"genderOther": "Divers",
"height": "Körpergröße",
"period": "Berichtszeitraum",
"createdOn": "Erstellt am",
"vitalsTitle": "Vitalparameter — Übersicht",
"colParameter": "Parameter",
"colCurrent": "Aktuell",
"colAvgPeriod": "Ø Zeitraum",
"colMin": "Min",
"colMax": "Max",
"colN": "n",
"bpClassificationTitle": "Blutdruck — ESC/ESH-Klassifikation (2018)",
"avgBp": "Durchschnittlicher Blutdruck",
"classification": "Klassifikation",
"bmiTitle": "Body-Mass-Index (BMI)",
"bmiRow": "BMI: {bmi} kg/m² — Klassifikation: {class} (WHO)",
"complianceTitle": "Medikamenten-Compliance",
"colMedication": "Medikament",
"colTaken": "Eingenommen",
"colSkipped": "Übersprungen",
"colMissed": "Verpasst",
"colTotal": "Gesamt",
"colComplianceRate": "Compliance-Rate",
"moodTitle": "Stimmungsverlauf",
"moodSummary": "Durchschnitt: {avg} / 5,0 · Einträge: {count} · Bereich: {min}–{max}",
"colMood": "Stimmung",
"colCount": "Anzahl",
"colShare": "Anteil",
"footerDisclaimer1": "Dieser Bericht wurde automatisch aus selbst erfassten Daten generiert und dient ausschließlich zur Unterstützung",
"footerDisclaimer2": "des Arzt-Patienten-Gesprächs. Er ersetzt keine ärztliche Diagnose. Korrelationen implizieren keine Kausalität.",
"footerSource": "Quelle: HealthLog | Zeitzone: Europe/Berlin | Erstellt: {timestamp}",
"typeWeight": "Körpergewicht",
"typeBpSys": "Systolischer Blutdruck",
"typeBpDia": "Diastolischer Blutdruck",
"typePulse": "Ruhepuls",
"typeBodyFat": "Körperfettanteil",
"typeSleep": "Schlafdauer",
"typeSteps": "Aktivität (Schritte)",
"typeTotalBodyWater": "Gesamtkörperwasser",
"typeBoneMass": "Knochenmasse",
"typeOxygenSaturation": "Sauerstoffsättigung",
"unitSteps": "Schritte",
"bmiUnderweight": "Untergewicht",
"bmiNormal": "Normalgewicht",
"bmiOverweight": "Übergewicht (Präadipositas)",
"bmiObeseGrade1": "Adipositas Grad I",
"bmiObeseGrade2": "Adipositas Grad II",
"bmiObeseGrade3": "Adipositas Grad III",
"bpOptimal": "Optimal",
"bpNormal": "Normal",
"bpHighNormal": "Hochnormal",
"bpHypertensionGrade1": "Hypertonie Grad 1",
"bpHypertensionGrade2": "Hypertonie Grad 2",
"bpHypertensionGrade3": "Hypertonie Grad 3",
"moodAwful": "Sehr schlecht",
"moodBad": "Schlecht",
"moodNeutral": "Neutral",
"moodGood": "Gut",
"moodGreat": "Sehr gut",
"typeGlucoseFasting": "Glukose (nüchtern)",
"typeGlucosePostprandial": "Glukose (postprandial)",
"typeGlucoseRandom": "Glukose (beliebig)",
"typeGlucoseBedtime": "Glukose (vor dem Schlafen)",
"glucoseClassificationTitle": "Blutzucker — Referenzbereiche (ADA 2024 / DDG)",
"glucoseRow": "{label}: Ø {avg} {unit} (Ziel {rangeMin}–{rangeMax}) — {class}",
"glucoseInTarget": "im Ziel",
"glucoseBelowTarget": "unter Ziel",
"glucoseAboveTarget": "über Ziel"
},
"errorBoundary": {
"title": "Da ist etwas schiefgelaufen",
"description": "Ein unerwarteter Fehler ist aufgetreten. Du kannst es erneut versuchen, die technischen Details kopieren oder einen Bug-Report öffnen."
},
"nav": {
"home": "Home",
@@ -90,6 +176,7 @@
"backToPasskey": "Zurück zu Passkey",
"passkeySetupLater": "Passkeys kannst du jederzeit in den Einstellungen einrichten.",
"passwordMinLength": "Min. 12 Zeichen",
"passwordTooShort": "Passwort muss mindestens {minLength} Zeichen lang sein.",
"passwordStrength": "Passwortstärke",
"strengthWeak": "Sehr schwach",
"strengthFair": "Schwach",
@@ -113,6 +200,8 @@
"bloodPressureDia": "Blutdruck (Dia)",
"pulse": "Puls",
"bodyFat": "Körperfett",
"sleep": "Schlaf",
"steps": "Schritte",
"bpInTarget": "BD im Zielbereich",
"days30Short": "30T",
"medications": "Medikamente",
@@ -127,7 +216,17 @@
"compliance7d": "Einnahmetreue (7 Tage)",
"complianceGood": "Gut",
"complianceLow": "Niedrig",
"mood": "Stimmung"
"mood": "Stimmung",
"customizeTitle": "Dashboard-Layout",
"customizeSubtitle": "Wähle, welche Karten auf dem Dashboard angezeigt werden und in welcher Reihenfolge. Defaults funktionieren sofort.",
"layoutReset": "Auf Defaults zurücksetzen",
"layoutSaveSuccess": "Dashboard-Layout gespeichert",
"layoutSaveError": "Layout konnte nicht gespeichert werden",
"layoutResetSuccess": "Auf Standard-Layout zurückgesetzt",
"layoutUsingDefaults": "Standard-Layout aktiv",
"layoutCustomized": "Eigenes Layout aktiv",
"moveUp": "Nach oben",
"moveDown": "Nach unten"
},
"measurements": {
"title": "Messungen",
@@ -148,6 +247,17 @@
"typeBodyFat": "Körperfett",
"typeSleep": "Schlaf",
"typeSteps": "Schritte",
"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)",
"glucoseContextRandom": "Beliebig / ohne Kontext",
"glucoseContextBedtime": "Vor dem Schlafen",
"glucoseUnit": "Blutzucker-Einheit",
"glucoseUnitHint": "Gespeichert in mg/dL; Anzeige-Einheit ist deine Präferenz.",
"unitHours": "Stunden",
"unitSteps": "Schritte",
"sourceManual": "Manuell",
@@ -385,7 +495,21 @@
"phaseAfterEnd": "nach Ende",
"phaseResetDefaults": "Auf Defaults zurücksetzen",
"phaseSaved": "Erinnerungsphasen gespeichert",
"phaseReset": "Auf Defaults zurückgesetzt"
"phaseReset": "Auf Defaults zurückgesetzt",
"sourceWeb": "Webseite",
"sourceApi": "Externe API",
"sourceReminder": "Telegram / Erinnerung",
"sourceImport": "CSV-Import",
"unitDrops": "Tropfen",
"unitTablets": "Tablette(n)",
"unitCapsules": "Kapsel(n)",
"unitPieces": "Stück",
"namePlaceholder": "z.B. Ibuprofen",
"dosePlaceholder": "z.B. 400",
"labelPlaceholder": "z.B. Morgens",
"defaultDose": "Standard",
"dayDeactivate": "{day} deaktivieren",
"dayActivate": "{day} aktivieren"
},
"charts": {
"title": "Verlauf",
@@ -406,17 +530,22 @@
"noData": "Keine Daten im gewählten Zeitraum",
"movingAverage": "Gleitender Durchschnitt",
"target": "Zielbereich",
"points7Label": "7M",
"points7Title": "Die sieben letzten Messpunkte",
"points30Label": "30M",
"points30Title": "Die dreißig letzten Messpunkte",
"points90Label": "90M",
"points90Title": "Die neunzig letzten Messpunkte",
"points7Label": "7 Pkt",
"points7Title": "Letzte 7 Messpunkte",
"points30Label": "30 Pkt",
"points30Title": "Letzte 30 Messpunkte",
"points90Label": "90 Pkt",
"points90Title": "Letzte 90 Messpunkte",
"pointsAllLabel": "Alle",
"pointsAllTitle": "Alle verfügbaren Messpunkte",
"systolic": "Systolisch",
"diastolic": "Diastolisch",
"bodyWater": "Körperwasser",
"boneMass": "Knochenmasse",
"spo2": "SpO₂",
"movingAverage7d": "7T-Schnitt",
"avg7dShort": "7T",
"avg30dShort": "30T",
"trend": "Trend",
"targetRanges": "Zielbereiche",
"perWeek": "/Woche",
@@ -429,9 +558,58 @@
"colorRed": "Rot:",
"mood": "Stimmung",
"moodScore": "Stimmungswert",
"moodMA": "7-Tage-Schnitt"
"moodMA": "7-Tage-Schnitt",
"moodLabel1": "Lausig",
"moodLabel2": "Schlecht",
"moodLabel3": "Okay",
"moodLabel4": "Gut",
"moodLabel5": "Super",
"weekdays": {
"mon": "Mo",
"wed": "Mi",
"fri": "Fr",
"sun": "So"
},
"months": {
"jan": "Jan",
"feb": "Feb",
"mar": "Mär",
"apr": "Apr",
"may": "Mai",
"jun": "Jun",
"jul": "Jul",
"aug": "Aug",
"sep": "Sep",
"oct": "Okt",
"nov": "Nov",
"dec": "Dez"
},
"heatmapOnTime": "pünktlich",
"heatmapLate": "spät",
"heatmapVeryLate": "sehr spät",
"legendOnTime": "Pünktlich",
"legendLate": "Spät",
"legendVeryLate": "Sehr spät",
"legendMissed": "Verpasst"
},
"insights": {
"aiAnalysisTitle": "KI-Gesundheitsanalyse",
"noAnalysisYet": "Noch keine Analyse vorhanden.",
"noProviderConfigured": "KI-Provider nicht konfiguriert.",
"startAnalysis": "Analyse starten",
"refreshAnalysis": "Analyse aktualisieren",
"keyTakeaway": "Das Wichtigste",
"findingsTitle": "Befunde",
"correlationsTitle": "Zusammenhänge",
"recommendationsTitle": "Empfehlungen",
"dataFoundation": "Datengrundlage",
"dataGaps": "Datenlücken:",
"lastUpdated": "Zuletzt aktualisiert",
"confidenceHoch": "Hoch",
"confidenceMittel": "Mittel",
"confidenceGering": "Gering",
"loadError": "Auswertungen konnten nicht geladen werden",
"generationFailed": "Die Analyse konnte nicht erstellt werden. Bitte erneut versuchen.",
"title": "Insights",
"subtitle": "KI-basierte Analyse deiner Gesundheitsdaten",
"overviewSubtitle": "Trends, Risiken, Fortschritt auf einen Blick",
@@ -543,47 +721,55 @@
"moodWeightNone": "Kein erkennbarer Zusammenhang zwischen Stimmung und Gewicht.",
"moodWeightStrong": "Es besteht ein starker Zusammenhang zwischen Stimmung und Gewicht.",
"moodWeightWeak": "Es besteht ein schwacher Zusammenhang zwischen Stimmung und Gewicht.",
"notEnoughMoodCorrelationData": "Zu wenig Stimmungsdaten für Korrelationsanalyse."
"notEnoughMoodCorrelationData": "Zu wenig Stimmungsdaten für Korrelationsanalyse.",
"refreshButton": "Aktualisieren",
"generateButton": "Generieren",
"analyzing": "Analysiere Gesundheitsdaten...",
"confidence": "Konfidenz",
"confidence_niedrig": "niedrig",
"confidence_mittel": "mittel",
"confidence_hoch": "hoch",
"cached": "Gecacht",
"sectionChanged": "Was hat sich verändert",
"sectionStable": "Was ist stabil",
"sectionDrivers": "Mögliche Zusammenhänge",
"sectionNextSteps": "Nächste Schritte",
"generatePrompt": "Klicke auf \"Generieren\" für KI-basierte Insights deiner Gesundheitsdaten."
},
"targets": {
"title": "Zielwerte",
"subtitle": "Persönliche Referenzbereiche basierend auf deinem Profil",
"introText": "Diese Zielwerte sind persönliche Referenzbereiche, die aus deinem Profil und anerkannten medizinischen Richtwerten abgeleitet werden. Sie helfen dir, deine wichtigsten Gesundheitswerte schnell einzuordnen und Entwicklungen früh zu erkennen. Die Bereiche werden für dich individuell berechnet, zum Beispiel anhand von Alter, Geschlecht und Körpergröße. Zu jedem Wert siehst du direkt, ob du im Zielbereich liegst oder darüber beziehungsweise darunter. So bekommst du eine kompakte Übersicht darüber, welche Zielwerte du aktuell gut einhältst und wo noch Potenzial besteht.",
"current": "Aktuell",
"average30d": "30-Tage-Durchschnitt:",
"targetRange": "Zielbereich",
"classification": "Bewertung",
"source": "Quelle",
"sourceLabel": "Quelle: {source}",
"trend": "Trend",
"weight": "Gewicht",
"bloodPressure": "Blutdruck",
"pulse": "Ruhepuls",
"sleep": "Schlafdauer",
"bmi": "BMI",
"bodyFat": "Körperfett",
"steps": "Schritte/Tag",
"compliance": "Einnahmetreue",
"compliancePerMedication": "Pro Medikament",
"diastolic": "Diastolisch",
"noProfile": "Bitte fülle dein Profil aus, um personalisierte Zielwerte zu erhalten.",
"profileIncomplete": "Profil unvollständig",
"profileIncompleteHeightAge": "Hinterlege deine Größe und dein Geburtsdatum im Profil, um personalisierte Zielbereiche zu erhalten.",
"profileIncompleteHeight": "Hinterlege deine Größe im Profil, um den Gewichts-Zielbereich zu berechnen.",
"profileIncompleteAge": "Hinterlege dein Geburtsdatum im Profil, um altersbasierte Zielbereiche zu erhalten.",
"noMeasurementData": "Noch keine Messdaten vorhanden.",
"noMeasurementYet": "Noch keine Messung vorhanden",
"currentValue": "Aktuell: {value} {unit}",
"targetRangeValue": "Zielbereich: {min}–{max} {unit}",
"inTarget": "Im Zielbereich",
"belowTarget": "{delta} {unit} unter Ziel",
"aboveTarget": "{delta} {unit} über Ziel",
"loadError": "Fehler beim Laden",
"classificationOptimal": "Optimal",
"classificationNormal": "Normal",
"classificationElevated": "Erhöht",
"classificationHigh": "Hoch",
"classificationLow": "Niedrig"
"thresholds": {
"title": "Persönliche Zielwerte",
"subtitle": "Überschreibe den berechneten Zielbereich für jede Metrik. Ohne Eintrag gilt der Default.",
"defaultLabel": "Default",
"overrideLabel": "Dein Wert",
"autoModeLabel": "Auto",
"overrideModeLabel": "Überschrieben",
"resetAction": "Auf Default zurücksetzen",
"resetAllAction": "Alle auf Default zurücksetzen",
"minLabel": "Min",
"maxLabel": "Max",
"unitSuffix": "({unit})",
"outOfBoundsHint": "Erlaubter Bereich: {min}–{max} {unit}",
"overrideWarning": "Werte außerhalb der Leitlinien können zu ungesunden Zielen führen. Nur nach ärztlicher Rücksprache überschreiben.",
"saveSuccess": "Zielwerte aktualisiert",
"saveError": "Zielwerte konnten nicht gespeichert werden",
"resetSuccess": "Auf Default zurückgesetzt",
"sourceOverride": "Eigener Wert",
"metricWeight": "Gewicht",
"metricBpSys": "Blutdruck (systolisch)",
"metricBpDia": "Blutdruck (diastolisch)",
"metricPulse": "Ruhepuls",
"metricBodyFat": "Körperfett",
"metricSleep": "Schlafdauer",
"metricSteps": "Schritte/Tag",
"metricGlucoseFasting": "Glukose — nüchtern",
"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"
},
"onboarding": {
"welcome": "Willkommen bei HealthLog!",
@@ -603,7 +789,12 @@
"targetsDescription": "Setze optionale Zielwerte für Blutdruck und Gewicht.",
"targetSys": "Systolisch (mmHg)",
"targetDia": "Diastolisch (mmHg)",
"targetWeight": "Zielgewicht (kg)"
"targetWeight": "Zielgewicht (kg)",
"errorProfile": "Dein Profil konnte nicht gespeichert werden. Bitte erneut versuchen.",
"errorMedication": "Das Medikament „{name}\" konnte nicht angelegt werden. Bitte erneut versuchen oder später unter „Medikamente\" hinzufügen.",
"errorTargets": "Deine Zielwerte konnten nicht gespeichert werden. Bitte erneut versuchen.",
"errorGeneric": "Beim Abschluss des Setups ist etwas schiefgelaufen. Bitte erneut versuchen.",
"medScheduleHint": "Wir legen ein Standard-Erinnerungsfenster an (08:00–09:00 täglich). Du kannst es jederzeit unter „Medikamente\" anpassen."
},
"settings": {
"title": "Einstellungen",
@@ -612,6 +803,7 @@
"categoryGeneral": "Allgemein",
"categoryNotifications": "Benachrichtigungen",
"categoryAi": "KI",
"categoryPersonalization": "Personalisierung",
"categoryIntegration": "Integration",
"categoryApi": "API",
"profile": "Profil",
@@ -651,6 +843,11 @@
"passkeyOptionsError": "Konnte Passkey-Optionen nicht laden",
"passkeyRegistrationFailed": "Fehler bei Passkey-Registrierung",
"passkeyRegistrationCancelled": "Passkey-Registrierung abgebrochen",
"passkeyNotSupported": "Dein Browser oder Gerät unterstützt Passkeys noch nicht. Bitte einen anderen Browser oder ein anderes Gerät verwenden.",
"passkeyAlreadyRegistered": "Dieses Gerät ist bereits als Passkey für dein Konto registriert.",
"passkeySecurityBlocked": "Dein Browser hat die Passkey-Anfrage aus Sicherheitsgründen blockiert. Bitte auf einer vertrauenswürdigen HTTPS-URL erneut versuchen.",
"passkeyTimeout": "Die Passkey-Aufforderung ist abgelaufen. Bitte erneut versuchen.",
"passkeyUnknownError": "Passkey-Registrierung fehlgeschlagen: {message}",
"deletePasskey": "Passkey löschen?",
"deletePasskeyDescription": "Der Passkey wird unwiderruflich gelöscht.",
"singleDevice": "Einzelgerät",
@@ -697,6 +894,9 @@
"apiKeyStored": "Key hinterlegt — neuen eingeben zum Ersetzen",
"apiKeySaved": "API-Key gespeichert",
"apiKeyRemoved": "API-Key entfernt",
"codexConnected": "ChatGPT erfolgreich verbunden! KI-Insights sind jetzt aktiv.",
"codexDisconnected": "ChatGPT-Verbindung getrennt.",
"codexConnectionFailed": "ChatGPT-Verbindung fehlgeschlagen. Bitte versuche es erneut.",
"rawData": "Rohdaten mitsenden",
"rawDataOnDescription": "Es werden aggregierte Kennzahlen plus anonymisierte Rohdaten der letzten 30 Tage gesendet. Dazu zählen einzelne Messpunkte je Bereich (z. B. Gewicht, Blutdruck, Puls) mit Zeitbezug, damit die KI zeitliche Muster, Ausreißer und Zusammenhänge zuverlässiger erkennen kann. Nicht übertragen werden Name, E-Mail oder direkte Kontodaten.",
"rawDataOffDescription": "Es werden nur zusammengefasste Werte gesendet (z. B. Durchschnitt, Trend, Minimum/Maximum). Keine einzelnen Messpunkte und keine exakten Zeitbezüge. Das ist datensparsamer, die KI kann dafür aber weniger fein auf Muster und kurzfristige Schwankungen eingehen.",
@@ -715,6 +915,7 @@
"withingsCredentialsSavedPlaceholderSecret": "Gespeichert — neues eingeben zum Ersetzen",
"withingsSaveCredentials": "Zugangsdaten speichern",
"withingsConnected": "Verbunden",
"configured": "Konfiguriert",
"withingsTokenExpired": "Token abgelaufen",
"withingsLastSync": "Letzte Sync",
"withingsSync": "Jetzt synchronisieren",
@@ -843,6 +1044,8 @@
"admin": {
"title": "Administration",
"subtitle": "Systemstatus, Benutzer und Einstellungen",
"settingsSaveError": "Einstellungen konnten nicht gespeichert werden",
"settingsSaveValidationError": "Bitte markierte Felder prüfen",
"sectionsTitle": "Admin",
"systemStatus": "Systemstatus",
"version": "Version",
@@ -870,6 +1073,8 @@
"telegramGlobal": "Telegram-Benachrichtigungen systemweit erlauben.",
"ntfyGlobal": "ntfy-Benachrichtigungen systemweit erlauben.",
"webPushGlobal": "Browser-Push-Benachrichtigungen systemweit erlauben.",
"integrationWebPush": "Web Push",
"integrationBugReport": "Bug-Reports",
"apiGlobal": "API-Endpunkte und API-Tokens systemweit erlauben.",
"moodLogGlobal": "moodLog-Integration systemweit erlauben.",
"sectionGeneral": "Allgemein",
@@ -991,7 +1196,55 @@
"reminderCheckNotifOn": "Benachrichtigungen an",
"reminderCheckNotifOff": "Benachrichtigungen aus",
"reminderCheckEventsToday": "Heutige Events",
"reminderCheckNotifSent": "Gesendet"
"reminderCheckNotifSent": "Gesendet",
"feedback": {
"title": "Feedback-Postfach",
"description": "Eingegangene Rückmeldungen, Fehlerberichte und Feature-Wünsche.",
"tabOpen": "Offen",
"tabAcknowledged": "Bestätigt",
"tabResolved": "Erledigt",
"tabArchived": "Archiviert",
"subject": "Betreff",
"category": "Kategorie",
"user": "Benutzer",
"createdAt": "Eingegangen",
"actions": "Aktionen",
"details": "Details",
"noEntries": "Keine Feedback-Einträge in dieser Kategorie.",
"loading": "Lade Feedback...",
"anonymous": "anonym",
"categoryBug": "Bug",
"categoryFeature": "Feature",
"categoryQuestion": "Frage",
"categoryOther": "Sonstiges",
"statusOpen": "Offen",
"statusAcknowledged": "Bestätigt",
"statusResolved": "Erledigt",
"statusArchived": "Archiviert",
"dialogTitle": "Feedback-Details",
"metadataHeading": "Metadaten",
"metaUrl": "URL",
"metaLocale": "Sprache",
"metaUserAgent": "User-Agent",
"metaAppVersion": "App-Version",
"screenshotHeading": "Screenshot",
"adminNote": "Admin-Notiz",
"adminNotePlaceholder": "Interne Notiz (nicht für Benutzer sichtbar)",
"saveNote": "Notiz speichern",
"noteSaved": "Notiz gespeichert",
"actionAcknowledge": "Bestätigen",
"actionResolve": "Erledigen",
"actionArchive": "Archivieren",
"actionPublishGithub": "Auf GitHub veröffentlichen",
"publishedToGithub": "Auf GitHub veröffentlicht",
"viewIssue": "Issue ansehen",
"publishSuccess": "Issue erstellt",
"publishFailed": "GitHub-Veröffentlichung fehlgeschlagen",
"updateFailed": "Aktualisierung fehlgeschlagen",
"archiveConfirm": "Diesen Feedback-Eintrag archivieren?",
"submittedBy": "Eingereicht von",
"sectionTitle": "Feedback"
}
},
"achievements": {
"title": "Erfolge",
@@ -1171,10 +1424,9 @@
},
"bugreport": {
"title": "Bugreport",
"subtitle": "Melde einen Fehler oder ein Problem. Es wird automatisch ein GitHub Issue erstellt.",
"subtitle": "Sende eine Fehlermeldung, einen Feature-Wunsch oder eine Frage. Der Server-Administrator sieht deine Einreichung.",
"loginRequired": "Bitte anmelden, um einen Bug zu melden.",
"bugTitle": "Titel",
"bugTitlePlaceholder": "Kurze Beschreibung des Problems",
"description": "Beschreibung",
"descriptionPlaceholder": "Beschreibe den Fehler möglichst genau. Was hast du getan? Was ist passiert? Was hättest du erwartet?",
"screenshot": "Screenshot (optional)",
@@ -1185,7 +1437,19 @@
"submit": "Senden",
"success": "Vielen Dank! Dein Bug Report wurde erfolgreich übermittelt.",
"viewOnGithub": "Auf GitHub ansehen",
"errorCreating": "Fehler beim Erstellen"
"errorCreating": "Fehler beim Erstellen",
"notConfiguredTitle": "Bug-Reports noch nicht aktiviert",
"notConfiguredUser": "Bug-Reports sind auf diesem Server nicht aktiv. Bitte wende dich an den Administrator.",
"notConfiguredAdmin": "Für Bug-Reports wird eine GitHub-Integration benötigt (Issue-Token + Repository). Im Admin-Panel unter Einstellungen → Bug-Reports einrichten.",
"openAdminSettings": "Admin-Einstellungen öffnen",
"category": "Art",
"categoryBug": "Fehlermeldung",
"categoryFeature": "Feature-Wunsch",
"categoryQuestion": "Frage",
"categoryOther": "Sonstiges Feedback",
"bugTitlePlaceholder": "Kurze Zusammenfassung (optional)",
"githubEscalationNote": "Dein Feedback wird lokal gespeichert. Wenn der Administrator die GitHub-Integration aktiviert, kann es auf Wunsch als öffentliches Issue veröffentlicht werden.",
"internalOnlyNote": "Dein Feedback wird lokal gespeichert und ist nur für den Administrator dieses Servers sichtbar."
},
"notifications": {
"title": "Benachrichtigungen",
@@ -1208,5 +1472,162 @@
"dateShort": "{day}.{month}.{year}",
"timeShort": "{hours}:{minutes}",
"dateTime": "{day}.{month}.{year}, {hours}:{minutes}"
},
"classifications": {
"bmi": {
"Underweight": "Untergewicht",
"Normal": "Normalgewicht",
"Overweight": "Übergewicht",
"ObesityGradeI": "Adipositas Grad I",
"ObesityGradeII": "Adipositas Grad II",
"ObesityGradeIII": "Adipositas Grad III"
},
"bp": {
"Optimal": "Optimal",
"Normal": "Normal",
"HighNormal": "Hoch-normal",
"HypertensionGrade1": "Hypertonie Grad 1",
"HypertensionGrade2": "Hypertonie Grad 2",
"HypertensionGrade3": "Hypertonie Grad 3"
},
"pulse": {
"Bradycardia": "Bradykardie",
"Normal": "Normal",
"Elevated": "Erhöht",
"Tachycardia": "Tachykardie"
},
"pulseTarget": {
"SignificantlyLow": "Deutlich niedrig",
"SlightlyLow": "Leicht niedrig",
"OnTarget": "Zielbereich",
"SlightlyElevated": "Leicht erhöht",
"SignificantlyElevated": "Deutlich erhöht"
},
"sleep": {
"FarTooShort": "Deutlich zu kurz",
"TooShort": "Zu kurz",
"OnTarget": "Zielbereich",
"SlightlyLong": "Etwas lang",
"FarTooLong": "Deutlich zu lang"
},
"bodyFat": {
"Essential": "Essenziell",
"Athletic": "Athletisch",
"Fitness": "Fitness",
"Average": "Durchschnitt",
"Obese": "Adipös"
},
"steps": {
"VeryLow": "Sehr wenig",
"LowActive": "Wenig aktiv",
"ModeratelyActive": "Moderat aktiv",
"Active": "Aktiv",
"VeryActive": "Sehr aktiv"
},
"general": {
"Good": "Gut",
"Moderate": "Moderat",
"Low": "Niedrig",
"VeryGood": "Sehr gut",
"VeryStable": "Sehr stabil",
"Stable": "Stabil",
"Fluctuating": "Schwankend"
},
"alerts": {
"bmiBelowNormalTitle": "BMI unter Normalbereich",
"bmiBelowNormalMessage": "Ihr BMI von {bmi} liegt unter dem Normalbereich (18,5–24,9).",
"bmiOverweightTitle": "BMI im Bereich Übergewicht",
"bmiOverweightMessage": "Ihr BMI von {bmi} liegt im Bereich Übergewicht. Regelmäßige Bewegung und ausgewogene Ernährung können helfen.",
"bmiObesityTitle": "BMI im Bereich Adipositas",
"bmiObesityMessage": "Ihr BMI von {bmi} liegt im Bereich Adipositas.",
"bmiNormalTitle": "BMI im Normalbereich",
"bmiNormalMessage": "Ihr BMI von {bmi} liegt im gesunden Bereich.",
"bpDangerTitle": "Blutdruck deutlich erhöht",
"bpDangerMessage": "Ihr durchschnittlicher Blutdruck ({sys}/{dia}) wird als \"{category}\" eingestuft.",
"bpWarningTitle": "Blutdruck leicht erhöht",
"bpWarningMessage": "Ihr durchschnittlicher Blutdruck ({sys}/{dia}) liegt im Bereich \"{category}\".",
"bpTargetGoodTitle": "Gute Blutdruck-Zielbereichstreue",
"bpTargetGoodMessage": "{pct}% Ihrer Messungen liegen im Zielbereich.",
"bpTargetBadTitle": "Blutdruck oft außerhalb des Zielbereichs",
"bpTargetBadMessage": "Nur {pct}% Ihrer Messungen liegen im Zielbereich.",
"weightIncreasingTitle": "Gewicht steigend",
"weightIncreasingMessage": "Ihr Gewicht steigt um ca. {change} kg pro Woche in den letzten 30 Tagen.",
"weightDecreasingTitle": "Gewicht fallend",
"weightDecreasingMessage": "Ihr Gewicht sinkt um ca. {change} kg pro Woche in den letzten 30 Tagen.",
"pulseOutliersTitle": "Puls-Ausreißer erkannt",
"pulseOutliersMessage": "{count} ungewöhnliche Pulsmessungen in den letzten 30 Tagen.",
"complianceLowTitle": "Niedrige Einnahmetreue: {name}",
"complianceLowMessage": "Ihre 7-Tage-Compliance für {name} liegt bei nur {pct}%. Regelmäßige Einnahme ist wichtig.",
"complianceGoodTitle": "Sehr gute Einnahmetreue: {name}",
"complianceGoodMessage": "{pct}% Compliance in den letzten 7 Tagen. Weiter so!"
}
},
"targets": {
"title": "Zielwerte",
"introText": "Deine aktuellen Werte im Vergleich zu empfohlenen Zielbereichen. Quellen der Leitlinien sind auf jeder Karte verlinkt.",
"weight": "Gewicht",
"bloodPressure": "Blutdruck",
"bloodPressureOnTarget": "Blutdruck im Zielbereich",
"restingPulse": "Ruhepuls",
"sleepDuration": "Schlafdauer",
"bmi": "BMI",
"bodyFat": "Körperfett",
"stepsPerDay": "Schritte/Tag",
"medicationCompliance": "Einnahmetreue",
"mood": "Stimmung",
"moodStability": "Stimmungsstabilität",
"steps": "Schritte",
"average30d": "30-Tage-Schnitt:",
"currentValue": "Aktuell: {value} {unit}",
"targetRangeValue": "Zielbereich: {min}–{max} {unit}",
"sourceLabel": "Quelle: {source}",
"inTarget": "Im Zielbereich",
"aboveTarget": "{delta} {unit} über Zielbereich",
"belowTarget": "{delta} {unit} unter Zielbereich",
"diastolic": "Diastolisch",
"noMeasurementYet": "Noch keine Messungen",
"noMeasurementData": "Keine Messdaten verfügbar. Füge Messwerte hinzu, um deine Ziele zu sehen.",
"profileIncomplete": "Profil unvollständig",
"profileIncompleteHeight": "Trage deine Körpergröße im Profil ein, um BMI-Ziele zu aktivieren.",
"profileIncompleteAge": "Trage dein Geburtsdatum im Profil ein, um altersspezifische Ziele zu aktivieren.",
"profileIncompleteHeightAge": "Trage Körpergröße und Geburtsdatum im Profil ein, um personalisierte Ziele zu sehen.",
"compliancePerMedication": "Einnahmetreue pro Medikament",
"glucoseFasting": "Glukose — nüchtern",
"glucosePostprandial": "Glukose — postprandial",
"glucoseRandom": "Glukose — beliebig",
"glucoseBedtime": "Glukose — vor dem Schlafen",
"loadError": "Zielwerte konnten nicht geladen werden"
},
"passwordStrength": {
"veryWeak": "Sehr schwach",
"weak": "Schwach",
"acceptable": "Akzeptabel",
"strong": "Stark",
"veryStrong": "Sehr stark",
"tooShort": "Zu kurz",
"characters": "Zeichen",
"minLength": "Mindestens {count} Zeichen erforderlich."
},
"telegram": {
"errorInvalidAction": "Ungültige Aktion.",
"errorMedicationNotFound": "Medikament nicht gefunden.",
"errorMedicationInactive": "Medikament nicht gefunden oder inaktiv.",
"errorUnknownAction": "Unbekannte Aktion.",
"snoozeOneHour": "1 Stunde",
"snoozeThreeHours": "3 Stunden",
"snoozedFor": "{name} für {duration} zurückgestellt.",
"skipped": "{name} übersprungen.",
"alreadyRecorded": "{name} war bereits erfasst.",
"recordedAsTaken": "{name} als eingenommen erfasst.",
"confirmed": "{name} bestätigt.",
"genericConfirmed": "Bestätigt.",
"cancelled": "Abgebrochen.",
"noActiveMedications": "Keine aktiven Medikamente gefunden.",
"whichMedication": "<b>Welches Medikament eingenommen?</b>",
"cancelButton": "Abbrechen",
"askMedicationName": "Bitte Medikament angeben, z.B. <b>genommen Ramipril</b>.",
"medicationNotFoundExact": "Medikament nicht gefunden. Bitte Namen exakt wie in HealthLog senden.",
"helpHeader": "<b>Verfügbare Befehle:</b>",
"helpBody": "/help — Diese Hilfe anzeigen\n/add — Einnahme erfassen\n/start — Bot starten\n\n<b>Textbefehle:</b>\ngenommen &lt;Name&gt; — Einnahme bestätigen\n\n<b>Über die Buttons in Erinnerungen:</b>\n• Genommen — Einnahme bestätigen\n• 🕐 1h / 🕐 3h — Erinnerung verschieben\n• ⏭ Überspringen — Einnahme überspringen\n• ✓ Bestätigen — Verpasste Einnahme bestätigen"
}
}
+476 -55
View File
@@ -36,7 +36,93 @@
"replace": "Replace",
"add": "Add",
"import": "Import",
"test": "Test"
"test": "Test",
"reset": "Reset",
"copy": "Copy",
"copyDetails": "Copy details",
"retry": "Retry",
"reportIssue": "Report issue"
},
"doctorReport": {
"title": "Health Report",
"subtitle": "HealthLog — Personal Health Report",
"patient": "Patient",
"dateOfBirth": "Date of birth",
"gender": "Gender",
"genderMale": "Male",
"genderFemale": "Female",
"genderOther": "Non-binary",
"height": "Height",
"period": "Reporting period",
"createdOn": "Created on",
"vitalsTitle": "Vital signs — Summary",
"colParameter": "Parameter",
"colCurrent": "Current",
"colAvgPeriod": "Avg. period",
"colMin": "Min",
"colMax": "Max",
"colN": "n",
"bpClassificationTitle": "Blood pressure — ESC/ESH classification (2018)",
"avgBp": "Average blood pressure",
"classification": "Classification",
"bmiTitle": "Body Mass Index (BMI)",
"bmiRow": "BMI: {bmi} kg/m² — Classification: {class} (WHO)",
"complianceTitle": "Medication compliance",
"colMedication": "Medication",
"colTaken": "Taken",
"colSkipped": "Skipped",
"colMissed": "Missed",
"colTotal": "Total",
"colComplianceRate": "Compliance rate",
"moodTitle": "Mood trajectory",
"moodSummary": "Average: {avg} / 5.0 · Entries: {count} · Range: {min}–{max}",
"colMood": "Mood",
"colCount": "Count",
"colShare": "Share",
"footerDisclaimer1": "This report was generated automatically from self-tracked data and is intended to support",
"footerDisclaimer2": "the doctor–patient conversation. It does not replace a medical diagnosis. Correlations do not imply causation.",
"footerSource": "Source: HealthLog | Timezone: Europe/Berlin | Created: {timestamp}",
"typeWeight": "Body weight",
"typeBpSys": "Systolic blood pressure",
"typeBpDia": "Diastolic blood pressure",
"typePulse": "Resting pulse",
"typeBodyFat": "Body fat",
"typeSleep": "Sleep duration",
"typeSteps": "Activity (steps)",
"typeTotalBodyWater": "Total body water",
"typeBoneMass": "Bone mass",
"typeOxygenSaturation": "Oxygen saturation",
"unitSteps": "steps",
"bmiUnderweight": "Underweight",
"bmiNormal": "Normal weight",
"bmiOverweight": "Overweight (pre-obese)",
"bmiObeseGrade1": "Obesity grade I",
"bmiObeseGrade2": "Obesity grade II",
"bmiObeseGrade3": "Obesity grade III",
"bpOptimal": "Optimal",
"bpNormal": "Normal",
"bpHighNormal": "High-normal",
"bpHypertensionGrade1": "Hypertension grade 1",
"bpHypertensionGrade2": "Hypertension grade 2",
"bpHypertensionGrade3": "Hypertension grade 3",
"moodAwful": "Very bad",
"moodBad": "Bad",
"moodNeutral": "Neutral",
"moodGood": "Good",
"moodGreat": "Very good",
"typeGlucoseFasting": "Glucose (fasting)",
"typeGlucosePostprandial": "Glucose (postprandial)",
"typeGlucoseRandom": "Glucose (random)",
"typeGlucoseBedtime": "Glucose (bedtime)",
"glucoseClassificationTitle": "Blood glucose — reference ranges (ADA 2024 / DDG)",
"glucoseRow": "{label}: avg {avg} {unit} (target {rangeMin}–{rangeMax}) — {class}",
"glucoseInTarget": "in target",
"glucoseBelowTarget": "below target",
"glucoseAboveTarget": "above target"
},
"errorBoundary": {
"title": "Something went wrong",
"description": "An unexpected error occurred. You can retry, copy the technical details, or open a bug report."
},
"nav": {
"home": "Home",
@@ -90,6 +176,7 @@
"backToPasskey": "Back to Passkey",
"passkeySetupLater": "You can set up passkeys at any time in Settings.",
"passwordMinLength": "Min. 12 characters",
"passwordTooShort": "Password must be at least {minLength} characters long.",
"passwordStrength": "Password strength",
"strengthWeak": "Very weak",
"strengthFair": "Weak",
@@ -113,6 +200,8 @@
"bloodPressureDia": "Blood Pressure (Dia)",
"pulse": "Pulse",
"bodyFat": "Body Fat",
"sleep": "Sleep",
"steps": "Steps",
"bpInTarget": "BP in Target",
"days30Short": "30d",
"medications": "Medications",
@@ -127,7 +216,17 @@
"compliance7d": "Compliance (7 days)",
"complianceGood": "Good",
"complianceLow": "Low",
"mood": "Mood"
"mood": "Mood",
"customizeTitle": "Dashboard layout",
"customizeSubtitle": "Choose which cards appear on your dashboard and in what order. Defaults work out of the box.",
"layoutReset": "Reset to defaults",
"layoutSaveSuccess": "Dashboard layout saved",
"layoutSaveError": "Could not save dashboard layout",
"layoutResetSuccess": "Reset to default layout",
"layoutUsingDefaults": "Using default layout",
"layoutCustomized": "Custom layout active",
"moveUp": "Move up",
"moveDown": "Move down"
},
"measurements": {
"title": "Measurements",
@@ -148,6 +247,17 @@
"typeBodyFat": "Body Fat",
"typeSleep": "Sleep",
"typeSteps": "Steps",
"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)",
"glucoseContextRandom": "Random / casual",
"glucoseContextBedtime": "Before bed",
"glucoseUnit": "Glucose display unit",
"glucoseUnitHint": "Stored canonically in mg/dL; display unit is your preference.",
"unitHours": "hours",
"unitSteps": "steps",
"sourceManual": "Manual",
@@ -385,7 +495,21 @@
"phaseAfterEnd": "after end",
"phaseResetDefaults": "Reset to defaults",
"phaseSaved": "Reminder phases saved",
"phaseReset": "Reset to defaults"
"phaseReset": "Reset to defaults",
"sourceWeb": "Website",
"sourceApi": "External API",
"sourceReminder": "Telegram / Reminder",
"sourceImport": "CSV Import",
"unitDrops": "Drops",
"unitTablets": "Tablet(s)",
"unitCapsules": "Capsule(s)",
"unitPieces": "Piece(s)",
"namePlaceholder": "e.g. Ibuprofen",
"dosePlaceholder": "e.g. 400",
"labelPlaceholder": "e.g. Morning",
"defaultDose": "Default",
"dayDeactivate": "Deactivate {day}",
"dayActivate": "Activate {day}"
},
"charts": {
"title": "History",
@@ -406,17 +530,22 @@
"noData": "No data in selected time range",
"movingAverage": "Moving average",
"target": "Target range",
"points7Label": "7P",
"points7Title": "Last seven data points",
"points30Label": "30P",
"points30Title": "Last thirty data points",
"points90Label": "90P",
"points90Title": "Last ninety data points",
"points7Label": "7 pts",
"points7Title": "Last 7 data points",
"points30Label": "30 pts",
"points30Title": "Last 30 data points",
"points90Label": "90 pts",
"points90Title": "Last 90 data points",
"pointsAllLabel": "All",
"pointsAllTitle": "All available data points",
"systolic": "Systolic",
"diastolic": "Diastolic",
"bodyWater": "Body water",
"boneMass": "Bone mass",
"spo2": "SpO₂",
"movingAverage7d": "7d avg",
"avg7dShort": "7d",
"avg30dShort": "30d",
"trend": "Trend",
"targetRanges": "Target ranges",
"perWeek": "/week",
@@ -429,9 +558,58 @@
"colorRed": "Red:",
"mood": "Mood",
"moodScore": "Mood Score",
"moodMA": "7-Day Average"
"moodMA": "7-Day Average",
"moodLabel1": "Awful",
"moodLabel2": "Bad",
"moodLabel3": "Okay",
"moodLabel4": "Good",
"moodLabel5": "Great",
"weekdays": {
"mon": "Mon",
"wed": "Wed",
"fri": "Fri",
"sun": "Sun"
},
"months": {
"jan": "Jan",
"feb": "Feb",
"mar": "Mar",
"apr": "Apr",
"may": "May",
"jun": "Jun",
"jul": "Jul",
"aug": "Aug",
"sep": "Sep",
"oct": "Oct",
"nov": "Nov",
"dec": "Dec"
},
"heatmapOnTime": "on time",
"heatmapLate": "late",
"heatmapVeryLate": "very late",
"legendOnTime": "On time",
"legendLate": "Late",
"legendVeryLate": "Very late",
"legendMissed": "Missed"
},
"insights": {
"aiAnalysisTitle": "AI Health Analysis",
"noAnalysisYet": "No analysis yet.",
"noProviderConfigured": "AI provider not configured.",
"startAnalysis": "Start analysis",
"refreshAnalysis": "Refresh analysis",
"keyTakeaway": "Key Takeaway",
"findingsTitle": "Findings",
"correlationsTitle": "Correlations",
"recommendationsTitle": "Recommendations",
"dataFoundation": "Data coverage",
"dataGaps": "Data gaps:",
"lastUpdated": "Last updated",
"confidenceHoch": "High",
"confidenceMittel": "Medium",
"confidenceGering": "Low",
"loadError": "Failed to load insights",
"generationFailed": "The analysis couldn't be generated. Please try again.",
"title": "Insights",
"subtitle": "AI-powered analysis of your health data",
"overviewSubtitle": "Trends, risks, progress at a glance",
@@ -543,47 +721,55 @@
"moodWeightNone": "No detectable correlation between mood and weight.",
"moodWeightStrong": "There is a strong correlation between mood and weight.",
"moodWeightWeak": "There is a weak correlation between mood and weight.",
"notEnoughMoodCorrelationData": "Not enough mood data for correlation analysis."
"notEnoughMoodCorrelationData": "Not enough mood data for correlation analysis.",
"refreshButton": "Refresh",
"generateButton": "Generate",
"analyzing": "Analyzing health data...",
"confidence": "Confidence",
"confidence_niedrig": "low",
"confidence_mittel": "medium",
"confidence_hoch": "high",
"cached": "Cached",
"sectionChanged": "What has changed",
"sectionStable": "What is stable",
"sectionDrivers": "Possible correlations",
"sectionNextSteps": "Next steps",
"generatePrompt": "Click \"Generate\" for AI-powered insights about your health data."
},
"targets": {
"title": "Targets",
"subtitle": "Personal reference ranges based on your profile",
"introText": "These targets are personal reference ranges derived from your profile and established medical guidelines. They help you quickly classify your key health values and detect changes early. The ranges are calculated individually for you, for example based on age, sex, and height. For each value, you can immediately see whether you are within the target range or above or below it. This gives you a compact overview of which targets you are currently meeting well and where improvement is still possible.",
"current": "Current",
"average30d": "30-day average:",
"targetRange": "Target range",
"classification": "Rating",
"source": "Source",
"sourceLabel": "Source: {source}",
"trend": "Trend",
"weight": "Weight",
"bloodPressure": "Blood Pressure",
"pulse": "Resting Pulse",
"sleep": "Sleep Duration",
"bmi": "BMI",
"bodyFat": "Body Fat",
"steps": "Steps/Day",
"compliance": "Compliance",
"compliancePerMedication": "Per medication",
"diastolic": "Diastolic",
"noProfile": "Please complete your profile to get personalized targets.",
"profileIncomplete": "Profile incomplete",
"profileIncompleteHeightAge": "Add your height and date of birth in your profile to get personalized target ranges.",
"profileIncompleteHeight": "Add your height in your profile to calculate weight target range.",
"profileIncompleteAge": "Add your date of birth in your profile to get age-based target ranges.",
"noMeasurementData": "No measurement data available yet.",
"noMeasurementYet": "No measurement available yet",
"currentValue": "Current: {value} {unit}",
"targetRangeValue": "Target range: {min}–{max} {unit}",
"inTarget": "In target range",
"belowTarget": "{delta} {unit} below target",
"aboveTarget": "{delta} {unit} above target",
"loadError": "Error loading data",
"classificationOptimal": "Optimal",
"classificationNormal": "Normal",
"classificationElevated": "Elevated",
"classificationHigh": "High",
"classificationLow": "Low"
"thresholds": {
"title": "Personal thresholds",
"subtitle": "Override the computed target range for any metric. Defaults stay in effect until you set a value here.",
"defaultLabel": "Default",
"overrideLabel": "Your override",
"autoModeLabel": "Auto",
"overrideModeLabel": "Override",
"resetAction": "Reset to default",
"resetAllAction": "Reset all to default",
"minLabel": "Min",
"maxLabel": "Max",
"unitSuffix": "({unit})",
"outOfBoundsHint": "Allowed range: {min}–{max} {unit}",
"overrideWarning": "Overriding with values outside guideline ranges may produce unhealthy targets. Only do this on doctor's advice.",
"saveSuccess": "Thresholds updated",
"saveError": "Could not save thresholds",
"resetSuccess": "Reset to default",
"sourceOverride": "User override",
"metricWeight": "Weight",
"metricBpSys": "Blood pressure (systolic)",
"metricBpDia": "Blood pressure (diastolic)",
"metricPulse": "Resting pulse",
"metricBodyFat": "Body fat",
"metricSleep": "Sleep duration",
"metricSteps": "Daily steps",
"metricGlucoseFasting": "Glucose — fasting",
"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"
},
"onboarding": {
"welcome": "Welcome to HealthLog!",
@@ -603,7 +789,12 @@
"targetsDescription": "Set optional target values for blood pressure and weight.",
"targetSys": "Systolic (mmHg)",
"targetDia": "Diastolic (mmHg)",
"targetWeight": "Target weight (kg)"
"targetWeight": "Target weight (kg)",
"errorProfile": "Could not save your profile. Please try again.",
"errorMedication": "Could not add the medication \"{name}\". Please try again or add it later in Medications.",
"errorTargets": "Could not save your target values. Please try again.",
"errorGeneric": "Something went wrong while finishing setup. Please try again.",
"medScheduleHint": "We'll add a default reminder window (08:00–09:00 daily). You can adjust it anytime in Medications."
},
"settings": {
"title": "Settings",
@@ -612,6 +803,7 @@
"categoryGeneral": "General",
"categoryNotifications": "Notifications",
"categoryAi": "AI",
"categoryPersonalization": "Personalization",
"categoryIntegration": "Integration",
"categoryApi": "API",
"profile": "Profile",
@@ -651,6 +843,11 @@
"passkeyOptionsError": "Could not load passkey options",
"passkeyRegistrationFailed": "Passkey registration failed",
"passkeyRegistrationCancelled": "Passkey registration cancelled",
"passkeyNotSupported": "Your browser or device doesn't support passkeys yet. Try a different browser or device.",
"passkeyAlreadyRegistered": "This device is already registered as a passkey for your account.",
"passkeySecurityBlocked": "Your browser blocked the passkey request for security reasons. Make sure you're on a trusted HTTPS origin and try again.",
"passkeyTimeout": "The passkey prompt timed out. Please try again.",
"passkeyUnknownError": "Passkey registration failed: {message}",
"deletePasskey": "Delete passkey?",
"deletePasskeyDescription": "The passkey will be permanently deleted.",
"singleDevice": "Single device",
@@ -697,6 +894,9 @@
"apiKeyStored": "Key stored — enter new one to replace",
"apiKeySaved": "API key saved",
"apiKeyRemoved": "API key removed",
"codexConnected": "ChatGPT connected successfully! AI insights are now active.",
"codexDisconnected": "ChatGPT connection removed.",
"codexConnectionFailed": "ChatGPT connection failed. Please try again.",
"rawData": "Send raw data",
"rawDataOnDescription": "Aggregated metrics plus anonymized raw points from the last 30 days are sent. This includes per-metric measurements (for example weight, blood pressure, pulse) with time context so AI can detect patterns, outliers, and correlations more reliably. Name, email, and direct account identifiers are not transmitted.",
"rawDataOffDescription": "Only summarized metrics are sent (for example averages, trends, minimum/maximum). No individual points and no exact time context. This is more privacy-preserving, but AI can be less precise for short-term patterns and fluctuations.",
@@ -715,6 +915,7 @@
"withingsCredentialsSavedPlaceholderSecret": "Saved — enter new to replace",
"withingsSaveCredentials": "Save credentials",
"withingsConnected": "Connected",
"configured": "Configured",
"withingsTokenExpired": "Token expired",
"withingsLastSync": "Last sync",
"withingsSync": "Sync now",
@@ -843,6 +1044,8 @@
"admin": {
"title": "Administration",
"subtitle": "System status, users, and settings",
"settingsSaveError": "Failed to save settings",
"settingsSaveValidationError": "Please check the highlighted fields",
"sectionsTitle": "Admin",
"systemStatus": "System Status",
"version": "Version",
@@ -870,6 +1073,8 @@
"telegramGlobal": "Allow Telegram notifications system-wide.",
"ntfyGlobal": "Allow ntfy notifications system-wide.",
"webPushGlobal": "Allow browser push notifications system-wide.",
"integrationWebPush": "Web Push",
"integrationBugReport": "Bug Report",
"apiGlobal": "Allow API endpoints and API tokens system-wide.",
"moodLogGlobal": "Allow moodLog integration system-wide.",
"sectionGeneral": "General",
@@ -991,7 +1196,55 @@
"reminderCheckNotifOn": "Notifications on",
"reminderCheckNotifOff": "Notifications off",
"reminderCheckEventsToday": "Events today",
"reminderCheckNotifSent": "Sent"
"reminderCheckNotifSent": "Sent",
"feedback": {
"title": "Feedback Inbox",
"description": "User-submitted feedback, bug reports, and feature requests.",
"tabOpen": "Open",
"tabAcknowledged": "Acknowledged",
"tabResolved": "Resolved",
"tabArchived": "Archived",
"subject": "Subject",
"category": "Category",
"user": "User",
"createdAt": "Submitted",
"actions": "Actions",
"details": "Details",
"noEntries": "No feedback in this category.",
"loading": "Loading feedback...",
"anonymous": "anonymous",
"categoryBug": "Bug",
"categoryFeature": "Feature",
"categoryQuestion": "Question",
"categoryOther": "Other",
"statusOpen": "Open",
"statusAcknowledged": "Acknowledged",
"statusResolved": "Resolved",
"statusArchived": "Archived",
"dialogTitle": "Feedback details",
"metadataHeading": "Metadata",
"metaUrl": "URL",
"metaLocale": "Locale",
"metaUserAgent": "User agent",
"metaAppVersion": "App version",
"screenshotHeading": "Screenshot",
"adminNote": "Admin note",
"adminNotePlaceholder": "Internal note (not visible to user)",
"saveNote": "Save note",
"noteSaved": "Note saved",
"actionAcknowledge": "Acknowledge",
"actionResolve": "Resolve",
"actionArchive": "Archive",
"actionPublishGithub": "Publish to GitHub",
"publishedToGithub": "Published to GitHub",
"viewIssue": "View issue",
"publishSuccess": "Issue created",
"publishFailed": "GitHub publish failed",
"updateFailed": "Update failed",
"archiveConfirm": "Archive this feedback entry?",
"submittedBy": "Submitted by",
"sectionTitle": "Feedback"
}
},
"achievements": {
"title": "Achievements",
@@ -1171,10 +1424,9 @@
},
"bugreport": {
"title": "Bug Report",
"subtitle": "Report a bug or issue. A GitHub issue will be created automatically.",
"subtitle": "Send a bug report, feature request, or question. The server administrator will see your submission.",
"loginRequired": "Please sign in to report a bug.",
"bugTitle": "Title",
"bugTitlePlaceholder": "Brief description of the problem",
"description": "Description",
"descriptionPlaceholder": "Describe the bug in detail. What did you do? What happened? What did you expect?",
"screenshot": "Screenshot (optional)",
@@ -1185,7 +1437,19 @@
"submit": "Send",
"success": "Thank you! Your bug report has been submitted successfully.",
"viewOnGithub": "View on GitHub",
"errorCreating": "Error creating report"
"errorCreating": "Error creating report",
"notConfiguredTitle": "Bug reports not available yet",
"notConfiguredUser": "Bug reporting is disabled on this server. Please contact your administrator.",
"notConfiguredAdmin": "Bug reporting requires a GitHub integration (issue token + repository). Configure it in the admin panel under Settings → Bug reports.",
"openAdminSettings": "Open admin settings",
"category": "Type",
"categoryBug": "Bug report",
"categoryFeature": "Feature request",
"categoryQuestion": "Question",
"categoryOther": "Other feedback",
"bugTitlePlaceholder": "Short summary (optional)",
"githubEscalationNote": "Your feedback is stored locally. If the admin enables GitHub integration, it can be promoted to a public issue on request.",
"internalOnlyNote": "Your feedback is stored locally and only visible to the administrator of this server."
},
"notifications": {
"title": "Notifications",
@@ -1208,5 +1472,162 @@
"dateShort": "{month}/{day}/{year}",
"timeShort": "{hours}:{minutes}",
"dateTime": "{month}/{day}/{year}, {hours}:{minutes}"
},
"classifications": {
"bmi": {
"Underweight": "Underweight",
"Normal": "Normal",
"Overweight": "Overweight",
"ObesityGradeI": "Obesity Grade I",
"ObesityGradeII": "Obesity Grade II",
"ObesityGradeIII": "Obesity Grade III"
},
"bp": {
"Optimal": "Optimal",
"Normal": "Normal",
"HighNormal": "High-normal",
"HypertensionGrade1": "Hypertension Grade 1",
"HypertensionGrade2": "Hypertension Grade 2",
"HypertensionGrade3": "Hypertension Grade 3"
},
"pulse": {
"Bradycardia": "Bradycardia",
"Normal": "Normal",
"Elevated": "Elevated",
"Tachycardia": "Tachycardia"
},
"pulseTarget": {
"SignificantlyLow": "Significantly low",
"SlightlyLow": "Slightly low",
"OnTarget": "On target",
"SlightlyElevated": "Slightly elevated",
"SignificantlyElevated": "Significantly elevated"
},
"sleep": {
"FarTooShort": "Far too short",
"TooShort": "Too short",
"OnTarget": "On target",
"SlightlyLong": "Slightly long",
"FarTooLong": "Far too long"
},
"bodyFat": {
"Essential": "Essential",
"Athletic": "Athletic",
"Fitness": "Fitness",
"Average": "Average",
"Obese": "Obese"
},
"steps": {
"VeryLow": "Very low",
"LowActive": "Low active",
"ModeratelyActive": "Moderately active",
"Active": "Active",
"VeryActive": "Very active"
},
"general": {
"Good": "Good",
"Moderate": "Moderate",
"Low": "Low",
"VeryGood": "Very good",
"VeryStable": "Very stable",
"Stable": "Stable",
"Fluctuating": "Fluctuating"
},
"alerts": {
"bmiBelowNormalTitle": "BMI below normal range",
"bmiBelowNormalMessage": "Your BMI of {bmi} is below the normal range (18.5–24.9).",
"bmiOverweightTitle": "BMI in overweight range",
"bmiOverweightMessage": "Your BMI of {bmi} is in the overweight range. Regular exercise and a balanced diet can help.",
"bmiObesityTitle": "BMI in obesity range",
"bmiObesityMessage": "Your BMI of {bmi} is in the obesity range.",
"bmiNormalTitle": "BMI in normal range",
"bmiNormalMessage": "Your BMI of {bmi} is in the healthy range.",
"bpDangerTitle": "Blood pressure significantly elevated",
"bpDangerMessage": "Your average blood pressure ({sys}/{dia}) is classified as \"{category}\".",
"bpWarningTitle": "Blood pressure slightly elevated",
"bpWarningMessage": "Your average blood pressure ({sys}/{dia}) is in the \"{category}\" range.",
"bpTargetGoodTitle": "Good blood pressure target adherence",
"bpTargetGoodMessage": "{pct}% of your readings are within the target range.",
"bpTargetBadTitle": "Blood pressure often outside target range",
"bpTargetBadMessage": "Only {pct}% of your readings are within the target range.",
"weightIncreasingTitle": "Weight increasing",
"weightIncreasingMessage": "Your weight is increasing by approx. {change} kg per week over the last 30 days.",
"weightDecreasingTitle": "Weight decreasing",
"weightDecreasingMessage": "Your weight is decreasing by approx. {change} kg per week over the last 30 days.",
"pulseOutliersTitle": "Pulse outliers detected",
"pulseOutliersMessage": "{count} unusual pulse readings in the last 30 days.",
"complianceLowTitle": "Low compliance: {name}",
"complianceLowMessage": "Your 7-day compliance for {name} is only {pct}%. Regular intake is important.",
"complianceGoodTitle": "Excellent compliance: {name}",
"complianceGoodMessage": "{pct}% compliance over the last 7 days. Keep it up!"
}
},
"targets": {
"title": "Targets",
"introText": "Your current values compared to recommended target ranges. Guideline sources are linked on each card.",
"weight": "Weight",
"bloodPressure": "Blood pressure",
"bloodPressureOnTarget": "Blood pressure on target",
"restingPulse": "Resting pulse",
"sleepDuration": "Sleep duration",
"bmi": "BMI",
"bodyFat": "Body fat",
"stepsPerDay": "Steps/day",
"medicationCompliance": "Medication compliance",
"mood": "Mood",
"moodStability": "Mood stability",
"steps": "steps",
"average30d": "30-day average:",
"currentValue": "Current: {value} {unit}",
"targetRangeValue": "Target: {min}–{max} {unit}",
"sourceLabel": "Source: {source}",
"inTarget": "Within target range",
"aboveTarget": "{delta} {unit} above target",
"belowTarget": "{delta} {unit} below target",
"diastolic": "Diastolic",
"noMeasurementYet": "No measurements yet",
"noMeasurementData": "No measurement data available. Add some measurements to see your targets.",
"profileIncomplete": "Profile incomplete",
"profileIncompleteHeight": "Add your height in profile settings to enable BMI targets.",
"profileIncompleteAge": "Add your date of birth in profile settings for age-specific targets.",
"profileIncompleteHeightAge": "Add your height and date of birth in profile settings for personalized targets.",
"compliancePerMedication": "Compliance per medication",
"glucoseFasting": "Glucose — fasting",
"glucosePostprandial": "Glucose — post-meal",
"glucoseRandom": "Glucose — random",
"glucoseBedtime": "Glucose — bedtime",
"loadError": "Failed to load targets"
},
"passwordStrength": {
"veryWeak": "Very weak",
"weak": "Weak",
"acceptable": "Acceptable",
"strong": "Strong",
"veryStrong": "Very strong",
"tooShort": "Too short",
"characters": "characters",
"minLength": "At least {count} characters required."
},
"telegram": {
"errorInvalidAction": "Invalid action.",
"errorMedicationNotFound": "Medication not found.",
"errorMedicationInactive": "Medication not found or inactive.",
"errorUnknownAction": "Unknown action.",
"snoozeOneHour": "1 hour",
"snoozeThreeHours": "3 hours",
"snoozedFor": "{name} snoozed for {duration}.",
"skipped": "{name} skipped.",
"alreadyRecorded": "{name} was already recorded.",
"recordedAsTaken": "{name} recorded as taken.",
"confirmed": "{name} confirmed.",
"genericConfirmed": "Confirmed.",
"cancelled": "Cancelled.",
"noActiveMedications": "No active medications found.",
"whichMedication": "<b>Which medication did you take?</b>",
"cancelButton": "Cancel",
"askMedicationName": "Please specify a medication, e.g. <b>taken Ramipril</b>.",
"medicationNotFoundExact": "Medication not found. Please send the name exactly as in HealthLog.",
"helpHeader": "<b>Available commands:</b>",
"helpBody": "/help — show this help\n/add — record an intake\n/start — start the bot\n\n<b>Text commands:</b>\ntaken &lt;name&gt; — confirm an intake\n\n<b>Buttons in reminders:</b>\n• Taken — confirm intake\n• 🕐 1h / 🕐 3h — postpone reminder\n• ⏭ Skip — skip intake\n• ✓ Confirm — confirm a missed intake"
}
}
+25 -24
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "0.1.0",
"version": "1.3.3",
"private": true,
"packageManager": "pnpm@10.31.0",
"scripts": {
@@ -22,48 +22,49 @@
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@node-rs/argon2": "^2.0.2",
"@prisma/adapter-pg": "^7.4.2",
"@prisma/client": "^7.4.2",
"@simplewebauthn/browser": "^13.2.2",
"@simplewebauthn/server": "^13.2.3",
"@tanstack/react-query": "^5.90.21",
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.0",
"@tanstack/react-query": "^5.100.9",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dotenv": "^17.3.1",
"jspdf": "^4.2.0",
"dotenv": "^17.4.2",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.7",
"lucide-react": "^0.575.0",
"next": "16.1.6",
"lucide-react": "^1.14.0",
"next": "16.2.4",
"next-themes": "^0.4.6",
"pg-boss": "^12.13.0",
"prisma": "^7.4.2",
"pg-boss": "^12.18.2",
"prisma": "^7.8.0",
"radix-ui": "^1.4.3",
"react": "19.2.4",
"react-dom": "19.2.3",
"react-hook-form": "^7.71.1",
"recharts": "^3.7.0",
"react": "19.2.5",
"react-dom": "19.2.5",
"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": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/node": "^25",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/web-push": "^3.6.4",
"@vitejs/plugin-react": "^5.1.4",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"prettier": "^3.5.0",
"prettier-plugin-tailwindcss": "^0.6.0",
"shadcn": "^3.8.5",
"eslint-config-next": "16.2.4",
"pdf-parse": "^2.4.5",
"prettier": "^3.8.3",
"prettier-plugin-tailwindcss": "^0.8.0",
"shadcn": "^4.6.0",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5",
"vitest": "^4.0.18"
"typescript": "^6",
"vitest": "^4.1.5"
},
"pnpm": {
"onlyBuiltDependencies": [
+2020 -1897
View File
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,13 @@
-- AlterTable: Remove openai_key_encrypted, add Codex OAuth fields to users
ALTER TABLE "users" DROP COLUMN "openai_key_encrypted";
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(3);
ALTER TABLE "users" ADD COLUMN "codex_connected_at" TIMESTAMP(3);
ALTER TABLE "users" ADD COLUMN "codex_connection_status" TEXT NOT NULL DEFAULT 'disconnected';
-- AlterTable: Add admin AI provider fields to app_settings
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';
@@ -0,0 +1,73 @@
-- v1.2: Personalization + Blood Glucose + Built-in Feedback
--
-- This migration is purely additive.
-- - New enums: measurement_type.BLOOD_GLUCOSE, glucose_context, feedback_category, feedback_status.
-- - New columns on users: thresholds_json, dashboard_widgets_json, glucose_unit,
-- ai_provider, ai_model, ai_base_url, ai_anthropic_key_encrypted, ai_local_key_encrypted.
-- - New column on measurements: glucose_context (only set for BLOOD_GLUCOSE rows).
-- - New table: feedback.
-- ── Enums ────────────────────────────────────────────────────────────────
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'BLOOD_GLUCOSE';
CREATE TYPE "glucose_context" AS ENUM ('FASTING', 'POSTPRANDIAL', 'RANDOM', 'BEDTIME');
CREATE TYPE "feedback_category" AS ENUM ('BUG', 'FEATURE_REQUEST', 'QUESTION', 'OTHER');
CREATE TYPE "feedback_status" AS ENUM ('OPEN', 'ACKNOWLEDGED', 'RESOLVED', 'ARCHIVED');
-- ── users personalization columns ───────────────────────────────────────
ALTER TABLE "users"
ADD COLUMN "thresholds_json" JSONB,
ADD COLUMN "dashboard_widgets_json" JSONB,
ADD COLUMN "glucose_unit" TEXT,
ADD COLUMN "ai_provider" TEXT,
ADD COLUMN "ai_model" TEXT,
ADD COLUMN "ai_base_url" TEXT,
ADD COLUMN "ai_anthropic_key_encrypted" TEXT,
ADD COLUMN "ai_local_key_encrypted" TEXT;
-- ── measurements.glucose_context ────────────────────────────────────────
ALTER TABLE "measurements"
ADD COLUMN "glucose_context" "glucose_context";
-- Enforce: glucose_context is only set for BLOOD_GLUCOSE rows.
ALTER TABLE "measurements"
ADD CONSTRAINT "measurements_glucose_context_requires_type"
CHECK (
(type = 'BLOOD_GLUCOSE' AND glucose_context IS NOT NULL)
OR
(type <> 'BLOOD_GLUCOSE' AND glucose_context IS NULL)
);
-- ── feedback table ──────────────────────────────────────────────────────
CREATE TABLE "feedback" (
"id" TEXT NOT NULL,
"user_id" TEXT,
"email" TEXT,
"category" "feedback_category" NOT NULL DEFAULT 'BUG',
"subject" TEXT NOT NULL,
"description" TEXT NOT NULL,
"screenshot_base64" TEXT,
"metadata" JSONB,
"status" "feedback_status" NOT NULL DEFAULT 'OPEN',
"admin_note" TEXT,
"github_issue_url" TEXT,
"notified_email" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "feedback_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "feedback_status_created_at_idx" ON "feedback" ("status", "created_at");
CREATE INDEX "feedback_user_id_idx" ON "feedback" ("user_id");
ALTER TABLE "feedback"
ADD CONSTRAINT "feedback_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,17 @@
-- v1.3: Add body-composition measurement types
--
-- Closes #89: total body water and bone mass as first-class measurement types.
-- 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, 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.
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'TOTAL_BODY_WATER';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'BONE_MASS';
@@ -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';
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"
+150 -3
View File
@@ -24,8 +24,12 @@ model User {
gender String? @map("gender") // "MALE" | "FEMALE" | null = keine Angabe
timezone String @default("Europe/Berlin")
// OpenAI Insights (optional)
openaiKeyEncrypted String? @map("openai_key_encrypted") // AES-256-GCM encrypted
// Codex OAuth (ChatGPT subscription)
codexAccessTokenEncrypted String? @map("codex_access_token_encrypted")
codexRefreshTokenEncrypted String? @map("codex_refresh_token_encrypted")
codexTokenExpiresAt DateTime? @map("codex_token_expires_at")
codexConnectedAt DateTime? @map("codex_connected_at")
codexConnectionStatus String @default("disconnected") @map("codex_connection_status")
insightsPrivacyMode String @default("aggregated") @map("insights_privacy_mode") // "aggregated" | "raw"
insightsCachedAt DateTime? @map("insights_cached_at")
insightsCachedText String? @map("insights_cached_text") // cached JSON output
@@ -49,9 +53,35 @@ model User {
// Locale (null = use system/admin default)
locale String? @map("locale") // "de" | "en" | null
// Personalization
// Per-user overrides for computed default thresholds. Shape:
// { "WEIGHT": { "min": 70, "max": 85 },
// "BLOOD_PRESSURE_SYS": { "min": 110, "max": 130 }, ... }
// Null or missing metric → use computed default.
thresholdsJson Json? @map("thresholds_json")
// Per-user dashboard layout — widget visibility + ordering.
// Shape: { "widgets": [{ "id": "weight", "visible": true, "order": 0 }, ...], "version": 1 }
dashboardWidgetsJson Json? @map("dashboard_widgets_json")
// Blood-glucose display unit preference. Canonical storage is mg/dL.
glucoseUnit String? @map("glucose_unit") // "mg/dL" | "mmol/L" | null = default "mg/dL"
// AI provider selection for insights. Null = OPENAI fallback.
aiProvider String? @map("ai_provider") // "OPENAI" | "ANTHROPIC" | "LOCAL" | "CHATGPT_OAUTH"
aiModel String? @map("ai_model")
aiBaseUrl String? @map("ai_base_url")
aiAnthropicKeyEncrypted String? @map("ai_anthropic_key_encrypted")
aiLocalKeyEncrypted String? @map("ai_local_key_encrypted")
// Onboarding
onboardingCompletedAt DateTime? @map("onboarding_completed_at")
// HealthKit (iOS) integration config — list of per-metric direction toggles.
// Shape: { entries: [{ id, kind, direction, enabled }], lastSyncedAt }
healthKitConfigJson Json? @map("healthkit_config_json")
healthKitLastSyncedAt DateTime? @map("healthkit_last_synced_at")
// Display name (optional override of username for greetings).
displayName String? @map("display_name")
// Relations
passkeys Passkey[]
sessions Session[]
@@ -67,6 +97,9 @@ model User {
pushSubscriptions PushSubscription[]
dataBackups DataBackup[]
achievements UserAchievement[]
feedback Feedback[]
idempotencyKeys IdempotencyKey[]
devices Device[]
@@map("users")
}
@@ -126,10 +159,25 @@ enum MeasurementType {
BODY_FAT
SLEEP_DURATION
ACTIVITY_STEPS
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")
}
/// Context for glucose measurements — affects which target range applies.
/// NULL for non-glucose measurements.
enum GlucoseContext {
FASTING // ≥8h without food, typically morning
POSTPRANDIAL // 2h after meal
RANDOM // any time, non-fasting, non-post-meal
BEDTIME // before sleep
@@map("glucose_context")
}
enum MeasurementSource {
MANUAL
WITHINGS
@@ -143,11 +191,13 @@ model Measurement {
userId String @map("user_id")
type MeasurementType
value Float
unit String // kg, mmHg, bpm, %, hours, steps
unit String // kg, mmHg, bpm, %, hours, steps, mg/dL
source MeasurementSource @default(MANUAL)
measuredAt DateTime @map("measured_at")
notes String? // encrypted at app level if sensitive
externalId String? @map("external_id") // Withings grpid for dedup
// Only set for BLOOD_GLUCOSE measurements. NULL for all others.
glucoseContext GlucoseContext? @map("glucose_context")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -374,9 +424,60 @@ model AppSettings {
reminderLateMinutes Int @default(120) @map("reminder_late_minutes")
reminderMissedMinutes Int @default(240) @map("reminder_missed_minutes")
// Admin AI Provider (fallback for users without Codex OAuth)
adminAiKeyEncrypted String? @map("admin_ai_key_encrypted")
adminAiModel String @default("gpt-4o-mini") @map("admin_ai_model")
adminAiBaseUrl String @default("https://api.openai.com/v1") @map("admin_ai_base_url")
@@map("app_settings")
}
// ─── Built-in Feedback ───────────────────────────────────────
enum FeedbackCategory {
BUG
FEATURE_REQUEST
QUESTION
OTHER
@@map("feedback_category")
}
enum FeedbackStatus {
OPEN
ACKNOWLEDGED
RESOLVED
ARCHIVED
@@map("feedback_status")
}
/// Stores user-submitted feedback without requiring external GitHub config.
/// Admin inbox lives at /admin#feedback; optional escalation publishes to a
/// GitHub issue reusing the existing integration when configured.
model Feedback {
id String @id @default(cuid())
userId String? @map("user_id") // Null after user deletion; email snapshot remains.
email String? // Snapshot at submission time for triage continuity.
category FeedbackCategory @default(BUG)
subject String
description String
screenshotBase64 String? @map("screenshot_base64") // Optional, <5MB, PNG/JPG.
metadata Json? // URL, userAgent, locale, appVersion, …
status FeedbackStatus @default(OPEN)
adminNote String? @map("admin_note")
gitHubIssueUrl String? @map("github_issue_url")
notifiedEmail Boolean @default(false) @map("notified_email")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
@@index([status, createdAt])
@@index([userId])
@@map("feedback")
}
// ─── Audit Log ─────────────────────────────────────────────
model AuditLog {
@@ -469,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 {
+554 -6
View File
@@ -19,6 +19,14 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import {
Shield,
Users,
@@ -45,10 +53,14 @@ import {
Key,
Bug,
Cog,
MessageSquare,
Inbox,
ExternalLink,
GitPullRequest,
} from "lucide-react";
import { PasswordStrength } from "@/components/ui/password-strength";
import { formatDate, formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { toast } from "sonner";
function PasswordInput(props: React.ComponentProps<typeof Input>) {
@@ -167,6 +179,11 @@ export default function AdminPage() {
<WebPushVapidSection id="section-admin-webpush" />
<BugReportSection id="section-admin-bugreport" />
<h2 className="text-muted-foreground text-sm font-semibold tracking-wider uppercase">
{t("admin.feedback.sectionTitle")}
</h2>
<FeedbackInboxSection id="section-admin-feedback" />
<h2 className="text-muted-foreground text-sm font-semibold tracking-wider uppercase">
{t("admin.sectionMedication")}
</h2>
@@ -203,6 +220,7 @@ function useAdminSettings() {
function useUpdateSettings() {
const queryClient = useQueryClient();
const { t } = useTranslations();
return useMutation({
mutationFn: async (data: Record<string, unknown>) => {
const res = await fetch("/api/admin/settings", {
@@ -210,10 +228,20 @@ function useUpdateSettings() {
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error("Failed");
if (!res.ok) {
throw new Error(await getApiErrorMessage(res));
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["admin", "settings"] });
toast.success(t("common.saved"));
},
onError: (err) => {
toast.error(
err instanceof Error && err.message
? err.message
: t("admin.settingsSaveError"),
);
},
});
}
@@ -235,6 +263,7 @@ async function getApiErrorMessage(response: Response): Promise<string> {
function SystemStatusSection({ id }: { id: string }) {
const { t } = useTranslations();
const fmt = useFormatters();
const { data: status } = useQuery({
queryKey: ["admin", "status"],
@@ -283,7 +312,7 @@ function SystemStatusSection({ id }: { id: string }) {
<StatusItem
icon={Activity}
label={t("admin.measurementsCount")}
value={status.counts.measurements.toLocaleString("de-DE")}
value={fmt.integer(status.counts.measurements)}
/>
<StatusItem
icon={Key}
@@ -349,7 +378,7 @@ function SystemStatusSection({ id }: { id: string }) {
{status.integrations.webPush && (
<StatusItem
icon={BellRing}
label="Web Push"
label={t("admin.integrationWebPush")}
value={t("admin.configured")}
className="text-dracula-green"
/>
@@ -357,7 +386,7 @@ function SystemStatusSection({ id }: { id: string }) {
{status.integrations.bugReport && (
<StatusItem
icon={Bug}
label="Bug Report"
label={t("admin.integrationBugReport")}
value={t("admin.configured")}
className="text-dracula-green"
/>
@@ -489,7 +518,7 @@ function ServicesSection({ id }: { id: string }) {
disabled={updateSettings.isPending}
/>
<SettingsToggle
label="Web Push"
label={t("admin.integrationWebPush")}
description={t("admin.webPushGlobal")}
icon={Globe}
checked={settings?.webPushGlobal ?? true}
@@ -1875,6 +1904,14 @@ function UserManagementSection({
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["admin", "users"] });
setEditingUser(null);
toast.success(t("common.saved"));
},
onError: (err) => {
toast.error(
err instanceof Error && err.message
? err.message
: t("admin.settingsSaveError"),
);
},
});
@@ -2149,3 +2186,514 @@ function UserManagementSection({
</div>
);
}
/* ─────────────────────── Feedback Inbox ─────────────────────── */
type FeedbackStatusType = "OPEN" | "ACKNOWLEDGED" | "RESOLVED" | "ARCHIVED";
type FeedbackCategoryType = "BUG" | "FEATURE_REQUEST" | "QUESTION" | "OTHER";
interface FeedbackItem {
id: string;
userId: string | null;
email: string | null;
category: FeedbackCategoryType;
subject: string;
description: string;
status: FeedbackStatusType;
adminNote: string | null;
gitHubIssueUrl: string | null;
metadata: Record<string, unknown> | null;
screenshotBase64: string | null;
createdAt: string;
updatedAt: string;
user: { username: string } | null;
}
interface FeedbackListResponse {
items: FeedbackItem[];
meta: {
total: number;
limit: number;
offset: number;
countsByStatus: Partial<Record<FeedbackStatusType, number>>;
};
}
const STATUS_TABS: FeedbackStatusType[] = [
"OPEN",
"ACKNOWLEDGED",
"RESOLVED",
"ARCHIVED",
];
function FeedbackInboxSection({ id }: { id: string }) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const { data: status } = useQuery({
queryKey: ["admin", "status"],
queryFn: async () => {
const res = await fetch("/api/admin/status");
if (!res.ok) throw new Error("Failed");
return (await res.json()).data as SystemStatus;
},
});
const githubConfigured = Boolean(status?.integrations.bugReport?.configured);
const [activeStatus, setActiveStatus] = useState<FeedbackStatusType>("OPEN");
const [selected, setSelected] = useState<FeedbackItem | null>(null);
const { data, isLoading } = useQuery({
queryKey: ["admin", "feedback", activeStatus],
queryFn: async () => {
const res = await fetch(
`/api/admin/feedback?status=${activeStatus}&limit=100`,
);
if (!res.ok) throw new Error("Failed");
return (await res.json()).data as FeedbackListResponse;
},
});
const counts = data?.meta.countsByStatus ?? {};
function refresh() {
queryClient.invalidateQueries({ queryKey: ["admin", "feedback"] });
}
return (
<div
id={id}
className="bg-card border-border scroll-mt-28 rounded-xl border p-6"
>
<div className="flex items-center gap-2">
<Inbox className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">{t("admin.feedback.title")}</h2>
</div>
<p className="text-muted-foreground mt-1 text-xs">
{t("admin.feedback.description")}
</p>
<Tabs
value={activeStatus}
onValueChange={(v) => setActiveStatus(v as FeedbackStatusType)}
className="mt-4"
>
<TabsList>
{STATUS_TABS.map((s) => (
<TabsTrigger key={s} value={s}>
<span>
{t(
`admin.feedback.tab${s.charAt(0) + s.slice(1).toLowerCase()}`,
)}
</span>
<Badge variant="secondary" className="ml-1.5 text-xs">
{counts[s] ?? 0}
</Badge>
</TabsTrigger>
))}
</TabsList>
{STATUS_TABS.map((s) => (
<TabsContent key={s} value={s} className="mt-4">
{isLoading ? (
<div className="flex items-center gap-2">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
<span className="text-muted-foreground text-sm">
{t("admin.feedback.loading")}
</span>
</div>
) : !data?.items.length ? (
<p className="text-muted-foreground text-sm">
{t("admin.feedback.noEntries")}
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-muted-foreground border-b text-xs">
<th className="px-3 py-2 text-left font-medium">
{t("admin.feedback.createdAt")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("admin.feedback.category")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("admin.feedback.subject")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("admin.feedback.user")}
</th>
<th className="px-3 py-2 text-right font-medium">
{t("admin.feedback.actions")}
</th>
</tr>
</thead>
<tbody className="divide-border divide-y">
{data.items.map((item, i) => (
<tr
key={item.id}
className={`hover:bg-muted/40 cursor-pointer ${i % 2 === 0 ? "bg-muted/30" : ""}`}
onClick={() => setSelected(item)}
>
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
{formatDateTime(item.createdAt)}
</td>
<td className="px-3 py-2">
<FeedbackCategoryBadge category={item.category} />
</td>
<td className="max-w-[28ch] truncate px-3 py-2 font-medium">
{item.subject}
</td>
<td className="text-muted-foreground px-3 py-2 text-xs">
{item.user?.username ?? t("admin.feedback.anonymous")}
</td>
<td className="px-3 py-2 text-right">
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={(e) => {
e.stopPropagation();
setSelected(item);
}}
>
{t("admin.feedback.details")}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</TabsContent>
))}
</Tabs>
{selected && (
<FeedbackDetailDialog
item={selected}
open={!!selected}
onOpenChange={(open) => !open && setSelected(null)}
githubConfigured={githubConfigured}
onMutated={refresh}
/>
)}
</div>
);
}
function FeedbackCategoryBadge({
category,
}: {
category: FeedbackCategoryType;
}) {
const { t } = useTranslations();
const map: Record<
FeedbackCategoryType,
{ label: string; className: string }
> = {
BUG: {
label: t("admin.feedback.categoryBug"),
className: "bg-red-500/15 text-red-400 border-red-500/30",
},
FEATURE_REQUEST: {
label: t("admin.feedback.categoryFeature"),
className: "bg-purple-500/15 text-purple-400 border-purple-500/30",
},
QUESTION: {
label: t("admin.feedback.categoryQuestion"),
className: "bg-blue-500/15 text-blue-400 border-blue-500/30",
},
OTHER: {
label: t("admin.feedback.categoryOther"),
className: "bg-muted text-muted-foreground border-border",
},
};
const cfg = map[category];
return (
<Badge className={`border text-xs ${cfg.className}`}>{cfg.label}</Badge>
);
}
function FeedbackDetailDialog({
item,
open,
onOpenChange,
githubConfigured,
onMutated,
}: {
item: FeedbackItem;
open: boolean;
onOpenChange: (open: boolean) => void;
githubConfigured: boolean;
onMutated: () => void;
}) {
const { t } = useTranslations();
const [note, setNote] = useState(item.adminNote ?? "");
const [issueUrl, setIssueUrl] = useState(item.gitHubIssueUrl);
const update = useMutation({
mutationFn: async (payload: {
status?: FeedbackStatusType;
adminNote?: string | null;
}) => {
const res = await fetch(`/api/admin/feedback/${item.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(await getApiErrorMessage(res));
},
onSuccess: (_, vars) => {
onMutated();
if (vars.adminNote !== undefined) {
toast.success(t("admin.feedback.noteSaved"));
}
},
onError: (err) => {
toast.error(
err instanceof Error ? err.message : t("admin.feedback.updateFailed"),
);
},
});
const archive = useMutation({
mutationFn: async () => {
const res = await fetch(`/api/admin/feedback/${item.id}`, {
method: "DELETE",
});
if (!res.ok) throw new Error(await getApiErrorMessage(res));
},
onSuccess: () => {
onMutated();
onOpenChange(false);
},
onError: (err) => {
toast.error(
err instanceof Error ? err.message : t("admin.feedback.updateFailed"),
);
},
});
const publish = useMutation({
mutationFn: async () => {
const res = await fetch(`/api/admin/feedback/${item.id}/github`, {
method: "POST",
});
if (!res.ok) throw new Error(await getApiErrorMessage(res));
return (await res.json()).data as { issueUrl: string };
},
onSuccess: (data) => {
setIssueUrl(data.issueUrl);
toast.success(t("admin.feedback.publishSuccess"));
onMutated();
},
onError: (err) => {
toast.error(
err instanceof Error ? err.message : t("admin.feedback.publishFailed"),
);
},
});
const meta = item.metadata ?? {};
const url = typeof meta.url === "string" ? meta.url : null;
const locale = typeof meta.locale === "string" ? meta.locale : null;
const userAgent = typeof meta.userAgent === "string" ? meta.userAgent : null;
const appVersion =
typeof meta.appVersion === "string" ? meta.appVersion : null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<MessageSquare className="h-5 w-5" />
{item.subject}
</DialogTitle>
<DialogDescription>
<span className="flex flex-wrap items-center gap-2 text-xs">
<FeedbackCategoryBadge category={item.category} />
<span>
{t("admin.feedback.submittedBy")}:{" "}
{item.user?.username ?? t("admin.feedback.anonymous")}
</span>
<span>·</span>
<span>{formatDateTime(item.createdAt)}</span>
{issueUrl && (
<a
href={issueUrl}
target="_blank"
rel="noopener noreferrer"
className="text-primary inline-flex items-center gap-1"
>
<GitPullRequest className="h-3 w-3" />
{t("admin.feedback.viewIssue")}
<ExternalLink className="h-3 w-3" />
</a>
)}
</span>
</DialogDescription>
</DialogHeader>
<div className="space-y-4 text-sm">
<div className="bg-muted/40 rounded-md p-3 whitespace-pre-wrap">
{item.description}
</div>
{(url || locale || userAgent || appVersion) && (
<div>
<h4 className="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
{t("admin.feedback.metadataHeading")}
</h4>
<dl className="mt-2 grid grid-cols-[max-content_1fr] gap-x-3 gap-y-1 text-xs">
{url && (
<>
<dt className="text-muted-foreground">
{t("admin.feedback.metaUrl")}
</dt>
<dd className="font-mono break-all">{url}</dd>
</>
)}
{locale && (
<>
<dt className="text-muted-foreground">
{t("admin.feedback.metaLocale")}
</dt>
<dd className="font-mono">{locale}</dd>
</>
)}
{appVersion && (
<>
<dt className="text-muted-foreground">
{t("admin.feedback.metaAppVersion")}
</dt>
<dd className="font-mono">{appVersion}</dd>
</>
)}
{userAgent && (
<>
<dt className="text-muted-foreground">
{t("admin.feedback.metaUserAgent")}
</dt>
<dd className="font-mono break-all">{userAgent}</dd>
</>
)}
</dl>
</div>
)}
{item.screenshotBase64 && (
<div>
<h4 className="text-muted-foreground mb-2 text-xs font-semibold tracking-wider uppercase">
{t("admin.feedback.screenshotHeading")}
</h4>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={item.screenshotBase64}
alt="Screenshot"
className="border-border max-h-72 rounded-md border"
/>
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="admin-feedback-note" className="text-xs">
{t("admin.feedback.adminNote")}
</Label>
<textarea
id="admin-feedback-note"
value={note}
onChange={(e) => setNote(e.target.value)}
rows={3}
maxLength={5000}
placeholder={t("admin.feedback.adminNotePlaceholder")}
className="border-input bg-background text-foreground placeholder:text-muted-foreground focus-visible:ring-ring w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none"
/>
<div className="flex justify-end">
<Button
size="sm"
variant="outline"
disabled={update.isPending || note === (item.adminNote ?? "")}
onClick={() => update.mutate({ adminNote: note || null })}
>
{update.isPending && (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
)}
{t("admin.feedback.saveNote")}
</Button>
</div>
</div>
<div className="border-border flex flex-wrap items-center gap-2 border-t pt-3">
<Button
size="sm"
variant="outline"
disabled={update.isPending || item.status === "ACKNOWLEDGED"}
onClick={() => update.mutate({ status: "ACKNOWLEDGED" })}
>
<CheckCircle2 className="mr-1.5 h-3.5 w-3.5" />
{t("admin.feedback.actionAcknowledge")}
</Button>
<Button
size="sm"
disabled={update.isPending || item.status === "RESOLVED"}
onClick={() => update.mutate({ status: "RESOLVED" })}
>
<CheckCircle2 className="mr-1.5 h-3.5 w-3.5" />
{t("admin.feedback.actionResolve")}
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
size="sm"
variant="destructive"
disabled={archive.isPending || item.status === "ARCHIVED"}
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
{t("admin.feedback.actionArchive")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("admin.feedback.archiveConfirm")}
</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={() => archive.mutate()}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{t("admin.feedback.actionArchive")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{githubConfigured && !issueUrl && (
<Button
size="sm"
variant="outline"
disabled={publish.isPending}
onClick={() => publish.mutate()}
className="ml-auto"
>
{publish.isPending ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<GitPullRequest className="mr-1.5 h-3.5 w-3.5" />
)}
{t("admin.feedback.actionPublishGithub")}
</Button>
)}
{issueUrl && (
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green ml-auto">
{t("admin.feedback.publishedToGithub")}
</Badge>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}
+138
View File
@@ -0,0 +1,138 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAdmin, HttpError } from "@/lib/api-handler";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, getClientIp, safeJson } from "@/lib/api-response";
import { encrypt, decrypt } from "@/lib/crypto";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
await requireAdmin();
annotate({ action: { name: "admin.ai-settings.get" } });
const settings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
});
const hasKey = Boolean(settings?.adminAiKeyEncrypted);
const model = settings?.adminAiModel ?? "gpt-4o-mini";
const baseUrl = settings?.adminAiBaseUrl ?? "https://api.openai.com/v1";
return apiSuccess({
hasKey,
keyPreview: settings?.adminAiKeyEncrypted
? `...${decrypt(settings.adminAiKeyEncrypted).slice(-4)}`
: null,
model,
baseUrl,
});
});
export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAdmin();
annotate({ action: { name: "admin.ai-settings.update" } });
const rl = await checkRateLimit("admin-ai-settings", 10, 60_000);
if (!rl.allowed) throw new HttpError(429, "Too many requests");
const { data: body, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
const { apiKey, model, baseUrl } = body as {
apiKey?: string;
model?: string;
baseUrl?: string;
};
const updates: Record<string, unknown> = {};
const auditDetails: Record<string, unknown> = {};
if (apiKey !== undefined) {
const trimmed = apiKey.trim();
if (trimmed) {
updates.adminAiKeyEncrypted = encrypt(trimmed);
auditDetails.apiKeyUpdated = true;
} else {
updates.adminAiKeyEncrypted = null;
auditDetails.apiKeyUpdated = false;
}
}
if (model !== undefined) {
const trimmed = model.trim();
if (!trimmed) throw new HttpError(422, "Model cannot be empty");
updates.adminAiModel = trimmed;
auditDetails.model = trimmed;
}
if (baseUrl !== undefined) {
const trimmed = baseUrl.trim();
if (!trimmed) throw new HttpError(422, "Base URL cannot be empty");
// The admin AI base URL has full egress of the bearer key on every
// insight call. Lock it to HTTPS + a hostname allowlist so a
// compromised admin (or stolen cookie) cannot point it at
// attacker.example and exfiltrate the key on the next AI call.
let parsed: URL;
try {
parsed = new URL(trimmed);
} catch {
throw new HttpError(422, "Base URL is not a valid URL");
}
if (parsed.protocol !== "https:") {
throw new HttpError(422, "Base URL must use https://");
}
const allowedHosts = new Set([
"api.openai.com",
"api.anthropic.com",
"generativelanguage.googleapis.com",
"api.mistral.ai",
"api.groq.com",
"openrouter.ai",
]);
for (const h of (process.env.ADMIN_AI_BASE_URL_ALLOWLIST ?? "")
.split(",")
.map((h) => h.trim())
.filter(Boolean)) {
allowedHosts.add(h);
}
if (!allowedHosts.has(parsed.hostname)) {
throw new HttpError(
422,
`Base URL host '${parsed.hostname}' not in allowlist. Set ADMIN_AI_BASE_URL_ALLOWLIST if you need a custom provider.`,
);
}
updates.adminAiBaseUrl = trimmed;
auditDetails.baseUrl = trimmed;
}
if (Object.keys(updates).length === 0) {
throw new HttpError(422, "No valid fields");
}
const settings = await prisma.appSettings.upsert({
where: { id: "singleton" },
update: updates,
create: { id: "singleton", ...updates },
});
await auditLog("admin.ai-settings.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: auditDetails,
});
const hasKey = Boolean(settings.adminAiKeyEncrypted);
return apiSuccess({
hasKey,
keyPreview: settings.adminAiKeyEncrypted
? `...${decrypt(settings.adminAiKeyEncrypted).slice(-4)}`
: null,
model: settings.adminAiModel,
baseUrl: settings.adminAiBaseUrl,
});
});
+2 -3
View File
@@ -30,11 +30,11 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
const body = await request.json();
confirm = typeof body?.confirm === "string" ? body.confirm : "";
} catch {
return apiError("Ungültige Anfrage", 422);
return apiError("Invalid request", 422);
}
if (confirm !== "DELETE ALL") {
return apiError("Bestätigung fehlt", 422);
return apiError("Confirmation missing", 422);
}
const result = await prisma.$transaction(async (tx) => {
@@ -51,7 +51,6 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
heightCm: null,
dateOfBirth: null,
gender: null,
openaiKeyEncrypted: null,
insightsPrivacyMode: "aggregated",
insightsCachedAt: null,
insightsCachedText: null,
@@ -0,0 +1,99 @@
/**
* POST /api/admin/feedback/[id]/github — promote feedback to a GitHub Issue.
*
* Reuses publishFeedbackToGithub() so the issue body matches the legacy
* /api/bugreport flow. Idempotent: if gitHubIssueUrl is already set we return
* the existing URL instead of creating a duplicate.
*/
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import {
publishFeedbackToGithub,
GithubPublishError,
} from "@/lib/feedback/publish-github";
import type { NextRequest } from "next/server";
export const POST = apiHandler(
async (request: NextRequest, ctx: { params: Promise<{ id: string }> }) => {
const { user: admin } = await requireAdmin();
const { id } = await ctx.params;
const feedback = await prisma.feedback.findUnique({
where: { id },
select: {
id: true,
category: true,
subject: true,
description: true,
gitHubIssueUrl: true,
screenshotBase64: true,
metadata: true,
user: { select: { username: true } },
},
});
if (!feedback) return apiError("Feedback not found", 404);
if (feedback.gitHubIssueUrl) {
return apiError(
`Already published: ${feedback.gitHubIssueUrl}`,
409,
);
}
// Atomic claim to prevent concurrent admin clicks from creating two
// duplicate GitHub issues. Only the first caller whose updateMany
// matches (gitHubIssueUrl still null) proceeds. We write a PENDING
// sentinel now and replace it with the real URL after the API call.
const PENDING = "pending://creating";
const claim = await prisma.feedback.updateMany({
where: { id, gitHubIssueUrl: null },
data: { gitHubIssueUrl: PENDING },
});
if (claim.count !== 1) {
return apiError("Already being published by another request", 409);
}
try {
const result = await publishFeedbackToGithub({
category: feedback.category,
subject: feedback.subject,
description: feedback.description,
username: feedback.user?.username ?? "anonymous",
metadata: feedback.metadata,
});
await prisma.feedback.update({
where: { id },
data: { gitHubIssueUrl: result.issueUrl },
});
await auditLog("admin.feedback.github_publish", {
userId: admin.id,
ipAddress: getClientIp(request),
details: { feedbackId: id, issueNumber: result.issueNumber },
});
annotate({
action: { name: "admin.feedback.github_publish" },
meta: { feedback_id: id, issue_number: result.issueNumber },
});
return apiSuccess({ issueUrl: result.issueUrl });
} catch (err) {
// Release the PENDING claim so a retry can succeed.
await prisma.feedback
.updateMany({
where: { id, gitHubIssueUrl: PENDING },
data: { gitHubIssueUrl: null },
})
.catch(() => undefined);
if (err instanceof GithubPublishError) {
return apiError(err.message, err.status);
}
throw err;
}
},
);
+78
View File
@@ -0,0 +1,78 @@
/**
* PATCH /api/admin/feedback/[id] — change status or admin note.
* DELETE /api/admin/feedback/[id] — archive (soft: sets status to ARCHIVED).
*/
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { apiError, apiSuccess, safeJson, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { updateFeedbackSchema } from "@/lib/validations/feedback";
import type { NextRequest } from "next/server";
export const PATCH = apiHandler(
async (request: NextRequest, ctx: { params: Promise<{ id: string }> }) => {
const { user } = await requireAdmin();
const { id } = await ctx.params;
const { data: body, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
const parsed = updateFeedbackSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
}
const before = await prisma.feedback.findUnique({ where: { id } });
if (!before) return apiError("Feedback not found", 404);
const updated = await prisma.feedback.update({
where: { id },
data: parsed.data,
select: {
id: true,
status: true,
adminNote: true,
updatedAt: true,
},
});
await auditLog("admin.feedback.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
feedbackId: id,
before: { status: before.status, adminNote: before.adminNote },
after: parsed.data,
},
});
annotate({
action: { name: "admin.feedback.update" },
meta: { feedback_id: id },
});
return apiSuccess(updated);
},
);
export const DELETE = apiHandler(
async (request: NextRequest, ctx: { params: Promise<{ id: string }> }) => {
const { user } = await requireAdmin();
const { id } = await ctx.params;
const updated = await prisma.feedback.update({
where: { id },
data: { status: "ARCHIVED" },
select: { id: true, status: true },
});
await auditLog("admin.feedback.archive", {
userId: user.id,
ipAddress: getClientIp(request),
details: { feedbackId: id },
});
return apiSuccess(updated);
},
);
+67
View File
@@ -0,0 +1,67 @@
/**
* Admin feedback inbox. Lists all feedback with status filter.
*/
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import type { NextRequest } from "next/server";
import type { FeedbackStatus } from "@/generated/prisma/client";
export const GET = apiHandler(async (request: NextRequest) => {
await requireAdmin();
const url = new URL(request.url);
const status = url.searchParams.get("status") as FeedbackStatus | null;
const limit = Math.min(
100,
Math.max(1, Number(url.searchParams.get("limit") ?? 50)),
);
const offset = Math.max(0, Number(url.searchParams.get("offset") ?? 0));
const where = status ? { status } : {};
const [items, total, counts] = await Promise.all([
prisma.feedback.findMany({
where,
orderBy: { createdAt: "desc" },
take: limit,
skip: offset,
select: {
id: true,
userId: true,
email: true,
category: true,
subject: true,
description: true,
status: true,
adminNote: true,
gitHubIssueUrl: true,
metadata: true,
screenshotBase64: true,
createdAt: true,
updatedAt: true,
user: { select: { username: true } },
},
}),
prisma.feedback.count({ where }),
prisma.feedback.groupBy({
by: ["status"],
_count: { _all: true },
}),
]);
annotate({
action: { name: "admin.feedback.list" },
meta: { total, filtered_status: status },
});
const countsByStatus = Object.fromEntries(
counts.map((c) => [c.status, c._count._all]),
) as Record<FeedbackStatus, number>;
return apiSuccess({
items,
meta: { total, limit, offset, countsByStatus },
});
});
@@ -12,10 +12,10 @@ export const POST = apiHandler(async () => {
const settings = await getGlitchtipSettings();
if (!settings.glitchtipEnabled) {
return apiError("Glitchtip ist deaktiviert", 422);
return apiError("Glitchtip is disabled", 422);
}
if (!settings.glitchtipDsn) {
return apiError("Glitchtip DSN fehlt", 422);
return apiError("Glitchtip DSN is missing", 422);
}
const delivery = await sendGlitchtipEvent({
@@ -32,13 +32,13 @@ export const POST = apiHandler(async () => {
if (!delivery.ok) {
getEvent()?.addWarning("Glitchtip test event rejected: " + delivery.method + " " + delivery.status + " " + delivery.details);
return apiError(
`Glitchtip-Testevent wurde abgelehnt (HTTP ${delivery.status ?? 502})`,
`Glitchtip test event rejected (HTTP ${delivery.status ?? 502})`,
502,
);
}
return apiSuccess({
sent: true,
message: "Glitchtip-Testevent wurde gesendet",
message: "Glitchtip test event sent",
});
});
@@ -51,18 +51,18 @@ export const POST = apiHandler(async (request: NextRequest) => {
const settings = await getPublicMonitoringSettings();
if (!settings.umamiEnabled) {
return apiError("Umami ist deaktiviert", 422);
return apiError("Umami is disabled", 422);
}
if (!settings.umamiScriptUrl || !settings.umamiWebsiteId) {
return apiError(
"Umami Script-URL und Website-ID müssen konfiguriert sein",
"Umami script URL and website ID must be configured",
422,
);
}
const targetUrls = resolveUmamiSendUrls(settings.umamiScriptUrl);
if (targetUrls.length === 0) {
return apiError("Umami Script-URL ist ungültig", 422);
return apiError("Umami script URL is invalid", 422);
}
const appUrl = resolveAppUrl(request);
@@ -72,7 +72,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
website: settings.umamiWebsiteId,
hostname: appUrl.hostname,
screen: "1920x1080",
language: "de-DE",
language: "en-US",
title: "HealthLog Monitoring Test",
url: "/admin",
referrer: "",
@@ -100,7 +100,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
if (upstream.ok) {
return apiSuccess({
sent: true,
message: "Umami-Testevent wurde gesendet",
message: "Umami test event sent",
});
}
@@ -117,7 +117,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
getEvent()?.addWarning("Umami test event rejected: " + lastStatus);
return apiError(
`Umami-Testevent wurde abgelehnt (HTTP ${lastStatus})`,
`Umami test event rejected (HTTP ${lastStatus})`,
502,
);
});
@@ -11,7 +11,9 @@ export const dynamic = "force-dynamic";
function parseTimeToMinutes(value: string): number {
const [h, m] = value.split(":").map(Number);
if (!Number.isFinite(h) || !Number.isFinite(m)) return 0;
return h * 60 + m;
// Normalize "24:00" → "00:00" (some ICU builds emit that for midnight).
const hours = h === 24 ? 0 : h;
return hours * 60 + m;
}
const dayLabels = ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"];
@@ -71,7 +73,7 @@ export const POST = apiHandler(async () => {
now,
userTz,
);
const currentTime = now.toLocaleTimeString("de-DE", {
const currentTime = now.toLocaleTimeString("en-GB", {
timeZone: userTz,
hour: "2-digit",
minute: "2-digit",
+10 -10
View File
@@ -79,7 +79,7 @@ export const POST = apiHandler(async () => {
if (channels.length === 0) {
return apiSuccess({
sent: false,
message: "Keine aktivierten Benachrichtigungskanäle gefunden",
message: "No enabled notification channels found",
results: [],
});
}
@@ -89,7 +89,7 @@ export const POST = apiHandler(async () => {
userId: user.id,
title: "Test-Notification",
message:
"<b>HealthLog Test:</b> Wenn du diese Nachricht siehst, funktionieren deine Benachrichtigungen!",
"<b>HealthLog Test:</b> If you see this message, your notifications are working!",
};
const results: Array<{
@@ -104,7 +104,7 @@ export const POST = apiHandler(async () => {
results.push({
channel: channel.type,
success: false,
error: "SYSTEM_ALERT deaktiviert in Einstellungen",
error: "SYSTEM_ALERT disabled in settings",
});
continue;
}
@@ -124,7 +124,7 @@ export const POST = apiHandler(async () => {
results.push({
channel: "TELEGRAM",
success: false,
error: `Telegram API hat Fehler gemeldet (chatId: ${config.chatId})`,
error: `Telegram API reported error (chatId: ${config.chatId})`,
});
continue;
}
@@ -139,7 +139,7 @@ export const POST = apiHandler(async () => {
results.push({
channel: "NTFY",
success: false,
error: `Senden fehlgeschlagen (topic: ${config.topic})`,
error: `Send failed (topic: ${config.topic})`,
});
continue;
}
@@ -153,7 +153,7 @@ export const POST = apiHandler(async () => {
results.push({
channel: type,
success: false,
error: "Unbekannter Kanaltyp",
error: "Unknown channel type",
});
continue;
}
@@ -163,7 +163,7 @@ export const POST = apiHandler(async () => {
results.push({
channel: channel.type,
success: false,
error: err instanceof Error ? err.message : "Unbekannter Fehler",
error: err instanceof Error ? err.message : "Unknown error",
});
}
}
@@ -173,11 +173,11 @@ export const POST = apiHandler(async () => {
let message: string;
if (failCount === 0) {
message = `Alle ${successCount} Kanäle erfolgreich`;
message = `All ${successCount} channels successful`;
} else if (successCount === 0) {
message = `Alle ${failCount} Kanäle fehlgeschlagen`;
message = `All ${failCount} channels failed`;
} else {
message = `${successCount} erfolgreich, ${failCount} fehlgeschlagen`;
message = `${successCount} successful, ${failCount} failed`;
}
return apiSuccess({ sent: successCount > 0, message, results });
+1 -1
View File
@@ -180,7 +180,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
}
if (Object.keys(updates).length === 0) {
return apiError("Keine gültigen Felder", 422);
return apiError("No valid fields", 422);
}
const settings = await prisma.appSettings.upsert({
@@ -7,6 +7,7 @@ import { annotate } from "@/lib/logging/context";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { destroyAllSessions } from "@/lib/auth/session";
import { NextRequest, NextResponse } from "next/server";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
export const POST = apiHandler(async (
request: NextRequest,
@@ -32,16 +33,20 @@ export const POST = apiHandler(async (
const { password } = body as { password?: string };
if (!password || typeof password !== "string") {
return apiError("Passwort erforderlich", 422);
return apiError("Password required", 422);
}
const target = await prisma.user.findUnique({ where: { id } });
if (!target) return apiError("Benutzer nicht gefunden", 404);
if (!target) return apiError("User not found", 404);
const strength = checkPasswordStrength(password, [target.username]);
const locale = await resolveServerLocale({
request,
userLocale: target.locale ?? null,
});
const strength = checkPasswordStrength(password, [target.username], locale);
if (!strength.isAcceptable) {
return apiError(
strength.feedback[0] || "Passwort zu schwach (Score < 3)",
strength.feedback[0] || "Password too weak (score < 3)",
422,
);
}
+2 -2
View File
@@ -30,13 +30,13 @@ export const PUT = apiHandler(async (
}
const target = await prisma.user.findUnique({ where: { id } });
if (!target) return apiError("Benutzer nicht gefunden", 404);
if (!target) return apiError("User not found", 404);
// Prevent removing the last admin
if (parsed.data.role === "USER" && target.role === "ADMIN") {
const adminCount = await prisma.user.count({ where: { role: "ADMIN" } });
if (adminCount <= 1) {
return apiError("Der letzte Admin kann nicht degradiert werden", 400);
return apiError("The last admin cannot be demoted", 400);
}
}
@@ -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",
);
});
});
+61
View File
@@ -0,0 +1,61 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { resolveProvider } from "@/lib/ai/provider";
import { apiSuccess, apiError } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
export const dynamic = "force-dynamic";
export const POST = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "ai.test" } });
const rl = await checkRateLimit(`ai-test:${user.id}`, 5, 60_000);
if (!rl.allowed) return apiError("Too many test requests", 429);
const provider = await resolveProvider(user.id);
if (provider.type === "none") {
return apiError("No AI provider configured", 422);
}
try {
const result = await provider.generateCompletion({
systemPrompt: "You are a connection-test responder.",
userPrompt: 'Reply with the JSON object {"ok": true} and nothing else.',
temperature: 0,
maxTokens: 32,
});
return apiSuccess({
ok: true,
providerType: result.providerType,
model: result.model,
tokensUsed: result.tokensUsed,
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 };
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);
}
});
+23 -9
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,15 +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",
];
// 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) =>
@@ -110,9 +105,28 @@ export const GET = apiHandler(async () => {
}
}
// Per-context glucose summaries (canonical mg/dL).
const glucoseRows = await prisma.measurement.findMany({
where: { userId: user.id, type: "BLOOD_GLUCOSE" },
orderBy: { measuredAt: "asc" },
select: { value: true, measuredAt: true, glucoseContext: true },
});
const glucoseByContext: Record<string, ReturnType<typeof summarize>> = {};
if (glucoseRows.length > 0) {
const contexts = ["FASTING", "POSTPRANDIAL", "RANDOM", "BEDTIME"] as const;
for (const ctx of contexts) {
const ctxRows = glucoseRows.filter((r) => r.glucoseContext === ctx);
if (ctxRows.length === 0) continue;
glucoseByContext[ctx] = summarize(
ctxRows.map((r): DataPoint => ({ date: r.measuredAt, value: r.value })),
);
}
}
return apiSuccess({
summaries: results,
bmi,
bpInTargetPct,
glucoseByContext,
});
});
+46
View File
@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from "next/server";
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import { checkRateLimit } from "@/lib/rate-limit";
import {
generatePKCE,
generateState,
buildAuthorizationUrl,
} from "@/lib/ai/codex-oauth";
import { cookies } from "next/headers";
import { annotate } from "@/lib/logging/context";
export const GET = apiHandler(async (_request: NextRequest) => {
const { user } = await requireAuth();
const rl = await checkRateLimit(`codex-authorize:${user.id}`, 5, 60_000);
if (!rl.allowed) throw new HttpError(429, "Too many requests");
const { verifier, challenge } = generatePKCE();
const state = generateState();
const appUrl = process.env.APP_URL || process.env.NEXT_PUBLIC_APP_URL;
if (!appUrl) throw new Error("APP_URL not configured");
const redirectUri = `${appUrl}/api/auth/codex/callback`;
const cookieStore = await cookies();
const cookieOptions = {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax" as const,
path: "/",
maxAge: 300,
};
cookieStore.set("codex_verifier", verifier, cookieOptions);
cookieStore.set("codex_state", state, cookieOptions);
const authUrl = buildAuthorizationUrl({
codeChallenge: challenge,
state,
redirectUri,
});
annotate({ action: { name: "codex.oauth.authorize" } });
return NextResponse.redirect(authUrl);
});
+97
View File
@@ -0,0 +1,97 @@
import { NextRequest, NextResponse } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { checkRateLimit } from "@/lib/rate-limit";
import { exchangeCodeForTokens, encryptTokens } from "@/lib/ai/codex-oauth";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { getClientIp } from "@/lib/api-response";
import { cookies } from "next/headers";
import { annotate } from "@/lib/logging/context";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const rl = await checkRateLimit(`codex-callback:${user.id}`, 10, 60_000);
if (!rl.allowed) {
const appUrl = process.env.APP_URL || process.env.NEXT_PUBLIC_APP_URL;
return NextResponse.redirect(`${appUrl}/settings?codex_error=rate_limited`);
}
const url = new URL(request.url);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const error = url.searchParams.get("error");
const appUrl = process.env.APP_URL || process.env.NEXT_PUBLIC_APP_URL;
if (!appUrl) throw new Error("APP_URL not configured");
if (error) {
annotate({ meta: { oauth_error: error } });
return NextResponse.redirect(
`${appUrl}/settings?codex_error=${encodeURIComponent(error)}`,
);
}
if (!code || !state) {
return NextResponse.redirect(
`${appUrl}/settings?codex_error=missing_params`,
);
}
const cookieStore = await cookies();
const storedState = cookieStore.get("codex_state")?.value;
const storedVerifier = cookieStore.get("codex_verifier")?.value;
cookieStore.delete("codex_state");
cookieStore.delete("codex_verifier");
if (!storedState || !storedVerifier || state !== storedState) {
return NextResponse.redirect(
`${appUrl}/settings?codex_error=invalid_state`,
);
}
const redirectUri = `${appUrl}/api/auth/codex/callback`;
try {
const tokens = await exchangeCodeForTokens({
code,
codeVerifier: storedVerifier,
redirectUri,
});
const encrypted = encryptTokens({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
});
await prisma.user.update({
where: { id: user.id },
data: {
codexAccessTokenEncrypted: encrypted.accessEncrypted,
codexRefreshTokenEncrypted: encrypted.refreshEncrypted,
codexTokenExpiresAt: new Date(
Date.now() + tokens.expires_in * 1000,
),
codexConnectedAt: new Date(),
codexConnectionStatus: "connected",
insightsCachedAt: null,
insightsCachedText: null,
},
});
await auditLog("codex.oauth.connected", {
userId: user.id,
ipAddress: getClientIp(request),
});
annotate({ action: { name: "codex.oauth.callback.success" } });
return NextResponse.redirect(`${appUrl}/settings?codex_connected=true`);
} catch (err) {
const message = err instanceof Error ? err.message : "unknown";
annotate({ meta: { codex_token_error: message } });
return NextResponse.redirect(
`${appUrl}/settings?codex_error=token_exchange_failed`,
);
}
});
@@ -0,0 +1,35 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import { checkRateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
export const DELETE = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const rl = await checkRateLimit(`codex-disconnect:${user.id}`, 5, 60_000);
if (!rl.allowed) throw new HttpError(429, "Too many requests");
await prisma.user.update({
where: { id: user.id },
data: {
codexAccessTokenEncrypted: null,
codexRefreshTokenEncrypted: null,
codexTokenExpiresAt: null,
codexConnectedAt: null,
codexConnectionStatus: "disconnected",
insightsCachedAt: null,
insightsCachedText: null,
},
});
await auditLog("codex.oauth.disconnected", {
userId: user.id,
ipAddress: getClientIp(request),
});
annotate({ action: { name: "codex.oauth.disconnect" } });
return apiSuccess({ disconnected: true });
});
@@ -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;
}
});
});
+28 -4
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";
@@ -17,7 +18,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
return NextResponse.json(
{
data: null,
error: "Zu viele Anmeldeversuche. Bitte später erneut versuchen.",
error: "Too many login attempts. Please try again later.",
},
{ status: 429, headers: rateLimitHeaders(rl) },
);
@@ -31,7 +32,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
const parsed = loginPasswordSchema.safeParse(body);
if (!parsed.success) {
return apiError("Ungültige Anmeldedaten", 422);
return apiError("Invalid credentials", 422);
}
const { email, password } = parsed.data;
@@ -51,7 +52,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
ipAddress: ip,
details: { identifier, reason: "user_not_found_or_no_password" },
});
return apiError("Ungültige Anmeldedaten", 401);
return apiError("Invalid credentials", 401);
}
const valid = await verifyPassword(user.passwordHash, password);
@@ -61,7 +62,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
ipAddress: ip,
details: { reason: "invalid_password" },
});
return apiError("Ungültige Anmeldedaten", 401);
return apiError("Invalid credentials", 401);
}
const ua = request.headers.get("user-agent");
@@ -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 },
});
+1
View File
@@ -21,5 +21,6 @@ export const GET = apiHandler(async () => {
timezone: user.timezone,
onboardingCompletedAt: user.onboardingCompletedAt,
gravatarUrl: user.email ? getGravatarUrl(user.email) : null,
glucoseUnit: user.glucoseUnit ?? null,
});
});
@@ -16,7 +16,7 @@ export const POST = apiHandler(async (request: Request) => {
return NextResponse.json(
{
data: null,
error: "Zu viele Passkey-Anfragen. Bitte später erneut versuchen.",
error: "Too many passkey requests. Please try again later.",
},
{ status: 429, headers: rateLimitHeaders(rl) },
);
@@ -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();
});
});
+26 -4
View File
@@ -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();
@@ -15,7 +16,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
const ip = getClientIp(request);
const rl = await checkRateLimit(`auth:passkey-verify:${ip}`, 10, 15 * 60 * 1000);
if (!rl.allowed) {
return apiError("Zu viele Versuche. Bitte 15 Minuten warten.", 429);
return apiError("Too many attempts. Please wait 15 minutes.", 429);
}
const { data: body, error: jsonError } = await safeJson<Record<string, unknown>>(request);
@@ -25,7 +26,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
const credential = body.credential;
if (!challengeId || !credential) {
return apiError("challengeId und credential erforderlich", 422);
return apiError("challengeId and credential required", 422);
}
const { verification, passkey } = await verifyAuthentication(
@@ -38,7 +39,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
ipAddress: ip,
details: { reason: "passkey_verification_failed" },
});
return apiError("Passkey-Verifizierung fehlgeschlagen", 401);
return apiError("Passkey verification failed", 401);
}
const user = await prisma.user.findUnique({
@@ -46,7 +47,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
if (!user) {
return apiError("Benutzer nicht gefunden", 404);
return apiError("User not found", 404);
}
const ua = request.headers.get("user-agent");
@@ -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 },
});
@@ -16,13 +16,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
const credential = body.credential as Record<string, unknown> | undefined;
if (!challengeId || !credential) {
return apiError("challengeId und credential erforderlich", 422);
return apiError("challengeId and credential required", 422);
}
const verification = await verifyRegistration(challengeId, credential);
if (!verification.verified || !verification.registrationInfo) {
return apiError("Passkey-Verifizierung fehlgeschlagen", 400);
return apiError("Passkey verification failed", 400);
}
const { registrationInfo } = verification;
+2 -2
View File
@@ -18,7 +18,7 @@ export const DELETE = apiHandler(async (
});
if (!passkey || passkey.userId !== user.id) {
return apiError("Passkey nicht gefunden", 404);
return apiError("Passkey not found", 404);
}
// Check: at least 1 auth method must remain
@@ -29,7 +29,7 @@ export const DELETE = apiHandler(async (
if (passkeyCount <= 1 && !hasPassword) {
return apiError(
"Kann nicht gelöscht werden — es muss mindestens eine Anmeldemethode bestehen bleiben",
"Cannot delete — at least one authentication method must remain",
400,
);
}
+15 -9
View File
@@ -12,6 +12,7 @@ import { checkRateLimit } from "@/lib/rate-limit";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { destroyAllSessions, createSession } from "@/lib/auth/session";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
@@ -23,7 +24,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
);
if (!rl.allowed) {
return apiError(
"Zu viele Versuche. Bitte 15 Minuten warten.",
"Too many attempts. Please wait 15 minutes.",
429,
);
}
@@ -38,7 +39,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
const { currentPassword, newPassword } = parsed.data;
if (!user.passwordHash) {
return apiError("Für dieses Konto ist kein Passwort gesetzt", 400);
return apiError("No password set for this account", 400);
}
const currentValid = await verifyPassword(
@@ -46,23 +47,28 @@ export const POST = apiHandler(async (request: NextRequest) => {
currentPassword,
);
if (!currentValid) {
return apiError("Aktuelles Passwort ist falsch", 401);
return apiError("Current password is incorrect", 401);
}
if (currentPassword === newPassword) {
return apiError(
"Neues Passwort muss sich vom aktuellen unterscheiden",
"New password must differ from current password",
422,
);
}
const strength = checkPasswordStrength(newPassword, [
user.username,
user.email ?? "",
]);
const locale = await resolveServerLocale({
request,
userLocale: user.locale ?? null,
});
const strength = checkPasswordStrength(
newPassword,
[user.username, user.email ?? ""],
locale,
);
if (!strength.isAcceptable) {
return apiError(
strength.feedback[0] || "Passwort zu schwach (Score < 3)",
strength.feedback[0] || "Password too weak (score < 3)",
422,
);
}
+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("E-Mail-Adresse wird bereits verwendet", 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,
});
});
+8 -6
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 { resolveServerLocale } from "@/lib/i18n/server-locale";
export const POST = apiHandler(async (request: NextRequest) => {
const ip = getClientIp(request) ?? "unknown";
@@ -18,7 +19,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
{
data: null,
error:
"Zu viele Registrierungsversuche. Bitte später erneut versuchen.",
"Too many registration attempts. Please try again later.",
},
{ status: 429, headers: rateLimitHeaders(rl) },
);
@@ -42,7 +43,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
// Table may not exist yet; allow registration
}
if (!registrationEnabled) {
return apiError("Registrierung ist deaktiviert", 403);
return apiError("Registration is disabled", 403);
}
const { data: body, error: jsonError } = await safeJson(request);
@@ -62,14 +63,15 @@ export const POST = apiHandler(async (request: NextRequest) => {
prisma.user.findUnique({ where: { username } }),
]);
if (existingEmail || existingUsername) {
return apiError("Benutzername oder E-Mail-Adresse bereits vergeben", 409);
return apiError("Username or email already taken", 409);
}
// Validate password strength
const strength = checkPasswordStrength(password, [username, email]);
// Validate password strength (locale-aware feedback)
const locale = await resolveServerLocale({ request });
const strength = checkPasswordStrength(password, [username, email], locale);
if (!strength.isAcceptable) {
return apiError(
strength.feedback[0] || "Passwort zu schwach (Score < 3)",
strength.feedback[0] || "Password too weak (score < 3)",
422,
);
}
+34 -17
View File
@@ -8,11 +8,16 @@ import { decrypt } from "@/lib/crypto";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
// Strict image/* data URL (no SVG — it can carry inline JS).
const SCREENSHOT_DATA_URL =
/^data:image\/(png|jpeg|webp|gif);base64,[A-Za-z0-9+/=]+$/;
const bugReportSchema = z.object({
description: z.string().min(10, "Beschreibung zu kurz").max(5000),
description: z.string().min(10, "Description too short").max(5000),
screenshot: z
.string()
.max(7_000_000, "Screenshot zu groß (max. 5 MB)")
.max(7_000_000, "Screenshot too large (max 5 MB)")
.regex(SCREENSHOT_DATA_URL, "Screenshot must be a base64-encoded PNG/JPEG/WEBP/GIF data URL")
.optional(),
});
@@ -21,7 +26,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
const rl = await checkRateLimit(`bugreport:${user.id}`, 3, 60 * 60 * 1000);
if (!rl.allowed) {
return apiError("Maximal 3 Bugreports pro Stunde", 429);
return apiError("Maximum 3 bug reports per hour", 429);
}
annotate({ action: { name: "bugreport.submit" } });
@@ -47,9 +52,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
const ghRepo = appSettings?.githubIssueRepo || process.env.GITHUB_ISSUE_REPO; // e.g. "owner/repo"
if (!ghToken || !ghRepo) {
// 503 instead of 500: the client keys off status to render the "not
// configured" screen. The user also sees a clear message rather than
// "Internal server error".
return apiError(
"Bugreport ist nicht konfiguriert (GitHub-Issue-Token/Repository in Admin-Einstellungen fehlt)",
500,
"Bug report not configured (GitHub issue token/repository missing in admin settings)",
503,
);
}
@@ -63,27 +71,36 @@ export const POST = apiHandler(async (request: NextRequest) => {
const { description, screenshot } = parsed.data;
const dateStr = new Date().toLocaleString("de-DE", {
timeZone: "Europe/Berlin",
});
// GitHub issues are maintainer-facing and the repo's primary language is English.
// Use an ISO-8601 timestamp so titles are unambiguous and sortable.
const now = new Date();
const dateStr = now.toISOString().replace("T", " ").slice(0, 16) + " UTC";
const title = `Bug Report – ${dateStr}`;
// Sanitize user-provided content for GitHub markdown
// Sanitize user-provided content for GitHub markdown. Strip HTML tags
// entirely (prevents `<img onerror=>` etc.) and neutralise markdown link
// targets that start with dangerous schemes like javascript: or data:.
const safeUsername = user.username.replace(/[`*_~<>\[\]|]/g, "");
const safeDescription = description
.replace(/<[^>]*>/g, "") // strip HTML tags
.replace(
/\]\(\s*(javascript|data|vbscript|file):/gi,
"](about:blank#",
) // neutralise dangerous markdown link schemes
.replace(/```/g, "\\`\\`\\`")
.slice(0, 5000);
// Build issue body
let issueBody = `**Gemeldet von:** ${safeUsername}\n`;
issueBody += `**Datum:** ${dateStr}\n\n`;
issueBody += `## Beschreibung\n\n${safeDescription}\n`;
issueBody += `\n---\n*Erstellt über die HealthLog Bugreport-Funktion*`;
// Wrap the user's description in a fenced code block so any residual
// markdown renders literally rather than as executable markup.
let issueBody = `**Reported by:** ${safeUsername}\n`;
issueBody += `**Date:** ${dateStr}\n\n`;
issueBody += `## Description\n\n\`\`\`\n${safeDescription}\n\`\`\`\n`;
issueBody += `\n---\n*Created via HealthLog bug report*`;
// If screenshot provided, upload to GitHub as a gist or include as base64
// For simplicity, we'll note there's a screenshot but GitHub issues don't support inline base64
if (screenshot) {
issueBody += `\n\n**Screenshot:** Ein Screenshot wurde angehängt (siehe Kommentar).`;
issueBody += `\n\n**Screenshot:** A screenshot was attached (see comment).`;
}
// Create the issue
@@ -107,7 +124,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
if (!issueRes.ok) {
const errBody = await issueRes.text();
getEvent()?.addWarning("GitHub issue creation failed: " + errBody);
return apiError("Issue konnte nicht erstellt werden", 500);
return apiError("Failed to create issue", 500);
}
const issue = (await issueRes.json()) as {
@@ -123,7 +140,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
// Upload screenshot as issue comment with embedded image
// GitHub doesn't support base64 in comments, so we note the limitation
const commentBody = `### Screenshot\n\n![Screenshot](data:image/${ext};base64,${base64Data.slice(0, 100)}...)\n\n*Hinweis: Der vollständige Screenshot wurde aus Größengründen gekürzt. Bitte den Nutzer direkt kontaktieren.*`;
const commentBody = `### Screenshot\n\n![Screenshot](data:image/${ext};base64,${base64Data.slice(0, 100)}...)\n\n*Note: The full screenshot was truncated due to size limits. Please contact the user directly.*`;
await fetch(
`https://api.github.com/repos/${ghRepo}/issues/${issue.number}/comments`,
+49
View File
@@ -0,0 +1,49 @@
/**
* Returns whether bug report submission is configured (GitHub token + repo).
*
* The bug report page uses this to gate its UI so users don't hit a silent
* 500 when the admin hasn't set up the GitHub integration. Only two booleans
* escape: a single `configured` flag and `isAdmin` so the page can render
* the admin-specific copy + admin link. Token/repo granularity is kept
* server-side (log annotation only) so we don't hand regular users a probe.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/db";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
// Light rate limit — the endpoint is cheap, but no reason to let a logged-in
// client hammer Postgres in a loop.
const rl = await checkRateLimit(
`bugreport-status:${user.id}`,
30,
60 * 1000,
);
if (!rl.allowed) {
return apiError("Rate limit exceeded", 429);
}
const appSettings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
select: { githubIssueTokenEncrypted: true, githubIssueRepo: true },
});
const hasToken = Boolean(
appSettings?.githubIssueTokenEncrypted || process.env.GITHUB_ISSUE_TOKEN,
);
const hasRepo = Boolean(
appSettings?.githubIssueRepo || process.env.GITHUB_ISSUE_REPO,
);
const configured = hasToken && hasRepo;
annotate({
action: { name: "bugreport.status" },
meta: { configured, has_token: hasToken, has_repo: hasRepo },
});
return apiSuccess({ configured, isAdmin: user.role === "ADMIN" });
});
@@ -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(),
});
});
+103
View File
@@ -0,0 +1,103 @@
/**
* GET / PUT / DELETE dashboard widget layout.
*
* GET returns the resolved effective layout (defaults merged in if the user
* hasn't customized yet). PUT replaces the layout atomically. DELETE resets
* to default.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import {
resolveDashboardLayout,
serializeDashboardLayout,
DEFAULT_DASHBOARD_LAYOUT,
type DashboardLayout,
} from "@/lib/dashboard-layout";
import { Prisma } from "@/generated/prisma/client";
import { z } from "zod/v4";
import type { NextRequest } from "next/server";
const widgetIdEnum = z.enum([
"weight",
"bp",
"pulse",
"bodyFat",
"mood",
"medications",
"sleep",
"steps",
"glucose",
"totalBodyWater",
"boneMass",
"bpInTarget",
"oxygenSaturation",
]);
const layoutSchema = z.object({
version: z.literal(1),
widgets: z
.array(
z.object({
id: widgetIdEnum,
visible: z.boolean(),
order: z.number().int().min(0).max(99),
}),
)
.min(1)
.max(20),
});
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const row = await prisma.user.findUnique({
where: { id: user.id },
select: { dashboardWidgetsJson: true },
});
const layout = resolveDashboardLayout(row?.dashboardWidgetsJson);
return apiSuccess(layout);
});
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 = layoutSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
}
const normalized = serializeDashboardLayout(parsed.data as DashboardLayout);
await prisma.user.update({
where: { id: user.id },
data: {
dashboardWidgetsJson: normalized as unknown as Prisma.InputJsonValue,
},
});
annotate({
action: { name: "dashboard.widgets.update" },
meta: { visible_count: normalized.widgets.filter((w) => w.visible).length },
});
return apiSuccess(normalized);
});
export const DELETE = apiHandler(async () => {
const { user } = await requireAuth();
await prisma.user.update({
where: { id: user.id },
data: { dashboardWidgetsJson: Prisma.JsonNull },
});
annotate({ action: { name: "dashboard.widgets.reset" } });
return apiSuccess(DEFAULT_DASHBOARD_LAYOUT);
});
+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);
}
+20 -142
View File
@@ -1,10 +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 {
collectDoctorReportData,
normaliseDays,
} from "@/lib/doctor-report-data";
/**
* Collect data for doctor report PDF generation (client-side).
@@ -14,158 +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("Maximal 10 Reports pro Stunde", 429);
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,
},
}),
]);
// 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 } });
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,
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);
});
+3 -3
View File
@@ -26,7 +26,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
const rl = await checkRateLimit(`export:${user.id}`, 10, 60 * 60 * 1000);
if (!rl.allowed) {
return apiError("Maximal 10 Exports pro Stunde", 429);
return apiError("Maximum 10 exports per hour", 429);
}
annotate({ action: { name: "export.download" } });
@@ -36,10 +36,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
const type = searchParams.get("type") ?? "all";
if (!["csv", "json"].includes(format)) {
return apiError("Format muss csv oder json sein", 422);
return apiError("Format must be csv or json", 422);
}
if (!["measurements", "medications", "intake", "mood", "all"].includes(type)) {
return apiError("Typ muss measurements, medications, intake, mood oder all sein", 422);
return apiError("Type must be measurements, medications, intake, mood, or all", 422);
}
const userId = user.id;
+102
View File
@@ -0,0 +1,102 @@
/**
* POST /api/feedback — user submits feedback. Always works, no GitHub needed.
* GET /api/feedback — user's own feedback history (paginated).
*
* Rate-limited at 5 per hour per user. Screenshots are stored as base64 in
* the database (≤5 MB each).
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess, safeJson, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { createFeedbackSchema } from "@/lib/validations/feedback";
import { Prisma } from "@/generated/prisma/client";
import type { NextRequest } from "next/server";
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const rl = await checkRateLimit(`feedback:${user.id}`, 5, 60 * 60 * 1000);
if (!rl.allowed) {
return apiError("Too many submissions — please try again later", 429);
}
const { data: body, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
const parsed = createFeedbackSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
}
const feedback = await prisma.feedback.create({
data: {
userId: user.id,
email: user.email ?? null,
category: parsed.data.category,
subject: parsed.data.subject,
description: parsed.data.description,
screenshotBase64: parsed.data.screenshot ?? null,
metadata:
parsed.data.metadata !== undefined
? (parsed.data.metadata as Prisma.InputJsonValue)
: Prisma.JsonNull,
},
select: { id: true, createdAt: true, category: true, status: true },
});
await auditLog("feedback.submit", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
feedbackId: feedback.id,
category: feedback.category,
hasScreenshot: Boolean(parsed.data.screenshot),
},
});
annotate({
action: { name: "feedback.submit" },
meta: {
feedback_id: feedback.id,
category: feedback.category,
},
});
return apiSuccess(feedback, 201);
});
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const url = new URL(request.url);
const limit = Math.min(
50,
Math.max(1, Number(url.searchParams.get("limit") ?? 25)),
);
const offset = Math.max(0, Number(url.searchParams.get("offset") ?? 0));
const [items, total] = await Promise.all([
prisma.feedback.findMany({
where: { userId: user.id },
orderBy: { createdAt: "desc" },
take: limit,
skip: offset,
select: {
id: true,
category: true,
subject: true,
status: true,
adminNote: true,
gitHubIssueUrl: true,
createdAt: true,
updatedAt: true,
},
}),
prisma.feedback.count({ where: { userId: user.id } }),
]);
return apiSuccess({ items, meta: { total, limit, offset } });
});
@@ -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++;
+11 -5
View File
@@ -19,7 +19,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "ingest.medication" } });
if (!(await isApiGloballyEnabled())) {
return apiError("API ist global deaktiviert", 403);
return apiError("API is globally disabled", 403);
}
const ip = getClientIp(request);
@@ -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);
}
@@ -109,12 +112,15 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
if (!medication) {
return apiError("Medikament nicht gefunden", 404);
return apiError("Medication not found", 404);
}
const medicationScope = `medication:${medication.id}:ingest`;
if (!apiToken.permissions.includes(medicationScope)) {
return apiError("API-Endpoint fuer dieses Medikament ist deaktiviert", 403);
if (
!apiToken.permissions.includes("*") &&
!apiToken.permissions.includes(medicationScope)
) {
return apiError("API endpoint for this medication is disabled", 403);
}
const event = await prisma.medicationIntakeEvent.create({
@@ -6,6 +6,7 @@ import {
} from "@/lib/insights/blood-pressure-status";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
export const dynamic = "force-dynamic";
@@ -13,9 +14,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const localeParam = request.nextUrl.searchParams.get("locale");
const locale = resolveBloodPressureStatusLocale(
localeParam ?? user.locale ?? "de",
);
const resolved = await resolveServerLocale({
request,
userLocale: user.locale ?? null,
override: localeParam,
});
const locale = resolveBloodPressureStatusLocale(resolved);
const result = await generateBloodPressureStatusForUser(
user.id,
+7 -3
View File
@@ -6,6 +6,7 @@ import {
} from "@/lib/insights/bmi-status";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
export const dynamic = "force-dynamic";
@@ -13,9 +14,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const localeParam = request.nextUrl.searchParams.get("locale");
const locale = resolveBmiStatusLocale(
localeParam ?? user.locale ?? "de",
);
const resolved = await resolveServerLocale({
request,
userLocale: user.locale ?? null,
override: localeParam,
});
const locale = resolveBmiStatusLocale(resolved);
const result = await generateBmiStatusForUser(user.id, {
locale,
@@ -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);
});
+29 -18
View File
@@ -1,4 +1,5 @@
import { prisma } from "@/lib/db";
import { resolveProvider } from "@/lib/ai/provider";
import { apiSuccess } from "@/lib/api-response";
import { summarize, type DataPoint } from "@/lib/analytics/trends";
import { getBpTargets } from "@/lib/analytics/bp-targets";
@@ -17,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";
@@ -32,18 +34,12 @@ export const GET = apiHandler(async () => {
select: {
heightCm: true,
dateOfBirth: true,
openaiKeyEncrypted: true,
},
});
// 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 } },
@@ -216,15 +212,30 @@ export const GET = apiHandler(async () => {
const bpMedications = medications.filter(
(med) => (categoryMap[med.id] ?? "OTHER") === "BLOOD_PRESSURE",
);
// Single round-trip for all medications instead of N+1: one query keyed
// on `medicationId IN (...)`, then group in memory. The previous loop
// hit Postgres once per medication, which scales poorly for users with
// many active meds.
const allEvents = medications.length
? await prisma.medicationIntakeEvent.findMany({
where: {
medicationId: { in: medications.map((m) => m.id) },
userId,
scheduledFor: { gte: ninetyDaysAgo },
},
orderBy: { scheduledFor: "desc" },
})
: [];
const eventsByMed = new Map<string, typeof allEvents>();
for (const ev of allEvents) {
const list = eventsByMed.get(ev.medicationId);
if (list) list.push(ev);
else eventsByMed.set(ev.medicationId, [ev]);
}
for (const med of medications) {
const events = await prisma.medicationIntakeEvent.findMany({
where: {
medicationId: med.id,
userId,
scheduledFor: { gte: ninetyDaysAgo },
},
orderBy: { scheduledFor: "desc" },
});
const events = eventsByMed.get(med.id) ?? [];
const mapped = events.map((e) => ({
takenAt: e.takenAt,
skipped: e.skipped,
@@ -371,7 +382,7 @@ export const GET = apiHandler(async () => {
moodPulseScatterData,
medications: medCompliance,
alerts,
hasOpenAiKey: !!dbUser?.openaiKeyEncrypted,
hasProvider: (await resolveProvider(userId)).type !== "none",
dataSpanDays,
totalMeasurements: allMeasurements.length,
});
@@ -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([]);
});
+7 -3
View File
@@ -6,6 +6,7 @@ import {
} from "@/lib/insights/general-status";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
export const dynamic = "force-dynamic";
@@ -13,9 +14,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const localeParam = request.nextUrl.searchParams.get("locale");
const locale = resolveGeneralStatusLocale(
localeParam ?? user.locale ?? "de",
);
const resolved = await resolveServerLocale({
request,
userLocale: user.locale ?? null,
override: localeParam,
});
const locale = resolveGeneralStatusLocale(resolved);
const result = await generateGeneralStatusForUser(user.id, {
locale,
+43 -81
View File
@@ -1,130 +1,90 @@
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { decrypt } from "@/lib/crypto";
import { extractFeatures } from "@/lib/insights/features";
import {
INSIGHTS_SYSTEM_PROMPT,
buildUserPrompt,
type InsightsOutput,
} from "@/lib/insights/prompt";
import { getInsightsSystemPrompt, buildUserPrompt } from "@/lib/insights/prompt";
import { insightResultSchema, type InsightResult } from "@/lib/ai/types";
import { resolveProvider } from "@/lib/ai/provider";
import { checkRateLimit } from "@/lib/rate-limit";
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
/**
* Generate AI-powered health insights.
* Rate limit: 2 per hour per user.
* Caches result daily (or until new data arrives).
*/
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const userId = user.id;
// Rate limit: 2 per hour per user
const rl = await checkRateLimit(`insights:${userId}`, 2, 60 * 60 * 1000);
if (!rl.allowed) {
return apiError(
"Maximal 2 Insights-Generierungen pro Stunde. Bitte spaeter erneut versuchen.",
429,
);
return apiError("Maximum 2 insight generations per hour.", 429);
}
const dbUser = await prisma.user.findUnique({
where: { id: userId },
select: {
openaiKeyEncrypted: true,
insightsPrivacyMode: true,
insightsCachedAt: true,
insightsCachedText: true,
locale: true,
},
});
if (!dbUser?.openaiKeyEncrypted) {
return apiError(
"Kein OpenAI API-Key hinterlegt. Bitte in den Einstellungen konfigurieren.",
422,
);
}
const locale = await resolveServerLocale({
request,
userLocale: dbUser?.locale ?? user.locale ?? null,
});
// Check if force refresh is requested
const body = await request.json().catch(() => ({}));
const forceRefresh = body.force === true;
// Return cached result if available and less than 24h old
if (
!forceRefresh &&
dbUser.insightsCachedAt &&
dbUser?.insightsCachedAt &&
dbUser.insightsCachedText &&
Date.now() - dbUser.insightsCachedAt.getTime() < 24 * 60 * 60 * 1000
) {
try {
const cached = JSON.parse(dbUser.insightsCachedText) as InsightsOutput;
const cached = JSON.parse(dbUser.insightsCachedText);
annotate({ action: { name: "insights.generate" }, meta: { cached: true } });
return apiSuccess({
insights: cached,
cached: true,
cachedAt: dbUser.insightsCachedAt,
});
return apiSuccess({ insights: cached, cached: true, cachedAt: dbUser.insightsCachedAt });
} catch {
// Invalid cache, regenerate
}
}
const apiKey = decrypt(dbUser.openaiKeyEncrypted);
const includeRaw = dbUser.insightsPrivacyMode === "raw";
const provider = await resolveProvider(userId);
if (provider.type === "none") {
return apiError("No AI provider configured. Connect ChatGPT in settings or ask your admin to set up an API key.", 422);
}
const includeRaw = dbUser?.insightsPrivacyMode === "raw";
const features = await extractFeatures(userId, includeRaw);
const featuresJson = JSON.stringify(features, null, 2);
const userPrompt = buildUserPrompt(featuresJson, dbUser.insightsPrivacyMode);
// Call OpenAI
const openaiRes = await fetch(
"https://api.openai.com/v1/chat/completions",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: "gpt-4o-mini",
messages: [
{ role: "system", content: INSIGHTS_SYSTEM_PROMPT },
{ role: "user", content: userPrompt },
],
temperature: 0.3,
max_tokens: 1000,
response_format: { type: "json_object" },
}),
},
const userPrompt = buildUserPrompt(
featuresJson,
dbUser?.insightsPrivacyMode ?? "aggregated",
locale,
);
if (!openaiRes.ok) {
const errBody = await openaiRes.text();
annotate({ meta: { openai_status: openaiRes.status, openai_error: errBody } });
if (openaiRes.status === 401) {
return apiError("Ungueltiger OpenAI API-Key", 422);
}
return apiError("OpenAI-Anfrage fehlgeschlagen", 502);
}
const result = await provider.generateCompletion({
systemPrompt: getInsightsSystemPrompt(locale),
userPrompt,
temperature: 0.3,
maxTokens: 1500,
});
const openaiJson = await openaiRes.json();
const content = openaiJson.choices?.[0]?.message?.content;
if (!content) {
return apiError("Keine Antwort von OpenAI erhalten", 502);
}
let insights: InsightsOutput;
let insights: InsightResult | Record<string, unknown>;
try {
insights = JSON.parse(content);
const parsed = JSON.parse(result.content);
// Try new schema first, fall back to raw parsed if validation fails
const validated = insightResultSchema.safeParse(parsed);
insights = validated.success ? validated.data : parsed;
} catch {
return apiError("OpenAI-Antwort konnte nicht verarbeitet werden", 502);
return apiError("Failed to parse AI response", 502);
}
// Cache the result
await prisma.user.update({
where: { id: userId },
data: {
@@ -133,13 +93,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
});
// Audit log (no sensitive content)
await auditLog("insights.generate", {
userId,
ipAddress: getClientIp(request),
details: {
privacyMode: dbUser.insightsPrivacyMode,
tokensUsed: openaiJson.usage?.total_tokens ?? null,
privacyMode: dbUser?.insightsPrivacyMode,
tokensUsed: result.tokensUsed,
providerType: result.providerType,
model: result.model,
},
});
@@ -147,8 +108,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
action: { name: "insights.generate" },
meta: {
cached: false,
privacyMode: dbUser.insightsPrivacyMode,
tokensUsed: openaiJson.usage?.total_tokens ?? null,
providerType: result.providerType,
model: result.model,
tokensUsed: result.tokensUsed,
},
});
@@ -6,6 +6,7 @@ import {
} from "@/lib/insights/medication-compliance-status";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
export const dynamic = "force-dynamic";
@@ -13,9 +14,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const localeParam = request.nextUrl.searchParams.get("locale");
const locale = resolveMedicationComplianceStatusLocale(
localeParam ?? user.locale ?? "de",
);
const resolved = await resolveServerLocale({
request,
userLocale: user.locale ?? null,
override: localeParam,
});
const locale = resolveMedicationComplianceStatusLocale(resolved);
const result = await generateMedicationComplianceStatusForUser(
user.id,
+7 -3
View File
@@ -6,6 +6,7 @@ import {
} from "@/lib/insights/mood-status";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
export const dynamic = "force-dynamic";
@@ -13,9 +14,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const localeParam = request.nextUrl.searchParams.get("locale");
const locale = resolveMoodStatusLocale(
localeParam ?? user.locale ?? "de",
);
const resolved = await resolveServerLocale({
request,
userLocale: user.locale ?? null,
override: localeParam,
});
const locale = resolveMoodStatusLocale(resolved);
const result = await generateMoodStatusForUser(user.id, {
locale,
+7 -3
View File
@@ -6,6 +6,7 @@ import {
} from "@/lib/insights/pulse-status";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
export const dynamic = "force-dynamic";
@@ -13,9 +14,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const localeParam = request.nextUrl.searchParams.get("locale");
const locale = resolvePulseStatusLocale(
localeParam ?? user.locale ?? "de",
);
const resolved = await resolveServerLocale({
request,
userLocale: user.locale ?? null,
override: localeParam,
});
const locale = resolvePulseStatusLocale(resolved);
const result = await generatePulseStatusForUser(user.id, {
locale,
+13 -33
View File
@@ -1,78 +1,58 @@
import { prisma } from "@/lib/db";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { encrypt } from "@/lib/crypto";
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
/**
* Get insights configuration status.
*/
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: {
openaiKeyEncrypted: true,
codexConnectionStatus: true,
codexConnectedAt: true,
insightsPrivacyMode: true,
insightsCachedAt: true,
},
});
const settings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
select: { adminAiKeyEncrypted: true },
});
annotate({ action: { name: "insights.settings.get" } });
return apiSuccess({
hasKey: !!dbUser?.openaiKeyEncrypted,
codexStatus: dbUser?.codexConnectionStatus ?? "disconnected",
codexConnectedAt: dbUser?.codexConnectedAt ?? null,
hasAdminKey: !!settings?.adminAiKeyEncrypted,
privacyMode: dbUser?.insightsPrivacyMode ?? "aggregated",
lastInsightAt: dbUser?.insightsCachedAt ?? null,
});
});
/**
* Update insights settings (API key and/or privacy mode).
*/
export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson<Record<string, unknown>>(request);
if (jsonError) return jsonError;
const data: Record<string, unknown> = {};
// Update API key if provided
if (typeof body.apiKey === "string") {
const key = (body.apiKey as string).trim();
if (key === "") {
// Remove key
data.openaiKeyEncrypted = null;
data.insightsCachedAt = null;
data.insightsCachedText = null;
} else {
if (!key.startsWith("sk-")) {
return apiError(
"Ungueltiges API-Key-Format (muss mit sk- beginnen)",
422,
);
}
data.openaiKeyEncrypted = encrypt(key);
}
}
// Update privacy mode if provided
if (typeof body.privacyMode === "string") {
const mode = body.privacyMode as string;
if (!["aggregated", "raw"].includes(mode)) {
return apiError("Ungueltiger Privacy-Modus", 422);
return apiError("Invalid privacy mode", 422);
}
data.insightsPrivacyMode = mode;
// Clear cache when privacy mode changes
data.insightsCachedAt = null;
data.insightsCachedText = null;
}
if (Object.keys(data).length === 0) {
return apiError("Keine Aenderungen", 422);
return apiError("No changes", 422);
}
await prisma.user.update({
+107 -25
View File
@@ -17,9 +17,14 @@ import {
classifyPulseByTarget,
getPersonalizedPulseTarget,
} from "@/lib/analytics/pulse-targets";
import type { MeasurementType } from "@/generated/prisma/client";
import type { MeasurementType, GlucoseContext } from "@/generated/prisma/client";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import {
getEffectiveRange,
type ThresholdOverridesJson,
} from "@/lib/analytics/effective-range";
import { thresholdMetricForContext, resolveGlucoseUnit } from "@/lib/glucose";
export const dynamic = "force-dynamic";
@@ -67,6 +72,8 @@ export const GET = apiHandler(async () => {
heightCm: true,
dateOfBirth: true,
gender: true,
glucoseUnit: true,
thresholdsJson: true,
},
});
@@ -161,7 +168,7 @@ export const GET = apiHandler(async () => {
}
targets.push({
type: "WEIGHT",
label: "Gewicht",
label: "Weight",
current: latestByType.WEIGHT ?? null,
average30: avg30ByType.WEIGHT ?? null,
trend: computeTrend("WEIGHT"),
@@ -186,7 +193,7 @@ export const GET = apiHandler(async () => {
}
targets.push({
type: "BLOOD_PRESSURE",
label: "Blutdruck",
label: "Blood pressure",
current: latestByType.BLOOD_PRESSURE_SYS ?? null,
average30: avg30ByType.BLOOD_PRESSURE_SYS ?? null,
trend: computeTrend("BLOOD_PRESSURE_SYS"),
@@ -227,7 +234,7 @@ export const GET = apiHandler(async () => {
targets.push({
type: "BLOOD_PRESSURE_IN_TARGET",
label: "Blutdruck im Zielbereich",
label: "Blood pressure on target",
current: bpInTargetRate,
average30: bpInTargetRate,
trend: null,
@@ -236,10 +243,10 @@ export const GET = apiHandler(async () => {
classification:
bpInTargetRate != null
? bpInTargetRate >= 70
? { category: "Gut", color: "#50fa7b" }
? { category: "Good", color: "#50fa7b" }
: bpInTargetRate >= 40
? { category: "Moderat", color: "#f1fa8c" }
: { category: "Niedrig", color: "#ff5555" }
? { category: "Moderate", color: "#f1fa8c" }
: { category: "Low", color: "#ff5555" }
: null,
source: "ESC/ESH 2018",
});
@@ -254,7 +261,7 @@ export const GET = apiHandler(async () => {
}
targets.push({
type: "PULSE",
label: "Ruhepuls",
label: "Resting pulse",
current: latestByType.PULSE ?? null,
average30: avg30ByType.PULSE ?? null,
trend: computeTrend("PULSE"),
@@ -273,7 +280,7 @@ export const GET = apiHandler(async () => {
}
targets.push({
type: "SLEEP_DURATION",
label: "Schlafdauer",
label: "Sleep duration",
current: latestByType.SLEEP_DURATION ?? null,
average30: avg30ByType.SLEEP_DURATION ?? null,
trend: computeTrend("SLEEP_DURATION"),
@@ -335,7 +342,7 @@ export const GET = apiHandler(async () => {
}
targets.push({
type: "BODY_FAT",
label: "Koerperfett",
label: "Body fat",
current: latestByType.BODY_FAT ?? null,
average30: avg30ByType.BODY_FAT ?? null,
trend: computeTrend("BODY_FAT"),
@@ -354,11 +361,11 @@ export const GET = apiHandler(async () => {
}
targets.push({
type: "ACTIVITY_STEPS",
label: "Schritte/Tag",
label: "Steps/day",
current: latestByType.ACTIVITY_STEPS ?? null,
average30: avg30ByType.ACTIVITY_STEPS ?? null,
trend: computeTrend("ACTIVITY_STEPS"),
unit: "Schritte",
unit: "steps",
range: stepsRange,
classification: stepsClassification,
source: "WHO",
@@ -453,7 +460,7 @@ export const GET = apiHandler(async () => {
targets.push({
type: "MEDICATION_COMPLIANCE",
label: "Einnahmetreue",
label: "Medication compliance",
current: complianceRate7,
average30: complianceRate30,
trend: null,
@@ -462,12 +469,12 @@ export const GET = apiHandler(async () => {
classification:
complianceRate7 != null
? complianceRate7 >= 90
? { category: "Sehr gut", color: "#50fa7b" }
? { category: "Very good", color: "#50fa7b" }
: complianceRate7 >= 70
? { category: "Gut", color: "#f1fa8c" }
: { category: "Niedrig", color: "#ff5555" }
? { category: "Good", color: "#f1fa8c" }
: { category: "Low", color: "#ff5555" }
: null,
source: "7-Tage",
source: "7-day",
details: {
medications: medicationStats.map((medication) => ({
name: medication.name,
@@ -523,15 +530,15 @@ export const GET = apiHandler(async () => {
const moodClassification =
latestMoodScore != null
? latestMoodScore >= 3.5
? { category: "Gut", color: "#50fa7b" }
? { category: "Good", color: "#50fa7b" }
: latestMoodScore >= 2
? { category: "Moderat", color: "#f1fa8c" }
: { category: "Niedrig", color: "#ff5555" }
? { category: "Moderate", color: "#f1fa8c" }
: { category: "Low", color: "#ff5555" }
: null;
targets.push({
type: "MOOD_SCORE",
label: "Stimmung",
label: "Mood",
current: latestMoodScore,
average30: moodAvg30,
trend: moodTrend,
@@ -551,14 +558,14 @@ export const GET = apiHandler(async () => {
const stabilityClassification =
stdDev <= 0.5
? { category: "Sehr stabil", color: "#50fa7b" }
? { category: "Very stable", color: "#50fa7b" }
: stdDev <= 1.0
? { category: "Stabil", color: "#f1fa8c" }
: { category: "Schwankend", color: "#ff5555" };
? { category: "Stable", color: "#f1fa8c" }
: { category: "Fluctuating", color: "#ff5555" };
targets.push({
type: "MOOD_STABILITY",
label: "Stimmungsstabilitaet",
label: "Mood stability",
current: stdDev,
average30: stdDev,
trend: null,
@@ -570,6 +577,80 @@ export const GET = apiHandler(async () => {
}
}
// 10. Blood glucose — one card per logged context.
const glucoseContexts: GlucoseContext[] = [
"FASTING",
"POSTPRANDIAL",
"RANDOM",
"BEDTIME",
];
const glucoseUnit = resolveGlucoseUnit(dbUser?.glucoseUnit ?? null);
const overrides = (dbUser?.thresholdsJson ?? null) as ThresholdOverridesJson | null;
const profileForRange = {
heightCm,
dateOfBirth: dbUser?.dateOfBirth ?? null,
gender: dbUser?.gender ?? null,
};
const thirtyDaysAgoGlucose = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const glucoseRows = await prisma.measurement.findMany({
where: { userId, type: "BLOOD_GLUCOSE" },
orderBy: { measuredAt: "desc" },
select: { value: true, measuredAt: true, glucoseContext: true },
});
const labelKeyByContext: Record<GlucoseContext, string> = {
FASTING: "targets.glucoseFasting",
POSTPRANDIAL: "targets.glucosePostprandial",
RANDOM: "targets.glucoseRandom",
BEDTIME: "targets.glucoseBedtime",
};
for (const ctx of glucoseContexts) {
const ctxRows = glucoseRows.filter((r) => r.glucoseContext === ctx);
if (ctxRows.length === 0) continue;
const latest = ctxRows[0].value;
const recent = ctxRows.filter((r) => r.measuredAt >= thirtyDaysAgoGlucose);
const avg30 =
recent.length > 0
? Math.round(
(recent.reduce((s, r) => s + r.value, 0) / recent.length) * 10,
) / 10
: null;
const metric = thresholdMetricForContext(ctx);
const eff = getEffectiveRange(metric, profileForRange, overrides);
const range = eff.range
? { min: eff.range.greenMin, max: eff.range.greenMax }
: null;
let classification: { category: string; color: string } | null = null;
if (range) {
if (latest >= range.min && latest <= range.max) {
classification = { category: "Optimal", color: "#50fa7b" };
} else if (
eff.range &&
latest >= eff.range.orangeMin &&
latest <= eff.range.orangeMax
) {
classification = { category: "Elevated", color: "#f1fa8c" };
} else {
classification = { category: "High", color: "#ff5555" };
}
}
targets.push({
type: `BLOOD_GLUCOSE_${ctx}`,
label: labelKeyByContext[ctx],
current: latest,
average30: avg30,
trend: null,
unit: glucoseUnit,
range,
classification,
source: eff.isOverride ? "Custom" : "ADA 2024 / DDG",
});
}
annotate({ action: { name: "insights.targets" }, meta: { targetCount: targets.length } });
return apiSuccess({
@@ -584,6 +665,7 @@ export const GET = apiHandler(async () => {
heightCm,
age,
gender,
glucoseUnit,
},
});
});
+7 -3
View File
@@ -6,6 +6,7 @@ import {
} from "@/lib/insights/weight-status";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
export const dynamic = "force-dynamic";
@@ -13,9 +14,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const localeParam = request.nextUrl.searchParams.get("locale");
const locale = resolveWeightStatusLocale(
localeParam ?? user.locale ?? "de",
);
const resolved = await resolveServerLocale({
request,
userLocale: user.locale ?? null,
override: localeParam,
});
const locale = resolveWeightStatusLocale(resolved);
const result = await generateWeightStatusForUser(user.id, {
locale,
@@ -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,
});
});
@@ -17,7 +17,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
select: { moodLogGlobal: true },
});
if (appSettings && !appSettings.moodLogGlobal) {
return apiError("moodLog-Integration ist deaktiviert", 403);
return apiError("moodLog integration is disabled", 403);
}
let fullSync = false;
@@ -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);
});
+3 -3
View File
@@ -18,7 +18,7 @@ export const GET = apiHandler(async (_request: NextRequest, { params }: RoutePar
});
if (!measurement || measurement.userId !== user.id) {
return apiError("Messwert nicht gefunden", 404);
return apiError("Measurement not found", 404);
}
annotate({
@@ -42,7 +42,7 @@ export const PUT = apiHandler(async (request: NextRequest, { params }: RoutePara
});
if (!existing || existing.userId !== user.id) {
return apiError("Messwert nicht gefunden", 404);
return apiError("Measurement not found", 404);
}
const { data: body, error: jsonError } = await safeJson(request);
@@ -90,7 +90,7 @@ export const DELETE = apiHandler(async (request: NextRequest, { params }: RouteP
});
if (!existing || existing.userId !== user.id) {
return apiError("Messwert nicht gefunden", 404);
return apiError("Measurement not found", 404);
}
await prisma.measurement.delete({ where: { id } });
+22 -6
View File
@@ -2,17 +2,24 @@ 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,
MeasurementSource,
GlucoseContext,
} from "@/generated/prisma/client";
import { Prisma } from "@/generated/prisma/client";
@@ -58,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);
@@ -85,6 +94,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
source: (m.source ?? "MANUAL") as MeasurementSource,
measuredAt: m.measuredAt,
notes: m.notes ?? null,
glucoseContext:
(m.glucoseContext as GlucoseContext | undefined) ?? null,
},
}),
),
@@ -116,7 +127,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError(parsed.error.issues[0].message, 422);
}
const { type, value, measuredAt, notes, source } = parsed.data;
const { type, value, measuredAt, notes, source, glucoseContext } =
parsed.data;
// Handle unique constraint violation
let measurement;
@@ -130,11 +142,15 @@ export const POST = apiHandler(async (request: NextRequest) => {
source: (source ?? "MANUAL") as MeasurementSource,
measuredAt,
notes: notes ?? null,
glucoseContext: (glucoseContext as GlucoseContext | undefined) ?? null,
},
});
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") {
return apiError("Ein Messwert mit diesen Daten existiert bereits", 409);
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
err.code === "P2002"
) {
return apiError("A measurement with this data already exists", 409);
}
throw err;
}
@@ -154,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 },
});
});
@@ -17,13 +17,13 @@ function medicationScope(medicationId: string): string {
export const GET = apiHandler(async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
if (!(await isApiGloballyEnabled())) {
return apiError("API ist global deaktiviert", 403);
return apiError("API is globally disabled", 403);
}
const { id } = await params;
const medication = await prisma.medication.findUnique({ where: { id } });
if (!medication || medication.userId !== user.id) {
return apiError("Medikament nicht gefunden", 404);
return apiError("Medication not found", 404);
}
const scope = medicationScope(id);
@@ -56,13 +56,13 @@ export const GET = apiHandler(async (_request: NextRequest, { params }: RoutePar
export const PUT = apiHandler(async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
if (!(await isApiGloballyEnabled())) {
return apiError("API ist global deaktiviert", 403);
return apiError("API is globally disabled", 403);
}
const { id } = await params;
const medication = await prisma.medication.findUnique({ where: { id } });
if (!medication || medication.userId !== user.id) {
return apiError("Medikament nicht gefunden", 404);
return apiError("Medication not found", 404);
}
let enabled = false;
@@ -70,7 +70,7 @@ export const PUT = apiHandler(async (request: NextRequest, { params }: RoutePara
const body = await request.json();
enabled = body?.enabled === true;
} catch {
return apiError("Ungültige Anfrage", 422);
return apiError("Invalid request", 422);
}
const scope = medicationScope(id);
Loaded 100 of 262 files, more files were not shown because too many files have changed in this diff. Show more