mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
1 parent
67575180b8
commit
02b99553b9
135 files changed
+2159
-1583
No files matched your search
+12
-12
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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[]) ?? [],
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
@@ -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({
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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) },
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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" } });
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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:") {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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"),
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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[] };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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
Reference in new issue
Block a user