From 02b99553b99ea35d8d59d5ff8a6bac79142e06e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 9 May 2026 02:27:17 +0200 Subject: [PATCH] chore(style): repo-wide prettier sweep before v1.4.6 tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Long-overdue prettier sweep; prettier had drifted vs. checked-in code across ~130 files (mostly long-argument line-break wrapping in TS and table-padding in markdown). Pure formatting — no semantic changes — and required to clear the v1.4.6 release gate's `pnpm format:check`. Co-Authored-By: Claude Opus 4.7 (1M context) --- .planning/ROADMAP.md | 24 +- .planning/STATE.md | 11 + CHANGELOG.md | 6 +- docs/audit/2026-04-26-PLAN.md | 75 ++-- docs/audit/v146-findings.md | 53 +-- public/sw.js | 4 +- scripts/seed-demo.ts | 84 +++-- .../api/admin/feedback/[id]/github/route.ts | 5 +- src/app/api/admin/feedback/[id]/route.ts | 7 +- .../admin/monitoring/glitchtip-test/route.ts | 9 +- .../api/admin/monitoring/umami-test/route.ts | 10 +- .../notifications/reminder-check/route.ts | 4 +- src/app/api/admin/settings/route.ts | 18 +- src/app/api/admin/status-overview/route.ts | 27 +- .../admin/users/[id]/reset-password/route.ts | 111 +++--- src/app/api/admin/users/[id]/route.ts | 109 +++--- src/app/api/auth/codex/callback/route.ts | 4 +- .../api/auth/passkey/register-verify/route.ts | 14 +- src/app/api/auth/passkeys/[id]/route.ts | 66 ++-- src/app/api/auth/password/route.ts | 22 +- src/app/api/auth/profile/route.ts | 7 +- src/app/api/auth/register/route.ts | 10 +- src/app/api/bugreport/route.ts | 17 +- src/app/api/bugreport/status/route.ts | 6 +- .../dashboard/summary/__tests__/route.test.ts | 10 +- src/app/api/devices/__tests__/route.test.ts | 6 +- src/app/api/devices/route.ts | 5 +- src/app/api/export/route.ts | 9 +- src/app/api/feedback/route.ts | 7 +- .../achievements/__tests__/ios-format.test.ts | 10 +- .../api/gamification/achievements/route.ts | 22 +- src/app/api/import/__tests__/route.test.ts | 5 +- src/app/api/import/route.ts | 7 +- src/app/api/ingest/medication/route.ts | 7 +- .../insights/blood-pressure-status/route.ts | 11 +- .../insights/cards/__tests__/route.test.ts | 26 +- src/app/api/insights/cards/route.ts | 36 +- src/app/api/insights/comprehensive/route.ts | 25 +- .../correlations/__tests__/route.test.ts | 6 +- src/app/api/insights/generate/route.ts | 21 +- .../medication-compliance-status/route.ts | 11 +- src/app/api/insights/settings/route.ts | 3 +- .../healthkit/__tests__/route.test.ts | 9 +- src/app/api/integrations/healthkit/route.ts | 25 +- src/app/api/measurements/[id]/route.ts | 173 +++++---- .../series/__tests__/route.test.ts | 6 +- .../medications/[id]/api-endpoint/route.ts | 256 ++++++------- .../api/medications/[id]/compliance/route.ts | 232 ++++++------ .../[id]/intake/[eventId]/route.ts | 153 ++++---- .../medications/[id]/intake/import/route.ts | 177 ++++----- .../medications/[id]/intake/purge/route.ts | 52 +-- .../medications/[id]/phase-config/route.ts | 184 ++++----- src/app/api/medications/[id]/route.ts | 355 +++++++++--------- .../api/medications/intake-summary/route.ts | 5 +- .../intake/__tests__/route.test.ts | 10 +- src/app/api/medications/intake/route.ts | 10 +- src/app/api/medications/route.ts | 7 +- src/app/api/monitoring/glitchtip/route.ts | 9 +- src/app/api/mood-entries/[id]/route.ts | 150 ++++---- src/app/api/mood/analytics/route.ts | 5 +- src/app/api/settings/account/route.ts | 5 +- src/app/api/settings/moodlog/route.ts | 5 +- src/app/api/settings/ntfy/test/route.ts | 3 +- src/app/api/settings/telegram/route.ts | 5 +- src/app/api/settings/telegram/test/route.ts | 10 +- src/app/api/telegram/webhook/route.ts | 13 +- src/app/api/tokens/[id]/route.ts | 36 +- src/app/api/user/ai-provider/route.ts | 8 +- .../api/user/profile/__tests__/route.test.ts | 6 +- src/app/api/user/profile/route.ts | 7 +- src/app/api/user/thresholds/route.ts | 15 +- src/app/api/withings/status/route.ts | 3 +- src/app/api/withings/webhook/route.ts | 12 +- src/app/auth/login/page.tsx | 4 +- src/app/auth/register/page.tsx | 5 +- src/app/bugreport/page.tsx | 16 +- src/app/global-error.tsx | 6 +- src/app/insights/page.tsx | 89 ++--- src/app/page.tsx | 2 +- .../admin/feedback-inbox-section.tsx | 6 +- src/components/charts/compliance-heatmap.tsx | 24 +- src/components/charts/mood-chart.tsx | 42 ++- src/components/charts/trend-card.tsx | 12 +- src/components/error-details.tsx | 10 +- .../insights/insight-status-card.tsx | 16 +- src/components/insights/insights-card.tsx | 34 +- src/components/layout/auth-shell.tsx | 2 +- .../measurements/measurement-list.tsx | 6 +- .../medications/intake-history-list.tsx | 104 ++--- .../medications/medication-form.tsx | 13 +- src/components/mood/mood-form.tsx | 6 +- src/components/mood/mood-list.tsx | 27 +- src/components/settings/about-section.tsx | 14 +- src/components/settings/ai-section.tsx | 16 +- .../settings/thresholds-section.tsx | 23 +- src/components/ui/dialog.tsx | 5 +- src/components/ui/password-strength.tsx | 5 +- .../__tests__/i18n-locale-integrity.test.ts | 86 ++++- src/lib/__tests__/idempotency.test.ts | 19 +- src/lib/__tests__/moodlog-secret.test.ts | 4 +- src/lib/__tests__/query-keys.test.ts | 4 +- src/lib/__tests__/require-auth-bearer.test.ts | 6 +- src/lib/__tests__/server-translator.test.ts | 6 +- src/lib/ai/__tests__/anthropic-client.test.ts | 6 +- src/lib/ai/__tests__/codex-client.test.ts | 20 +- src/lib/ai/__tests__/codex-oauth.test.ts | 4 +- src/lib/ai/__tests__/local-client.test.ts | 2 +- src/lib/ai/__tests__/openai-client.test.ts | 5 +- src/lib/ai/__tests__/provider.test.ts | 5 +- src/lib/ai/anthropic-client.ts | 13 +- src/lib/ai/codex-client.ts | 13 +- src/lib/ai/codex-oauth.ts | 4 +- src/lib/ai/local-client.ts | 4 +- src/lib/ai/openai-client.ts | 4 +- src/lib/ai/types.ts | 15 +- .../__tests__/effective-range.test.ts | 12 +- src/lib/analytics/pulse-targets.ts | 18 +- src/lib/app-settings.ts | 4 +- src/lib/auth/session.ts | 6 +- src/lib/charts/bucket-time-series.ts | 4 +- src/lib/feedback/publish-github.ts | 30 +- src/lib/i18n/context.tsx | 5 +- src/lib/i18n/server-locale.ts | 3 +- .../insights/__tests__/mood-status.test.ts | 2 +- src/lib/insights/prompt.ts | 4 +- src/lib/logging/__tests__/redact.test.ts | 6 +- src/lib/logging/sampler.ts | 3 +- src/lib/logging/types.ts | 8 +- src/lib/moodlog-secret.ts | 4 +- src/lib/notifications/senders/web-push.ts | 6 +- src/lib/rate-limit.ts | 4 +- src/lib/telegram.ts | 20 +- src/lib/validations/auth.ts | 10 +- src/lib/validations/feedback.ts | 5 +- src/lib/validations/thresholds.ts | 5 +- 135 files changed, 2159 insertions(+), 1583 deletions(-) diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index d980e6937..b54d40660 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -2,18 +2,18 @@ Milestone: **v1.4.6** (overnight 2026-05-09) -| Phase | Goal | State | -|-------|------|-------| -| 1 | CI green (e2e workflow root cause + fix) | pending | -| 2 | Tier 1 fixes T1–T9 | pending | -| 3 | New feature: chart bucketing for >1y ranges | pending | -| 4 | Tier 2 polish P1–P20 | pending | -| 5 | Multi-agent QA + simplify pass | pending | -| 6 | Pre-release verification | pending | -| 7 | Release commit, tag, deploy, verify production | pending | -| 8 | GitHub releases backfill (v1.4.2–v1.4.6) + GHCR | pending | -| 9 | Docs site (`healthlog-docs`) + landing site sync | pending | -| 10 | Summary doc + Marc-facing brief | pending | +| Phase | Goal | State | +| ----- | ------------------------------------------------ | ------- | +| 1 | CI green (e2e workflow root cause + fix) | pending | +| 2 | Tier 1 fixes T1–T9 | pending | +| 3 | New feature: chart bucketing for >1y ranges | pending | +| 4 | Tier 2 polish P1–P20 | pending | +| 5 | Multi-agent QA + simplify pass | pending | +| 6 | Pre-release verification | pending | +| 7 | Release commit, tag, deploy, verify production | pending | +| 8 | GitHub releases backfill (v1.4.2–v1.4.6) + GHCR | pending | +| 9 | Docs site (`healthlog-docs`) + landing site sync | pending | +| 10 | Summary doc + Marc-facing brief | pending | Stop conditions: production red, large un-spec'd issue, context cutoff, budget short of polish — write status doc, do not auto-rollback. diff --git a/.planning/STATE.md b/.planning/STATE.md index 963083a0c..8db452a85 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -5,17 +5,20 @@ Status: **bootstrapped** (2026-05-09 ~01:25) Update at every phase boundary so `cat .planning/STATE.md` is the truth. ## Phase 0 — Bootstrap + - [x] `.planning/PROJECT.md` - [x] `.planning/ROADMAP.md` - [x] `.planning/STATE.md` - [ ] commit `chore(planning): bootstrap v1.4.6 marathon` ## Phase 1 — CI green + - [ ] reproduce e2e failure locally - [ ] root-cause + fix - [ ] verify CI green ## Phase 2 — Tier 1 fixes (T1–T9) + - [ ] T1 trend-card w-full (in working tree) - [ ] T2 muted-foreground colour split - [ ] T3 primaryRecommendation chart-token rendering @@ -27,11 +30,13 @@ Update at every phase boundary so `cat .planning/STATE.md` is the truth. - [ ] T9 KI per-card 360 daily + 24 monthly (7 generators) ## Phase 3 — Chart bucketing + - [ ] research recharts patterns - [ ] `bucketTimeSeries` helper + tests - [ ] chart header chip + i18n ## Phase 4 — Tier 2 polish + - [ ] P1–P5 trend-card + chart card (single commit) - [ ] P6 welcome subtitle - [ ] P7 auth-shell pb buffer @@ -50,16 +55,19 @@ Update at every phase boundary so `cat .planning/STATE.md` is the truth. - [ ] P20 status-overview Promise.allSettled ## Phase 5 — QA + - [ ] superpowers:code-reviewer - [ ] Plan as senior security reviewer - [ ] Plan as senior design reviewer - [ ] simplify pass ## Phase 6 — Pre-release verification + - [ ] typecheck / lint / format / test / integration / build / e2e - [ ] before-screenshots vs prod ## Phase 7 — Release + - [ ] package.json 1.4.6 - [ ] CHANGELOG `[1.4.6]` block - [ ] tag + push @@ -68,12 +76,15 @@ Update at every phase boundary so `cat .planning/STATE.md` is the truth. - [ ] /api/version=1.4.6, image digest changed ## Phase 8 — Releases + - [ ] gh release create v1.4.2…v1.4.6 - [ ] GHCR untagged cleanup ## Phase 9 — Docs / landing + - [ ] healthlog-docs synced to v1.4.6 - [ ] healthlog-landing minimal update ## Phase 10 — Summary + - [ ] `docs/audit/v146-summary.md` with German Marc-brief on top diff --git a/CHANGELOG.md b/CHANGELOG.md index 05311a6e8..e286d96df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ bad.** The endpoint mapped every upstream error to HTTP 502, which Cloudflare intercepts and replaces with its own HTML error page. `await res.json()` in the browser then crashed with `Unexpected - token '<', " Installation" + „Self-Hosting > Docker" | Phase 2 (DX-004) | abgedeckt | -| Vorschlag: Same presets across compose+docs+.env.example | Phase 1 + Phase 2 | abgedeckt | + +| #88 Punkt | Phase | Status | +| --------------------------------------------------------------------------- | ----------------------------- | --------- | +| (a) Bundled compose ≠ docs version | Phase 2 (DOC-005) | abgedeckt | +| (b) ghcr.io 404 (kein Image gepusht) | Phase 2 (DX-001 GHA-Workflow) | abgedeckt | +| (c) `.env.example` `localhost:5432` vs Compose `db:5432` | Phase 1 (DX-003) | abgedeckt | +| (d) `expose:3000` statt `ports:3000:3000` | Phase 1 (DX-002) | abgedeckt | +| Vorschlag: GHA für Image-Push (GHCR/Docker Hub) | Phase 2 | abgedeckt | +| Vorschlag: Merge „Getting started > Installation" + „Self-Hosting > Docker" | Phase 2 (DX-004) | abgedeckt | +| Vorschlag: Same presets across compose+docs+.env.example | Phase 1 + Phase 2 | abgedeckt | ### Issue #89 (TBW + Bone Mass) + - Phase 3 hardcoded mit Withings-Mapping. Optional `/api/ingest/measurements` für n8n in Phase 3. ### Issue #73 (UX-Liste mit 8 Punkten — CLOSED in v1.2) -| #73 Punkt | v1.2-Status | Residual? | -|---|---|---| -| 1. Passkey-Errors unklar | Resolved (Package C, typed errors) | TEST-002 (keine Tests) → Phase 6 | -| 2. Number-Formatting (DE 70,5 vs US 70.5) | Resolved (Package B, locale-aware formatters) | I18N-003 in `medication-card.tsx` → Phase 7 | -| 3. Chart-Labels „7P/30P/90P/All" | Resolved (Package A, „7 Pkt"/„7 pts") | — | -| 4. Dashboard nicht real-time | Resolved (`invalidateKeys` durchgängig) | — | -| 5. GitHub-Setup-Confusion | Resolved (Built-in Feedback in v1.2) | — | -| 6. Built-in Feedback | Done in v1.2 | — | -| 7. Admin-Save ohne Error-UX | Partial (Success-Msg ja, Error-Highlight schwach) | UX-002 → Phase 7 | -| 8. Blood-Sugar | Done in v1.2 | — | + +| #73 Punkt | v1.2-Status | Residual? | +| ----------------------------------------- | ------------------------------------------------- | ------------------------------------------- | +| 1. Passkey-Errors unklar | Resolved (Package C, typed errors) | TEST-002 (keine Tests) → Phase 6 | +| 2. Number-Formatting (DE 70,5 vs US 70.5) | Resolved (Package B, locale-aware formatters) | I18N-003 in `medication-card.tsx` → Phase 7 | +| 3. Chart-Labels „7P/30P/90P/All" | Resolved (Package A, „7 Pkt"/„7 pts") | — | +| 4. Dashboard nicht real-time | Resolved (`invalidateKeys` durchgängig) | — | +| 5. GitHub-Setup-Confusion | Resolved (Built-in Feedback in v1.2) | — | +| 6. Built-in Feedback | Done in v1.2 | — | +| 7. Admin-Save ohne Error-UX | Partial (Success-Msg ja, Error-Highlight schwach) | UX-002 → Phase 7 | +| 8. Blood-Sugar | Done in v1.2 | — | ### Issue #74 (raw locale keys in UI) + Resolved in v1.2. --- @@ -221,6 +231,7 @@ Resolved in v1.2. ## Time-to-First-Value (TTFV) Ziel Nach Onboarding-Redesign + Empty-State-Pattern: + - Login → Dashboard mit Empty-States: 2 Klicks - Klick auf „Add your first weight" → Form prefilled „now" → Submit: 30 Sek - **TTFV-Ziel: <60 Sekunden** (Branchenvergleich: führende Health-Apps 30-90s) diff --git a/docs/audit/v146-findings.md b/docs/audit/v146-findings.md index 7ebe2c4dc..3ac5b0e43 100644 --- a/docs/audit/v146-findings.md +++ b/docs/audit/v146-findings.md @@ -100,6 +100,7 @@ Same one-liner pattern as the v1.4.5 ai/test fix (see CHANGELOG). `src/components/admin/status-card-grid.tsx:147, 159, 177, 189, 210, 222` Hrefs to non-existent routes/anchors: + - `/admin/users`, `/admin/audit-log` — sub-routes don't exist - `/admin#integrations`, `/admin#monitoring`, `/admin#backups`, `/admin#maintenance` — anchor IDs don't match the rendered DOM @@ -110,6 +111,7 @@ The `status-card-grid.test.tsx:105` only asserts `startsWith("/admin")` — so this regression slipped through. **Fix:** Point hrefs at the existing anchor IDs: + - Users → `/admin#section-user-management` - Audit log → `/admin#section-login-overview` - Integrations → `/admin#section-admin-umami` @@ -134,6 +136,7 @@ The CLAUDE.md / commit message claim ("the report button gracefully disappears") is false. **Fix:** Pick one approach: + - **(a)** Add `bugReportEnabled` to `/api/bugreport/status` and to `/api/feedback` POST so the form is hidden + blocked when off. Recommended. @@ -158,6 +161,7 @@ Worse: `tx.auditLog.deleteMany({})` runs in the same transaction — primary purpose is auditability, this is wrong. **Fix:** + - **Stop deleting `AuditLog`.** Keep the audit trail. (If retention is desired, add a separate maintenance route that prunes by age.) - Reflect the actual scope in the response toast (the API already @@ -229,6 +233,7 @@ in the selected range. For the dashboard "All" / "Alle" range filter this can be 1000s of points and is unreadable. Add **automatic bucketing** based on the rendered range: + - `≤ 90 days` → daily points (current behaviour) - `91-730 days` → weekly average - `> 730 days` → monthly average @@ -256,28 +261,28 @@ this. Don't reinvent. ## Tier 2 — Polish (also for v1.4.6) -| ID | File | Change | -|----|------|--------| -| P1 | `trend-card.tsx:93,109,118,130,139` | Add `tabular-nums` to numeric spans (digits jiggle on refresh) | -| P2 | `trend-card.tsx:87,93` | Bump value `text-2xl` → `text-3xl tracking-tight`, label `text-sm font-medium` → `text-xs uppercase tracking-wide` (KPI hierarchy) | -| P3 | `trend-card.tsx:85` vs `health-chart.tsx:551` | Match padding: tile `p-3` → `p-4 md:p-6` to align with chart cards | -| P4 | `trend-card.tsx:64-69` | Trend-arrow color: keep flat at muted; up/down both `text-foreground` (neutral). Direction-as-good-or-bad is metric-specific and v1.5+ work. | -| P5 | `trend-card.tsx:103-145` | Always render avg7/avg30 chips; use `—` when null so vertical rhythm is consistent across tiles | -| P6 | `page.tsx:362` | Welcome subtitle: drop `hidden sm:block`, add `text-muted-foreground` (visible on mobile, muted) | -| P7 | `auth-shell.tsx:129` | Bottom-nav buffer: `pb-[calc(5rem+env(safe-area-inset-bottom,0px))]` → `pb-[calc(4rem+env(safe-area-inset-bottom,0px))] md:pb-0` | -| P8 | `empty-state.tsx:71-77` | TrendHint title default: add `text-muted-foreground` so the title doesn't fight chart titles after T2 | -| P9 | `getting-started-checklist.tsx:299` | Drop `font-mono` from "X von Y" / "%" — keep `tabular-nums` | -| P10 | `page.tsx:846-862` | Medications card: `rounded-lg border p-4` → `rounded-xl border p-4 md:p-6` (match other chart cards) | -| P11 | `src/lib/logging/redact.ts:22` | Add `sk-(ant-)?` regex to `redactSecrets` to scrub OpenAI/Anthropic keys centrally | -| P12 | `src/lib/idempotency.ts:227` | Extend the `hlk_` / `hlr_` exclusion to also reject bodies containing `"sk-"` or `"sk-ant-"` | -| P13 | `src/app/api/insights/generate/route.ts:18-22` | Move `checkRateLimit` BELOW the cache-return so cached hits don't burn rate-limit tokens | -| P14 | `src/lib/ai/codex-client.ts:28,34` | Copy structured-error pattern from `openai-client.ts:42-58` (httpStatus, bodyExcerpt, upstream fields) | -| P15 | `src/components/settings/ai-section.tsx:49` | Drop `gpt-5` from `MODEL_PRESETS["OPENAI"]` (not a released model). Drop `o3-mini` too unless you wire the o-series param contract (`max_completion_tokens` not `max_tokens`, no `temperature` override). | -| P16 | `src/components/settings/ai-section.tsx` (~29 strings) | i18n: pipe German strings through `t("settings.ai.…")` — add keys to `messages/en.json` + `messages/de.json` | -| P17 | `src/components/admin/feedback-inbox-section.tsx:209-222` | Replace `bg-red-500/15 text-red-400` with `bg-dracula-red/15 text-dracula-red` etc. for theme consistency | -| P18 | `src/components/admin/danger-zone-section.tsx:110` | Drop string-prefix-matches-success heuristic — track via `mutation.isSuccess` / `mutation.isError` | -| P19 | `src/components/admin/_shared.tsx:268-277,216-225` | `useSystemStatus()` and `useAdminSettings()`: surface `isError` with an inline "Failed to load settings" message instead of infinite spinner | -| P20 | `src/app/api/admin/status-overview/route.ts` | `Promise.all` → `Promise.allSettled` server-side so one failed probe doesn't blank the whole grid | +| ID | File | Change | +| --- | --------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| P1 | `trend-card.tsx:93,109,118,130,139` | Add `tabular-nums` to numeric spans (digits jiggle on refresh) | +| P2 | `trend-card.tsx:87,93` | Bump value `text-2xl` → `text-3xl tracking-tight`, label `text-sm font-medium` → `text-xs uppercase tracking-wide` (KPI hierarchy) | +| P3 | `trend-card.tsx:85` vs `health-chart.tsx:551` | Match padding: tile `p-3` → `p-4 md:p-6` to align with chart cards | +| P4 | `trend-card.tsx:64-69` | Trend-arrow color: keep flat at muted; up/down both `text-foreground` (neutral). Direction-as-good-or-bad is metric-specific and v1.5+ work. | +| P5 | `trend-card.tsx:103-145` | Always render avg7/avg30 chips; use `—` when null so vertical rhythm is consistent across tiles | +| P6 | `page.tsx:362` | Welcome subtitle: drop `hidden sm:block`, add `text-muted-foreground` (visible on mobile, muted) | +| P7 | `auth-shell.tsx:129` | Bottom-nav buffer: `pb-[calc(5rem+env(safe-area-inset-bottom,0px))]` → `pb-[calc(4rem+env(safe-area-inset-bottom,0px))] md:pb-0` | +| P8 | `empty-state.tsx:71-77` | TrendHint title default: add `text-muted-foreground` so the title doesn't fight chart titles after T2 | +| P9 | `getting-started-checklist.tsx:299` | Drop `font-mono` from "X von Y" / "%" — keep `tabular-nums` | +| P10 | `page.tsx:846-862` | Medications card: `rounded-lg border p-4` → `rounded-xl border p-4 md:p-6` (match other chart cards) | +| P11 | `src/lib/logging/redact.ts:22` | Add `sk-(ant-)?` regex to `redactSecrets` to scrub OpenAI/Anthropic keys centrally | +| P12 | `src/lib/idempotency.ts:227` | Extend the `hlk_` / `hlr_` exclusion to also reject bodies containing `"sk-"` or `"sk-ant-"` | +| P13 | `src/app/api/insights/generate/route.ts:18-22` | Move `checkRateLimit` BELOW the cache-return so cached hits don't burn rate-limit tokens | +| P14 | `src/lib/ai/codex-client.ts:28,34` | Copy structured-error pattern from `openai-client.ts:42-58` (httpStatus, bodyExcerpt, upstream fields) | +| P15 | `src/components/settings/ai-section.tsx:49` | Drop `gpt-5` from `MODEL_PRESETS["OPENAI"]` (not a released model). Drop `o3-mini` too unless you wire the o-series param contract (`max_completion_tokens` not `max_tokens`, no `temperature` override). | +| P16 | `src/components/settings/ai-section.tsx` (~29 strings) | i18n: pipe German strings through `t("settings.ai.…")` — add keys to `messages/en.json` + `messages/de.json` | +| P17 | `src/components/admin/feedback-inbox-section.tsx:209-222` | Replace `bg-red-500/15 text-red-400` with `bg-dracula-red/15 text-dracula-red` etc. for theme consistency | +| P18 | `src/components/admin/danger-zone-section.tsx:110` | Drop string-prefix-matches-success heuristic — track via `mutation.isSuccess` / `mutation.isError` | +| P19 | `src/components/admin/_shared.tsx:268-277,216-225` | `useSystemStatus()` and `useAdminSettings()`: surface `isError` with an inline "Failed to load settings" message instead of infinite spinner | +| P20 | `src/app/api/admin/status-overview/route.ts` | `Promise.all` → `Promise.allSettled` server-side so one failed probe doesn't blank the whole grid | --- @@ -351,7 +356,7 @@ After all fixes, the autonomous executor MUST: - Marc's session: `cmox4d6fj000101p8w9ykhcnm` (still valid as of 2026-05-09; if expired, re-pull from prod DB — `SELECT id FROM sessions WHERE user_id='cmlupy4tn000001rpzx1pxvz7' - AND expires_at > now() ORDER BY created_at DESC LIMIT 1;`) +AND expires_at > now() ORDER BY created_at DESC LIMIT 1;`) - Hit `/api/ai/test` with fake key → still returns 422 - Hit `/api/insights/generate` with `forceRefresh=false` and a fresh user → does not consume rate-limit token (P13 verify). @@ -385,7 +390,7 @@ create an orphan release. `ghcr.io/mbombeck/healthlog` packages page may have stale tags from the v1.4.0/v1.4.1 deploy thrash. Don't delete `v1.x.x` tagged -versions (some users may pin). Only delete explicitly *untagged* +versions (some users may pin). Only delete explicitly _untagged_ manifests if any exist. ### Docs site (`/Users/marc/Projects/healthlog-docs`) diff --git a/public/sw.js b/public/sw.js index 102cb651a..5d2dcbbb8 100644 --- a/public/sw.js +++ b/public/sw.js @@ -56,9 +56,7 @@ self.addEventListener("fetch", (event) => { } // Static files (fonts, icons, images) — cache-first with long TTL - if ( - /\.(png|svg|jpg|jpeg|gif|webp|ico|woff2?|ttf|eot)$/i.test(url.pathname) - ) { + if (/\.(png|svg|jpg|jpeg|gif|webp|ico|woff2?|ttf|eot)$/i.test(url.pathname)) { event.respondWith(cacheFirst(request, STATIC_CACHE)); return; } diff --git a/scripts/seed-demo.ts b/scripts/seed-demo.ts index ba96346f5..529e543a8 100644 --- a/scripts/seed-demo.ts +++ b/scripts/seed-demo.ts @@ -36,7 +36,12 @@ function cuid(): string { function daysAgo(n: number): Date { const d = new Date(); d.setDate(d.getDate() - n); - d.setHours(7 + Math.floor(Math.random() * 3), Math.floor(Math.random() * 60), 0, 0); + d.setHours( + 7 + Math.floor(Math.random() * 3), + Math.floor(Math.random() * 60), + 0, + 0, + ); return d; } @@ -45,7 +50,12 @@ function formatDate(d: Date): string { } // Smooth random walk with mean reversion -function randomWalk(start: number, target: number, days: number, volatility: number): number[] { +function randomWalk( + start: number, + target: number, + days: number, + volatility: number, +): number[] { const values: number[] = [start]; for (let i = 1; i < days; i++) { const prev = values[i - 1]; @@ -88,12 +98,24 @@ async function seed() { // argon2id hash of "demo123demo123" — pre-computed // We'll use a bcrypt-compatible approach: register via API later // For now, insert with a placeholder and we'll set it via the app - const passwordHash = "$argon2id$v=19$m=65536,t=3,p=4$Kips6OxPAl0vmspO9SoKZQ$oX9gLgwHVnnENCqBloyM13ewuqmhPnw8EpLoemS3MNI"; + const passwordHash = + "$argon2id$v=19$m=65536,t=3,p=4$Kips6OxPAl0vmspO9SoKZQ$oX9gLgwHVnnENCqBloyM13ewuqmhPnw8EpLoemS3MNI"; await client.query( `INSERT INTO users (id, username, email, password_hash, role, height_cm, date_of_birth, gender, timezone, locale, onboarding_completed_at, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW(), NOW())`, - [userId, "demo", "demo@healthlog.app", passwordHash, "ADMIN", 182.0, "1990-05-15", "MALE", "Europe/Berlin", "en"] + [ + userId, + "demo", + "demo@healthlog.app", + passwordHash, + "ADMIN", + 182.0, + "1990-05-15", + "MALE", + "Europe/Berlin", + "en", + ], ); // ── Measurements (90 days) ──────────────── @@ -122,26 +144,26 @@ async function seed() { await client.query( `INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at) VALUES ($1, $2, 'WEIGHT', $3, 'kg', 'MANUAL', $4, $4, $4)`, - [cuid(), userId, weights[i], date] + [cuid(), userId, weights[i], date], ); // Blood pressure (daily) await client.query( `INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at) VALUES ($1, $2, 'BLOOD_PRESSURE_SYS', $3, 'mmHg', 'MANUAL', $4, $4, $4)`, - [cuid(), userId, Math.round(sysBP[i]), date] + [cuid(), userId, Math.round(sysBP[i]), date], ); await client.query( `INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at) VALUES ($1, $2, 'BLOOD_PRESSURE_DIA', $3, 'mmHg', 'MANUAL', $4, $4, $4)`, - [cuid(), userId, Math.round(diaBP[i]), date] + [cuid(), userId, Math.round(diaBP[i]), date], ); // Pulse (daily) await client.query( `INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at) VALUES ($1, $2, 'PULSE', $3, 'bpm', 'MANUAL', $4, $4, $4)`, - [cuid(), userId, Math.round(pulse[i]), date] + [cuid(), userId, Math.round(pulse[i]), date], ); // Body fat (every 2-3 days) @@ -149,7 +171,7 @@ async function seed() { await client.query( `INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at) VALUES ($1, $2, 'BODY_FAT', $3, '%', 'MANUAL', $4, $4, $4)`, - [cuid(), userId, bodyFat[i], date] + [cuid(), userId, bodyFat[i], date], ); } @@ -157,14 +179,14 @@ async function seed() { await client.query( `INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at) VALUES ($1, $2, 'SLEEP_DURATION', $3, 'hours', 'MANUAL', $4, $4, $4)`, - [cuid(), userId, Math.round(sleep[i] * 10) / 10, date] + [cuid(), userId, Math.round(sleep[i] * 10) / 10, date], ); // Steps (daily) await client.query( `INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at) VALUES ($1, $2, 'ACTIVITY_STEPS', $3, 'steps', 'MANUAL', $4, $4, $4)`, - [cuid(), userId, Math.round(steps[i]), date] + [cuid(), userId, Math.round(steps[i]), date], ); } @@ -176,13 +198,13 @@ async function seed() { await client.query( `INSERT INTO medications (id, user_id, name, dose, active, notifications_enabled, created_at, updated_at) VALUES ($1, $2, 'Ramipril', '5mg', true, true, $3, $3)`, - [med1Id, userId, daysAgo(120)] + [med1Id, userId, daysAgo(120)], ); const sched1Id = cuid(); await client.query( `INSERT INTO medication_schedules (id, medication_id, window_start, window_end, label) VALUES ($1, $2, '08:00', '10:00', 'Morning')`, - [sched1Id, med1Id] + [sched1Id, med1Id], ); // Medication 2: Vitamin D3 @@ -190,13 +212,13 @@ async function seed() { await client.query( `INSERT INTO medications (id, user_id, name, dose, active, notifications_enabled, created_at, updated_at) VALUES ($1, $2, 'Vitamin D3', '2000 IE', true, true, $3, $3)`, - [med2Id, userId, daysAgo(90)] + [med2Id, userId, daysAgo(90)], ); const sched2Id = cuid(); await client.query( `INSERT INTO medication_schedules (id, medication_id, window_start, window_end, label) VALUES ($1, $2, '08:00', '10:00', 'Morning')`, - [sched2Id, med2Id] + [sched2Id, med2Id], ); // Medication 3: Magnesium (evening) @@ -204,13 +226,13 @@ async function seed() { await client.query( `INSERT INTO medications (id, user_id, name, dose, active, notifications_enabled, created_at, updated_at) VALUES ($1, $2, 'Magnesium', '400mg', true, true, $3, $3)`, - [med3Id, userId, daysAgo(60)] + [med3Id, userId, daysAgo(60)], ); const sched3Id = cuid(); await client.query( `INSERT INTO medication_schedules (id, medication_id, window_start, window_end, label) VALUES ($1, $2, '20:00', '22:00', 'Evening')`, - [sched3Id, med3Id] + [sched3Id, med3Id], ); // ── Medication Intake Events ────────────── @@ -227,13 +249,13 @@ async function seed() { await client.query( `INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at) VALUES ($1, $2, $3, $4, $5, false, 'WEB', $5)`, - [cuid(), userId, med1Id, date, takenTime] + [cuid(), userId, med1Id, date, takenTime], ); } else { await client.query( `INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at) VALUES ($1, $2, $3, $4, NULL, true, 'WEB', $4)`, - [cuid(), userId, med1Id, date] + [cuid(), userId, med1Id, date], ); } @@ -245,13 +267,13 @@ async function seed() { await client.query( `INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at) VALUES ($1, $2, $3, $4, $5, false, 'WEB', $5)`, - [cuid(), userId, med2Id, date, takenTime] + [cuid(), userId, med2Id, date, takenTime], ); } else { await client.query( `INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at) VALUES ($1, $2, $3, $4, NULL, true, 'WEB', $4)`, - [cuid(), userId, med2Id, date] + [cuid(), userId, med2Id, date], ); } @@ -264,13 +286,13 @@ async function seed() { await client.query( `INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at) VALUES ($1, $2, $3, $4, $5, false, 'WEB', $5)`, - [cuid(), userId, med3Id, date, takenTime] + [cuid(), userId, med3Id, date, takenTime], ); } else { await client.query( `INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at) VALUES ($1, $2, $3, $4, NULL, true, 'WEB', $4)`, - [cuid(), userId, med3Id, date] + [cuid(), userId, med3Id, date], ); } } @@ -305,7 +327,15 @@ async function seed() { await client.query( `INSERT INTO mood_entries (id, user_id, date, mood, score, tags, source, mood_logged_at, synced_at, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, 'WEB', $7, $7, $7, $7)`, - [cuid(), userId, formatDate(date), mood, rawScore, JSON.stringify(tags), loggedAt] + [ + cuid(), + userId, + formatDate(date), + mood, + rawScore, + JSON.stringify(tags), + loggedAt, + ], ); } @@ -332,7 +362,7 @@ async function seed() { await client.query( `INSERT INTO user_achievements (id, user_id, achievement_id, unlocked_at, created_at) VALUES ($1, $2, $3, $4, $4)`, - [cuid(), userId, ach.id, unlocked] + [cuid(), userId, ach.id, unlocked], ); } @@ -341,7 +371,7 @@ async function seed() { await client.query( `INSERT INTO app_settings (id, registration_enabled, default_locale) VALUES ('singleton', false, 'en') - ON CONFLICT (id) DO UPDATE SET registration_enabled = false, default_locale = 'en'` + ON CONFLICT (id) DO UPDATE SET registration_enabled = false, default_locale = 'en'`, ); // ── Audit Log (some login entries) ────── @@ -351,7 +381,7 @@ async function seed() { await client.query( `INSERT INTO audit_logs (id, user_id, action, details, ip_address, created_at) VALUES ($1, $2, 'auth.login', '{"method":"password"}', '203.0.113.42', $3)`, - [cuid(), userId, loginDate] + [cuid(), userId, loginDate], ); } diff --git a/src/app/api/admin/feedback/[id]/github/route.ts b/src/app/api/admin/feedback/[id]/github/route.ts index 016b98cc2..bf4f6111b 100644 --- a/src/app/api/admin/feedback/[id]/github/route.ts +++ b/src/app/api/admin/feedback/[id]/github/route.ts @@ -37,10 +37,7 @@ export const POST = apiHandler( if (!feedback) return apiError("Feedback not found", 404); if (feedback.gitHubIssueUrl) { - return apiError( - `Already published: ${feedback.gitHubIssueUrl}`, - 409, - ); + return apiError(`Already published: ${feedback.gitHubIssueUrl}`, 409); } // Atomic claim to prevent concurrent admin clicks from creating two diff --git a/src/app/api/admin/feedback/[id]/route.ts b/src/app/api/admin/feedback/[id]/route.ts index 4a3541b79..62e2a0ccd 100644 --- a/src/app/api/admin/feedback/[id]/route.ts +++ b/src/app/api/admin/feedback/[id]/route.ts @@ -3,7 +3,12 @@ * DELETE /api/admin/feedback/[id] — archive (soft: sets status to ARCHIVED). */ import { apiHandler, requireAdmin } from "@/lib/api-handler"; -import { apiError, apiSuccess, safeJson, getClientIp } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + safeJson, + getClientIp, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; diff --git a/src/app/api/admin/monitoring/glitchtip-test/route.ts b/src/app/api/admin/monitoring/glitchtip-test/route.ts index 3ad6de52f..d5998aabd 100644 --- a/src/app/api/admin/monitoring/glitchtip-test/route.ts +++ b/src/app/api/admin/monitoring/glitchtip-test/route.ts @@ -30,7 +30,14 @@ export const POST = apiHandler(async () => { }); if (!delivery.ok) { - getEvent()?.addWarning("Glitchtip test event rejected: " + delivery.method + " " + delivery.status + " " + delivery.details); + getEvent()?.addWarning( + "Glitchtip test event rejected: " + + delivery.method + + " " + + delivery.status + + " " + + delivery.details, + ); return apiError( `Glitchtip test event rejected (HTTP ${delivery.status ?? 502})`, 502, diff --git a/src/app/api/admin/monitoring/umami-test/route.ts b/src/app/api/admin/monitoring/umami-test/route.ts index 2ae9a78aa..eea22aa0e 100644 --- a/src/app/api/admin/monitoring/umami-test/route.ts +++ b/src/app/api/admin/monitoring/umami-test/route.ts @@ -54,10 +54,7 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("Umami is disabled", 422); } if (!settings.umamiScriptUrl || !settings.umamiWebsiteId) { - return apiError( - "Umami script URL and website ID must be configured", - 422, - ); + return apiError("Umami script URL and website ID must be configured", 422); } const targetUrls = resolveUmamiSendUrls(settings.umamiScriptUrl); @@ -116,8 +113,5 @@ export const POST = apiHandler(async (request: NextRequest) => { } getEvent()?.addWarning("Umami test event rejected: " + lastStatus); - return apiError( - `Umami test event rejected (HTTP ${lastStatus})`, - 502, - ); + return apiError(`Umami test event rejected (HTTP ${lastStatus})`, 502); }); diff --git a/src/app/api/admin/notifications/reminder-check/route.ts b/src/app/api/admin/notifications/reminder-check/route.ts index 4d54e3ca8..01d6eca4c 100644 --- a/src/app/api/admin/notifications/reminder-check/route.ts +++ b/src/app/api/admin/notifications/reminder-check/route.ts @@ -158,7 +158,9 @@ export const POST = apiHandler(async () => { notificationSent = true; notificationsSent++; } catch (err) { - getEvent()?.addWarning("Notification failed for " + med.name + ": " + err); + getEvent()?.addWarning( + "Notification failed for " + med.name + ": " + err, + ); } } diff --git a/src/app/api/admin/settings/route.ts b/src/app/api/admin/settings/route.ts index b4155f145..c55033309 100644 --- a/src/app/api/admin/settings/route.ts +++ b/src/app/api/admin/settings/route.ts @@ -1,7 +1,12 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAdmin } from "@/lib/api-handler"; import { auditLog } from "@/lib/auth/audit"; -import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response"; +import { + apiSuccess, + apiError, + getClientIp, + safeJson, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { encrypt } from "@/lib/crypto"; import { adminSettingsSchema } from "@/lib/validations/admin"; @@ -27,8 +32,8 @@ export const GET = apiHandler(async () => { webPushVapidSubject: settings?.webPushVapidSubject ?? null, webPushVapidConfigured: Boolean( settings?.webPushVapidPublicKey && - settings?.webPushVapidPrivateKeyEncrypted && - settings?.webPushVapidSubject, + settings?.webPushVapidPrivateKeyEncrypted && + settings?.webPushVapidSubject, ), apiGlobal: settings?.apiGlobal ?? true, umamiEnabled: settings?.umamiEnabled ?? false, @@ -207,8 +212,8 @@ export const PUT = apiHandler(async (request: NextRequest) => { webPushVapidSubject: settings.webPushVapidSubject, webPushVapidConfigured: Boolean( settings.webPushVapidPublicKey && - settings.webPushVapidPrivateKeyEncrypted && - settings.webPushVapidSubject, + settings.webPushVapidPrivateKeyEncrypted && + settings.webPushVapidSubject, ), apiGlobal: settings.apiGlobal, umamiEnabled: settings.umamiEnabled, @@ -216,8 +221,7 @@ export const PUT = apiHandler(async (request: NextRequest) => { umamiWebsiteId: settings.umamiWebsiteId, glitchtipEnabled: settings.glitchtipEnabled, glitchtipDsn: settings.glitchtipDsn, - glitchtipEnvironment: - settings.glitchtipEnvironment ?? "production", + glitchtipEnvironment: settings.glitchtipEnvironment ?? "production", bugReportRepo: settings.githubIssueRepo, bugReportConfigured: Boolean( settings.githubIssueRepo && settings.githubIssueTokenEncrypted, diff --git a/src/app/api/admin/status-overview/route.ts b/src/app/api/admin/status-overview/route.ts index 493f6f699..0b1d23255 100644 --- a/src/app/api/admin/status-overview/route.ts +++ b/src/app/api/admin/status-overview/route.ts @@ -107,7 +107,10 @@ export const GET = apiHandler(async () => { () => prisma.user.count({ where: { createdAt: { gte: sevenDaysAgo } } }), ], ["withingsCount", () => prisma.withingsConnection.count()], - ["moodLogCount", () => prisma.user.count({ where: { moodLogEnabled: true } })], + [ + "moodLogCount", + () => prisma.user.count({ where: { moodLogEnabled: true } }), + ], [ "telegramCount", () => prisma.user.count({ where: { telegramEnabled: true } }), @@ -207,20 +210,21 @@ export const GET = apiHandler(async () => { const telegramCount = (results.telegramCount as number | null) ?? 0; const ntfyCount = (results.ntfyCount as number | null) ?? 0; const webPushCount = (results.webPushCount as number | null) ?? 0; - const appSettings = results.appSettings as - | { glitchtipEnabled: boolean; umamiEnabled: boolean } - | null; + const appSettings = results.appSettings as { + glitchtipEnabled: boolean; + umamiEnabled: boolean; + } | null; const lastErrorEntry = results.lastErrorEntry as { createdAt: Date } | null; const latestBackup = results.latestBackup as { createdAt: Date } | null; const backedUpUsers = (results.backedUpUsers as number | null) ?? 0; const last30dEvents = (results.last30dEvents as number | null) ?? 0; const lastLoginEntry = results.lastLoginEntry as { createdAt: Date } | null; - const lastIdempotencyCleanup = results.lastIdempotencyCleanup as - | { createdAt: Date } - | null; - const lastAuditLogCleanup = results.lastAuditLogCleanup as - | { createdAt: Date } - | null; + const lastIdempotencyCleanup = results.lastIdempotencyCleanup as { + createdAt: Date; + } | null; + const lastAuditLogCleanup = results.lastAuditLogCleanup as { + createdAt: Date; + } | null; if (failed.length > 0) { annotate({ meta: { statusOverviewProbeFailures: failed } }); @@ -300,7 +304,8 @@ export const GET = apiHandler(async () => { workerUptimeSeconds, lastIdempotencyCleanup: lastIdempotencyCleanup?.createdAt?.toISOString() ?? null, - lastAuditLogCleanup: lastAuditLogCleanup?.createdAt?.toISOString() ?? null, + lastAuditLogCleanup: + lastAuditLogCleanup?.createdAt?.toISOString() ?? null, }, auditLog: { severity: anyFailed("last30dEvents", "lastLoginEntry") ? "alert" : "info", diff --git a/src/app/api/admin/users/[id]/reset-password/route.ts b/src/app/api/admin/users/[id]/reset-password/route.ts index a046ab2f4..9fa2e5ffd 100644 --- a/src/app/api/admin/users/[id]/reset-password/route.ts +++ b/src/app/api/admin/users/[id]/reset-password/route.ts @@ -2,69 +2,82 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAdmin } from "@/lib/api-handler"; import { hashPassword, checkPasswordStrength } from "@/lib/auth/password"; import { auditLog } from "@/lib/auth/audit"; -import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response"; +import { + apiSuccess, + apiError, + getClientIp, + safeJson, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { destroyAllSessions } from "@/lib/auth/session"; import { NextRequest, NextResponse } from "next/server"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; -export const POST = apiHandler(async ( - request: NextRequest, - { params }: { params: Promise<{ id: string }> }, -) => { - const ip = getClientIp(request); - const rl = await checkRateLimit(`admin-reset-pw:${ip}`, 10, 60 * 1000); - if (!rl.allowed) { - return NextResponse.json( - { data: null, error: "Rate limit exceeded" }, - { status: 429, headers: rateLimitHeaders(rl) }, - ); - } +export const POST = apiHandler( + async ( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, + ) => { + const ip = getClientIp(request); + const rl = await checkRateLimit(`admin-reset-pw:${ip}`, 10, 60 * 1000); + if (!rl.allowed) { + return NextResponse.json( + { data: null, error: "Rate limit exceeded" }, + { status: 429, headers: rateLimitHeaders(rl) }, + ); + } - const { user } = await requireAdmin(); + const { user } = await requireAdmin(); - const { id } = await params; - annotate({ action: { name: "admin.users.reset-password", entity_type: "user", entity_id: id } }); + const { id } = await params; + annotate({ + action: { + name: "admin.users.reset-password", + entity_type: "user", + entity_id: id, + }, + }); - const { data: body, error: jsonError } = await safeJson(request); + const { data: body, error: jsonError } = await safeJson(request); - if (jsonError) return jsonError; - const { password } = body as { password?: string }; + if (jsonError) return jsonError; + const { password } = body as { password?: string }; - if (!password || typeof password !== "string") { - return apiError("Password required", 422); - } + if (!password || typeof password !== "string") { + return apiError("Password required", 422); + } - const target = await prisma.user.findUnique({ where: { id } }); - if (!target) return apiError("User not found", 404); + const target = await prisma.user.findUnique({ where: { id } }); + if (!target) return apiError("User not found", 404); - const locale = await resolveServerLocale({ - request, - userLocale: target.locale ?? null, - }); - const strength = checkPasswordStrength(password, [target.username], locale); - if (!strength.isAcceptable) { - return apiError( - strength.feedback[0] || "Password too weak (score < 3)", - 422, - ); - } + const locale = await resolveServerLocale({ + request, + userLocale: target.locale ?? null, + }); + const strength = checkPasswordStrength(password, [target.username], locale); + if (!strength.isAcceptable) { + return apiError( + strength.feedback[0] || "Password too weak (score < 3)", + 422, + ); + } - const passwordHash = await hashPassword(password); - await prisma.user.update({ - where: { id }, - data: { passwordHash }, - }); + const passwordHash = await hashPassword(password); + await prisma.user.update({ + where: { id }, + data: { passwordHash }, + }); - // Invalidate all sessions of the target user - await destroyAllSessions(id); + // Invalidate all sessions of the target user + await destroyAllSessions(id); - await auditLog("admin.user.reset-password", { - userId: user.id, - ipAddress: getClientIp(request), - details: { targetUserId: id }, - }); + await auditLog("admin.user.reset-password", { + userId: user.id, + ipAddress: getClientIp(request), + details: { targetUserId: id }, + }); - return apiSuccess({ success: true }); -}); + return apiSuccess({ success: true }); + }, +); diff --git a/src/app/api/admin/users/[id]/route.ts b/src/app/api/admin/users/[id]/route.ts index 2529da085..c4c4d1aba 100644 --- a/src/app/api/admin/users/[id]/route.ts +++ b/src/app/api/admin/users/[id]/route.ts @@ -1,7 +1,12 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAdmin } from "@/lib/api-handler"; import { auditLog } from "@/lib/auth/audit"; -import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response"; +import { + apiSuccess, + apiError, + getClientIp, + safeJson, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { NextRequest } from "next/server"; import { z } from "zod/v4"; @@ -12,57 +17,65 @@ const updateUserSchema = z.object({ role: z.enum(["ADMIN", "USER"]).optional(), }); -export const PUT = apiHandler(async ( - request: NextRequest, - { params }: { params: Promise<{ id: string }> }, -) => { - const { user } = await requireAdmin(); +export const PUT = apiHandler( + async ( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, + ) => { + const { user } = await requireAdmin(); - const { id } = await params; - annotate({ action: { name: "admin.users.update", entity_type: "user", entity_id: id } }); + const { id } = await params; + annotate({ + action: { + name: "admin.users.update", + entity_type: "user", + entity_id: id, + }, + }); - const { data: body, error: jsonError } = await safeJson(request); + const { data: body, error: jsonError } = await safeJson(request); - if (jsonError) return jsonError; - const parsed = updateUserSchema.safeParse(body); - if (!parsed.success) { - return apiError(parsed.error.issues[0].message, 422); - } - - const target = await prisma.user.findUnique({ where: { id } }); - if (!target) return apiError("User not found", 404); - - // Prevent removing the last admin - if (parsed.data.role === "USER" && target.role === "ADMIN") { - const adminCount = await prisma.user.count({ where: { role: "ADMIN" } }); - if (adminCount <= 1) { - return apiError("The last admin cannot be demoted", 400); + if (jsonError) return jsonError; + const parsed = updateUserSchema.safeParse(body); + if (!parsed.success) { + return apiError(parsed.error.issues[0].message, 422); } - } - const updatedUser = await prisma.user.update({ - where: { id }, - data: { - ...(parsed.data.username !== undefined && { - username: parsed.data.username, - }), - ...(parsed.data.email !== undefined && { email: parsed.data.email }), - ...(parsed.data.role !== undefined && { role: parsed.data.role }), - }, - select: { - id: true, - username: true, - email: true, - role: true, - createdAt: true, - }, - }); + const target = await prisma.user.findUnique({ where: { id } }); + if (!target) return apiError("User not found", 404); - await auditLog("admin.user.update", { - userId: user.id, - ipAddress: getClientIp(request), - details: { targetUserId: id, changes: parsed.data }, - }); + // Prevent removing the last admin + if (parsed.data.role === "USER" && target.role === "ADMIN") { + const adminCount = await prisma.user.count({ where: { role: "ADMIN" } }); + if (adminCount <= 1) { + return apiError("The last admin cannot be demoted", 400); + } + } - return apiSuccess(updatedUser); -}); + const updatedUser = await prisma.user.update({ + where: { id }, + data: { + ...(parsed.data.username !== undefined && { + username: parsed.data.username, + }), + ...(parsed.data.email !== undefined && { email: parsed.data.email }), + ...(parsed.data.role !== undefined && { role: parsed.data.role }), + }, + select: { + id: true, + username: true, + email: true, + role: true, + createdAt: true, + }, + }); + + await auditLog("admin.user.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: { targetUserId: id, changes: parsed.data }, + }); + + return apiSuccess(updatedUser); + }, +); diff --git a/src/app/api/auth/codex/callback/route.ts b/src/app/api/auth/codex/callback/route.ts index 2bd6301f4..72f062cfd 100644 --- a/src/app/api/auth/codex/callback/route.ts +++ b/src/app/api/auth/codex/callback/route.ts @@ -14,7 +14,9 @@ export const GET = apiHandler(async (request: NextRequest) => { const rl = await checkRateLimit(`codex-callback:${user.id}`, 10, 60_000); if (!rl.allowed) { const appUrl = process.env.APP_URL || process.env.NEXT_PUBLIC_APP_URL; - return NextResponse.redirect(`${appUrl}/settings/integrations?codex_error=rate_limited`); + return NextResponse.redirect( + `${appUrl}/settings/integrations?codex_error=rate_limited`, + ); } const url = new URL(request.url); diff --git a/src/app/api/auth/passkey/register-verify/route.ts b/src/app/api/auth/passkey/register-verify/route.ts index 7f4dbda7a..14c405f2c 100644 --- a/src/app/api/auth/passkey/register-verify/route.ts +++ b/src/app/api/auth/passkey/register-verify/route.ts @@ -1,7 +1,12 @@ import { prisma } from "@/lib/db"; import { verifyRegistration } from "@/lib/auth/passkey"; import { auditLog } from "@/lib/auth/audit"; -import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response"; +import { + apiSuccess, + apiError, + getClientIp, + safeJson, +} from "@/lib/api-response"; import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; @@ -9,7 +14,8 @@ import { annotate } from "@/lib/logging/context"; export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); - const { data: body, error: jsonError } = await safeJson>(request); + const { data: body, error: jsonError } = + await safeJson>(request); if (jsonError) return jsonError; const challengeId = body.challengeId as string | undefined; @@ -35,7 +41,9 @@ export const POST = apiHandler(async (request: NextRequest) => { counter: BigInt(registrationInfo.credential.counter), credentialDeviceType: registrationInfo.credentialDeviceType, credentialBackedUp: registrationInfo.credentialBackedUp, - transports: ((credential.response as Record | undefined)?.transports as string[]) ?? [], + transports: + ((credential.response as Record | undefined) + ?.transports as string[]) ?? [], }, }); diff --git a/src/app/api/auth/passkeys/[id]/route.ts b/src/app/api/auth/passkeys/[id]/route.ts index a44174494..5b0417864 100644 --- a/src/app/api/auth/passkeys/[id]/route.ts +++ b/src/app/api/auth/passkeys/[id]/route.ts @@ -5,44 +5,46 @@ import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -export const DELETE = apiHandler(async ( - request: NextRequest, - { params }: { params: Promise<{ id: string }> }, -) => { - const { user } = await requireAuth(); +export const DELETE = apiHandler( + async ( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, + ) => { + const { user } = await requireAuth(); - const { id } = await params; + const { id } = await params; - const passkey = await prisma.passkey.findUnique({ - where: { id }, - }); + const passkey = await prisma.passkey.findUnique({ + where: { id }, + }); - if (!passkey || passkey.userId !== user.id) { - return apiError("Passkey not found", 404); - } + if (!passkey || passkey.userId !== user.id) { + return apiError("Passkey not found", 404); + } - // Check: at least 1 auth method must remain - const passkeyCount = await prisma.passkey.count({ - where: { userId: user.id }, - }); - const hasPassword = !!user.passwordHash; + // Check: at least 1 auth method must remain + const passkeyCount = await prisma.passkey.count({ + where: { userId: user.id }, + }); + const hasPassword = !!user.passwordHash; - if (passkeyCount <= 1 && !hasPassword) { - return apiError( - "Cannot delete — at least one authentication method must remain", - 400, - ); - } + if (passkeyCount <= 1 && !hasPassword) { + return apiError( + "Cannot delete — at least one authentication method must remain", + 400, + ); + } - await prisma.passkey.delete({ where: { id } }); + await prisma.passkey.delete({ where: { id } }); - await auditLog("auth.passkey.delete", { - userId: user.id, - ipAddress: getClientIp(request), - details: { passkeyId: id, passkeyName: passkey.name }, - }); + await auditLog("auth.passkey.delete", { + userId: user.id, + ipAddress: getClientIp(request), + details: { passkeyId: id, passkeyName: passkey.name }, + }); - annotate({ action: { name: "auth.passkey.delete" } }); + annotate({ action: { name: "auth.passkey.delete" } }); - return apiSuccess({ success: true }); -}); + return apiSuccess({ success: true }); + }, +); diff --git a/src/app/api/auth/password/route.ts b/src/app/api/auth/password/route.ts index 04016c70f..0267d24b2 100644 --- a/src/app/api/auth/password/route.ts +++ b/src/app/api/auth/password/route.ts @@ -7,7 +7,12 @@ import { } from "@/lib/auth/password"; import { changePasswordSchema } from "@/lib/validations/auth"; import { auditLog } from "@/lib/auth/audit"; -import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response"; +import { + apiSuccess, + apiError, + getClientIp, + safeJson, +} from "@/lib/api-response"; import { checkRateLimit } from "@/lib/rate-limit"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; @@ -23,10 +28,7 @@ export const POST = apiHandler(async (request: NextRequest) => { 15 * 60 * 1000, ); if (!rl.allowed) { - return apiError( - "Too many attempts. Please wait 15 minutes.", - 429, - ); + return apiError("Too many attempts. Please wait 15 minutes.", 429); } const { data: body, error: jsonError } = await safeJson(request); @@ -42,19 +44,13 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("No password set for this account", 400); } - const currentValid = await verifyPassword( - user.passwordHash, - currentPassword, - ); + const currentValid = await verifyPassword(user.passwordHash, currentPassword); if (!currentValid) { return apiError("Current password is incorrect", 401); } if (currentPassword === newPassword) { - return apiError( - "New password must differ from current password", - 422, - ); + return apiError("New password must differ from current password", 422); } const locale = await resolveServerLocale({ diff --git a/src/app/api/auth/profile/route.ts b/src/app/api/auth/profile/route.ts index 62c806960..164291eaa 100644 --- a/src/app/api/auth/profile/route.ts +++ b/src/app/api/auth/profile/route.ts @@ -1,4 +1,9 @@ -import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response"; +import { + apiSuccess, + apiError, + getClientIp, + safeJson, +} from "@/lib/api-response"; import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index b340863b6..d41413195 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -3,7 +3,12 @@ import { registerSchema } from "@/lib/validations/auth"; import { hashPassword, checkPasswordStrength } from "@/lib/auth/password"; import { createSession } from "@/lib/auth/session"; import { auditLog } from "@/lib/auth/audit"; -import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response"; +import { + apiSuccess, + apiError, + getClientIp, + safeJson, +} from "@/lib/api-response"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { ensureDbCompatibility } from "@/lib/db-compat"; import { NextRequest, NextResponse } from "next/server"; @@ -18,8 +23,7 @@ export const POST = apiHandler(async (request: NextRequest) => { return NextResponse.json( { data: null, - error: - "Too many registration attempts. Please try again later.", + error: "Too many registration attempts. Please try again later.", }, { status: 429, headers: rateLimitHeaders(rl) }, ); diff --git a/src/app/api/bugreport/route.ts b/src/app/api/bugreport/route.ts index 5d1e4a3d9..44b48bb3c 100644 --- a/src/app/api/bugreport/route.ts +++ b/src/app/api/bugreport/route.ts @@ -1,6 +1,11 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate, getEvent } from "@/lib/logging/context"; -import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response"; +import { + apiSuccess, + apiError, + getClientIp, + safeJson, +} from "@/lib/api-response"; import { checkRateLimit } from "@/lib/rate-limit"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; @@ -17,7 +22,10 @@ const bugReportSchema = z.object({ screenshot: z .string() .max(7_000_000, "Screenshot too large (max 5 MB)") - .regex(SCREENSHOT_DATA_URL, "Screenshot must be a base64-encoded PNG/JPEG/WEBP/GIF data URL") + .regex( + SCREENSHOT_DATA_URL, + "Screenshot must be a base64-encoded PNG/JPEG/WEBP/GIF data URL", + ) .optional(), }); @@ -92,10 +100,7 @@ export const POST = apiHandler(async (request: NextRequest) => { const safeUsername = user.username.replace(/[`*_~<>\[\]|]/g, ""); const safeDescription = description .replace(/<[^>]*>/g, "") // strip HTML tags - .replace( - /\]\(\s*(javascript|data|vbscript|file):/gi, - "](about:blank#", - ) // neutralise dangerous markdown link schemes + .replace(/\]\(\s*(javascript|data|vbscript|file):/gi, "](about:blank#") // neutralise dangerous markdown link schemes .replace(/```/g, "\\`\\`\\`") .slice(0, 5000); diff --git a/src/app/api/bugreport/status/route.ts b/src/app/api/bugreport/status/route.ts index 1f5d56fac..a1f5aa88c 100644 --- a/src/app/api/bugreport/status/route.ts +++ b/src/app/api/bugreport/status/route.ts @@ -18,11 +18,7 @@ export const GET = apiHandler(async () => { // Light rate limit — the endpoint is cheap, but no reason to let a logged-in // client hammer Postgres in a loop. - const rl = await checkRateLimit( - `bugreport-status:${user.id}`, - 30, - 60 * 1000, - ); + const rl = await checkRateLimit(`bugreport-status:${user.id}`, 30, 60 * 1000); if (!rl.allowed) { return apiError("Rate limit exceeded", 429); } diff --git a/src/app/api/dashboard/summary/__tests__/route.test.ts b/src/app/api/dashboard/summary/__tests__/route.test.ts index 2a129e314..d981399a0 100644 --- a/src/app/api/dashboard/summary/__tests__/route.test.ts +++ b/src/app/api/dashboard/summary/__tests__/route.test.ts @@ -22,7 +22,11 @@ vi.mock("@/lib/db-compat", () => ({ vi.mock("next/headers", () => ({ headers: vi.fn(async () => ({ get: () => null })), - cookies: vi.fn(async () => ({ get: () => undefined, set: () => {}, delete: () => {} })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), })); import { GET } from "../route"; @@ -50,7 +54,9 @@ function makeReq(): NextRequest { beforeEach(() => { vi.resetAllMocks(); vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); }); describe("GET /api/dashboard/summary", () => { diff --git a/src/app/api/devices/__tests__/route.test.ts b/src/app/api/devices/__tests__/route.test.ts index 06156b3e6..8f7f45485 100644 --- a/src/app/api/devices/__tests__/route.test.ts +++ b/src/app/api/devices/__tests__/route.test.ts @@ -25,7 +25,11 @@ vi.mock("@/lib/db-compat", () => ({ vi.mock("next/headers", () => ({ headers: vi.fn(async () => ({ get: () => null })), - cookies: vi.fn(async () => ({ get: () => undefined, set: () => {}, delete: () => {} })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), })); import { POST } from "../route"; diff --git a/src/app/api/devices/route.ts b/src/app/api/devices/route.ts index b5e5e1558..23dac745f 100644 --- a/src/app/api/devices/route.ts +++ b/src/app/api/devices/route.ts @@ -52,7 +52,10 @@ export const POST = apiHandler(async (request: NextRequest) => { userId: user.id, details: { reason: "token_owned_by_other_user", deviceId: existing.id }, }); - return apiError("Device token already registered to another account", 409); + return apiError( + "Device token already registered to another account", + 409, + ); } const updated = await prisma.device.update({ where: { token }, diff --git a/src/app/api/export/route.ts b/src/app/api/export/route.ts index b9afccf37..f78367fee 100644 --- a/src/app/api/export/route.ts +++ b/src/app/api/export/route.ts @@ -38,8 +38,13 @@ export const GET = apiHandler(async (request: NextRequest) => { if (!["csv", "json"].includes(format)) { return apiError("Format must be csv or json", 422); } - if (!["measurements", "medications", "intake", "mood", "all"].includes(type)) { - return apiError("Type must be measurements, medications, intake, mood, or all", 422); + if ( + !["measurements", "medications", "intake", "mood", "all"].includes(type) + ) { + return apiError( + "Type must be measurements, medications, intake, mood, or all", + 422, + ); } const userId = user.id; diff --git a/src/app/api/feedback/route.ts b/src/app/api/feedback/route.ts index c3923b684..eeee1591e 100644 --- a/src/app/api/feedback/route.ts +++ b/src/app/api/feedback/route.ts @@ -6,7 +6,12 @@ * the database (≤5 MB each). */ import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiError, apiSuccess, safeJson, getClientIp } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + safeJson, + getClientIp, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { checkRateLimit } from "@/lib/rate-limit"; import { auditLog } from "@/lib/auth/audit"; diff --git a/src/app/api/gamification/achievements/__tests__/ios-format.test.ts b/src/app/api/gamification/achievements/__tests__/ios-format.test.ts index 380be4080..9fff64150 100644 --- a/src/app/api/gamification/achievements/__tests__/ios-format.test.ts +++ b/src/app/api/gamification/achievements/__tests__/ios-format.test.ts @@ -25,7 +25,11 @@ vi.mock("@/lib/db-compat", () => ({ vi.mock("next/headers", () => ({ headers: vi.fn(async () => ({ get: () => null })), - cookies: vi.fn(async () => ({ get: () => undefined, set: () => {}, delete: () => {} })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), })); import { GET } from "../route"; @@ -47,7 +51,9 @@ const SESSION_OK = { beforeEach(() => { vi.resetAllMocks(); vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.medicationIntakeEvent.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); diff --git a/src/app/api/gamification/achievements/route.ts b/src/app/api/gamification/achievements/route.ts index c8f10099b..730dba630 100644 --- a/src/app/api/gamification/achievements/route.ts +++ b/src/app/api/gamification/achievements/route.ts @@ -456,10 +456,7 @@ export const GET = apiHandler(async (request: NextRequest) => { const now = new Date(); const userId = user.id; - const startDate = maxDate( - user.createdAt, - GAMIFICATION_ROLLOUT_AT, - ); + const startDate = maxDate(user.createdAt, GAMIFICATION_ROLLOUT_AT); const [measurements, intakeEvents, medications, passkeys, auditEvents] = await Promise.all([ @@ -469,12 +466,7 @@ export const GET = apiHandler(async (request: NextRequest) => { measuredAt: { gte: startDate, lte: now }, source: { not: "IMPORT" }, type: { - in: [ - "WEIGHT", - "BLOOD_PRESSURE_SYS", - "BLOOD_PRESSURE_DIA", - "PULSE", - ], + in: ["WEIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "PULSE"], }, }, select: { @@ -741,14 +733,14 @@ export const GET = apiHandler(async (request: NextRequest) => { mergedDates[id] = date; } - const result = evaluateAchievementsWithCompletionDates( - metrics, - mergedDates, - ); + const result = evaluateAchievementsWithCompletionDates(metrics, mergedDates); // Persist newly unlocked achievements const newUnlocks = result.achievements.filter( - (a) => a.unlocked && a.completedAt && !persisted.some((p) => p.achievementId === a.id), + (a) => + a.unlocked && + a.completedAt && + !persisted.some((p) => p.achievementId === a.id), ); if (newUnlocks.length > 0) { await prisma.userAchievement.createMany({ diff --git a/src/app/api/import/__tests__/route.test.ts b/src/app/api/import/__tests__/route.test.ts index 90e45860f..f35e2882e 100644 --- a/src/app/api/import/__tests__/route.test.ts +++ b/src/app/api/import/__tests__/route.test.ts @@ -2,7 +2,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; vi.mock("@/lib/api-handler", () => ({ apiHandler: unknown>(fn: T) => fn, - requireAuth: vi.fn(async () => ({ user: { id: "u-1" }, session: { id: "s-1" } })), + requireAuth: vi.fn(async () => ({ + user: { id: "u-1" }, + session: { id: "s-1" }, + })), })); vi.mock("@/lib/db", () => ({ diff --git a/src/app/api/import/route.ts b/src/app/api/import/route.ts index cc9acf25d..c183a4584 100644 --- a/src/app/api/import/route.ts +++ b/src/app/api/import/route.ts @@ -2,7 +2,12 @@ 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 { z } from "zod/v4"; diff --git a/src/app/api/ingest/medication/route.ts b/src/app/api/ingest/medication/route.ts index 91f0be3c4..b3c61e3c7 100644 --- a/src/app/api/ingest/medication/route.ts +++ b/src/app/api/ingest/medication/route.ts @@ -2,7 +2,12 @@ import { prisma } from "@/lib/db"; import { apiHandler } from "@/lib/api-handler"; import { annotate, getEvent } 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 { hashToken } from "@/lib/auth/hmac"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { externalIntakeSchema } from "@/lib/validations/medication"; diff --git a/src/app/api/insights/blood-pressure-status/route.ts b/src/app/api/insights/blood-pressure-status/route.ts index 014c1a639..5f1c6da18 100644 --- a/src/app/api/insights/blood-pressure-status/route.ts +++ b/src/app/api/insights/blood-pressure-status/route.ts @@ -21,13 +21,10 @@ export const GET = apiHandler(async (request: NextRequest) => { }); const locale = resolveBloodPressureStatusLocale(resolved); - const result = await generateBloodPressureStatusForUser( - user.id, - { - locale, - force: false, - }, - ); + const result = await generateBloodPressureStatusForUser(user.id, { + locale, + force: false, + }); annotate({ action: { name: "insights.blood-pressure-status" } }); diff --git a/src/app/api/insights/cards/__tests__/route.test.ts b/src/app/api/insights/cards/__tests__/route.test.ts index 72d0e5a1f..0f1a471f7 100644 --- a/src/app/api/insights/cards/__tests__/route.test.ts +++ b/src/app/api/insights/cards/__tests__/route.test.ts @@ -24,7 +24,11 @@ vi.mock("@/lib/db-compat", () => ({ vi.mock("next/headers", () => ({ headers: vi.fn(async () => ({ get: () => null })), - cookies: vi.fn(async () => ({ get: () => undefined, set: () => {}, delete: () => {} })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), })); import { GET } from "../route"; @@ -40,7 +44,9 @@ 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.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); vi.mocked(prisma.user.findUnique).mockResolvedValue({ heightCm: null, dateOfBirth: null, @@ -80,10 +86,20 @@ describe("GET /api/insights/cards", () => { }> = []; 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 }); + 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.measurement.findMany).mockResolvedValue( + measurements as never, + ); vi.mocked(prisma.user.findUnique).mockResolvedValue({ heightCm: null, dateOfBirth: null, diff --git a/src/app/api/insights/cards/route.ts b/src/app/api/insights/cards/route.ts index bb2d2a705..e9ab527ce 100644 --- a/src/app/api/insights/cards/route.ts +++ b/src/app/api/insights/cards/route.ts @@ -68,12 +68,7 @@ export const GET = apiHandler(async () => { where: { userId: user.id, type: { - in: [ - "WEIGHT", - "BLOOD_PRESSURE_SYS", - "BLOOD_PRESSURE_DIA", - "PULSE", - ], + in: ["WEIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "PULSE"], }, measuredAt: { gte: ninetyDaysAgo }, }, @@ -91,7 +86,9 @@ export const GET = apiHandler(async () => { .filter((m) => m.type === t) .map((m) => ({ date: m.measuredAt, value: m.value })); - const summaries: Partial>> = {}; + const summaries: Partial< + Record> + > = {}; for (const t of [ "WEIGHT", "BLOOD_PRESSURE_SYS", @@ -105,8 +102,7 @@ export const GET = apiHandler(async () => { 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; + bmi = Math.round((summaries.WEIGHT.latest / (heightM * heightM)) * 10) / 10; } let bpPctInTarget: number | null = null; @@ -142,7 +138,12 @@ export const GET = apiHandler(async () => { 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 }, + select: { + medicationId: true, + takenAt: true, + skipped: true, + scheduledFor: true, + }, }); const eventsByMed = new Map(); for (const e of allEvents) { @@ -154,15 +155,22 @@ export const GET = apiHandler(async () => { 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, + (e) => + e.takenAt !== null && + !e.skipped && + e.scheduledFor.getTime() >= cutoff7, + ).length; + const taken30 = events.filter( + (e) => e.takenAt !== null && !e.skipped, ).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, + compliance7: + expected7 > 0 ? Math.round((takenLast7 / expected7) * 100) : 0, + compliance30: + expected30 > 0 ? Math.round((taken30 / expected30) * 100) : 0, }); } } diff --git a/src/app/api/insights/comprehensive/route.ts b/src/app/api/insights/comprehensive/route.ts index ade034c8e..9fb11e737 100644 --- a/src/app/api/insights/comprehensive/route.ts +++ b/src/app/api/insights/comprehensive/route.ts @@ -74,7 +74,8 @@ export const GET = apiHandler(async () => { date: e.moodLoggedAt, value: e.score, })); - const moodSummary = moodDataPoints.length > 0 ? summarize(moodDataPoints) : null; + const moodSummary = + moodDataPoints.length > 0 ? summarize(moodDataPoints) : null; const byType = (t: MeasurementType): DataPoint[] => allMeasurements @@ -173,7 +174,9 @@ export const GET = apiHandler(async () => { // Mood vs Systolic BP const moodBpPairs = buildMoodMetricPairs( dailyMoodEntries, - allMeasurements.filter((m) => m.type === "BLOOD_PRESSURE_SYS").map((m) => ({ measuredAt: m.measuredAt, value: m.value })), + allMeasurements + .filter((m) => m.type === "BLOOD_PRESSURE_SYS") + .map((m) => ({ measuredAt: m.measuredAt, value: m.value })), ); const moodBpCorrelation = pearsonCorrelation(moodBpPairs); const moodBpScatterData = moodBpPairs.map((p) => ({ mood: p.a, sysBP: p.b })); @@ -181,18 +184,28 @@ export const GET = apiHandler(async () => { // Mood vs Weight const moodWeightPairs = buildMoodMetricPairs( dailyMoodEntries, - allMeasurements.filter((m) => m.type === "WEIGHT").map((m) => ({ measuredAt: m.measuredAt, value: m.value })), + allMeasurements + .filter((m) => m.type === "WEIGHT") + .map((m) => ({ measuredAt: m.measuredAt, value: m.value })), ); const moodWeightCorrelation = pearsonCorrelation(moodWeightPairs); - const moodWeightScatterData = moodWeightPairs.map((p) => ({ mood: p.a, weight: p.b })); + const moodWeightScatterData = moodWeightPairs.map((p) => ({ + mood: p.a, + weight: p.b, + })); // Mood vs Pulse const moodPulsePairs = buildMoodMetricPairs( dailyMoodEntries, - allMeasurements.filter((m) => m.type === "PULSE").map((m) => ({ measuredAt: m.measuredAt, value: m.value })), + allMeasurements + .filter((m) => m.type === "PULSE") + .map((m) => ({ measuredAt: m.measuredAt, value: m.value })), ); const moodPulseCorrelation = pearsonCorrelation(moodPulsePairs); - const moodPulseScatterData = moodPulsePairs.map((p) => ({ mood: p.a, pulse: p.b })); + const moodPulseScatterData = moodPulsePairs.map((p) => ({ + mood: p.a, + pulse: p.b, + })); // Medication compliance const medications = await prisma.medication.findMany({ diff --git a/src/app/api/insights/correlations/__tests__/route.test.ts b/src/app/api/insights/correlations/__tests__/route.test.ts index 8599f907e..d6ffd8c7f 100644 --- a/src/app/api/insights/correlations/__tests__/route.test.ts +++ b/src/app/api/insights/correlations/__tests__/route.test.ts @@ -19,7 +19,11 @@ vi.mock("@/lib/db-compat", () => ({ vi.mock("next/headers", () => ({ headers: vi.fn(async () => ({ get: () => null })), - cookies: vi.fn(async () => ({ get: () => undefined, set: () => {}, delete: () => {} })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), })); import { GET } from "../route"; diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index e09dd799f..107bb0156 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -2,7 +2,10 @@ import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; import { extractFeatures } from "@/lib/insights/features"; -import { getInsightsSystemPrompt, buildUserPrompt } 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"; @@ -41,8 +44,15 @@ export const POST = apiHandler(async (request: NextRequest) => { ) { try { const cached = JSON.parse(dbUser.insightsCachedText); - annotate({ action: { name: "insights.generate" }, meta: { cached: true } }); - return apiSuccess({ insights: cached, cached: true, cachedAt: dbUser.insightsCachedAt }); + annotate({ + action: { name: "insights.generate" }, + meta: { cached: true }, + }); + return apiSuccess({ + insights: cached, + cached: true, + cachedAt: dbUser.insightsCachedAt, + }); } catch { // Invalid cache, regenerate } @@ -60,7 +70,10 @@ export const POST = apiHandler(async (request: NextRequest) => { 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); + 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"; diff --git a/src/app/api/insights/medication-compliance-status/route.ts b/src/app/api/insights/medication-compliance-status/route.ts index 022f488d7..7d20e1f94 100644 --- a/src/app/api/insights/medication-compliance-status/route.ts +++ b/src/app/api/insights/medication-compliance-status/route.ts @@ -21,13 +21,10 @@ export const GET = apiHandler(async (request: NextRequest) => { }); const locale = resolveMedicationComplianceStatusLocale(resolved); - const result = await generateMedicationComplianceStatusForUser( - user.id, - { - locale, - force: false, - }, - ); + const result = await generateMedicationComplianceStatusForUser(user.id, { + locale, + force: false, + }); annotate({ action: { name: "insights.medication-compliance-status" } }); diff --git a/src/app/api/insights/settings/route.ts b/src/app/api/insights/settings/route.ts index 18383e515..7f7a5c7f5 100644 --- a/src/app/api/insights/settings/route.ts +++ b/src/app/api/insights/settings/route.ts @@ -42,7 +42,8 @@ export const GET = apiHandler(async () => { export const PUT = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); - const { data: body, error: jsonError } = await safeJson>(request); + const { data: body, error: jsonError } = + await safeJson>(request); if (jsonError) return jsonError; const data: Record = {}; diff --git a/src/app/api/integrations/healthkit/__tests__/route.test.ts b/src/app/api/integrations/healthkit/__tests__/route.test.ts index bffbb1161..41d4c980a 100644 --- a/src/app/api/integrations/healthkit/__tests__/route.test.ts +++ b/src/app/api/integrations/healthkit/__tests__/route.test.ts @@ -21,7 +21,11 @@ vi.mock("@/lib/db-compat", () => ({ vi.mock("next/headers", () => ({ headers: vi.fn(async () => ({ get: () => null })), - cookies: vi.fn(async () => ({ get: () => undefined, set: () => {}, delete: () => {} })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), })); import { GET, PATCH } from "../route"; @@ -102,7 +106,8 @@ describe("PATCH /api/integrations/healthkit", () => { ); 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 { + 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); diff --git a/src/app/api/integrations/healthkit/route.ts b/src/app/api/integrations/healthkit/route.ts index e0c769719..d9cb34a4a 100644 --- a/src/app/api/integrations/healthkit/route.ts +++ b/src/app/api/integrations/healthkit/route.ts @@ -43,9 +43,24 @@ export interface HealthKitEntry { } 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: "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 }, @@ -135,9 +150,7 @@ export const PATCH = apiHandler(async (request: NextRequest) => { const stored = parseStored(row?.healthKitConfigJson ?? null); // Resolve known ids set: default ids + already-stored ids. - const knownIds = new Set( - DEFAULT_HEALTHKIT_ENTRIES.map((e) => e.id), - ); + const knownIds = new Set(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. diff --git a/src/app/api/measurements/[id]/route.ts b/src/app/api/measurements/[id]/route.ts index 4474ae34d..8459f4f30 100644 --- a/src/app/api/measurements/[id]/route.ts +++ b/src/app/api/measurements/[id]/route.ts @@ -2,112 +2,123 @@ 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 { updateMeasurementSchema } from "@/lib/validations/measurement"; import { NextRequest } from "next/server"; type RouteParams = { params: Promise<{ id: string }> }; -export const GET = apiHandler(async (_request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); +export const GET = apiHandler( + async (_request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); - const { id } = await params; + const { id } = await params; - const measurement = await prisma.measurement.findUnique({ - where: { id }, - }); + const measurement = await prisma.measurement.findUnique({ + where: { id }, + }); - if (!measurement || measurement.userId !== user.id) { - return apiError("Measurement not found", 404); - } + if (!measurement || measurement.userId !== user.id) { + return apiError("Measurement not found", 404); + } - annotate({ - action: { - name: "measurement.get", - entity_type: "measurement", - entity_id: id, - }, - }); + annotate({ + action: { + name: "measurement.get", + entity_type: "measurement", + entity_id: id, + }, + }); - return apiSuccess(measurement); -}); + return apiSuccess(measurement); + }, +); -export const PUT = apiHandler(async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); +export const PUT = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); - const { id } = await params; + const { id } = await params; - const existing = await prisma.measurement.findUnique({ - where: { id }, - }); + const existing = await prisma.measurement.findUnique({ + where: { id }, + }); - if (!existing || existing.userId !== user.id) { - return apiError("Measurement not found", 404); - } + if (!existing || existing.userId !== user.id) { + return apiError("Measurement not found", 404); + } - const { data: body, error: jsonError } = await safeJson(request); + const { data: body, error: jsonError } = await safeJson(request); - if (jsonError) return jsonError; - const parsed = updateMeasurementSchema.safeParse(body); - if (!parsed.success) { - return apiError(parsed.error.issues[0].message, 422); - } + if (jsonError) return jsonError; + const parsed = updateMeasurementSchema.safeParse(body); + if (!parsed.success) { + return apiError(parsed.error.issues[0].message, 422); + } - const data = parsed.data; - const measurement = await prisma.measurement.update({ - where: { id }, - data: { - ...(data.value !== undefined && { value: data.value }), - ...(data.measuredAt !== undefined && { measuredAt: data.measuredAt }), - ...(data.notes !== undefined && { notes: data.notes }), - }, - }); + const data = parsed.data; + const measurement = await prisma.measurement.update({ + where: { id }, + data: { + ...(data.value !== undefined && { value: data.value }), + ...(data.measuredAt !== undefined && { measuredAt: data.measuredAt }), + ...(data.notes !== undefined && { notes: data.notes }), + }, + }); - await auditLog("measurement.update", { - userId: user.id, - ipAddress: getClientIp(request), - details: { measurementId: id }, - }); + await auditLog("measurement.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: { measurementId: id }, + }); - annotate({ - action: { - name: "measurement.update", - entity_type: "measurement", - entity_id: id, - }, - }); + annotate({ + action: { + name: "measurement.update", + entity_type: "measurement", + entity_id: id, + }, + }); - return apiSuccess(measurement); -}); + return apiSuccess(measurement); + }, +); -export const DELETE = apiHandler(async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); +export const DELETE = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); - const { id } = await params; + const { id } = await params; - const existing = await prisma.measurement.findUnique({ - where: { id }, - }); + const existing = await prisma.measurement.findUnique({ + where: { id }, + }); - if (!existing || existing.userId !== user.id) { - return apiError("Measurement not found", 404); - } + if (!existing || existing.userId !== user.id) { + return apiError("Measurement not found", 404); + } - await prisma.measurement.delete({ where: { id } }); + await prisma.measurement.delete({ where: { id } }); - await auditLog("measurement.delete", { - userId: user.id, - ipAddress: getClientIp(request), - details: { measurementId: id, type: existing.type }, - }); + await auditLog("measurement.delete", { + userId: user.id, + ipAddress: getClientIp(request), + details: { measurementId: id, type: existing.type }, + }); - annotate({ - action: { - name: "measurement.delete", - entity_type: "measurement", - entity_id: id, - }, - }); + annotate({ + action: { + name: "measurement.delete", + entity_type: "measurement", + entity_id: id, + }, + }); - return apiSuccess({ deleted: true }); -}); + return apiSuccess({ deleted: true }); + }, +); diff --git a/src/app/api/measurements/series/__tests__/route.test.ts b/src/app/api/measurements/series/__tests__/route.test.ts index bcfcf041e..e93cbbb1d 100644 --- a/src/app/api/measurements/series/__tests__/route.test.ts +++ b/src/app/api/measurements/series/__tests__/route.test.ts @@ -15,7 +15,11 @@ vi.mock("@/lib/db-compat", () => ({ vi.mock("next/headers", () => ({ headers: vi.fn(async () => ({ get: () => null })), - cookies: vi.fn(async () => ({ get: () => undefined, set: () => {}, delete: () => {} })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), })); import { GET } from "../route"; diff --git a/src/app/api/medications/[id]/api-endpoint/route.ts b/src/app/api/medications/[id]/api-endpoint/route.ts index 4f36d2700..67f4482c4 100644 --- a/src/app/api/medications/[id]/api-endpoint/route.ts +++ b/src/app/api/medications/[id]/api-endpoint/route.ts @@ -14,164 +14,168 @@ function medicationScope(medicationId: string): string { return `medication:${medicationId}:ingest`; } -export const GET = apiHandler(async (_request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); - if (!(await isApiGloballyEnabled())) { - return apiError("API is globally disabled", 403); - } +export const GET = apiHandler( + async (_request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + if (!(await isApiGloballyEnabled())) { + 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("Medication not found", 404); - } + const { id } = await params; + const medication = await prisma.medication.findUnique({ where: { id } }); + if (!medication || medication.userId !== user.id) { + return apiError("Medication not found", 404); + } - const scope = medicationScope(id); - const now = new Date(); + const scope = medicationScope(id); + const now = new Date(); - const activeTokenCount = await prisma.apiToken.count({ - where: { - userId: user.id, - revoked: false, - permissions: { has: scope }, - OR: [{ expiresAt: null }, { expiresAt: { gt: now } }], - }, - }); - - annotate({ - action: { - name: "medication.api_endpoint.get", - entity_type: "medication", - entity_id: id, - }, - meta: { active_token_count: activeTokenCount }, - }); - - return apiSuccess({ - enabled: activeTokenCount > 0, - activeTokenCount, - }); -}); - -export const PUT = apiHandler(async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); - if (!(await isApiGloballyEnabled())) { - 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("Medication not found", 404); - } - - let enabled = false; - try { - const body = await request.json(); - enabled = body?.enabled === true; - } catch { - return apiError("Invalid request", 422); - } - - const scope = medicationScope(id); - - if (enabled) { - const existing = await prisma.apiToken.count({ + const activeTokenCount = await prisma.apiToken.count({ where: { userId: user.id, revoked: false, permissions: { has: scope }, - OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }], + OR: [{ expiresAt: null }, { expiresAt: { gt: now } }], }, }); - if (existing > 0) { + annotate({ + action: { + name: "medication.api_endpoint.get", + entity_type: "medication", + entity_id: id, + }, + meta: { active_token_count: activeTokenCount }, + }); + + return apiSuccess({ + enabled: activeTokenCount > 0, + activeTokenCount, + }); + }, +); + +export const PUT = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + if (!(await isApiGloballyEnabled())) { + 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("Medication not found", 404); + } + + let enabled = false; + try { + const body = await request.json(); + enabled = body?.enabled === true; + } catch { + return apiError("Invalid request", 422); + } + + const scope = medicationScope(id); + + if (enabled) { + const existing = await prisma.apiToken.count({ + where: { + userId: user.id, + revoked: false, + permissions: { has: scope }, + OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }], + }, + }); + + if (existing > 0) { + annotate({ + action: { + name: "medication.api_endpoint.enable", + entity_type: "medication", + entity_id: id, + }, + meta: { already_enabled: true, active_token_count: existing }, + }); + + return apiSuccess({ + enabled: true, + activeTokenCount: existing, + token: null, + created: false, + }); + } + + const rawToken = `hlk_${randomBytes(32).toString("hex")}`; + const tokenHashValue = hashToken(rawToken); + + await prisma.apiToken.create({ + data: { + userId: user.id, + name: `API Endpoint: ${medication.name}`, + tokenHash: tokenHashValue, + permissions: ["medication:ingest", scope], + expiresAt: null, + }, + }); + + await auditLog("medication.api_endpoint.enable", { + userId: user.id, + ipAddress: getClientIp(request), + details: { medicationId: id, medicationName: medication.name }, + }); + annotate({ action: { name: "medication.api_endpoint.enable", entity_type: "medication", entity_id: id, }, - meta: { already_enabled: true, active_token_count: existing }, + meta: { created: true }, }); - return apiSuccess({ - enabled: true, - activeTokenCount: existing, - token: null, - created: false, - }); + return apiSuccess( + { + enabled: true, + activeTokenCount: 1, + token: rawToken, + created: true, + }, + 201, + ); } - const rawToken = `hlk_${randomBytes(32).toString("hex")}`; - const tokenHashValue = hashToken(rawToken); - - await prisma.apiToken.create({ - data: { + const revoked = await prisma.apiToken.updateMany({ + where: { userId: user.id, - name: `API Endpoint: ${medication.name}`, - tokenHash: tokenHashValue, - permissions: ["medication:ingest", scope], - expiresAt: null, + revoked: false, + permissions: { has: scope }, }, + data: { revoked: true }, }); - await auditLog("medication.api_endpoint.enable", { + await auditLog("medication.api_endpoint.disable", { userId: user.id, ipAddress: getClientIp(request), - details: { medicationId: id, medicationName: medication.name }, + details: { + medicationId: id, + medicationName: medication.name, + revokedTokens: revoked.count, + }, }); annotate({ action: { - name: "medication.api_endpoint.enable", + name: "medication.api_endpoint.disable", entity_type: "medication", entity_id: id, }, - meta: { created: true }, + meta: { revoked_token_count: revoked.count }, }); - return apiSuccess( - { - enabled: true, - activeTokenCount: 1, - token: rawToken, - created: true, - }, - 201, - ); - } - - const revoked = await prisma.apiToken.updateMany({ - where: { - userId: user.id, - revoked: false, - permissions: { has: scope }, - }, - data: { revoked: true }, - }); - - await auditLog("medication.api_endpoint.disable", { - userId: user.id, - ipAddress: getClientIp(request), - details: { - medicationId: id, - medicationName: medication.name, - revokedTokens: revoked.count, - }, - }); - - annotate({ - action: { - name: "medication.api_endpoint.disable", - entity_type: "medication", - entity_id: id, - }, - meta: { revoked_token_count: revoked.count }, - }); - - return apiSuccess({ - enabled: false, - revokedTokenCount: revoked.count, - }); -}); + return apiSuccess({ + enabled: false, + revokedTokenCount: revoked.count, + }); + }, +); diff --git a/src/app/api/medications/[id]/compliance/route.ts b/src/app/api/medications/[id]/compliance/route.ts index 60d528ad3..fb5a14595 100644 --- a/src/app/api/medications/[id]/compliance/route.ts +++ b/src/app/api/medications/[id]/compliance/route.ts @@ -10,124 +10,126 @@ import type { DailyComplianceEntry } from "@/lib/analytics/compliance"; type RouteParams = { params: Promise<{ id: string }> }; -export const GET = apiHandler(async (_request: Request, { params }: RouteParams) => { - const { user } = await requireAuth(); +export const GET = apiHandler( + async (_request: Request, { params }: RouteParams) => { + const { user } = await requireAuth(); - const { id } = await params; - const medication = await prisma.medication.findUnique({ - where: { id }, - include: { schedules: true }, - }); + const { id } = await params; + const medication = await prisma.medication.findUnique({ + where: { id }, + include: { schedules: true }, + }); - if (!medication || medication.userId !== user.id) { - return apiError("Medication not found", 404); - } - - const events = await prisma.medicationIntakeEvent.findMany({ - where: { medicationId: id, userId: user.id }, - orderBy: { scheduledFor: "desc" }, - }); - - const mapped = events.map((e) => ({ - takenAt: e.takenAt, - skipped: e.skipped, - scheduledFor: e.scheduledFor, - })); - - const createdAt = medication.createdAt; - const compliance7 = calculateCompliance( - mapped, - medication.schedules, - 7, - createdAt, - ); - const compliance30 = calculateCompliance( - mapped, - medication.schedules, - 30, - createdAt, - ); - - // Build daily compliance map for heatmap/line chart (90 days) - const now = new Date(); - const schedulesPerDay = medication.schedules.length; - const dailyCompliance: Record = {}; - - for (let d = 0; d < 90; d++) { - const dayStart = new Date(now.getTime() - (d + 1) * 24 * 60 * 60 * 1000); - const dayEnd = new Date(now.getTime() - d * 24 * 60 * 60 * 1000); - - // Skip days before medication was created - if (dayEnd <= createdAt) continue; - - const dateKey = dayStart.toISOString().slice(0, 10); - - const dayEvents = mapped.filter( - (e) => e.scheduledFor >= dayStart && e.scheduledFor < dayEnd, - ); - - const takenEvents = dayEvents.filter( - (e) => e.takenAt !== null && !e.skipped, - ); - - // Classify timing for each taken event against the best-matching schedule - let onTime = 0; - let late = 0; - let veryLate = 0; - - for (const evt of takenEvents) { - if (medication.schedules.length === 0) { - // No schedule info: treat all taken as on_time - onTime++; - continue; - } - - // Match event to the closest schedule window by scheduledFor time - const evtHour = evt.scheduledFor.getUTCHours(); - const evtMin = evt.scheduledFor.getUTCMinutes(); - - let bestSchedule = medication.schedules[0]; - let bestDist = Infinity; - - for (const sched of medication.schedules) { - const [sh, sm] = sched.windowStart.split(":").map(Number); - const dist = Math.abs(evtHour * 60 + evtMin - (sh * 60 + sm)); - if (dist < bestDist) { - bestDist = dist; - bestSchedule = sched; - } - } - - const timing = classifyIntakeTiming( - evt.takenAt, - bestSchedule.windowStart, - bestSchedule.windowEnd, - dayStart, // the scheduled date - ); - - if (timing === "on_time") onTime++; - else if (timing === "late") late++; - else veryLate++; + if (!medication || medication.userId !== user.id) { + return apiError("Medication not found", 404); } - dailyCompliance[dateKey] = { - expected: schedulesPerDay, - taken: takenEvents.length, - skipped: dayEvents.filter((e) => e.skipped).length, - onTime, - late, - veryLate, - }; - } + const events = await prisma.medicationIntakeEvent.findMany({ + where: { medicationId: id, userId: user.id }, + orderBy: { scheduledFor: "desc" }, + }); - annotate({ - action: { - name: "medication.compliance", - entity_type: "medication", - entity_id: id, - }, - meta: { compliance7: compliance7.rate, compliance30: compliance30.rate }, - }); + const mapped = events.map((e) => ({ + takenAt: e.takenAt, + skipped: e.skipped, + scheduledFor: e.scheduledFor, + })); - return apiSuccess({ compliance7, compliance30, dailyCompliance }); -}); + const createdAt = medication.createdAt; + const compliance7 = calculateCompliance( + mapped, + medication.schedules, + 7, + createdAt, + ); + const compliance30 = calculateCompliance( + mapped, + medication.schedules, + 30, + createdAt, + ); + + // Build daily compliance map for heatmap/line chart (90 days) + const now = new Date(); + const schedulesPerDay = medication.schedules.length; + const dailyCompliance: Record = {}; + + for (let d = 0; d < 90; d++) { + const dayStart = new Date(now.getTime() - (d + 1) * 24 * 60 * 60 * 1000); + const dayEnd = new Date(now.getTime() - d * 24 * 60 * 60 * 1000); + + // Skip days before medication was created + if (dayEnd <= createdAt) continue; + + const dateKey = dayStart.toISOString().slice(0, 10); + + const dayEvents = mapped.filter( + (e) => e.scheduledFor >= dayStart && e.scheduledFor < dayEnd, + ); + + const takenEvents = dayEvents.filter( + (e) => e.takenAt !== null && !e.skipped, + ); + + // Classify timing for each taken event against the best-matching schedule + let onTime = 0; + let late = 0; + let veryLate = 0; + + for (const evt of takenEvents) { + if (medication.schedules.length === 0) { + // No schedule info: treat all taken as on_time + onTime++; + continue; + } + + // Match event to the closest schedule window by scheduledFor time + const evtHour = evt.scheduledFor.getUTCHours(); + const evtMin = evt.scheduledFor.getUTCMinutes(); + + let bestSchedule = medication.schedules[0]; + let bestDist = Infinity; + + for (const sched of medication.schedules) { + const [sh, sm] = sched.windowStart.split(":").map(Number); + const dist = Math.abs(evtHour * 60 + evtMin - (sh * 60 + sm)); + if (dist < bestDist) { + bestDist = dist; + bestSchedule = sched; + } + } + + const timing = classifyIntakeTiming( + evt.takenAt, + bestSchedule.windowStart, + bestSchedule.windowEnd, + dayStart, // the scheduled date + ); + + if (timing === "on_time") onTime++; + else if (timing === "late") late++; + else veryLate++; + } + + dailyCompliance[dateKey] = { + expected: schedulesPerDay, + taken: takenEvents.length, + skipped: dayEvents.filter((e) => e.skipped).length, + onTime, + late, + veryLate, + }; + } + + annotate({ + action: { + name: "medication.compliance", + entity_type: "medication", + entity_id: id, + }, + meta: { compliance7: compliance7.rate, compliance30: compliance30.rate }, + }); + + return apiSuccess({ compliance7, compliance30, dailyCompliance }); + }, +); diff --git a/src/app/api/medications/[id]/intake/[eventId]/route.ts b/src/app/api/medications/[id]/intake/[eventId]/route.ts index 6555160df..57c3fbf5f 100644 --- a/src/app/api/medications/[id]/intake/[eventId]/route.ts +++ b/src/app/api/medications/[id]/intake/[eventId]/route.ts @@ -2,101 +2,102 @@ 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 { updateIntakeEventSchema } from "@/lib/validations/medication"; import { NextRequest } from "next/server"; type RouteParams = { params: Promise<{ id: string; eventId: string }> }; -export const PUT = apiHandler(async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); +export const PUT = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); - const { id, eventId } = await params; + const { id, eventId } = await params; - const event = await prisma.medicationIntakeEvent.findUnique({ - where: { id: eventId }, - }); + const event = await prisma.medicationIntakeEvent.findUnique({ + where: { id: eventId }, + }); - if ( - !event || - event.userId !== user.id || - event.medicationId !== id - ) { - return apiError("Intake not found", 404); - } + if (!event || event.userId !== user.id || event.medicationId !== id) { + return apiError("Intake not found", 404); + } - const { data: body, error: jsonError } = await safeJson(request); + const { data: body, error: jsonError } = await safeJson(request); - if (jsonError) return jsonError; - const parsed = updateIntakeEventSchema.safeParse(body); - if (!parsed.success) { - return apiError(parsed.error.issues[0].message, 422); - } + if (jsonError) return jsonError; + const parsed = updateIntakeEventSchema.safeParse(body); + if (!parsed.success) { + return apiError(parsed.error.issues[0].message, 422); + } - const data = parsed.data; - const updated = await prisma.medicationIntakeEvent.update({ - where: { id: eventId }, - data: { - ...(data.takenAt !== undefined && { takenAt: data.takenAt }), - ...(data.skipped !== undefined && { skipped: data.skipped }), - ...(data.scheduledFor !== undefined && { - scheduledFor: data.scheduledFor, - }), - }, - }); + const data = parsed.data; + const updated = await prisma.medicationIntakeEvent.update({ + where: { id: eventId }, + data: { + ...(data.takenAt !== undefined && { takenAt: data.takenAt }), + ...(data.skipped !== undefined && { skipped: data.skipped }), + ...(data.scheduledFor !== undefined && { + scheduledFor: data.scheduledFor, + }), + }, + }); - await auditLog("medication.intake.update", { - userId: user.id, - ipAddress: getClientIp(request), - details: { eventId, medicationId: id }, - }); + await auditLog("medication.intake.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: { eventId, medicationId: id }, + }); - annotate({ - action: { - name: "medication.intake.update", - entity_type: "intake_event", - entity_id: eventId, - }, - meta: { medication_id: id }, - }); + annotate({ + action: { + name: "medication.intake.update", + entity_type: "intake_event", + entity_id: eventId, + }, + meta: { medication_id: id }, + }); - return apiSuccess(updated); -}); + return apiSuccess(updated); + }, +); -export const DELETE = apiHandler(async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); +export const DELETE = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); - const { id, eventId } = await params; + const { id, eventId } = await params; - const event = await prisma.medicationIntakeEvent.findUnique({ - where: { id: eventId }, - }); + const event = await prisma.medicationIntakeEvent.findUnique({ + where: { id: eventId }, + }); - if ( - !event || - event.userId !== user.id || - event.medicationId !== id - ) { - return apiError("Intake not found", 404); - } + if (!event || event.userId !== user.id || event.medicationId !== id) { + return apiError("Intake not found", 404); + } - await prisma.medicationIntakeEvent.delete({ where: { id: eventId } }); + await prisma.medicationIntakeEvent.delete({ where: { id: eventId } }); - const ip = getClientIp(request) ?? "unknown"; - await auditLog("medication.intake.delete", { - userId: user.id, - ipAddress: ip, - details: { eventId, medicationId: id }, - }); + const ip = getClientIp(request) ?? "unknown"; + await auditLog("medication.intake.delete", { + userId: user.id, + ipAddress: ip, + details: { eventId, medicationId: id }, + }); - annotate({ - action: { - name: "medication.intake.delete", - entity_type: "intake_event", - entity_id: eventId, - }, - meta: { medication_id: id }, - }); + annotate({ + action: { + name: "medication.intake.delete", + entity_type: "intake_event", + entity_id: eventId, + }, + meta: { medication_id: id }, + }); - return apiSuccess({ deleted: true }); -}); + return apiSuccess({ deleted: true }); + }, +); diff --git a/src/app/api/medications/[id]/intake/import/route.ts b/src/app/api/medications/[id]/intake/import/route.ts index c14e79b39..b4e400651 100644 --- a/src/app/api/medications/[id]/intake/import/route.ts +++ b/src/app/api/medications/[id]/intake/import/route.ts @@ -2,7 +2,12 @@ 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 { NextRequest } from "next/server"; import { z } from "zod/v4"; @@ -16,97 +21,101 @@ const importSchema = z.array(importEntrySchema).min(1).max(1000); type RouteParams = { params: Promise<{ id: string }> }; -export const POST = apiHandler(async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); +export const POST = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); - const { id } = await params; - const medication = await prisma.medication.findUnique({ where: { id } }); - if (!medication || medication.userId !== user.id) { - return apiError("Medication not found", 404); - } - - const { data: body, error: jsonError } = await safeJson(request); - - if (jsonError) return jsonError; - - // Support both direct arrays and object-with-array payloads - const payload = Array.isArray(body) - ? body - : typeof body === "object" && body !== null - ? Object.values(body).find((value) => Array.isArray(value)) - : null; - - const parsed = importSchema.safeParse(payload); - if (!parsed.success) { - return apiError( - `Invalid format: ${parsed.error.issues[0].message}`, - 422, - ); - } - - const entries = parsed.data; - let imported = 0; - let skippedDuplicates = 0; - let skippedInvalid = 0; - - for (const entry of entries) { - // Parse as local datetime first; if invalid, fall back to CET offset. - let takenAt = new Date(`${entry.datum}T${entry.uhrzeit}`); - if (isNaN(takenAt.getTime())) { - takenAt = new Date(`${entry.datum}T${entry.uhrzeit}+01:00`); - } - if (isNaN(takenAt.getTime())) { - skippedInvalid++; - continue; + const { id } = await params; + const medication = await prisma.medication.findUnique({ where: { id } }); + if (!medication || medication.userId !== user.id) { + return apiError("Medication not found", 404); } - // Prefer explicit counter; otherwise use timestamp-based dedup key. - const idempotencyKey = entry.zaehler - ? `import-${id}-${String(entry.zaehler)}` - : `import-${id}-${takenAt.getTime()}`; + const { data: body, error: jsonError } = await safeJson(request); - const existing = await prisma.medicationIntakeEvent.findUnique({ - where: { idempotencyKey }, - }); - if (existing) { - skippedDuplicates++; - continue; + if (jsonError) return jsonError; + + // Support both direct arrays and object-with-array payloads + const payload = Array.isArray(body) + ? body + : typeof body === "object" && body !== null + ? Object.values(body).find((value) => Array.isArray(value)) + : null; + + const parsed = importSchema.safeParse(payload); + if (!parsed.success) { + return apiError(`Invalid format: ${parsed.error.issues[0].message}`, 422); } - await prisma.medicationIntakeEvent.create({ - data: { - userId: user.id, + const entries = parsed.data; + let imported = 0; + let skippedDuplicates = 0; + let skippedInvalid = 0; + + for (const entry of entries) { + // Parse as local datetime first; if invalid, fall back to CET offset. + let takenAt = new Date(`${entry.datum}T${entry.uhrzeit}`); + if (isNaN(takenAt.getTime())) { + takenAt = new Date(`${entry.datum}T${entry.uhrzeit}+01:00`); + } + if (isNaN(takenAt.getTime())) { + skippedInvalid++; + continue; + } + + // Prefer explicit counter; otherwise use timestamp-based dedup key. + const idempotencyKey = entry.zaehler + ? `import-${id}-${String(entry.zaehler)}` + : `import-${id}-${takenAt.getTime()}`; + + const existing = await prisma.medicationIntakeEvent.findUnique({ + where: { idempotencyKey }, + }); + if (existing) { + skippedDuplicates++; + continue; + } + + await prisma.medicationIntakeEvent.create({ + data: { + userId: user.id, + medicationId: id, + scheduledFor: takenAt, + takenAt, + skipped: false, + source: "IMPORT", + idempotencyKey, + }, + }); + imported++; + } + + await auditLog("medication.intake.import", { + userId: user.id, + ipAddress: getClientIp(request), + details: { medicationId: id, - scheduledFor: takenAt, - takenAt, - skipped: false, - source: "IMPORT", - idempotencyKey, + imported, + skippedDuplicates, + skippedInvalid, + total: entries.length, }, }); - imported++; - } - await auditLog("medication.intake.import", { - userId: user.id, - ipAddress: getClientIp(request), - details: { - medicationId: id, - imported, - skippedDuplicates, - skippedInvalid, - total: entries.length, - }, - }); + annotate({ + action: { + name: "medication.intake.import", + entity_type: "medication", + entity_id: id, + }, + meta: { + imported, + skippedDuplicates, + skippedInvalid, + total: entries.length, + }, + }); - annotate({ - action: { - name: "medication.intake.import", - entity_type: "medication", - entity_id: id, - }, - meta: { imported, skippedDuplicates, skippedInvalid, total: entries.length }, - }); - - return apiSuccess({ imported, skippedDuplicates, skippedInvalid }, 201); -}); + return apiSuccess({ imported, skippedDuplicates, skippedInvalid }, 201); + }, +); diff --git a/src/app/api/medications/[id]/intake/purge/route.ts b/src/app/api/medications/[id]/intake/purge/route.ts index 007a4edf1..a29e5ce6d 100644 --- a/src/app/api/medications/[id]/intake/purge/route.ts +++ b/src/app/api/medications/[id]/intake/purge/route.ts @@ -7,33 +7,35 @@ import { NextRequest } from "next/server"; type RouteParams = { params: Promise<{ id: string }> }; -export const DELETE = apiHandler(async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); +export const DELETE = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); - const { id } = await params; - const medication = await prisma.medication.findUnique({ where: { id } }); - if (!medication || medication.userId !== user.id) { - return apiError("Medication not found", 404); - } + const { id } = await params; + const medication = await prisma.medication.findUnique({ where: { id } }); + if (!medication || medication.userId !== user.id) { + return apiError("Medication not found", 404); + } - const { count } = await prisma.medicationIntakeEvent.deleteMany({ - where: { medicationId: id, userId: user.id }, - }); + const { count } = await prisma.medicationIntakeEvent.deleteMany({ + where: { medicationId: id, userId: user.id }, + }); - await auditLog("medication.intake.purge", { - userId: user.id, - ipAddress: getClientIp(request), - details: { medicationId: id, name: medication.name, deletedCount: count }, - }); + await auditLog("medication.intake.purge", { + userId: user.id, + ipAddress: getClientIp(request), + details: { medicationId: id, name: medication.name, deletedCount: count }, + }); - annotate({ - action: { - name: "medication.intake.purge", - entity_type: "medication", - entity_id: id, - }, - meta: { purged_count: count }, - }); + annotate({ + action: { + name: "medication.intake.purge", + entity_type: "medication", + entity_id: id, + }, + meta: { purged_count: count }, + }); - return apiSuccess({ purged: true, count }); -}); + return apiSuccess({ purged: true, count }); + }, +); diff --git a/src/app/api/medications/[id]/phase-config/route.ts b/src/app/api/medications/[id]/phase-config/route.ts index 24e0580ab..814721763 100644 --- a/src/app/api/medications/[id]/phase-config/route.ts +++ b/src/app/api/medications/[id]/phase-config/route.ts @@ -7,108 +7,114 @@ import { NextRequest } from "next/server"; type RouteParams = { params: Promise<{ id: string }> }; -export const GET = apiHandler(async (_request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); +export const GET = apiHandler( + async (_request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); - const { id } = await params; - const medication = await prisma.medication.findUnique({ - where: { id }, - select: { userId: true }, - }); - if (!medication || medication.userId !== user.id) { - return apiError("Medication not found", 404); - } + const { id } = await params; + const medication = await prisma.medication.findUnique({ + where: { id }, + select: { userId: true }, + }); + if (!medication || medication.userId !== user.id) { + return apiError("Medication not found", 404); + } - const config = await prisma.reminderPhaseConfig.findUnique({ - where: { medicationId: id }, - }); + const config = await prisma.reminderPhaseConfig.findUnique({ + where: { medicationId: id }, + }); - annotate({ - action: { - name: "medication.phase_config.get", - entity_type: "medication", - entity_id: id, - }, - }); + annotate({ + action: { + name: "medication.phase_config.get", + entity_type: "medication", + entity_id: id, + }, + }); - // Return config or defaults - return apiSuccess( - config ?? { - greenValue: 60, - greenMode: "MINUTES", - yellowValue: 30, - yellowMode: "MINUTES", - orangeValue: 0, - orangeMode: "MINUTES", - redValue: 240, - redMode: "MINUTES", - }, - ); -}); + // Return config or defaults + return apiSuccess( + config ?? { + greenValue: 60, + greenMode: "MINUTES", + yellowValue: 30, + yellowMode: "MINUTES", + orangeValue: 0, + orangeMode: "MINUTES", + redValue: 240, + redMode: "MINUTES", + }, + ); + }, +); -export const PUT = apiHandler(async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); +export const PUT = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); - const { id } = await params; - const medication = await prisma.medication.findUnique({ - where: { id }, - select: { userId: true }, - }); - if (!medication || medication.userId !== user.id) { - return apiError("Medication not found", 404); - } + const { id } = await params; + const medication = await prisma.medication.findUnique({ + where: { id }, + select: { userId: true }, + }); + if (!medication || medication.userId !== user.id) { + return apiError("Medication not found", 404); + } - const { data: body, error: jsonError } = await safeJson(request); + const { data: body, error: jsonError } = await safeJson(request); - if (jsonError) return jsonError; - const parsed = phaseConfigSchema.safeParse(body); - if (!parsed.success) { - return apiError("Invalid input", 400); - } + if (jsonError) return jsonError; + const parsed = phaseConfigSchema.safeParse(body); + if (!parsed.success) { + return apiError("Invalid input", 400); + } - const config = await prisma.reminderPhaseConfig.upsert({ - where: { medicationId: id }, - create: { - medicationId: id, - ...parsed.data, - }, - update: parsed.data, - }); + const config = await prisma.reminderPhaseConfig.upsert({ + where: { medicationId: id }, + create: { + medicationId: id, + ...parsed.data, + }, + update: parsed.data, + }); - annotate({ - action: { - name: "medication.phase_config.update", - entity_type: "medication", - entity_id: id, - }, - }); + annotate({ + action: { + name: "medication.phase_config.update", + entity_type: "medication", + entity_id: id, + }, + }); - return apiSuccess(config); -}); + return apiSuccess(config); + }, +); -export const DELETE = apiHandler(async (_request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); +export const DELETE = apiHandler( + async (_request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); - const { id } = await params; - const medication = await prisma.medication.findUnique({ - where: { id }, - select: { userId: true }, - }); - if (!medication || medication.userId !== user.id) { - return apiError("Medication not found", 404); - } + const { id } = await params; + const medication = await prisma.medication.findUnique({ + where: { id }, + select: { userId: true }, + }); + if (!medication || medication.userId !== user.id) { + return apiError("Medication not found", 404); + } - await prisma.reminderPhaseConfig.deleteMany({ - where: { medicationId: id }, - }); + await prisma.reminderPhaseConfig.deleteMany({ + where: { medicationId: id }, + }); - annotate({ - action: { - name: "medication.phase_config.reset", - entity_type: "medication", - entity_id: id, - }, - }); + annotate({ + action: { + name: "medication.phase_config.reset", + entity_type: "medication", + entity_id: id, + }, + }); - return apiSuccess({ reset: true }); -}); + return apiSuccess({ reset: true }); + }, +); diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index a82a1e568..64bf97303 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -2,7 +2,12 @@ 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 { updateMedicationSchema } from "@/lib/validations/medication"; import { deleteMedicationCategory, @@ -14,193 +19,199 @@ import { NextRequest } from "next/server"; type RouteParams = { params: Promise<{ id: string }> }; -export const GET = apiHandler(async (_request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); +export const GET = apiHandler( + async (_request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); - const { id } = await params; - const medication = await prisma.medication.findUnique({ - where: { id }, - include: { schedules: true }, - }); - - if (!medication || medication.userId !== user.id) { - return apiError("Medication not found", 404); - } - - let category = "OTHER"; - try { - const categories = await getMedicationCategories([id]); - category = categories[id] ?? "OTHER"; - } catch { - // Category enrichment is optional - } - - annotate({ - action: { - name: "medication.get", - entity_type: "medication", - entity_id: id, - }, - }); - - return apiSuccess({ ...medication, category }); -}); - -export const PUT = apiHandler(async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); - - const { id } = await params; - const existing = await prisma.medication.findUnique({ where: { id } }); - if (!existing || existing.userId !== user.id) { - return apiError("Medication not found", 404); - } - - const { data: body, error: jsonError } = await safeJson(request); - - if (jsonError) return jsonError; - const parsed = updateMedicationSchema.safeParse(body); - if (!parsed.success) { - return apiError(parsed.error.issues[0].message, 422); - } - - const { name, dose, category, active, notificationsEnabled, schedules } = - parsed.data; - - const pausedAtPatch = - active === undefined - ? {} - : active - ? { pausedAt: null as Date | null } - : existing.active - ? { pausedAt: new Date() } - : {}; - - // If schedules provided, replace all - if (schedules) { - await prisma.medicationSchedule.deleteMany({ - where: { medicationId: id }, + const { id } = await params; + const medication = await prisma.medication.findUnique({ + where: { id }, + include: { schedules: true }, }); - } - const baseUpdateData = { - ...(name !== undefined && { name }), - ...(dose !== undefined && { dose }), - ...(active !== undefined && { active }), - ...(notificationsEnabled !== undefined && { notificationsEnabled }), - ...(schedules && { - schedules: { - create: schedules.map((s) => ({ - windowStart: s.windowStart, - windowEnd: s.windowEnd, - label: s.label ?? null, - dose: s.dose ?? null, - daysOfWeek: serializeScheduleRecurrence({ - daysOfWeek: s.daysOfWeek ?? [], - intervalWeeks: s.intervalWeeks ?? 1, - }), - })), - }, - }), - }; - - const withoutNotifications = { ...baseUpdateData } as Record< - string, - unknown - >; - delete withoutNotifications.notificationsEnabled; - const hasPausedAtPatch = Object.keys(pausedAtPatch).length > 0; - const hasNotificationsPatch = notificationsEnabled !== undefined; - - const updateCandidates: Array> = [ - { ...baseUpdateData, ...pausedAtPatch }, - ]; - if (hasPausedAtPatch) { - updateCandidates.push(baseUpdateData); - } - if (hasNotificationsPatch) { - updateCandidates.push({ ...withoutNotifications, ...pausedAtPatch }); - if (hasPausedAtPatch) { - updateCandidates.push(withoutNotifications); + if (!medication || medication.userId !== user.id) { + return apiError("Medication not found", 404); } - } - let medication; - let lastUpdateErr: unknown; - for (const candidate of updateCandidates) { + let category = "OTHER"; try { - medication = await prisma.medication.update({ - where: { id }, - data: candidate, - include: { schedules: true }, - }); - break; - } catch (updateErr) { - lastUpdateErr = updateErr; + const categories = await getMedicationCategories([id]); + category = categories[id] ?? "OTHER"; + } catch { + // Category enrichment is optional } - } - if (!medication) throw lastUpdateErr; - const normalizedCategory = - category !== undefined - ? await setMedicationCategory(id, category) - : ((await getMedicationCategories([id]))[id] ?? "OTHER"); + annotate({ + action: { + name: "medication.get", + entity_type: "medication", + entity_id: id, + }, + }); - await auditLog("medication.update", { - userId: user.id, - ipAddress: getClientIp(request), - details: { medicationId: id }, - }); + return apiSuccess({ ...medication, category }); + }, +); - annotate({ - action: { - name: "medication.update", - entity_type: "medication", - entity_id: id, - }, - }); +export const PUT = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); - return apiSuccess({ - ...medication, - category: normalizedCategory, - }); -}); + const { id } = await params; + const existing = await prisma.medication.findUnique({ where: { id } }); + if (!existing || existing.userId !== user.id) { + return apiError("Medication not found", 404); + } -export const DELETE = apiHandler(async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + const { data: body, error: jsonError } = await safeJson(request); - const { id } = await params; - const existing = await prisma.medication.findUnique({ where: { id } }); - if (!existing || existing.userId !== user.id) { - return apiError("Medication not found", 404); - } + if (jsonError) return jsonError; + const parsed = updateMedicationSchema.safeParse(body); + if (!parsed.success) { + return apiError(parsed.error.issues[0].message, 422); + } - // Revoke API tokens scoped to this medication - const medicationScope = `medication:${id}:ingest`; - await prisma.apiToken.updateMany({ - where: { + const { name, dose, category, active, notificationsEnabled, schedules } = + parsed.data; + + const pausedAtPatch = + active === undefined + ? {} + : active + ? { pausedAt: null as Date | null } + : existing.active + ? { pausedAt: new Date() } + : {}; + + // If schedules provided, replace all + if (schedules) { + await prisma.medicationSchedule.deleteMany({ + where: { medicationId: id }, + }); + } + + const baseUpdateData = { + ...(name !== undefined && { name }), + ...(dose !== undefined && { dose }), + ...(active !== undefined && { active }), + ...(notificationsEnabled !== undefined && { notificationsEnabled }), + ...(schedules && { + schedules: { + create: schedules.map((s) => ({ + windowStart: s.windowStart, + windowEnd: s.windowEnd, + label: s.label ?? null, + dose: s.dose ?? null, + daysOfWeek: serializeScheduleRecurrence({ + daysOfWeek: s.daysOfWeek ?? [], + intervalWeeks: s.intervalWeeks ?? 1, + }), + })), + }, + }), + }; + + const withoutNotifications = { ...baseUpdateData } as Record< + string, + unknown + >; + delete withoutNotifications.notificationsEnabled; + const hasPausedAtPatch = Object.keys(pausedAtPatch).length > 0; + const hasNotificationsPatch = notificationsEnabled !== undefined; + + const updateCandidates: Array> = [ + { ...baseUpdateData, ...pausedAtPatch }, + ]; + if (hasPausedAtPatch) { + updateCandidates.push(baseUpdateData); + } + if (hasNotificationsPatch) { + updateCandidates.push({ ...withoutNotifications, ...pausedAtPatch }); + if (hasPausedAtPatch) { + updateCandidates.push(withoutNotifications); + } + } + + let medication; + let lastUpdateErr: unknown; + for (const candidate of updateCandidates) { + try { + medication = await prisma.medication.update({ + where: { id }, + data: candidate, + include: { schedules: true }, + }); + break; + } catch (updateErr) { + lastUpdateErr = updateErr; + } + } + if (!medication) throw lastUpdateErr; + + const normalizedCategory = + category !== undefined + ? await setMedicationCategory(id, category) + : ((await getMedicationCategories([id]))[id] ?? "OTHER"); + + await auditLog("medication.update", { userId: user.id, - revoked: false, - permissions: { has: medicationScope }, - }, - data: { revoked: true }, - }); + ipAddress: getClientIp(request), + details: { medicationId: id }, + }); - await deleteMedicationCategory(id); - await prisma.medication.delete({ where: { id } }); + annotate({ + action: { + name: "medication.update", + entity_type: "medication", + entity_id: id, + }, + }); - await auditLog("medication.delete", { - userId: user.id, - ipAddress: getClientIp(request), - details: { medicationId: id, name: existing.name }, - }); + return apiSuccess({ + ...medication, + category: normalizedCategory, + }); + }, +); - annotate({ - action: { - name: "medication.delete", - entity_type: "medication", - entity_id: id, - }, - }); +export const DELETE = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); - return apiSuccess({ deleted: true }); -}); + const { id } = await params; + const existing = await prisma.medication.findUnique({ where: { id } }); + if (!existing || existing.userId !== user.id) { + return apiError("Medication not found", 404); + } + + // Revoke API tokens scoped to this medication + const medicationScope = `medication:${id}:ingest`; + await prisma.apiToken.updateMany({ + where: { + userId: user.id, + revoked: false, + permissions: { has: medicationScope }, + }, + data: { revoked: true }, + }); + + await deleteMedicationCategory(id); + await prisma.medication.delete({ where: { id } }); + + await auditLog("medication.delete", { + userId: user.id, + ipAddress: getClientIp(request), + details: { medicationId: id, name: existing.name }, + }); + + annotate({ + action: { + name: "medication.delete", + entity_type: "medication", + entity_id: id, + }, + }); + + return apiSuccess({ deleted: true }); + }, +); diff --git a/src/app/api/medications/intake-summary/route.ts b/src/app/api/medications/intake-summary/route.ts index 24a2bf174..3a2d0d1d5 100644 --- a/src/app/api/medications/intake-summary/route.ts +++ b/src/app/api/medications/intake-summary/route.ts @@ -92,7 +92,10 @@ export const GET = apiHandler(async (request: NextRequest) => { annotate({ action: { name: "medication.intake_summary" }, - meta: { point_count: points.length, medication_count: medicationNames.size }, + meta: { + point_count: points.length, + medication_count: medicationNames.size, + }, }); return apiSuccess({ diff --git a/src/app/api/medications/intake/__tests__/route.test.ts b/src/app/api/medications/intake/__tests__/route.test.ts index a865812ed..d01908e1b 100644 --- a/src/app/api/medications/intake/__tests__/route.test.ts +++ b/src/app/api/medications/intake/__tests__/route.test.ts @@ -27,7 +27,11 @@ vi.mock("@/lib/db-compat", () => ({ vi.mock("next/headers", () => ({ headers: vi.fn(async () => ({ get: () => null })), - cookies: vi.fn(async () => ({ get: () => undefined, set: () => {}, delete: () => {} })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), })); import { GET, POST } from "../route"; @@ -41,7 +45,9 @@ const SESSION_OK = { beforeEach(() => { vi.resetAllMocks(); - vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); }); describe("GET /api/medications/intake", () => { diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts index c379e35dd..5a4b08dba 100644 --- a/src/app/api/medications/intake/route.ts +++ b/src/app/api/medications/intake/route.ts @@ -14,7 +14,12 @@ import { NextRequest } from "next/server"; import { z } from "zod/v4"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiError, apiSuccess, getClientIp, safeJson } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + getClientIp, + safeJson, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; @@ -175,8 +180,7 @@ export const POST = apiHandler(async (request: NextRequest) => { }); } else { // snoozed: snoozedUntil lives on the Medication row. - const until = - snoozedUntil ?? new Date(Date.now() + 30 * 60_000); // default +30min + const until = snoozedUntil ?? new Date(Date.now() + 30 * 60_000); // default +30min await prisma.medication.update({ where: { id: existing.medicationId }, data: { snoozedUntil: until }, diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index cfeaba7c4..9239d7eca 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -2,7 +2,12 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate, getEvent } 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 { createMedicationSchema } from "@/lib/validations/medication"; import { getMedicationCategories, diff --git a/src/app/api/monitoring/glitchtip/route.ts b/src/app/api/monitoring/glitchtip/route.ts index 02211bd24..e3a720d4a 100644 --- a/src/app/api/monitoring/glitchtip/route.ts +++ b/src/app/api/monitoring/glitchtip/route.ts @@ -53,7 +53,14 @@ export const POST = apiHandler(async (request: NextRequest) => { }); if (!delivery.ok) { - getEvent()?.addWarning("Glitchtip event rejected: " + delivery.method + " " + delivery.status + " " + delivery.details); + getEvent()?.addWarning( + "Glitchtip event rejected: " + + delivery.method + + " " + + delivery.status + + " " + + delivery.details, + ); return apiError("Glitchtip could not accept error", 502); } diff --git a/src/app/api/mood-entries/[id]/route.ts b/src/app/api/mood-entries/[id]/route.ts index 1fd4e0620..f0f08d419 100644 --- a/src/app/api/mood-entries/[id]/route.ts +++ b/src/app/api/mood-entries/[id]/route.ts @@ -1,6 +1,11 @@ import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; -import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response"; +import { + apiSuccess, + apiError, + getClientIp, + safeJson, +} from "@/lib/api-response"; import { updateMoodEntrySchema, getScoreForMood, @@ -26,92 +31,107 @@ function parseTags(tags: string | null): string[] { } } -export const GET = apiHandler(async (_request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); +export const GET = apiHandler( + async (_request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); - const { id } = await params; + const { id } = await params; - const entry = await prisma.moodEntry.findUnique({ where: { id } }); + const entry = await prisma.moodEntry.findUnique({ where: { id } }); - if (!entry || entry.userId !== user.id) { - return apiError("Mood entry not found", 404); - } + if (!entry || entry.userId !== user.id) { + return apiError("Mood entry not found", 404); + } - annotate({ action: { name: "mood-entries.get" }, meta: { moodEntryId: id } }); + annotate({ + action: { name: "mood-entries.get" }, + meta: { moodEntryId: id }, + }); - return apiSuccess({ ...entry, tags: parseTags(entry.tags) }); -}); + return apiSuccess({ ...entry, tags: parseTags(entry.tags) }); + }, +); -export const PUT = apiHandler(async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); +export const PUT = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); - const { id } = await params; + const { id } = await params; - const existing = await prisma.moodEntry.findUnique({ where: { id } }); + const existing = await prisma.moodEntry.findUnique({ where: { id } }); - if (!existing || existing.userId !== user.id) { - return apiError("Mood entry not found", 404); - } + if (!existing || existing.userId !== user.id) { + return apiError("Mood entry not found", 404); + } - const { data: body, error: jsonError } = await safeJson(request); + const { data: body, error: jsonError } = await safeJson(request); - if (jsonError) return jsonError; - const parsed = updateMoodEntrySchema.safeParse(body); - if (!parsed.success) { - return apiError(parsed.error.issues[0].message, 422); - } + if (jsonError) return jsonError; + const parsed = updateMoodEntrySchema.safeParse(body); + if (!parsed.success) { + return apiError(parsed.error.issues[0].message, 422); + } - const data = parsed.data; + const data = parsed.data; - const updateData: Record = {}; - if (data.mood !== undefined) { - updateData.mood = data.mood; - updateData.score = getScoreForMood(data.mood); - } - if (data.moodLoggedAt !== undefined) { - updateData.moodLoggedAt = data.moodLoggedAt; - updateData.date = toBerlinDate(data.moodLoggedAt); - } - if (data.tags !== undefined) { - updateData.tags = data.tags ? JSON.stringify(data.tags) : null; - } + const updateData: Record = {}; + if (data.mood !== undefined) { + updateData.mood = data.mood; + updateData.score = getScoreForMood(data.mood); + } + if (data.moodLoggedAt !== undefined) { + updateData.moodLoggedAt = data.moodLoggedAt; + updateData.date = toBerlinDate(data.moodLoggedAt); + } + if (data.tags !== undefined) { + updateData.tags = data.tags ? JSON.stringify(data.tags) : null; + } - const entry = await prisma.moodEntry.update({ - where: { id }, - data: updateData, - }); + const entry = await prisma.moodEntry.update({ + where: { id }, + data: updateData, + }); - await auditLog("moodEntry.update", { - userId: user.id, - ipAddress: getClientIp(request), - details: { moodEntryId: id }, - }); + await auditLog("moodEntry.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: { moodEntryId: id }, + }); - annotate({ action: { name: "mood-entries.update" }, meta: { moodEntryId: id } }); + annotate({ + action: { name: "mood-entries.update" }, + meta: { moodEntryId: id }, + }); - return apiSuccess({ ...entry, tags: parseTags(entry.tags) }); -}); + return apiSuccess({ ...entry, tags: parseTags(entry.tags) }); + }, +); -export const DELETE = apiHandler(async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); +export const DELETE = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); - const { id } = await params; + const { id } = await params; - const existing = await prisma.moodEntry.findUnique({ where: { id } }); + const existing = await prisma.moodEntry.findUnique({ where: { id } }); - if (!existing || existing.userId !== user.id) { - return apiError("Mood entry not found", 404); - } + if (!existing || existing.userId !== user.id) { + return apiError("Mood entry not found", 404); + } - await prisma.moodEntry.delete({ where: { id } }); + await prisma.moodEntry.delete({ where: { id } }); - await auditLog("moodEntry.delete", { - userId: user.id, - ipAddress: getClientIp(request), - details: { moodEntryId: id, mood: existing.mood }, - }); + await auditLog("moodEntry.delete", { + userId: user.id, + ipAddress: getClientIp(request), + details: { moodEntryId: id, mood: existing.mood }, + }); - annotate({ action: { name: "mood-entries.delete" }, meta: { moodEntryId: id } }); + annotate({ + action: { name: "mood-entries.delete" }, + meta: { moodEntryId: id }, + }); - return apiSuccess({ deleted: true }); -}); + return apiSuccess({ deleted: true }); + }, +); diff --git a/src/app/api/mood/analytics/route.ts b/src/app/api/mood/analytics/route.ts index 85cd975ad..6be9db8f2 100644 --- a/src/app/api/mood/analytics/route.ts +++ b/src/app/api/mood/analytics/route.ts @@ -47,7 +47,10 @@ export const GET = apiHandler(async () => { const summary = summarize(dataPoints); - annotate({ action: { name: "mood.analytics" }, meta: { entryCount: moodEntries.length } }); + annotate({ + action: { name: "mood.analytics" }, + meta: { entryCount: moodEntries.length }, + }); return apiSuccess({ entries, summary }); }); diff --git a/src/app/api/settings/account/route.ts b/src/app/api/settings/account/route.ts index e5073018b..7a9ef3213 100644 --- a/src/app/api/settings/account/route.ts +++ b/src/app/api/settings/account/route.ts @@ -34,10 +34,7 @@ export const DELETE = apiHandler(async (request: NextRequest) => { if (user.role === "ADMIN") { const adminCount = await prisma.user.count({ where: { role: "ADMIN" } }); if (adminCount <= 1) { - return apiError( - "The last admin account cannot be deleted", - 400, - ); + return apiError("The last admin account cannot be deleted", 400); } } diff --git a/src/app/api/settings/moodlog/route.ts b/src/app/api/settings/moodlog/route.ts index fb6172553..97b2dd564 100644 --- a/src/app/api/settings/moodlog/route.ts +++ b/src/app/api/settings/moodlog/route.ts @@ -3,10 +3,7 @@ import { randomBytes } from "node:crypto"; import { prisma } from "@/lib/db"; import { apiSuccess, apiError } from "@/lib/api-response"; import { encrypt } from "@/lib/crypto"; -import { - encryptMoodLogSecret, - readMoodLogSecret, -} from "@/lib/moodlog-secret"; +import { encryptMoodLogSecret, readMoodLogSecret } from "@/lib/moodlog-secret"; import { moodLogCredentialsSchema } from "@/lib/validations/moodlog"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; diff --git a/src/app/api/settings/ntfy/test/route.ts b/src/app/api/settings/ntfy/test/route.ts index faf64a16b..d09aa48f5 100644 --- a/src/app/api/settings/ntfy/test/route.ts +++ b/src/app/api/settings/ntfy/test/route.ts @@ -38,8 +38,7 @@ export const POST = apiHandler(async () => { eventType: "SYSTEM_ALERT", userId: user.id, title: "HealthLog Test", - message: - "HealthLog: Connection successful! ntfy notifications are active.", + message: "HealthLog: Connection successful! ntfy notifications are active.", }); if (!success) { diff --git a/src/app/api/settings/telegram/route.ts b/src/app/api/settings/telegram/route.ts index f8295ae69..ec99a1222 100644 --- a/src/app/api/settings/telegram/route.ts +++ b/src/app/api/settings/telegram/route.ts @@ -91,10 +91,7 @@ export const PUT = apiHandler(async (request: NextRequest) => { try { appBaseUrl = new URL(appUrl); } catch { - return apiError( - "Server configuration error: APP_URL is invalid.", - 500, - ); + return apiError("Server configuration error: APP_URL is invalid.", 500); } if (appBaseUrl.protocol !== "https:") { diff --git a/src/app/api/settings/telegram/test/route.ts b/src/app/api/settings/telegram/test/route.ts index ed04db345..4fc9bafc7 100644 --- a/src/app/api/settings/telegram/test/route.ts +++ b/src/app/api/settings/telegram/test/route.ts @@ -23,10 +23,7 @@ export const POST = apiHandler(async () => { }); if (!dbUser?.telegramBotToken || !dbUser?.telegramChatId) { - return apiError( - "Bot token and chat ID must be saved first", - 422, - ); + return apiError("Bot token and chat ID must be saved first", 422); } const botToken = decrypt(dbUser.telegramBotToken); @@ -43,7 +40,10 @@ export const POST = apiHandler(async () => { ); } - annotate({ action: { name: "settings.telegram.test" }, meta: { success: true } }); + annotate({ + action: { name: "settings.telegram.test" }, + meta: { success: true }, + }); return apiSuccess({ sent: true }); }); diff --git a/src/app/api/telegram/webhook/route.ts b/src/app/api/telegram/webhook/route.ts index 4de2101c5..ad38d209f 100644 --- a/src/app/api/telegram/webhook/route.ts +++ b/src/app/api/telegram/webhook/route.ts @@ -244,7 +244,9 @@ async function handleCallback(update: TelegramUpdate) { }); const duration = - minutes <= 60 ? t("telegram.snoozeOneHour") : t("telegram.snoozeThreeHours"); + minutes <= 60 + ? t("telegram.snoozeOneHour") + : t("telegram.snoozeThreeHours"); await answerTelegramCallbackQuery( botToken, callback.id, @@ -350,7 +352,8 @@ async function handleCallback(update: TelegramUpdate) { // Format: "add:{medicationId}" or "add:{medicationId}:umid:{userMsgId}" const withoutPrefix = data.slice("add:".length); const umidIdx = withoutPrefix.indexOf(":umid:"); - const medicationId = umidIdx >= 0 ? withoutPrefix.slice(0, umidIdx) : withoutPrefix.trim(); + const medicationId = + umidIdx >= 0 ? withoutPrefix.slice(0, umidIdx) : withoutPrefix.trim(); if (!medicationId) { await answerTelegramCallbackQuery( botToken, @@ -395,7 +398,11 @@ async function handleCallback(update: TelegramUpdate) { await deleteMessage(botToken, chatId, userMsgId).catch(() => {}); } } - await answerTelegramCallbackQuery(botToken, callback.id, t("telegram.cancelled")); + await answerTelegramCallbackQuery( + botToken, + callback.id, + t("telegram.cancelled"), + ); } else { await answerTelegramCallbackQuery( botToken, diff --git a/src/app/api/tokens/[id]/route.ts b/src/app/api/tokens/[id]/route.ts index c94dc40ed..ef19a4348 100644 --- a/src/app/api/tokens/[id]/route.ts +++ b/src/app/api/tokens/[id]/route.ts @@ -9,25 +9,27 @@ type RouteParams = { params: Promise<{ id: string }> }; /** * Revoke an API token. */ -export const DELETE = apiHandler(async (_request: Request, { params }: RouteParams) => { - const { user } = await requireAuth(); - annotate({ action: { name: "tokens.revoke" } }); +export const DELETE = apiHandler( + async (_request: Request, { params }: RouteParams) => { + const { user } = await requireAuth(); + annotate({ action: { name: "tokens.revoke" } }); - if (!(await isApiGloballyEnabled())) { - return apiError("API is globally disabled", 403); - } + if (!(await isApiGloballyEnabled())) { + return apiError("API is globally disabled", 403); + } - const { id } = await params; - const token = await prisma.apiToken.findUnique({ where: { id } }); + const { id } = await params; + const token = await prisma.apiToken.findUnique({ where: { id } }); - if (!token || token.userId !== user.id) { - return apiError("Token not found", 404); - } + if (!token || token.userId !== user.id) { + return apiError("Token not found", 404); + } - await prisma.apiToken.update({ - where: { id }, - data: { revoked: true }, - }); + await prisma.apiToken.update({ + where: { id }, + data: { revoked: true }, + }); - return apiSuccess({ revoked: true }); -}); + return apiSuccess({ revoked: true }); + }, +); diff --git a/src/app/api/user/ai-provider/route.ts b/src/app/api/user/ai-provider/route.ts index fd5a31444..cbd63f9a5 100644 --- a/src/app/api/user/ai-provider/route.ts +++ b/src/app/api/user/ai-provider/route.ts @@ -46,7 +46,8 @@ export const PATCH = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); annotate({ action: { name: "user.ai-provider.update" } }); - const { data: body, error } = await safeJson>(request); + const { data: body, error } = + await safeJson>(request); if (error) return error; const updates: Record = {}; @@ -54,7 +55,10 @@ export const PATCH = apiHandler(async (request: NextRequest) => { if (body.provider !== undefined) { if (body.provider === null || body.provider === "") { updates.aiProvider = null; - } else if (typeof body.provider === "string" && ALLOWED.has(body.provider)) { + } else if ( + typeof body.provider === "string" && + ALLOWED.has(body.provider) + ) { updates.aiProvider = body.provider; } else { throw new HttpError(422, "Invalid provider"); diff --git a/src/app/api/user/profile/__tests__/route.test.ts b/src/app/api/user/profile/__tests__/route.test.ts index af1d9078d..5c68f57f8 100644 --- a/src/app/api/user/profile/__tests__/route.test.ts +++ b/src/app/api/user/profile/__tests__/route.test.ts @@ -21,7 +21,11 @@ vi.mock("@/lib/db-compat", () => ({ vi.mock("next/headers", () => ({ headers: vi.fn(async () => ({ get: () => null })), - cookies: vi.fn(async () => ({ get: () => undefined, set: () => {}, delete: () => {} })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), })); import { GET, PATCH } from "../route"; diff --git a/src/app/api/user/profile/route.ts b/src/app/api/user/profile/route.ts index 6101276b2..bed82717a 100644 --- a/src/app/api/user/profile/route.ts +++ b/src/app/api/user/profile/route.ts @@ -8,7 +8,12 @@ */ import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiError, apiSuccess, getClientIp, safeJson } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + getClientIp, + safeJson, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { applyProfileUpdate } from "@/lib/auth/profile-update"; import { prisma } from "@/lib/db"; diff --git a/src/app/api/user/thresholds/route.ts b/src/app/api/user/thresholds/route.ts index 2d422ef8a..c2a8c755a 100644 --- a/src/app/api/user/thresholds/route.ts +++ b/src/app/api/user/thresholds/route.ts @@ -8,7 +8,12 @@ * DELETE with ?metric=... resets one metric to default. */ import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiSuccess, apiError, safeJson, getClientIp } from "@/lib/api-response"; +import { + apiSuccess, + apiError, + safeJson, + getClientIp, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; @@ -36,9 +41,8 @@ async function loadProfileAndOverrides(userId: string) { }, }); if (!profile) return null; - const overrides = (profile.thresholdsJson ?? null) as - | ThresholdOverridesJson - | null; + const overrides = (profile.thresholdsJson ?? + null) as ThresholdOverridesJson | null; return { profile, overrides }; } @@ -130,8 +134,7 @@ export const DELETE = apiHandler(async (request: NextRequest) => { await prisma.user.update({ where: { id: user.id }, data: { - thresholdsJson: - Object.keys(after).length === 0 ? Prisma.JsonNull : after, + thresholdsJson: Object.keys(after).length === 0 ? Prisma.JsonNull : after, }, }); diff --git a/src/app/api/withings/status/route.ts b/src/app/api/withings/status/route.ts index 9d6397c32..926945044 100644 --- a/src/app/api/withings/status/route.ts +++ b/src/app/api/withings/status/route.ts @@ -23,7 +23,8 @@ export const GET = apiHandler(async () => { }); const configured = - !!dbUser?.withingsClientIdEncrypted && !!dbUser?.withingsClientSecretEncrypted; + !!dbUser?.withingsClientIdEncrypted && + !!dbUser?.withingsClientSecretEncrypted; const connection = await prisma.withingsConnection.findUnique({ where: { userId: user.id }, diff --git a/src/app/api/withings/webhook/route.ts b/src/app/api/withings/webhook/route.ts index 15b5dcbf8..9f2960c6e 100644 --- a/src/app/api/withings/webhook/route.ts +++ b/src/app/api/withings/webhook/route.ts @@ -72,7 +72,9 @@ export const POST = apiHandler(async (request: NextRequest) => { const formData = await request.formData(); withingsUserId = formData.get("userid") as string; } else { - const { data: body, error: jsonError } = await safeJson<{ userid?: string | number }>(request); + const { data: body, error: jsonError } = await safeJson<{ + userid?: string | number; + }>(request); if (jsonError) return jsonError; withingsUserId = body.userid?.toString() ?? null; } @@ -89,13 +91,17 @@ export const POST = apiHandler(async (request: NextRequest) => { }); if (!connection) { - getEvent()?.addWarning("Webhook for unknown withings user: " + withingsUserId); + getEvent()?.addWarning( + "Webhook for unknown withings user: " + withingsUserId, + ); return NextResponse.json({ status: "unknown_user" }, { status: 200 }); } // Sync measurements (non-blocking response for Withings) syncUserMeasurements(connection.userId).catch((err) => { - getEvent()?.addWarning("Sync failed for user " + connection.userId + ": " + err); + getEvent()?.addWarning( + "Sync failed for user " + connection.userId + ": " + err, + ); }); return NextResponse.json({ status: "ok" }, { status: 200 }); diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index 03625fd61..befa9f494 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -62,9 +62,7 @@ export default function LoginPage() { } const { options, challengeId } = optJson.data; - const { startAuthentication } = await import( - "@simplewebauthn/browser" - ); + const { startAuthentication } = await import("@simplewebauthn/browser"); const credential = await startAuthentication({ optionsJSON: options }); const verifyRes = await fetch("/api/auth/passkey/login-verify", { diff --git a/src/app/auth/register/page.tsx b/src/app/auth/register/page.tsx index 6b1404426..7f185ece0 100644 --- a/src/app/auth/register/page.tsx +++ b/src/app/auth/register/page.tsx @@ -114,7 +114,10 @@ export default function RegisterPage() { {error && ( -
+
{error}
)} diff --git a/src/app/bugreport/page.tsx b/src/app/bugreport/page.tsx index a62291a7a..bd76f6ddd 100644 --- a/src/app/bugreport/page.tsx +++ b/src/app/bugreport/page.tsx @@ -13,13 +13,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { - Loader2, - CheckCircle2, - Bug, - GitPullRequest, - Info, -} from "lucide-react"; +import { Loader2, CheckCircle2, Bug, GitPullRequest, Info } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -154,16 +148,16 @@ export default function BugReportPage() {
{status?.configured && ( -
- +
+

{t("bugreport.githubEscalationNote")}

)} {!status?.configured && ( -
- +
+

{t("bugreport.internalOnlyNote")}

diff --git a/src/app/global-error.tsx b/src/app/global-error.tsx index f223ff6de..3852ce934 100644 --- a/src/app/global-error.tsx +++ b/src/app/global-error.tsx @@ -14,16 +14,14 @@ export default function GlobalError({ }) { async function handleCopy() { // Strip query string — OAuth callback routes carry sensitive tokens. - const loc = - typeof window !== "undefined" ? window.location : null; + const loc = typeof window !== "undefined" ? window.location : null; const payload = { message: error.message, digest: error.digest, name: error.name, stack: error.stack?.split("\n").slice(0, 10).join("\n"), urlPath: loc?.pathname ?? null, - userAgent: - typeof navigator !== "undefined" ? navigator.userAgent : null, + userAgent: typeof navigator !== "undefined" ? navigator.userAgent : null, timestamp: new Date().toISOString(), }; const text = JSON.stringify(payload, null, 2); diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index a5ef009e8..979ad9b9c 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -427,9 +427,10 @@ function getBmiSectionStatus(input: { }; } -function getMoodSectionStatus(input: { - avg30: number | null | undefined; -}): { level: "good" | "watch" | "critical"; className: string } { +function getMoodSectionStatus(input: { avg30: number | null | undefined }): { + level: "good" | "watch" | "critical"; + className: string; +} { if (input.avg30 == null) { return { level: "watch", @@ -463,7 +464,10 @@ function getMedicationComplianceSectionStatus(input: { }): { level: "good" | "watch" | "critical"; className: string } { const MIN_EVENTS_FOR_ASSESSMENT = 14; - if (input.average30 == null || input.totalEvents < MIN_EVENTS_FOR_ASSESSMENT) { + if ( + input.average30 == null || + input.totalEvents < MIN_EVENTS_FOR_ASSESSMENT + ) { return { level: "watch", className: "border-yellow-500/30 bg-yellow-500/15 text-yellow-300", @@ -524,10 +528,7 @@ export default function InsightsPage() { enabled: isAuthenticated, }); - const { - data: generalStatus, - isLoading: isGeneralStatusLoading, - } = useQuery({ + const { data: generalStatus, isLoading: isGeneralStatusLoading } = useQuery({ queryKey: ["insights", "general-status", locale], queryFn: async () => { const res = await fetch(`/api/insights/general-status?locale=${locale}`); @@ -539,27 +540,22 @@ export default function InsightsPage() { staleTime: 60 * 1000, }); - const { - data: bloodPressureStatus, - isLoading: isBloodPressureStatusLoading, - } = useQuery({ - queryKey: ["insights", "blood-pressure-status", locale], - queryFn: async () => { - const res = await fetch( - `/api/insights/blood-pressure-status?locale=${locale}`, - ); - if (!res.ok) throw new Error("Failed"); - const json = await res.json(); - return json.data as BloodPressureStatusData; - }, - enabled: isAuthenticated, - staleTime: 60 * 1000, - }); + const { data: bloodPressureStatus, isLoading: isBloodPressureStatusLoading } = + useQuery({ + queryKey: ["insights", "blood-pressure-status", locale], + queryFn: async () => { + const res = await fetch( + `/api/insights/blood-pressure-status?locale=${locale}`, + ); + if (!res.ok) throw new Error("Failed"); + const json = await res.json(); + return json.data as BloodPressureStatusData; + }, + enabled: isAuthenticated, + staleTime: 60 * 1000, + }); - const { - data: weightStatus, - isLoading: isWeightStatusLoading, - } = useQuery({ + const { data: weightStatus, isLoading: isWeightStatusLoading } = useQuery({ queryKey: ["insights", "weight-status", locale], queryFn: async () => { const res = await fetch(`/api/insights/weight-status?locale=${locale}`); @@ -571,10 +567,7 @@ export default function InsightsPage() { staleTime: 60 * 1000, }); - const { - data: pulseStatus, - isLoading: isPulseStatusLoading, - } = useQuery({ + const { data: pulseStatus, isLoading: isPulseStatusLoading } = useQuery({ queryKey: ["insights", "pulse-status", locale], queryFn: async () => { const res = await fetch(`/api/insights/pulse-status?locale=${locale}`); @@ -586,10 +579,7 @@ export default function InsightsPage() { staleTime: 60 * 1000, }); - const { - data: bmiStatus, - isLoading: isBmiStatusLoading, - } = useQuery({ + const { data: bmiStatus, isLoading: isBmiStatusLoading } = useQuery({ queryKey: ["insights", "bmi-status", locale], queryFn: async () => { const res = await fetch(`/api/insights/bmi-status?locale=${locale}`); @@ -601,10 +591,7 @@ export default function InsightsPage() { staleTime: 60 * 1000, }); - const { - data: moodStatus, - isLoading: isMoodStatusLoading, - } = useQuery({ + const { data: moodStatus, isLoading: isMoodStatusLoading } = useQuery({ queryKey: ["insights", "mood-status", locale], queryFn: async () => { const res = await fetch(`/api/insights/mood-status?locale=${locale}`); @@ -883,8 +870,18 @@ export default function InsightsPage() { avg30ColorClass={getRangeColorClass(p?.avg30, { range: pulseDisplayRange, })} - avg7Hint={getRangeHint("bpm", { range: pulseDisplayRange }, t, fmt.number)} - avg30Hint={getRangeHint("bpm", { range: pulseDisplayRange }, t, fmt.number)} + avg7Hint={getRangeHint( + "bpm", + { range: pulseDisplayRange }, + t, + fmt.number, + )} + avg30Hint={getRangeHint( + "bpm", + { range: pulseDisplayRange }, + t, + fmt.number, + )} slope30={p?.slope30 ?? null} icon={TrendingUp} /> @@ -1153,7 +1150,9 @@ export default function InsightsPage() {

{t("insights.notEnoughMoodCorrelationData")}

-

{t("insights.minMoodCorrelationData")}

+

+ {t("insights.minMoodCorrelationData")} +

)} @@ -1419,7 +1418,9 @@ export default function InsightsPage() {

{t("insights.notEnoughMoodCorrelationData")}

-

{t("insights.minMoodCorrelationData")}

+

+ {t("insights.minMoodCorrelationData")} +

)} diff --git a/src/app/page.tsx b/src/app/page.tsx index 56b83c90c..ca7a65257 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -894,7 +894,7 @@ export default function DashboardPage() { // scrolling — Marc tested both and prefers the v1.3-era // wrap behaviour over the one-row scroll for the symmetry // it preserves. - className="grid auto-rows-fr gap-3 [grid-template-columns:repeat(auto-fit,minmax(9rem,1fr))] pb-2" + className="grid auto-rows-fr [grid-template-columns:repeat(auto-fit,minmax(9rem,1fr))] gap-3 pb-2" data-slot="dashboard-tile-strip" data-tile-count={trendCards.length} > diff --git a/src/components/admin/feedback-inbox-section.tsx b/src/components/admin/feedback-inbox-section.tsx index a72c7c6eb..42a8d52c4 100644 --- a/src/components/admin/feedback-inbox-section.tsx +++ b/src/components/admin/feedback-inbox-section.tsx @@ -206,8 +206,7 @@ function FeedbackCategoryBadge({ > = { BUG: { label: t("admin.feedback.categoryBug"), - className: - "bg-dracula-red/15 text-dracula-red border-dracula-red/30", + className: "bg-dracula-red/15 text-dracula-red border-dracula-red/30", }, FEATURE_REQUEST: { label: t("admin.feedback.categoryFeature"), @@ -216,8 +215,7 @@ function FeedbackCategoryBadge({ }, QUESTION: { label: t("admin.feedback.categoryQuestion"), - className: - "bg-dracula-cyan/15 text-dracula-cyan border-dracula-cyan/30", + className: "bg-dracula-cyan/15 text-dracula-cyan border-dracula-cyan/30", }, OTHER: { label: t("admin.feedback.categoryOther"), diff --git a/src/components/charts/compliance-heatmap.tsx b/src/components/charts/compliance-heatmap.tsx index abd2d5796..25e8b9337 100644 --- a/src/components/charts/compliance-heatmap.tsx +++ b/src/components/charts/compliance-heatmap.tsx @@ -76,7 +76,13 @@ export function ComplianceHeatmap({ } | null>(null); const WEEKDAY_LABELS = [ - t("charts.weekdays.mon"), "", t("charts.weekdays.wed"), "", t("charts.weekdays.fri"), "", t("charts.weekdays.sun"), + t("charts.weekdays.mon"), + "", + t("charts.weekdays.wed"), + "", + t("charts.weekdays.fri"), + "", + t("charts.weekdays.sun"), ]; useEffect(() => { if (!stretch) return; @@ -98,10 +104,18 @@ export function ComplianceHeatmap({ const { cells, weeks, monthMarkers } = useMemo(() => { const MONTH_LABELS = [ - t("charts.months.jan"), t("charts.months.feb"), t("charts.months.mar"), - t("charts.months.apr"), t("charts.months.may"), t("charts.months.jun"), - t("charts.months.jul"), t("charts.months.aug"), t("charts.months.sep"), - t("charts.months.oct"), t("charts.months.nov"), t("charts.months.dec"), + t("charts.months.jan"), + t("charts.months.feb"), + t("charts.months.mar"), + t("charts.months.apr"), + t("charts.months.may"), + t("charts.months.jun"), + t("charts.months.jul"), + t("charts.months.aug"), + t("charts.months.sep"), + t("charts.months.oct"), + t("charts.months.nov"), + t("charts.months.dec"), ]; const now = new Date(); const cellList: Array<{ diff --git a/src/components/charts/mood-chart.tsx b/src/components/charts/mood-chart.tsx index 295eaaeaa..df53de478 100644 --- a/src/components/charts/mood-chart.tsx +++ b/src/components/charts/mood-chart.tsx @@ -17,12 +17,7 @@ import { useState, useMemo, useId } from "react"; import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; -import { - Card, - CardHeader, - CardTitle, - CardContent, -} from "@/components/ui/card"; +import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; import { formatDateShort } from "@/lib/format"; import { useTranslations } from "@/lib/i18n/context"; import type { DataSummary } from "@/lib/analytics/trends"; @@ -50,10 +45,26 @@ interface MoodChartProps { // --- Constants --- const TIME_RANGES_KEYS = [ - { labelKey: "charts.points7Label", points: 7, titleKey: "charts.points7Title" }, - { labelKey: "charts.points30Label", points: 30, titleKey: "charts.points30Title" }, - { labelKey: "charts.points90Label", points: 90, titleKey: "charts.points90Title" }, - { labelKey: "charts.pointsAllLabel", points: 0, titleKey: "charts.pointsAllTitle" }, + { + labelKey: "charts.points7Label", + points: 7, + titleKey: "charts.points7Title", + }, + { + labelKey: "charts.points30Label", + points: 30, + titleKey: "charts.points30Title", + }, + { + labelKey: "charts.points90Label", + points: 90, + titleKey: "charts.points90Title", + }, + { + labelKey: "charts.pointsAllLabel", + points: 0, + titleKey: "charts.pointsAllTitle", + }, ] as const; // MOOD_LABELS built dynamically via t() in the component @@ -95,9 +106,10 @@ function movingAverageByPoints( }); } -function buildTrendLine( - data: Array<{ date: Date; value: number }>, -): { points: Array<{ date: Date; value: number }>; slopePerDay: number } | null { +function buildTrendLine(data: Array<{ date: Date; value: number }>): { + points: Array<{ date: Date; value: number }>; + slopePerDay: number; +} | null { if (data.length < 2) return null; const sorted = [...data].sort((a, b) => a.date.getTime() - b.date.getTime()); @@ -188,9 +200,7 @@ export function MoodChart({ title }: MoodChartProps) { const ma = movingAverageByPoints(scoreData, 7); for (const point of ma) { const pointTimestamp = point.date.getTime(); - const existing = enriched.find( - (d) => d.timestamp === pointTimestamp, - ); + const existing = enriched.find((d) => d.timestamp === pointTimestamp); if (existing) { existing.ma = point.value; } diff --git a/src/components/charts/trend-card.tsx b/src/components/charts/trend-card.tsx index 6f1bf81ea..c8d3e1930 100644 --- a/src/components/charts/trend-card.tsx +++ b/src/components/charts/trend-card.tsx @@ -88,9 +88,7 @@ export function TrendCard({
- {latest !== null - ? renderPair(latest, secondary?.latest) - : "—"} + {latest !== null ? renderPair(latest, secondary?.latest) : "—"} {unit} @@ -115,9 +113,7 @@ export function TrendCard({ ) : ( - + {avg7 !== null ? renderPair(avg7, secondary?.avg7) : "—"} )} @@ -138,9 +134,7 @@ export function TrendCard({ ) : ( - + {avg30 !== null ? renderPair(avg30, secondary?.avg30) : "—"} )} diff --git a/src/components/error-details.tsx b/src/components/error-details.tsx index c13a0c284..47d4409d7 100644 --- a/src/components/error-details.tsx +++ b/src/components/error-details.tsx @@ -39,8 +39,7 @@ export function ErrorDetails({ // in the query string (Withings connect, password reset) and we don't // want users pasting those into public GitHub issues. Preserve the count // for context without exposing values. - const loc = - typeof window !== "undefined" ? window.location : null; + const loc = typeof window !== "undefined" ? window.location : null; const urlPath = loc?.pathname ?? null; const searchParamCount = loc?.search ? new URLSearchParams(loc.search).toString().split("&").filter(Boolean) @@ -55,8 +54,7 @@ export function ErrorDetails({ requestId: requestId ?? null, urlPath, searchParamCount, - userAgent: - typeof navigator !== "undefined" ? navigator.userAgent : null, + userAgent: typeof navigator !== "undefined" ? navigator.userAgent : null, locale, timestamp: new Date().toISOString(), context, @@ -79,7 +77,7 @@ export function ErrorDetails({

{t("errorBoundary.description")}

-
+
{error.message || t("common.unknownError")}
@@ -91,7 +89,7 @@ export function ErrorDetails({ )}
-

+

{t("insights.noProviderConfigured")}

@@ -70,7 +70,7 @@ export function InsightStatusCard({
-

+

{t("insights.noAnalysisYet")}

@@ -79,7 +79,7 @@ export function InsightStatusCard({ } return ( - +
@@ -87,14 +87,14 @@ export function InsightStatusCard({ {title}
{cached && ( - + {t("insights.cached")} )}
-

{text}

+

{text}

@@ -106,7 +106,7 @@ function LastUpdatedFooter({ updatedAt }: { updatedAt: string | null }) { const fmt = useFormatters(); if (!updatedAt) return null; return ( -

+

{t("insights.lastUpdated")}: {fmt.dateTime(updatedAt)}

); diff --git a/src/components/insights/insights-card.tsx b/src/components/insights/insights-card.tsx index 5869f7256..6ec818280 100644 --- a/src/components/insights/insights-card.tsx +++ b/src/components/insights/insights-card.tsx @@ -74,7 +74,8 @@ export function InsightsCard() { }); // Don't show if no API key configured - if (!(settings?.codexStatus === "connected" || settings?.hasAdminKey)) return null; + if (!(settings?.codexStatus === "connected" || settings?.hasAdminKey)) + return null; const insights = generate.data?.insights; @@ -84,7 +85,9 @@ export function InsightsCard() {
- {t("insights.aiInsights")} + + {t("insights.aiInsights")} +
@@ -123,7 +128,8 @@ export function InsightsCard() {
- {t("insights.confidence")}: {t(`insights.confidence_${insights.confidence}`)} + {t("insights.confidence")}:{" "} + {t(`insights.confidence_${insights.confidence}`)} {generate.data?.cached && ( @@ -132,10 +138,22 @@ export function InsightsCard() { )}
-
-
-
-
+
+
+
+

diff --git a/src/components/layout/auth-shell.tsx b/src/components/layout/auth-shell.tsx index a4573d713..b3228fbf2 100644 --- a/src/components/layout/auth-shell.tsx +++ b/src/components/layout/auth-shell.tsx @@ -116,7 +116,7 @@ export function AuthShell({ children }: { children: React.ReactNode }) { ) : null} {t("nav.skipToContent")} diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx index 142b42301..2cba40824 100644 --- a/src/components/measurements/measurement-list.tsx +++ b/src/components/measurements/measurement-list.tsx @@ -545,7 +545,11 @@ export function MeasurementList({ onEdit }: MeasurementListProps) {

{editError && ( -
+
{editError}
)} diff --git a/src/components/medications/intake-history-list.tsx b/src/components/medications/intake-history-list.tsx index 1760baf14..3ad8a950a 100644 --- a/src/components/medications/intake-history-list.tsx +++ b/src/components/medications/intake-history-list.tsx @@ -88,7 +88,11 @@ function dateTimeLocalToISO(value: string): string { return new Date(value).toISOString(); } -export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange }: IntakeHistoryListProps) { +export function IntakeHistoryList({ + medicationId, + createOpen, + onCreateOpenChange, +}: IntakeHistoryListProps) { const { isAuthenticated } = useAuth(); const queryClient = useQueryClient(); const { t } = useTranslations(); @@ -120,16 +124,19 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange const [createSkipped, setCreateSkipped] = useState(false); const [createError, setCreateError] = useState(null); const setCreatingRaw = onCreateOpenChange ?? setInternalCreating; - const setCreating = useCallback((open: boolean) => { - if (open) { - const now = toDateTimeLocalValue(new Date().toISOString()); - setCreateScheduledFor(now); - setCreateTakenAt(now); - setCreateSkipped(false); - setCreateError(null); - } - setCreatingRaw(open); - }, [setCreatingRaw]); + const setCreating = useCallback( + (open: boolean) => { + if (open) { + const now = toDateTimeLocalValue(new Date().toISOString()); + setCreateScheduledFor(now); + setCreateTakenAt(now); + setCreateSkipped(false); + setCreateError(null); + } + setCreatingRaw(open); + }, + [setCreatingRaw], + ); function toggleSort(column: string) { if (sortBy === column) { @@ -142,14 +149,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange } const { data, isLoading } = useQuery({ - queryKey: [ - "medications", - medicationId, - "intake", - page, - sortBy, - sortDir, - ], + queryKey: ["medications", medicationId, "intake", page, sortBy, sortDir], queryFn: async () => { const params = new URLSearchParams(); params.set("limit", String(PAGE_SIZE)); @@ -227,14 +227,11 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange takenAt: string | null; skipped: boolean; }) => { - const res = await fetch( - `/api/medications/${medicationId}/intake`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ scheduledFor, takenAt, skipped }), - }, - ); + const res = await fetch(`/api/medications/${medicationId}/intake`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ scheduledFor, takenAt, skipped }), + }); const json = await res.json(); if (!res.ok) throw new Error(json.error ?? "Create failed"); }, @@ -267,7 +264,6 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange setEditDeleteDialogOpen(false); } - function closeCreate() { if (createMutation.isPending) return; setCreating(false); @@ -299,9 +295,8 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange updateMutation.mutate({ eventId: editing.id, scheduledFor: dateTimeLocalToISO(editScheduledFor), - takenAt: editSkipped || !editTakenAt - ? null - : dateTimeLocalToISO(editTakenAt), + takenAt: + editSkipped || !editTakenAt ? null : dateTimeLocalToISO(editTakenAt), skipped: editSkipped, }); } @@ -318,9 +313,10 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange setCreateError(null); createMutation.mutate({ scheduledFor: dateTimeLocalToISO(createScheduledFor), - takenAt: createSkipped || !createTakenAt - ? null - : dateTimeLocalToISO(createTakenAt), + takenAt: + createSkipped || !createTakenAt + ? null + : dateTimeLocalToISO(createTakenAt), skipped: createSkipped, }); } @@ -336,7 +332,10 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange } if (event.takenAt) { return ( - + {t("medications.intakeStatusTaken")} @@ -398,7 +397,9 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange currentDir={sortDir} onSort={toggleSort} /> - {t("medications.intakeStatus")} + + {t("medications.intakeStatus")} + - {event.takenAt - ? formatDateTime(event.takenAt) - : "—"} + {event.takenAt ? formatDateTime(event.takenAt) : "—"} {getStatusBadge(event)} @@ -442,7 +441,9 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange deleteMutation.mutate(event.id)} />
@@ -467,7 +468,8 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange

{event.takenAt && (

- {t("medications.intakeTakenAt")}: {formatDateTime(event.takenAt)} + {t("medications.intakeTakenAt")}:{" "} + {formatDateTime(event.takenAt)}

)}
@@ -550,7 +552,9 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
- + {editError && ( -
+
{editError}
)} @@ -688,7 +696,9 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
- + {createError && ( -
+
{createError}
)} diff --git a/src/components/medications/medication-form.tsx b/src/components/medications/medication-form.tsx index 46fa10a52..91417ed8b 100644 --- a/src/components/medications/medication-form.tsx +++ b/src/components/medications/medication-form.tsx @@ -238,12 +238,15 @@ export function MedicationForm({ const fmt = useFormatters(); const doseUnits = [ - "mg", "g", "ml", + "mg", + "g", + "ml", t("medications.unitDrops"), t("medications.unitTablets"), t("medications.unitCapsules"), t("medications.unitPieces"), - "IE", "µg", + "IE", + "µg", ]; const [name, setName] = useState(initial?.name ?? ""); @@ -739,7 +742,11 @@ export function MedicationForm({
{error && ( -
+
{error}
)} diff --git a/src/components/mood/mood-form.tsx b/src/components/mood/mood-form.tsx index 6523dd991..eb39bfcdb 100644 --- a/src/components/mood/mood-form.tsx +++ b/src/components/mood/mood-form.tsx @@ -162,7 +162,11 @@ export function MoodForm({ onSuccess, onCancel }: MoodFormProps) {
{error && ( -
+
{error}
)} diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx index a900a5911..bb72543a8 100644 --- a/src/components/mood/mood-list.tsx +++ b/src/components/mood/mood-list.tsx @@ -76,7 +76,6 @@ const MOOD_LABEL_KEYS: Record = { LAUSIG: "mood.levelLausig", }; - interface MoodEntry { id: string; date: string; @@ -342,7 +341,11 @@ export function MoodList() { {entry.score}{" "} - ({MOOD_LABEL_KEYS[entry.mood] ? t(MOOD_LABEL_KEYS[entry.mood]) : entry.mood}) + ( + {MOOD_LABEL_KEYS[entry.mood] + ? t(MOOD_LABEL_KEYS[entry.mood]) + : entry.mood} + ) @@ -388,7 +391,7 @@ export function MoodList() { className="bg-card border-border flex items-center justify-between rounded-lg border p-3" >
-
+
{entry.score} @@ -397,7 +400,11 @@ export function MoodList() { {entry.score}{" "} - ({MOOD_LABEL_KEYS[entry.mood] ? t(MOOD_LABEL_KEYS[entry.mood]) : entry.mood}) + ( + {MOOD_LABEL_KEYS[entry.mood] + ? t(MOOD_LABEL_KEYS[entry.mood]) + : entry.mood} + )

@@ -471,7 +478,11 @@ export function MoodList() {

-
+
{MOOD_LEVELS_LIST.map((level) => { const isSelected = editMood === level; return ( @@ -530,7 +541,11 @@ export function MoodList() {
{editError && ( -
+
{editError}
)} diff --git a/src/components/settings/about-section.tsx b/src/components/settings/about-section.tsx index f14d7d9ba..794c9384f 100644 --- a/src/components/settings/about-section.tsx +++ b/src/components/settings/about-section.tsx @@ -127,6 +127,10 @@ export function AboutSection() { // Auto-check on mount when the last successful check is older than the // refresh interval (or has never run). Keeps the UI populated without // requiring a click — but won't spam GitHub on every navigation. + // The setState inside runCheck() is intentional: the check is the + // whole point of the effect, and the staleness gate prevents a + // cascading-render loop because the timestamp resets after the run. + /* eslint-disable react-hooks/exhaustive-deps, react-hooks/set-state-in-effect */ useEffect(() => { if (!version) return; const last = readLastCheckedISO(); @@ -136,8 +140,8 @@ export function AboutSection() { if (stale) { void runCheck(); } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [version?.version]); + /* eslint-enable react-hooks/exhaustive-deps, react-hooks/set-state-in-effect */ return (
-
+
{t("settings.about.version")}
v{version.version}
-
+
{t("settings.about.license")}
{version.license}
@@ -191,7 +195,7 @@ export function AboutSection() { {version.buildSha && (
-
+
{t("settings.about.gitSha")}
{version.buildSha.slice(0, 7)}
@@ -200,7 +204,7 @@ export function AboutSection() { {version.builtAt && (
-
+
{t("settings.about.builtAt", { time: "" }).trim() || t("settings.about.gitSha")}
diff --git a/src/components/settings/ai-section.tsx b/src/components/settings/ai-section.tsx index e830c235a..0b6a78517 100644 --- a/src/components/settings/ai-section.tsx +++ b/src/components/settings/ai-section.tsx @@ -487,7 +487,9 @@ function UserAIProviderSubsection() { queryClient.invalidateQueries({ queryKey: ["insights"] }); }, onError: (e) => { - setSaveMsg(e instanceof Error ? e.message : t("settings.ai.errorGeneric")); + setSaveMsg( + e instanceof Error ? e.message : t("settings.ai.errorGeneric"), + ); setSaveOk(false); }, }); @@ -522,9 +524,7 @@ function UserAIProviderSubsection() { ); setTestOk(true); } catch (e) { - setTestMsg( - e instanceof Error ? e.message : t("settings.ai.testFailed"), - ); + setTestMsg(e instanceof Error ? e.message : t("settings.ai.testFailed")); setTestOk(false); } finally { setTesting(false); @@ -556,11 +556,15 @@ function UserAIProviderSubsection() { className="bg-background border-input mt-1 h-9 w-full rounded-md border px-2 text-sm" > - + - +
diff --git a/src/components/settings/thresholds-section.tsx b/src/components/settings/thresholds-section.tsx index 3b1959a63..cfb591cd4 100644 --- a/src/components/settings/thresholds-section.tsx +++ b/src/components/settings/thresholds-section.tsx @@ -3,7 +3,12 @@ import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; -import { SlidersHorizontal, RotateCcw, Loader2, AlertTriangle } from "lucide-react"; +import { + SlidersHorizontal, + RotateCcw, + Loader2, + AlertTriangle, +} from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -91,7 +96,9 @@ export function ThresholdsSection({ id }: { id: string }) { toast.success(t("thresholds.saveSuccess")); }, onError: (err) => - toast.error(err instanceof Error ? err.message : t("thresholds.saveError")), + toast.error( + err instanceof Error ? err.message : t("thresholds.saveError"), + ), }); const resetMutation = useMutation({ @@ -114,7 +121,7 @@ export function ThresholdsSection({ id }: { id: string }) { return (
@@ -138,7 +145,7 @@ export function ThresholdsSection({ id }: { id: string }) {

{isLoading || !data ? ( -
+
{t("common.loading")}
@@ -202,7 +209,7 @@ function MetricRow({ const defaultRange = effective?.default; return ( -
+

{t(METRIC_LABEL_KEYS[metric])}

@@ -212,7 +219,7 @@ function MetricRow({ : t("thresholds.unsetExplanation")}

-
+