chore(style): repo-wide prettier sweep before v1.4.6 tag

Long-overdue prettier sweep; prettier had drifted vs. checked-in code
across ~130 files (mostly long-argument line-break wrapping in TS and
table-padding in markdown). Pure formatting — no semantic changes —
and required to clear the v1.4.6 release gate's `pnpm format:check`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-09 02:27:17 +02:00
1 parent 67575180b8
commit 02b99553b9
135 files changed
+2159 -1583

No files matched your search

+12 -12
View File
@@ -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.
+11
View File
@@ -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
+3 -3
View File
@@ -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 '<', "<!DOCTYPE "`. 401/403 from the provider now map to 422,
token '<', "<!DOCTYPE "`. 401/403 from the provider now map to 422,
429 to 429, and only genuine 5xx upstream errors keep the 502 —
Cloudflare passes 4xx through untouched, so the React Query
mutation reads the JSON body and surfaces a readable message.
@@ -69,8 +69,8 @@
"Zuletzt geprüft am …" timestamp so you can tell when the answer
was confirmed.
- **About restructured into three titled cards** — HealthLog (version
+ license inline, no boxed badge), Quellen & Dokumentation, and
Updates.
- license inline, no boxed badge), Quellen & Dokumentation, and
Updates.
- **KI provider — OpenAI users can enter their own API key.** Schema
didn't have a column for it before; added it, plumbed it through
the resolver and test endpoint, surfaced the input in the Settings
+43 -32
View File
@@ -1,4 +1,5 @@
# HealthLog Audit-Plan — Phasen-Übersicht
**Datum:** 2026-04-26
**Audit-Bericht:** `~/infra/reports/healthlog-audit-2026-04-26.md`
**Status:** Geplant, noch nicht in Execution
@@ -13,19 +14,19 @@
## Phasen-Sequenz (10 Phasen)
| # | Phase | Skill | Effort | Issue-Bezug |
|---|---|---|---|---|
| **1** | Setup-Notfall + Versions-Sync | `gsd:quick` | ≤4 h | #87 (Pflaster), #88 |
| **2** | Docker-Image-Pipeline (GHCR-Push + Doc-Konsolidierung) | `gsd:plan-phase` | ~1 d | #88 |
| **3** | Issue #89 — TBW + Bone Mass hardcoded | `gsd:plan-phase` | ~1 d | #89 |
| **4** | `apiClient`-Wrapper + geteilte Zod-Schemas | `gsd:research-phase` → `gsd:plan-phase` | 2-3 d | #87 (strukturell) |
| **3.5** | **Onboarding-Redesign** (2 Steps, TTFV <60s) | `gsd:research-phase` → `gsd:plan-phase` | ~2 d | #87 (strukturell) |
| **4.5** | Empty-State-Dashboard-Tiles (eine pro Type) | `gsd:plan-phase` | ~1 d | #87, #89 |
| **4.6** | „Getting Started"-Checklist auf Dashboard | `gsd:plan-phase` | ~4 h | #87 |
| **5** | Security + Reliability Hardening | `gsd:plan-phase` | 1-2 d | — |
| **6** | Test-Coverage-Boost (Auth/Passkey/Withings/pg-boss) | `gsd:plan-phase` | 1-2 d | — |
| **7** | UX/i18n/a11y Polish (Residuals aus #73) | `gsd:quick` | ≤4 h | #73 (Residuals) |
| **8** | Settings-Page-Splitting (3143 → Sub-Components) | `gsd:research-phase` → `gsd:plan-phase` | 2-3 d | — |
| # | Phase | Skill | Effort | Issue-Bezug |
| ------- | ------------------------------------------------------ | --------------------------------------- | ------ | ------------------- |
| **1** | Setup-Notfall + Versions-Sync | `gsd:quick` | ≤4 h | #87 (Pflaster), #88 |
| **2** | Docker-Image-Pipeline (GHCR-Push + Doc-Konsolidierung) | `gsd:plan-phase` | ~1 d | #88 |
| **3** | Issue #89 — TBW + Bone Mass hardcoded | `gsd:plan-phase` | ~1 d | #89 |
| **4** | `apiClient`-Wrapper + geteilte Zod-Schemas | `gsd:research-phase` → `gsd:plan-phase` | 2-3 d | #87 (strukturell) |
| **3.5** | **Onboarding-Redesign** (2 Steps, TTFV <60s) | `gsd:research-phase` → `gsd:plan-phase` | ~2 d | #87 (strukturell) |
| **4.5** | Empty-State-Dashboard-Tiles (eine pro Type) | `gsd:plan-phase` | ~1 d | #87, #89 |
| **4.6** | „Getting Started"-Checklist auf Dashboard | `gsd:plan-phase` | ~4 h | #87 |
| **5** | Security + Reliability Hardening | `gsd:plan-phase` | 1-2 d | — |
| **6** | Test-Coverage-Boost (Auth/Passkey/Withings/pg-boss) | `gsd:plan-phase` | 1-2 d | — |
| **7** | UX/i18n/a11y Polish (Residuals aus #73) | `gsd:quick` | ≤4 h | #73 (Residuals) |
| **8** | Settings-Page-Splitting (3143 → Sub-Components) | `gsd:research-phase` → `gsd:plan-phase` | 2-3 d | — |
**Reihenfolge:** 1 → 2 → 3 → **4 (apiClient)** → **3.5 (Onboarding nutzt apiClient)** → 4.5 → 4.6 → 5 → 6 → 7 → 8.
@@ -64,6 +65,7 @@
**Goal:** User-Anfrage von Manschk3rl beantworten. Blueprint = v1.2-Glucose-Migration.
**Touchpoints:**
1. `prisma/schema.prisma`: `MeasurementType` Enum + 2 neue Werte
2. `prisma/migrations/0019_*` (additive `ALTER TYPE ... ADD VALUE`)
3. `src/lib/validations/measurement.ts`: `unitMap`, `VALUE_RANGES`
@@ -94,6 +96,7 @@
**Goal:** TTFV <60s, 90% Completion (statt aktuell ~8% global).
**Neuer Flow:**
- Step 1: Welcome + Anzeigename (optional) + Einheiten + Größe (optional). Sekundärlink „Skip everything → Dashboard".
- Step 2: Multi-Select-Chips „Was willst du tracken?" (Weight/BP/Pulse/Mood/Glucose/Sleep/Steps/BodyFat/TBW/BoneMass/Medications). Pre-checked Defaults. Speichert in `User.dashboardWidgetsJson`.
- Routing nach Onboarding: direkt zum Dashboard mit „Getting Started"-Card (s. Phase 4.6).
@@ -149,6 +152,7 @@
Coverage 35% → 60% auf kritischen Pfaden.
**Top-5-Test-Suites:**
1. Session-Validation Roundtrip (`src/lib/auth/session.ts`)
2. Passkey-Register/Verify-Integration (Attestation-Flow)
3. Notification-Dispatcher Multi-Channel (TELEGRAM/NTFY/WEB_PUSH)
@@ -162,6 +166,7 @@ Coverage 35% → 60% auf kritischen Pfaden.
## Phase 7 — UX/i18n/a11y Polish (≤4 h)
**Residuals aus Issue #73 + neue Findings:**
- I18N-002: Admin-Page Hardcoded „Web Push" / „Bug Report" → `t(...)`
- I18N-003: `medication-card.tsx` `Intl.DateTimeFormat("en-CA")` und `toLocaleString("en-US")` → `useFormatters()`
- A11Y-001: `phase-config-dialog.tsx` Text-Indicator neben Farb-Punkt (WCAG 1.4.1)
@@ -184,36 +189,41 @@ Coverage 35% → 60% auf kritischen Pfaden.
## Issue-Vorschlags-Abdeckung
### Issue #87 (Onboarding-Meds verschwinden)
- **Pflaster:** Phase 1 (`.ok`-Check + Toast)
- **Strukturelle Lösung:** Phase 4 (`apiClient`) + Phase 3.5 (neuer Flow ohne Med-Wizard) + Phase 4.5 (Empty-State-Tiles, Meds direkt sichtbar) + Phase 4.6 (Checklist)
### Issue #88 (Setup holprig — 4 Hauptpunkte + 3 Verbesserungsvorschläge)
| #88 Punkt | Phase | Status |
|---|---|---|
| (a) Bundled compose ≠ docs version | Phase 2 (DOC-005) | abgedeckt |
| (b) ghcr.io 404 (kein Image gepusht) | Phase 2 (DX-001 GHA-Workflow) | abgedeckt |
| (c) `.env.example` `localhost:5432` vs Compose `db:5432` | Phase 1 (DX-003) | abgedeckt |
| (d) `expose:3000` statt `ports:3000:3000` | Phase 1 (DX-002) | abgedeckt |
| Vorschlag: GHA für Image-Push (GHCR/Docker Hub) | Phase 2 | abgedeckt |
| Vorschlag: Merge „Getting started > Installation" + „Self-Hosting > Docker" | Phase 2 (DX-004) | abgedeckt |
| Vorschlag: Same presets across compose+docs+.env.example | Phase 1 + Phase 2 | abgedeckt |
| #88 Punkt | Phase | Status |
| --------------------------------------------------------------------------- | ----------------------------- | --------- |
| (a) Bundled compose ≠ docs version | Phase 2 (DOC-005) | abgedeckt |
| (b) ghcr.io 404 (kein Image gepusht) | Phase 2 (DX-001 GHA-Workflow) | abgedeckt |
| (c) `.env.example` `localhost:5432` vs Compose `db:5432` | Phase 1 (DX-003) | abgedeckt |
| (d) `expose:3000` statt `ports:3000:3000` | Phase 1 (DX-002) | abgedeckt |
| Vorschlag: GHA für Image-Push (GHCR/Docker Hub) | Phase 2 | abgedeckt |
| Vorschlag: Merge „Getting started > Installation" + „Self-Hosting > Docker" | Phase 2 (DX-004) | abgedeckt |
| Vorschlag: Same presets across compose+docs+.env.example | Phase 1 + Phase 2 | abgedeckt |
### Issue #89 (TBW + Bone Mass)
- Phase 3 hardcoded mit Withings-Mapping. Optional `/api/ingest/measurements` für n8n in Phase 3.
### Issue #73 (UX-Liste mit 8 Punkten — CLOSED in v1.2)
| #73 Punkt | v1.2-Status | Residual? |
|---|---|---|
| 1. Passkey-Errors unklar | Resolved (Package C, typed errors) | TEST-002 (keine Tests) → Phase 6 |
| 2. Number-Formatting (DE 70,5 vs US 70.5) | Resolved (Package B, locale-aware formatters) | I18N-003 in `medication-card.tsx` → Phase 7 |
| 3. Chart-Labels „7P/30P/90P/All" | Resolved (Package A, „7 Pkt"/„7 pts") | — |
| 4. Dashboard nicht real-time | Resolved (`invalidateKeys` durchgängig) | — |
| 5. GitHub-Setup-Confusion | Resolved (Built-in Feedback in v1.2) | — |
| 6. Built-in Feedback | Done in v1.2 | — |
| 7. Admin-Save ohne Error-UX | Partial (Success-Msg ja, Error-Highlight schwach) | UX-002 → Phase 7 |
| 8. Blood-Sugar | Done in v1.2 | — |
| #73 Punkt | v1.2-Status | Residual? |
| ----------------------------------------- | ------------------------------------------------- | ------------------------------------------- |
| 1. Passkey-Errors unklar | Resolved (Package C, typed errors) | TEST-002 (keine Tests) → Phase 6 |
| 2. Number-Formatting (DE 70,5 vs US 70.5) | Resolved (Package B, locale-aware formatters) | I18N-003 in `medication-card.tsx` → Phase 7 |
| 3. Chart-Labels „7P/30P/90P/All" | Resolved (Package A, „7 Pkt"/„7 pts") | — |
| 4. Dashboard nicht real-time | Resolved (`invalidateKeys` durchgängig) | — |
| 5. GitHub-Setup-Confusion | Resolved (Built-in Feedback in v1.2) | — |
| 6. Built-in Feedback | Done in v1.2 | — |
| 7. Admin-Save ohne Error-UX | Partial (Success-Msg ja, Error-Highlight schwach) | UX-002 → Phase 7 |
| 8. Blood-Sugar | Done in v1.2 | — |
### Issue #74 (raw locale keys in UI)
Resolved in v1.2.
---
@@ -221,6 +231,7 @@ Resolved in v1.2.
## Time-to-First-Value (TTFV) Ziel
Nach Onboarding-Redesign + Empty-State-Pattern:
- Login → Dashboard mit Empty-States: 2 Klicks
- Klick auf „Add your first weight" → Form prefilled „now" → Submit: 30 Sek
- **TTFV-Ziel: <60 Sekunden** (Branchenvergleich: führende Health-Apps 30-90s)
+29 -24
View File
@@ -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`)
+1 -3
View File
@@ -56,9 +56,7 @@ self.addEventListener("fetch", (event) => {
}
// Static files (fonts, icons, images) — cache-first with long TTL
if (
/\.(png|svg|jpg|jpeg|gif|webp|ico|woff2?|ttf|eot)$/i.test(url.pathname)
) {
if (/\.(png|svg|jpg|jpeg|gif|webp|ico|woff2?|ttf|eot)$/i.test(url.pathname)) {
event.respondWith(cacheFirst(request, STATIC_CACHE));
return;
}
+57 -27
View File
@@ -36,7 +36,12 @@ function cuid(): string {
function daysAgo(n: number): Date {
const d = new Date();
d.setDate(d.getDate() - n);
d.setHours(7 + Math.floor(Math.random() * 3), Math.floor(Math.random() * 60), 0, 0);
d.setHours(
7 + Math.floor(Math.random() * 3),
Math.floor(Math.random() * 60),
0,
0,
);
return d;
}
@@ -45,7 +50,12 @@ function formatDate(d: Date): string {
}
// Smooth random walk with mean reversion
function randomWalk(start: number, target: number, days: number, volatility: number): number[] {
function randomWalk(
start: number,
target: number,
days: number,
volatility: number,
): number[] {
const values: number[] = [start];
for (let i = 1; i < days; i++) {
const prev = values[i - 1];
@@ -88,12 +98,24 @@ async function seed() {
// argon2id hash of "demo123demo123" — pre-computed
// We'll use a bcrypt-compatible approach: register via API later
// For now, insert with a placeholder and we'll set it via the app
const passwordHash = "$argon2id$v=19$m=65536,t=3,p=4$Kips6OxPAl0vmspO9SoKZQ$oX9gLgwHVnnENCqBloyM13ewuqmhPnw8EpLoemS3MNI";
const passwordHash =
"$argon2id$v=19$m=65536,t=3,p=4$Kips6OxPAl0vmspO9SoKZQ$oX9gLgwHVnnENCqBloyM13ewuqmhPnw8EpLoemS3MNI";
await client.query(
`INSERT INTO users (id, username, email, password_hash, role, height_cm, date_of_birth, gender, timezone, locale, onboarding_completed_at, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW(), NOW())`,
[userId, "demo", "demo@healthlog.app", passwordHash, "ADMIN", 182.0, "1990-05-15", "MALE", "Europe/Berlin", "en"]
[
userId,
"demo",
"demo@healthlog.app",
passwordHash,
"ADMIN",
182.0,
"1990-05-15",
"MALE",
"Europe/Berlin",
"en",
],
);
// ── Measurements (90 days) ────────────────
@@ -122,26 +144,26 @@ async function seed() {
await client.query(
`INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at)
VALUES ($1, $2, 'WEIGHT', $3, 'kg', 'MANUAL', $4, $4, $4)`,
[cuid(), userId, weights[i], date]
[cuid(), userId, weights[i], date],
);
// Blood pressure (daily)
await client.query(
`INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at)
VALUES ($1, $2, 'BLOOD_PRESSURE_SYS', $3, 'mmHg', 'MANUAL', $4, $4, $4)`,
[cuid(), userId, Math.round(sysBP[i]), date]
[cuid(), userId, Math.round(sysBP[i]), date],
);
await client.query(
`INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at)
VALUES ($1, $2, 'BLOOD_PRESSURE_DIA', $3, 'mmHg', 'MANUAL', $4, $4, $4)`,
[cuid(), userId, Math.round(diaBP[i]), date]
[cuid(), userId, Math.round(diaBP[i]), date],
);
// Pulse (daily)
await client.query(
`INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at)
VALUES ($1, $2, 'PULSE', $3, 'bpm', 'MANUAL', $4, $4, $4)`,
[cuid(), userId, Math.round(pulse[i]), date]
[cuid(), userId, Math.round(pulse[i]), date],
);
// Body fat (every 2-3 days)
@@ -149,7 +171,7 @@ async function seed() {
await client.query(
`INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at)
VALUES ($1, $2, 'BODY_FAT', $3, '%', 'MANUAL', $4, $4, $4)`,
[cuid(), userId, bodyFat[i], date]
[cuid(), userId, bodyFat[i], date],
);
}
@@ -157,14 +179,14 @@ async function seed() {
await client.query(
`INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at)
VALUES ($1, $2, 'SLEEP_DURATION', $3, 'hours', 'MANUAL', $4, $4, $4)`,
[cuid(), userId, Math.round(sleep[i] * 10) / 10, date]
[cuid(), userId, Math.round(sleep[i] * 10) / 10, date],
);
// Steps (daily)
await client.query(
`INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at)
VALUES ($1, $2, 'ACTIVITY_STEPS', $3, 'steps', 'MANUAL', $4, $4, $4)`,
[cuid(), userId, Math.round(steps[i]), date]
[cuid(), userId, Math.round(steps[i]), date],
);
}
@@ -176,13 +198,13 @@ async function seed() {
await client.query(
`INSERT INTO medications (id, user_id, name, dose, active, notifications_enabled, created_at, updated_at)
VALUES ($1, $2, 'Ramipril', '5mg', true, true, $3, $3)`,
[med1Id, userId, daysAgo(120)]
[med1Id, userId, daysAgo(120)],
);
const sched1Id = cuid();
await client.query(
`INSERT INTO medication_schedules (id, medication_id, window_start, window_end, label)
VALUES ($1, $2, '08:00', '10:00', 'Morning')`,
[sched1Id, med1Id]
[sched1Id, med1Id],
);
// Medication 2: Vitamin D3
@@ -190,13 +212,13 @@ async function seed() {
await client.query(
`INSERT INTO medications (id, user_id, name, dose, active, notifications_enabled, created_at, updated_at)
VALUES ($1, $2, 'Vitamin D3', '2000 IE', true, true, $3, $3)`,
[med2Id, userId, daysAgo(90)]
[med2Id, userId, daysAgo(90)],
);
const sched2Id = cuid();
await client.query(
`INSERT INTO medication_schedules (id, medication_id, window_start, window_end, label)
VALUES ($1, $2, '08:00', '10:00', 'Morning')`,
[sched2Id, med2Id]
[sched2Id, med2Id],
);
// Medication 3: Magnesium (evening)
@@ -204,13 +226,13 @@ async function seed() {
await client.query(
`INSERT INTO medications (id, user_id, name, dose, active, notifications_enabled, created_at, updated_at)
VALUES ($1, $2, 'Magnesium', '400mg', true, true, $3, $3)`,
[med3Id, userId, daysAgo(60)]
[med3Id, userId, daysAgo(60)],
);
const sched3Id = cuid();
await client.query(
`INSERT INTO medication_schedules (id, medication_id, window_start, window_end, label)
VALUES ($1, $2, '20:00', '22:00', 'Evening')`,
[sched3Id, med3Id]
[sched3Id, med3Id],
);
// ── Medication Intake Events ──────────────
@@ -227,13 +249,13 @@ async function seed() {
await client.query(
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at)
VALUES ($1, $2, $3, $4, $5, false, 'WEB', $5)`,
[cuid(), userId, med1Id, date, takenTime]
[cuid(), userId, med1Id, date, takenTime],
);
} else {
await client.query(
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at)
VALUES ($1, $2, $3, $4, NULL, true, 'WEB', $4)`,
[cuid(), userId, med1Id, date]
[cuid(), userId, med1Id, date],
);
}
@@ -245,13 +267,13 @@ async function seed() {
await client.query(
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at)
VALUES ($1, $2, $3, $4, $5, false, 'WEB', $5)`,
[cuid(), userId, med2Id, date, takenTime]
[cuid(), userId, med2Id, date, takenTime],
);
} else {
await client.query(
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at)
VALUES ($1, $2, $3, $4, NULL, true, 'WEB', $4)`,
[cuid(), userId, med2Id, date]
[cuid(), userId, med2Id, date],
);
}
@@ -264,13 +286,13 @@ async function seed() {
await client.query(
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at)
VALUES ($1, $2, $3, $4, $5, false, 'WEB', $5)`,
[cuid(), userId, med3Id, date, takenTime]
[cuid(), userId, med3Id, date, takenTime],
);
} else {
await client.query(
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at)
VALUES ($1, $2, $3, $4, NULL, true, 'WEB', $4)`,
[cuid(), userId, med3Id, date]
[cuid(), userId, med3Id, date],
);
}
}
@@ -305,7 +327,15 @@ async function seed() {
await client.query(
`INSERT INTO mood_entries (id, user_id, date, mood, score, tags, source, mood_logged_at, synced_at, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, 'WEB', $7, $7, $7, $7)`,
[cuid(), userId, formatDate(date), mood, rawScore, JSON.stringify(tags), loggedAt]
[
cuid(),
userId,
formatDate(date),
mood,
rawScore,
JSON.stringify(tags),
loggedAt,
],
);
}
@@ -332,7 +362,7 @@ async function seed() {
await client.query(
`INSERT INTO user_achievements (id, user_id, achievement_id, unlocked_at, created_at)
VALUES ($1, $2, $3, $4, $4)`,
[cuid(), userId, ach.id, unlocked]
[cuid(), userId, ach.id, unlocked],
);
}
@@ -341,7 +371,7 @@ async function seed() {
await client.query(
`INSERT INTO app_settings (id, registration_enabled, default_locale)
VALUES ('singleton', false, 'en')
ON CONFLICT (id) DO UPDATE SET registration_enabled = false, default_locale = 'en'`
ON CONFLICT (id) DO UPDATE SET registration_enabled = false, default_locale = 'en'`,
);
// ── Audit Log (some login entries) ──────
@@ -351,7 +381,7 @@ async function seed() {
await client.query(
`INSERT INTO audit_logs (id, user_id, action, details, ip_address, created_at)
VALUES ($1, $2, 'auth.login', '{"method":"password"}', '203.0.113.42', $3)`,
[cuid(), userId, loginDate]
[cuid(), userId, loginDate],
);
}
@@ -37,10 +37,7 @@ export const POST = apiHandler(
if (!feedback) return apiError("Feedback not found", 404);
if (feedback.gitHubIssueUrl) {
return apiError(
`Already published: ${feedback.gitHubIssueUrl}`,
409,
);
return apiError(`Already published: ${feedback.gitHubIssueUrl}`, 409);
}
// Atomic claim to prevent concurrent admin clicks from creating two
+6 -1
View File
@@ -3,7 +3,12 @@
* DELETE /api/admin/feedback/[id] — archive (soft: sets status to ARCHIVED).
*/
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { apiError, apiSuccess, safeJson, getClientIp } from "@/lib/api-response";
import {
apiError,
apiSuccess,
safeJson,
getClientIp,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
@@ -30,7 +30,14 @@ export const POST = apiHandler(async () => {
});
if (!delivery.ok) {
getEvent()?.addWarning("Glitchtip test event rejected: " + delivery.method + " " + delivery.status + " " + delivery.details);
getEvent()?.addWarning(
"Glitchtip test event rejected: " +
delivery.method +
" " +
delivery.status +
" " +
delivery.details,
);
return apiError(
`Glitchtip test event rejected (HTTP ${delivery.status ?? 502})`,
502,
@@ -54,10 +54,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("Umami is disabled", 422);
}
if (!settings.umamiScriptUrl || !settings.umamiWebsiteId) {
return apiError(
"Umami script URL and website ID must be configured",
422,
);
return apiError("Umami script URL and website ID must be configured", 422);
}
const targetUrls = resolveUmamiSendUrls(settings.umamiScriptUrl);
@@ -116,8 +113,5 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
getEvent()?.addWarning("Umami test event rejected: " + lastStatus);
return apiError(
`Umami test event rejected (HTTP ${lastStatus})`,
502,
);
return apiError(`Umami test event rejected (HTTP ${lastStatus})`, 502);
});
@@ -158,7 +158,9 @@ export const POST = apiHandler(async () => {
notificationSent = true;
notificationsSent++;
} catch (err) {
getEvent()?.addWarning("Notification failed for " + med.name + ": " + err);
getEvent()?.addWarning(
"Notification failed for " + med.name + ": " + err,
);
}
}
+11 -7
View File
@@ -1,7 +1,12 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
import {
apiSuccess,
apiError,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { encrypt } from "@/lib/crypto";
import { adminSettingsSchema } from "@/lib/validations/admin";
@@ -27,8 +32,8 @@ export const GET = apiHandler(async () => {
webPushVapidSubject: settings?.webPushVapidSubject ?? null,
webPushVapidConfigured: Boolean(
settings?.webPushVapidPublicKey &&
settings?.webPushVapidPrivateKeyEncrypted &&
settings?.webPushVapidSubject,
settings?.webPushVapidPrivateKeyEncrypted &&
settings?.webPushVapidSubject,
),
apiGlobal: settings?.apiGlobal ?? true,
umamiEnabled: settings?.umamiEnabled ?? false,
@@ -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,
+16 -11
View File
@@ -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",
@@ -2,69 +2,82 @@ import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { hashPassword, checkPasswordStrength } from "@/lib/auth/password";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
import {
apiSuccess,
apiError,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { destroyAllSessions } from "@/lib/auth/session";
import { NextRequest, NextResponse } from "next/server";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
export const POST = apiHandler(async (
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) => {
const ip = getClientIp(request);
const rl = await checkRateLimit(`admin-reset-pw:${ip}`, 10, 60 * 1000);
if (!rl.allowed) {
return NextResponse.json(
{ data: null, error: "Rate limit exceeded" },
{ status: 429, headers: rateLimitHeaders(rl) },
);
}
export const POST = apiHandler(
async (
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) => {
const ip = getClientIp(request);
const rl = await checkRateLimit(`admin-reset-pw:${ip}`, 10, 60 * 1000);
if (!rl.allowed) {
return NextResponse.json(
{ data: null, error: "Rate limit exceeded" },
{ status: 429, headers: rateLimitHeaders(rl) },
);
}
const { user } = await requireAdmin();
const { user } = await requireAdmin();
const { id } = await params;
annotate({ action: { name: "admin.users.reset-password", entity_type: "user", entity_id: id } });
const { id } = await params;
annotate({
action: {
name: "admin.users.reset-password",
entity_type: "user",
entity_id: id,
},
});
const { data: body, error: jsonError } = await safeJson(request);
const { data: body, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
const { password } = body as { password?: string };
if (jsonError) return jsonError;
const { password } = body as { password?: string };
if (!password || typeof password !== "string") {
return apiError("Password required", 422);
}
if (!password || typeof password !== "string") {
return apiError("Password required", 422);
}
const target = await prisma.user.findUnique({ where: { id } });
if (!target) return apiError("User not found", 404);
const target = await prisma.user.findUnique({ where: { id } });
if (!target) return apiError("User not found", 404);
const locale = await resolveServerLocale({
request,
userLocale: target.locale ?? null,
});
const strength = checkPasswordStrength(password, [target.username], locale);
if (!strength.isAcceptable) {
return apiError(
strength.feedback[0] || "Password too weak (score < 3)",
422,
);
}
const locale = await resolveServerLocale({
request,
userLocale: target.locale ?? null,
});
const strength = checkPasswordStrength(password, [target.username], locale);
if (!strength.isAcceptable) {
return apiError(
strength.feedback[0] || "Password too weak (score < 3)",
422,
);
}
const passwordHash = await hashPassword(password);
await prisma.user.update({
where: { id },
data: { passwordHash },
});
const passwordHash = await hashPassword(password);
await prisma.user.update({
where: { id },
data: { passwordHash },
});
// Invalidate all sessions of the target user
await destroyAllSessions(id);
// Invalidate all sessions of the target user
await destroyAllSessions(id);
await auditLog("admin.user.reset-password", {
userId: user.id,
ipAddress: getClientIp(request),
details: { targetUserId: id },
});
await auditLog("admin.user.reset-password", {
userId: user.id,
ipAddress: getClientIp(request),
details: { targetUserId: id },
});
return apiSuccess({ success: true });
});
return apiSuccess({ success: true });
},
);
+61 -48
View File
@@ -1,7 +1,12 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
import {
apiSuccess,
apiError,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
@@ -12,57 +17,65 @@ const updateUserSchema = z.object({
role: z.enum(["ADMIN", "USER"]).optional(),
});
export const PUT = apiHandler(async (
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) => {
const { user } = await requireAdmin();
export const PUT = apiHandler(
async (
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) => {
const { user } = await requireAdmin();
const { id } = await params;
annotate({ action: { name: "admin.users.update", entity_type: "user", entity_id: id } });
const { id } = await params;
annotate({
action: {
name: "admin.users.update",
entity_type: "user",
entity_id: id,
},
});
const { data: body, error: jsonError } = await safeJson(request);
const { data: body, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
const parsed = updateUserSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
}
const target = await prisma.user.findUnique({ where: { id } });
if (!target) return apiError("User not found", 404);
// Prevent removing the last admin
if (parsed.data.role === "USER" && target.role === "ADMIN") {
const adminCount = await prisma.user.count({ where: { role: "ADMIN" } });
if (adminCount <= 1) {
return apiError("The last admin cannot be demoted", 400);
if (jsonError) return jsonError;
const parsed = updateUserSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
}
}
const updatedUser = await prisma.user.update({
where: { id },
data: {
...(parsed.data.username !== undefined && {
username: parsed.data.username,
}),
...(parsed.data.email !== undefined && { email: parsed.data.email }),
...(parsed.data.role !== undefined && { role: parsed.data.role }),
},
select: {
id: true,
username: true,
email: true,
role: true,
createdAt: true,
},
});
const target = await prisma.user.findUnique({ where: { id } });
if (!target) return apiError("User not found", 404);
await auditLog("admin.user.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: { targetUserId: id, changes: parsed.data },
});
// Prevent removing the last admin
if (parsed.data.role === "USER" && target.role === "ADMIN") {
const adminCount = await prisma.user.count({ where: { role: "ADMIN" } });
if (adminCount <= 1) {
return apiError("The last admin cannot be demoted", 400);
}
}
return apiSuccess(updatedUser);
});
const updatedUser = await prisma.user.update({
where: { id },
data: {
...(parsed.data.username !== undefined && {
username: parsed.data.username,
}),
...(parsed.data.email !== undefined && { email: parsed.data.email }),
...(parsed.data.role !== undefined && { role: parsed.data.role }),
},
select: {
id: true,
username: true,
email: true,
role: true,
createdAt: true,
},
});
await auditLog("admin.user.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: { targetUserId: id, changes: parsed.data },
});
return apiSuccess(updatedUser);
},
);
+3 -1
View File
@@ -14,7 +14,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
const rl = await checkRateLimit(`codex-callback:${user.id}`, 10, 60_000);
if (!rl.allowed) {
const appUrl = process.env.APP_URL || process.env.NEXT_PUBLIC_APP_URL;
return NextResponse.redirect(`${appUrl}/settings/integrations?codex_error=rate_limited`);
return NextResponse.redirect(
`${appUrl}/settings/integrations?codex_error=rate_limited`,
);
}
const url = new URL(request.url);
@@ -1,7 +1,12 @@
import { prisma } from "@/lib/db";
import { verifyRegistration } from "@/lib/auth/passkey";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
import {
apiSuccess,
apiError,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
@@ -9,7 +14,8 @@ import { annotate } from "@/lib/logging/context";
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson<Record<string, unknown>>(request);
const { data: body, error: jsonError } =
await safeJson<Record<string, unknown>>(request);
if (jsonError) return jsonError;
const challengeId = body.challengeId as string | undefined;
@@ -35,7 +41,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
counter: BigInt(registrationInfo.credential.counter),
credentialDeviceType: registrationInfo.credentialDeviceType,
credentialBackedUp: registrationInfo.credentialBackedUp,
transports: ((credential.response as Record<string, unknown> | undefined)?.transports as string[]) ?? [],
transports:
((credential.response as Record<string, unknown> | undefined)
?.transports as string[]) ?? [],
},
});
+34 -32
View File
@@ -5,44 +5,46 @@ import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
export const DELETE = apiHandler(async (
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) => {
const { user } = await requireAuth();
export const DELETE = apiHandler(
async (
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) => {
const { user } = await requireAuth();
const { id } = await params;
const { id } = await params;
const passkey = await prisma.passkey.findUnique({
where: { id },
});
const passkey = await prisma.passkey.findUnique({
where: { id },
});
if (!passkey || passkey.userId !== user.id) {
return apiError("Passkey not found", 404);
}
if (!passkey || passkey.userId !== user.id) {
return apiError("Passkey not found", 404);
}
// Check: at least 1 auth method must remain
const passkeyCount = await prisma.passkey.count({
where: { userId: user.id },
});
const hasPassword = !!user.passwordHash;
// Check: at least 1 auth method must remain
const passkeyCount = await prisma.passkey.count({
where: { userId: user.id },
});
const hasPassword = !!user.passwordHash;
if (passkeyCount <= 1 && !hasPassword) {
return apiError(
"Cannot delete — at least one authentication method must remain",
400,
);
}
if (passkeyCount <= 1 && !hasPassword) {
return apiError(
"Cannot delete — at least one authentication method must remain",
400,
);
}
await prisma.passkey.delete({ where: { id } });
await prisma.passkey.delete({ where: { id } });
await auditLog("auth.passkey.delete", {
userId: user.id,
ipAddress: getClientIp(request),
details: { passkeyId: id, passkeyName: passkey.name },
});
await auditLog("auth.passkey.delete", {
userId: user.id,
ipAddress: getClientIp(request),
details: { passkeyId: id, passkeyName: passkey.name },
});
annotate({ action: { name: "auth.passkey.delete" } });
annotate({ action: { name: "auth.passkey.delete" } });
return apiSuccess({ success: true });
});
return apiSuccess({ success: true });
},
);
+9 -13
View File
@@ -7,7 +7,12 @@ import {
} from "@/lib/auth/password";
import { changePasswordSchema } from "@/lib/validations/auth";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
import {
apiSuccess,
apiError,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
@@ -23,10 +28,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
15 * 60 * 1000,
);
if (!rl.allowed) {
return apiError(
"Too many attempts. Please wait 15 minutes.",
429,
);
return apiError("Too many attempts. Please wait 15 minutes.", 429);
}
const { data: body, error: jsonError } = await safeJson(request);
@@ -42,19 +44,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("No password set for this account", 400);
}
const currentValid = await verifyPassword(
user.passwordHash,
currentPassword,
);
const currentValid = await verifyPassword(user.passwordHash, currentPassword);
if (!currentValid) {
return apiError("Current password is incorrect", 401);
}
if (currentPassword === newPassword) {
return apiError(
"New password must differ from current password",
422,
);
return apiError("New password must differ from current password", 422);
}
const locale = await resolveServerLocale({
+6 -1
View File
@@ -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";
+7 -3
View File
@@ -3,7 +3,12 @@ import { registerSchema } from "@/lib/validations/auth";
import { hashPassword, checkPasswordStrength } from "@/lib/auth/password";
import { createSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
import {
apiSuccess,
apiError,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { ensureDbCompatibility } from "@/lib/db-compat";
import { NextRequest, NextResponse } from "next/server";
@@ -18,8 +23,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
return NextResponse.json(
{
data: null,
error:
"Too many registration attempts. Please try again later.",
error: "Too many registration attempts. Please try again later.",
},
{ status: 429, headers: rateLimitHeaders(rl) },
);
+11 -6
View File
@@ -1,6 +1,11 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
import {
apiSuccess,
apiError,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
@@ -17,7 +22,10 @@ const bugReportSchema = z.object({
screenshot: z
.string()
.max(7_000_000, "Screenshot too large (max 5 MB)")
.regex(SCREENSHOT_DATA_URL, "Screenshot must be a base64-encoded PNG/JPEG/WEBP/GIF data URL")
.regex(
SCREENSHOT_DATA_URL,
"Screenshot must be a base64-encoded PNG/JPEG/WEBP/GIF data URL",
)
.optional(),
});
@@ -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);
+1 -5
View File
@@ -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);
}
@@ -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", () => {
+5 -1
View File
@@ -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";
+4 -1
View File
@@ -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 },
+7 -2
View File
@@ -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;
+6 -1
View File
@@ -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";
@@ -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);
+7 -15
View File
@@ -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({
+4 -1
View File
@@ -2,7 +2,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(async () => ({ user: { id: "u-1" }, session: { id: "s-1" } })),
requireAuth: vi.fn(async () => ({
user: { id: "u-1" },
session: { id: "s-1" },
})),
}));
vi.mock("@/lib/db", () => ({
+6 -1
View File
@@ -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";
+6 -1
View File
@@ -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";
@@ -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" } });
@@ -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,
+22 -14
View File
@@ -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<Record<MeasurementType, ReturnType<typeof summarize>>> = {};
const summaries: Partial<
Record<MeasurementType, ReturnType<typeof summarize>>
> = {};
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<string, typeof allEvents>();
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,
});
}
}
+19 -6
View File
@@ -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({
@@ -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";
+17 -4
View File
@@ -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";
@@ -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" } });
+2 -1
View File
@@ -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<Record<string, unknown>>(request);
const { data: body, error: jsonError } =
await safeJson<Record<string, unknown>>(request);
if (jsonError) return jsonError;
const data: Record<string, unknown> = {};
@@ -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);
+19 -6
View File
@@ -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<string>(
DEFAULT_HEALTHKIT_ENTRIES.map((e) => e.id),
);
const knownIds = new Set<string>(DEFAULT_HEALTHKIT_ENTRIES.map((e) => e.id));
if (stored) for (const e of stored.entries) knownIds.add(e.id);
// Merge by id; unknown ids are silently ignored per spec.
+92 -81
View File
@@ -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 });
},
);
@@ -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";
+130 -126
View File
@@ -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,
});
},
);
+117 -115
View File
@@ -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<string, DailyComplianceEntry> = {};
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<string, DailyComplianceEntry> = {};
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 });
},
);
@@ -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 });
},
);
@@ -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);
},
);
@@ -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 });
},
);
@@ -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 });
},
);
+183 -172
View File
@@ -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<Record<string, unknown>> = [
{ ...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<Record<string, unknown>> = [
{ ...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 });
},
);
@@ -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({
@@ -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", () => {
+7 -3
View File
@@ -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 },
+6 -1
View File
@@ -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,
+8 -1
View File
@@ -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);
}
+85 -65
View File
@@ -1,6 +1,11 @@
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
import {
apiSuccess,
apiError,
getClientIp,
safeJson,
} from "@/lib/api-response";
import {
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<string, unknown> = {};
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<string, unknown> = {};
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 });
},
);
+4 -1
View File
@@ -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 });
});
+1 -4
View File
@@ -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);
}
}
+1 -4
View File
@@ -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";
+1 -2
View File
@@ -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) {
+1 -4
View File
@@ -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:") {
+5 -5
View File
@@ -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 });
});
+10 -3
View File
@@ -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,
+19 -17
View File
@@ -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 });
},
);
+6 -2
View File
@@ -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<Record<string, unknown>>(request);
const { data: body, error } =
await safeJson<Record<string, unknown>>(request);
if (error) return error;
const updates: Record<string, unknown> = {};
@@ -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");
@@ -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";
+6 -1
View File
@@ -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";
+9 -6
View File
@@ -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,
},
});
+2 -1
View File
@@ -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 },
+9 -3
View File
@@ -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 });
+1 -3
View File
@@ -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", {
+4 -1
View File
@@ -114,7 +114,10 @@ export default function RegisterPage() {
</div>
{error && (
<div role="alert" className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm">
<div
role="alert"
className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm"
>
{error}
</div>
)}
+5 -11
View File
@@ -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() {
</div>
{status?.configured && (
<div className="bg-card border-border flex gap-2 rounded-lg border-l-4 border-l-dracula-cyan p-3 text-sm">
<GitPullRequest className="mt-0.5 h-4 w-4 shrink-0 text-dracula-cyan" />
<div className="bg-card border-border border-l-dracula-cyan flex gap-2 rounded-lg border-l-4 p-3 text-sm">
<GitPullRequest className="text-dracula-cyan mt-0.5 h-4 w-4 shrink-0" />
<p className="text-muted-foreground">
{t("bugreport.githubEscalationNote")}
</p>
</div>
)}
{!status?.configured && (
<div className="bg-card border-border flex gap-2 rounded-lg border-l-4 border-l-dracula-purple p-3 text-sm">
<Info className="mt-0.5 h-4 w-4 shrink-0 text-dracula-purple" />
<div className="bg-card border-border border-l-dracula-purple flex gap-2 rounded-lg border-l-4 p-3 text-sm">
<Info className="text-dracula-purple mt-0.5 h-4 w-4 shrink-0" />
<p className="text-muted-foreground">
{t("bugreport.internalOnlyNote")}
</p>
+2 -4
View File
@@ -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);
+45 -44
View File
@@ -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() {
<div className="text-muted-foreground flex flex-col items-center justify-center py-8 text-sm">
<Smile className="mb-2 h-8 w-8 opacity-50" />
<p>{t("insights.notEnoughMoodCorrelationData")}</p>
<p className="text-xs">{t("insights.minMoodCorrelationData")}</p>
<p className="text-xs">
{t("insights.minMoodCorrelationData")}
</p>
</div>
)}
</CardContent>
@@ -1419,7 +1418,9 @@ export default function InsightsPage() {
<div className="text-muted-foreground flex flex-col items-center justify-center py-8 text-sm">
<Smile className="mb-2 h-8 w-8 opacity-50" />
<p>{t("insights.notEnoughMoodCorrelationData")}</p>
<p className="text-xs">{t("insights.minMoodCorrelationData")}</p>
<p className="text-xs">
{t("insights.minMoodCorrelationData")}
</p>
</div>
)}
</CardContent>
+1 -1
View File
@@ -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}
>
@@ -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"),
+19 -5
View File
@@ -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<{
+26 -16
View File
@@ -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;
}
+3 -9
View File
@@ -88,9 +88,7 @@ export function TrendCard({
</div>
<div className="mt-2 flex items-baseline gap-2">
<span className="text-3xl font-bold tracking-tight tabular-nums">
{latest !== null
? renderPair(latest, secondary?.latest)
: "—"}
{latest !== null ? renderPair(latest, secondary?.latest) : "—"}
</span>
<span className="text-muted-foreground text-sm tabular-nums">
{unit}
@@ -115,9 +113,7 @@ export function TrendCard({
</TooltipContent>
</Tooltip>
) : (
<span
className={cn("font-medium tabular-nums", avg7ColorClass)}
>
<span className={cn("font-medium tabular-nums", avg7ColorClass)}>
{avg7 !== null ? renderPair(avg7, secondary?.avg7) : "—"}
</span>
)}
@@ -138,9 +134,7 @@ export function TrendCard({
</TooltipContent>
</Tooltip>
) : (
<span
className={cn("font-medium tabular-nums", avg30ColorClass)}
>
<span className={cn("font-medium tabular-nums", avg30ColorClass)}>
{avg30 !== null ? renderPair(avg30, secondary?.avg30) : "—"}
</span>
)}
+4 -6
View File
@@ -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({
<p className="text-muted-foreground text-sm">
{t("errorBoundary.description")}
</p>
<div className="bg-muted/50 rounded-md border p-3 font-mono text-xs text-muted-foreground break-all">
<div className="bg-muted/50 text-muted-foreground rounded-md border p-3 font-mono text-xs break-all">
{error.message || t("common.unknownError")}
</div>
<div className="flex flex-wrap items-center gap-2">
@@ -91,7 +89,7 @@ export function ErrorDetails({
)}
<Button onClick={handleCopy} variant="outline" size="sm">
{copied ? (
<CheckCircle2 className="mr-2 h-4 w-4 text-dracula-green" />
<CheckCircle2 className="text-dracula-green mr-2 h-4 w-4" />
) : (
<Copy className="mr-2 h-4 w-4" />
)}
@@ -33,8 +33,8 @@ export function InsightStatusCard({
return (
<Card>
<CardContent className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-dracula-purple" />
<span className="ml-2 text-sm text-muted-foreground">
<Loader2 className="text-dracula-purple h-5 w-5 animate-spin" />
<span className="text-muted-foreground ml-2 text-sm">
{t("common.loading")}
</span>
</CardContent>
@@ -52,7 +52,7 @@ export function InsightStatusCard({
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground text-sm">
{t("insights.noProviderConfigured")}
</p>
</CardContent>
@@ -70,7 +70,7 @@ export function InsightStatusCard({
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground text-sm">
{t("insights.noAnalysisYet")}
</p>
</CardContent>
@@ -79,7 +79,7 @@ export function InsightStatusCard({
}
return (
<Card className="animate-insight-in border-l-2 border-l-dracula-purple">
<Card className="animate-insight-in border-l-dracula-purple border-l-2">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
@@ -87,14 +87,14 @@ export function InsightStatusCard({
<CardTitle className="text-base">{title}</CardTitle>
</div>
{cached && (
<span className="text-xs text-muted-foreground">
<span className="text-muted-foreground text-xs">
{t("insights.cached")}
</span>
)}
</div>
</CardHeader>
<CardContent className="space-y-2">
<p className="text-sm leading-relaxed text-muted-foreground">{text}</p>
<p className="text-muted-foreground text-sm leading-relaxed">{text}</p>
<LastUpdatedFooter updatedAt={updatedAt} />
</CardContent>
</Card>
@@ -106,7 +106,7 @@ function LastUpdatedFooter({ updatedAt }: { updatedAt: string | null }) {
const fmt = useFormatters();
if (!updatedAt) return null;
return (
<p className="text-xs text-muted-foreground">
<p className="text-muted-foreground text-xs">
{t("insights.lastUpdated")}: {fmt.dateTime(updatedAt)}
</p>
);
+26 -8
View File
@@ -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() {
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Sparkles className="text-dracula-purple h-5 w-5" />
<CardTitle className="text-lg">{t("insights.aiInsights")}</CardTitle>
<CardTitle className="text-lg">
{t("insights.aiInsights")}
</CardTitle>
</div>
<Button
variant="ghost"
@@ -97,7 +100,9 @@ export function InsightsCard() {
) : (
<RefreshCw className="mr-1 h-3.5 w-3.5" />
)}
{insights ? t("insights.refreshButton") : t("insights.generateButton")}
{insights
? t("insights.refreshButton")
: t("insights.generateButton")}
</Button>
</div>
</CardHeader>
@@ -123,7 +128,8 @@ export function InsightsCard() {
<div className="space-y-4">
<div className="flex items-center gap-2">
<Badge className={CONFIDENCE_COLORS[insights.confidence] ?? ""}>
{t("insights.confidence")}: {t(`insights.confidence_${insights.confidence}`)}
{t("insights.confidence")}:{" "}
{t(`insights.confidence_${insights.confidence}`)}
</Badge>
{generate.data?.cached && (
<Badge variant="outline" className="text-xs">
@@ -132,10 +138,22 @@ export function InsightsCard() {
)}
</div>
<Section title={t("insights.sectionChanged")} text={insights.changed} />
<Section title={t("insights.sectionStable")} text={insights.stable} />
<Section title={t("insights.sectionDrivers")} text={insights.drivers} />
<Section title={t("insights.sectionNextSteps")} text={insights.nextSteps} />
<Section
title={t("insights.sectionChanged")}
text={insights.changed}
/>
<Section
title={t("insights.sectionStable")}
text={insights.stable}
/>
<Section
title={t("insights.sectionDrivers")}
text={insights.drivers}
/>
<Section
title={t("insights.sectionNextSteps")}
text={insights.nextSteps}
/>
<div className="bg-muted/50 rounded-lg p-3">
<p className="text-muted-foreground text-xs">
+1 -1
View File
@@ -116,7 +116,7 @@ export function AuthShell({ children }: { children: React.ReactNode }) {
) : null}
<a
href="#main-content"
className="bg-primary text-primary-foreground fixed left-2 top-2 z-[100] rounded-md px-4 py-2 text-sm font-medium opacity-0 focus:opacity-100"
className="bg-primary text-primary-foreground fixed top-2 left-2 z-[100] rounded-md px-4 py-2 text-sm font-medium opacity-0 focus:opacity-100"
>
{t("nav.skipToContent")}
</a>
@@ -545,7 +545,11 @@ export function MeasurementList({ onEdit }: MeasurementListProps) {
</div>
{editError && (
<div role="alert" aria-live="assertive" className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm">
<div
role="alert"
aria-live="assertive"
className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm"
>
{editError}
</div>
)}
@@ -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<string | null>(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 (
<Badge variant="secondary" className="gap-1 bg-green-500/20 text-green-400">
<Badge
variant="secondary"
className="gap-1 bg-green-500/20 text-green-400"
>
<Check className="h-3 w-3" />
{t("medications.intakeStatusTaken")}
</Badge>
@@ -398,7 +397,9 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
currentDir={sortDir}
onSort={toggleSort}
/>
<TableHead className="w-32">{t("medications.intakeStatus")}</TableHead>
<TableHead className="w-32">
{t("medications.intakeStatus")}
</TableHead>
<SortableHead
column="source"
label={t("medications.intakeSource")}
@@ -419,9 +420,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
{formatDateTime(event.scheduledFor)}
</TableCell>
<TableCell className="text-muted-foreground text-sm tabular-nums">
{event.takenAt
? formatDateTime(event.takenAt)
: "—"}
{event.takenAt ? formatDateTime(event.takenAt) : "—"}
</TableCell>
<TableCell>{getStatusBadge(event)}</TableCell>
<TableCell>
@@ -442,7 +441,9 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
</Button>
<DeleteButton
label={t("medications.intakeDeleteConfirm")}
description={t("medications.intakeDeleteDescription")}
description={t(
"medications.intakeDeleteDescription",
)}
onConfirm={() => deleteMutation.mutate(event.id)}
/>
</div>
@@ -467,7 +468,8 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
</p>
{event.takenAt && (
<p className="text-muted-foreground text-xs tabular-nums">
{t("medications.intakeTakenAt")}: {formatDateTime(event.takenAt)}
{t("medications.intakeTakenAt")}:{" "}
{formatDateTime(event.takenAt)}
</p>
)}
<div className="flex items-center gap-1.5">
@@ -550,7 +552,9 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
</div>
<div className="space-y-2">
<Label htmlFor="edit-takenAt">{t("medications.intakeTakenAt")}</Label>
<Label htmlFor="edit-takenAt">
{t("medications.intakeTakenAt")}
</Label>
<Input
id="edit-takenAt"
type="datetime-local"
@@ -575,7 +579,11 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
</div>
{editError && (
<div role="alert" aria-live="assertive" className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm">
<div
role="alert"
aria-live="assertive"
className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm"
>
{editError}
</div>
)}
@@ -688,7 +696,9 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
</div>
<div className="space-y-2">
<Label htmlFor="create-takenAt">{t("medications.intakeTakenAt")}</Label>
<Label htmlFor="create-takenAt">
{t("medications.intakeTakenAt")}
</Label>
<Input
id="create-takenAt"
type="datetime-local"
@@ -713,7 +723,11 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
</div>
{createError && (
<div role="alert" aria-live="assertive" className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm">
<div
role="alert"
aria-live="assertive"
className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm"
>
{createError}
</div>
)}
+10 -3
View File
@@ -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({
</div>
{error && (
<div role="alert" aria-live="assertive" className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm">
<div
role="alert"
aria-live="assertive"
className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm"
>
{error}
</div>
)}
+5 -1
View File
@@ -162,7 +162,11 @@ export function MoodForm({ onSuccess, onCancel }: MoodFormProps) {
</div>
{error && (
<div role="alert" aria-live="assertive" className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm">
<div
role="alert"
aria-live="assertive"
className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm"
>
{error}
</div>
)}
+21 -6
View File
@@ -76,7 +76,6 @@ const MOOD_LABEL_KEYS: Record<string, string> = {
LAUSIG: "mood.levelLausig",
};
interface MoodEntry {
id: string;
date: string;
@@ -342,7 +341,11 @@ export function MoodList() {
<TableCell className="pl-4 font-semibold tabular-nums">
{entry.score}{" "}
<span className="text-muted-foreground font-normal">
({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}
)
</span>
</TableCell>
<TableCell className="text-muted-foreground text-sm">
@@ -388,7 +391,7 @@ export function MoodList() {
className="bg-card border-border flex items-center justify-between rounded-lg border p-3"
>
<div className="flex items-center gap-2.5 overflow-hidden">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted">
<div className="bg-muted flex h-8 w-8 shrink-0 items-center justify-center rounded-lg">
<span className="text-lg font-bold tabular-nums">
{entry.score}
</span>
@@ -397,7 +400,11 @@ export function MoodList() {
<span className="text-sm font-semibold tabular-nums">
{entry.score}{" "}
<span className="text-muted-foreground font-normal">
({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}
)
</span>
</span>
<p className="text-muted-foreground truncate text-xs">
@@ -471,7 +478,11 @@ export function MoodList() {
<form onSubmit={submitEdit} className="space-y-4">
<div className="space-y-2">
<Label id="edit-mood-level-label">{t("mood.moodLevel")}</Label>
<div role="radiogroup" aria-labelledby="edit-mood-level-label" className="grid grid-cols-5 gap-2">
<div
role="radiogroup"
aria-labelledby="edit-mood-level-label"
className="grid grid-cols-5 gap-2"
>
{MOOD_LEVELS_LIST.map((level) => {
const isSelected = editMood === level;
return (
@@ -530,7 +541,11 @@ export function MoodList() {
</div>
{editError && (
<div role="alert" aria-live="assertive" className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm">
<div
role="alert"
aria-live="assertive"
className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm"
>
{editError}
</div>
)}
+9 -5
View File
@@ -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 (
<section
@@ -176,14 +180,14 @@ export function AboutSection() {
) : (
<dl className="flex flex-wrap items-baseline gap-x-6 gap-y-3 text-sm">
<div className="flex items-baseline gap-2">
<dt className="text-muted-foreground text-xs uppercase tracking-wide">
<dt className="text-muted-foreground text-xs tracking-wide uppercase">
{t("settings.about.version")}
</dt>
<dd className="font-mono font-medium">v{version.version}</dd>
</div>
<div className="flex items-baseline gap-2">
<dt className="text-muted-foreground text-xs uppercase tracking-wide">
<dt className="text-muted-foreground text-xs tracking-wide uppercase">
{t("settings.about.license")}
</dt>
<dd className="font-mono">{version.license}</dd>
@@ -191,7 +195,7 @@ export function AboutSection() {
{version.buildSha && (
<div className="flex items-baseline gap-2">
<dt className="text-muted-foreground text-xs uppercase tracking-wide">
<dt className="text-muted-foreground text-xs tracking-wide uppercase">
{t("settings.about.gitSha")}
</dt>
<dd className="font-mono">{version.buildSha.slice(0, 7)}</dd>
@@ -200,7 +204,7 @@ export function AboutSection() {
{version.builtAt && (
<div className="flex items-baseline gap-2">
<dt className="text-muted-foreground text-xs uppercase tracking-wide">
<dt className="text-muted-foreground text-xs tracking-wide uppercase">
{t("settings.about.builtAt", { time: "" }).trim() ||
t("settings.about.gitSha")}
</dt>
+10 -6
View File
@@ -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"
>
<option value="">{t("settings.ai.providerOptionDefault")}</option>
<option value="OPENAI">{t("settings.ai.providerOptionOpenai")}</option>
<option value="OPENAI">
{t("settings.ai.providerOptionOpenai")}
</option>
<option value="ANTHROPIC">
{t("settings.ai.providerOptionAnthropic")}
</option>
<option value="LOCAL">{t("settings.ai.providerOptionLocal")}</option>
<option value="LOCAL">
{t("settings.ai.providerOptionLocal")}
</option>
</select>
</div>
+15 -8
View File
@@ -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 (
<div
id={id}
className="bg-card border-border scroll-mt-28 rounded-xl border p-6 space-y-5"
className="bg-card border-border scroll-mt-28 space-y-5 rounded-xl border p-6"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
@@ -138,7 +145,7 @@ export function ThresholdsSection({ id }: { id: string }) {
</p>
{isLoading || !data ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2 className="h-4 w-4 animate-spin" />
{t("common.loading")}
</div>
@@ -202,7 +209,7 @@ function MetricRow({
const defaultRange = effective?.default;
return (
<div className="rounded-lg border border-border p-4 space-y-3">
<div className="border-border space-y-3 rounded-lg border p-4">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-medium">{t(METRIC_LABEL_KEYS[metric])}</p>
@@ -212,7 +219,7 @@ function MetricRow({
: t("thresholds.unsetExplanation")}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<div className="flex shrink-0 items-center gap-2">
<Label htmlFor={`override-${metric}`} className="text-xs">
{overrideMode
? t("thresholds.overrideModeLabel")
@@ -300,8 +307,8 @@ function MetricRow({
defaultRange &&
(override!.min < defaultRange.greenMin * 0.7 ||
override!.max > defaultRange.greenMax * 1.3) && (
<div className="flex items-start gap-2 rounded-md border-l-2 border-dracula-orange/50 bg-dracula-orange/5 p-2 text-xs">
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0 text-dracula-orange" />
<div className="border-dracula-orange/50 bg-dracula-orange/5 flex items-start gap-2 rounded-md border-l-2 p-2 text-xs">
<AlertTriangle className="text-dracula-orange mt-0.5 h-3 w-3 shrink-0" />
<span>{t("thresholds.overrideWarning")}</span>
</div>
)}
+1 -4
View File
@@ -85,10 +85,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn(
"flex flex-col gap-2 pr-9 text-left",
className,
)}
className={cn("flex flex-col gap-2 pr-9 text-left", className)}
{...props}
/>
);
+1 -4
View File
@@ -23,10 +23,7 @@ export function PasswordStrength({
minLength = 12,
}: PasswordStrengthProps) {
const { t, locale } = useTranslations();
const { translate } = useMemo(
() => getZxcvbnTranslations(locale),
[locale],
);
const { translate } = useMemo(() => getZxcvbnTranslations(locale), [locale]);
const [result, setResult] = useState<{
score: number;
feedback: { warning: string; suggestions: string[] };
+69 -17
View File
@@ -8,7 +8,9 @@ const DE_PATH = join(ROOT, "messages/de.json");
// JSON.parse silently keeps the last value when an object has duplicate keys,
// which lets shadowed keys hide bugs. Walk the source manually to surface them.
function findDuplicateKeys(src: string): { path: string; line: number; firstLine: number }[] {
function findDuplicateKeys(
src: string,
): { path: string; line: number; firstLine: number }[] {
const dups: { path: string; line: number; firstLine: number }[] = [];
const pathStack: string[] = [];
let i = 0;
@@ -22,8 +24,10 @@ function findDuplicateKeys(src: string): { path: string; line: number; firstLine
while (i < src.length) {
const c = src[i];
if (c === " " || c === "\t" || c === "\r") i++;
else if (c === "\n") { i++; line++; }
else break;
else if (c === "\n") {
i++;
line++;
} else break;
}
}
@@ -55,23 +59,46 @@ function findDuplicateKeys(src: string): { path: string; line: number; firstLine
const c = src[i];
if (c === "{") return parseObject();
if (c === "[") return parseArray();
if (c === '"') { readString(); return; }
if (c === '"') {
readString();
return;
}
while (i < src.length) {
const ch = src[i];
if (ch === "," || ch === "}" || ch === "]" || ch === " " || ch === "\n" || ch === "\t" || ch === "\r") break;
if (
ch === "," ||
ch === "}" ||
ch === "]" ||
ch === " " ||
ch === "\n" ||
ch === "\t" ||
ch === "\r"
)
break;
if (ch === "\n") line++;
i++;
}
}
function parseArray() {
i++; skipWs();
if (src[i] === "]") { i++; return; }
i++;
skipWs();
if (src[i] === "]") {
i++;
return;
}
while (true) {
parseValue();
skipWs();
if (src[i] === ",") { i++; skipWs(); continue; }
if (src[i] === "]") { i++; return; }
if (src[i] === ",") {
i++;
skipWs();
continue;
}
if (src[i] === "]") {
i++;
return;
}
err("expected , or ] in array");
}
}
@@ -80,13 +107,20 @@ function findDuplicateKeys(src: string): { path: string; line: number; firstLine
i++;
const seen = new Map<string, number>();
skipWs();
if (src[i] === "}") { i++; return; }
if (src[i] === "}") {
i++;
return;
}
while (true) {
skipWs();
const { value: key, line: keyLine } = readString();
const seenAt = seen.get(key);
if (seenAt != null) {
dups.push({ path: [...pathStack, key].join("."), line: keyLine, firstLine: seenAt });
dups.push({
path: [...pathStack, key].join("."),
line: keyLine,
firstLine: seenAt,
});
} else {
seen.set(key, keyLine);
}
@@ -97,8 +131,14 @@ function findDuplicateKeys(src: string): { path: string; line: number; firstLine
parseValue();
pathStack.pop();
skipWs();
if (src[i] === ",") { i++; continue; }
if (src[i] === "}") { i++; return; }
if (src[i] === ",") {
i++;
continue;
}
if (src[i] === "}") {
i++;
return;
}
err("expected , or } in object");
}
}
@@ -114,7 +154,10 @@ describe("i18n locale file integrity", () => {
dups,
`Duplicate keys silently shadow earlier definitions:\n` +
dups
.map((d) => ` ${d.path} at line ${d.line} (first defined at line ${d.firstLine})`)
.map(
(d) =>
` ${d.path} at line ${d.line} (first defined at line ${d.firstLine})`,
)
.join("\n"),
).toEqual([]);
});
@@ -125,14 +168,23 @@ describe("i18n locale file integrity", () => {
dups,
`Duplicate keys silently shadow earlier definitions:\n` +
dups
.map((d) => ` ${d.path} at line ${d.line} (first defined at line ${d.firstLine})`)
.map(
(d) =>
` ${d.path} at line ${d.line} (first defined at line ${d.firstLine})`,
)
.join("\n"),
).toEqual([]);
});
it("en and de share the same key shape (locale parity)", () => {
const en = JSON.parse(readFileSync(EN_PATH, "utf8")) as Record<string, unknown>;
const de = JSON.parse(readFileSync(DE_PATH, "utf8")) as Record<string, unknown>;
const en = JSON.parse(readFileSync(EN_PATH, "utf8")) as Record<
string,
unknown
>;
const de = JSON.parse(readFileSync(DE_PATH, "utf8")) as Record<
string,
unknown
>;
function flatten(obj: unknown, prefix: string, out: string[]) {
if (obj == null || typeof obj !== "object") return;
+10 -9
View File
@@ -205,9 +205,11 @@ describe("withIdempotency", () => {
describe("defaultUserIdResolver (audit C-4)", () => {
function mockHeader(value: string | null) {
vi.mocked(headers).mockResolvedValue({
get: vi.fn().mockImplementation((name: string) =>
name.toLowerCase() === "authorization" ? value : null,
),
get: vi
.fn()
.mockImplementation((name: string) =>
name.toLowerCase() === "authorization" ? value : null,
),
} as unknown as ReturnType<typeof headers> extends Promise<infer T>
? T
: never);
@@ -291,9 +293,7 @@ describe("withIdempotency body-content exclusion (P12)", () => {
'{"data":{"echoed":"sk-ant-api03-xyz"},"error":null}',
],
])("does NOT cache responses containing %s", async (_label, body) => {
const handler = vi.fn(
async () => new NextResponse(body, { status: 201 }),
);
const handler = vi.fn(async () => new NextResponse(body, { status: 201 }));
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
await wrapped(makeRequest("POST", { "idempotency-key": "abc-12345678" }));
expect(handler).toHaveBeenCalledTimes(1);
@@ -310,10 +310,11 @@ describe("withIdempotency body-content exclusion (P12)", () => {
const handler = vi.fn(
async () => new NextResponse(body, { status: 422 }),
);
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
await wrapped(
makeRequest("POST", { "idempotency-key": "key-12345678" }),
const wrapped = withIdempotency<[NextRequest]>(
handler,
async () => "u-1",
);
await wrapped(makeRequest("POST", { "idempotency-key": "key-12345678" }));
expect(prisma.idempotencyKey.create).toHaveBeenCalledTimes(1);
},
);
+3 -1
View File
@@ -68,7 +68,9 @@ describe("moodLog secret encrypt-at-rest", () => {
for (const update of updates) {
// Each rotation must produce a value that decrypts back to the
// original legacy plaintext — no data loss.
const original = rows.find((r) => r.id === update.id)!.moodLogWebhookSecret;
const original = rows.find(
(r) => r.id === update.id,
)!.moodLogWebhookSecret;
expect(readMoodLogSecret(update.encrypted)).toBe(original);
}
Loaded 100 of 135 files, more files were not shown because too many files have changed in this diff. Show more