mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 12:22:03 +00:00
Compare commits
No files matched your search
@@ -39,3 +39,10 @@ next-env.d.ts
|
||||
|
||||
# prisma generated
|
||||
/src/generated/prisma
|
||||
|
||||
# AI session artefacts (kept locally, not committed)
|
||||
/docs/superpowers/
|
||||
.claude/
|
||||
.cursor/
|
||||
.codex/
|
||||
.aider*
|
||||
@@ -0,0 +1,30 @@
|
||||
# Gitleaks configuration. Extends the built-in default ruleset and adds
|
||||
# a project-specific allowlist for false-positives that gitleaks 8.x flags
|
||||
# as "generic-api-key" inside test fixtures.
|
||||
[extend]
|
||||
useDefault = true
|
||||
|
||||
[allowlist]
|
||||
description = "Project-wide ignores for test fixtures and known non-secret tokens"
|
||||
|
||||
paths = [
|
||||
# All test files contain hardcoded fake tokens / idempotency-keys.
|
||||
'''(^|/)__tests__/''',
|
||||
'''\.test\.ts$''',
|
||||
# OpenAPI spec contains example tokens (`hlk_example…`) used to document the schema.
|
||||
'''^docs/api/openapi\.yaml$''',
|
||||
]
|
||||
|
||||
# Substring matches — anything matching is treated as non-secret regardless of file.
|
||||
regexes = [
|
||||
# Idempotency-Key request-header values are random UUIDs / opaque strings,
|
||||
# not secrets. The header name itself just trips the generic-api-key rule.
|
||||
'''idempotency-key''',
|
||||
# Allow the placeholder prefix used in OpenAPI examples + docs.
|
||||
'''hlk_example''',
|
||||
]
|
||||
|
||||
# Specific commit fingerprints from prior false-positive findings (kept for reproducibility).
|
||||
# Empty by default; populate via `gitleaks detect --report-path` if a future regression
|
||||
# triggers the same false-positive on a stable commit hash.
|
||||
stopwords = []
|
||||
@@ -6,21 +6,21 @@ Instructions for AI coding agents (OpenAI Codex, Claude Code, Cursor, etc.) work
|
||||
|
||||
**HealthLog** — a personal health-tracking web app (weight, blood pressure, pulse, mood, medication compliance) with Withings integration, moodLog.app sync, Dracula-themed UI, mobile-first PWA design.
|
||||
|
||||
**Status**: v1.2.0 (Custom Thresholds, Blood Glucose, Dashboard Customization, Built-in Feedback, Multi-Provider AI). See GitHub Issues for open tasks.
|
||||
**Status**: v1.3.3 — Pulse oximetry (SpO₂) as a first-class measurement type, layered on top of v1.3.2 body composition (TBW + Bone Mass). SSRF-hardened outbound fetches (now also covers Web-Push endpoint + Bearer-scope wildcard handling + IP-geolocation HTTPS-only), GHCR multi-arch images (`linux/amd64` + `linux/arm64`) with SLSA provenance + SBOM, pg-boss graceful SIGTERM drain + audit-log retention purge (GDPR Art. 5(1)(e)), blocking TypeScript CI, locale-integrity test guard. moodLog webhook secret now AES-GCM encrypted at rest. See GitHub Releases + CHANGELOG.md for the full feature timeline (v1.0 → v1.3).
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology | Version | Notes |
|
||||
| --------------- | -------------------- | ------- | ----------------------------------------------------------------------- |
|
||||
| Framework | Next.js (App Router) | 16 | TypeScript strict, RSC default, `"use client"` only for interactivity |
|
||||
| ORM | Prisma | 7.4 | Uses `PrismaPg` adapter, **not** `url` in schema — see gotchas below |
|
||||
| ORM | Prisma | 7.8 | Uses `PrismaPg` adapter, **not** `url` in schema — see gotchas below |
|
||||
| Database | PostgreSQL | 16 | Docker Compose service, port 5432, user `healthlog` |
|
||||
| UI | shadcn/ui (new-york) | latest | Components in `src/components/ui/` |
|
||||
| Theme | Dracula | — | CSS variables in `globals.css`, dark mode default, `--dracula-*` tokens |
|
||||
| CSS | Tailwind | 4 | CSS-first config (`@import "tailwindcss"` syntax) |
|
||||
| Data fetching | TanStack Query | 5 | Provider in `src/components/providers.tsx` |
|
||||
| Validation | Zod | v4 | Import as `zod/v4` (not `zod`) |
|
||||
| Testing | Vitest | latest | Config in `vitest.config.ts` |
|
||||
| Testing | Vitest | latest | Config in `vitest.config.mts` |
|
||||
| Package manager | pnpm | latest | **Not** npm or yarn |
|
||||
| Node | 20.x | via nvm | |
|
||||
| Job queue | pg-boss | 12 | Named import `{ PgBoss }`, see gotchas |
|
||||
@@ -76,10 +76,10 @@ src/
|
||||
│ ├── medications/page.tsx # Medications management
|
||||
│ ├── notifications/page.tsx # Notification preferences matrix
|
||||
│ ├── onboarding/page.tsx # 4-step guided onboarding
|
||||
│ ├── settings/page.tsx # All settings (13 sections, ~2100 lines)
|
||||
│ ├── settings/page.tsx # All settings (8 top-level sections, ~3150 lines — split tracked for 1.4.0)
|
||||
│ ├── mood/page.tsx # Mood tracking
|
||||
│ ├── targets/page.tsx # Target values dashboard
|
||||
│ └── api/ # ~60 API route files (see docs/api.md)
|
||||
│ └── api/ # 100+ API route files (admin, auth, measurements, medications, mood, insights, integrations, ingest, dashboard, feedback, tokens, notifications, monitoring, …)
|
||||
│ ├── mood-entries/ # Mood CRUD
|
||||
│ ├── import/ # JSON data import
|
||||
│ ├── doctor-report/ # Doctor report data aggregation
|
||||
@@ -93,7 +93,9 @@ src/
|
||||
│ ├── measurements/ # Measurement form, list
|
||||
│ ├── mood/ # Mood form, mood list
|
||||
│ ├── charts/ # Recharts wrappers, compliance charts
|
||||
│ ├── insights/ # AI insights card
|
||||
│ ├── insights/ # AI insights cards (status, advisor)
|
||||
│ ├── gamification/ # Achievement cards, progress
|
||||
│ ├── settings/ # Settings-page section components
|
||||
│ └── monitoring/ # Umami, GlitchTip bootstrap
|
||||
├── lib/
|
||||
│ ├── db.ts # Prisma client singleton
|
||||
@@ -109,6 +111,13 @@ src/
|
||||
│ ├── notifications/ # Dispatcher, types, senders (telegram, ntfy, web-push)
|
||||
│ ├── jobs/ # pg-boss worker (reminders, insights, backups)
|
||||
│ ├── validations/ # Zod schemas (shared between API + client)
|
||||
│ ├── ai/ # Multi-provider client (OpenAI, Anthropic, local OpenAI-compat)
|
||||
│ ├── feedback/ # In-app feedback + optional GitHub escalation
|
||||
│ ├── gamification/ # Achievement definitions + progress
|
||||
│ ├── insights/ # Insight pipeline + prompts
|
||||
│ ├── logging/ # Wide Events: builder, context, sampler, transports, background
|
||||
│ ├── moodlog/ # moodLog.app webhook + sync
|
||||
│ ├── monitoring/ # Umami / GlitchTip server-side hooks
|
||||
│ └── withings/ # Withings OAuth client + sync service
|
||||
├── hooks/
|
||||
│ └── use-auth.ts # useAuth(), useLogout() hooks
|
||||
@@ -117,8 +126,8 @@ messages/
|
||||
├── de.json # German translations (primary UI language)
|
||||
└── en.json # English translations
|
||||
prisma/
|
||||
├── schema.prisma # Database schema (23 models)
|
||||
└── migrations/ # Migration files (0001–0018)
|
||||
├── schema.prisma # Database schema (25 models)
|
||||
└── migrations/ # Migration files (0001–0024; latest: oxygen_saturation)
|
||||
prisma.config.ts # Prisma config (DB URL lives here, NOT in schema)
|
||||
public/
|
||||
├── sw.js # Service worker (Web Push + offline caching)
|
||||
@@ -167,15 +176,15 @@ These are hard-won lessons. Ignoring them will cause errors:
|
||||
|
||||
### Settings Page
|
||||
|
||||
- One large file (~2100 lines), 13 sections. Sidebar switches to "settings mode" showing section shortcuts.
|
||||
- One large file (~3150 lines), 8 top-level sections. Sidebar switches to "settings mode" showing section shortcuts. Splitting into per-section files is tracked for 1.4.0 — until then, ESLint `react-hooks/set-state-in-effect` stays non-blocking because of the long-standing violations in this file.
|
||||
- Sections scroll-to with highlight animation (`section-highlight` CSS class).
|
||||
- Section IDs: `profil`, `sprache`, `sicherheit`, `telegram`, `ntfy`, `web-push`, `insights`, `withings`, `api`, `export`, `protokoll`, `daten`.
|
||||
- Top-level section IDs: `section-allgemein`, `section-sicherheit`, `section-benachrichtigungen`, `section-personalization`, `section-integration`, `section-api`, `section-export`, `section-danger-zone`. Sub-anchors inside those sections include `profil`, `passwort`, `passkeys`, `telegram`, `ntfy`, `web-push`, `insights`, `dashboard-layout`, `thresholds`, `withings`, `moodlog`, `api-tokens`, `api-endpoints`.
|
||||
|
||||
### Insights Page
|
||||
|
||||
- Large file (~1800+ lines), 7 content sections with AI summaries.
|
||||
- Large file (~1750 lines), 7 content sections with AI summaries.
|
||||
- Sticky horizontal chip navigation (`InsightsSectionNav` component) with IntersectionObserver for active state tracking.
|
||||
- Section IDs: `general`, `bp`, `weight`, `pulse`, `mood`, `medications`, `bmi`.
|
||||
- Section IDs: `general`, `bp`, `weight`, `pulse`, `mood`, `meds`, `bmi`.
|
||||
- Each section has `scroll-mt-28` for header offset on scroll.
|
||||
|
||||
### Sidebar
|
||||
@@ -192,8 +201,8 @@ These are hard-won lessons. Ignoring them will cause errors:
|
||||
|
||||
### Notification System
|
||||
|
||||
- **Channels**: `TELEGRAM`, `NTFY`, `WEB_PUSH` (DB: `NotificationChannel`).
|
||||
- **Event types**: `MEDICATION_REMINDER`, `MEASUREMENT_ANOMALY`, `COMPLIANCE_LOW`, `WITHINGS_SYNC_FAILED`, `SYSTEM_ALERT`.
|
||||
- **Channels**: `telegram`, `ntfy`, `web_push` — stored as plain `String` on `NotificationChannel.type` (no Prisma enum; the canonical list lives in `src/lib/notifications/types.ts`).
|
||||
- **Event types**: `medication_reminder`, `measurement_anomaly`, `compliance_low`, `withings_sync_failed`, `system_alert` — also `String` on `NotificationPreference.eventType`. Source of truth is the discriminated union in `src/lib/notifications/types.ts`.
|
||||
- **Opt-out model**: Preferences default to ON (enabled) when no `NotificationPreference` row exists.
|
||||
- **Dispatcher**: `src/lib/notifications/dispatcher.ts` checks channel enabled + preference per event type.
|
||||
- **Telegram**: Inline buttons (Take/Skip/Snooze 1h/Snooze 3h), pre-end window reminders (30 min before), message auto-cleanup after 24h.
|
||||
@@ -207,7 +216,7 @@ These are hard-won lessons. Ignoring them will cause errors:
|
||||
|
||||
## Database Models (Prisma)
|
||||
|
||||
23 models: `User`, `Passkey`, `Session`, `AuthChallenge`, `Measurement`, `Medication`, `MedicationSchedule`, `MedicationIntakeEvent`, `ReminderPhaseConfig`, `TelegramReminderMessage`, `TelegramScheduledDeletion`, `ApiToken`, `WithingsConnection`, `MoodEntry`, `AppSettings`, `Feedback`, `AuditLog`, `NotificationChannel`, `NotificationPreference`, `PushSubscription`, `DataBackup`, `UserAchievement`, `RateLimit`.
|
||||
25 models: `User`, `Passkey`, `Session`, `AuthChallenge`, `Measurement`, `Medication`, `MedicationSchedule`, `MedicationIntakeEvent`, `ReminderPhaseConfig`, `TelegramReminderMessage`, `TelegramScheduledDeletion`, `ApiToken`, `WithingsConnection`, `MoodEntry`, `AppSettings`, `Feedback`, `AuditLog`, `NotificationChannel`, `NotificationPreference`, `PushSubscription`, `DataBackup`, `UserAchievement`, `RateLimit`, `Device`, `IdempotencyKey`.
|
||||
|
||||
## When Making Changes
|
||||
|
||||
|
||||
+131
@@ -1,5 +1,136 @@
|
||||
# Changelog
|
||||
|
||||
## [1.3.3] — 2026-05-08
|
||||
|
||||
### Added
|
||||
|
||||
- **Pulse oximetry as a first-class measurement type (`OXYGEN_SATURATION`).**
|
||||
Closes the SpO2 part of #109. Migration `0024_oxygen_saturation` extends
|
||||
the `MeasurementType` enum. Plausibility range 50–100% (below 50% is
|
||||
incompatible with sustained life and almost certainly a faulty sensor;
|
||||
upper bound 100% is physical). Default severity bands follow BTS Guideline
|
||||
2017 + ATS clinical practice: green 95–100%, orange 92–94%, red <92% —
|
||||
lower-only concern (the upper orange wing collapses onto greenMax since
|
||||
saturation cannot physically exceed 100%). COPD / chronic-respiratory
|
||||
users with a doctor-set baseline of 88–92% can personalize via the
|
||||
threshold-override UI. Wired through Withings (ScanWatch type 54),
|
||||
measurement form, list, charts, doctor PDF, OpenAPI spec, and i18n (DE +
|
||||
EN). iOS DTO already declared `OXYGEN_SATURATION` from a prior commit;
|
||||
the server enum addition closes the long-standing drift.
|
||||
- **Body composition surfaces (TOTAL_BODY_WATER, BONE_MASS, BLOOD_GLUCOSE)
|
||||
in the measurements list filter, badge, mobile icon, edit dialog, and
|
||||
server-rendered doctor-report PDF** — closes the UI side of #109. Root
|
||||
cause was three local maps in `measurement-list.tsx` that drifted from
|
||||
the v1.3 server enum; extracted to `measurement-list-meta.ts` with
|
||||
fail-fast coverage tests so future enum additions are caught at build
|
||||
time. Server-side PDF used a separately-drifted type map vs. the
|
||||
browser-side renderer; both are now in sync.
|
||||
- **Effective-range thresholds for `TOTAL_BODY_WATER` and `BONE_MASS`** —
|
||||
severity logic was returning `nominal` for any value because no defaults
|
||||
existed.
|
||||
|
||||
### Changed
|
||||
|
||||
- **OpenAPI `MeasurementType` enum extended + spec version bumped 1.3.0 →
|
||||
1.3.3** to match the actual app. Spec was lagging by two minor releases.
|
||||
- **Withings webhook secret now reads from `X-Withings-Webhook-Secret`
|
||||
header** in preference to the legacy `?secret=…` URL query parameter.
|
||||
Closes the URL-leak-via-access-logs vector flagged in audit C-3. Legacy
|
||||
query-param path is retained for backwards compatibility and emits a
|
||||
Wide Event warning so operators can spot still-using-the-old-flow
|
||||
integrators. Plan: remove the query fallback in 1.4.x once warnings drain.
|
||||
- **Idempotency `defaultUserIdResolver` now supports Bearer tokens.**
|
||||
Cookie sessions tried first, then Bearer-token via `hashToken` lookup.
|
||||
Without the Bearer fallback, every iOS / external-ingest retry was
|
||||
hitting the handler again and creating duplicate measurements (audit
|
||||
C-4 — the exact use case `withIdempotency` was built for).
|
||||
- **GlitchTip URL stripping** — `reportToGlitchtip` now strips the URL
|
||||
query string before forwarding so Withings legacy `?secret=…` and OAuth
|
||||
`?code=…` callbacks cannot leak via the error tracker (audit H-B7).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Migration `0022_body_composition_metrics` unit comment lied** —
|
||||
claimed `TOTAL_BODY_WATER: percent of body weight (%)` while every other
|
||||
surface (validators, Withings client, doctor PDF) treated it as `kg`.
|
||||
Comment corrected to match reality.
|
||||
|
||||
### Security
|
||||
|
||||
- **Bearer-scope wildcard handling (CRITICAL — V3-1).** `requireAuth()`
|
||||
previously accepted any non-admin token regardless of declared
|
||||
permission scope, so a token with `permissions:["medication:ingest"]`
|
||||
could DELETE the user account. Spec now requires `permissions:["*"]`
|
||||
or the explicit required permission.
|
||||
- **Account-deletion completeness (CRITICAL — V3-2 / GDPR Art. 17).**
|
||||
Cascades through `Feedback` + `AuditLog` rows so user-erasure is
|
||||
actually total. Daily retention job sweeps orphaned audit rows after
|
||||
90 days as a defence-in-depth.
|
||||
- **Withings webhook secret header migration (audit C-3)**, idempotency
|
||||
Bearer-resolver (audit C-4), GlitchTip URL strip (audit H-B7).
|
||||
- **Truthfulness pass on medical citations** — SpO2 normal-range source
|
||||
is now consumer-pulse-oximeter consensus + NICE NG115 + FDA labelling
|
||||
(BTS-2017 was for clinical hypoxaemia thresholds, not consumer
|
||||
monitoring); body-composition metrics are explicitly labelled
|
||||
"bioimpedance-estimated, not DEXA-comparable" in the doctor PDF;
|
||||
TBW citation now references the Watson formula / ICRP Reference Man
|
||||
(was misattributed to ESPEN 2017); steps target now references
|
||||
Saint-Maurice JAMA 2020 (WHO publishes minutes/week, not steps).
|
||||
- **SpO2 user-override clamp** — overrides could emit physical
|
||||
impossibilities (e.g. `orangeMax = 100.75`); clamped to METRIC_BOUNDS
|
||||
for SpO2 + BODY_FAT.
|
||||
- **moodLog webhook secret encrypted at rest with AES-256-GCM** (V3
|
||||
STILL-V2-C-2). Read path tolerates legacy plaintext rows during the
|
||||
transition window; one-shot startup migration in the worker rotates
|
||||
any leftover plaintext rows.
|
||||
- **CSP tightening** — `chatgpt.com` + `api.openai.com` `connect-src`
|
||||
now gated to `/settings/ai/**` (was a global blanket on every page,
|
||||
including `/auth/login` → DOM-XSS exfil channel).
|
||||
- **Web-Push subscription endpoint SSRF guard** — `endpoint` now
|
||||
requires HTTPS + passes `isPublicUrl()` (was `z.url()` only).
|
||||
Side-fix: `isPublicUrl()` no longer falsely classifies DNS labels
|
||||
starting with `fc`/`fd` (e.g. `fcm.googleapis.com`) as IPv6
|
||||
unique-local; the IPv6 check is now gated on a colon being present.
|
||||
- **IP-geolocation lookup is now HTTPS-only.** Default provider is
|
||||
`ipwho.is` (free, HTTPS, no key). Existing `ip-api.com` plaintext
|
||||
HTTP path leaked auth-event IP + timestamp on every login (GDPR Art.
|
||||
32 + Art. 44). Operators can override via `IP_GEO_LOOKUP_URL` (HTTPS
|
||||
only) or disable entirely with `IP_GEO_LOOKUP_DISABLED=1`.
|
||||
- **`/api/ai/test` no longer returns provider error message + body
|
||||
excerpt to the client.** Diagnostics land server-side via Wide Events
|
||||
(annotate); client gets a categorised generic message. Closes provider
|
||||
URL / partial key / internal header leak.
|
||||
- **`/api/import` rate-limit added** — 5 imports/hour/user. Was
|
||||
unlimited (bulk-injection vector).
|
||||
- **Trusted-proxy XFF semantics** — `getClientIp()` now reads
|
||||
`X-Forwarded-For` right-to-left with a configurable
|
||||
`TRUST_PROXY_HOPS` (default 1, matches typical single-proxy
|
||||
self-host). Closes XFF rotation bypass of per-IP rate-limits.
|
||||
- **Audit-log retention job** — `audit_logs` rows older than
|
||||
`AUDIT_LOG_RETENTION_DAYS` (default 365) are purged daily. Closes
|
||||
GDPR Art. 5(1)(e) "storage limitation" gap.
|
||||
- **Idempotency cachable-status filter** is now an exported, unit-tested
|
||||
function — pins the do-not-cache contract for 401/403/408/429/5xx.
|
||||
- **Bearer mock tightening** in `require-auth-bearer.test.ts` +
|
||||
`idempotency.test.ts`: `apiToken.findUnique` calls are now asserted
|
||||
to use `where: { tokenHash: <hashed> }`, so a regression to raw-token
|
||||
comparison would break the suite immediately.
|
||||
|
||||
### Internal
|
||||
|
||||
- **Server-side enum drift cousins closed.** Five module-level
|
||||
hardcoded type-arrays in `/api/insights/comprehensive`,
|
||||
`/api/dashboard/summary`, `/api/analytics`, `/lib/insights/general-status`,
|
||||
`/api/import` are now derived from `measurementTypeEnum.options`.
|
||||
External-contract enums extended additively:
|
||||
`/api/measurements/series` (`oxygen`, `totalBodyWater`, `boneMass`),
|
||||
`/api/dashboard/widgets` (`oxygenSaturation`), `DashboardWidgetId` +
|
||||
`DEFAULT_DASHBOARD_LAYOUT`. New coverage test asserts the canonical
|
||||
enum stays the source of truth.
|
||||
- **Doctor-PDF text-content tests** — replaced bytes-only "renders body
|
||||
composition rows" theatre with `pdf-parse`-driven assertions on the
|
||||
actual rendered DE + EN labels and values. Adds dev dep `pdf-parse`.
|
||||
|
||||
## [1.3.2] — 2026-04-28
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -36,7 +36,7 @@ docker compose logs -f app # Tail app logs
|
||||
## Architecture
|
||||
|
||||
- **Next.js 16** App Router with TypeScript strict. Pages are RSC by default; `"use client"` only for interactivity.
|
||||
- **Prisma 7** ORM with PostgreSQL (23 models). Uses `PrismaPg` adapter from `@prisma/adapter-pg`. Client singleton at `src/lib/db.ts`. Generated client at `src/generated/prisma/client` (note the `/client` suffix). Prisma config in `prisma.config.ts` (not in schema.prisma).
|
||||
- **Prisma 7** ORM with PostgreSQL (25 models). Uses `PrismaPg` adapter from `@prisma/adapter-pg`. Client singleton at `src/lib/db.ts`. Generated client at `src/generated/prisma/client` (note the `/client` suffix). Prisma config in `prisma.config.ts` (not in schema.prisma).
|
||||
- **shadcn/ui** components (new-york style) in `src/components/ui/`. Add new ones via `pnpm dlx shadcn@latest add <component>`.
|
||||
- **Dracula theme** via CSS variables in `globals.css`. Dark mode is default. Use `--dracula-*` tokens for chart colors.
|
||||
- **TanStack Query** for client-side data fetching. Provider in `src/components/providers.tsx`.
|
||||
@@ -44,7 +44,7 @@ docker compose logs -f app # Tail app logs
|
||||
- **API response envelope**: `{ data, error, meta }` via helpers in `src/lib/api-response.ts`.
|
||||
- **apiHandler** wraps ALL API route handlers (`src/lib/api-handler.ts`) — error handling, Wide Event logging, x-request-id propagation.
|
||||
- **Structured Logging** (Wide Events) in `src/lib/logging/` — one JSON event per request/operation via `WideEventBuilder` + `AsyncLocalStorage`. Tail sampling, stdout JSON + optional Loki Push.
|
||||
- **Vitest** for unit testing. Config in `vitest.config.ts`.
|
||||
- **Vitest** for unit testing. Config in `vitest.config.mts`.
|
||||
- **pg-boss** (PostgreSQL-native) for job queue: medication reminders, insight caching, weekly data backups.
|
||||
- **jsPDF** + jspdf-autotable for client-side doctor report PDF generation (`src/lib/doctor-report-pdf.ts`).
|
||||
- **PWA offline** via service worker caching strategies in `public/sw.js` (cache-first for static, network-first for pages, network-only for API).
|
||||
@@ -56,7 +56,7 @@ docker compose logs -f app # Tail app logs
|
||||
- Timezone: `Europe/Berlin` for display, UTC in database.
|
||||
- Sensitive data (Withings tokens, API keys, VAPID private keys) encrypted with AES-256-GCM (`src/lib/crypto.ts`) before DB storage.
|
||||
- Passkeys are primary auth (SimpleWebAuthn v13). Sessions stored server-side in PostgreSQL.
|
||||
- All API mutations require authentication. External ingest uses Bearer token (hashed with SHA-256).
|
||||
- All API mutations require authentication. External ingest uses Bearer token (hashed with HMAC-SHA256 keyed by `API_TOKEN_HMAC_KEY`).
|
||||
- Rate limiting (in-memory sliding window) on auth and external-facing endpoints.
|
||||
|
||||
## File Layout
|
||||
@@ -81,12 +81,12 @@ docker compose logs -f app # Tail app logs
|
||||
- `src/lib/validations/` — Zod schemas shared between API + client
|
||||
- `src/hooks/` — React hooks (`use-auth`)
|
||||
- `messages/de.json` + `messages/en.json` — i18n translations
|
||||
- `prisma/schema.prisma` — database schema (23 models)
|
||||
- `prisma/schema.prisma` — database schema (25 models)
|
||||
- `prisma.config.ts` — Prisma config (DB URL here, not in schema)
|
||||
- `public/sw.js` — Service worker for Web Push notifications + offline caching
|
||||
- `docs/` — architecture, security, assumptions, ops, API docs, ADRs
|
||||
- `docs/STATUS.md` — Current project status + open tasks
|
||||
- `docs/` — long-form audit notes (`docs/audit/`); end-user docs live in the separate site at https://docs.healthlog.dev
|
||||
- `AGENTS.md` — AI agent instructions (Codex, Cursor, etc.)
|
||||
- `CHANGELOG.md` — release notes per semver tag
|
||||
|
||||
## Important Patterns
|
||||
|
||||
@@ -107,3 +107,12 @@ docker compose logs -f app # Tail app logs
|
||||
- **Achievements**: Persistent in `UserAchievement` table. Computed on API call, new unlocks written to DB with stable `unlockedAt` timestamps
|
||||
- **Data backup**: pg-boss `data-backup` queue runs weekly (Sundays 03:00), stores JSON in `DataBackup` model
|
||||
- **Wide Events / Structured Logging**: `apiHandler()` wraps all API routes. Use `annotate()` from `@/lib/logging/context` for business-action annotations. Use `requireAuth()` / `requireAdmin()` from `@/lib/api-handler` (auto-annotates auth). Background jobs use `withBackgroundEvent()`. External calls tracked via `getEvent()?.addExternalCall()`. No `console.log` in production code — use event annotations instead. Env vars: `LOG_LEVEL`, `LOG_SAMPLE_RATE`, `LOG_SLOW_THRESHOLD_MS`, `LOG_INCLUDE_STACK`, `LOKI_ENDPOINT`, `LOKI_USERNAME`, `LOKI_PASSWORD`
|
||||
|
||||
## Headless-Client API Patterns
|
||||
|
||||
The iOS app talks to bearer-protected routes while the web PWA uses cookie sessions; both share the same handlers (`requireAuth()` accepts either credential).
|
||||
|
||||
- **Bearer-token issuance**: `POST /api/auth/login` and `POST /api/auth/passkey/login-verify` return a `token` field for native clients. `isNativeClientRequest()` (`src/lib/auth/issue-token.ts`) opts in via `X-Client-Type: native` header **or** a `User-Agent` starting with `HealthLog-iOS/`. The raw `hlk_<64-hex>` value is returned exactly once; default lifetime 90 days, permissions `["*"]`.
|
||||
- **Token storage**: API tokens are stored as **HMAC-SHA-256** hashes (`hashToken()` in `src/lib/auth/hmac.ts`, keyed by env `API_TOKEN_HMAC_KEY`) — not plain SHA-256. The keyed HMAC defends against precomputed rainbow tables if the DB leaks. Same mechanism is reused for external-ingest token verification.
|
||||
- **Idempotency-Key**: `withIdempotency()` (`src/lib/idempotency.ts`) wraps POST/PUT/PATCH/DELETE handlers so retries with the same `Idempotency-Key` (regex `^[A-Za-z0-9_\-:.]{8,128}$`) for the same `(userId, method, path)` replay the original response with `X-Idempotent-Replay: true` for 24h. **NOT cached**: 401, 403, 408, 429, any 5xx, and any body containing `hlk_` (defence against caching a token-issuing response). Validation 4xx (422 etc.) **is** cached so a broken retry doesn't re-hit the DB. `userIdResolver` defaults to the cookie session — pass a 2nd argument for Bearer-only routes.
|
||||
- **Device-registration cross-user-hijack guard**: `POST /api/devices` rejects re-registration of an existing APNs token under a different user with **409** (`Device token already registered to another account`) instead of silently transferring ownership. APNs tokens aren't a secret, so trusting wire input would let anyone who learns a token redirect another user's pushes. Audit trail: `device.register.denied` with reason `token_owned_by_other_user`.
|
||||
+29
-16
@@ -4,7 +4,7 @@ Contributions are welcome. This guide covers how to set up the project and submi
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 22+
|
||||
- Node.js 20+
|
||||
- pnpm
|
||||
- PostgreSQL 16+ (or Docker)
|
||||
- Git
|
||||
@@ -20,13 +20,21 @@ cp .env.example .env
|
||||
Edit `.env` and configure at minimum:
|
||||
|
||||
```
|
||||
DATABASE_URL="postgresql://healthlog:healthlog@localhost:5432/healthlog"
|
||||
POSTGRES_PASSWORD=<base64, 24 bytes>
|
||||
DATABASE_URL="postgresql://healthlog:${POSTGRES_PASSWORD}@db:5432/healthlog"
|
||||
SESSION_SECRET=<64-char hex>
|
||||
ENCRYPTION_KEY=<64-char hex>
|
||||
API_TOKEN_HMAC_KEY=<64-char hex>
|
||||
```
|
||||
|
||||
Generate secrets with `openssl rand -hex 32`.
|
||||
Generate the four secrets:
|
||||
|
||||
```bash
|
||||
echo "POSTGRES_PASSWORD=$(openssl rand -base64 24)" >> .env
|
||||
echo "SESSION_SECRET=$(openssl rand -hex 32)" >> .env
|
||||
echo "ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .env
|
||||
echo "API_TOKEN_HMAC_KEY=$(openssl rand -hex 32)" >> .env
|
||||
```
|
||||
|
||||
### With Docker (database only)
|
||||
|
||||
@@ -47,23 +55,28 @@ docker compose up -d
|
||||
## Development Workflow
|
||||
|
||||
```bash
|
||||
pnpm dev # Start dev server (http://localhost:3000)
|
||||
pnpm lint # ESLint
|
||||
pnpm format # Prettier
|
||||
pnpm typecheck # TypeScript strict mode
|
||||
pnpm test # Vitest
|
||||
pnpm dev # Start dev server (http://localhost:3000)
|
||||
pnpm lint # ESLint
|
||||
pnpm format:check # Prettier (check)
|
||||
pnpm typecheck # TypeScript strict mode
|
||||
pnpm test # Vitest
|
||||
pnpm build # Next.js production build
|
||||
```
|
||||
|
||||
All four checks must pass before submitting a PR.
|
||||
All five checks must pass before submitting a PR. The full one-liner that mirrors CI:
|
||||
|
||||
```bash
|
||||
pnpm typecheck && pnpm lint && pnpm test && pnpm format:check && pnpm build
|
||||
```
|
||||
|
||||
## Code Conventions
|
||||
|
||||
- **TypeScript strict mode** -- no `any` types, no type assertions unless absolutely necessary
|
||||
- **UI text in German**, code/comments/docs in English
|
||||
- **API responses** follow `{ data, error, meta }` envelope pattern
|
||||
- **Zod v4** for input validation (API routes and forms)
|
||||
- **shadcn/ui** components (new-york style) for UI primitives
|
||||
- **Dracula theme** -- dark mode only, use `--dracula-*` CSS tokens
|
||||
- **TypeScript strict mode** -- avoid `any`. Where it's truly unavoidable (e.g. variadic Next.js handler signatures in `src/lib/api-handler.ts`), document the reason inline.
|
||||
- **English-default UI** -- the default locale is English. German is user-selectable. Code, comments, commit messages, and docs are English. All user-facing strings go through `t("key")` with translations in both `messages/en.json` and `messages/de.json`. The `i18n-locale-integrity.test.ts` suite blocks merges if keys drift between locales or duplicate at any nesting depth.
|
||||
- **API responses** follow the `{ data, error }` envelope via `apiSuccess()` / `apiError()` in `src/lib/api-response.ts`. Wrap every route handler in `apiHandler()` (`src/lib/api-handler.ts`) for error handling, structured Wide-Event logging, and `x-request-id` propagation.
|
||||
- **Zod v4** (`import { ... } from "zod/v4"`) for input validation in API routes and forms.
|
||||
- **shadcn/ui** components (new-york style) for UI primitives.
|
||||
- **Dracula theme** -- dark mode is the default; a `light`/`system` toggle is exposed in the topbar. Chart and accent colors use the `--dracula-*` CSS tokens so both themes stay coherent.
|
||||
|
||||
See `CLAUDE.md` for detailed architecture and patterns.
|
||||
|
||||
@@ -82,7 +95,7 @@ Always include the migration file in your PR.
|
||||
1. Fork the repository
|
||||
2. Create a feature branch (`git checkout -b feature/your-feature`)
|
||||
3. Make your changes
|
||||
4. Ensure all checks pass (`pnpm lint && pnpm typecheck && pnpm test`)
|
||||
4. Ensure all checks pass (`pnpm typecheck && pnpm lint && pnpm test && pnpm format:check && pnpm build`)
|
||||
5. Submit a PR with a clear description of the changes
|
||||
|
||||
## Security
|
||||
|
||||
@@ -37,7 +37,7 @@ Most health apps lock your data behind proprietary clouds, push subscriptions, a
|
||||
|
||||
## Key Features
|
||||
|
||||
**Health Metrics** -- Track weight, blood pressure, pulse, body fat, sleep, steps, blood glucose (fasting/postprandial/random/bedtime, mg/dL ↔ mmol/L), total body water, and bone mass with interactive trend charts, moving averages, and traffic-light ranges based on ESC/ESH 2018 and ADA 2024 guidelines. Body-composition metrics sync automatically from Withings Body+ scales.
|
||||
**Health Metrics** -- Track weight, blood pressure, pulse, body fat, sleep, steps, blood glucose (fasting/postprandial/random/bedtime, mg/dL ↔ mmol/L), total body water, bone mass, and pulse oximetry (SpO₂) with interactive trend charts, moving averages, and traffic-light ranges based on ESC/ESH 2018, ADA 2024, and consensus pulse-oximeter guidance (NICE NG115). Body-composition + SpO₂ metrics sync automatically from Withings Body+ scales and ScanWatch devices.
|
||||
|
||||
**Custom Thresholds** -- Override the computed default ranges per metric with the targets your clinician set. Audit-logged. Doctor Report PDF prints both your target and the standard reference.
|
||||
|
||||
@@ -59,9 +59,9 @@ Most health apps lock your data behind proprietary clouds, push subscriptions, a
|
||||
|
||||
**Multi-Channel Notifications** -- Telegram (with inline action buttons), ntfy (self-hostable), and Web Push. Medication reminders with late/missed escalation.
|
||||
|
||||
**Gamification** -- 30+ persistent achievements across intake streaks, compliance milestones, and healthy metric streaks.
|
||||
**Gamification** -- 38+ persistent achievements across intake streaks, compliance milestones, and healthy metric streaks.
|
||||
|
||||
**Internationalization** -- English (default) and German UI with 1000+ translation keys. Numbers, dates, units, and AI prompts all locale-aware via `useFormatters()`. Browser-based detection with per-user override.
|
||||
**Internationalization** -- English (default) and German UI with 1500+ translation keys, guarded by a CI integrity test that fails the build on duplicate keys or drift between locales. Numbers, dates, units, and AI prompts all locale-aware via `useFormatters()`. Browser-based detection with per-user override.
|
||||
|
||||
---
|
||||
|
||||
@@ -102,7 +102,7 @@ Open **http://localhost:3000**. The first registered user becomes admin.
|
||||
| ------------- | ------------------------------------------------- |
|
||||
| Framework | Next.js 16 (App Router, React Server Components) |
|
||||
| Language | TypeScript (strict mode) |
|
||||
| Database | PostgreSQL 16 + Prisma 7 (23 models) |
|
||||
| Database | PostgreSQL 16 + Prisma 7 (25 models) |
|
||||
| Job Queue | pg-boss 12 (reminders, insights, backups) |
|
||||
| UI | shadcn/ui, Tailwind CSS 4, Radix UI, Lucide Icons |
|
||||
| Charts | Recharts 3 |
|
||||
@@ -163,30 +163,50 @@ Telegram bot token, ntfy settings, Web Push VAPID keys, Umami, and GlitchTip URL
|
||||
```
|
||||
src/
|
||||
├── app/ # Next.js App Router pages & API routes
|
||||
│ ├── api/ # REST API endpoints (~60 route files)
|
||||
│ ├── api/ # REST API endpoints (100+ route files)
|
||||
│ ├── admin/ # Admin panel
|
||||
│ ├── auth/ # Login, register, passkey enrolment
|
||||
│ ├── medications/ # Medication management
|
||||
│ ├── measurements/ # Health metric entry
|
||||
│ ├── mood/ # Mood log
|
||||
│ ├── insights/ # AI-powered analytics
|
||||
│ ├── charts/ # Long-form charts
|
||||
│ ├── achievements/ # Gamification page
|
||||
│ ├── targets/ # Custom thresholds dashboard
|
||||
│ ├── notifications/ # Notification preferences matrix
|
||||
│ ├── bugreport/ # Built-in feedback / bug report
|
||||
│ ├── onboarding/ # 4-step guided setup
|
||||
│ └── settings/ # User preferences
|
||||
│ └── settings/ # User preferences (8 top-level sections)
|
||||
├── components/
|
||||
│ ├── ui/ # shadcn/ui primitives
|
||||
│ ├── layout/ # Shell (sidebar, topbar, bottom nav)
|
||||
│ ├── medications/ # Medication cards, forms, timeline
|
||||
│ ├── measurements/ # Measurement form, list
|
||||
│ ├── mood/ # Mood form, mood list
|
||||
│ └── charts/ # Recharts wrappers
|
||||
│ ├── charts/ # Recharts wrappers
|
||||
│ ├── insights/ # AI insight status / advisor cards
|
||||
│ ├── gamification/ # Achievement cards, progress
|
||||
│ ├── monitoring/ # Umami / GlitchTip bootstrap
|
||||
│ └── settings/ # Settings-page section components
|
||||
├── lib/
|
||||
│ ├── auth/ # Session, audit, passkey logic
|
||||
│ ├── notifications/ # Dispatcher + channel senders
|
||||
│ ├── jobs/ # pg-boss worker
|
||||
│ ├── analytics/ # Trend calculations, compliance
|
||||
│ ├── jobs/ # pg-boss worker (reminders, insights, backups)
|
||||
│ ├── analytics/ # Trend calculations, compliance, correlations
|
||||
│ ├── ai/ # Multi-provider client (OpenAI, Anthropic, local)
|
||||
│ ├── insights/ # Insight pipeline + medical prompts
|
||||
│ ├── gamification/ # Achievement definitions
|
||||
│ ├── feedback/ # Built-in feedback + GitHub escalation
|
||||
│ ├── moodlog/ # moodLog.app webhook + sync
|
||||
│ ├── monitoring/ # Umami / GlitchTip server-side hooks
|
||||
│ ├── withings/ # OAuth client, sync service
|
||||
│ ├── logging/ # Wide Events: builder, context, transports
|
||||
│ ├── i18n/ # Translations context & config
|
||||
│ ├── validations/ # Shared Zod schemas
|
||||
│ ├── api-handler.ts # apiHandler wrapper, requireAuth/requireAdmin
|
||||
│ ├── api-response.ts # { data, error } envelope helpers
|
||||
│ ├── crypto.ts # AES-256-GCM encrypt/decrypt
|
||||
│ ├── rate-limit.ts # Sliding-window rate limiter
|
||||
│ ├── db.ts # Prisma singleton
|
||||
│ └── doctor-report-pdf.ts # Client-side PDF generation
|
||||
├── hooks/ # React hooks
|
||||
@@ -196,10 +216,11 @@ src/
|
||||
### Key Patterns
|
||||
|
||||
- **RSC by default** -- `"use client"` only for interactive components
|
||||
- **API envelope** -- All responses follow `{ data, error, meta }` shape
|
||||
- **API envelope** -- All responses follow `{ data, error }` shape via `apiSuccess()` / `apiError()` in `src/lib/api-response.ts`
|
||||
- **apiHandler wrapper** -- Every API route wraps its handler in `apiHandler()` (`src/lib/api-handler.ts`) for consistent error handling, Wide-Event structured logging, and `x-request-id` propagation
|
||||
- **Encrypted secrets** -- Withings tokens, API keys, VAPID keys, notification credentials
|
||||
- **Timezone-aware** -- `Europe/Berlin` for display, UTC in database
|
||||
- **Route protection** -- `proxy.ts` checks session cookie, redirects unauthenticated requests
|
||||
- **Route protection** -- `proxy.ts` (Next.js 16's renamed middleware) checks session cookie, redirects unauthenticated requests
|
||||
- **Client-side PDF** -- Doctor reports generated in browser via jsPDF
|
||||
|
||||
---
|
||||
@@ -254,17 +275,83 @@ All mutations require authentication via session cookie. External ingest uses Be
|
||||
<details>
|
||||
<summary><strong>Auth and Integrations</strong></summary>
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| ------ | -------------------------------- | ----------------------- |
|
||||
| `POST` | `/api/auth/register` | Create account |
|
||||
| `POST` | `/api/auth/login` | Password login |
|
||||
| `POST` | `/api/auth/logout` | Destroy session |
|
||||
| `GET` | `/api/auth/me` | Current user profile |
|
||||
| `POST` | `/api/auth/passkey/*` | WebAuthn flows |
|
||||
| `GET` | `/api/withings/connect` | Initiate Withings OAuth |
|
||||
| `POST` | `/api/insights/generate` | Regenerate AI insights |
|
||||
| `GET` | `/api/gamification/achievements` | Achievement progress |
|
||||
| `GET` | `/api/health` | Docker health check |
|
||||
| Method | Endpoint | Description |
|
||||
| ------ | -------------------------------- | ------------------------------------ |
|
||||
| `POST` | `/api/auth/register` | Create account |
|
||||
| `POST` | `/api/auth/login` | Password login |
|
||||
| `POST` | `/api/auth/logout` | Destroy session |
|
||||
| `GET` | `/api/auth/me` | Current user profile + Gravatar URL |
|
||||
| `POST` | `/api/auth/password` | Change password |
|
||||
| `PATCH`| `/api/auth/profile` | Update profile fields |
|
||||
| `POST` | `/api/auth/passkey/*` | WebAuthn flows (4 sub-routes) |
|
||||
| `GET` | `/api/auth/passkeys` | List enrolled passkeys |
|
||||
| `GET` | `/api/auth/codex/authorize` | ChatGPT (codex) OAuth start |
|
||||
| `GET` | `/api/withings/connect` | Initiate Withings OAuth |
|
||||
| `POST` | `/api/withings/sync` | Trigger manual Withings sync |
|
||||
| `POST` | `/api/withings/webhook` | Withings notification webhook |
|
||||
| `POST` | `/api/insights/generate` | Regenerate AI insights |
|
||||
| `GET` | `/api/insights/comprehensive` | Aggregated insight payload |
|
||||
| `GET` | `/api/gamification/achievements` | Achievement progress |
|
||||
| `GET` | `/api/health` | Docker health check |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Personalization (Thresholds + Dashboard)</strong></summary>
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| -------- | ------------------------- | --------------------------------------------- |
|
||||
| `GET` | `/api/user/thresholds` | Read per-user threshold overrides |
|
||||
| `PUT` | `/api/user/thresholds` | Upsert thresholds (rate-limited, audit-logged)|
|
||||
| `GET` | `/api/insights/targets` | Effective ranges (defaults + overrides merged)|
|
||||
| `GET` | `/api/dashboard/widgets` | Read dashboard layout |
|
||||
| `PUT` | `/api/dashboard/widgets` | Persist dashboard layout (show/hide/reorder) |
|
||||
| `POST` | `/api/onboarding/complete`| Mark onboarding finished |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Feedback + API Tokens</strong></summary>
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| -------- | --------------------------------------- | ------------------------------------ |
|
||||
| `POST` | `/api/feedback` | Submit in-app feedback |
|
||||
| `GET` | `/api/bugreport/status` | Check published GitHub issue state |
|
||||
| `GET` | `/api/tokens` | List own API tokens |
|
||||
| `POST` | `/api/tokens` | Mint new API token (Bearer, hashed) |
|
||||
| `DELETE` | `/api/tokens/:id` | Revoke API token |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Notifications</strong></summary>
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| -------- | --------------------------------- | -------------------------------------- |
|
||||
| `GET` | `/api/notifications/preferences` | Read per-channel × per-event matrix |
|
||||
| `PUT` | `/api/notifications/preferences` | Update preferences |
|
||||
| `GET` | `/api/notifications/vapid` | VAPID public key for Web Push |
|
||||
| `POST` | `/api/notifications/web-push` | Register Web Push subscription |
|
||||
| `POST` | `/api/telegram/webhook` | Telegram bot inline-button callback |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Admin (admin role required)</strong></summary>
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| -------- | --------------------------------- | -------------------------------------- |
|
||||
| `GET` | `/api/admin/status` | System + integration status |
|
||||
| `GET` | `/api/admin/users` | List users |
|
||||
| `POST` | `/api/admin/users/:id/reset-password` | Force password reset |
|
||||
| `GET` | `/api/admin/feedback` | All feedback / bug reports |
|
||||
| `POST` | `/api/admin/feedback/:id/github` | Escalate feedback to GitHub issue |
|
||||
| `GET` | `/api/admin/audit-log` | Audit-log viewer |
|
||||
| `GET` | `/api/admin/ai-settings` | Read shared AI provider config |
|
||||
| `PUT` | `/api/admin/ai-settings` | Update shared AI provider config |
|
||||
| `GET` | `/api/admin/tokens` | All issued API tokens |
|
||||
| `POST` | `/api/admin/notifications/test` | Send test notification |
|
||||
| `GET` | `/api/admin/data` | Data backups + counts |
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+1
-1
@@ -70,6 +70,6 @@ HealthLog Docker images are built and published from this repository's CI:
|
||||
- **Provenance attestation**: each image carries a [SLSA build provenance](https://slsa.dev/spec/v1.0/provenance) statement linking it back to the GitHub Actions run, the commit SHA, and the workflow definition
|
||||
- **SBOM**: each image includes a [Software Bill of Materials](https://docs.docker.com/build/metadata/attestations/sbom/) you can inspect with `docker buildx imagetools inspect ghcr.io/mbombeck/healthlog:latest --format '{{ json .SBOM }}'`
|
||||
|
||||
To pin to a specific version in production, replace `:latest` with the released tag in your `docker-compose.yml`, e.g. `ghcr.io/mbombeck/healthlog:1.2.0`. Pinning is recommended for self-hosters who want explicit control over upgrades.
|
||||
To pin to a specific version in production, replace `:latest` with the released tag in your `docker-compose.yml`, e.g. `ghcr.io/mbombeck/healthlog:1.3.2`. Pinning is recommended for self-hosters who want explicit control over upgrades.
|
||||
|
||||
If you discover a tampered or unexpected image, please report it as a vulnerability via the email above.
|
||||
@@ -0,0 +1,76 @@
|
||||
# HealthLog API Spec
|
||||
|
||||
`openapi.yaml` is the canonical OpenAPI 3.1 description for the HealthLog
|
||||
Next.js API. It is the source of truth for the native iOS client's
|
||||
DTO codegen.
|
||||
|
||||
## Preview locally
|
||||
|
||||
Open an interactive Redoc preview in your browser:
|
||||
|
||||
```bash
|
||||
npx @redocly/cli preview docs/api/openapi.yaml
|
||||
```
|
||||
|
||||
Generate static HTML for sharing:
|
||||
|
||||
```bash
|
||||
npx @redocly/cli build-docs docs/api/openapi.yaml --output docs/api/index.html
|
||||
```
|
||||
|
||||
## Validate
|
||||
|
||||
```bash
|
||||
npx @redocly/cli lint docs/api/openapi.yaml
|
||||
```
|
||||
|
||||
The spec is expected to be lint-clean (errors = 0). Some warnings are
|
||||
intentional:
|
||||
|
||||
- `no-server-example.com` — `http://localhost:3000` is the dev server.
|
||||
- `operation-2xx-response` / `operation-4xx-response` — webhook + redirect
|
||||
endpoints intentionally omit one or the other.
|
||||
- `no-unused-components` — `ReminderPhase` is exposed for client codegen
|
||||
even though no path response references it directly.
|
||||
|
||||
## Layout
|
||||
|
||||
- **`info`** — title, version (mirrored from `package.json`), description.
|
||||
- **`servers`** — production `https://healthlog.bombeck.io` plus
|
||||
`http://localhost:3000` for local development.
|
||||
- **`tags`** — domain groupings (`Auth`, `Measurements`, `Medications`,
|
||||
`Mood`, `Dashboard`, `Insights`, `Achievements`, `DoctorReport`,
|
||||
`Integrations`, `User`, `Tokens`, `Notifications`, `Onboarding`,
|
||||
`Health`, `Settings`, `Analytics`, `Export`, `AuditLog`, `Feedback`,
|
||||
`BugReport`, `Monitoring`, `Webhooks`, `Ingest`, `Admin`).
|
||||
- **`securitySchemes`**:
|
||||
- `bearerAuth` — long-lived `hlk_*` API tokens issued via `POST /api/tokens`.
|
||||
Required on `/api/ingest/medication`.
|
||||
- `sessionCookie` — HttpOnly `healthlog_session` cookie set by the
|
||||
password and passkey login flows. Used by the web app and any iOS
|
||||
client running inside a web view.
|
||||
- **`components.schemas`** — DTOs for all routes plus the underlying
|
||||
Prisma models (e.g. `Measurement`, `Medication`, `MedicationIntakeEvent`,
|
||||
`MoodEntry`, `User`, `DashboardLayout`, `ApiEnvelope`, `ApiError`).
|
||||
- **`paths`** — all routes from `src/app/api/**/route.ts`, including
|
||||
the `/api/admin/*` family which is tagged `Admin` and marked internal.
|
||||
|
||||
## Conventions
|
||||
|
||||
- Successful responses use the envelope `{ data, error: null }`.
|
||||
- Error responses use the envelope `{ data: null, error: '<message>' }`.
|
||||
- Pagination uses `limit` + `offset` query parameters and a
|
||||
`meta: { total, limit, offset }` block on the response.
|
||||
- All timestamps are ISO 8601 / RFC 3339 (`format: date-time`) in UTC.
|
||||
- IDs are CUIDs (opaque strings).
|
||||
|
||||
## Updating the spec
|
||||
|
||||
When adding or changing routes:
|
||||
|
||||
1. Update the relevant route handler under `src/app/api/**/route.ts`.
|
||||
2. Mirror the change in `openapi.yaml`. Keep `operationId` camelCase and
|
||||
stable — the iOS codegen keys off it.
|
||||
3. Run `npx @redocly/cli lint docs/api/openapi.yaml` and resolve all
|
||||
errors. Warnings are tolerated when documented above.
|
||||
4. Bump `info.version` if the change is breaking for clients.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,57 @@
|
||||
# Doctor Report
|
||||
|
||||
The doctor report is a printable PDF summary of a user's tracked vitals,
|
||||
medication compliance, glucose context, and mood over a configurable window
|
||||
(default 90 days, max 365). It is intended as discussion material for a
|
||||
doctor's appointment, **not** a medical diagnosis.
|
||||
|
||||
## Two endpoints
|
||||
|
||||
The same aggregation logic backs both endpoints — `collectDoctorReportData`
|
||||
in `src/lib/doctor-report-data.ts` is the single source of truth — so the
|
||||
JSON payload and the server-rendered PDF are guaranteed to be in sync.
|
||||
|
||||
### `POST /api/doctor-report` — JSON payload
|
||||
|
||||
Returns the aggregated `DoctorReportData` envelope. Used by the PWA's
|
||||
client-side renderer (`src/lib/doctor-report-pdf.ts`, jsPDF + autotable).
|
||||
Best on desktop browsers and on Android, where local generation keeps the
|
||||
PDF off the wire and works offline (cached page).
|
||||
|
||||
- Auth: session required.
|
||||
- Rate-limit: 10/h per user.
|
||||
- Body: `{ days?: number }` (default 90).
|
||||
- Response: `200 application/json` with envelope `{ data, error: null }`.
|
||||
- Audit action: `doctor-report.generate`.
|
||||
|
||||
### `POST /api/doctor-report/pdf` — server-rendered PDF
|
||||
|
||||
Returns the ready-to-download PDF bytes. Used primarily on iOS/Safari, where
|
||||
the client-side jsPDF download UX is unreliable (Safari sometimes opens the
|
||||
PDF in a new tab without preserving the filename or fails the blob save).
|
||||
|
||||
- Auth: session required (`401` otherwise).
|
||||
- Rate-limit: 10/h per user, shared with the JSON endpoint (`429` on excess).
|
||||
- Body: `{ days?: number; locale?: "de" | "en" }`. Body is optional — empty
|
||||
bodies are accepted.
|
||||
- Locale resolution: explicit `body.locale` wins, then the
|
||||
`Accept-Language` header (`de*` → `de`, anything else → `en`), then the
|
||||
hard fallback `de`.
|
||||
- Response: `200 application/pdf` with
|
||||
`Content-Disposition: attachment; filename="healthlog-report-YYYY-MM-DD.pdf"`
|
||||
and `Cache-Control: no-store`. Body is the raw PDF bytes.
|
||||
- Errors: JSON envelope `{ data: null, error: "..." }` for `401` and `429`;
|
||||
`500` if rendering fails.
|
||||
- Audit action: `doctor-report.pdf.generate`.
|
||||
|
||||
## Implementation notes
|
||||
|
||||
- `src/lib/doctor-report-pdf-core.ts` contains the isomorphic renderer.
|
||||
jsPDF runs unchanged in Node — `doc.output("arraybuffer")` returns a valid
|
||||
`%PDF-` byte stream in both environments, so we deliberately avoid headless
|
||||
Chromium (Puppeteer/Playwright) to keep the runtime dependency surface low.
|
||||
- `src/lib/doctor-report-pdf.ts` is a thin client façade that returns the
|
||||
raw `jsPDF` instance so the existing settings-page download flow (which
|
||||
calls `doc.save(...)`) keeps working unchanged.
|
||||
- Server-side translations come from `messages/{de,en}.json` via
|
||||
`getServerTranslator()` (`src/lib/i18n/server-translator.ts`).
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,359 +0,0 @@
|
||||
# Codex OAuth + Medizinische Insights — Design Spec
|
||||
|
||||
**Datum:** 2026-04-06
|
||||
**Status:** Approved
|
||||
**Scope:** Replace OpenAI API-Key flow with ChatGPT Codex OAuth, improve medical insight prompts
|
||||
|
||||
## 1. Problem
|
||||
|
||||
HealthLog uses user-provided OpenAI API keys for AI-powered health insights. The API quota ran out (`insufficient_quota`), breaking all insight generation and background jobs. Users shouldn't need to manage API billing.
|
||||
|
||||
## 2. Solution
|
||||
|
||||
Replace the API-key model with **OpenAI Codex OAuth** — users authenticate with their ChatGPT Pro/Max subscription, and insights run against their flat-rate plan. An admin-level API key serves as a global fallback.
|
||||
|
||||
## 3. Provider Hierarchy
|
||||
|
||||
```
|
||||
1. User has Codex OAuth connection → Codex Responses API (gpt-5.3-codex)
|
||||
2. Admin key configured → Standard OpenAI Chat Completions API (configurable model)
|
||||
3. Nothing configured → Local fallback texts (no-key-fallbacks.ts)
|
||||
```
|
||||
|
||||
## 4. Architecture
|
||||
|
||||
### New Files
|
||||
|
||||
```
|
||||
src/lib/ai/
|
||||
provider.ts — resolveProvider(userId) → { type, client, model }
|
||||
codex-oauth.ts — PKCE generation, token exchange, refresh
|
||||
codex-client.ts — Codex Responses API client with auto-refresh
|
||||
openai-client.ts — Standard OpenAI client (admin key, configurable base URL)
|
||||
types.ts — Shared types (InsightResult, ProviderConfig, etc.)
|
||||
prompts/
|
||||
base-system.ts — Shared medical context, role, constraints
|
||||
general-status.ts — Holistic health assessment prompt
|
||||
blood-pressure.ts — ESC/ESH guideline-based BP analysis
|
||||
weight.ts — BMI context, trend analysis, plateau detection
|
||||
pulse.ts — Resting HR trends, stress correlation
|
||||
bmi.ts — WHO classification, age-adjusted
|
||||
medication-compliance.ts — Intake patterns, efficacy correlation
|
||||
schema.ts — Zod schema for InsightResult validation
|
||||
```
|
||||
|
||||
### Modified Files
|
||||
|
||||
```
|
||||
src/app/api/auth/codex/authorize/route.ts — NEW: Start OAuth flow
|
||||
src/app/api/auth/codex/callback/route.ts — NEW: Handle OAuth callback
|
||||
src/app/api/auth/codex/disconnect/route.ts — NEW: Remove tokens
|
||||
src/app/api/admin/ai-settings/route.ts — NEW: Admin key management
|
||||
src/app/api/insights/generate/route.ts — MODIFY: Use provider abstraction
|
||||
src/app/api/insights/settings/route.ts — MODIFY: Show Codex status instead of API key
|
||||
src/app/api/insights/*/route.ts — MODIFY: New output schema
|
||||
src/lib/insights/*.ts — MODIFY: Use new prompts + provider
|
||||
src/lib/jobs/reminder-worker.ts — MODIFY: Use provider with auto-refresh
|
||||
src/proxy.ts — MODIFY: Add /api/auth/codex/callback to PUBLIC_PATHS
|
||||
prisma/schema.prisma — MODIFY: New fields, remove openaiKeyEncrypted
|
||||
```
|
||||
|
||||
### Removed Files/Code
|
||||
|
||||
```
|
||||
src/app/api/insights/settings/route.ts — OpenAI key PUT handler (replaced)
|
||||
src/lib/insights/prompt.ts — Single prompt file (replaced by prompts/)
|
||||
User.openaiKeyEncrypted — DB field removed
|
||||
```
|
||||
|
||||
## 5. OAuth Flow
|
||||
|
||||
### Authorization (User-initiated)
|
||||
|
||||
```
|
||||
1. User clicks "Mit ChatGPT verbinden" in Settings
|
||||
2. GET /api/auth/codex/authorize
|
||||
- Generate PKCE code_verifier (128 bytes, base64url)
|
||||
- Compute code_challenge = SHA-256(code_verifier), base64url
|
||||
- Generate random state parameter
|
||||
- Store code_verifier + state in session
|
||||
- Redirect to: chatgpt.com/authorize?
|
||||
response_type=code
|
||||
&code_challenge=<challenge>
|
||||
&code_challenge_method=S256
|
||||
&redirect_uri=https://healthlog.bombeck.io/api/auth/codex/callback
|
||||
&state=<state>
|
||||
3. User logs in at ChatGPT, grants access
|
||||
4. Redirect to /api/auth/codex/callback?code=...&state=...
|
||||
5. Validate state against session
|
||||
6. Exchange code for tokens (POST chatgpt.com/oauth/token)
|
||||
7. Encrypt access_token + refresh_token with AES-256-GCM
|
||||
8. Store in User record with expiresAt
|
||||
9. Redirect to /settings with success toast
|
||||
```
|
||||
|
||||
### Token Refresh (Automatic)
|
||||
|
||||
```
|
||||
Before every API call:
|
||||
if codexTokenExpiresAt < now + 5min:
|
||||
POST chatgpt.com/oauth/token (grant_type=refresh_token)
|
||||
Store new encrypted tokens + new expiresAt
|
||||
If refresh fails:
|
||||
Increment failCount
|
||||
If failCount >= 3 over 72h:
|
||||
Set codexConnectionStatus = 'expired'
|
||||
Fall back to admin key silently
|
||||
Else:
|
||||
Retry on next call
|
||||
|
||||
On 401 response:
|
||||
Refresh token immediately, retry request once
|
||||
```
|
||||
|
||||
### Disconnect
|
||||
|
||||
```
|
||||
DELETE /api/auth/codex/disconnect
|
||||
- Delete tokens from DB
|
||||
- Revoke at OpenAI if endpoint exists
|
||||
- Audit log entry
|
||||
```
|
||||
|
||||
## 6. Database Changes
|
||||
|
||||
### Prisma Schema
|
||||
|
||||
```prisma
|
||||
model User {
|
||||
// REMOVE:
|
||||
// openaiKeyEncrypted String?
|
||||
|
||||
// ADD:
|
||||
codexAccessTokenEncrypted String?
|
||||
codexRefreshTokenEncrypted String?
|
||||
codexTokenExpiresAt DateTime?
|
||||
codexConnectedAt DateTime?
|
||||
codexConnectionStatus String @default("disconnected")
|
||||
// Values: "connected", "disconnected", "expired"
|
||||
}
|
||||
|
||||
model AppSettings {
|
||||
// ADD:
|
||||
adminAiKeyEncrypted String?
|
||||
adminAiModel String @default("gpt-4o-mini")
|
||||
adminAiBaseUrl String @default("https://api.openai.com/v1")
|
||||
}
|
||||
```
|
||||
|
||||
### Migration
|
||||
|
||||
```sql
|
||||
-- Add new columns
|
||||
ALTER TABLE users ADD COLUMN codex_access_token_encrypted TEXT;
|
||||
ALTER TABLE users ADD COLUMN codex_refresh_token_encrypted TEXT;
|
||||
ALTER TABLE users ADD COLUMN codex_token_expires_at TIMESTAMP;
|
||||
ALTER TABLE users ADD COLUMN codex_connected_at TIMESTAMP;
|
||||
ALTER TABLE users ADD COLUMN codex_connection_status TEXT NOT NULL DEFAULT 'disconnected';
|
||||
|
||||
ALTER TABLE app_settings ADD COLUMN admin_ai_key_encrypted TEXT;
|
||||
ALTER TABLE app_settings ADD COLUMN admin_ai_model TEXT NOT NULL DEFAULT 'gpt-4o-mini';
|
||||
ALTER TABLE app_settings ADD COLUMN admin_ai_base_url TEXT NOT NULL DEFAULT 'https://api.openai.com/v1';
|
||||
|
||||
-- Remove old column
|
||||
ALTER TABLE users DROP COLUMN openai_key_encrypted;
|
||||
```
|
||||
|
||||
## 7. Insight Output Schema
|
||||
|
||||
### New InsightResult (replaces old changed/stable/drivers/nextSteps)
|
||||
|
||||
```typescript
|
||||
interface InsightResult {
|
||||
summary: string
|
||||
classification: 'optimal' | 'gut' | 'grenzwertig' | 'erhoht' | 'kritisch'
|
||||
findings: {
|
||||
label: string
|
||||
value: string
|
||||
assessment: 'positive' | 'neutral' | 'attention' | 'warning'
|
||||
guideline?: string
|
||||
}[]
|
||||
correlations: {
|
||||
factor: string
|
||||
effect: string
|
||||
confidence: 'hoch' | 'mittel' | 'gering'
|
||||
}[]
|
||||
recommendations: string[]
|
||||
dataQuality: {
|
||||
coverage: string
|
||||
gaps: string[]
|
||||
confidence: 'hoch' | 'mittel' | 'gering'
|
||||
}
|
||||
disclaimer: string
|
||||
}
|
||||
```
|
||||
|
||||
### Zod Validation
|
||||
|
||||
Every AI response is validated against a Zod schema before being stored.
|
||||
Invalid responses are logged as errors and the user gets a generic fallback.
|
||||
|
||||
## 8. Prompt Design
|
||||
|
||||
### Base System Prompt
|
||||
|
||||
```
|
||||
Du bist ein klinischer Gesundheitsanalyst mit Expertise in Innerer Medizin
|
||||
und Präventivmedizin. Deine Analyse basiert auf aktuellen Leitlinien
|
||||
(ESC/ESH 2023, WHO, DGE, DEGAM).
|
||||
|
||||
Regeln:
|
||||
- Evidenzbasiert: Referenziere Grenzwerte und Leitlinien explizit
|
||||
- Muster erkennen: Tageszeit, Wochentag, saisonale Trends
|
||||
- Korrelationen: Wechselwirkungen zwischen Medikation und Vitalwerten benennen
|
||||
- Limitationen: Klar kommunizieren was die Datenlage hergibt und was nicht
|
||||
- Kein Ersatz für ärztliche Diagnose — immer explizit als Disclaimer
|
||||
- Sprache: Deutsch, medizinisch präzise aber allgemeinverständlich
|
||||
- Antworte ausschließlich im vorgegebenen JSON-Schema
|
||||
```
|
||||
|
||||
### Per-Type Specialization
|
||||
|
||||
Each insight type adds domain-specific instructions:
|
||||
|
||||
- **Blood Pressure**: ESC/ESH classification (optimal <120/80, normal <130/85, ...), morning surge detection, white-coat vs. masked hypertension hints, medication correlation
|
||||
- **Weight**: BMI-adjusted analysis, 7/30/90-day slope, plateau detection (±0.5kg over 14+ days), realistic goal projection
|
||||
- **Pulse**: Resting HR zones (bradycardia <50, athletic 50-60, normal 60-100, tachycardia >100), trend stability, stress/recovery indicators
|
||||
- **BMI**: WHO classification, waist-to-height hints if available, age context
|
||||
- **Medication Compliance**: Pattern analysis (morning vs evening adherence), streak tracking, correlation with vital sign improvements
|
||||
- **General Status**: Cross-domain synthesis, risk stratification, most impactful change recommendation
|
||||
|
||||
## 9. UI Changes
|
||||
|
||||
### User Settings (/settings)
|
||||
|
||||
Replace "OpenAI API Key" section with:
|
||||
|
||||
```
|
||||
KI-Insights
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Status: 🟢 Verbunden mit ChatGPT │
|
||||
│ Verbunden seit: 02.04.2026 │
|
||||
│ │
|
||||
│ [Trennen] │
|
||||
│ │
|
||||
│ ℹ️ Insights werden über dein ChatGPT- │
|
||||
│ Abo generiert (keine zusätzlichen │
|
||||
│ Kosten). │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Or when disconnected:
|
||||
|
||||
```
|
||||
KI-Insights
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Status: ⚪ Nicht verbunden │
|
||||
│ │
|
||||
│ [Mit ChatGPT verbinden] │
|
||||
│ │
|
||||
│ ℹ️ Verbinde dein ChatGPT Pro/Max-Konto │
|
||||
│ um KI-gestützte Gesundheitsanalysen zu │
|
||||
│ erhalten. │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Admin Settings (/settings — ADMIN only)
|
||||
|
||||
```
|
||||
KI-Anbieter (Global)
|
||||
┌─────────────────────────────────────────┐
|
||||
│ API Key: [sk-...****________] [Testen] │
|
||||
│ Modell: [gpt-4o-mini ▾] │
|
||||
│ Base URL: [https://api.openai.com/v1] │
|
||||
│ │
|
||||
│ ℹ️ Wird als Fallback verwendet, wenn │
|
||||
│ ein User keine eigene ChatGPT- │
|
||||
│ Verbindung hat. │
|
||||
│ │
|
||||
│ [Speichern] │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Insights Display
|
||||
|
||||
Update insight cards to render the richer InsightResult:
|
||||
- Classification badge with color (green/yellow/orange/red)
|
||||
- Findings list with assessment icons
|
||||
- Collapsible correlations section
|
||||
- Data quality indicator (small badge)
|
||||
- Disclaimer footer (smaller text)
|
||||
|
||||
## 10. Security
|
||||
|
||||
### OAuth Security
|
||||
- PKCE (S256) prevents authorization code interception
|
||||
- State parameter prevents CSRF
|
||||
- Tokens encrypted at rest (AES-256-GCM via existing crypto.ts)
|
||||
- Tokens never sent to client/browser
|
||||
- Refresh token rotation on every refresh
|
||||
- Callback route validates state against session
|
||||
|
||||
### Admin Key Security
|
||||
- Only ADMIN role can read/write
|
||||
- Key displayed masked in UI (sk-...****)
|
||||
- Rate limiting on admin key usage (prevents abuse by many users)
|
||||
- Encrypted at rest
|
||||
|
||||
### Audit Trail
|
||||
- Every OAuth connect/disconnect logged
|
||||
- Every insight generation logs: provider type, user, model, duration
|
||||
- Admin key changes logged
|
||||
|
||||
### Security Audits (3x)
|
||||
1. **Pre-Implementation**: OAuth flow validated against OWASP OAuth Security Checklist
|
||||
2. **Post-Implementation**: Code review agent checks all new files for injection, token leaks, unencrypted secrets
|
||||
3. **Post-Deploy**: Penetration test of OAuth endpoints (CSRF, token theft, callback manipulation)
|
||||
|
||||
## 11. Testing Strategy (TDD)
|
||||
|
||||
### Unit Tests (vitest)
|
||||
|
||||
| Suite | Coverage |
|
||||
|-------|----------|
|
||||
| `ai/provider.test.ts` | Provider hierarchy, correct client selection |
|
||||
| `ai/codex-oauth.test.ts` | PKCE generation, token exchange, refresh, error scenarios |
|
||||
| `ai/codex-client.test.ts` | Request format, auto-refresh on 401, retry with backoff |
|
||||
| `ai/openai-client.test.ts` | Chat completions format, custom base URL, error handling |
|
||||
| `ai/prompts/*.test.ts` | Prompt assembly per type, output schema validation with Zod |
|
||||
| `insights/*.test.ts` | Updated generators with new output schema |
|
||||
| `api/codex-callback.test.ts` | State validation, CSRF protection, token storage |
|
||||
| `api/admin-settings.test.ts` | ADMIN-only access, key encryption, validation |
|
||||
|
||||
### Integration Tests
|
||||
- OAuth flow end-to-end (mocked ChatGPT endpoint)
|
||||
- Background job token refresh + insight generation
|
||||
- Provider fallback chain under different DB states
|
||||
|
||||
## 12. CSP Changes
|
||||
|
||||
```diff
|
||||
- connect-src 'self' https://api.openai.com https://wbsapi.withings.net
|
||||
+ connect-src 'self' https://api.openai.com https://chatgpt.com https://wbsapi.withings.net
|
||||
```
|
||||
|
||||
## 13. Migration Path
|
||||
|
||||
1. Deploy new code with both old (openaiKeyEncrypted) and new fields
|
||||
2. Run migration to add new columns
|
||||
3. Remove old column in follow-up migration
|
||||
4. Users see "Nicht verbunden" in settings, can connect via OAuth
|
||||
5. Admin can configure fallback key immediately
|
||||
6. Old cached insights remain visible until refreshed
|
||||
|
||||
## 14. Out of Scope
|
||||
|
||||
- Multiple AI providers per user (only Codex OAuth OR admin key)
|
||||
- Custom model selection per user (model is fixed per provider)
|
||||
- Streaming responses (batch only, like current implementation)
|
||||
- OpenAI API key input for individual users (removed entirely)
|
||||
@@ -1,207 +0,0 @@
|
||||
# Blood Glucose Tracking — Design Spec
|
||||
|
||||
**Datum:** 2026-04-18
|
||||
**Status:** Draft
|
||||
**Scope:** Add `BLOOD_GLUCOSE` as a first-class measurement type with context-aware classification (fasting / postprandial / random / bedtime), user-selectable display unit (mg/dL ↔ mmol/L), reference ranges from ADA 2024 and DGIM 2023, and full integration across form, charts, targets, doctor PDF, and insights.
|
||||
|
||||
## 1. Problem Statement
|
||||
|
||||
Users repeatedly request blood-sugar logging — most recently in **GitHub issue #73, point 8**. Diabetics, pre-diabetics, and people on metabolic-impacting medication (steroids, atypical antipsychotics) need a single place to log glucose alongside weight, BP, and pulse so the doctor PDF reflects the complete metabolic picture. Today they fall back to spreadsheets or vendor-locked apps. Adding glucose to HealthLog closes the largest remaining gap in the "vital sign" set and unlocks correlation insights (e.g. weight ↔ fasting glucose, sleep ↔ glucose variability).
|
||||
|
||||
## 2. Scope
|
||||
|
||||
In v1 we ship:
|
||||
|
||||
- A new `BLOOD_GLUCOSE` measurement type stored canonically in **mg/dL**.
|
||||
- A required **context tag** per reading: `FASTING`, `POSTPRANDIAL`, `RANDOM`, `BEDTIME`.
|
||||
- A user preference `glucoseUnit` (`MG_DL` | `MMOL_L`, default `MG_DL` for `de` users, can flip in settings) — display only; storage is canonical.
|
||||
- Context-aware classification (see §4) and a target card on `/targets` for fasting glucose; secondary line for postprandial when data exists.
|
||||
- Health chart with diabetes/pre-diabetes band overlays.
|
||||
- Doctor PDF row + classification line.
|
||||
- Insights descriptor "glucose control" (fasting avg + variability).
|
||||
|
||||
**Stretch (v1.1, not in this spec):** HbA1c as a separate measurement type with its own classification (<5.7% / 5.7–6.4% / ≥6.5%, ADA 2024). Schema is forward-compatible (just add another enum value).
|
||||
|
||||
## 3. Data Model
|
||||
|
||||
```prisma
|
||||
enum MeasurementType {
|
||||
WEIGHT
|
||||
BLOOD_PRESSURE_SYS
|
||||
BLOOD_PRESSURE_DIA
|
||||
PULSE
|
||||
BODY_FAT
|
||||
SLEEP_DURATION
|
||||
ACTIVITY_STEPS
|
||||
BLOOD_GLUCOSE // NEW
|
||||
}
|
||||
|
||||
enum GlucoseContext { // NEW
|
||||
FASTING
|
||||
POSTPRANDIAL
|
||||
RANDOM
|
||||
BEDTIME
|
||||
|
||||
@@map("glucose_context")
|
||||
}
|
||||
|
||||
model Measurement {
|
||||
// ...existing fields
|
||||
glucoseContext GlucoseContext? @map("glucose_context") // NEW, only set when type = BLOOD_GLUCOSE
|
||||
}
|
||||
|
||||
model User {
|
||||
// ...existing fields
|
||||
glucoseUnit String @default("MG_DL") @map("glucose_unit") // "MG_DL" | "MMOL_L"
|
||||
}
|
||||
```
|
||||
|
||||
Migration `20260418_add_blood_glucose`:
|
||||
1. `ALTER TYPE measurement_type ADD VALUE 'BLOOD_GLUCOSE';`
|
||||
2. `CREATE TYPE glucose_context AS ENUM (...);`
|
||||
3. `ALTER TABLE measurements ADD COLUMN glucose_context glucose_context;`
|
||||
4. `ALTER TABLE users ADD COLUMN glucose_unit text NOT NULL DEFAULT 'MG_DL';`
|
||||
5. Add `CHECK ((type = 'BLOOD_GLUCOSE') = (glucose_context IS NOT NULL))` to enforce coupling.
|
||||
|
||||
The unique constraint `(userId, type, measuredAt, source)` already prevents duplicates; context is intentionally **not** part of the key — the same timestamp can't legally carry two contexts.
|
||||
|
||||
Storage unit is always **mg/dL** (`unit = "mg/dL"` in the row) so downstream analytics, exports, and Withings ingest don't need to branch.
|
||||
|
||||
## 4. Classifications (`src/lib/analytics/classifications.ts`)
|
||||
|
||||
New helpers, ranges from **ADA Standards of Care 2024** (Diabetes Care, Vol. 47, Suppl. 1) and **DGIM/DDG S3-Leitlinie 2023**:
|
||||
|
||||
```ts
|
||||
export type GlucoseContext = "FASTING" | "POSTPRANDIAL" | "RANDOM" | "BEDTIME";
|
||||
|
||||
export interface GlucoseClassification {
|
||||
category: string;
|
||||
color: string;
|
||||
severity: "info" | "normal" | "warning" | "danger";
|
||||
}
|
||||
|
||||
export function classifyGlucose(
|
||||
mgdl: number,
|
||||
context: GlucoseContext,
|
||||
): GlucoseClassification;
|
||||
```
|
||||
|
||||
Boundaries (mg/dL — mmol/L conversion shown for traceability):
|
||||
|
||||
| Context | Hypo (<70 / <3.9) | Normal | Pre-diabetes | Diabetes |
|
||||
|---------|-------------------|--------|--------------|----------|
|
||||
| FASTING | danger | 70–99 (3.9–5.5) normal | 100–125 (5.6–6.9) warning | ≥126 (≥7.0) danger |
|
||||
| POSTPRANDIAL (2h) | danger | <140 (<7.8) normal | 140–199 (7.8–11.0) warning | ≥200 (≥11.1) danger |
|
||||
| RANDOM | danger | <140 normal | 140–199 warning | ≥200 danger |
|
||||
| BEDTIME | danger | 90–150 normal (info if outside) | — | ≥250 danger |
|
||||
|
||||
Color tokens reuse the existing Dracula palette (`#50fa7b` normal, `#f1fa8c` info, `#ffb86c` / `#ff79c6` warning, `#ff5555` danger). Add `getGlucoseFastingRange()` returning `{ min: 70, max: 99 }` for the target card range bar, and `getGlucosePostprandialRange()` for the optional secondary bar.
|
||||
|
||||
## 5. UI
|
||||
|
||||
**Measurement form (`src/components/measurements/measurement-form.tsx`):**
|
||||
- New entry in `MEASUREMENT_TYPES`: `{ value: "BLOOD_GLUCOSE", labelKey: "measurements.typeGlucose", unitKey: "measurements.unitGlucose", placeholder: "95" }`.
|
||||
- When `type === "BLOOD_GLUCOSE"`, render an additional `<Select>` for context (4 options). Default = `FASTING` between 04:00–10:00 local time, else `RANDOM`.
|
||||
- Unit suffix is resolved via `useGlucoseUnit()` hook reading `user.glucoseUnit`; the input value is converted to mg/dL before POST.
|
||||
- Min 20, max 600 mg/dL (or equivalent mmol/L); inputs accept 1 decimal in mmol/L mode, integers in mg/dL mode.
|
||||
|
||||
**Targets page (`src/app/targets/page.tsx`):**
|
||||
- New `TYPE_ICONS["BLOOD_GLUCOSE"]` = `Droplet` (Lucide), color token `text-dracula-yellow`.
|
||||
- Card title "Fasting glucose" with `RangeBar` 70–99, orangeMin 100, orangeMax 125 (puts pre-diabetes in yellow zone, diabetes in red).
|
||||
- If postprandial data exists in last 30 days, render a second labelled `RangeBar` below using `getGlucosePostprandialRange()`, mirroring the BP-diastolic pattern already in `TargetCard`.
|
||||
- Source link → `https://diabetesjournals.org/care/issue/47/Supplement_1` (ADA 2024).
|
||||
|
||||
**Health chart (`src/components/charts/health-chart.tsx`):**
|
||||
- New chart preset on `/charts` page: `types: ["BLOOD_GLUCOSE"]`, color `var(--dracula-yellow)`, `valueBands` for pre-diabetes (100–125) and diabetes (≥126) overlays, plus a `ReferenceLine` at 70 (hypo).
|
||||
- Filter chips for context (All / Fasting / Postprandial / Random / Bedtime) — implemented as a client-side filter on the dataset, not a new API call.
|
||||
- Y-axis unit follows user pref (mg/dL or mmol/L); points are converted on render.
|
||||
|
||||
**Insights:** Add `glucose-control.ts` insight returning `{ avgFasting, fastingInTargetPct, postprandialPeak, variability, classification }`. AI-friendly descriptor injected into the general-status prompt: `"glucose control: avg fasting {avg} mg/dL ({class}), {pct}% of fasting readings in target."`
|
||||
|
||||
**Doctor PDF (`src/lib/doctor-report-pdf.ts`):**
|
||||
- `TYPE_LABEL_KEYS["BLOOD_GLUCOSE"] = "doctorReport.typeGlucose"`.
|
||||
- `TYPE_UNIT_KEYS["BLOOD_GLUCOSE"]` resolves to user pref via a new `unitFor(type, ctx)` extension.
|
||||
- Add `BLOOD_GLUCOSE` to `vitalTypes` array — table row appears automatically.
|
||||
- New optional section "Glukose-Klassifikation" below the BP block: shows fasting average + classification key, plus postprandial average if `count > 0`, with i18n keys `doctorReport.glucoseClassificationTitle`, `doctorReport.glucoseFastingRow`, `doctorReport.glucosePostprandialRow`.
|
||||
- Distribution table per context (n per FASTING/POSTPRANDIAL/RANDOM/BEDTIME) so the GP sees coverage.
|
||||
|
||||
## 6. API
|
||||
|
||||
No new endpoints. `POST /api/measurements` already accepts `type`, `value`, `measuredAt`, `notes` — extend the Zod schema in `src/lib/validations/measurement.ts` with optional `glucoseContext: z.enum([...])` and a refinement: `BLOOD_GLUCOSE` requires the context, other types reject it. The form sends mg/dL; conversion happens client-side.
|
||||
|
||||
`/api/analytics` and `/api/insights/targets` add `BLOOD_GLUCOSE` to their type whitelists and emit a `glucose` summary object with per-context aggregates when ≥1 reading exists.
|
||||
|
||||
`PATCH /api/auth/me` (already exists for profile updates) gains an optional `glucoseUnit` field.
|
||||
|
||||
## 7. Conversion Helpers
|
||||
|
||||
New module `src/lib/glucose.ts`:
|
||||
|
||||
```ts
|
||||
export const GLUCOSE_FACTOR = 18.0182; // mg/dL per mmol/L (molar mass of glucose)
|
||||
|
||||
export function mgdlToMmol(mgdl: number): number {
|
||||
return Math.round((mgdl / GLUCOSE_FACTOR) * 10) / 10; // 1 decimal
|
||||
}
|
||||
|
||||
export function mmolToMgdl(mmol: number): number {
|
||||
return Math.round(mmol * GLUCOSE_FACTOR); // integer
|
||||
}
|
||||
|
||||
export function formatGlucose(mgdl: number, unit: GlucoseUnit, t: T): string;
|
||||
```
|
||||
|
||||
Round-trip rule: `mmolToMgdl(mgdlToMmol(x))` must be within ±1 mg/dL of `x` for x ∈ [40, 400]. Tested in §9.
|
||||
|
||||
## 8. i18n Keys (`messages/de.json` + `messages/en.json`)
|
||||
|
||||
| Key | DE | EN |
|
||||
|---|---|---|
|
||||
| `measurements.typeGlucose` | Blutzucker | Blood glucose |
|
||||
| `measurements.unitGlucose.mgdl` | mg/dL | mg/dL |
|
||||
| `measurements.unitGlucose.mmol` | mmol/L | mmol/L |
|
||||
| `measurements.glucoseContextLabel` | Kontext | Context |
|
||||
| `measurements.glucoseContextFasting` | Nüchtern | Fasting |
|
||||
| `measurements.glucoseContextPostprandial` | Nach dem Essen (2 h) | Post-meal (2h) |
|
||||
| `measurements.glucoseContextRandom` | Zufällig | Random |
|
||||
| `measurements.glucoseContextBedtime` | Vor dem Schlafen | Bedtime |
|
||||
| `targets.glucoseFastingTitle` | Nüchtern-Blutzucker | Fasting glucose |
|
||||
| `targets.glucosePostprandialTitle` | 2-h-Postprandial | 2h post-meal |
|
||||
| `targets.glucoseSourceAda` | ADA 2024 / DGIM 2023 | ADA 2024 / DGIM 2023 |
|
||||
| `classifications.glucoseHypo` | Hypoglykämie | Hypoglycemia |
|
||||
| `classifications.glucoseNormal` | Normal | Normal |
|
||||
| `classifications.glucosePrediabetes` | Prädiabetes | Pre-diabetes |
|
||||
| `classifications.glucoseDiabetes` | Diabetes-Bereich | Diabetes range |
|
||||
| `doctorReport.typeGlucose` | Blutzucker | Blood glucose |
|
||||
| `doctorReport.glucoseClassificationTitle` | Glukose-Klassifikation | Glucose classification |
|
||||
| `doctorReport.glucoseFastingRow` | Ø Nüchtern: {avg} {unit} — {class} | Avg fasting: {avg} {unit} — {class} |
|
||||
| `doctorReport.glucosePostprandialRow` | Ø Postprandial: {avg} {unit} — {class} | Avg postprandial: {avg} {unit} — {class} |
|
||||
| `settings.glucoseUnitLabel` | Blutzucker-Einheit | Blood glucose unit |
|
||||
| `insights.glucoseControlTitle` | Glukose-Kontrolle | Glucose control |
|
||||
|
||||
## 9. Nyquist Validation
|
||||
|
||||
- **Unit conversion**: property test — for every integer mg/dL in [40, 400], `mmolToMgdl(mgdlToMmol(x))` differs from `x` by ≤1.
|
||||
- **Classification boundaries**: assert `classifyGlucose(99, "FASTING").severity === "normal"`, `classifyGlucose(100, "FASTING").severity === "warning"`, `classifyGlucose(125, ...) === "warning"`, `classifyGlucose(126, ...) === "danger"`, `classifyGlucose(69, "FASTING").severity === "danger"` (hypo). Same for postprandial at 139/140/199/200.
|
||||
- **Schema refinement**: `POST /api/measurements` with `BLOOD_GLUCOSE` and missing `glucoseContext` returns 400; with non-glucose type and a `glucoseContext` returns 400.
|
||||
- **End-to-end smoke**: form submit → DB row → chart render → PDF generation; assert mg/dL stored regardless of UI unit, assert PDF row appears in `vitalRows`.
|
||||
- **Doctor PDF snapshot test**: render with mixed contexts, assert classification line and per-context distribution rows.
|
||||
|
||||
## 10. Risks
|
||||
|
||||
- **Hypoglycemia notifications**: showing "severe low — seek help" on every reading <54 mg/dL could panic users with sensor noise or delayed entries. Notifications are **opt-in** via NotificationPreference (`MEASUREMENT_ANOMALY` event), default off, and the message text always ends with "HealthLog ist kein Medizinprodukt — bei akuten Symptomen 112 anrufen."
|
||||
- **Unit confusion**: a user flipping the unit pref must not reinterpret historical mg/dL values as mmol/L. Migration sets a default; UI shows the unit suffix on every value.
|
||||
- **Withings overlap**: Withings supports glucose via some BGM integrations. Document that Withings sync stays disabled for glucose in v1 (out of scope) to avoid double-entry races.
|
||||
- **Context defaulting**: auto-selecting `FASTING` based on time of day is a heuristic; the user must always be able to override before save.
|
||||
|
||||
## 11. Out of Scope
|
||||
|
||||
- CGM (Dexcom / Libre) live integration.
|
||||
- Ketone (β-hydroxybutyrate) tracking.
|
||||
- Insulin dose log and bolus calculator.
|
||||
- Carb counting / meal logging.
|
||||
- HbA1c (deferred to v1.1, schema is ready).
|
||||
|
||||
## 12. Effort
|
||||
|
||||
**M (medium).** Roughly: schema + migration (S), classifications + conversion lib + tests (S), form/context selector + unit pref (M), targets card + chart preset (S), insight + PDF row + i18n (M), Nyquist tests (S). Estimate 1.5–2 dev-days end-to-end including review. No external dependencies, no new infra.
|
||||
@@ -1,162 +0,0 @@
|
||||
# Built-in Feedback System — Design Spec
|
||||
|
||||
**Status:** Proposed (v1.2+)
|
||||
**Date:** 2026-04-18
|
||||
**Owner:** TBD
|
||||
**Tracking:** GitHub issue #73 (point 6)
|
||||
**Effort:** M
|
||||
|
||||
## 1. Problem Statement
|
||||
|
||||
The current bug-report flow (`src/app/bugreport/page.tsx` + `src/app/api/bugreport/route.ts`) is gated on a GitHub token and repository configured by an admin. The status route returns `configured: false` when those are missing, and the UI degrades to an "ask your admin to configure GitHub" screen — so a non-technical owner of a self-hosted HealthLog instance cannot collect feedback at all without first procuring a GitHub PAT and dedicating a repo.
|
||||
|
||||
Verbatim from issue #73, point 6: *"Provide a simple built-in feedback system where users can submit reports without GitHub accounts, repositories, or tokens. The app should handle issue creation automatically in the background. Alternatively, provide a pre-configured default repository maintained by the developer."*
|
||||
|
||||
We need a feedback path that **works with zero external configuration**, while keeping the existing GitHub integration available as an **optional escalation** for instance owners who do want public issues.
|
||||
|
||||
## 2. Approach
|
||||
|
||||
- **Primary store: Postgres.** Every submission lands in a new `Feedback` table managed by Prisma. No external dependency required.
|
||||
- **Admin inbox UI** at `/admin#feedback` lists, filters and triages submissions.
|
||||
- **GitHub stays optional and per-item.** The admin can press "Publish to GitHub issue" on any feedback row; the existing route logic in `src/app/api/bugreport/route.ts` is refactored into a reusable helper and called from the new admin endpoint. If GitHub isn't configured, the button is hidden — the rest of the flow is unaffected.
|
||||
- **v1 admin notification: email** via the existing `src/lib/notifications/dispatcher.ts` (Telegram / ntfy / web-push reuse the same dispatcher). Optional; silent if no admin channel is configured.
|
||||
- **Existing `/api/bugreport` is deprecated but kept** for two release cycles so any external automation still works. Internally it forwards to the new feedback pipeline.
|
||||
|
||||
## 3. Data Model
|
||||
|
||||
New Prisma model in `prisma/schema.prisma`, additive only:
|
||||
|
||||
```prisma
|
||||
enum FeedbackCategory { BUG FEATURE_REQUEST QUESTION OTHER }
|
||||
enum FeedbackStatus { OPEN ACK RESOLVED ARCHIVED }
|
||||
|
||||
model Feedback {
|
||||
id String @id @default(cuid())
|
||||
userId String? // nullable — see auth note below
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
email String? // snapshot of submitter email at time of submission
|
||||
category FeedbackCategory @default(BUG)
|
||||
subject String @db.VarChar(200)
|
||||
description String @db.Text // ≤ 5000 chars enforced in Zod
|
||||
screenshotBase64 String? @db.Text // ≤ 5 MB; later moved to object storage
|
||||
metadata Json? // { url, userAgent, locale, appVersion, viewport, timestamp }
|
||||
status FeedbackStatus @default(OPEN)
|
||||
adminNote String? @db.Text
|
||||
gitHubIssueUrl String?
|
||||
notifiedEmail Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([status, createdAt])
|
||||
@@index([userId])
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `userId` is nullable to keep the door open for anonymous public-instance submissions in a later iteration; **v1 still requires auth** (`requireAuth()`), so the column will always be populated. The `email` snapshot survives user deletion for triage continuity.
|
||||
- `metadata` is a free-form JSON envelope mirroring the same context we'd otherwise paste into a GitHub issue body — we capture it once at submission time so the admin doesn't have to ask "what page were you on?".
|
||||
- An `AuditLog` row (`feedback.submit`, `feedback.status_change`, `feedback.publish_github`) follows the existing pattern in `src/lib/auth/audit.ts`.
|
||||
|
||||
## 4. API
|
||||
|
||||
All routes use `apiHandler` (Wide Events + x-request-id) and return the standard `{ data, error, meta }` envelope.
|
||||
|
||||
| Method | Path | Auth | Purpose |
|
||||
|---|---|---|---|
|
||||
| `POST` | `/api/feedback` | user | Submit feedback. Rate-limited 5/h per `user.id`. Zod-validated. Returns `{ id, confirmationNumber }`. |
|
||||
| `GET` | `/api/admin/feedback` | admin | Paginated list. Query params: `status`, `category`, `q`, `page`, `pageSize` (default 20, cap 100). |
|
||||
| `GET` | `/api/admin/feedback/:id` | admin | Single item with raw `metadata` and screenshot. |
|
||||
| `PATCH`| `/api/admin/feedback/:id` | admin | Mutate `status` and/or `adminNote`. Writes audit log. |
|
||||
| `POST` | `/api/admin/feedback/:id/github` | admin | Publish to GitHub. 404 if GitHub not configured. Stores `gitHubIssueUrl` on success. |
|
||||
| `GET` | `/api/feedback/status` | user | Public capability probe: `{ available: true, githubAvailable: boolean }`. Replaces `/api/bugreport/status`'s "configured: false" wall. |
|
||||
|
||||
`apiSuccess` payloads use `confirmationNumber = id.slice(-6).toUpperCase()` so users can quote a short code without leaking the full cuid.
|
||||
|
||||
## 5. UI
|
||||
|
||||
### `/bugreport` (renamed conceptually to "Feedback", URL kept)
|
||||
- **Category** dropdown (Bug / Feature request / Question / Other).
|
||||
- **Subject** input (max 120 chars, required).
|
||||
- **Description** textarea (10–5000 chars, existing constraint).
|
||||
- **Screenshot** optional, same upload control as today, ≤ 5 MB, EXIF stripped client-side before base64 encoding.
|
||||
- **Submit** — always enabled when authenticated. No more "not configured" wall.
|
||||
- On success: toast "Thanks for your feedback — reference #ABC123".
|
||||
|
||||
### Admin section `/admin#feedback`
|
||||
- Table: created, category badge, subject, status badge, submitter, actions.
|
||||
- Filters: status (OPEN/ACK/RESOLVED/ARCHIVED), category, free-text search over `subject`/`description`.
|
||||
- Detail dialog: full description, screenshot preview, raw `metadata` JSON, admin note textarea, action buttons **Ack** / **Resolve** / **Archive** / **Publish to GitHub** (last one only when `githubAvailable`).
|
||||
- Empty states (no feedback yet, no results for filter) with translated copy.
|
||||
|
||||
## 6. Screenshot Handling
|
||||
|
||||
- **Storage:** base64 in the `screenshotBase64` column. Hard cap **5 MB per item** (matching the current `bugReportSchema.screenshot` limit) and a soft per-user **20 MB total quota** rejected at submit with a 413.
|
||||
- **EXIF stripping** in the browser via canvas re-encode before upload — prevents inadvertent GPS leak.
|
||||
- **PII warning** in the form copy: "Your screenshot may contain personal data. Crop it before uploading if unsure."
|
||||
- **Risk acknowledged in §13** — DB bloat is the obvious downside; v2 moves blobs to object storage.
|
||||
|
||||
## 7. Abuse & Safety
|
||||
|
||||
- Rate-limit via `src/lib/rate-limit.ts`: `feedback:submit:${user.id}` → 5 per rolling hour. Admin endpoints rate-limited too (`feedback:admin:${user.id}`, 60/min).
|
||||
- Description and subject are **plain text** — no HTML, no Markdown rendering on display in the admin inbox (use `<pre>` or a plain `<div>` with `whitespace-pre-wrap`). When publishing to GitHub we apply the same backtick-escape sanitization as the current bugreport route.
|
||||
- Length caps enforced in Zod and at the DB level (`@db.VarChar(200)` for subject).
|
||||
- **Auto-archive job** in `src/lib/jobs/`: daily pg-boss job marks `RESOLVED` items older than 180 days as `ARCHIVED`, and deletes `ARCHIVED` items older than 365 days. Configurable via `AppSettings`.
|
||||
- Inbox-size soft cap (e.g., 10 000 OPEN rows) surfaces a banner in the admin UI prompting cleanup.
|
||||
|
||||
## 8. Migration
|
||||
|
||||
- Additive Prisma migration only (new enum, new table). No existing data touched.
|
||||
- `/api/bugreport` route stays for **two release cycles**, internally calls the new feedback pipeline, and emits a `Deprecation` response header pointing to `/api/feedback`.
|
||||
- `/api/bugreport/status` keeps responding for legacy clients but its `configured` field now means "feedback works" (always `true` for authenticated users), with `hasGithub` exposed separately.
|
||||
- No data backfill required.
|
||||
|
||||
## 9. Notifications
|
||||
|
||||
- On a successful `POST /api/feedback`, dispatch through `src/lib/notifications/dispatcher.ts` to whichever admin channels are configured (email-first for v1; Telegram/ntfy/web-push fall out for free since they share the dispatcher).
|
||||
- Payload: category, subject, submitter username, deep link `/admin#feedback?id=...`. **No description body** in the notification — keeps PII out of email/push transports.
|
||||
- `notifiedEmail = true` after a successful send so retries don't double-fire.
|
||||
- If no channel is configured, dispatch is a no-op (logged as an info annotation, not a warning).
|
||||
|
||||
## 10. i18n
|
||||
|
||||
New translation namespace `feedback.*` in `messages/en.json` and `messages/de.json`:
|
||||
- Category names (`feedback.category.bug` etc.), status names, form labels, admin-inbox column headers, action button labels, empty states, toast/confirmation copy, PII warning text, deprecation banner on legacy bugreport endpoint.
|
||||
- Existing `bugreport.*` keys stay until the route is removed; new UI does not depend on them.
|
||||
|
||||
## 11. GitHub Publish
|
||||
|
||||
- Refactor the GitHub-issue creation in `src/app/api/bugreport/route.ts` into `src/lib/feedback/publish-github.ts` — pure function `publishToGithub(feedback): Promise<{ issueNumber, issueUrl }>`. Reused by both the deprecated bugreport route (transparently) and the new `POST /api/admin/feedback/:id/github`.
|
||||
- Idempotency: if `gitHubIssueUrl` is already set on the feedback row, return 409 with the existing URL.
|
||||
- Admin can append optional context via a textarea in the publish dialog; this is prepended to the issue body and **not** stored on the feedback row.
|
||||
|
||||
## 12. Nyquist Validation
|
||||
|
||||
Acceptance scenarios that must pass before sign-off:
|
||||
- Submit while logged out → 401.
|
||||
- Submit 6 times in an hour → 6th returns 429 with rate-limit headers.
|
||||
- Screenshot > 5 MB → 413, no DB row created.
|
||||
- Cumulative user quota > 20 MB → 413 with quota-specific error code.
|
||||
- Non-admin hits `/api/admin/feedback*` → 403.
|
||||
- Publish-to-GitHub round-trip: feedback row gains `gitHubIssueUrl`, repeat call returns 409.
|
||||
- Auto-archive job marks correct rows after fast-forwarding clock in test.
|
||||
- Notification dispatch is silent when no channel configured (info event, not warning).
|
||||
- Plain-text rendering verified — submitting `<script>alert(1)</script>` displays escaped in admin UI.
|
||||
- Wide Event payload includes `action.name=feedback.submit`, `meta.category`, `meta.has_screenshot`.
|
||||
|
||||
## 13. Risks
|
||||
|
||||
- **DB bloat from base64 screenshots.** Mitigation: per-item + per-user quotas now, object storage migration (S3/MinIO/Coolify volume) tracked as a follow-up. Schema is forward-compatible — `screenshotBase64` becomes nullable alongside a future `screenshotKey` column.
|
||||
- **Email transport coupling.** If the dispatcher fails, submission must still succeed; notifications are fire-and-forget after the DB row commits.
|
||||
- **Admin-inbox UX scaling** past a few hundred items needs server-side filtering (already in the API spec).
|
||||
|
||||
## 14. Out of Scope
|
||||
|
||||
- User-to-user comment threads on feedback items.
|
||||
- Voting / upvoting feedback.
|
||||
- Public read-only feedback board.
|
||||
- Anonymous (logged-out) submissions — schema-ready, intentionally not enabled in v1.
|
||||
- Importing existing GitHub issues back into the inbox.
|
||||
|
||||
## 15. Effort
|
||||
|
||||
**M** — one Prisma migration, ~6 new API routes (most thin), one form refactor, one new admin section, refactor of existing GitHub call into a helper, dispatcher hookup, one pg-boss job, ~30 i18n keys × 2 locales.
|
||||
@@ -1,227 +0,0 @@
|
||||
# User-Configurable Health Thresholds — Design Spec
|
||||
|
||||
**Datum:** 2026-04-18
|
||||
**Status:** Draft
|
||||
**Scope:** Per-user override layer for the metric target ranges currently rendered on `/targets` and used across analytics.
|
||||
|
||||
## 1. Problem
|
||||
|
||||
HealthLog computes target ranges from established medical guidelines:
|
||||
|
||||
- Weight from BMI 18.5–24.9 (WHO) via `getWeightRange` / `buildWeightBandsFromHeight`
|
||||
- BP targets age-personalized per ESC/ESH 2018 (`bp-targets.ts`, `getBpTargetsByAge`)
|
||||
- Pulse from CDC/NCHS percentiles or AHA fallback (`pulse-targets.ts`)
|
||||
- Body fat from ACE thresholds (`classifyBodyFat`)
|
||||
- Sleep duration from AASM/SRS (`getSleepDurationRange`)
|
||||
- Steps from WHO (`getStepsRange`)
|
||||
|
||||
These are sane defaults, but real patients receive **doctor-recommended individual targets**. A diabetic with autonomic neuropathy may have a BP target of 110/65–135/80; a marathoner may have a personal "green" pulse band of 38–55 that the AHA bands flag as bradycardia; a recovering anorexia patient may have a clinically supervised weight floor above the BMI-18.5 line. Today the UI exposes computed ranges with **no override mechanism**, and analytics, alerts, and the doctor-report PDF all silently use the guideline values.
|
||||
|
||||
## 2. Why (User Intent)
|
||||
|
||||
- Users may have doctor-recommended personalized targets that differ from the default ESC/ESH / WHO / AHA guidelines.
|
||||
- Current UI shows computed defaults with no override mechanism.
|
||||
- User wants a dedicated settings page where thresholds can be overridden per metric, with the default still visible for comparison and one-click reset.
|
||||
|
||||
## 3. Requirements
|
||||
|
||||
1. **Per-metric override** for the full set: `WEIGHT` (min/max), `BLOOD_PRESSURE_SYS` (low/high), `BLOOD_PRESSURE_DIA` (low/high), `PULSE` (min/max), `BODY_FAT` (min/max), `SLEEP_DURATION` (min/max in hours), `ACTIVITY_STEPS` (min/max). Reserve slots for `BLOOD_GLUCOSE_FASTING` and `BLOOD_GLUCOSE_POSTPRANDIAL` even though they are not yet measured — the schema should not need a migration when the metric ships.
|
||||
2. **Optional yellow zone**: each metric may carry a separate `warnMin/warnMax` (orange band) in addition to `min/max` (green band). Falls back to the default's orange computation (currently `±30%` span via `buildTrafficRange`) if absent.
|
||||
3. **Comparable to default**: every UI surface that renders a range must be able to show "Default: X / Your override: Y" — the API therefore returns `defaults`, `overrides`, and `effective` together.
|
||||
4. **Reset to default per metric** — single-click, no confirm needed unless overrides existed (see §11).
|
||||
5. **Source-of-truth precedence**: `effective = override ?? default(profile)`. Default still recomputes when profile (height, age, gender) changes, so users who never override see no change in behavior.
|
||||
6. **Server-side validation** — overrides must satisfy `min < max`, sit within hard sanity bounds, and pass plausibility checks (out-of-normal-range warns, see §11).
|
||||
7. **All consumers must use one helper** — no analytics module should call the raw default-computer directly anymore (see §6).
|
||||
8. **Audit trail** for every change (medical data, see §8).
|
||||
|
||||
## 4. Data Model
|
||||
|
||||
### Decision: JSON column on `User`, not a new table
|
||||
|
||||
Two options were considered:
|
||||
|
||||
**Option A — `UserThreshold` table** (one row per user/metric):
|
||||
- Pro: query-friendly, easy partial indexes, future reporting per metric.
|
||||
- Con: 9+ rows per user for a feature that is read together every page load; joins on every targets fetch; migration friction when adding metrics.
|
||||
|
||||
**Option B — JSON column on `User`** (`thresholdsJson Json?`):
|
||||
- Pro: read-with-user is one row, atomic write, schema-less for new metrics, mirrors Prisma 7 / Postgres `jsonb` strengths, validated by Zod at the application layer.
|
||||
- Con: cannot index individual thresholds in the DB (acceptable — there is no use case for "find all users whose pulse cap > 110").
|
||||
|
||||
**Choose Option B.** A `UserSetting` model does not currently exist, so we add the column directly to `User`:
|
||||
|
||||
```
|
||||
// prisma/schema.prisma — additive
|
||||
model User {
|
||||
...
|
||||
thresholdsJson Json? @map("thresholds_json")
|
||||
}
|
||||
```
|
||||
|
||||
### JSON shape (TypeScript, source of truth in `src/lib/validations/thresholds.ts`)
|
||||
|
||||
```ts
|
||||
type ThresholdOverride = {
|
||||
min?: number;
|
||||
max?: number;
|
||||
warnMin?: number; // optional yellow zone lower bound
|
||||
warnMax?: number; // optional yellow zone upper bound
|
||||
updatedAt: string; // ISO-8601, set server-side on every PUT
|
||||
};
|
||||
|
||||
type ThresholdsJson = {
|
||||
WEIGHT?: ThresholdOverride;
|
||||
BLOOD_PRESSURE_SYS?: ThresholdOverride;
|
||||
BLOOD_PRESSURE_DIA?: ThresholdOverride;
|
||||
PULSE?: ThresholdOverride;
|
||||
BODY_FAT?: ThresholdOverride;
|
||||
SLEEP_DURATION?: ThresholdOverride;
|
||||
ACTIVITY_STEPS?: ThresholdOverride;
|
||||
BLOOD_GLUCOSE_FASTING?: ThresholdOverride; // reserved
|
||||
BLOOD_GLUCOSE_POSTPRANDIAL?: ThresholdOverride; // reserved
|
||||
};
|
||||
```
|
||||
|
||||
An override is "active" when `min` and `max` are both numbers; partial-only objects are rejected by Zod. Absence of a key === use default.
|
||||
|
||||
## 5. API
|
||||
|
||||
### `GET /api/user/thresholds`
|
||||
|
||||
Response (envelope `{ data, error, meta }`):
|
||||
```
|
||||
data: {
|
||||
defaults: { WEIGHT: { min, max, warnMin, warnMax, source }, ... },
|
||||
overrides: { PULSE: { min, max, warnMin?, warnMax?, updatedAt }, ... },
|
||||
effective: { WEIGHT: { min, max, warnMin, warnMax, isOverride: false }, ... },
|
||||
profile: { heightCm, age, gender }
|
||||
}
|
||||
```
|
||||
`defaults` is always populated (even for metrics the user has not measured, so the UI can render an empty slot with the default visible). `overrides` only contains keys the user has actually customized. `effective` is the merged result every analytics consumer should think in.
|
||||
|
||||
### `PUT /api/user/thresholds`
|
||||
|
||||
Body: partial map of metric → override (Zod-validated). Replaces only the listed metrics; unspecified keys are untouched.
|
||||
```
|
||||
{ "PULSE": { "min": 50, "max": 95 },
|
||||
"BLOOD_PRESSURE_SYS": { "min": 110, "max": 130, "warnMin": 105, "warnMax": 140 } }
|
||||
```
|
||||
Per-metric Zod rules (`src/lib/validations/thresholds.ts`):
|
||||
- `min < max`
|
||||
- if `warnMin` set: `warnMin <= min`; if `warnMax` set: `warnMax >= max`
|
||||
- hard sanity bounds (reject obviously broken input):
|
||||
- `WEIGHT`: 30–300 kg
|
||||
- `BLOOD_PRESSURE_SYS`: 70–220 mmHg
|
||||
- `BLOOD_PRESSURE_DIA`: 40–140 mmHg
|
||||
- `PULSE`: 30–220 bpm
|
||||
- `BODY_FAT`: 3–60 %
|
||||
- `SLEEP_DURATION`: 3–14 h
|
||||
- `ACTIVITY_STEPS`: 0–50000
|
||||
- `BLOOD_GLUCOSE_FASTING`: 50–250 mg/dL
|
||||
- `BLOOD_GLUCOSE_POSTPRANDIAL`: 70–350 mg/dL
|
||||
- soft "out-of-normal-range" check returns a Zod warning (not error) — surfaced to the UI to trigger the confirm dialog (see §11).
|
||||
|
||||
### `DELETE /api/user/thresholds/:metric`
|
||||
|
||||
Removes one key from `thresholdsJson`. Returns the new `effective` block so the client can refresh without a second roundtrip.
|
||||
|
||||
All three routes are wrapped by `apiHandler` and `requireAuth()`, log Wide Events, and emit AuditLog entries (§8).
|
||||
|
||||
## 6. Integration Points
|
||||
|
||||
A single helper becomes the only public entry for "what is the target range":
|
||||
|
||||
```
|
||||
src/lib/analytics/effective-range.ts
|
||||
getEffectiveRange(metric, user, overrides?) → { min, max, warnMin, warnMax, source, isOverride }
|
||||
```
|
||||
|
||||
It internally calls the existing default computers (`getWeightRange`, `getBpTargets`, `getPersonalizedPulseTarget`, `classifyBodyFat`'s table, `getSleepDurationRange`, `getStepsRange`) and applies any matching override. `source` becomes `"User override"` when `isOverride === true`, otherwise the existing guideline string.
|
||||
|
||||
Refactor in scope:
|
||||
- `src/lib/analytics/classifications.ts` — `classifyBP`, `classifyPulse`, `classifyBodyFat`, `classifySleepDuration`, `classifySteps`, `getWeightRange`, `getPulseRange`, `getSleepDurationRange`, `getBpTargetsByAge`: keep as pure default computers, but stop being called from feature code. Add thin `classifyXWithRange(value, range)` variants that take the resolved range.
|
||||
- `src/lib/analytics/value-bands.ts` — `buildTrafficRange` and `buildWeightBandsFromHeight` accept an optional `override` argument and prefer it over the BMI-derived band.
|
||||
- `src/lib/analytics/pulse-targets.ts` — `getPersonalizedPulseTarget` stays as the default; new `resolvePulseTarget(user, overrides)` lives in `effective-range.ts`.
|
||||
- `src/lib/analytics/bp-targets.ts` — `getBpTargets` likewise stays; `effective-range.ts` wraps it.
|
||||
- `src/app/api/insights/targets/route.ts` (the API behind `/targets`) reads `thresholdsJson` once and passes it to `getEffectiveRange` per metric, then returns the `effective` block to the client. The `source` field already shown on `TargetCard` flips to "User override" automatically.
|
||||
- Doctor-report PDF (`src/lib/doctor-report-pdf.ts`) and Wide-Event annotations include `isOverride` so reports clearly mark customized targets.
|
||||
|
||||
## 7. Migration
|
||||
|
||||
Additive only — single Prisma migration adds `users.thresholds_json jsonb`. Existing users get `NULL`, which `getEffectiveRange` treats as "no overrides → behave exactly as before". No data backfill, no flag rollout.
|
||||
|
||||
## 8. Auditing
|
||||
|
||||
Every `PUT` and `DELETE` writes one `AuditLog` row (model already exists at `prisma/schema.prisma:391`):
|
||||
|
||||
- `action`: `"thresholds.update"` or `"thresholds.reset"`
|
||||
- `details` (JSON string): `{ metric, before: {min,max,warnMin,warnMax} | null, after: {...} | null, reason?: "user" | "out_of_range_confirmed" }`
|
||||
- `userId`, `ipAddress`, `location` from session context (already populated by `apiHandler`).
|
||||
|
||||
The `before` value is read from the current `thresholdsJson` *or* the computed default (so the audit trail captures the implicit baseline a user customized away from). Reset writes `after: null`.
|
||||
|
||||
## 9. i18n Keys
|
||||
|
||||
New section under `messages/en.json` and `messages/de.json`:
|
||||
|
||||
| Key | EN | DE |
|
||||
|---|---|---|
|
||||
| `thresholds.title` | "Custom thresholds" | "Eigene Zielwerte" |
|
||||
| `thresholds.intro` | "Override the medical default ranges with values your doctor recommended. Defaults stay visible for comparison and you can reset any metric to the guideline at any time." | "Ersetze die medizinischen Standardbereiche durch arztempfohlene Werte. Die Standardwerte bleiben sichtbar und du kannst jeden Wert jederzeit auf die Leitlinie zurücksetzen." |
|
||||
| `thresholds.defaultLabel` | "Default" | "Standard" |
|
||||
| `thresholds.overrideLabel` | "Your override" | "Eigener Wert" |
|
||||
| `thresholds.enableToggle` | "Use custom value" | "Eigenen Wert verwenden" |
|
||||
| `thresholds.warnZoneLabel` | "Yellow zone (optional)" | "Gelber Bereich (optional)" |
|
||||
| `thresholds.minLabel` | "Min" | "Min" |
|
||||
| `thresholds.maxLabel` | "Max" | "Max" |
|
||||
| `thresholds.resetButton` | "Reset to default" | "Auf Standard zurücksetzen" |
|
||||
| `thresholds.resetPrompt` | "Reset {metric} to the medical default?" | "{metric} auf den medizinischen Standardwert zurücksetzen?" |
|
||||
| `thresholds.saved` | "Thresholds saved" | "Zielwerte gespeichert" |
|
||||
| `thresholds.outOfRangeWarn` | "This value lies outside the typical medical range. Please confirm only if your doctor recommended it." | "Dieser Wert liegt außerhalb des üblichen medizinischen Bereichs. Bitte nur bestätigen, wenn dein Arzt das empfohlen hat." |
|
||||
| `thresholds.notMedicalAdvice` | "HealthLog does not provide medical advice. Custom thresholds are stored as-is." | "HealthLog ersetzt keine ärztliche Beratung. Eigene Zielwerte werden unverändert gespeichert." |
|
||||
| `thresholds.validation.minLessMax` | "Min must be less than max" | "Min muss kleiner als Max sein" |
|
||||
| `thresholds.validation.warnEnvelope` | "Yellow zone must surround the green zone" | "Der gelbe Bereich muss den grünen Bereich umschließen" |
|
||||
| `thresholds.validation.outOfBounds` | "Value outside allowed range ({min}–{max} {unit})" | "Wert außerhalb des erlaubten Bereichs ({min}–{max} {unit})" |
|
||||
|
||||
Existing `targets.*` keys stay; the targets page gains a small "Customize" link routing to `/settings#thresholds`.
|
||||
|
||||
## 10. UI
|
||||
|
||||
New section in the existing settings shell at `/settings#thresholds`:
|
||||
|
||||
- One card per metric. Header shows the metric label + icon (reuses `TYPE_ICONS` from `targets/page.tsx`).
|
||||
- Body left: read-only "Default: 60 – 100 bpm" with the source citation (same external link logic as `getTargetSourceLink`).
|
||||
- Body right: an "Use custom value" toggle. When on, two number inputs (min, max) plus an optional collapsible "Yellow zone" with `warnMin`/`warnMax`.
|
||||
- Inline validation via React Hook Form + Zod (the same schema the server uses).
|
||||
- Footer: "Reset to default" button (visible only when an override exists).
|
||||
- Out-of-range soft warnings render as an inline orange callout with a "I confirm my doctor recommended this" checkbox required to enable Save.
|
||||
- Persistent disclaimer banner at the top: `thresholds.notMedicalAdvice`.
|
||||
|
||||
## 11. Nyquist Validation (tests in `src/lib/analytics/__tests__/effective-range.test.ts` and route tests)
|
||||
|
||||
- `getEffectiveRange` returns the override when present and the default when absent (one test per metric).
|
||||
- Boundary tests: `min === max` rejected; `warnMin === min` accepted; off-by-one above/below the hard sanity bounds rejected/accepted.
|
||||
- Profile-change test: removing `heightCm` collapses the WEIGHT default to "no range", but a present override survives.
|
||||
- API contract: `GET` returns `defaults`, `overrides`, `effective`, `profile`. `PUT` with partial body leaves untouched metrics intact. `DELETE :metric` removes only that key.
|
||||
- AuditLog firing: spy that one row is written per `PUT`/`DELETE`, with correct `before`/`after`. Confirm `details` JSON parses.
|
||||
- Out-of-range confirmation: PUT with a value flagged "out_of_normal_range" requires `?confirm=1` query param (or a body flag) — without it the route returns `400 confirmation_required`.
|
||||
- i18n parity test: every new EN key has a DE counterpart (re-uses the existing parity test if present, else add).
|
||||
|
||||
## 12. Risks & Mitigations
|
||||
|
||||
- **Users set unhealthy targets.** Hard sanity bounds reject obvious nonsense; soft "out-of-normal-range" warns and demands explicit confirmation. Persistent banner ("not medical advice"). Audit trail enables forensic review.
|
||||
- **Insights/AI prompts may quote a user-customized range as if it were the medical guideline.** The provider prompts must receive the `source` field and explicitly distinguish "User override" from a guideline citation. Update `src/lib/ai/prompts/*` accordingly.
|
||||
- **Doctor-report PDF.** Mark each customized range with a small "(custom)" tag so a treating physician sees that the report's coloring reflects the patient's own targets, not the guideline.
|
||||
- **Default drift over time.** If we update guidelines (e.g. ESC/ESH 2024), users with overrides are unaffected — but users without overrides will see their colored bands shift. Document in the changelog.
|
||||
|
||||
## 13. Out of Scope
|
||||
|
||||
- Sharing thresholds between users / household.
|
||||
- Doctor export of overrides (a treating physician downloading the override JSON to reuse) — could come later as part of the doctor report.
|
||||
- Time-bounded thresholds ("until 2026-12-31"), seasonal thresholds, schedule-based thresholds.
|
||||
- Per-medication or per-condition threshold packs.
|
||||
- Threshold suggestions from AI insights.
|
||||
|
||||
## 14. Effort
|
||||
|
||||
**M (medium).** One Prisma migration, one new helper module, three new API routes, refactoring of ~5 analytics consumers to go through `getEffectiveRange`, one new settings card, ~14 i18n keys per locale, and one new test file. No external integrations, no UI primitive work (existing shadcn `Card`/`Input`/`Switch`/`Button` cover everything). Estimated 1.5–2 focused days for a senior dev including tests and audit-log wiring.
|
||||
@@ -1,117 +0,0 @@
|
||||
# Dashboard Customization — Design Spec
|
||||
|
||||
**Status:** Proposed (v1.2+)
|
||||
**Date:** 2026-04-18
|
||||
**Owner:** TBD
|
||||
|
||||
## Why
|
||||
|
||||
- Users have different health focus areas. Someone not tracking blood pressure shouldn't see an empty BP card.
|
||||
- Current "hide if no data" rule (`showBodyFatCard = (bf?.count ?? 0) > 0`, etc. in `src/app/page.tsx`) is implicit — users can't pin the cards they care about, and a card vanishes the moment they delete the last entry.
|
||||
- This is for a v1.2+ release, not part of the current bugfix push.
|
||||
|
||||
## 1. Problem Statement
|
||||
|
||||
The dashboard at `src/app/page.tsx` renders a fixed set of trend cards and charts (weight, BP sys/dia, pulse, body fat, mood, BMI) in a hard-coded order. Visibility is implicit (derived from data presence), ordering is non-negotiable. Narrowly-focused users carry visual noise; users with strong preferences cannot reorder. We need explicit, server-persisted, per-user control over which widgets appear and in what order.
|
||||
|
||||
## 2. Requirements
|
||||
|
||||
- **Per-user widget visibility.** Each chart and trend card is a "widget" that the user can show or hide independently.
|
||||
- **Widget ordering — list-based reorder with up/down buttons + numeric position.** Defended below.
|
||||
- **"Reset to defaults" action** that restores the v1.0 behavior (everything visible, default order).
|
||||
- **Server-persisted** so settings follow the user across devices (web + PWA on phone). localStorage only acts as an optimistic cache.
|
||||
- **i18n-friendly.** Widget IDs are stable English keys; display labels resolve through existing `dashboard.*` translation keys (`messages/en.json`, `messages/de.json`).
|
||||
|
||||
**Reorder UX choice:** list-based (up/down arrows) over drag-and-drop. Reasons: (a) mobile-first PWA — drag-on-touch with scroll containers is fragile; (b) keyboard/screen-reader accessible by default; (c) no extra dep (`@dnd-kit` ~30 KB). DnD can be added later behind a flag.
|
||||
|
||||
## 3. Data Model
|
||||
|
||||
**Decision: extend `User` with a `dashboardWidgets Json?` column.** No new table.
|
||||
|
||||
```prisma
|
||||
model User {
|
||||
// ...
|
||||
dashboardWidgets Json? @map("dashboard_widgets")
|
||||
}
|
||||
```
|
||||
|
||||
Shape (validated by Zod on read/write):
|
||||
```ts
|
||||
type DashboardWidgetState = {
|
||||
version: 1;
|
||||
widgets: Array<{ id: WidgetId; visible: boolean; order: number }>;
|
||||
};
|
||||
```
|
||||
|
||||
`WidgetId` is a string-literal union seeded from a server-side registry (`src/lib/dashboard/widgets.ts`):
|
||||
`"weight" | "bp" | "pulse" | "body_fat" | "mood" | "bmi"`.
|
||||
|
||||
**Tradeoffs vs. dedicated `DashboardWidget` model:** JSON wins because the widget set is small (<20 forecast), state is read once per dashboard load, and we never need to query "which users hide widget X." A table would cost a migration + FK + N writes per reorder for ~6 rows, with no queryability payoff. Cost: schema drift risk — mitigated by the Zod gate and the `version` field, which lets us evolve the shape without a SQL migration.
|
||||
|
||||
## 4. API
|
||||
|
||||
Two routes under `src/app/api/dashboard/widgets/route.ts`, both wrapped in `apiHandler` and `requireAuth()`.
|
||||
|
||||
**`GET /api/dashboard/widgets`** — returns merged state (stored values overlaid on the registry; new widgets appear at the end with `visible: true`):
|
||||
```json
|
||||
{ "data": { "version": 1, "widgets": [
|
||||
{ "id": "weight", "visible": true, "order": 0 },
|
||||
{ "id": "mood", "visible": false, "order": 5 }
|
||||
]}, "error": null, "meta": {...} }
|
||||
```
|
||||
|
||||
**`PUT /api/dashboard/widgets`** — replaces full state. Zod schema in `src/lib/validations/dashboard.ts`:
|
||||
```ts
|
||||
const dashboardWidgetsSchema = z.object({
|
||||
version: z.literal(1),
|
||||
widgets: z.array(z.object({
|
||||
id: z.enum(WIDGET_REGISTRY.map(w => w.id)),
|
||||
visible: z.boolean(),
|
||||
order: z.int().min(0),
|
||||
})).min(1),
|
||||
}).refine(s => new Set(s.widgets.map(w => w.id)).size === s.widgets.length);
|
||||
```
|
||||
Unknown IDs and duplicates are rejected. Mutations invalidate `queryKeys.dashboardLayout()`.
|
||||
|
||||
**`DELETE /api/dashboard/widgets`** — clears the column → user gets defaults again ("Reset to defaults").
|
||||
|
||||
## 5. UI
|
||||
|
||||
**Location:** new section in `/settings` named **"Dashboard"** (matches existing settings architecture: Profile, Notifications, Integrations, etc.). Plus a small **"Customize"** icon button on the dashboard header next to the "Add" dropdown that deep-links to `/settings/dashboard`.
|
||||
|
||||
**Flow** (settings page):
|
||||
1. Header "Dashboard widgets" + subhead "Choose which cards and charts appear on your dashboard."
|
||||
2. Ordered list of shadcn `Card` rows — translated label (`t("dashboard.weight")`), short description, `Switch` for visibility, `ChevronUp`/`ChevronDown` reorder buttons (disabled at list ends).
|
||||
3. Sticky footer: outline "Reset to defaults" (confirm via `AlertDialog`) + primary "Save" (disabled until dirty).
|
||||
4. Optimistic update via TanStack Query mutation; on error, rollback + toast.
|
||||
|
||||
**Dashboard read path:** `src/app/page.tsx` becomes a thin orchestrator — fetch layout via `useQuery(queryKeys.dashboardLayout())`, sort by `order`, filter `visible`, render through a `<DashboardWidget id="…" />` switch. Existing data-presence guards stay as a safety net.
|
||||
|
||||
## 6. Migration
|
||||
|
||||
- Prisma migration: `ALTER TABLE users ADD COLUMN dashboard_widgets jsonb`.
|
||||
- No backfill. Null = "use defaults" = the v1.0 behavior frozen as `DEFAULT_LAYOUT` constant in `src/lib/dashboard/widgets.ts`. The `GET` endpoint synthesizes this on the fly.
|
||||
- First save writes the full normalized blob.
|
||||
- Existing users see no change until they open settings.
|
||||
|
||||
## 7. Nyquist Validation Criteria
|
||||
|
||||
- **Vitest:** Zod rejects duplicate IDs, unknown IDs, negative order, missing version.
|
||||
- **Vitest:** layout merger appends new registry widgets with `visible: true` when user state predates them.
|
||||
- **Vitest:** `DELETE` then `GET` returns `DEFAULT_LAYOUT`.
|
||||
- **API integration:** unauth `PUT` → 401; valid `PUT` → 200 + persisted; payload >50 KB → 413.
|
||||
- **Manual UAT:** hide BP on desktop → reload on phone PWA → BP gone. Reorder weight to last → reload → still last. Reset → defaults restored.
|
||||
- **A11y:** keyboard nav on reorder buttons, `aria-label` on Up/Down, screen-reader announces position changes.
|
||||
- **Perf:** dashboard initial render does not regress (React Profiler check).
|
||||
|
||||
## 8. Out of Scope
|
||||
|
||||
- Custom user-defined widgets / per-widget chart config (color, range, time window).
|
||||
- Widgets for future measurement types (blood glucose, SpO2, sleep stages, steps) — added to registry when those features ship.
|
||||
- Multiple dashboards or tabs; sharing layouts between users.
|
||||
- Drag-and-drop reorder (revisit if list UX gets complaints).
|
||||
- Per-widget grid size (1x/2x spans).
|
||||
|
||||
## 9. Estimated Effort
|
||||
|
||||
**M** — roughly 1–2 days. Schema + Zod + registry ~2h, API + tests ~3h, Settings UI ~4h, dashboard refactor ~3h, i18n + polish + UAT ~2h.
|
||||
@@ -1,216 +0,0 @@
|
||||
# Multi-provider AI Insights — Design Spec
|
||||
|
||||
**Date:** 2026-04-18
|
||||
**Status:** Draft
|
||||
**Scope:** Add Anthropic Claude and self-hosted (OpenAI-compatible) LLM endpoints alongside the existing OpenAI / ChatGPT Codex providers. Per-user provider choice, admin-level fallback, encrypted credential storage.
|
||||
|
||||
## 1. Problem
|
||||
|
||||
Today HealthLog can only talk to OpenAI — either the user's ChatGPT subscription via Codex OAuth, or an admin-managed OpenAI API key. That ties insight generation to a single vendor and to the public internet.
|
||||
|
||||
> "I'd like to pick my provider per user. OpenAI for some accounts, Claude for me, and on my home server I want to point HealthLog at a local Ollama instance — none of my BP readings should leave the LAN."
|
||||
> — User intent
|
||||
|
||||
Consequences of the single-vendor model:
|
||||
- No way to use Anthropic Claude despite better medical-reasoning answers.
|
||||
- Privacy-sensitive users (BP, mood, medication) have no on-prem option.
|
||||
- Vendor outages or quota exhaustion bring all insights down (already observed with OpenAI quota).
|
||||
|
||||
## 2. Current state
|
||||
|
||||
`src/lib/ai/provider.ts` exposes `resolveProvider(userId)` returning an `AIProvider`. The contract in `src/lib/ai/types.ts` is small and provider-agnostic:
|
||||
|
||||
```ts
|
||||
export type ProviderType = "codex" | "admin-key" | "none";
|
||||
export interface AIProvider {
|
||||
type: ProviderType;
|
||||
generateCompletion(params: CompletionParams): Promise<CompletionResult>;
|
||||
}
|
||||
export interface CompletionParams {
|
||||
systemPrompt: string;
|
||||
userPrompt: string;
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
}
|
||||
export interface CompletionResult {
|
||||
content: string;
|
||||
tokensUsed: number | null;
|
||||
model: string;
|
||||
providerType: ProviderType;
|
||||
}
|
||||
```
|
||||
|
||||
Implementations: `OpenAIClient` (Chat Completions, `response_format: json_object`, captures upstream body excerpt for diagnostics), `CodexClient` (chatgpt.com Responses API with refresh-on-401). Selection is hard-coded: Codex if connected, else admin OpenAI key, else `NoProvider`.
|
||||
|
||||
What needs to change:
|
||||
- Widen `ProviderType` to include `anthropic` and `local`.
|
||||
- Add per-user provider choice (currently only Codex is per-user).
|
||||
- Add per-user model + base-URL + API key fields, mirrored on `AppSettings`.
|
||||
- Two new clients (`AnthropicClient`, `LocalOpenAICompatibleClient`).
|
||||
- New selection function that honors user preference before admin defaults.
|
||||
|
||||
## 3. Target abstraction
|
||||
|
||||
The `AIProvider` interface stays exactly as-is. New concrete clients implement it without touching call-sites in `src/app/api/insights/*`.
|
||||
|
||||
```
|
||||
ProviderType = "codex" | "openai" | "anthropic" | "local" | "admin-key" | "none"
|
||||
```
|
||||
(`"admin-key"` retained for back-compat with already-cached `InsightResult.providerType` strings.)
|
||||
|
||||
Selection order in `resolveProvider(userId)`:
|
||||
1. **User explicit choice** (`User.aiProvider`) with valid credentials.
|
||||
2. **Codex OAuth** if user picked `CHATGPT_OAUTH` and tokens are present (existing path).
|
||||
3. **Admin default** (`AppSettings.adminAiProvider` + admin key) for users with no personal config.
|
||||
4. **`NoProvider`** — surface a clear error to the route handler.
|
||||
|
||||
The function is the single boundary: every call that needs an LLM goes through it. No client is constructed elsewhere.
|
||||
|
||||
## 4. Data model
|
||||
|
||||
`User` model additions (all nullable; user keeps current behaviour until they opt in):
|
||||
|
||||
```prisma
|
||||
aiProvider String? @map("ai_provider") // OPENAI | ANTHROPIC | LOCAL | CHATGPT_OAUTH
|
||||
aiModel String? @map("ai_model") // e.g. claude-sonnet-4-6
|
||||
aiBaseUrl String? @map("ai_base_url") // local endpoint
|
||||
aiOpenAiKeyEncrypted String? @map("ai_openai_key_encrypted")
|
||||
aiAnthropicKeyEncrypted String? @map("ai_anthropic_key_encrypted")
|
||||
aiLocalKeyEncrypted String? @map("ai_local_key_encrypted") // optional bearer for local
|
||||
```
|
||||
|
||||
`AppSettings` mirrors:
|
||||
|
||||
```prisma
|
||||
adminAiProvider String @default("OPENAI") @map("admin_ai_provider")
|
||||
adminAiAnthropicKeyEncrypted String? @map("admin_ai_anthropic_key_encrypted")
|
||||
adminAiAnthropicModel String? @default("claude-sonnet-4-6") @map("admin_ai_anthropic_model")
|
||||
adminAiLocalBaseUrl String? @map("admin_ai_local_base_url")
|
||||
adminAiLocalKeyEncrypted String? @map("admin_ai_local_key_encrypted")
|
||||
adminAiLocalModel String? @map("admin_ai_local_model")
|
||||
```
|
||||
|
||||
The existing `adminAiKeyEncrypted` / `adminAiModel` / `adminAiBaseUrl` fields stay and represent the OpenAI configuration. One Prisma migration adds the columns; existing users have `aiProvider = NULL` and route through admin default exactly like today.
|
||||
|
||||
## 5. New clients
|
||||
|
||||
### AnthropicClient
|
||||
- `POST {baseUrl}/v1/messages` with `x-api-key` and `anthropic-version: 2023-06-01`.
|
||||
- `system` parameter for `params.systemPrompt`; `messages: [{ role: "user", content: params.userPrompt }]`.
|
||||
- Structured JSON via **tool use**: declare a single `record_insight` tool whose `input_schema` mirrors `insightResultSchema`. Set `tool_choice: { type: "tool", name: "record_insight" }`. Read JSON from the `tool_use` block's `input` and serialize to a string for `CompletionResult.content`.
|
||||
- Prompt caching: send the system prompt with `cache_control: { type: "ephemeral" }`. Insights re-run the same multi-paragraph medical preamble per metric — empirically ~90% cost reduction on cached reads.
|
||||
- Tokens: sum `usage.input_tokens + usage.output_tokens` (incl. `cache_read_input_tokens`).
|
||||
- Errors: capture body excerpt like `OpenAIClient` already does, scrub any `sk-ant-...` substrings before logging.
|
||||
|
||||
Default models (selectable in UI):
|
||||
- `claude-sonnet-4-6` (default — quality / cost balance)
|
||||
- `claude-opus-4-7` (highest quality)
|
||||
- `claude-haiku-4-5` (cheap, fast)
|
||||
|
||||
### LocalOpenAICompatibleClient
|
||||
- Same wire format as `OpenAIClient` (`/chat/completions`, optional `Authorization: Bearer ...` only if a key is set).
|
||||
- `baseUrl` is required and user-configurable (e.g. `http://localhost:11434/v1` for Ollama, `http://lm-studio.lan:1234/v1`).
|
||||
- Some local backends reject `response_format: { type: "json_object" }`. Strategy: feature-detect on first failure (HTTP 400 with body containing `response_format`), persist `User.aiLocalSupportsJsonMode = false`, and re-issue without the field. Append `"Return JSON only, no prose."` to the user prompt and run output through `jsonrepair` before `JSON.parse`.
|
||||
- No-telemetry mode: never set `User-Agent` beyond `HealthLog/local`, never include user IDs in headers, do not send the request body to Wide Event annotation when provider is `local` (only `model`, `latencyMs`, `tokensUsed`).
|
||||
|
||||
## 6. UI — `/settings#ai`
|
||||
|
||||
Single dropdown `aiProvider` with options OpenAI / Anthropic Claude / Local LLM / ChatGPT (OAuth). Conditional fields:
|
||||
|
||||
| Provider | Visible fields |
|
||||
| ------------ | --------------------------------------------------------------- |
|
||||
| OpenAI | API key, model (default `gpt-4o-mini`) |
|
||||
| Anthropic | API key, model dropdown (sonnet/opus/haiku) |
|
||||
| Local | Base URL, model name, optional API key, "Disable JSON mode" hint |
|
||||
| ChatGPT OAuth| Existing Codex connect button |
|
||||
|
||||
Each provider section has a **Test connection** button that POSTs to `/api/insights/test` with a tiny prompt (`"Reply with {\"ok\":true}"`), surfacing the upstream status + body excerpt on failure.
|
||||
|
||||
Admin settings page gains the same dropdown for the global default plus an explanatory note: *"Used only for users who have not configured their own provider."*
|
||||
|
||||
## 7. Backward compatibility
|
||||
|
||||
- Migration is additive — no column drops, no data rewrites.
|
||||
- `User.aiProvider IS NULL` ⇒ behave exactly as today (Codex if connected, else admin OpenAI).
|
||||
- `CompletionResult.providerType = "admin-key"` continues to be emitted by `OpenAIClient` so cached insights and analytics dashboards keep working. New clients use new strings.
|
||||
- Existing tests in `src/lib/ai/__tests__` keep passing; new tests are additive.
|
||||
|
||||
## 8. Error handling
|
||||
|
||||
Each client follows the `OpenAIClient` pattern: capture up to 500 chars of the upstream body, attach `{ httpStatus, upstream, model }` to the thrown `Error`, let `apiHandler` log it as a Wide Event. Per-provider mapping in `src/lib/ai/errors.ts`:
|
||||
|
||||
| Upstream | User-facing message (i18n key) |
|
||||
| --------------------------------------- | ----------------------------------------- |
|
||||
| Anthropic 401 / `invalid_api_key` | `insights.error.anthropicInvalidKey` |
|
||||
| Anthropic 429 / `rate_limit_error` | `insights.error.anthropicRateLimited` |
|
||||
| Anthropic 529 / `overloaded_error` | `insights.error.anthropicOverloaded` |
|
||||
| Local fetch ECONNREFUSED | `insights.error.localUnreachable` |
|
||||
| Local 400 mentioning `response_format` | auto-retry once, then `insights.error.localNoJsonMode` |
|
||||
| OpenAI 429 `insufficient_quota` | existing key |
|
||||
|
||||
## 9. Internationalisation
|
||||
|
||||
New keys (EN + DE) under `settings.ai.*` and `insights.error.*`:
|
||||
|
||||
```
|
||||
settings.ai.providerLabel "AI Provider" / "KI-Anbieter"
|
||||
settings.ai.providerOpenAI "OpenAI"
|
||||
settings.ai.providerAnthropic "Anthropic Claude"
|
||||
settings.ai.providerLocal "Local LLM"
|
||||
settings.ai.providerChatGptOAuth "ChatGPT (OAuth)"
|
||||
settings.ai.baseUrlLabel "Endpoint URL" / "Endpunkt-URL"
|
||||
settings.ai.baseUrlHint.local "z.B. http://localhost:11434/v1"
|
||||
settings.ai.modelLabel "Model" / "Modell"
|
||||
settings.ai.testConnection "Test connection" / "Verbindung testen"
|
||||
settings.ai.testSuccess "Connection OK ({model}, {ms} ms)"
|
||||
settings.ai.testFailure "Failed: {detail}"
|
||||
settings.ai.localPrivacyNote "Requests stay on your network. Bodies are not logged."
|
||||
insights.error.anthropicInvalidKey "Anthropic key rejected. Check Settings → AI."
|
||||
insights.error.anthropicRateLimited "Anthropic rate-limited. Try again shortly."
|
||||
insights.error.anthropicOverloaded "Anthropic is overloaded. Try again in a minute."
|
||||
insights.error.localUnreachable "Local LLM endpoint not reachable: {url}"
|
||||
insights.error.localNoJsonMode "Local model rejected JSON mode — falling back to text parsing."
|
||||
```
|
||||
|
||||
## 10. Security
|
||||
|
||||
- All keys stored via existing AES-256-GCM helpers in `src/lib/crypto.ts`. Never persist plaintext.
|
||||
- Anthropic keys begin with `sk-ant-`. The Wide Event redactor (`src/lib/logging/event-builder.ts`) gets a regex `/sk-ant-[A-Za-z0-9_\-]{20,}/g` → `"sk-ant-***"` applied to all `externalCalls[].errorBody`.
|
||||
- Local provider runs with `next/server` `fetch` against arbitrary URLs — validate that `aiBaseUrl` parses as `http`/`https`, reject `file://`, `data://`, and (in production) bare IPs in private ranges *unless* `LOCAL_LLM_ALLOW_PRIVATE=true` (default true on self-hosted, false on `apps-01` build).
|
||||
- Test-connection endpoint is `requireAuth` and rate-limited (5 req/min/user) to prevent SSRF probing.
|
||||
- Admin keys remain admin-only via `requireAdmin()`.
|
||||
|
||||
## 11. Nyquist validation
|
||||
|
||||
- **Unit (Vitest)** — one `*.test.ts` per new client, mocking `fetch`:
|
||||
- `anthropic-client.test.ts`: success path returns parsed JSON from tool_use; 401 maps to error; cache_control header present.
|
||||
- `local-client.test.ts`: success path; auto-retry on `response_format` 400; ECONNREFUSED handled; bearer header omitted when no key.
|
||||
- **Integration** — extend `provider.test.ts`:
|
||||
- User with `aiProvider = ANTHROPIC` and key → returns `AnthropicClient`.
|
||||
- User with no preference falls back to admin default of each kind.
|
||||
- Codex still wins when user picks `CHATGPT_OAUTH` and is connected.
|
||||
- **End-to-end smoke** — manual: switch provider in `/settings#ai`, hit `/api/insights/general-status`, verify `providerType` in cached `InsightResult`.
|
||||
|
||||
## 12. Risks & mitigations
|
||||
|
||||
- **Admin changes default provider, breaks users.** Mitigated by precedence rule: any user with `aiProvider != NULL` is unaffected. Migration backfills nothing — admin only impacts opted-out users.
|
||||
- **Local model returns malformed JSON.** Two-layer defence: tool-use where supported, `jsonrepair` fallback otherwise; on persistent failure surface `dataQuality.confidence = "gering"` and the raw text in a debug field.
|
||||
- **Cached insight from old provider.** Cache key already includes `providerType`; switching provider invalidates naturally on next generate.
|
||||
- **Anthropic prompt-caching cost regression** if system prompt is rebuilt per request without cache_control. Mitigation: emit a Wide Event metric `ai.cache_read_tokens` and add a Grafana panel.
|
||||
|
||||
## 13. Out of scope
|
||||
|
||||
- Vendor-specific features beyond JSON output (Anthropic citations, OpenAI function-calling tool registries, Google Gemini).
|
||||
- Streaming responses to the UI (insights are batch-generated).
|
||||
- Multi-region routing or load-balancing across providers.
|
||||
- Per-metric provider override (one provider per user, applied to all metrics).
|
||||
- Cost dashboards beyond the existing `tokensUsed` field.
|
||||
|
||||
## 14. Effort
|
||||
|
||||
**M–L.** ~2 days dev:
|
||||
- 0.5d schema migration + `resolveProvider` rewrite + types.
|
||||
- 0.5d `AnthropicClient` (tool-use, caching, errors).
|
||||
- 0.5d `LocalOpenAICompatibleClient` + JSON-mode feature detection + jsonrepair.
|
||||
- 0.25d settings UI + test-connection endpoint.
|
||||
- 0.25d i18n + tests + docs.
|
||||
@@ -91,6 +91,7 @@
|
||||
"typeSteps": "Aktivität (Schritte)",
|
||||
"typeTotalBodyWater": "Gesamtkörperwasser",
|
||||
"typeBoneMass": "Knochenmasse",
|
||||
"typeOxygenSaturation": "Sauerstoffsättigung",
|
||||
"unitSteps": "Schritte",
|
||||
"bmiUnderweight": "Untergewicht",
|
||||
"bmiNormal": "Normalgewicht",
|
||||
@@ -249,6 +250,7 @@
|
||||
"typeBloodGlucose": "Blutzucker",
|
||||
"typeTotalBodyWater": "Gesamtkörperwasser",
|
||||
"typeBoneMass": "Knochenmasse",
|
||||
"typeOxygenSaturation": "Sauerstoffsättigung",
|
||||
"glucoseContext": "Kontext",
|
||||
"glucoseContextFasting": "Nüchtern (≥8 h ohne Nahrung)",
|
||||
"glucoseContextPostprandial": "Postprandial (2 h nach dem Essen)",
|
||||
@@ -538,6 +540,9 @@
|
||||
"pointsAllTitle": "Alle verfügbaren Messpunkte",
|
||||
"systolic": "Systolisch",
|
||||
"diastolic": "Diastolisch",
|
||||
"bodyWater": "Körperwasser",
|
||||
"boneMass": "Knochenmasse",
|
||||
"spo2": "SpO₂",
|
||||
"movingAverage7d": "7T-Schnitt",
|
||||
"avg7dShort": "7T",
|
||||
"avg30dShort": "30T",
|
||||
@@ -760,6 +765,9 @@
|
||||
"metricGlucosePostprandial": "Glukose — postprandial",
|
||||
"metricGlucoseRandom": "Glukose — beliebig",
|
||||
"metricGlucoseBedtime": "Glukose — vor dem Schlafen",
|
||||
"metricBodyWater": "Körperwasser",
|
||||
"metricBoneMass": "Knochenmasse",
|
||||
"metricOxygenSaturation": "Sauerstoffsättigung (SpO₂)",
|
||||
"unsetExplanation": "Kein eigener Wert — berechneter Default aktiv.",
|
||||
"loadError": "Zielwerte konnten nicht geladen werden"
|
||||
},
|
||||
|
||||
@@ -91,6 +91,7 @@
|
||||
"typeSteps": "Activity (steps)",
|
||||
"typeTotalBodyWater": "Total body water",
|
||||
"typeBoneMass": "Bone mass",
|
||||
"typeOxygenSaturation": "Oxygen saturation",
|
||||
"unitSteps": "steps",
|
||||
"bmiUnderweight": "Underweight",
|
||||
"bmiNormal": "Normal weight",
|
||||
@@ -249,6 +250,7 @@
|
||||
"typeBloodGlucose": "Blood glucose",
|
||||
"typeTotalBodyWater": "Total body water",
|
||||
"typeBoneMass": "Bone mass",
|
||||
"typeOxygenSaturation": "Oxygen saturation",
|
||||
"glucoseContext": "Context",
|
||||
"glucoseContextFasting": "Fasting (≥8h without food)",
|
||||
"glucoseContextPostprandial": "Post-meal (2h after eating)",
|
||||
@@ -538,6 +540,9 @@
|
||||
"pointsAllTitle": "All available data points",
|
||||
"systolic": "Systolic",
|
||||
"diastolic": "Diastolic",
|
||||
"bodyWater": "Body water",
|
||||
"boneMass": "Bone mass",
|
||||
"spo2": "SpO₂",
|
||||
"movingAverage7d": "7d avg",
|
||||
"avg7dShort": "7d",
|
||||
"avg30dShort": "30d",
|
||||
@@ -760,6 +765,9 @@
|
||||
"metricGlucosePostprandial": "Glucose — post-meal",
|
||||
"metricGlucoseRandom": "Glucose — random",
|
||||
"metricGlucoseBedtime": "Glucose — bedtime",
|
||||
"metricBodyWater": "Body water",
|
||||
"metricBoneMass": "Bone mass",
|
||||
"metricOxygenSaturation": "Oxygen saturation (SpO₂)",
|
||||
"unsetExplanation": "No override — using computed default.",
|
||||
"loadError": "Could not load thresholds"
|
||||
},
|
||||
|
||||
+9
-8
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.3",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.31.0",
|
||||
"scripts": {
|
||||
@@ -26,26 +26,26 @@
|
||||
"@prisma/client": "^7.8.0",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@simplewebauthn/server": "^13.3.0",
|
||||
"@tanstack/react-query": "^5.100.5",
|
||||
"@tanstack/react-query": "^5.100.9",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^17.4.2",
|
||||
"jspdf": "^4.2.1",
|
||||
"jspdf-autotable": "^5.0.7",
|
||||
"lucide-react": "^1.11.0",
|
||||
"lucide-react": "^1.14.0",
|
||||
"next": "16.2.4",
|
||||
"next-themes": "^0.4.6",
|
||||
"pg-boss": "^12.18.1",
|
||||
"pg-boss": "^12.18.2",
|
||||
"prisma": "^7.8.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.5",
|
||||
"react-dom": "19.2.5",
|
||||
"react-hook-form": "^7.74.0",
|
||||
"react-hook-form": "^7.75.0",
|
||||
"recharts": "^3.8.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"web-push": "^3.6.7",
|
||||
"zod": "^4.3.6",
|
||||
"zod": "^4.4.2",
|
||||
"zxcvbn-typescript": "^5.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -57,9 +57,10 @@
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.4",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"prettier": "^3.8.3",
|
||||
"prettier-plugin-tailwindcss": "^0.7.3",
|
||||
"shadcn": "^4.5.0",
|
||||
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||
"shadcn": "^4.6.0",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^6",
|
||||
|
||||
Generated
+272
-116
@@ -10,7 +10,7 @@ importers:
|
||||
dependencies:
|
||||
'@hookform/resolvers':
|
||||
specifier: ^5.2.2
|
||||
version: 5.2.2(react-hook-form@7.74.0(react@19.2.5))
|
||||
version: 5.2.2(react-hook-form@7.75.0(react@19.2.5))
|
||||
'@node-rs/argon2':
|
||||
specifier: ^2.0.2
|
||||
version: 2.0.2
|
||||
@@ -27,8 +27,8 @@ importers:
|
||||
specifier: ^13.3.0
|
||||
version: 13.3.0
|
||||
'@tanstack/react-query':
|
||||
specifier: ^5.100.5
|
||||
version: 5.100.5(react@19.2.5)
|
||||
specifier: ^5.100.9
|
||||
version: 5.100.9(react@19.2.5)
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
@@ -45,8 +45,8 @@ importers:
|
||||
specifier: ^5.0.7
|
||||
version: 5.0.7(jspdf@4.2.1)
|
||||
lucide-react:
|
||||
specifier: ^1.11.0
|
||||
version: 1.11.0(react@19.2.5)
|
||||
specifier: ^1.14.0
|
||||
version: 1.14.0(react@19.2.5)
|
||||
next:
|
||||
specifier: 16.2.4
|
||||
version: 16.2.4(@babel/core@7.29.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
@@ -54,8 +54,8 @@ importers:
|
||||
specifier: ^0.4.6
|
||||
version: 0.4.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
pg-boss:
|
||||
specifier: ^12.18.1
|
||||
version: 12.18.1
|
||||
specifier: ^12.18.2
|
||||
version: 12.18.2
|
||||
prisma:
|
||||
specifier: ^7.8.0
|
||||
version: 7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.3)
|
||||
@@ -69,8 +69,8 @@ importers:
|
||||
specifier: 19.2.5
|
||||
version: 19.2.5(react@19.2.5)
|
||||
react-hook-form:
|
||||
specifier: ^7.74.0
|
||||
version: 7.74.0(react@19.2.5)
|
||||
specifier: ^7.75.0
|
||||
version: 7.75.0(react@19.2.5)
|
||||
recharts:
|
||||
specifier: ^3.8.1
|
||||
version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react-is@16.13.1)(react@19.2.5)(redux@5.0.1)
|
||||
@@ -84,8 +84,8 @@ importers:
|
||||
specifier: ^3.6.7
|
||||
version: 3.6.7
|
||||
zod:
|
||||
specifier: ^4.3.6
|
||||
version: 4.3.6
|
||||
specifier: ^4.4.2
|
||||
version: 4.4.2
|
||||
zxcvbn-typescript:
|
||||
specifier: ^5.0.1
|
||||
version: 5.0.1
|
||||
@@ -114,15 +114,18 @@ importers:
|
||||
eslint-config-next:
|
||||
specifier: 16.2.4
|
||||
version: 16.2.4(@typescript-eslint/parser@8.58.2(eslint@9.39.3(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.3(jiti@2.6.1))(typescript@6.0.3)
|
||||
pdf-parse:
|
||||
specifier: ^2.4.5
|
||||
version: 2.4.5
|
||||
prettier:
|
||||
specifier: ^3.8.3
|
||||
version: 3.8.3
|
||||
prettier-plugin-tailwindcss:
|
||||
specifier: ^0.7.3
|
||||
version: 0.7.3(prettier@3.8.3)
|
||||
specifier: ^0.8.0
|
||||
version: 0.8.0(prettier@3.8.3)
|
||||
shadcn:
|
||||
specifier: ^4.5.0
|
||||
version: 4.5.0(@types/node@25.6.0)(typescript@6.0.3)
|
||||
specifier: ^4.6.0
|
||||
version: 4.6.0(@types/node@25.6.0)(typescript@6.0.3)
|
||||
tailwindcss:
|
||||
specifier: ^4
|
||||
version: 4.2.4
|
||||
@@ -134,7 +137,7 @@ importers:
|
||||
version: 6.0.3
|
||||
vitest:
|
||||
specifier: ^4.1.5
|
||||
version: 4.1.5(@types/node@25.6.0)(msw@2.13.6(@types/node@25.6.0)(typescript@6.0.3))(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0))
|
||||
version: 4.1.5(@types/node@25.6.0)(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0))
|
||||
|
||||
packages:
|
||||
|
||||
@@ -146,8 +149,8 @@ packages:
|
||||
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/compat-data@7.29.0':
|
||||
resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==}
|
||||
'@babel/compat-data@7.29.3':
|
||||
resolution: {integrity: sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/core@7.29.0':
|
||||
@@ -166,8 +169,8 @@ packages:
|
||||
resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-create-class-features-plugin@7.28.6':
|
||||
resolution: {integrity: sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==}
|
||||
'@babel/helper-create-class-features-plugin@7.29.3':
|
||||
resolution: {integrity: sha512-RpLYy2sb51oNLjuu1iD3bwBqCBWUzjO0ocp+iaCP/lJtb2CPLcnC2Fftw+4sAzaMELGeWTgExSKADbdo0GFVzA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
peerDependencies:
|
||||
'@babel/core': ^7.0.0
|
||||
@@ -224,8 +227,8 @@ packages:
|
||||
resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/parser@7.29.2':
|
||||
resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==}
|
||||
'@babel/parser@7.29.3':
|
||||
resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
@@ -287,8 +290,8 @@ packages:
|
||||
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@dotenvx/dotenvx@1.63.0':
|
||||
resolution: {integrity: sha512-jjkmzIRu19uH78AjFInqfcALehbDCZZ7M09hurVawyqNxtOXEg2LR73L59y4QnzfYDEzjbhVzGAd2uDHu0D1aQ==}
|
||||
'@dotenvx/dotenvx@1.64.0':
|
||||
resolution: {integrity: sha512-6+xRpZaWuHXEqnhBjae+VmQI9Uaqw5Uzu/ScpO+W7ww9Zp3lHSNBoNjFcUxhrCyc7pRGQzyDjhKzloqrPHERiQ==}
|
||||
hasBin: true
|
||||
|
||||
'@ecies/ciphers@0.2.6':
|
||||
@@ -788,10 +791,79 @@ packages:
|
||||
'@cfworker/json-schema':
|
||||
optional: true
|
||||
|
||||
'@mswjs/interceptors@0.41.6':
|
||||
resolution: {integrity: sha512-qmDvJIjcNsZ6tXWy2G9yuCgMPTTn35GMA3dPpSLm7QJVpbQzYdw0ALy1bKoivXnEM3U93/OrK+/M719b+fg84Q==}
|
||||
'@mswjs/interceptors@0.41.8':
|
||||
resolution: {integrity: sha512-pRLMNKTSGRoLq+KnEB/7OY5vijw1XmcheAAOiv6pj7W1FG32kAGqj1C/RK/cqxRGr1Fh+zBi8sDur8kj3EQv6A==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@napi-rs/canvas-android-arm64@0.1.80':
|
||||
resolution: {integrity: sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@napi-rs/canvas-darwin-arm64@0.1.80':
|
||||
resolution: {integrity: sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@napi-rs/canvas-darwin-x64@0.1.80':
|
||||
resolution: {integrity: sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.80':
|
||||
resolution: {integrity: sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-gnu@0.1.80':
|
||||
resolution: {integrity: sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-musl@0.1.80':
|
||||
resolution: {integrity: sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@napi-rs/canvas-linux-riscv64-gnu@0.1.80':
|
||||
resolution: {integrity: sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/canvas-linux-x64-gnu@0.1.80':
|
||||
resolution: {integrity: sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/canvas-linux-x64-musl@0.1.80':
|
||||
resolution: {integrity: sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@napi-rs/canvas-win32-x64-msvc@0.1.80':
|
||||
resolution: {integrity: sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@napi-rs/canvas@0.1.80':
|
||||
resolution: {integrity: sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
'@napi-rs/wasm-runtime@0.2.12':
|
||||
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
|
||||
|
||||
@@ -2044,11 +2116,11 @@ packages:
|
||||
'@tailwindcss/postcss@4.2.4':
|
||||
resolution: {integrity: sha512-wgAVj6nUWAolAu8YFvzT2cTBIElWHkjZwFYovF+xsqKsW2ADxM/X2opxj5NsF/qVccAOjRNe8X2IdPzMsWyHTg==}
|
||||
|
||||
'@tanstack/query-core@5.100.5':
|
||||
resolution: {integrity: sha512-t20KrhKkf0HXzqQkPbJ5erhFesup68BAbwFgYmTrS7bxMF7O5MdmL8jUkik4thsG7Hg00fblz30h6yF1d5TxGg==}
|
||||
'@tanstack/query-core@5.100.9':
|
||||
resolution: {integrity: sha512-SJSFw1S8+kQ0+knv/XGfrbocWoAlT7vDKsSImtLx3ZPQmEcR46hkDjLSvynSy25N8Ms4tIEini1FuBd5k7IscQ==}
|
||||
|
||||
'@tanstack/react-query@5.100.5':
|
||||
resolution: {integrity: sha512-aNwj1mi2v2bQ9IxkyR1grLOUkv3BYWoykHy9KDyLNbjC3tsahbOHJibK+Wjtr1wRhG59/AvJhiJG5OlthaCgJA==}
|
||||
'@tanstack/react-query@5.100.9':
|
||||
resolution: {integrity: sha512-Oa44XkaI3kCNN6ME0KByU3xT3SEUNOMfZpHxL6+wFoTm+OeUFYHKdeYVe0aOXlRDm/f15sgLwEt2HDorIdW8+A==}
|
||||
peerDependencies:
|
||||
react: ^18 || ^19
|
||||
|
||||
@@ -2488,8 +2560,8 @@ packages:
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
baseline-browser-mapping@2.10.23:
|
||||
resolution: {integrity: sha512-xwVXGqevyKPsiuQdLj+dZMVjidjJV508TBqexND5HrF89cGdCYCJFB3qhcxRHSeMctdCfbR1jrxBajhDy7o29g==}
|
||||
baseline-browser-mapping@2.10.27:
|
||||
resolution: {integrity: sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
@@ -2870,8 +2942,8 @@ packages:
|
||||
effect@3.20.0:
|
||||
resolution: {integrity: sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==}
|
||||
|
||||
electron-to-chromium@1.5.344:
|
||||
resolution: {integrity: sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==}
|
||||
electron-to-chromium@1.5.349:
|
||||
resolution: {integrity: sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A==}
|
||||
|
||||
emoji-regex@10.6.0:
|
||||
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
|
||||
@@ -3385,6 +3457,10 @@ packages:
|
||||
resolution: {integrity: sha512-qM0jDhFEaCBb4TxoW7f53Qrpv9RBiayUHo0S52JudprkhvpjIrGoU1mnnr29Fvd1U335ZFPZQY1wlkqgfGXyLg==}
|
||||
engines: {node: '>=16.9.0'}
|
||||
|
||||
hono@4.12.16:
|
||||
resolution: {integrity: sha512-jN0ZewiNAWSe5khM3EyCmBb250+b40wWbwNILNfEvq84VREWwOIkuUsFONk/3i3nqkz7Oe1PcpM2mwQEK2L9Kg==}
|
||||
engines: {node: '>=16.9.0'}
|
||||
|
||||
html2canvas@1.4.1:
|
||||
resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
@@ -3647,8 +3723,8 @@ packages:
|
||||
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
||||
hasBin: true
|
||||
|
||||
jose@6.2.2:
|
||||
resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==}
|
||||
jose@6.2.3:
|
||||
resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==}
|
||||
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
@@ -3834,8 +3910,8 @@ packages:
|
||||
resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==}
|
||||
engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'}
|
||||
|
||||
lucide-react@1.11.0:
|
||||
resolution: {integrity: sha512-UOhjdztXCgdBReRcIhsvz2siIBogfv/lhJEIViCpLt924dO+GDms9T7DNoucI23s6kEPpe988m5N0D2ajnzb2g==}
|
||||
lucide-react@1.14.0:
|
||||
resolution: {integrity: sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA==}
|
||||
peerDependencies:
|
||||
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
@@ -3904,8 +3980,8 @@ packages:
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
msw@2.13.6:
|
||||
resolution: {integrity: sha512-GAJbQy8Ra/Ydjt0Hb2MGT2qhzd83J3+QZMHdH85uW7r/XkKc846+Ma2PLif5hGvTm5Yqa+wkcstpim0WeLZU9g==}
|
||||
msw@2.14.2:
|
||||
resolution: {integrity: sha512-D2bTe0tpuf9nw4DA39wFaqUD/hRPKj0DKpo2lAqu+A47Ifg4+h0hbfn6QxVOsiUY2uhgEN6TTpGSHDsc+ysYNg==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@@ -3931,6 +4007,11 @@ packages:
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
nanoid@3.3.12:
|
||||
resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
napi-postinstall@0.3.4:
|
||||
resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==}
|
||||
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
|
||||
@@ -4128,14 +4209,23 @@ packages:
|
||||
pathe@2.0.3:
|
||||
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||
|
||||
pdf-parse@2.4.5:
|
||||
resolution: {integrity: sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==}
|
||||
engines: {node: '>=20.16.0 <21 || >=22.3.0'}
|
||||
hasBin: true
|
||||
|
||||
pdfjs-dist@5.4.296:
|
||||
resolution: {integrity: sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==}
|
||||
engines: {node: '>=20.16.0 || >=22.3.0'}
|
||||
|
||||
perfect-debounce@2.1.0:
|
||||
resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==}
|
||||
|
||||
performance-now@2.1.0:
|
||||
resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==}
|
||||
|
||||
pg-boss@12.18.1:
|
||||
resolution: {integrity: sha512-eh7rRSTxUbPguKNDChWunKd//t4CuPFLqg9mL93sq9fai4YiGMe2HrSDqGF8gQ1Kn7VMxnB5IGRkPGDoMm4yrg==}
|
||||
pg-boss@12.18.2:
|
||||
resolution: {integrity: sha512-06kXeWvVWY+BUNsOt2me1okg6NXx2DBnAQHTurA9jtrvbAO9qUOSE3/0ERERQDrokI+FREFM2Twha+JbrFT/8Q==}
|
||||
engines: {node: '>=22.12.0'}
|
||||
hasBin: true
|
||||
|
||||
@@ -4207,6 +4297,10 @@ packages:
|
||||
resolution: {integrity: sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
postcss@8.5.13:
|
||||
resolution: {integrity: sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
postgres-array@2.0.0:
|
||||
resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -4239,8 +4333,8 @@ packages:
|
||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
prettier-plugin-tailwindcss@0.7.3:
|
||||
resolution: {integrity: sha512-lckXaWWdo2ZVXoMoUO3WIBiz9hVY+YBEh1gYyMFfrWP9WZW/wpFXQKizHx7WrFQFMkcG0bGShdpp531X1n+qpg==}
|
||||
prettier-plugin-tailwindcss@0.8.0:
|
||||
resolution: {integrity: sha512-V8ITGH87yuBDF6JpEZTOVlUz/saAwqb8f3HRgUj8Lh+tGCcrmorhsLpYqzygwFwK0PE2Ib6Mv3M7T/uE2tZV1g==}
|
||||
engines: {node: '>=20.19'}
|
||||
peerDependencies:
|
||||
'@ianvs/prettier-plugin-sort-imports': '*'
|
||||
@@ -4383,8 +4477,8 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^19.2.5
|
||||
|
||||
react-hook-form@7.74.0:
|
||||
resolution: {integrity: sha512-yR6wHr99p9wFv686jhRWVSFhUvDvNbdUf2dKlbno8/VKOCuoNobDGC6S+M2dua9A9Yo8vpcrp8assIYbsZCQ9g==}
|
||||
react-hook-form@7.75.0:
|
||||
resolution: {integrity: sha512-Ovv94H+0p3sJ7B9B5QxPuCP1u8V/cHuVGyH55cSwodYDtoJwK+fqk3vjfIgSX59I2U/bU4z0nRJ9HMLpNiWEmw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17 || ^18 || ^19
|
||||
@@ -4514,8 +4608,8 @@ packages:
|
||||
resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
rettime@0.11.8:
|
||||
resolution: {integrity: sha512-0fERGXktJTyJ+h8fBEiPxHPEFOu0h15JY7JtwrOVqR5K+vb99ho6IyOo7ekLS3h4sJCzIDy4VWKIbZUfe9njmg==}
|
||||
rettime@0.11.10:
|
||||
resolution: {integrity: sha512-zzgUfAF20wZtfDs72M15qiX6EutHxgEZ1PAEJUsWQsOi4aOdcK8BJ3/WSDHBhJ7P27fQjd6iOE1+CWIO8t3XOA==}
|
||||
|
||||
reusify@1.1.0:
|
||||
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
|
||||
@@ -4604,8 +4698,8 @@ packages:
|
||||
setprototypeof@1.2.0:
|
||||
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
|
||||
|
||||
shadcn@4.5.0:
|
||||
resolution: {integrity: sha512-ZpNOz7IMI5aezbMEWNxBvl2aJ1ek6NuAMqpL/FUnk5IuRxERl8ohYEnqqAmhPOcur8RbGuCoqTZLQ3Oi4Xkf8A==}
|
||||
shadcn@4.6.0:
|
||||
resolution: {integrity: sha512-4XeMwFf8ZZxmqQQp+U+Nsq2M+cY4Da8Joo/EaMdHVc4uVuWSTJoeidlZ3gDjyxXCjYB1FLcxYwR4lYQAH8emOg==}
|
||||
hasBin: true
|
||||
|
||||
sharp@0.34.5:
|
||||
@@ -4821,11 +4915,11 @@ packages:
|
||||
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
tldts-core@7.0.28:
|
||||
resolution: {integrity: sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==}
|
||||
tldts-core@7.0.30:
|
||||
resolution: {integrity: sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==}
|
||||
|
||||
tldts@7.0.28:
|
||||
resolution: {integrity: sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==}
|
||||
tldts@7.0.30:
|
||||
resolution: {integrity: sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==}
|
||||
hasBin: true
|
||||
|
||||
to-regex-range@5.0.1:
|
||||
@@ -5152,8 +5246,8 @@ packages:
|
||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
yocto-spinner@1.1.0:
|
||||
resolution: {integrity: sha512-/BY0AUXnS7IKO354uLLA2eRcWiqDifEbd6unXCsOxkFDAkhgUL3PH9X2bFoaU0YchnDXsF+iKleeTLJGckbXfA==}
|
||||
yocto-spinner@1.2.0:
|
||||
resolution: {integrity: sha512-Yw0hUB6UA3o4YUgKy3oSe9a4cxoaZ9sBfYDw+JSxo6Id0KoJGoxzPA24qqUXYKBWABs/zDSGTz9kww7t3F0XGw==}
|
||||
engines: {node: '>=18.19'}
|
||||
|
||||
yoctocolors@2.1.2:
|
||||
@@ -5177,8 +5271,8 @@ packages:
|
||||
zod@3.25.76:
|
||||
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
||||
|
||||
zod@4.3.6:
|
||||
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
|
||||
zod@4.4.2:
|
||||
resolution: {integrity: sha512-IynmDyxsEsb9RKzO3J9+4SxXnl2FTFSzNBaKKaMV6tsSk0rw9gYw9gs+JFCq/qk2LCZ78KDwyj+Z289TijSkUw==}
|
||||
|
||||
zxcvbn-typescript@5.0.1:
|
||||
resolution: {integrity: sha512-If2a6jdm278dfWmn6NL7oC1lNYRHOthBw0C78LsKvtAHHFNsMudvFhDn2fF/b74txWtSosJJiuJ7jBPn/xP6HQ==}
|
||||
@@ -5193,7 +5287,7 @@ snapshots:
|
||||
js-tokens: 4.0.0
|
||||
picocolors: 1.1.1
|
||||
|
||||
'@babel/compat-data@7.29.0': {}
|
||||
'@babel/compat-data@7.29.3': {}
|
||||
|
||||
'@babel/core@7.29.0':
|
||||
dependencies:
|
||||
@@ -5202,7 +5296,7 @@ snapshots:
|
||||
'@babel/helper-compilation-targets': 7.28.6
|
||||
'@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0)
|
||||
'@babel/helpers': 7.29.2
|
||||
'@babel/parser': 7.29.2
|
||||
'@babel/parser': 7.29.3
|
||||
'@babel/template': 7.28.6
|
||||
'@babel/traverse': 7.29.0
|
||||
'@babel/types': 7.29.0
|
||||
@@ -5217,7 +5311,7 @@ snapshots:
|
||||
|
||||
'@babel/generator@7.29.1':
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.2
|
||||
'@babel/parser': 7.29.3
|
||||
'@babel/types': 7.29.0
|
||||
'@jridgewell/gen-mapping': 0.3.13
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
@@ -5229,13 +5323,13 @@ snapshots:
|
||||
|
||||
'@babel/helper-compilation-targets@7.28.6':
|
||||
dependencies:
|
||||
'@babel/compat-data': 7.29.0
|
||||
'@babel/compat-data': 7.29.3
|
||||
'@babel/helper-validator-option': 7.27.1
|
||||
browserslist: 4.28.2
|
||||
lru-cache: 5.1.1
|
||||
semver: 6.3.1
|
||||
|
||||
'@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.29.0)':
|
||||
'@babel/helper-create-class-features-plugin@7.29.3(@babel/core@7.29.0)':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
'@babel/helper-annotate-as-pure': 7.27.3
|
||||
@@ -5306,7 +5400,7 @@ snapshots:
|
||||
'@babel/template': 7.28.6
|
||||
'@babel/types': 7.29.0
|
||||
|
||||
'@babel/parser@7.29.2':
|
||||
'@babel/parser@7.29.3':
|
||||
dependencies:
|
||||
'@babel/types': 7.29.0
|
||||
|
||||
@@ -5342,7 +5436,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
'@babel/helper-annotate-as-pure': 7.27.3
|
||||
'@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0)
|
||||
'@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.0)
|
||||
'@babel/helper-plugin-utils': 7.28.6
|
||||
'@babel/helper-skip-transparent-expression-wrappers': 7.27.1
|
||||
'@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0)
|
||||
@@ -5365,7 +5459,7 @@ snapshots:
|
||||
'@babel/template@7.28.6':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.0
|
||||
'@babel/parser': 7.29.2
|
||||
'@babel/parser': 7.29.3
|
||||
'@babel/types': 7.29.0
|
||||
|
||||
'@babel/traverse@7.29.0':
|
||||
@@ -5373,7 +5467,7 @@ snapshots:
|
||||
'@babel/code-frame': 7.29.0
|
||||
'@babel/generator': 7.29.1
|
||||
'@babel/helper-globals': 7.28.0
|
||||
'@babel/parser': 7.29.2
|
||||
'@babel/parser': 7.29.3
|
||||
'@babel/template': 7.28.6
|
||||
'@babel/types': 7.29.0
|
||||
debug: 4.4.3
|
||||
@@ -5385,7 +5479,7 @@ snapshots:
|
||||
'@babel/helper-string-parser': 7.27.1
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
|
||||
'@dotenvx/dotenvx@1.63.0':
|
||||
'@dotenvx/dotenvx@1.64.0':
|
||||
dependencies:
|
||||
commander: 11.1.0
|
||||
dotenv: 17.4.2
|
||||
@@ -5396,7 +5490,7 @@ snapshots:
|
||||
object-treeify: 1.1.33
|
||||
picomatch: 4.0.4
|
||||
which: 4.0.0
|
||||
yocto-spinner: 1.1.0
|
||||
yocto-spinner: 1.2.0
|
||||
|
||||
'@ecies/ciphers@0.2.6(@noble/ciphers@1.3.0)':
|
||||
dependencies:
|
||||
@@ -5580,14 +5674,14 @@ snapshots:
|
||||
dependencies:
|
||||
hono: 4.12.15
|
||||
|
||||
'@hono/node-server@1.19.14(hono@4.12.15)':
|
||||
'@hono/node-server@1.19.14(hono@4.12.16)':
|
||||
dependencies:
|
||||
hono: 4.12.15
|
||||
hono: 4.12.16
|
||||
|
||||
'@hookform/resolvers@5.2.2(react-hook-form@7.74.0(react@19.2.5))':
|
||||
'@hookform/resolvers@5.2.2(react-hook-form@7.75.0(react@19.2.5))':
|
||||
dependencies:
|
||||
'@standard-schema/utils': 0.3.0
|
||||
react-hook-form: 7.74.0(react@19.2.5)
|
||||
react-hook-form: 7.75.0(react@19.2.5)
|
||||
|
||||
'@humanfs/core@0.19.1': {}
|
||||
|
||||
@@ -5749,7 +5843,7 @@ snapshots:
|
||||
|
||||
'@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@hono/node-server': 1.19.14(hono@4.12.15)
|
||||
'@hono/node-server': 1.19.14(hono@4.12.16)
|
||||
ajv: 8.20.0
|
||||
ajv-formats: 3.0.1(ajv@8.20.0)
|
||||
content-type: 1.0.5
|
||||
@@ -5759,8 +5853,8 @@ snapshots:
|
||||
eventsource-parser: 3.0.8
|
||||
express: 5.2.1
|
||||
express-rate-limit: 8.4.1(express@5.2.1)
|
||||
hono: 4.12.15
|
||||
jose: 6.2.2
|
||||
hono: 4.12.16
|
||||
jose: 6.2.3
|
||||
json-schema-typed: 8.0.2
|
||||
pkce-challenge: 5.0.1
|
||||
raw-body: 3.0.2
|
||||
@@ -5769,7 +5863,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@mswjs/interceptors@0.41.6':
|
||||
'@mswjs/interceptors@0.41.8':
|
||||
dependencies:
|
||||
'@open-draft/deferred-promise': 2.2.0
|
||||
'@open-draft/logger': 0.3.0
|
||||
@@ -5778,6 +5872,49 @@ snapshots:
|
||||
outvariant: 1.4.3
|
||||
strict-event-emitter: 0.5.1
|
||||
|
||||
'@napi-rs/canvas-android-arm64@0.1.80':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-darwin-arm64@0.1.80':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-darwin-x64@0.1.80':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.80':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-gnu@0.1.80':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-musl@0.1.80':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-riscv64-gnu@0.1.80':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-x64-gnu@0.1.80':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-x64-musl@0.1.80':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-win32-x64-msvc@0.1.80':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas@0.1.80':
|
||||
optionalDependencies:
|
||||
'@napi-rs/canvas-android-arm64': 0.1.80
|
||||
'@napi-rs/canvas-darwin-arm64': 0.1.80
|
||||
'@napi-rs/canvas-darwin-x64': 0.1.80
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf': 0.1.80
|
||||
'@napi-rs/canvas-linux-arm64-gnu': 0.1.80
|
||||
'@napi-rs/canvas-linux-arm64-musl': 0.1.80
|
||||
'@napi-rs/canvas-linux-riscv64-gnu': 0.1.80
|
||||
'@napi-rs/canvas-linux-x64-gnu': 0.1.80
|
||||
'@napi-rs/canvas-linux-x64-musl': 0.1.80
|
||||
'@napi-rs/canvas-win32-x64-msvc': 0.1.80
|
||||
|
||||
'@napi-rs/wasm-runtime@0.2.12':
|
||||
dependencies:
|
||||
'@emnapi/core': 1.8.1
|
||||
@@ -7036,11 +7173,11 @@ snapshots:
|
||||
postcss: 8.5.12
|
||||
tailwindcss: 4.2.4
|
||||
|
||||
'@tanstack/query-core@5.100.5': {}
|
||||
'@tanstack/query-core@5.100.9': {}
|
||||
|
||||
'@tanstack/react-query@5.100.5(react@19.2.5)':
|
||||
'@tanstack/react-query@5.100.9(react@19.2.5)':
|
||||
dependencies:
|
||||
'@tanstack/query-core': 5.100.5
|
||||
'@tanstack/query-core': 5.100.9
|
||||
react: 19.2.5
|
||||
|
||||
'@ts-morph/common@0.27.0':
|
||||
@@ -7056,7 +7193,7 @@ snapshots:
|
||||
|
||||
'@types/babel__core@7.20.5':
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.2
|
||||
'@babel/parser': 7.29.3
|
||||
'@babel/types': 7.29.0
|
||||
'@types/babel__generator': 7.27.0
|
||||
'@types/babel__template': 7.4.4
|
||||
@@ -7068,7 +7205,7 @@ snapshots:
|
||||
|
||||
'@types/babel__template@7.4.4':
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.2
|
||||
'@babel/parser': 7.29.3
|
||||
'@babel/types': 7.29.0
|
||||
|
||||
'@types/babel__traverse@7.28.0':
|
||||
@@ -7323,13 +7460,13 @@ snapshots:
|
||||
chai: 6.2.2
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vitest/mocker@4.1.5(msw@2.13.6(@types/node@25.6.0)(typescript@6.0.3))(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0))':
|
||||
'@vitest/mocker@4.1.5(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0))':
|
||||
dependencies:
|
||||
'@vitest/spy': 4.1.5
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
msw: 2.13.6(@types/node@25.6.0)(typescript@6.0.3)
|
||||
msw: 2.14.2(@types/node@25.6.0)(typescript@6.0.3)
|
||||
vite: 7.3.1(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)
|
||||
|
||||
'@vitest/pretty-format@4.1.5':
|
||||
@@ -7512,7 +7649,7 @@ snapshots:
|
||||
|
||||
baseline-browser-mapping@2.10.20: {}
|
||||
|
||||
baseline-browser-mapping@2.10.23: {}
|
||||
baseline-browser-mapping@2.10.27: {}
|
||||
|
||||
better-result@2.8.2: {}
|
||||
|
||||
@@ -7552,9 +7689,9 @@ snapshots:
|
||||
|
||||
browserslist@4.28.2:
|
||||
dependencies:
|
||||
baseline-browser-mapping: 2.10.23
|
||||
baseline-browser-mapping: 2.10.27
|
||||
caniuse-lite: 1.0.30001791
|
||||
electron-to-chromium: 1.5.344
|
||||
electron-to-chromium: 1.5.349
|
||||
node-releases: 2.0.38
|
||||
update-browserslist-db: 1.2.3(browserslist@4.28.2)
|
||||
|
||||
@@ -7867,7 +8004,7 @@ snapshots:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
fast-check: 3.23.2
|
||||
|
||||
electron-to-chromium@1.5.344: {}
|
||||
electron-to-chromium@1.5.349: {}
|
||||
|
||||
emoji-regex@10.6.0: {}
|
||||
|
||||
@@ -8137,11 +8274,11 @@ snapshots:
|
||||
eslint-plugin-react-hooks@7.1.1(eslint@9.39.3(jiti@2.6.1)):
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
'@babel/parser': 7.29.2
|
||||
'@babel/parser': 7.29.3
|
||||
eslint: 9.39.3(jiti@2.6.1)
|
||||
hermes-parser: 0.25.1
|
||||
zod: 4.3.6
|
||||
zod-validation-error: 4.0.2(zod@4.3.6)
|
||||
zod: 4.4.2
|
||||
zod-validation-error: 4.0.2(zod@4.4.2)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -8574,6 +8711,8 @@ snapshots:
|
||||
|
||||
hono@4.12.15: {}
|
||||
|
||||
hono@4.12.16: {}
|
||||
|
||||
html2canvas@1.4.1:
|
||||
dependencies:
|
||||
css-line-break: 2.1.0
|
||||
@@ -8805,7 +8944,7 @@ snapshots:
|
||||
|
||||
jiti@2.6.1: {}
|
||||
|
||||
jose@6.2.2: {}
|
||||
jose@6.2.3: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
@@ -8965,7 +9104,7 @@ snapshots:
|
||||
|
||||
lru.min@1.1.4: {}
|
||||
|
||||
lucide-react@1.11.0(react@19.2.5):
|
||||
lucide-react@1.14.0(react@19.2.5):
|
||||
dependencies:
|
||||
react: 19.2.5
|
||||
|
||||
@@ -9018,10 +9157,10 @@ snapshots:
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
msw@2.13.6(@types/node@25.6.0)(typescript@6.0.3):
|
||||
msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3):
|
||||
dependencies:
|
||||
'@inquirer/confirm': 6.0.12(@types/node@25.6.0)
|
||||
'@mswjs/interceptors': 0.41.6
|
||||
'@mswjs/interceptors': 0.41.8
|
||||
'@open-draft/deferred-promise': 3.0.0
|
||||
'@types/statuses': 2.0.6
|
||||
cookie: 1.1.1
|
||||
@@ -9031,7 +9170,7 @@ snapshots:
|
||||
outvariant: 1.4.3
|
||||
path-to-regexp: 6.3.0
|
||||
picocolors: 1.1.1
|
||||
rettime: 0.11.8
|
||||
rettime: 0.11.10
|
||||
statuses: 2.0.2
|
||||
strict-event-emitter: 0.5.1
|
||||
tough-cookie: 6.0.1
|
||||
@@ -9063,6 +9202,8 @@ snapshots:
|
||||
|
||||
nanoid@3.3.11: {}
|
||||
|
||||
nanoid@3.3.12: {}
|
||||
|
||||
napi-postinstall@0.3.4: {}
|
||||
|
||||
natural-compare@1.4.0: {}
|
||||
@@ -9269,12 +9410,21 @@ snapshots:
|
||||
|
||||
pathe@2.0.3: {}
|
||||
|
||||
pdf-parse@2.4.5:
|
||||
dependencies:
|
||||
'@napi-rs/canvas': 0.1.80
|
||||
pdfjs-dist: 5.4.296
|
||||
|
||||
pdfjs-dist@5.4.296:
|
||||
optionalDependencies:
|
||||
'@napi-rs/canvas': 0.1.80
|
||||
|
||||
perfect-debounce@2.1.0: {}
|
||||
|
||||
performance-now@2.1.0:
|
||||
optional: true
|
||||
|
||||
pg-boss@12.18.1:
|
||||
pg-boss@12.18.2:
|
||||
dependencies:
|
||||
cron-parser: 5.5.0
|
||||
pg: 8.20.0
|
||||
@@ -9350,6 +9500,12 @@ snapshots:
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
postcss@8.5.13:
|
||||
dependencies:
|
||||
nanoid: 3.3.12
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
postgres-array@2.0.0: {}
|
||||
|
||||
postgres-array@3.0.4: {}
|
||||
@@ -9368,7 +9524,7 @@ snapshots:
|
||||
|
||||
prelude-ls@1.2.1: {}
|
||||
|
||||
prettier-plugin-tailwindcss@0.7.3(prettier@3.8.3):
|
||||
prettier-plugin-tailwindcss@0.8.0(prettier@3.8.3):
|
||||
dependencies:
|
||||
prettier: 3.8.3
|
||||
|
||||
@@ -9520,7 +9676,7 @@ snapshots:
|
||||
react: 19.2.5
|
||||
scheduler: 0.27.0
|
||||
|
||||
react-hook-form@7.74.0(react@19.2.5):
|
||||
react-hook-form@7.75.0(react@19.2.5):
|
||||
dependencies:
|
||||
react: 19.2.5
|
||||
|
||||
@@ -9655,7 +9811,7 @@ snapshots:
|
||||
|
||||
retry@0.12.0: {}
|
||||
|
||||
rettime@0.11.8: {}
|
||||
rettime@0.11.10: {}
|
||||
|
||||
reusify@1.1.0: {}
|
||||
|
||||
@@ -9796,13 +9952,13 @@ snapshots:
|
||||
|
||||
setprototypeof@1.2.0: {}
|
||||
|
||||
shadcn@4.5.0(@types/node@25.6.0)(typescript@6.0.3):
|
||||
shadcn@4.6.0(@types/node@25.6.0)(typescript@6.0.3):
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
'@babel/parser': 7.29.2
|
||||
'@babel/parser': 7.29.3
|
||||
'@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0)
|
||||
'@babel/preset-typescript': 7.28.5(@babel/core@7.29.0)
|
||||
'@dotenvx/dotenvx': 1.63.0
|
||||
'@dotenvx/dotenvx': 1.64.0
|
||||
'@modelcontextprotocol/sdk': 1.29.0(zod@3.25.76)
|
||||
'@types/validate-npm-package-name': 4.0.2
|
||||
browserslist: 4.28.2
|
||||
@@ -9817,11 +9973,11 @@ snapshots:
|
||||
fuzzysort: 3.1.0
|
||||
https-proxy-agent: 7.0.6
|
||||
kleur: 4.1.5
|
||||
msw: 2.13.6(@types/node@25.6.0)(typescript@6.0.3)
|
||||
msw: 2.14.2(@types/node@25.6.0)(typescript@6.0.3)
|
||||
node-fetch: 3.3.2
|
||||
open: 11.0.0
|
||||
ora: 8.2.0
|
||||
postcss: 8.5.12
|
||||
postcss: 8.5.13
|
||||
postcss-selector-parser: 7.1.1
|
||||
prompts: 2.4.2
|
||||
recast: 0.23.11
|
||||
@@ -10074,11 +10230,11 @@ snapshots:
|
||||
|
||||
tinyrainbow@3.1.0: {}
|
||||
|
||||
tldts-core@7.0.28: {}
|
||||
tldts-core@7.0.30: {}
|
||||
|
||||
tldts@7.0.28:
|
||||
tldts@7.0.30:
|
||||
dependencies:
|
||||
tldts-core: 7.0.28
|
||||
tldts-core: 7.0.30
|
||||
|
||||
to-regex-range@5.0.1:
|
||||
dependencies:
|
||||
@@ -10088,7 +10244,7 @@ snapshots:
|
||||
|
||||
tough-cookie@6.0.1:
|
||||
dependencies:
|
||||
tldts: 7.0.28
|
||||
tldts: 7.0.30
|
||||
|
||||
ts-api-utils@2.5.0(typescript@6.0.3):
|
||||
dependencies:
|
||||
@@ -10289,7 +10445,7 @@ snapshots:
|
||||
esbuild: 0.27.7
|
||||
fdir: 6.5.0(picomatch@4.0.4)
|
||||
picomatch: 4.0.4
|
||||
postcss: 8.5.12
|
||||
postcss: 8.5.13
|
||||
rollup: 4.60.2
|
||||
tinyglobby: 0.2.16
|
||||
optionalDependencies:
|
||||
@@ -10298,10 +10454,10 @@ snapshots:
|
||||
jiti: 2.6.1
|
||||
lightningcss: 1.32.0
|
||||
|
||||
vitest@4.1.5(@types/node@25.6.0)(msw@2.13.6(@types/node@25.6.0)(typescript@6.0.3))(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)):
|
||||
vitest@4.1.5(@types/node@25.6.0)(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.1.5
|
||||
'@vitest/mocker': 4.1.5(msw@2.13.6(@types/node@25.6.0)(typescript@6.0.3))(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0))
|
||||
'@vitest/mocker': 4.1.5(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0))
|
||||
'@vitest/pretty-format': 4.1.5
|
||||
'@vitest/runner': 4.1.5
|
||||
'@vitest/snapshot': 4.1.5
|
||||
@@ -10426,7 +10582,7 @@ snapshots:
|
||||
|
||||
yocto-queue@0.1.0: {}
|
||||
|
||||
yocto-spinner@1.1.0:
|
||||
yocto-spinner@1.2.0:
|
||||
dependencies:
|
||||
yoctocolors: 2.1.2
|
||||
|
||||
@@ -10441,12 +10597,12 @@ snapshots:
|
||||
dependencies:
|
||||
zod: 3.25.76
|
||||
|
||||
zod-validation-error@4.0.2(zod@4.3.6):
|
||||
zod-validation-error@4.0.2(zod@4.4.2):
|
||||
dependencies:
|
||||
zod: 4.3.6
|
||||
zod: 4.4.2
|
||||
|
||||
zod@3.25.76: {}
|
||||
|
||||
zod@4.3.6: {}
|
||||
zod@4.4.2: {}
|
||||
|
||||
zxcvbn-typescript@5.0.1: {}
|
||||
@@ -4,9 +4,11 @@
|
||||
-- Both are reported by Withings body-composition scales and can also be ingested
|
||||
-- via the API by external pipelines (e.g. n8n + Health Connect).
|
||||
--
|
||||
-- Storage units (canonical):
|
||||
-- TOTAL_BODY_WATER: percent of body weight (%)
|
||||
-- BONE_MASS: kilograms (kg)
|
||||
-- Storage units (canonical, both kg — matches VALUE_RANGES in
|
||||
-- src/lib/validations/measurement.ts and Withings client type-77/type-88
|
||||
-- mapping). Adult plausibility ranges: water 30–55 kg, bone 2.5–4.5 kg.
|
||||
-- TOTAL_BODY_WATER: kilograms (kg) — total body water content
|
||||
-- BONE_MASS: kilograms (kg) — DEXA-equivalent bone mineral content
|
||||
--
|
||||
-- Both are unconditional value types — no companion enum (unlike BLOOD_GLUCOSE,
|
||||
-- which carries a glucose_context). The migration is purely additive.
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
-- v1.3: iOS adapter endpoints + idempotency cache + device registration
|
||||
--
|
||||
-- Purely additive:
|
||||
-- • users.display_name, users.healthkit_config_json, users.healthkit_last_synced_at
|
||||
-- • new tables: idempotency_keys, devices
|
||||
|
||||
-- ── users new columns ─────────────────────────────────────────────────
|
||||
|
||||
ALTER TABLE "users"
|
||||
ADD COLUMN "display_name" TEXT,
|
||||
ADD COLUMN "healthkit_config_json" JSONB,
|
||||
ADD COLUMN "healthkit_last_synced_at" TIMESTAMP(3);
|
||||
|
||||
-- ── idempotency_keys ──────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE "idempotency_keys" (
|
||||
"id" TEXT PRIMARY KEY,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
"method" TEXT NOT NULL,
|
||||
"path" TEXT NOT NULL,
|
||||
"response_status" INTEGER NOT NULL,
|
||||
"response_body" TEXT NOT NULL,
|
||||
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "idempotency_keys_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "idempotency_keys_user_id_key_method_path_key"
|
||||
ON "idempotency_keys" ("user_id", "key", "method", "path");
|
||||
|
||||
CREATE INDEX "idempotency_keys_expires_at_idx"
|
||||
ON "idempotency_keys" ("expires_at");
|
||||
|
||||
-- ── devices ───────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE "devices" (
|
||||
"id" TEXT PRIMARY KEY,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"platform" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"bundle_id" TEXT NOT NULL,
|
||||
"locale" TEXT,
|
||||
"app_version" TEXT,
|
||||
"model" TEXT,
|
||||
"last_seen" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "devices_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "devices_token_key" ON "devices" ("token");
|
||||
CREATE INDEX "devices_user_id_idx" ON "devices" ("user_id");
|
||||
@@ -0,0 +1,12 @@
|
||||
-- v1.3.3: Add OXYGEN_SATURATION (SpO2) measurement type
|
||||
--
|
||||
-- Closes #109 feature request. Single-value type (no per-context split like
|
||||
-- BLOOD_GLUCOSE — context goes into the free-text `notes` field).
|
||||
--
|
||||
-- Storage unit: percent (0..100). Plausibility range 50..100 in
|
||||
-- src/lib/validations/measurement.ts (anything below 50% is incompatible with
|
||||
-- sustained life and almost certainly a faulty sensor).
|
||||
--
|
||||
-- Withings devices that support SpO2 (e.g. ScanWatch) report measure type 54.
|
||||
-- See src/lib/withings/client.ts MEASURE_TYPE_MAP.
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'OXYGEN_SATURATION';
|
||||
@@ -74,6 +74,14 @@ model User {
|
||||
// Onboarding
|
||||
onboardingCompletedAt DateTime? @map("onboarding_completed_at")
|
||||
|
||||
// HealthKit (iOS) integration config — list of per-metric direction toggles.
|
||||
// Shape: { entries: [{ id, kind, direction, enabled }], lastSyncedAt }
|
||||
healthKitConfigJson Json? @map("healthkit_config_json")
|
||||
healthKitLastSyncedAt DateTime? @map("healthkit_last_synced_at")
|
||||
|
||||
// Display name (optional override of username for greetings).
|
||||
displayName String? @map("display_name")
|
||||
|
||||
// Relations
|
||||
passkeys Passkey[]
|
||||
sessions Session[]
|
||||
@@ -90,6 +98,8 @@ model User {
|
||||
dataBackups DataBackup[]
|
||||
achievements UserAchievement[]
|
||||
feedback Feedback[]
|
||||
idempotencyKeys IdempotencyKey[]
|
||||
devices Device[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@@ -152,6 +162,7 @@ enum MeasurementType {
|
||||
BLOOD_GLUCOSE // Canonical storage: mg/dL. Display unit (mg/dL or mmol/L) is user preference.
|
||||
TOTAL_BODY_WATER // Canonical storage: kilograms (kg of water). Matches Withings' hydration measure (type 77) and Health Connect's TotalBodyWaterRecord. Display can compute % from same-day weight if desired.
|
||||
BONE_MASS // Canonical storage: kilograms (kg). Matches Withings' bone mass measure (type 88).
|
||||
OXYGEN_SATURATION // Canonical storage: percent (0..100). Withings ScanWatch reports type 54. Health Connect's OxygenSaturationRecord uses Percentage (already 0..100). HealthKit uses HKQuantityTypeIdentifierOxygenSaturation as a fraction (0..1) — iOS DTO must multiply by 100 before submitting.
|
||||
|
||||
@@map("measurement_type")
|
||||
}
|
||||
@@ -559,6 +570,52 @@ model RateLimit {
|
||||
@@map("rate_limits")
|
||||
}
|
||||
|
||||
// ─── Idempotency Keys (mobile-client replay protection) ──
|
||||
|
||||
/// Caches the response of a POST/PUT/PATCH/DELETE request keyed by
|
||||
/// (userId, key, method, path) so retries within the TTL replay the
|
||||
/// original outcome instead of producing a duplicate side-effect.
|
||||
model IdempotencyKey {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
key String
|
||||
method String
|
||||
path String
|
||||
responseStatus Int @map("response_status")
|
||||
responseBody String @map("response_body") // serialized JSON envelope
|
||||
expiresAt DateTime @map("expires_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, key, method, path])
|
||||
@@index([expiresAt])
|
||||
@@map("idempotency_keys")
|
||||
}
|
||||
|
||||
// ─── Devices (APNs / mobile push targets) ────────────────
|
||||
|
||||
/// Registered native-client device for push delivery. Token is unique
|
||||
/// across users; re-registering on a different account moves ownership.
|
||||
model Device {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
platform String // "ios"
|
||||
token String @unique
|
||||
bundleId String @map("bundle_id")
|
||||
locale String?
|
||||
appVersion String? @map("app_version")
|
||||
model String?
|
||||
lastSeen DateTime @default(now()) @map("last_seen")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@map("devices")
|
||||
}
|
||||
|
||||
// ─── User Achievements ───────────────────────────────────────
|
||||
|
||||
model UserAchievement {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// Mocks must be hoisted before importing the route.
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({ user: { id: "u-1" }, session: { id: "s-1" } })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/ai/provider", () => ({
|
||||
resolveProvider: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { resolveProvider } from "@/lib/ai/provider";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
interface ApiErrorEnvelope {
|
||||
data: null;
|
||||
error: string;
|
||||
}
|
||||
|
||||
// V3 audit: /api/ai/test was returning provider err.message + bodyExcerpt
|
||||
// directly to the client, leaking provider URLs / partial keys / internal
|
||||
// headers. Server now logs full details via annotate() and responds with
|
||||
// a categorised, generic message.
|
||||
describe("POST /api/ai/test — provider error leak guard", () => {
|
||||
function makeProviderThatThrows(
|
||||
err: Error & { httpStatus?: number; bodyExcerpt?: string },
|
||||
) {
|
||||
vi.mocked(resolveProvider).mockResolvedValue({
|
||||
type: "openai",
|
||||
generateCompletion: vi.fn(async () => {
|
||||
throw err;
|
||||
}),
|
||||
} as never);
|
||||
}
|
||||
|
||||
it("does not echo provider err.message back to the client (HIGH coverage gap)", async () => {
|
||||
const err = Object.assign(
|
||||
new Error("OpenAI 401 from https://api.openai.com/v1 sk-leaked-key"),
|
||||
{ httpStatus: 401 as const, bodyExcerpt: '{"error":"invalid api key sk-secret"}' },
|
||||
);
|
||||
makeProviderThatThrows(err);
|
||||
|
||||
const response = await POST();
|
||||
const body = (await response.json()) as ApiErrorEnvelope;
|
||||
|
||||
expect(response.status).toBe(502);
|
||||
expect(body.error ?? "").not.toMatch(/sk-/);
|
||||
expect(body.error ?? "").not.toMatch(/api\.openai\.com/);
|
||||
expect(body.error ?? "").not.toMatch(/invalid api key/i);
|
||||
expect(body.error).toBe("Provider rejected the credentials");
|
||||
});
|
||||
|
||||
it("returns the 429-categorised message when the provider rate-limits", async () => {
|
||||
makeProviderThatThrows(
|
||||
Object.assign(new Error("429 from openai"), { httpStatus: 429 as const }),
|
||||
);
|
||||
const response = await POST();
|
||||
expect(response.status).toBe(502);
|
||||
expect(((await response.json()) as ApiErrorEnvelope).error).toBe(
|
||||
"Provider rate-limited the request",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the 5xx-categorised message when the provider has a server error", async () => {
|
||||
makeProviderThatThrows(
|
||||
Object.assign(new Error("503 upstream"), { httpStatus: 503 as const }),
|
||||
);
|
||||
const response = await POST();
|
||||
expect(((await response.json()) as ApiErrorEnvelope).error).toBe(
|
||||
"Provider returned a server error",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the unknown-error fallback otherwise", async () => {
|
||||
makeProviderThatThrows(new Error("ECONNRESET"));
|
||||
const response = await POST();
|
||||
expect(((await response.json()) as ApiErrorEnvelope).error).toBe(
|
||||
"Provider connection failed",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -34,10 +34,28 @@ export const POST = apiHandler(async () => {
|
||||
sample: result.content.slice(0, 200),
|
||||
});
|
||||
} catch (e) {
|
||||
// V3 audit: do not return provider error message + bodyExcerpt to the
|
||||
// client (leaks provider URL / partial keys / internal headers).
|
||||
// Log full details server-side for the operator and respond with a
|
||||
// categorised, generic message.
|
||||
const err = e as Error & { httpStatus?: number; bodyExcerpt?: string };
|
||||
return apiError(
|
||||
`${err.message}${err.bodyExcerpt ? ` — ${err.bodyExcerpt.slice(0, 200)}` : ""}`,
|
||||
502,
|
||||
);
|
||||
annotate({
|
||||
meta: {
|
||||
ai_test_error: err.message.slice(0, 500),
|
||||
ai_test_status: err.httpStatus ?? null,
|
||||
ai_test_body_excerpt: err.bodyExcerpt?.slice(0, 500) ?? null,
|
||||
ai_test_provider: provider.type,
|
||||
},
|
||||
});
|
||||
const status = err.httpStatus ?? 0;
|
||||
const safeMessage =
|
||||
status === 401 || status === 403
|
||||
? "Provider rejected the credentials"
|
||||
: status === 429
|
||||
? "Provider rate-limited the request"
|
||||
: status >= 500
|
||||
? "Provider returned a server error"
|
||||
: "Provider connection failed";
|
||||
return apiError(safeMessage, 502);
|
||||
}
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { apiSuccess } from "@/lib/api-response";
|
||||
import { summarize, type DataPoint } from "@/lib/analytics/trends";
|
||||
import { getBpTargets } from "@/lib/analytics/bp-targets";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -12,16 +13,9 @@ export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "analytics.get" } });
|
||||
|
||||
const types: MeasurementType[] = [
|
||||
"WEIGHT",
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
"PULSE",
|
||||
"BODY_FAT",
|
||||
"SLEEP_DURATION",
|
||||
"ACTIVITY_STEPS",
|
||||
"BLOOD_GLUCOSE",
|
||||
];
|
||||
// Derived from canonical enum so a new measurement type is auto-summarised
|
||||
// by /api/analytics (V3 audit: enum drift cousins).
|
||||
const types = [...measurementTypeEnum.options] as MeasurementType[];
|
||||
|
||||
const measurementsByType = await Promise.all(
|
||||
types.map((type) =>
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
// --- Mocks must come before importing the route. ---
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findFirst: vi.fn() },
|
||||
apiToken: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/password", () => ({
|
||||
verifyPassword: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({
|
||||
createSession: vi.fn().mockResolvedValue("session-id"),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({ allowed: true, remaining: 5, reset: 0 }),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/hmac", () => ({
|
||||
hashToken: vi.fn(() => "deadbeef"),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({
|
||||
emitIfSampled: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({ get: () => undefined, set: () => {}, delete: () => {} })),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { verifyPassword } from "@/lib/auth/password";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { ensureDbCompatibility } from "@/lib/db-compat";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { createSession } from "@/lib/auth/session";
|
||||
|
||||
const FAKE_USER = {
|
||||
id: "user-1",
|
||||
username: "marc",
|
||||
email: "marc@example.com",
|
||||
passwordHash: "$argon2id$...",
|
||||
};
|
||||
|
||||
function makeRequest(headers: Record<string, string> = {}): NextRequest {
|
||||
return new NextRequest("http://localhost/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...headers,
|
||||
},
|
||||
body: JSON.stringify({ email: "marc@example.com", password: "supersecret" }),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValue(FAKE_USER as never);
|
||||
vi.mocked(verifyPassword).mockResolvedValue(true);
|
||||
vi.mocked(prisma.apiToken.create).mockResolvedValue({ id: "tok-1" } as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 5,
|
||||
reset: 0,
|
||||
} as never);
|
||||
vi.mocked(rateLimitHeaders).mockReturnValue({} as never);
|
||||
vi.mocked(ensureDbCompatibility).mockResolvedValue(undefined);
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined as never);
|
||||
vi.mocked(createSession).mockResolvedValue("session-id");
|
||||
});
|
||||
|
||||
describe("POST /api/auth/login — native token issuance", () => {
|
||||
it("issues a Bearer token when X-Client-Type: native is set", async () => {
|
||||
const original = process.env.API_TOKEN_HMAC_KEY;
|
||||
process.env.API_TOKEN_HMAC_KEY = "test-key";
|
||||
try {
|
||||
const res = await POST(makeRequest({ "x-client-type": "native" }));
|
||||
const body = (await res.json()) as {
|
||||
data: { user: { id: string }; token?: string; tokenExpiresAt?: string };
|
||||
};
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.data.user.id).toBe("user-1");
|
||||
expect(body.data.token).toMatch(/^hlk_[a-f0-9]{64}$/);
|
||||
expect(body.data.tokenExpiresAt).toEqual(expect.any(String));
|
||||
expect(prisma.apiToken.create).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
process.env.API_TOKEN_HMAC_KEY = original;
|
||||
}
|
||||
});
|
||||
|
||||
it("does NOT issue a token without the native header", async () => {
|
||||
const res = await POST(makeRequest());
|
||||
const body = (await res.json()) as {
|
||||
data: { user: { id: string }; token?: string };
|
||||
};
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.data.user.id).toBe("user-1");
|
||||
expect(body.data.token).toBeUndefined();
|
||||
expect(prisma.apiToken.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("issues a token when User-Agent starts with HealthLog-iOS", async () => {
|
||||
const original = process.env.API_TOKEN_HMAC_KEY;
|
||||
process.env.API_TOKEN_HMAC_KEY = "test-key";
|
||||
try {
|
||||
const res = await POST(
|
||||
makeRequest({ "user-agent": "HealthLog-iOS/1.0 (iPhone)" }),
|
||||
);
|
||||
const body = (await res.json()) as { data: { token?: string } };
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.data.token).toMatch(/^hlk_/);
|
||||
} finally {
|
||||
process.env.API_TOKEN_HMAC_KEY = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import { ensureDbCompatibility } from "@/lib/db-compat";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { issueApiToken, isNativeClientRequest } from "@/lib/auth/issue-token";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const ip = getClientIp(request) ?? "unknown";
|
||||
@@ -74,6 +75,29 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
annotate({ action: { name: "auth.login.password" } });
|
||||
|
||||
// Native clients (iOS) additionally receive a long-lived Bearer token in
|
||||
// the response so they don't need to juggle the cookie jar.
|
||||
if (isNativeClientRequest(request.headers)) {
|
||||
const issued = await issueApiToken({
|
||||
userId: user.id,
|
||||
name: `iOS auto-login ${new Date().toISOString()}`,
|
||||
permissions: ["*"],
|
||||
expiresInDays: 90,
|
||||
});
|
||||
await auditLog("auth.token.autoissue.native", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: { tokenId: issued.tokenId, source: "login.password" },
|
||||
});
|
||||
annotate({ action: { name: "auth.token.autoissue.native" } });
|
||||
|
||||
return apiSuccess({
|
||||
user: { id: user.id, username: user.username },
|
||||
token: issued.token,
|
||||
tokenExpiresAt: issued.expiresAt.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
user: { id: user.id, username: user.username },
|
||||
});
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn() },
|
||||
apiToken: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/passkey", () => ({
|
||||
verifyAuthentication: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({
|
||||
createSession: vi.fn().mockResolvedValue("session-id"),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/hmac", () => ({
|
||||
hashToken: vi.fn(() => "deadbeef"),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({
|
||||
emitIfSampled: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({ get: () => undefined, set: () => {}, delete: () => {} })),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { verifyAuthentication } from "@/lib/auth/passkey";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const FAKE_USER = {
|
||||
id: "user-1",
|
||||
username: "marc",
|
||||
email: "marc@example.com",
|
||||
};
|
||||
|
||||
function makeRequest(headers: Record<string, string> = {}): NextRequest {
|
||||
return new NextRequest("http://localhost/api/auth/passkey/login-verify", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", ...headers },
|
||||
body: JSON.stringify({
|
||||
challengeId: "ch-1",
|
||||
credential: { id: "cred-1" },
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(FAKE_USER as never);
|
||||
vi.mocked(prisma.apiToken.create).mockResolvedValue({ id: "tok-1" } as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 9,
|
||||
reset: 0,
|
||||
} as never);
|
||||
vi.mocked(verifyAuthentication).mockResolvedValue({
|
||||
verification: { verified: true },
|
||||
passkey: { userId: "user-1" },
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("POST /api/auth/passkey/login-verify — native token issuance", () => {
|
||||
it("issues a Bearer token when X-Client-Type: native is set", async () => {
|
||||
const original = process.env.API_TOKEN_HMAC_KEY;
|
||||
process.env.API_TOKEN_HMAC_KEY = "test-key";
|
||||
try {
|
||||
const res = await POST(makeRequest({ "x-client-type": "native" }));
|
||||
const body = (await res.json()) as {
|
||||
data: { token?: string; tokenExpiresAt?: string };
|
||||
};
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.data.token).toMatch(/^hlk_[a-f0-9]{64}$/);
|
||||
expect(body.data.tokenExpiresAt).toEqual(expect.any(String));
|
||||
expect(prisma.apiToken.create).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
process.env.API_TOKEN_HMAC_KEY = original;
|
||||
}
|
||||
});
|
||||
|
||||
it("does NOT issue a token without the native header", async () => {
|
||||
const res = await POST(makeRequest());
|
||||
const body = (await res.json()) as { data: { token?: string } };
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.data.token).toBeUndefined();
|
||||
expect(prisma.apiToken.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 401 when verification fails", async () => {
|
||||
vi.mocked(verifyAuthentication).mockResolvedValue({
|
||||
verification: { verified: false },
|
||||
passkey: { userId: "user-1" },
|
||||
} as never);
|
||||
const res = await POST(makeRequest({ "x-client-type": "native" }));
|
||||
expect(res.status).toBe(401);
|
||||
expect(prisma.apiToken.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { issueApiToken, isNativeClientRequest } from "@/lib/auth/issue-token";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
await ensureDbCompatibility();
|
||||
@@ -59,6 +60,27 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
annotate({ action: { name: "auth.login.passkey" } });
|
||||
|
||||
if (isNativeClientRequest(request.headers)) {
|
||||
const issued = await issueApiToken({
|
||||
userId: user.id,
|
||||
name: `iOS auto-login ${new Date().toISOString()}`,
|
||||
permissions: ["*"],
|
||||
expiresInDays: 90,
|
||||
});
|
||||
await auditLog("auth.token.autoissue.native", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: { tokenId: issued.tokenId, source: "login.passkey" },
|
||||
});
|
||||
annotate({ action: { name: "auth.token.autoissue.native" } });
|
||||
|
||||
return apiSuccess({
|
||||
user: { id: user.id, username: user.username },
|
||||
token: issued.token,
|
||||
tokenExpiresAt: issued.expiresAt.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
user: { id: user.id, username: user.username },
|
||||
});
|
||||
|
||||
@@ -1,62 +1,30 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { profileSchema } from "@/lib/validations/auth";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { applyProfileUpdate } from "@/lib/auth/profile-update";
|
||||
|
||||
export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = profileSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
const result = await applyProfileUpdate(user.id, body, getClientIp(request));
|
||||
if (!result.ok) {
|
||||
return apiError(result.message, result.status);
|
||||
}
|
||||
|
||||
const data = parsed.data;
|
||||
const normalizedEmail = data.email ? data.email.trim().toLowerCase() : null;
|
||||
|
||||
if (data.email !== undefined && normalizedEmail) {
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { email: normalizedEmail },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (existingUser && existingUser.id !== user.id) {
|
||||
return apiError("Email already in use", 409);
|
||||
}
|
||||
}
|
||||
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
...(data.email !== undefined ? { email: normalizedEmail } : {}),
|
||||
heightCm: data.heightCm ?? null,
|
||||
dateOfBirth: data.dateOfBirth ? new Date(data.dateOfBirth) : null,
|
||||
...(data.gender !== undefined ? { gender: data.gender } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog("profile.update", {
|
||||
userId: updatedUser.id,
|
||||
ipAddress: getClientIp(request),
|
||||
});
|
||||
|
||||
annotate({ action: { name: "auth.profile.update" } });
|
||||
|
||||
return apiSuccess({
|
||||
id: updatedUser.id,
|
||||
username: updatedUser.username,
|
||||
email: updatedUser.email,
|
||||
role: updatedUser.role,
|
||||
heightCm: updatedUser.heightCm,
|
||||
dateOfBirth: updatedUser.dateOfBirth,
|
||||
gender: updatedUser.gender,
|
||||
timezone: updatedUser.timezone,
|
||||
id: result.user.id,
|
||||
username: result.user.username,
|
||||
email: result.user.email,
|
||||
role: result.user.role,
|
||||
heightCm: result.user.heightCm,
|
||||
dateOfBirth: result.user.dateOfBirth,
|
||||
gender: result.user.gender,
|
||||
timezone: result.user.timezone,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: { findMany: vi.fn() },
|
||||
medicationIntakeEvent: { findMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({
|
||||
getSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({
|
||||
emitIfSampled: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({ get: () => undefined, set: () => {}, delete: () => {} })),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "user-1",
|
||||
username: "marc",
|
||||
role: "USER" as const,
|
||||
displayName: null,
|
||||
},
|
||||
};
|
||||
|
||||
// `apiHandler` always reads `request.url` — even when the inner handler
|
||||
// ignores it — so we hand it a NextRequest and bypass the inner-handler
|
||||
// arity check via a cast.
|
||||
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
|
||||
function makeReq(): NextRequest {
|
||||
return new NextRequest("http://localhost/api/dashboard/summary");
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([] as never);
|
||||
});
|
||||
|
||||
describe("GET /api/dashboard/summary", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns the aggregated payload with empty data", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
greeting: { salutation: string; date: string };
|
||||
streak: { currentDays: number; longest: number; label: string };
|
||||
compliance: { scheduledToday: number; takenToday: number };
|
||||
metrics: Array<{ id: string; kind: string; sparkline: number[] }>;
|
||||
};
|
||||
};
|
||||
expect(body.data.greeting.salutation).toBe("Hi, marc");
|
||||
expect(body.data.streak.currentDays).toBe(0);
|
||||
expect(body.data.compliance.scheduledToday).toBe(0);
|
||||
expect(body.data.metrics).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: "weight" }),
|
||||
expect.objectContaining({ id: "bp" }),
|
||||
expect.objectContaining({ id: "pulse" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("computes intake compliance for today", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockImplementation(((
|
||||
args: unknown,
|
||||
) => {
|
||||
const a = args as { where: { OR?: unknown } };
|
||||
if (!a.where.OR) {
|
||||
return Promise.resolve([
|
||||
{ id: "e1", takenAt: new Date(), skipped: false },
|
||||
{ id: "e2", takenAt: null, skipped: false },
|
||||
]) as never;
|
||||
}
|
||||
return Promise.resolve([]) as never;
|
||||
}) as never);
|
||||
const res = await callGet(makeReq());
|
||||
const body = (await res.json()) as {
|
||||
data: { compliance: { scheduledToday: number; takenToday: number } };
|
||||
};
|
||||
expect(body.data.compliance.scheduledToday).toBe(2);
|
||||
expect(body.data.compliance.takenToday).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* GET /api/dashboard/summary
|
||||
*
|
||||
* Aggregator endpoint for the iOS DashboardSummary view. Combines
|
||||
* greeting, intake-day streaks, today's medication compliance, the
|
||||
* highlighted insight, and per-metric latest+sparkline+trend.
|
||||
*
|
||||
* The shape is fixed for the iOS client and intentionally normalised —
|
||||
* `kind` is iOS-friendly (camelCase), unlike the canonical Prisma enum
|
||||
* (BLOOD_PRESSURE_SYS etc.).
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||||
|
||||
const SPARK_DAYS = 7;
|
||||
const STREAK_WINDOW_DAYS = 365;
|
||||
|
||||
type MetricKind =
|
||||
| "weight"
|
||||
| "bloodPressure"
|
||||
| "pulse"
|
||||
| "bodyFat"
|
||||
| "glucose"
|
||||
| "sleep"
|
||||
| "steps"
|
||||
| "totalBodyWater"
|
||||
| "boneMass"
|
||||
| "oxygenSaturation";
|
||||
|
||||
interface MetricCard {
|
||||
id: string;
|
||||
kind: MetricKind;
|
||||
title: string;
|
||||
latestValue: number | null;
|
||||
secondaryValue: number | null;
|
||||
unit: string;
|
||||
trend: "up" | "down" | "flat" | "unknown";
|
||||
sparkline: number[];
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
const METRIC_TITLES: Record<MetricKind, string> = {
|
||||
weight: "Gewicht",
|
||||
bloodPressure: "Blutdruck",
|
||||
pulse: "Puls",
|
||||
bodyFat: "Körperfett",
|
||||
glucose: "Blutzucker",
|
||||
sleep: "Schlaf",
|
||||
steps: "Schritte",
|
||||
totalBodyWater: "Gesamtkörperwasser",
|
||||
boneMass: "Knochenmasse",
|
||||
oxygenSaturation: "Sauerstoffsättigung",
|
||||
};
|
||||
|
||||
const METRIC_UNITS: Record<MetricKind, string> = {
|
||||
weight: "kg",
|
||||
bloodPressure: "mmHg",
|
||||
pulse: "bpm",
|
||||
bodyFat: "%",
|
||||
glucose: "mg/dL",
|
||||
sleep: "h",
|
||||
steps: "Schritte",
|
||||
totalBodyWater: "kg",
|
||||
boneMass: "kg",
|
||||
oxygenSaturation: "%",
|
||||
};
|
||||
|
||||
function trendOf(values: number[]): MetricCard["trend"] {
|
||||
if (values.length < 2) return "unknown";
|
||||
const first = values[0];
|
||||
const last = values[values.length - 1];
|
||||
const delta = last - first;
|
||||
const epsilon = Math.max(1, Math.abs(first) * 0.01);
|
||||
if (Math.abs(delta) < epsilon) return "flat";
|
||||
return delta > 0 ? "up" : "down";
|
||||
}
|
||||
|
||||
function startOfDayBerlin(date: Date): Date {
|
||||
// Compute midnight in Europe/Berlin → UTC ms.
|
||||
const berlin = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: "Europe/Berlin",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).formatToParts(date);
|
||||
const y = berlin.find((p) => p.type === "year")?.value ?? "1970";
|
||||
const m = berlin.find((p) => p.type === "month")?.value ?? "01";
|
||||
const d = berlin.find((p) => p.type === "day")?.value ?? "01";
|
||||
return new Date(`${y}-${m}-${d}T00:00:00.000Z`);
|
||||
}
|
||||
|
||||
function berlinDayKey(date: Date): string {
|
||||
return new Intl.DateTimeFormat("sv-SE", {
|
||||
timeZone: "Europe/Berlin",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
interface StreakInfo {
|
||||
currentDays: number;
|
||||
longest: number;
|
||||
}
|
||||
|
||||
/** Compute the current logging-day streak (days where any measurement or
|
||||
* intake event was recorded, in Berlin time) plus the longest streak in
|
||||
* the last `STREAK_WINDOW_DAYS` days. */
|
||||
function computeStreak(activityDays: Set<string>): StreakInfo {
|
||||
if (activityDays.size === 0) return { currentDays: 0, longest: 0 };
|
||||
|
||||
const sorted = [...activityDays].sort();
|
||||
let longest = 1;
|
||||
let run = 1;
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
const prev = new Date(`${sorted[i - 1]}T00:00:00.000Z`).getTime();
|
||||
const cur = new Date(`${sorted[i]}T00:00:00.000Z`).getTime();
|
||||
if (cur - prev === 86_400_000) {
|
||||
run += 1;
|
||||
longest = Math.max(longest, run);
|
||||
} else {
|
||||
run = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Current streak: walk back from today (Berlin).
|
||||
const todayKey = berlinDayKey(new Date());
|
||||
let currentDays = 0;
|
||||
let cursor = new Date(`${todayKey}T00:00:00.000Z`);
|
||||
// Allow yesterday's last day to count if today not yet logged.
|
||||
if (!activityDays.has(berlinDayKey(cursor))) {
|
||||
cursor = new Date(cursor.getTime() - 86_400_000);
|
||||
}
|
||||
while (activityDays.has(berlinDayKey(cursor))) {
|
||||
currentDays += 1;
|
||||
cursor = new Date(cursor.getTime() - 86_400_000);
|
||||
}
|
||||
|
||||
return { currentDays, longest };
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "dashboard.summary" } });
|
||||
|
||||
const now = new Date();
|
||||
const sevenDaysAgo = new Date(now.getTime() - SPARK_DAYS * 86_400_000);
|
||||
const streakWindowStart = new Date(
|
||||
now.getTime() - STREAK_WINDOW_DAYS * 86_400_000,
|
||||
);
|
||||
const todayStart = startOfDayBerlin(now);
|
||||
const todayEnd = new Date(todayStart.getTime() + 86_400_000);
|
||||
|
||||
// Derived from canonical enum so a new measurement type is auto-included
|
||||
// (V3 audit: enum drift cousins). Per-kind display blocks below decide
|
||||
// which types render as MetricCards.
|
||||
const measurementTypes = [
|
||||
...measurementTypeEnum.options,
|
||||
] as MeasurementType[];
|
||||
|
||||
const [recentMeasurements, todaysIntakes, streakActivity] =
|
||||
await Promise.all([
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
type: { in: measurementTypes },
|
||||
measuredAt: { gte: sevenDaysAgo },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { type: true, value: true, measuredAt: true },
|
||||
}),
|
||||
prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
scheduledFor: { gte: todayStart, lt: todayEnd },
|
||||
},
|
||||
select: { id: true, takenAt: true, skipped: true },
|
||||
}),
|
||||
prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
scheduledFor: { gte: streakWindowStart },
|
||||
OR: [{ takenAt: { not: null } }, { skipped: true }],
|
||||
},
|
||||
select: { takenAt: true, scheduledFor: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const activityDays = new Set<string>();
|
||||
for (const m of recentMeasurements) activityDays.add(berlinDayKey(m.measuredAt));
|
||||
// Pull a wider measurement window for the streak so it isn't capped by
|
||||
// SPARK_DAYS — but only if the user has any data at all.
|
||||
if (recentMeasurements.length > 0) {
|
||||
const wider = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
measuredAt: { gte: streakWindowStart },
|
||||
},
|
||||
select: { measuredAt: true },
|
||||
});
|
||||
for (const m of wider) activityDays.add(berlinDayKey(m.measuredAt));
|
||||
}
|
||||
for (const e of streakActivity) {
|
||||
activityDays.add(berlinDayKey(e.takenAt ?? e.scheduledFor));
|
||||
}
|
||||
|
||||
const streak = computeStreak(activityDays);
|
||||
|
||||
// Per-type latest + sparkline.
|
||||
const byType = new Map<MeasurementType, { value: number; at: Date }[]>();
|
||||
for (const m of recentMeasurements) {
|
||||
const list = byType.get(m.type) ?? [];
|
||||
list.push({ value: m.value, at: m.measuredAt });
|
||||
byType.set(m.type, list);
|
||||
}
|
||||
|
||||
function latestOf(type: MeasurementType): { value: number; at: Date } | null {
|
||||
const list = byType.get(type);
|
||||
if (!list || list.length === 0) return null;
|
||||
return list[list.length - 1];
|
||||
}
|
||||
|
||||
function sparkOf(type: MeasurementType): number[] {
|
||||
const list = byType.get(type);
|
||||
if (!list) return [];
|
||||
return list.map((p) => p.value);
|
||||
}
|
||||
|
||||
const metrics: MetricCard[] = [];
|
||||
|
||||
// Weight
|
||||
{
|
||||
const latest = latestOf("WEIGHT");
|
||||
const spark = sparkOf("WEIGHT");
|
||||
metrics.push({
|
||||
id: "weight",
|
||||
kind: "weight",
|
||||
title: METRIC_TITLES.weight,
|
||||
latestValue: latest?.value ?? null,
|
||||
secondaryValue: null,
|
||||
unit: METRIC_UNITS.weight,
|
||||
trend: trendOf(spark),
|
||||
sparkline: spark,
|
||||
updatedAt: latest?.at.toISOString() ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
// Blood pressure (paired sys/dia)
|
||||
{
|
||||
const sysList = byType.get("BLOOD_PRESSURE_SYS") ?? [];
|
||||
const diaList = byType.get("BLOOD_PRESSURE_DIA") ?? [];
|
||||
const latestSys = sysList[sysList.length - 1] ?? null;
|
||||
const latestDia = diaList[diaList.length - 1] ?? null;
|
||||
metrics.push({
|
||||
id: "bp",
|
||||
kind: "bloodPressure",
|
||||
title: METRIC_TITLES.bloodPressure,
|
||||
latestValue: latestSys?.value ?? null,
|
||||
secondaryValue: latestDia?.value ?? null,
|
||||
unit: METRIC_UNITS.bloodPressure,
|
||||
trend: trendOf(sysList.map((p) => p.value)),
|
||||
sparkline: sysList.map((p) => p.value),
|
||||
updatedAt:
|
||||
latestSys?.at.toISOString() ?? latestDia?.at.toISOString() ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
// Pulse
|
||||
{
|
||||
const latest = latestOf("PULSE");
|
||||
const spark = sparkOf("PULSE");
|
||||
metrics.push({
|
||||
id: "pulse",
|
||||
kind: "pulse",
|
||||
title: METRIC_TITLES.pulse,
|
||||
latestValue: latest?.value ?? null,
|
||||
secondaryValue: null,
|
||||
unit: METRIC_UNITS.pulse,
|
||||
trend: trendOf(spark),
|
||||
sparkline: spark,
|
||||
updatedAt: latest?.at.toISOString() ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
// Body fat
|
||||
{
|
||||
const latest = latestOf("BODY_FAT");
|
||||
const spark = sparkOf("BODY_FAT");
|
||||
if (latest) {
|
||||
metrics.push({
|
||||
id: "bodyFat",
|
||||
kind: "bodyFat",
|
||||
title: METRIC_TITLES.bodyFat,
|
||||
latestValue: latest.value,
|
||||
secondaryValue: null,
|
||||
unit: METRIC_UNITS.bodyFat,
|
||||
trend: trendOf(spark),
|
||||
sparkline: spark,
|
||||
updatedAt: latest.at.toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Optional cards — only emitted if the user has data for that type.
|
||||
for (const [type, kind] of [
|
||||
["BLOOD_GLUCOSE", "glucose"],
|
||||
["SLEEP_DURATION", "sleep"],
|
||||
["ACTIVITY_STEPS", "steps"],
|
||||
["TOTAL_BODY_WATER", "totalBodyWater"],
|
||||
["BONE_MASS", "boneMass"],
|
||||
["OXYGEN_SATURATION", "oxygenSaturation"],
|
||||
] as const) {
|
||||
const latest = latestOf(type);
|
||||
if (!latest) continue;
|
||||
const spark = sparkOf(type);
|
||||
metrics.push({
|
||||
id: kind,
|
||||
kind,
|
||||
title: METRIC_TITLES[kind],
|
||||
latestValue: latest.value,
|
||||
secondaryValue: null,
|
||||
unit: METRIC_UNITS[kind],
|
||||
trend: trendOf(spark),
|
||||
sparkline: spark,
|
||||
updatedAt: latest.at.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
const scheduledToday = todaysIntakes.length;
|
||||
const takenToday = todaysIntakes.filter(
|
||||
(e) => e.takenAt !== null && !e.skipped,
|
||||
).length;
|
||||
|
||||
const greetingName = user.displayName ?? user.username;
|
||||
|
||||
return apiSuccess({
|
||||
greeting: {
|
||||
salutation: `Hi, ${greetingName}`,
|
||||
date: now.toISOString(),
|
||||
},
|
||||
streak: {
|
||||
currentDays: streak.currentDays,
|
||||
longest: streak.longest,
|
||||
label: "Tage in Folge",
|
||||
},
|
||||
compliance: {
|
||||
scheduledToday,
|
||||
takenToday,
|
||||
},
|
||||
highlightInsight: null,
|
||||
metrics,
|
||||
lastUpdated: now.toISOString(),
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,7 @@ const widgetIdEnum = z.enum([
|
||||
"totalBodyWater",
|
||||
"boneMass",
|
||||
"bpInTarget",
|
||||
"oxygenSaturation",
|
||||
]);
|
||||
|
||||
const layoutSchema = z.object({
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
device: {
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({ get: () => undefined, set: () => {}, delete: () => {} })),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
};
|
||||
|
||||
function req(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/devices", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.device.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(prisma.device.create).mockResolvedValue({ id: "dev-1" } as never);
|
||||
vi.mocked(prisma.device.update).mockResolvedValue({ id: "dev-1" } as never);
|
||||
});
|
||||
|
||||
describe("POST /api/devices", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await POST(
|
||||
req({
|
||||
token: "abcd1234efgh5678",
|
||||
bundleId: "io.healthlog.app",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 422 for missing token", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await POST(req({ bundleId: "io.healthlog.app" }));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("creates a new device row when token is unknown", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await POST(
|
||||
req({
|
||||
token: "abcd1234efgh5678",
|
||||
bundleId: "io.healthlog.app",
|
||||
locale: "de-DE",
|
||||
appVersion: "1.0.0",
|
||||
model: "iPhone15,2",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
expect(prisma.device.create).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.device.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects cross-user re-registration with 409", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.device.findUnique).mockResolvedValue({
|
||||
id: "dev-1",
|
||||
userId: "other-user",
|
||||
token: "abcd1234efgh5678",
|
||||
} as never);
|
||||
const res = await POST(
|
||||
req({ token: "abcd1234efgh5678", bundleId: "io.healthlog.app" }),
|
||||
);
|
||||
expect(res.status).toBe(409);
|
||||
expect(prisma.device.update).not.toHaveBeenCalled();
|
||||
expect(prisma.device.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("updates in place when same user re-registers their own token", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.device.findUnique).mockResolvedValue({
|
||||
id: "dev-1",
|
||||
userId: "user-1",
|
||||
token: "abcd1234efgh5678",
|
||||
} as never);
|
||||
const res = await POST(
|
||||
req({ token: "abcd1234efgh5678", bundleId: "io.healthlog.app" }),
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
expect(prisma.device.update).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.device.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* POST /api/devices
|
||||
*
|
||||
* APNs device registration. Native clients call this on login and on
|
||||
* APNs token rotation. We upsert by `token`; sending the same token on a
|
||||
* different account simply transfers ownership.
|
||||
*
|
||||
* No APNs send-side logic lives here — that ships in Phase 8.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, safeJson } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
const deviceSchema = z.object({
|
||||
token: z
|
||||
.string()
|
||||
.min(8)
|
||||
.max(512)
|
||||
.regex(/^[A-Za-z0-9+/=._:-]+$/, "Invalid token format"),
|
||||
bundleId: z.string().min(1).max(128),
|
||||
locale: z.string().min(2).max(16).optional(),
|
||||
appVersion: z.string().min(1).max(32).optional(),
|
||||
model: z.string().min(1).max(64).optional(),
|
||||
});
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { data: body, error } = await safeJson(request);
|
||||
if (error) return error;
|
||||
|
||||
const parsed = deviceSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
}
|
||||
|
||||
const { token, bundleId, locale, appVersion, model } = parsed.data;
|
||||
|
||||
const existing = await prisma.device.findUnique({ where: { token } });
|
||||
let id: string;
|
||||
if (existing) {
|
||||
// Cross-user-hijack guard: a device-token belongs to exactly one user.
|
||||
// Re-registering the same token under a different account is rejected
|
||||
// — APNs tokens aren't a secret, so trusting the wire input would let
|
||||
// anyone who learns/guesses a token redirect another user's pushes.
|
||||
if (existing.userId !== user.id) {
|
||||
await auditLog("device.register.denied", {
|
||||
userId: user.id,
|
||||
details: { reason: "token_owned_by_other_user", deviceId: existing.id },
|
||||
});
|
||||
return apiError("Device token already registered to another account", 409);
|
||||
}
|
||||
const updated = await prisma.device.update({
|
||||
where: { token },
|
||||
data: {
|
||||
platform: "ios",
|
||||
bundleId,
|
||||
locale: locale ?? null,
|
||||
appVersion: appVersion ?? null,
|
||||
model: model ?? null,
|
||||
lastSeen: new Date(),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
id = updated.id;
|
||||
} else {
|
||||
const created = await prisma.device.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
platform: "ios",
|
||||
token,
|
||||
bundleId,
|
||||
locale: locale ?? null,
|
||||
appVersion: appVersion ?? null,
|
||||
model: model ?? null,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
id = created.id;
|
||||
}
|
||||
|
||||
await auditLog("devices.register", {
|
||||
userId: user.id,
|
||||
details: { deviceId: id, bundleId, model: model ?? null },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "devices.register", entity_type: "device", entity_id: id },
|
||||
});
|
||||
|
||||
return apiSuccess({ id }, 201);
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
// --- Mocks must come before importing the route. ---
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: { findMany: vi.fn() },
|
||||
medication: { findMany: vi.fn() },
|
||||
medicationIntakeEvent: { findMany: vi.fn() },
|
||||
moodEntry: { findMany: vi.fn() },
|
||||
user: { findUnique: vi.fn() },
|
||||
auditLog: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({
|
||||
getSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/geo", () => ({
|
||||
lookupIpLocation: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/analytics/effective-range", () => ({
|
||||
getEffectiveRange: vi.fn(() => ({ range: null })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({
|
||||
emitIfSampled: vi.fn(),
|
||||
}));
|
||||
|
||||
import { POST } from "../pdf/route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
function makeRequest(
|
||||
body?: Record<string, unknown>,
|
||||
headers: Record<string, string> = {},
|
||||
): NextRequest {
|
||||
const baseHeaders: Record<string, string> = {
|
||||
"content-type": "application/json",
|
||||
...headers,
|
||||
};
|
||||
return new NextRequest("http://localhost/api/doctor-report/pdf", {
|
||||
method: "POST",
|
||||
headers: baseHeaders,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3600_000) },
|
||||
user: { id: "user-1", role: "USER" as const },
|
||||
};
|
||||
|
||||
function setEmptyDataMocks() {
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
username: "marc",
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
heightCm: null,
|
||||
glucoseUnit: null,
|
||||
thresholdsJson: null,
|
||||
} as never);
|
||||
}
|
||||
|
||||
describe("POST /api/doctor-report/pdf", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it("returns 401 when not authenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
|
||||
const res = await POST(makeRequest({ days: 90 }));
|
||||
expect(res.status).toBe(401);
|
||||
const json = (await res.json()) as { error: string };
|
||||
expect(json.error).toBe("Not authenticated");
|
||||
});
|
||||
|
||||
it("returns 429 when rate limit exceeded", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 3600_000,
|
||||
});
|
||||
|
||||
const res = await POST(makeRequest({ days: 90 }));
|
||||
expect(res.status).toBe(429);
|
||||
const json = (await res.json()) as { error: string };
|
||||
expect(json.error).toMatch(/Maximum/);
|
||||
});
|
||||
|
||||
it("returns 200 application/pdf with PDF bytes on happy path", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 9,
|
||||
resetAt: Date.now() + 3600_000,
|
||||
});
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined as never);
|
||||
setEmptyDataMocks();
|
||||
|
||||
const res = await POST(
|
||||
makeRequest({ days: 30, locale: "de" }, { "accept-language": "de-DE" }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("content-type")).toBe("application/pdf");
|
||||
expect(res.headers.get("cache-control")).toBe("no-store");
|
||||
const disposition = res.headers.get("content-disposition") ?? "";
|
||||
expect(disposition).toMatch(/attachment;\s*filename="healthlog-report-/);
|
||||
expect(disposition).toMatch(/\.pdf"/);
|
||||
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
expect(buf.byteLength).toBeGreaterThan(1024);
|
||||
expect(buf.slice(0, 5).toString("utf8")).toBe("%PDF-");
|
||||
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"doctor-report.pdf.generate",
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
details: expect.objectContaining({ days: 30, locale: "de" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to default days=90 when body omits it", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 9,
|
||||
resetAt: Date.now() + 3600_000,
|
||||
});
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined as never);
|
||||
setEmptyDataMocks();
|
||||
|
||||
const res = await POST(makeRequest({}));
|
||||
expect(res.status).toBe(200);
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"doctor-report.pdf.generate",
|
||||
expect.objectContaining({
|
||||
details: expect.objectContaining({ days: 90 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses Accept-Language to pick the locale when body omits it", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 9,
|
||||
resetAt: Date.now() + 3600_000,
|
||||
});
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined as never);
|
||||
setEmptyDataMocks();
|
||||
|
||||
const res = await POST(
|
||||
makeRequest({ days: 7 }, { "accept-language": "en-US,en;q=0.9" }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"doctor-report.pdf.generate",
|
||||
expect.objectContaining({
|
||||
details: expect.objectContaining({ locale: "en" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiError, getClientIp } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
collectDoctorReportData,
|
||||
normaliseDays,
|
||||
} from "@/lib/doctor-report-data";
|
||||
import { renderDoctorReportPdfBytes } from "@/lib/doctor-report-pdf-core";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { parseLocaleFromAcceptLanguage } from "@/lib/format-locale";
|
||||
import { locales, type Locale } from "@/lib/i18n/config";
|
||||
|
||||
/**
|
||||
* Server-rendered PDF doctor report.
|
||||
*
|
||||
* Mirrors `/api/doctor-report` (which returns JSON for client-side rendering)
|
||||
* but produces the finished PDF bytes server-side. Useful on iOS / Safari where
|
||||
* the client-side jsPDF download UX is unreliable.
|
||||
*
|
||||
* Auth, rate-limit (10/h), and audit-log policy match the JSON endpoint.
|
||||
*/
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "doctor-report.pdf.generate" } });
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`doctor-report:${user.id}`,
|
||||
10,
|
||||
60 * 60 * 1000,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Maximum 10 reports per hour", 429);
|
||||
}
|
||||
|
||||
// Body is optional — JSON is allowed but not required.
|
||||
const body = await readOptionalJsonBody(request);
|
||||
const days = normaliseDays(body?.days);
|
||||
const locale = resolveLocale(
|
||||
body?.locale,
|
||||
request.headers.get("accept-language"),
|
||||
);
|
||||
|
||||
const data = await collectDoctorReportData(user.id, days);
|
||||
|
||||
const { t } = getServerTranslator(locale);
|
||||
const pdfBytes = renderDoctorReportPdfBytes(data, { t, locale });
|
||||
|
||||
await auditLog("doctor-report.pdf.generate", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { days, locale },
|
||||
});
|
||||
|
||||
annotate({ meta: { report_days: days, locale, bytes: pdfBytes.byteLength } });
|
||||
|
||||
const filename = `healthlog-report-${new Date().toISOString().slice(0, 10)}.pdf`;
|
||||
// NextResponse accepts a BodyInit; copy to a fresh ArrayBuffer to avoid
|
||||
// SharedArrayBuffer typing surprises with Uint8Array.
|
||||
const buffer = pdfBytes.slice().buffer;
|
||||
return new NextResponse(buffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
"Content-Length": String(pdfBytes.byteLength),
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
interface PdfRequestBody {
|
||||
days?: unknown;
|
||||
locale?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort JSON body parse. Returns `null` if the request had no body or
|
||||
* a non-JSON content type — both are acceptable for this endpoint.
|
||||
*/
|
||||
async function readOptionalJsonBody(
|
||||
request: NextRequest,
|
||||
): Promise<PdfRequestBody | null> {
|
||||
const contentType = request.headers.get("content-type") ?? "";
|
||||
if (!contentType.includes("application/json")) return null;
|
||||
try {
|
||||
return (await request.json()) as PdfRequestBody;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the active locale: explicit body override wins, then a parsed
|
||||
* `Accept-Language`, then a hard fallback to German (the primary HealthLog
|
||||
* locale for the medical context).
|
||||
*/
|
||||
function resolveLocale(
|
||||
bodyLocale: unknown,
|
||||
acceptLanguage: string | null,
|
||||
): Locale {
|
||||
if (
|
||||
typeof bodyLocale === "string" &&
|
||||
(locales as readonly string[]).includes(bodyLocale)
|
||||
) {
|
||||
return bodyLocale as Locale;
|
||||
}
|
||||
if (!acceptLanguage) return "de";
|
||||
// parseLocaleFromAcceptLanguage returns "de" for de-* headers, otherwise
|
||||
// "en" — matching the broader app's i18n behaviour for present headers.
|
||||
return parseLocaleFromAcceptLanguage(acceptLanguage);
|
||||
}
|
||||
@@ -1,16 +1,18 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { NextRequest } from "next/server";
|
||||
import {
|
||||
getEffectiveRange,
|
||||
type ThresholdOverridesJson,
|
||||
} from "@/lib/analytics/effective-range";
|
||||
import { resolveGlucoseUnit, thresholdMetricForContext } from "@/lib/glucose";
|
||||
import type { GlucoseContext } from "@/generated/prisma/client";
|
||||
collectDoctorReportData,
|
||||
normaliseDays,
|
||||
} from "@/lib/doctor-report-data";
|
||||
|
||||
/**
|
||||
* Collect data for doctor report PDF generation (client-side).
|
||||
@@ -20,203 +22,28 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "doctor-report.generate" } });
|
||||
|
||||
const rl = await checkRateLimit(`doctor-report:${user.id}`, 10, 60 * 60 * 1000);
|
||||
const rl = await checkRateLimit(
|
||||
`doctor-report:${user.id}`,
|
||||
10,
|
||||
60 * 60 * 1000,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Maximum 10 reports per hour", 429);
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
|
||||
if (jsonError) return jsonError;
|
||||
const rawDays = (body as Record<string, unknown>)?.days;
|
||||
const days =
|
||||
typeof rawDays === "number" && Number.isInteger(rawDays) && rawDays >= 1 && rawDays <= 365
|
||||
? rawDays
|
||||
: 90;
|
||||
const since = new Date();
|
||||
since.setDate(since.getDate() - days);
|
||||
|
||||
const userId = user.id;
|
||||
|
||||
const [measurements, medications, intakeEvents, moodEntries, userProfile] =
|
||||
await Promise.all([
|
||||
prisma.measurement.findMany({
|
||||
where: { userId, measuredAt: { gte: since } },
|
||||
orderBy: { measuredAt: "asc" },
|
||||
}),
|
||||
prisma.medication.findMany({
|
||||
where: { userId, active: true },
|
||||
include: { schedules: true },
|
||||
}),
|
||||
prisma.medicationIntakeEvent.findMany({
|
||||
where: { userId, scheduledFor: { gte: since } },
|
||||
include: { medication: { select: { name: true } } },
|
||||
orderBy: { scheduledFor: "asc" },
|
||||
}),
|
||||
prisma.moodEntry.findMany({
|
||||
where: { userId, moodLoggedAt: { gte: since } },
|
||||
orderBy: { moodLoggedAt: "asc" },
|
||||
}),
|
||||
prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
username: true,
|
||||
dateOfBirth: true,
|
||||
gender: true,
|
||||
heightCm: true,
|
||||
glucoseUnit: true,
|
||||
thresholdsJson: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
// Group measurements by type
|
||||
const byType: Record<
|
||||
string,
|
||||
Array<{ value: number; measuredAt: string }>
|
||||
> = {};
|
||||
for (const m of measurements) {
|
||||
if (!byType[m.type]) byType[m.type] = [];
|
||||
byType[m.type].push({
|
||||
value: m.value,
|
||||
measuredAt: m.measuredAt.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate stats per type
|
||||
const stats: Record<
|
||||
string,
|
||||
{ avg: number; min: number; max: number; count: number; latest: number }
|
||||
> = {};
|
||||
for (const [type, entries] of Object.entries(byType)) {
|
||||
const values = entries.map((e) => e.value);
|
||||
stats[type] = {
|
||||
avg: values.reduce((a, b) => a + b, 0) / values.length,
|
||||
min: Math.min(...values),
|
||||
max: Math.max(...values),
|
||||
count: values.length,
|
||||
latest: values[values.length - 1],
|
||||
};
|
||||
}
|
||||
|
||||
// Medication compliance
|
||||
const complianceByMed: Record<
|
||||
string,
|
||||
{ total: number; taken: number; skipped: number; missed: number }
|
||||
> = {};
|
||||
for (const event of intakeEvents) {
|
||||
const name = event.medication.name;
|
||||
if (!complianceByMed[name]) {
|
||||
complianceByMed[name] = { total: 0, taken: 0, skipped: 0, missed: 0 };
|
||||
}
|
||||
complianceByMed[name].total++;
|
||||
if (event.takenAt) {
|
||||
complianceByMed[name].taken++;
|
||||
} else if (event.skipped) {
|
||||
complianceByMed[name].skipped++;
|
||||
} else {
|
||||
complianceByMed[name].missed++;
|
||||
}
|
||||
}
|
||||
|
||||
// Mood summary
|
||||
const moodScores = moodEntries.map((e) => e.score);
|
||||
const moodSummary =
|
||||
moodScores.length > 0
|
||||
? {
|
||||
avg: moodScores.reduce((a, b) => a + b, 0) / moodScores.length,
|
||||
min: Math.min(...moodScores),
|
||||
max: Math.max(...moodScores),
|
||||
count: moodScores.length,
|
||||
distribution: {
|
||||
1: moodScores.filter((s) => s === 1).length,
|
||||
2: moodScores.filter((s) => s === 2).length,
|
||||
3: moodScores.filter((s) => s === 3).length,
|
||||
4: moodScores.filter((s) => s === 4).length,
|
||||
5: moodScores.filter((s) => s === 5).length,
|
||||
},
|
||||
}
|
||||
: null;
|
||||
|
||||
// BMI
|
||||
const weightStats = stats.WEIGHT;
|
||||
const bmi =
|
||||
weightStats && userProfile?.heightCm
|
||||
? weightStats.latest / (userProfile.heightCm / 100) ** 2
|
||||
: null;
|
||||
const days = normaliseDays((body as Record<string, unknown>)?.days);
|
||||
const data = await collectDoctorReportData(user.id, days);
|
||||
|
||||
await auditLog("doctor-report.generate", {
|
||||
userId,
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { days },
|
||||
});
|
||||
|
||||
annotate({ meta: { report_days: days } });
|
||||
|
||||
// Per-context glucose stats (canonical mg/dL) and effective ranges.
|
||||
const glucoseContexts: GlucoseContext[] = [
|
||||
"FASTING",
|
||||
"POSTPRANDIAL",
|
||||
"RANDOM",
|
||||
"BEDTIME",
|
||||
];
|
||||
const glucoseStats: Record<
|
||||
string,
|
||||
{ avg: number; min: number; max: number; count: number; latest: number }
|
||||
> = {};
|
||||
const glucoseRanges: Record<string, { min: number; max: number }> = {};
|
||||
const glucoseRows = measurements.filter((m) => m.type === "BLOOD_GLUCOSE");
|
||||
const overrides = (userProfile?.thresholdsJson ?? null) as ThresholdOverridesJson | null;
|
||||
const profileForRange = {
|
||||
heightCm: userProfile?.heightCm ?? null,
|
||||
dateOfBirth: userProfile?.dateOfBirth ?? null,
|
||||
gender: userProfile?.gender ?? null,
|
||||
};
|
||||
for (const ctx of glucoseContexts) {
|
||||
const rows = glucoseRows.filter((m) => m.glucoseContext === ctx);
|
||||
if (rows.length === 0) continue;
|
||||
const values = rows.map((r) => r.value);
|
||||
glucoseStats[ctx] = {
|
||||
avg: values.reduce((a, b) => a + b, 0) / values.length,
|
||||
min: Math.min(...values),
|
||||
max: Math.max(...values),
|
||||
count: values.length,
|
||||
latest: values[values.length - 1],
|
||||
};
|
||||
const eff = getEffectiveRange(
|
||||
thresholdMetricForContext(ctx),
|
||||
profileForRange,
|
||||
overrides,
|
||||
);
|
||||
if (eff.range) {
|
||||
glucoseRanges[ctx] = { min: eff.range.greenMin, max: eff.range.greenMax };
|
||||
}
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
period: { days, since: since.toISOString() },
|
||||
patient: {
|
||||
username: userProfile?.username ?? null,
|
||||
dateOfBirth: userProfile?.dateOfBirth ?? null,
|
||||
gender: userProfile?.gender ?? null,
|
||||
heightCm: userProfile?.heightCm ?? null,
|
||||
},
|
||||
measurements: byType,
|
||||
stats,
|
||||
glucoseStats,
|
||||
glucoseRanges,
|
||||
glucoseUnit: resolveGlucoseUnit(userProfile?.glucoseUnit ?? null),
|
||||
bmi: bmi ? Math.round(bmi * 10) / 10 : null,
|
||||
compliance: complianceByMed,
|
||||
medications: medications.map((m) => ({
|
||||
name: m.name,
|
||||
dose: m.dose,
|
||||
schedules: m.schedules.map((s) => ({
|
||||
windowStart: s.windowStart,
|
||||
windowEnd: s.windowEnd,
|
||||
label: s.label,
|
||||
})),
|
||||
})),
|
||||
mood: moodSummary,
|
||||
});
|
||||
return apiSuccess(data);
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: { findMany: vi.fn() },
|
||||
medicationIntakeEvent: { findMany: vi.fn() },
|
||||
medication: { findMany: vi.fn() },
|
||||
passkey: { findMany: vi.fn() },
|
||||
auditLog: { findMany: vi.fn() },
|
||||
userAchievement: {
|
||||
findMany: vi.fn(),
|
||||
createMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({ get: () => undefined, set: () => {}, delete: () => {} })),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "user-1",
|
||||
username: "marc",
|
||||
role: "USER" as const,
|
||||
createdAt: new Date("2026-03-01T00:00:00.000Z"),
|
||||
heightCm: null,
|
||||
locale: "en",
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.passkey.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.auditLog.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.userAchievement.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.userAchievement.createMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("GET /api/gamification/achievements?format=ios", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await GET(
|
||||
new NextRequest(
|
||||
"http://localhost/api/gamification/achievements?format=ios",
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns a flat iOS-shaped achievement array", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await GET(
|
||||
new NextRequest(
|
||||
"http://localhost/api/gamification/achievements?format=ios",
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: Array<{
|
||||
id: string;
|
||||
key: string;
|
||||
title: string;
|
||||
description: string;
|
||||
iconName: string;
|
||||
unlocked: boolean;
|
||||
unlockedAt: string | null;
|
||||
progress: number;
|
||||
}>;
|
||||
};
|
||||
expect(Array.isArray(body.data)).toBe(true);
|
||||
expect(body.data.length).toBeGreaterThan(0);
|
||||
const first = body.data[0];
|
||||
expect(first.id).toEqual(expect.any(String));
|
||||
expect(first.key).toEqual(expect.any(String));
|
||||
expect(first.title).toEqual(expect.any(String));
|
||||
expect(first.iconName).toEqual(expect.any(String));
|
||||
expect(first.progress).toBeGreaterThanOrEqual(0);
|
||||
expect(first.progress).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("falls back to the legacy wrapped shape when no format is given", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/gamification/achievements"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { achievements: unknown[]; summary: unknown };
|
||||
};
|
||||
expect(body.data.achievements).toBeDefined();
|
||||
expect(body.data.summary).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,20 @@ import {
|
||||
classifyPulse,
|
||||
} from "@/lib/analytics/classifications";
|
||||
import { classifyIntakeTiming } from "@/lib/analytics/compliance";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
interface IosAchievement {
|
||||
id: string;
|
||||
key: string;
|
||||
title: string;
|
||||
description: string;
|
||||
iconName: string;
|
||||
unlocked: boolean;
|
||||
unlockedAt: string | null;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -431,9 +445,14 @@ function getCompliance80DaySeries(
|
||||
return toDaySeries(greenDays);
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "gamification.achievements" } });
|
||||
const formatParam = request.nextUrl.searchParams.get("format");
|
||||
const isIosFormat = formatParam === "ios";
|
||||
annotate({
|
||||
action: { name: "gamification.achievements" },
|
||||
meta: { format: isIosFormat ? "ios" : "default" },
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
const userId = user.id;
|
||||
@@ -742,5 +761,24 @@ export const GET = apiHandler(async () => {
|
||||
});
|
||||
}
|
||||
|
||||
if (isIosFormat) {
|
||||
const locale = await resolveServerLocale({
|
||||
request,
|
||||
userLocale: user.locale,
|
||||
});
|
||||
const t = getServerTranslator(locale);
|
||||
const ios: IosAchievement[] = result.achievements.map((a) => ({
|
||||
id: a.id,
|
||||
key: a.id,
|
||||
title: t.t(a.titleKey),
|
||||
description: t.t(a.descriptionKey),
|
||||
iconName: a.icon,
|
||||
unlocked: a.unlocked,
|
||||
unlockedAt: a.completedAt,
|
||||
progress: Math.max(0, Math.min(1, a.progressPercent / 100)),
|
||||
}));
|
||||
return apiSuccess(ios);
|
||||
}
|
||||
|
||||
return apiSuccess(result);
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({ user: { id: "u-1" }, session: { id: "s-1" } })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: { create: vi.fn() },
|
||||
moodEntry: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
import { POST } from "../route";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
interface ApiErrorEnvelope {
|
||||
data: null;
|
||||
error: string;
|
||||
}
|
||||
|
||||
// V3 audit: /api/import POST had no rate-limit. Bulk-injection vector
|
||||
// (max:10000 records per call). Now capped at 5/hour/user.
|
||||
describe("POST /api/import — rate-limit guard", () => {
|
||||
it("returns 429 when the user has exhausted the 5/hour quota (HIGH coverage gap)", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: new Date(Date.now() + 1000),
|
||||
} as never);
|
||||
|
||||
const response = await POST(
|
||||
new NextRequest("http://localhost/api/import", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ measurements: [] }),
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(429);
|
||||
const body = (await response.json()) as ApiErrorEnvelope;
|
||||
expect(body.error).toMatch(/per hour/i);
|
||||
expect(checkRateLimit).toHaveBeenCalledWith(
|
||||
expect.stringContaining("import:u-1"),
|
||||
5,
|
||||
60 * 60 * 1000,
|
||||
);
|
||||
});
|
||||
|
||||
it("processes the request when within the quota", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 4,
|
||||
resetAt: new Date(Date.now() + 1000),
|
||||
} as never);
|
||||
|
||||
const response = await POST(
|
||||
new NextRequest("http://localhost/api/import", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ measurements: [] }),
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBeLessThan(400);
|
||||
});
|
||||
});
|
||||
+21
-11
@@ -2,26 +2,27 @@ import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, getClientIp, safeJson } from "@/lib/api-response";
|
||||
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { validateMeasurementRange } from "@/lib/validations/measurement";
|
||||
import {
|
||||
validateMeasurementRange,
|
||||
measurementTypeEnum,
|
||||
glucoseContextEnum,
|
||||
} from "@/lib/validations/measurement";
|
||||
|
||||
// Derived from canonical enum so round-trip export → import covers every
|
||||
// type. Previous hardcoded subset silently dropped 4 of 11 types
|
||||
// (V3 audit: enum drift cousins).
|
||||
const measurementSchema = z
|
||||
.object({
|
||||
type: z.enum([
|
||||
"WEIGHT",
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
"PULSE",
|
||||
"BODY_FAT",
|
||||
"SLEEP_DURATION",
|
||||
"ACTIVITY_STEPS",
|
||||
]),
|
||||
type: measurementTypeEnum,
|
||||
value: z.number(),
|
||||
unit: z.string(),
|
||||
measuredAt: z.string().datetime(),
|
||||
glucoseContext: glucoseContextEnum.optional(),
|
||||
source: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
})
|
||||
@@ -51,6 +52,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "import.upload" } });
|
||||
|
||||
// V3 audit: bulk-injection vector unchecked. 5/hour matches the export
|
||||
// limit (10/hour) but is tighter because import writes have a higher
|
||||
// blast radius (DB writes vs. read-only export).
|
||||
const rl = await checkRateLimit(`import:${user.id}`, 5, 60 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Maximum 5 imports per hour", 429);
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
|
||||
if (jsonError) return jsonError;
|
||||
@@ -76,6 +85,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
source: "IMPORT",
|
||||
measuredAt: new Date(m.measuredAt),
|
||||
notes: m.notes || null,
|
||||
glucoseContext: m.glucoseContext ?? null,
|
||||
},
|
||||
});
|
||||
stats.measurements++;
|
||||
|
||||
@@ -62,7 +62,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
return apiError("Token expired", 401);
|
||||
}
|
||||
|
||||
if (!apiToken.permissions.includes("medication:ingest")) {
|
||||
if (
|
||||
!apiToken.permissions.includes("*") &&
|
||||
!apiToken.permissions.includes("medication:ingest")
|
||||
) {
|
||||
return apiError("Insufficient permissions", 403);
|
||||
}
|
||||
|
||||
@@ -113,7 +116,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
|
||||
const medicationScope = `medication:${medication.id}:ingest`;
|
||||
if (!apiToken.permissions.includes(medicationScope)) {
|
||||
if (
|
||||
!apiToken.permissions.includes("*") &&
|
||||
!apiToken.permissions.includes(medicationScope)
|
||||
) {
|
||||
return apiError("API endpoint for this medication is disabled", 403);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn() },
|
||||
measurement: { findMany: vi.fn() },
|
||||
medication: { findMany: vi.fn() },
|
||||
medicationIntakeEvent: { findMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({ get: () => undefined, set: () => {}, delete: () => {} })),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
heightCm: null,
|
||||
dateOfBirth: null,
|
||||
aiProvider: null,
|
||||
} as never);
|
||||
});
|
||||
|
||||
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
|
||||
function makeReq(): NextRequest {
|
||||
return new NextRequest("http://localhost/api/insights/cards");
|
||||
}
|
||||
|
||||
describe("GET /api/insights/cards", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns an empty array when no data is available", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: unknown[] };
|
||||
expect(Array.isArray(body.data)).toBe(true);
|
||||
expect(body.data).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("emits cards when measurements trigger alerts", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
// Provide enough sys + dia measurements to trigger danger-level alert.
|
||||
const now = new Date();
|
||||
const measurements: Array<{
|
||||
type: string;
|
||||
value: number;
|
||||
measuredAt: Date;
|
||||
}> = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const at = new Date(now.getTime() - i * 86_400_000);
|
||||
measurements.push({ type: "BLOOD_PRESSURE_SYS", value: 180, measuredAt: at });
|
||||
measurements.push({ type: "BLOOD_PRESSURE_DIA", value: 110, measuredAt: at });
|
||||
}
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue(measurements as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
heightCm: null,
|
||||
dateOfBirth: null,
|
||||
aiProvider: "ANTHROPIC",
|
||||
} as never);
|
||||
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: Array<{ severity: string; provider: string; title: string }>;
|
||||
};
|
||||
expect(body.data.length).toBeGreaterThan(0);
|
||||
expect(body.data[0].provider).toBe("anthropic");
|
||||
expect(body.data.some((c) => c.severity === "alert")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* GET /api/insights/cards — iOS adapter over `/api/insights/comprehensive`.
|
||||
*
|
||||
* Reuses the same data sources (measurements, mood, intake events) and the
|
||||
* shared `generateAlerts()` rule engine, then re-shapes each `HealthAlert`
|
||||
* to the iOS Insight model (id, title, summary, severity, recommendations,
|
||||
* provider).
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { summarize, type DataPoint } from "@/lib/analytics/trends";
|
||||
import { getBpTargets } from "@/lib/analytics/bp-targets";
|
||||
import {
|
||||
generateAlerts,
|
||||
type HealthAlert,
|
||||
} from "@/lib/analytics/classifications";
|
||||
import { pairByTimestamp } from "@/lib/analytics/correlations";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
|
||||
type Severity = "alert" | "caution" | "info" | "good";
|
||||
|
||||
function toSeverity(level: HealthAlert["level"]): Severity {
|
||||
switch (level) {
|
||||
case "danger":
|
||||
return "alert";
|
||||
case "warning":
|
||||
return "caution";
|
||||
case "success":
|
||||
return "good";
|
||||
default:
|
||||
return "info";
|
||||
}
|
||||
}
|
||||
|
||||
interface InsightRecommendation {
|
||||
id: string;
|
||||
label: string;
|
||||
actionURL: string | null;
|
||||
}
|
||||
|
||||
interface InsightCard {
|
||||
id: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
body: string | null;
|
||||
severity: Severity;
|
||||
recommendations: InsightRecommendation[];
|
||||
generatedAt: string;
|
||||
provider: string;
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "insights.cards" } });
|
||||
|
||||
const ninetyDaysAgo = new Date(Date.now() - 90 * 86_400_000);
|
||||
|
||||
const [dbUser, allMeasurements, medications] = await Promise.all([
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { heightCm: true, dateOfBirth: true, aiProvider: true },
|
||||
}),
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
type: {
|
||||
in: [
|
||||
"WEIGHT",
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
"PULSE",
|
||||
],
|
||||
},
|
||||
measuredAt: { gte: ninetyDaysAgo },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { type: true, value: true, measuredAt: true },
|
||||
}),
|
||||
prisma.medication.findMany({
|
||||
where: { userId: user.id, active: true },
|
||||
include: { schedules: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const byType = (t: MeasurementType): DataPoint[] =>
|
||||
allMeasurements
|
||||
.filter((m) => m.type === t)
|
||||
.map((m) => ({ date: m.measuredAt, value: m.value }));
|
||||
|
||||
const summaries: Partial<Record<MeasurementType, ReturnType<typeof summarize>>> = {};
|
||||
for (const t of [
|
||||
"WEIGHT",
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
"PULSE",
|
||||
] as MeasurementType[]) {
|
||||
const data = byType(t);
|
||||
if (data.length > 0) summaries[t] = summarize(data);
|
||||
}
|
||||
|
||||
let bmi: number | null = null;
|
||||
if (dbUser?.heightCm && summaries.WEIGHT?.latest) {
|
||||
const heightM = dbUser.heightCm / 100;
|
||||
bmi =
|
||||
Math.round((summaries.WEIGHT.latest / (heightM * heightM)) * 10) / 10;
|
||||
}
|
||||
|
||||
let bpPctInTarget: number | null = null;
|
||||
const bpTargets = getBpTargets(dbUser?.dateOfBirth ?? null);
|
||||
if (bpTargets) {
|
||||
const sysData = byType("BLOOD_PRESSURE_SYS");
|
||||
const diaData = byType("BLOOD_PRESSURE_DIA");
|
||||
const pairs = pairByTimestamp(sysData, diaData, 5 * 60_000);
|
||||
if (pairs.length > 0) {
|
||||
const inTarget = pairs.filter(
|
||||
(p) =>
|
||||
p.a >= bpTargets.sysLow &&
|
||||
p.a <= bpTargets.sysHigh &&
|
||||
p.b >= bpTargets.diaLow &&
|
||||
p.b <= bpTargets.diaHigh,
|
||||
).length;
|
||||
bpPctInTarget = Math.round((inTarget / pairs.length) * 100);
|
||||
}
|
||||
}
|
||||
|
||||
// Lightweight medication compliance for alert input.
|
||||
const medicationCompliance: Array<{
|
||||
name: string;
|
||||
compliance7: number;
|
||||
compliance30: number;
|
||||
}> = [];
|
||||
// Single batched query — bucket by medicationId in JS (fix N+1).
|
||||
const activeMeds = medications.filter((m) => m.schedules.length > 0);
|
||||
if (activeMeds.length > 0) {
|
||||
const allEvents = await prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
medicationId: { in: activeMeds.map((m) => m.id) },
|
||||
scheduledFor: { gte: new Date(Date.now() - 30 * 86_400_000) },
|
||||
},
|
||||
select: { medicationId: true, takenAt: true, skipped: true, scheduledFor: true },
|
||||
});
|
||||
const eventsByMed = new Map<string, typeof allEvents>();
|
||||
for (const e of allEvents) {
|
||||
const arr = eventsByMed.get(e.medicationId) ?? [];
|
||||
arr.push(e);
|
||||
eventsByMed.set(e.medicationId, arr);
|
||||
}
|
||||
const cutoff7 = Date.now() - 7 * 86_400_000;
|
||||
for (const med of activeMeds) {
|
||||
const events = eventsByMed.get(med.id) ?? [];
|
||||
const takenLast7 = events.filter(
|
||||
(e) => e.takenAt !== null && !e.skipped && e.scheduledFor.getTime() >= cutoff7,
|
||||
).length;
|
||||
const taken30 = events.filter((e) => e.takenAt !== null && !e.skipped).length;
|
||||
const expected7 = med.schedules.length * 7;
|
||||
const expected30 = med.schedules.length * 30;
|
||||
medicationCompliance.push({
|
||||
name: med.name,
|
||||
compliance7: expected7 > 0 ? Math.round((takenLast7 / expected7) * 100) : 0,
|
||||
compliance30: expected30 > 0 ? Math.round((taken30 / expected30) * 100) : 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const alerts = generateAlerts({
|
||||
bmi,
|
||||
bpAvgSys: summaries.BLOOD_PRESSURE_SYS?.avg30 ?? null,
|
||||
bpAvgDia: summaries.BLOOD_PRESSURE_DIA?.avg30 ?? null,
|
||||
bpPctInTarget,
|
||||
weightSlope30: summaries.WEIGHT?.slope30?.slope ?? null,
|
||||
pulseAvg30: summaries.PULSE?.avg30 ?? null,
|
||||
pulseAnomalyCount: summaries.PULSE?.anomalyCount,
|
||||
medications: medicationCompliance,
|
||||
});
|
||||
|
||||
const provider = dbUser?.aiProvider?.toLowerCase() ?? "claude";
|
||||
const generatedAt = new Date().toISOString();
|
||||
|
||||
const cards: InsightCard[] = alerts.map((alert, idx) => ({
|
||||
id: `alert-${idx + 1}`,
|
||||
title: alert.title,
|
||||
summary: alert.message,
|
||||
body: null,
|
||||
severity: toSeverity(alert.level),
|
||||
recommendations: [],
|
||||
generatedAt,
|
||||
provider,
|
||||
}));
|
||||
|
||||
return apiSuccess(cards);
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import { getMedicationCategories } from "@/lib/medication-category";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -36,14 +37,9 @@ export const GET = apiHandler(async () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch all measurements (90 days)
|
||||
const types: MeasurementType[] = [
|
||||
"WEIGHT",
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
"PULSE",
|
||||
"BODY_FAT",
|
||||
];
|
||||
// Derived from canonical enum so adding a new measurement type does not
|
||||
// require touching this file (V3 audit finding: enum drift cousins).
|
||||
const types = [...measurementTypeEnum.options] as MeasurementType[];
|
||||
|
||||
const allMeasurements = await prisma.measurement.findMany({
|
||||
where: { userId, type: { in: types }, measuredAt: { gte: ninetyDaysAgo } },
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { auditLog: { create: vi.fn() } },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({ get: () => undefined, set: () => {}, delete: () => {} })),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
|
||||
function makeReq(): NextRequest {
|
||||
return new NextRequest("http://localhost/api/insights/correlations");
|
||||
}
|
||||
|
||||
describe("GET /api/insights/correlations", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns an empty array as the placeholder", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: unknown[] };
|
||||
expect(body.data).toEqual([]);
|
||||
});
|
||||
|
||||
it("writes the empty-state audit log", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
await callGet(makeReq());
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"insights.correlations.empty",
|
||||
expect.objectContaining({ userId: "user-1" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* GET /api/insights/correlations — placeholder iOS endpoint.
|
||||
*
|
||||
* The full correlations engine is part of `/api/insights/comprehensive`
|
||||
* and is intentionally not re-exposed yet — iOS only needs a stable
|
||||
* shape to render the empty state. We return `[]` and emit an audit log
|
||||
* so the gap is visible until Phase 7 fills it in.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "insights.correlations" } });
|
||||
|
||||
await auditLog("insights.correlations.empty", {
|
||||
userId: user.id,
|
||||
details: { reason: "phase7_pending" },
|
||||
});
|
||||
|
||||
return apiSuccess([]);
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn(), update: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({ get: () => undefined, set: () => {}, delete: () => {} })),
|
||||
}));
|
||||
|
||||
import { GET, PATCH } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
healthKitConfigJson: null,
|
||||
healthKitLastSyncedAt: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
|
||||
function makeGetReq(): NextRequest {
|
||||
return new NextRequest("http://localhost/api/integrations/healthkit");
|
||||
}
|
||||
|
||||
describe("GET /api/integrations/healthkit", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await callGet(makeGetReq());
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns the default entries when nothing is stored", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await callGet(makeGetReq());
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { entries: Array<{ id: string; direction: string }> };
|
||||
};
|
||||
expect(body.data.entries.length).toBeGreaterThan(0);
|
||||
const bodyMass = body.data.entries.find((e) => e.id === "bodyMass");
|
||||
expect(bodyMass?.direction).toBe("bidirectional");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/integrations/healthkit", () => {
|
||||
function req(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/integrations/healthkit", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await PATCH(req({ entries: [] }));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 422 for invalid direction", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await PATCH(
|
||||
req({ entries: [{ id: "bodyMass", direction: "FOO" }] }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("updates known entries and silently ignores unknown ids", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await PATCH(
|
||||
req({
|
||||
entries: [
|
||||
{ id: "bodyMass", direction: "readOnly" },
|
||||
{ id: "totallyUnknown", direction: "bidirectional" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.user.update).toHaveBeenCalledTimes(1);
|
||||
const updateArgs = vi.mocked(prisma.user.update).mock.calls[0][0] as unknown as {
|
||||
data: { healthKitConfigJson: { entries: Array<{ id: string }> } };
|
||||
};
|
||||
const ids = updateArgs.data.healthKitConfigJson.entries.map((e) => e.id);
|
||||
expect(ids).toContain("bodyMass");
|
||||
expect(ids).not.toContain("totallyUnknown");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* HealthKit (iOS) integration config.
|
||||
*
|
||||
* Persisted on `User.healthKitConfigJson` as `{ entries: [...] }`.
|
||||
* Each entry: `{ id, kind, direction, enabled }` where `direction` is one
|
||||
* of `bidirectional | readOnly | writeOnly`.
|
||||
*
|
||||
* GET → resolved config (with default entries when nothing is stored).
|
||||
* PATCH → merge by `id`; unknown ids are silently ignored.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, safeJson } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
export const directionEnum = z.enum([
|
||||
"bidirectional",
|
||||
"readOnly",
|
||||
"writeOnly",
|
||||
"disabled",
|
||||
]);
|
||||
|
||||
const entrySchema = z.object({
|
||||
id: z.string().min(1).max(64),
|
||||
kind: z.string().min(1).max(64).optional(),
|
||||
direction: directionEnum,
|
||||
enabled: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const patchSchema = z.object({
|
||||
entries: z.array(entrySchema).max(50),
|
||||
});
|
||||
|
||||
export interface HealthKitEntry {
|
||||
id: string;
|
||||
kind: string;
|
||||
direction: z.infer<typeof directionEnum>;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_HEALTHKIT_ENTRIES: HealthKitEntry[] = [
|
||||
{ id: "bodyMass", kind: "bodyMass", direction: "bidirectional", enabled: true },
|
||||
{ id: "bp", kind: "bloodPressure", direction: "bidirectional", enabled: true },
|
||||
{ id: "glucose", kind: "bloodGlucose", direction: "bidirectional", enabled: true },
|
||||
{ id: "heartRate", kind: "heartRate", direction: "readOnly", enabled: true },
|
||||
{ id: "stepCount", kind: "stepCount", direction: "readOnly", enabled: true },
|
||||
{ id: "sleep", kind: "sleepAnalysis", direction: "readOnly", enabled: true },
|
||||
];
|
||||
|
||||
interface StoredConfig {
|
||||
entries: HealthKitEntry[];
|
||||
}
|
||||
|
||||
function parseStored(json: unknown): StoredConfig | null {
|
||||
if (!json || typeof json !== "object") return null;
|
||||
const obj = json as Record<string, unknown>;
|
||||
const entries = obj.entries;
|
||||
if (!Array.isArray(entries)) return null;
|
||||
const out: HealthKitEntry[] = [];
|
||||
for (const raw of entries) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
const e = raw as Record<string, unknown>;
|
||||
const id = typeof e.id === "string" ? e.id : null;
|
||||
const kind = typeof e.kind === "string" ? e.kind : id;
|
||||
const directionRaw = typeof e.direction === "string" ? e.direction : null;
|
||||
if (!id || !kind || !directionRaw) continue;
|
||||
const dir = directionEnum.safeParse(directionRaw);
|
||||
if (!dir.success) continue;
|
||||
out.push({
|
||||
id,
|
||||
kind,
|
||||
direction: dir.data,
|
||||
enabled: typeof e.enabled === "boolean" ? e.enabled : true,
|
||||
});
|
||||
}
|
||||
return { entries: out };
|
||||
}
|
||||
|
||||
function mergeWithDefaults(stored: StoredConfig | null): HealthKitEntry[] {
|
||||
const map = new Map<string, HealthKitEntry>();
|
||||
for (const def of DEFAULT_HEALTHKIT_ENTRIES) {
|
||||
map.set(def.id, { ...def });
|
||||
}
|
||||
if (stored) {
|
||||
for (const entry of stored.entries) {
|
||||
const existing = map.get(entry.id);
|
||||
if (existing) {
|
||||
map.set(entry.id, { ...existing, ...entry });
|
||||
} else {
|
||||
// Surface custom keys the user has added.
|
||||
map.set(entry.id, { ...entry, enabled: entry.enabled ?? true });
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...map.values()];
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "integrations.healthkit.get" } });
|
||||
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { healthKitConfigJson: true, healthKitLastSyncedAt: true },
|
||||
});
|
||||
|
||||
const stored = parseStored(row?.healthKitConfigJson ?? null);
|
||||
const entries = mergeWithDefaults(stored);
|
||||
|
||||
return apiSuccess({
|
||||
entries,
|
||||
lastSyncedAt: row?.healthKitLastSyncedAt?.toISOString() ?? null,
|
||||
});
|
||||
});
|
||||
|
||||
export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { data: body, error } = await safeJson(request);
|
||||
if (error) return error;
|
||||
|
||||
const parsed = patchSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
}
|
||||
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { healthKitConfigJson: true },
|
||||
});
|
||||
const stored = parseStored(row?.healthKitConfigJson ?? null);
|
||||
|
||||
// Resolve known ids set: default ids + already-stored ids.
|
||||
const knownIds = new Set<string>(
|
||||
DEFAULT_HEALTHKIT_ENTRIES.map((e) => e.id),
|
||||
);
|
||||
if (stored) for (const e of stored.entries) knownIds.add(e.id);
|
||||
|
||||
// Merge by id; unknown ids are silently ignored per spec.
|
||||
const map = new Map<string, HealthKitEntry>();
|
||||
if (stored) for (const e of stored.entries) map.set(e.id, e);
|
||||
for (const update of parsed.data.entries) {
|
||||
if (!knownIds.has(update.id)) continue;
|
||||
const existing =
|
||||
map.get(update.id) ??
|
||||
DEFAULT_HEALTHKIT_ENTRIES.find((d) => d.id === update.id);
|
||||
map.set(update.id, {
|
||||
id: update.id,
|
||||
kind: update.kind ?? existing?.kind ?? update.id,
|
||||
direction: update.direction,
|
||||
enabled: update.enabled ?? existing?.enabled ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
const updatedConfig: StoredConfig = { entries: [...map.values()] };
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
healthKitConfigJson: updatedConfig as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog("integrations.healthkit.update", {
|
||||
userId: user.id,
|
||||
details: { count: updatedConfig.entries.length },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "integrations.healthkit.update" },
|
||||
meta: { count: updatedConfig.entries.length },
|
||||
});
|
||||
|
||||
// Return resolved config (defaults merged) so the client always sees a
|
||||
// complete entries list.
|
||||
const merged = mergeWithDefaults(updatedConfig);
|
||||
return apiSuccess({
|
||||
entries: merged,
|
||||
lastSyncedAt: null,
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { readMoodLogSecret } from "@/lib/moodlog-secret";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -23,11 +24,15 @@ export const GET = apiHandler(async () => {
|
||||
where: { userId: user.id },
|
||||
});
|
||||
|
||||
// V3 audit STILL-V2-C-2: stored secret is now AES-GCM encrypted at rest.
|
||||
// Decrypt for the user's settings page; legacy plaintext is also handled.
|
||||
const webhookSecret = readMoodLogSecret(dbUser?.moodLogWebhookSecret ?? null);
|
||||
|
||||
return apiSuccess({
|
||||
configured: Boolean(dbUser?.moodLogUrlEncrypted),
|
||||
enabled: dbUser?.moodLogEnabled ?? false,
|
||||
lastSyncedAt: dbUser?.moodLogLastSyncedAt ?? null,
|
||||
entryCount,
|
||||
webhookSecret: dbUser?.moodLogWebhookSecret ?? null,
|
||||
webhookSecret,
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { apiError } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { getClientIp } from "@/lib/api-response";
|
||||
import { moodLogWebhookPayloadSchema } from "@/lib/validations/moodlog";
|
||||
import { readMoodLogSecret } from "@/lib/moodlog-secret";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
|
||||
@@ -44,10 +45,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
select: { id: true, moodLogWebhookSecret: true },
|
||||
});
|
||||
|
||||
// V3 audit STILL-V2-C-2: stored secret is now encrypted at rest. Decrypt
|
||||
// each candidate before the timing-safe compare. `readMoodLogSecret`
|
||||
// also tolerates legacy plaintext rows during the transition window.
|
||||
const receivedBuf = Buffer.from(webhookSecret, "utf8");
|
||||
const user = candidates.find((c) => {
|
||||
if (!c.moodLogWebhookSecret) return false;
|
||||
const expectedBuf = Buffer.from(c.moodLogWebhookSecret, "utf8");
|
||||
const expected = readMoodLogSecret(c.moodLogWebhookSecret);
|
||||
if (!expected) return false;
|
||||
const expectedBuf = Buffer.from(expected, "utf8");
|
||||
if (expectedBuf.length !== receivedBuf.length) return false;
|
||||
return timingSafeEqual(expectedBuf, receivedBuf);
|
||||
});
|
||||
|
||||
@@ -2,13 +2,19 @@ import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import {
|
||||
createMeasurementSchema,
|
||||
createBatchMeasurementSchema,
|
||||
listMeasurementsSchema,
|
||||
getUnitForType,
|
||||
} from "@/lib/validations/measurement";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { NextRequest } from "next/server";
|
||||
import type {
|
||||
MeasurementType,
|
||||
@@ -59,7 +65,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
});
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postMeasurement));
|
||||
|
||||
async function postMeasurement(request: NextRequest) {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
@@ -138,7 +146,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") {
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === "P2002"
|
||||
) {
|
||||
return apiError("A measurement with this data already exists", 409);
|
||||
}
|
||||
throw err;
|
||||
@@ -159,4 +170,4 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
|
||||
return apiSuccess(measurement, 201);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { measurement: { findMany: vi.fn() } },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({ get: () => undefined, set: () => {}, delete: () => {} })),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
};
|
||||
|
||||
function req(query: string): NextRequest {
|
||||
return new NextRequest(`http://localhost/api/measurements/series?${query}`);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
});
|
||||
|
||||
describe("GET /api/measurements/series", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await GET(req("kind=weight"));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 422 for invalid kind", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await GET(req("kind=garbage"));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("returns paired BP series with sys + dia", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const t = new Date("2026-05-01T10:00:00.000Z");
|
||||
const findManyMock = vi.mocked(prisma.measurement.findMany);
|
||||
findManyMock.mockImplementation(((args: unknown) => {
|
||||
const a = args as { where: { type: string } };
|
||||
if (a.where.type === "BLOOD_PRESSURE_SYS") {
|
||||
return Promise.resolve([
|
||||
{ id: "s1", value: 126, measuredAt: t },
|
||||
]) as never;
|
||||
}
|
||||
if (a.where.type === "BLOOD_PRESSURE_DIA") {
|
||||
return Promise.resolve([
|
||||
{
|
||||
id: "d1",
|
||||
value: 82,
|
||||
measuredAt: new Date(t.getTime() + 60_000),
|
||||
},
|
||||
]) as never;
|
||||
}
|
||||
return Promise.resolve([]) as never;
|
||||
}) as never);
|
||||
const res = await GET(req("kind=bloodPressure&days=30"));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
kind: string;
|
||||
points: Array<{ value: number; secondary: number | null }>;
|
||||
stats: { count: number };
|
||||
};
|
||||
};
|
||||
expect(body.data.kind).toBe("bloodPressure");
|
||||
expect(body.data.points).toHaveLength(1);
|
||||
expect(body.data.points[0].value).toBe(126);
|
||||
expect(body.data.points[0].secondary).toBe(82);
|
||||
expect(body.data.stats.count).toBe(1);
|
||||
});
|
||||
|
||||
it("returns single-value series for kind=weight", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{ id: "w1", value: 78.4, measuredAt: new Date() },
|
||||
] as never);
|
||||
const res = await GET(req("kind=weight&days=7"));
|
||||
const body = (await res.json()) as {
|
||||
data: { points: Array<{ secondary: number | null }> };
|
||||
};
|
||||
expect(body.data.points[0].secondary).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* GET /api/measurements/series?kind=&days=
|
||||
*
|
||||
* iOS-friendly time-series adapter. Maps the camelCase `kind` to the
|
||||
* canonical Prisma `MeasurementType`(s) and returns
|
||||
* { kind, points: [{ id, at, value, secondary }], stats }
|
||||
*
|
||||
* The `kind=bloodPressure` case pairs systolic + diastolic by
|
||||
* `measuredAt` so iOS can render the dual-line chart with one fetch.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { summarize, type DataPoint } from "@/lib/analytics/trends";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
|
||||
const kindEnum = z.enum([
|
||||
"weight",
|
||||
"bloodPressure",
|
||||
"pulse",
|
||||
"bodyFat",
|
||||
"glucose",
|
||||
"sleep",
|
||||
"steps",
|
||||
"totalBodyWater",
|
||||
"boneMass",
|
||||
"oxygenSaturation",
|
||||
]);
|
||||
|
||||
const querySchema = z.object({
|
||||
kind: kindEnum,
|
||||
days: z.coerce.number().int().min(1).max(365).optional().default(30),
|
||||
});
|
||||
|
||||
const KIND_TO_TYPE: Record<z.infer<typeof kindEnum>, MeasurementType> = {
|
||||
weight: "WEIGHT",
|
||||
bloodPressure: "BLOOD_PRESSURE_SYS",
|
||||
pulse: "PULSE",
|
||||
bodyFat: "BODY_FAT",
|
||||
glucose: "BLOOD_GLUCOSE",
|
||||
sleep: "SLEEP_DURATION",
|
||||
steps: "ACTIVITY_STEPS",
|
||||
totalBodyWater: "TOTAL_BODY_WATER",
|
||||
boneMass: "BONE_MASS",
|
||||
oxygenSaturation: "OXYGEN_SATURATION",
|
||||
};
|
||||
|
||||
interface SeriesPoint {
|
||||
id: string;
|
||||
at: string;
|
||||
value: number;
|
||||
secondary: number | null;
|
||||
}
|
||||
|
||||
function stdDev(values: number[]): number {
|
||||
if (values.length < 2) return 0;
|
||||
const mean = values.reduce((sum, v) => sum + v, 0) / values.length;
|
||||
const variance =
|
||||
values.reduce((sum, v) => sum + (v - mean) ** 2, 0) / values.length;
|
||||
return Math.round(Math.sqrt(variance) * 100) / 100;
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const parsed = querySchema.safeParse(
|
||||
Object.fromEntries(request.nextUrl.searchParams),
|
||||
);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
}
|
||||
|
||||
const { kind, days } = parsed.data;
|
||||
const since = new Date(Date.now() - days * 86_400_000);
|
||||
|
||||
let points: SeriesPoint[] = [];
|
||||
if (kind === "bloodPressure") {
|
||||
const [sys, dia] = await Promise.all([
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
measuredAt: { gte: since },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { id: true, value: true, measuredAt: true },
|
||||
}),
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
type: "BLOOD_PRESSURE_DIA",
|
||||
measuredAt: { gte: since },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { id: true, value: true, measuredAt: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
// Pair by closest timestamp within ±5 minutes.
|
||||
const PAIR_WINDOW_MS = 5 * 60_000;
|
||||
points = sys.map((s) => {
|
||||
let bestDia: { value: number; dt: number } | null = null;
|
||||
for (const d of dia) {
|
||||
const dt = Math.abs(d.measuredAt.getTime() - s.measuredAt.getTime());
|
||||
if (dt > PAIR_WINDOW_MS) continue;
|
||||
if (!bestDia || dt < bestDia.dt) {
|
||||
bestDia = { value: d.value, dt };
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: s.id,
|
||||
at: s.measuredAt.toISOString(),
|
||||
value: s.value,
|
||||
secondary: bestDia?.value ?? null,
|
||||
};
|
||||
});
|
||||
} else {
|
||||
const type = KIND_TO_TYPE[kind];
|
||||
const rows = await prisma.measurement.findMany({
|
||||
where: { userId: user.id, type, measuredAt: { gte: since } },
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { id: true, value: true, measuredAt: true },
|
||||
});
|
||||
points = rows.map((r) => ({
|
||||
id: r.id,
|
||||
at: r.measuredAt.toISOString(),
|
||||
value: r.value,
|
||||
secondary: null,
|
||||
}));
|
||||
}
|
||||
|
||||
const dataPoints: DataPoint[] = points.map((p) => ({
|
||||
date: new Date(p.at),
|
||||
value: p.value,
|
||||
}));
|
||||
const summary = dataPoints.length > 0 ? summarize(dataPoints) : null;
|
||||
const values = dataPoints.map((p) => p.value);
|
||||
|
||||
annotate({
|
||||
action: { name: "measurements.series" },
|
||||
meta: { kind, days, count: points.length },
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
kind,
|
||||
points,
|
||||
stats: summary
|
||||
? {
|
||||
mean: summary.mean,
|
||||
min: summary.min,
|
||||
max: summary.max,
|
||||
stdDev: stdDev(values),
|
||||
count: summary.count,
|
||||
}
|
||||
: { mean: 0, min: 0, max: 0, stdDev: 0, count: 0 },
|
||||
});
|
||||
});
|
||||
@@ -2,16 +2,26 @@ import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import {
|
||||
intakeSchema,
|
||||
listIntakeEventsSchema,
|
||||
} from "@/lib/validations/medication";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest, { params }: RouteParams) => {
|
||||
export const POST = apiHandler(
|
||||
withIdempotency<[NextRequest, RouteParams]>(postIntake),
|
||||
);
|
||||
|
||||
async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { id } = await params;
|
||||
@@ -100,40 +110,42 @@ export const POST = apiHandler(async (request: NextRequest, { params }: RoutePar
|
||||
});
|
||||
|
||||
return apiSuccess(event, 201);
|
||||
});
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
export const GET = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { id } = await params;
|
||||
const medication = await prisma.medication.findUnique({ where: { id } });
|
||||
if (!medication || medication.userId !== user.id) {
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
const { id } = await params;
|
||||
const medication = await prisma.medication.findUnique({ where: { id } });
|
||||
if (!medication || medication.userId !== user.id) {
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
|
||||
const searchParams = Object.fromEntries(request.nextUrl.searchParams);
|
||||
const parsed = listIntakeEventsSchema.safeParse(searchParams);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
}
|
||||
const searchParams = Object.fromEntries(request.nextUrl.searchParams);
|
||||
const parsed = listIntakeEventsSchema.safeParse(searchParams);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
}
|
||||
|
||||
const { limit, offset, sortBy, sortDir } = parsed.data;
|
||||
const where = { medicationId: id, userId: user.id };
|
||||
const { limit, offset, sortBy, sortDir } = parsed.data;
|
||||
const where = { medicationId: id, userId: user.id };
|
||||
|
||||
const [events, total] = await Promise.all([
|
||||
prisma.medicationIntakeEvent.findMany({
|
||||
where,
|
||||
orderBy: { [sortBy]: sortDir },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
}),
|
||||
prisma.medicationIntakeEvent.count({ where }),
|
||||
]);
|
||||
const [events, total] = await Promise.all([
|
||||
prisma.medicationIntakeEvent.findMany({
|
||||
where,
|
||||
orderBy: { [sortBy]: sortDir },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
}),
|
||||
prisma.medicationIntakeEvent.count({ where }),
|
||||
]);
|
||||
|
||||
annotate({
|
||||
action: { name: "medication.intake.list" },
|
||||
meta: { medication_id: id, total },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "medication.intake.list" },
|
||||
meta: { medication_id: id, total },
|
||||
});
|
||||
|
||||
return apiSuccess({ events, meta: { total, limit, offset } });
|
||||
});
|
||||
return apiSuccess({ events, meta: { total, limit, offset } });
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,160 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
medicationIntakeEvent: {
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
medication: { update: vi.fn() },
|
||||
$transaction: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({ get: () => undefined, set: () => {}, delete: () => {} })),
|
||||
}));
|
||||
|
||||
import { GET, POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([] as never);
|
||||
});
|
||||
|
||||
describe("GET /api/medications/intake", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/medications/intake?scope=today"),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 422 for invalid scope", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/medications/intake?scope=junk"),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("returns today's events as a flat array", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "e1",
|
||||
medicationId: "m1",
|
||||
scheduledFor: new Date(),
|
||||
takenAt: null,
|
||||
skipped: false,
|
||||
medication: { id: "m1", snoozedUntil: null },
|
||||
},
|
||||
] as never);
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/medications/intake?scope=today"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: Array<{ id: string; status: string }>;
|
||||
};
|
||||
expect(body.data).toHaveLength(1);
|
||||
expect(body.data[0].status).toBe("pending");
|
||||
});
|
||||
|
||||
it("returns compliance buckets for the last N days", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
|
||||
{
|
||||
scheduledFor: new Date(),
|
||||
takenAt: new Date(),
|
||||
skipped: false,
|
||||
},
|
||||
] as never);
|
||||
const res = await GET(
|
||||
new NextRequest(
|
||||
"http://localhost/api/medications/intake?scope=compliance&days=7",
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: Array<{ date: string; scheduled: number; taken: number }>;
|
||||
};
|
||||
expect(body.data.length).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/medications/intake", () => {
|
||||
function req(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/medications/intake", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await POST(req({ intakeId: "e1", status: "taken" }));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 404 when the event isn't owned by the user", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findUnique).mockResolvedValue({
|
||||
id: "e1",
|
||||
userId: "someone-else",
|
||||
medicationId: "m1",
|
||||
} as never);
|
||||
const res = await POST(req({ intakeId: "e1", status: "taken" }));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 422 for invalid status", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await POST(req({ intakeId: "e1", status: "broken" }));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("marks event as skipped", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findUnique).mockResolvedValue({
|
||||
id: "e1",
|
||||
userId: "user-1",
|
||||
medicationId: "m1",
|
||||
} as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValue({
|
||||
id: "e1",
|
||||
skipped: true,
|
||||
takenAt: null,
|
||||
} as never);
|
||||
const res = await POST(req({ intakeId: "e1", status: "skipped" }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledWith({
|
||||
where: { id: "e1" },
|
||||
data: { takenAt: null, skipped: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Top-level medication-intake aggregator + writer.
|
||||
*
|
||||
* GET /api/medications/intake?scope=today
|
||||
* → array of today's intake events for the user (across medications).
|
||||
*
|
||||
* GET /api/medications/intake?scope=compliance&days=N
|
||||
* → per-day { date, scheduled, taken } for the last N days.
|
||||
*
|
||||
* POST /api/medications/intake
|
||||
* Body: { intakeId, status: "taken" | "skipped" | "snoozed", takenAt?, snoozedUntil? }
|
||||
* Updates the named MedicationIntakeEvent and returns the updated row.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp, safeJson } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
const querySchema = z.object({
|
||||
scope: z.enum(["today", "compliance"]),
|
||||
days: z.coerce.number().int().min(1).max(365).optional().default(30),
|
||||
});
|
||||
|
||||
const updateSchema = z.object({
|
||||
intakeId: z.string().min(1),
|
||||
status: z.enum(["taken", "skipped", "snoozed"]),
|
||||
takenAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.transform((s) => new Date(s))
|
||||
.optional(),
|
||||
snoozedUntil: z.iso
|
||||
.datetime({ offset: true })
|
||||
.transform((s) => new Date(s))
|
||||
.optional(),
|
||||
});
|
||||
|
||||
function startOfDayBerlin(date: Date): Date {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: "Europe/Berlin",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).formatToParts(date);
|
||||
const y = parts.find((p) => p.type === "year")?.value ?? "1970";
|
||||
const m = parts.find((p) => p.type === "month")?.value ?? "01";
|
||||
const d = parts.find((p) => p.type === "day")?.value ?? "01";
|
||||
return new Date(`${y}-${m}-${d}T00:00:00.000Z`);
|
||||
}
|
||||
|
||||
function berlinDayKey(date: Date): string {
|
||||
return new Intl.DateTimeFormat("sv-SE", {
|
||||
timeZone: "Europe/Berlin",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const parsed = querySchema.safeParse(
|
||||
Object.fromEntries(request.nextUrl.searchParams),
|
||||
);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
}
|
||||
|
||||
const { scope, days } = parsed.data;
|
||||
|
||||
if (scope === "today") {
|
||||
const todayStart = startOfDayBerlin(new Date());
|
||||
const todayEnd = new Date(todayStart.getTime() + 86_400_000);
|
||||
const events = await prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
scheduledFor: { gte: todayStart, lt: todayEnd },
|
||||
},
|
||||
orderBy: { scheduledFor: "asc" },
|
||||
include: { medication: { select: { id: true, snoozedUntil: true } } },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "medications.intake.today" },
|
||||
meta: { count: events.length },
|
||||
});
|
||||
|
||||
return apiSuccess(
|
||||
events.map((e) => ({
|
||||
id: e.id,
|
||||
medicationId: e.medicationId,
|
||||
scheduledAt: e.scheduledFor.toISOString(),
|
||||
takenAt: e.takenAt?.toISOString() ?? null,
|
||||
status: e.skipped
|
||||
? "skipped"
|
||||
: e.takenAt
|
||||
? "taken"
|
||||
: e.medication.snoozedUntil &&
|
||||
e.medication.snoozedUntil > new Date()
|
||||
? "snoozed"
|
||||
: "pending",
|
||||
snoozedUntil: e.medication.snoozedUntil?.toISOString() ?? null,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
// compliance: per-day scheduled vs taken for the last N days
|
||||
const start = new Date(Date.now() - days * 86_400_000);
|
||||
const events = await prisma.medicationIntakeEvent.findMany({
|
||||
where: { userId: user.id, scheduledFor: { gte: start } },
|
||||
select: { scheduledFor: true, takenAt: true, skipped: true },
|
||||
});
|
||||
|
||||
const buckets = new Map<string, { scheduled: number; taken: number }>();
|
||||
for (let i = 0; i < days; i++) {
|
||||
const d = new Date(Date.now() - i * 86_400_000);
|
||||
buckets.set(berlinDayKey(d), { scheduled: 0, taken: 0 });
|
||||
}
|
||||
for (const e of events) {
|
||||
const key = berlinDayKey(e.scheduledFor);
|
||||
const bucket = buckets.get(key);
|
||||
if (!bucket) continue;
|
||||
bucket.scheduled += 1;
|
||||
if (e.takenAt && !e.skipped) bucket.taken += 1;
|
||||
}
|
||||
|
||||
const result = [...buckets.entries()]
|
||||
.map(([date, v]) => ({ date, scheduled: v.scheduled, taken: v.taken }))
|
||||
.sort((a, b) => a.date.localeCompare(b.date));
|
||||
|
||||
annotate({
|
||||
action: { name: "medications.intake.compliance" },
|
||||
meta: { days, count: result.length },
|
||||
});
|
||||
|
||||
return apiSuccess(result);
|
||||
});
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { data: body, error } = await safeJson(request);
|
||||
if (error) return error;
|
||||
|
||||
const parsed = updateSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
}
|
||||
|
||||
const { intakeId, status, takenAt, snoozedUntil } = parsed.data;
|
||||
|
||||
const existing = await prisma.medicationIntakeEvent.findUnique({
|
||||
where: { id: intakeId },
|
||||
});
|
||||
if (!existing || existing.userId !== user.id) {
|
||||
return apiError("Intake event not found", 404);
|
||||
}
|
||||
|
||||
let updated;
|
||||
if (status === "taken") {
|
||||
[updated] = await prisma.$transaction([
|
||||
prisma.medicationIntakeEvent.update({
|
||||
where: { id: intakeId },
|
||||
data: { takenAt: takenAt ?? new Date(), skipped: false },
|
||||
}),
|
||||
prisma.medication.update({
|
||||
where: { id: existing.medicationId },
|
||||
data: { snoozedUntil: null },
|
||||
}),
|
||||
]);
|
||||
} else if (status === "skipped") {
|
||||
updated = await prisma.medicationIntakeEvent.update({
|
||||
where: { id: intakeId },
|
||||
data: { takenAt: null, skipped: true },
|
||||
});
|
||||
} else {
|
||||
// snoozed: snoozedUntil lives on the Medication row.
|
||||
const until =
|
||||
snoozedUntil ?? new Date(Date.now() + 30 * 60_000); // default +30min
|
||||
await prisma.medication.update({
|
||||
where: { id: existing.medicationId },
|
||||
data: { snoozedUntil: until },
|
||||
});
|
||||
updated = await prisma.medicationIntakeEvent.findUnique({
|
||||
where: { id: intakeId },
|
||||
});
|
||||
}
|
||||
|
||||
await auditLog("medications.intake.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { intakeId, status },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "medications.intake.update" },
|
||||
meta: { intakeId, status },
|
||||
});
|
||||
|
||||
return apiSuccess(updated);
|
||||
});
|
||||
@@ -1,6 +1,11 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import {
|
||||
createMoodEntrySchema,
|
||||
listMoodEntriesSchema,
|
||||
@@ -10,6 +15,7 @@ import { NextRequest } from "next/server";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
|
||||
function toBerlinDate(date: Date): string {
|
||||
return new Intl.DateTimeFormat("sv-SE", {
|
||||
@@ -60,7 +66,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
prisma.moodEntry.count({ where }),
|
||||
]);
|
||||
|
||||
annotate({ action: { name: "mood-entries.list" }, meta: { total, limit, offset } });
|
||||
annotate({
|
||||
action: { name: "mood-entries.list" },
|
||||
meta: { total, limit, offset },
|
||||
});
|
||||
|
||||
const entriesWithParsedTags = entries.map((e) => ({
|
||||
...e,
|
||||
@@ -73,7 +82,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
});
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postMoodEntry));
|
||||
|
||||
async function postMoodEntry(request: NextRequest) {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
@@ -107,16 +118,19 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
details: { moodEntryId: entry.id, mood },
|
||||
});
|
||||
|
||||
annotate({ action: { name: "mood-entries.create" }, meta: { moodEntryId: entry.id, mood } });
|
||||
annotate({
|
||||
action: { name: "mood-entries.create" },
|
||||
meta: { moodEntryId: entry.id, mood },
|
||||
});
|
||||
|
||||
return apiSuccess({ ...entry, tags: parseTags(entry.tags) }, 201);
|
||||
} catch (err) {
|
||||
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") {
|
||||
return apiError(
|
||||
"A mood entry with this data already exists",
|
||||
409,
|
||||
);
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === "P2002"
|
||||
) {
|
||||
return apiError("A mood entry with this data already exists", 409);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -44,7 +44,9 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
const userId = user.id;
|
||||
const username = user.username;
|
||||
|
||||
// Log before deletion (userId will be SetNull in audit_logs after cascade)
|
||||
// Log BEFORE deletion. The audit row's userId is SetNull post-cascade
|
||||
// (per schema), but we then immediately purge it below for GDPR Art. 17
|
||||
// erasure completeness — see comment further down.
|
||||
await auditLog("user.account.delete", {
|
||||
userId,
|
||||
ipAddress: getClientIp(request),
|
||||
@@ -54,7 +56,15 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
// Destroy all sessions first
|
||||
await destroyAllSessions(userId);
|
||||
|
||||
// Delete user — all related data is removed via onDelete: Cascade
|
||||
// Audit V3 NEW-V3-2 / GDPR Art. 17 fix: Feedback and AuditLog rows have
|
||||
// `onDelete: SetNull` in the schema, which keeps PII (free-text symptom
|
||||
// descriptions, IP addresses, login city geo) attached to the record after
|
||||
// the user is deleted. We explicitly purge them inside the same logical
|
||||
// operation so account deletion is genuinely complete erasure.
|
||||
await prisma.feedback.deleteMany({ where: { userId } });
|
||||
await prisma.auditLog.deleteMany({ where: { userId } });
|
||||
|
||||
// Delete user — all other related data is removed via onDelete: Cascade
|
||||
await prisma.user.delete({ where: { id: userId } });
|
||||
|
||||
annotate({
|
||||
|
||||
@@ -3,6 +3,10 @@ import { randomBytes } from "node:crypto";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import {
|
||||
encryptMoodLogSecret,
|
||||
readMoodLogSecret,
|
||||
} from "@/lib/moodlog-secret";
|
||||
import { moodLogCredentialsSchema } from "@/lib/validations/moodlog";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
@@ -26,14 +30,16 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const { url, apiKey } = parsed.data;
|
||||
|
||||
// Generate webhook secret if not yet set
|
||||
// Generate webhook secret if not yet set; otherwise reuse the existing
|
||||
// one (decrypted from at-rest storage; legacy plaintext is also handled).
|
||||
const existing = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { moodLogWebhookSecret: true },
|
||||
});
|
||||
|
||||
const webhookSecret =
|
||||
existing?.moodLogWebhookSecret ?? `mb_${randomBytes(32).toString("hex")}`;
|
||||
readMoodLogSecret(existing?.moodLogWebhookSecret ?? null) ??
|
||||
`mb_${randomBytes(32).toString("hex")}`;
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
@@ -41,7 +47,10 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
moodLogUrlEncrypted: encrypt(url),
|
||||
moodLogApiKeyEncrypted: encrypt(apiKey),
|
||||
moodLogEnabled: true,
|
||||
moodLogWebhookSecret: webhookSecret,
|
||||
// V3 audit STILL-V2-C-2: encrypt at rest with AES-256-GCM. A legacy
|
||||
// plaintext value (rare) is rotated to the encrypted form on this
|
||||
// write transparently.
|
||||
moodLogWebhookSecret: encryptMoodLogSecret(webhookSecret),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn(), update: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({ get: () => undefined, set: () => {}, delete: () => {} })),
|
||||
}));
|
||||
|
||||
import { GET, PATCH } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
|
||||
function makeGetReq(): NextRequest {
|
||||
return new NextRequest("http://localhost/api/user/profile");
|
||||
}
|
||||
|
||||
describe("GET /api/user/profile", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await callGet(makeGetReq());
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns flattened iOS-style fields", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
username: "marc",
|
||||
displayName: "Marc B.",
|
||||
email: "marc@example.com",
|
||||
dateOfBirth: new Date("1985-03-12T00:00:00.000Z"),
|
||||
gender: "MALE",
|
||||
heightCm: 180,
|
||||
locale: "de",
|
||||
timezone: "Europe/Berlin",
|
||||
} as never);
|
||||
|
||||
const res = await callGet(makeGetReq());
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
username: string;
|
||||
displayName: string | null;
|
||||
heightCm: number;
|
||||
locale: string | null;
|
||||
};
|
||||
};
|
||||
expect(body.data.username).toBe("marc");
|
||||
expect(body.data.displayName).toBe("Marc B.");
|
||||
expect(body.data.heightCm).toBe(180);
|
||||
expect(body.data.locale).toBe("de");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/user/profile", () => {
|
||||
function req(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/user/profile", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await PATCH(req({ displayName: "Test" }));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 422 for invalid heightCm", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await PATCH(req({ heightCm: 9999 }));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("persists displayName + locale via shared helper", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({
|
||||
id: "user-1",
|
||||
username: "marc",
|
||||
displayName: "Marc B.",
|
||||
email: null,
|
||||
role: "USER",
|
||||
heightCm: null,
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
timezone: "Europe/Berlin",
|
||||
locale: "de",
|
||||
} as never);
|
||||
|
||||
const res = await PATCH(req({ displayName: "Marc B.", locale: "de" }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.user.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: "user-1" },
|
||||
data: expect.objectContaining({
|
||||
displayName: "Marc B.",
|
||||
locale: "de",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* iOS-friendly user profile endpoint.
|
||||
*
|
||||
* GET → flattened profile fields (camelCase) for the native client.
|
||||
* Aliased over the same data exposed by `/api/auth/me`.
|
||||
* PATCH → delegates to the shared `applyProfileUpdate` helper used by
|
||||
* `/api/auth/profile` PUT — same validation, same audit log.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp, safeJson } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { applyProfileUpdate } from "@/lib/auth/profile-update";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "user.profile.get" } });
|
||||
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
username: true,
|
||||
displayName: true,
|
||||
email: true,
|
||||
dateOfBirth: true,
|
||||
gender: true,
|
||||
heightCm: true,
|
||||
locale: true,
|
||||
timezone: true,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
username: dbUser?.username ?? user.username,
|
||||
displayName: dbUser?.displayName ?? null,
|
||||
email: dbUser?.email ?? null,
|
||||
dateOfBirth: dbUser?.dateOfBirth?.toISOString() ?? null,
|
||||
gender: dbUser?.gender ?? null,
|
||||
heightCm: dbUser?.heightCm ?? null,
|
||||
locale: dbUser?.locale ?? null,
|
||||
timezone: dbUser?.timezone ?? "Europe/Berlin",
|
||||
});
|
||||
});
|
||||
|
||||
export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { data: body, error } = await safeJson(request);
|
||||
if (error) return error;
|
||||
|
||||
const result = await applyProfileUpdate(user.id, body, getClientIp(request));
|
||||
if (!result.ok) {
|
||||
return apiError(result.message, result.status);
|
||||
}
|
||||
|
||||
annotate({ action: { name: "user.profile.update" } });
|
||||
|
||||
return apiSuccess({
|
||||
username: result.user.username,
|
||||
displayName: result.user.displayName,
|
||||
email: result.user.email,
|
||||
dateOfBirth: result.user.dateOfBirth?.toISOString() ?? null,
|
||||
gender: result.user.gender,
|
||||
heightCm: result.user.heightCm,
|
||||
locale: result.user.locale,
|
||||
timezone: result.user.timezone,
|
||||
});
|
||||
});
|
||||
@@ -7,14 +7,34 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
|
||||
/**
|
||||
* Audit C-3 / phase P2: Withings legacy callback URLs include the shared
|
||||
* secret as a `?secret=…` query param, which leaks into reverse-proxy
|
||||
* access logs and any error-tracking pipeline that captures `request.url`
|
||||
* (see `reportToGlitchtip`). New deployments should configure Withings to
|
||||
* send the secret via the `X-Withings-Webhook-Secret` header instead.
|
||||
*
|
||||
* The query-param fallback stays for backwards compatibility with existing
|
||||
* Withings configurations; remove once all integrators have migrated.
|
||||
*/
|
||||
function hasValidWebhookSecret(request: NextRequest): boolean {
|
||||
const expected = process.env.WITHINGS_WEBHOOK_SECRET;
|
||||
if (!expected) {
|
||||
getEvent()?.addWarning("WITHINGS_WEBHOOK_SECRET not configured");
|
||||
return false;
|
||||
}
|
||||
const received = request.nextUrl.searchParams.get("secret");
|
||||
|
||||
const fromHeader = request.headers.get("x-withings-webhook-secret");
|
||||
const fromQuery = request.nextUrl.searchParams.get("secret");
|
||||
const received = fromHeader ?? fromQuery;
|
||||
if (!received) return false;
|
||||
|
||||
if (!fromHeader && fromQuery) {
|
||||
getEvent()?.addWarning(
|
||||
"withings webhook secret received via legacy URL query — migrate to X-Withings-Webhook-Secret header",
|
||||
);
|
||||
}
|
||||
|
||||
const a = Buffer.from(expected, "utf8");
|
||||
const b = Buffer.from(received, "utf8");
|
||||
if (a.length !== b.length) return false;
|
||||
|
||||
@@ -106,6 +106,9 @@ const BASE_TYPE_LABEL_KEYS: Record<string, string> = {
|
||||
SLEEP_DURATION: "charts.sleep",
|
||||
ACTIVITY_STEPS: "charts.steps",
|
||||
BLOOD_GLUCOSE: "measurements.typeBloodGlucose",
|
||||
TOTAL_BODY_WATER: "charts.bodyWater",
|
||||
BONE_MASS: "charts.boneMass",
|
||||
OXYGEN_SATURATION: "charts.spo2",
|
||||
};
|
||||
|
||||
function getTypeLabel(
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||||
import {
|
||||
MEASUREMENT_TYPE_LABEL_KEYS,
|
||||
MEASUREMENT_TYPE_ICONS,
|
||||
MEASUREMENT_TYPE_COLORS,
|
||||
} from "../measurement-list-meta";
|
||||
import enMessages from "../../../../messages/en.json";
|
||||
import deMessages from "../../../../messages/de.json";
|
||||
|
||||
/**
|
||||
* Issue #109 root cause: measurement-list maps drifted from the canonical
|
||||
* measurementTypeEnum. New enum values silently fell through to the raw
|
||||
* string fallback. These tests fail fast if a new enum value is added
|
||||
* without a corresponding entry in every list-UI map.
|
||||
*/
|
||||
describe("measurement-list-meta", () => {
|
||||
const allTypes = [...measurementTypeEnum.options].sort();
|
||||
|
||||
it("MEASUREMENT_TYPE_LABEL_KEYS covers every measurement type", () => {
|
||||
expect(Object.keys(MEASUREMENT_TYPE_LABEL_KEYS).sort()).toEqual(allTypes);
|
||||
});
|
||||
|
||||
it("MEASUREMENT_TYPE_ICONS covers every measurement type", () => {
|
||||
expect(Object.keys(MEASUREMENT_TYPE_ICONS).sort()).toEqual(allTypes);
|
||||
});
|
||||
|
||||
it("MEASUREMENT_TYPE_COLORS covers every measurement type", () => {
|
||||
expect(Object.keys(MEASUREMENT_TYPE_COLORS).sort()).toEqual(allTypes);
|
||||
});
|
||||
|
||||
it("every label key resolves in both English and German locales", () => {
|
||||
type Bag = { measurements?: Record<string, string> };
|
||||
const en = (enMessages as Bag).measurements ?? {};
|
||||
const de = (deMessages as Bag).measurements ?? {};
|
||||
|
||||
for (const [type, key] of Object.entries(MEASUREMENT_TYPE_LABEL_KEYS)) {
|
||||
const leaf = key.replace(/^measurements\./, "");
|
||||
expect(en[leaf], `EN missing ${key} for ${type}`).toBeTruthy();
|
||||
expect(de[leaf], `DE missing ${key} for ${type}`).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -79,6 +79,12 @@ const MEASUREMENT_TYPES = [
|
||||
unit: "kg",
|
||||
placeholder: "3.2",
|
||||
},
|
||||
{
|
||||
value: "OXYGEN_SATURATION",
|
||||
labelKey: "measurements.typeOxygenSaturation",
|
||||
unit: "%",
|
||||
placeholder: "98",
|
||||
},
|
||||
] as const;
|
||||
|
||||
const GLUCOSE_CONTEXTS = [
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Per-measurement-type display metadata for the measurements list view.
|
||||
*
|
||||
* Extracted into its own module so we can:
|
||||
* 1. Run a coverage test that asserts every Zod-enum measurement type has
|
||||
* an entry in every map (so future enum additions can't silently fall
|
||||
* through to the raw-string fallback — which was the root cause of
|
||||
* issue #109).
|
||||
* 2. Reuse the same icon/color set in adjacent surfaces (mobile list,
|
||||
* edit dialog) without re-declaring it.
|
||||
*
|
||||
* Lead-architect note: this is the first step toward a single
|
||||
* `metrics.json` manifest the entire ecosystem derives from. Today this
|
||||
* module covers list-UI; phase P5 will subsume it into a cross-repo
|
||||
* manifest.
|
||||
*/
|
||||
import {
|
||||
Scale,
|
||||
Heart,
|
||||
Activity,
|
||||
Droplets,
|
||||
Droplet,
|
||||
Moon,
|
||||
Footprints,
|
||||
Bone,
|
||||
Wind,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
export const MEASUREMENT_TYPE_LABEL_KEYS: Record<string, string> = {
|
||||
WEIGHT: "measurements.typeWeight",
|
||||
BLOOD_PRESSURE_SYS: "measurements.typeBpSys",
|
||||
BLOOD_PRESSURE_DIA: "measurements.typeBpDia",
|
||||
PULSE: "measurements.typePulse",
|
||||
BODY_FAT: "measurements.typeBodyFat",
|
||||
SLEEP_DURATION: "measurements.typeSleep",
|
||||
ACTIVITY_STEPS: "measurements.typeSteps",
|
||||
BLOOD_GLUCOSE: "measurements.typeBloodGlucose",
|
||||
TOTAL_BODY_WATER: "measurements.typeTotalBodyWater",
|
||||
BONE_MASS: "measurements.typeBoneMass",
|
||||
OXYGEN_SATURATION: "measurements.typeOxygenSaturation",
|
||||
};
|
||||
|
||||
export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
|
||||
WEIGHT: Scale,
|
||||
BLOOD_PRESSURE_SYS: Heart,
|
||||
BLOOD_PRESSURE_DIA: Heart,
|
||||
PULSE: Activity,
|
||||
BODY_FAT: Droplets,
|
||||
SLEEP_DURATION: Moon,
|
||||
ACTIVITY_STEPS: Footprints,
|
||||
BLOOD_GLUCOSE: Droplet,
|
||||
TOTAL_BODY_WATER: Droplet,
|
||||
BONE_MASS: Bone,
|
||||
OXYGEN_SATURATION: Wind,
|
||||
};
|
||||
|
||||
export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
|
||||
WEIGHT: "bg-chart-1/20 text-chart-1",
|
||||
BLOOD_PRESSURE_SYS: "bg-chart-3/20 text-chart-3",
|
||||
BLOOD_PRESSURE_DIA: "bg-chart-3/20 text-chart-3",
|
||||
PULSE: "bg-chart-5/20 text-chart-5",
|
||||
BODY_FAT: "bg-chart-4/20 text-chart-4",
|
||||
SLEEP_DURATION: "bg-chart-2/20 text-chart-2",
|
||||
ACTIVITY_STEPS: "bg-chart-2/20 text-chart-2",
|
||||
BLOOD_GLUCOSE: "bg-chart-3/20 text-chart-3",
|
||||
TOTAL_BODY_WATER: "bg-chart-2/20 text-chart-2",
|
||||
BONE_MASS: "bg-chart-4/20 text-chart-4",
|
||||
OXYGEN_SATURATION: "bg-chart-5/20 text-chart-5",
|
||||
};
|
||||
@@ -54,50 +54,16 @@ import {
|
||||
ArrowDown,
|
||||
ArrowUpDown,
|
||||
MoreHorizontal,
|
||||
Scale,
|
||||
Heart,
|
||||
Activity,
|
||||
Droplets,
|
||||
Moon,
|
||||
Footprints,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { invalidateKeys, measurementDependentKeys } from "@/lib/query-keys";
|
||||
|
||||
const TYPE_LABEL_KEYS: Record<string, string> = {
|
||||
WEIGHT: "measurements.typeWeight",
|
||||
BLOOD_PRESSURE_SYS: "measurements.typeBpSys",
|
||||
BLOOD_PRESSURE_DIA: "measurements.typeBpDia",
|
||||
PULSE: "measurements.typePulse",
|
||||
BODY_FAT: "measurements.typeBodyFat",
|
||||
SLEEP_DURATION: "measurements.typeSleep",
|
||||
ACTIVITY_STEPS: "measurements.typeSteps",
|
||||
};
|
||||
|
||||
const TYPE_ICONS: Record<
|
||||
string,
|
||||
React.ComponentType<{ className?: string }>
|
||||
> = {
|
||||
WEIGHT: Scale,
|
||||
BLOOD_PRESSURE_SYS: Heart,
|
||||
BLOOD_PRESSURE_DIA: Heart,
|
||||
PULSE: Activity,
|
||||
BODY_FAT: Droplets,
|
||||
SLEEP_DURATION: Moon,
|
||||
ACTIVITY_STEPS: Footprints,
|
||||
};
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
WEIGHT: "bg-chart-1/20 text-chart-1",
|
||||
BLOOD_PRESSURE_SYS: "bg-chart-3/20 text-chart-3",
|
||||
BLOOD_PRESSURE_DIA: "bg-chart-3/20 text-chart-3",
|
||||
PULSE: "bg-chart-5/20 text-chart-5",
|
||||
BODY_FAT: "bg-chart-4/20 text-chart-4",
|
||||
SLEEP_DURATION: "bg-chart-2/20 text-chart-2",
|
||||
ACTIVITY_STEPS: "bg-chart-2/20 text-chart-2",
|
||||
};
|
||||
import {
|
||||
MEASUREMENT_TYPE_LABEL_KEYS as TYPE_LABEL_KEYS,
|
||||
MEASUREMENT_TYPE_ICONS as TYPE_ICONS,
|
||||
MEASUREMENT_TYPE_COLORS as TYPE_COLORS,
|
||||
} from "./measurement-list-meta";
|
||||
|
||||
interface Measurement {
|
||||
id: string;
|
||||
|
||||
@@ -32,6 +32,7 @@ const WIDGET_LABEL_KEYS: Record<DashboardWidgetId, string> = {
|
||||
totalBodyWater: "measurements.typeTotalBodyWater",
|
||||
boneMass: "measurements.typeBoneMass",
|
||||
bpInTarget: "dashboard.bpInTarget",
|
||||
oxygenSaturation: "measurements.typeOxygenSaturation",
|
||||
};
|
||||
|
||||
export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
|
||||
@@ -27,12 +27,15 @@ const METRIC_ORDER: ThresholdMetric[] = [
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
"PULSE",
|
||||
"BODY_FAT",
|
||||
"TOTAL_BODY_WATER",
|
||||
"BONE_MASS",
|
||||
"SLEEP_DURATION",
|
||||
"ACTIVITY_STEPS",
|
||||
"BLOOD_GLUCOSE_FASTING",
|
||||
"BLOOD_GLUCOSE_POSTPRANDIAL",
|
||||
"BLOOD_GLUCOSE_RANDOM",
|
||||
"BLOOD_GLUCOSE_BEDTIME",
|
||||
"OXYGEN_SATURATION",
|
||||
];
|
||||
|
||||
const METRIC_LABEL_KEYS: Record<ThresholdMetric, string> = {
|
||||
@@ -47,6 +50,9 @@ const METRIC_LABEL_KEYS: Record<ThresholdMetric, string> = {
|
||||
BLOOD_GLUCOSE_POSTPRANDIAL: "thresholds.metricGlucosePostprandial",
|
||||
BLOOD_GLUCOSE_RANDOM: "thresholds.metricGlucoseRandom",
|
||||
BLOOD_GLUCOSE_BEDTIME: "thresholds.metricGlucoseBedtime",
|
||||
TOTAL_BODY_WATER: "thresholds.metricBodyWater",
|
||||
BONE_MASS: "thresholds.metricBoneMass",
|
||||
OXYGEN_SATURATION: "thresholds.metricOxygenSaturation",
|
||||
};
|
||||
|
||||
export function ThresholdsSection({ id }: { id: string }) {
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { PDFParse } from "pdf-parse";
|
||||
import {
|
||||
buildDoctorReportPdfDocument,
|
||||
renderDoctorReportPdfBytes,
|
||||
DOCTOR_REPORT_VITAL_TYPES,
|
||||
DOCTOR_REPORT_TYPE_LABEL_KEYS,
|
||||
DOCTOR_REPORT_TYPE_UNIT_KEYS,
|
||||
} from "../doctor-report-pdf-core";
|
||||
import { measurementTypeEnum } from "../validations/measurement";
|
||||
import type { DoctorReportData } from "../doctor-report-data";
|
||||
import { getServerTranslator } from "../i18n/server-translator";
|
||||
|
||||
async function extractText(bytes: Uint8Array): Promise<string> {
|
||||
const parser = new PDFParse({ data: bytes });
|
||||
try {
|
||||
const result = await parser.getText();
|
||||
return result.text;
|
||||
} finally {
|
||||
await parser.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
const FIXED_NOW = new Date("2026-05-03T12:00:00.000Z");
|
||||
|
||||
function makeData(overrides?: Partial<DoctorReportData>): DoctorReportData {
|
||||
return {
|
||||
period: { days: 90, since: "2026-02-02T00:00:00.000Z" },
|
||||
patient: {
|
||||
username: "marc",
|
||||
dateOfBirth: "1985-06-15T00:00:00.000Z",
|
||||
gender: "MALE",
|
||||
heightCm: 182,
|
||||
},
|
||||
measurements: {
|
||||
WEIGHT: [
|
||||
{ value: 80, measuredAt: "2026-02-10T08:00:00.000Z" },
|
||||
{ value: 79.5, measuredAt: "2026-04-30T08:00:00.000Z" },
|
||||
],
|
||||
},
|
||||
stats: {
|
||||
WEIGHT: { avg: 79.75, min: 79.5, max: 80, count: 2, latest: 79.5 },
|
||||
BLOOD_PRESSURE_SYS: {
|
||||
avg: 122,
|
||||
min: 118,
|
||||
max: 128,
|
||||
count: 5,
|
||||
latest: 120,
|
||||
},
|
||||
BLOOD_PRESSURE_DIA: {
|
||||
avg: 78,
|
||||
min: 72,
|
||||
max: 82,
|
||||
count: 5,
|
||||
latest: 78,
|
||||
},
|
||||
PULSE: { avg: 65, min: 58, max: 72, count: 5, latest: 64 },
|
||||
},
|
||||
glucoseStats: {
|
||||
FASTING: { avg: 92, min: 85, max: 100, count: 4, latest: 90 },
|
||||
},
|
||||
glucoseRanges: { FASTING: { min: 70, max: 99 } },
|
||||
glucoseUnit: "mg/dL",
|
||||
bmi: 24.1,
|
||||
compliance: {
|
||||
Ramipril: { total: 90, taken: 85, skipped: 3, missed: 2 },
|
||||
},
|
||||
medications: [
|
||||
{
|
||||
name: "Ramipril",
|
||||
dose: "5mg",
|
||||
schedules: [
|
||||
{ windowStart: "08:00", windowEnd: "09:00", label: "Morning" },
|
||||
],
|
||||
},
|
||||
],
|
||||
mood: {
|
||||
avg: 3.6,
|
||||
min: 2,
|
||||
max: 5,
|
||||
count: 30,
|
||||
distribution: { 1: 0, 2: 3, 3: 9, 4: 14, 5: 4 },
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("renderDoctorReportPdfBytes", () => {
|
||||
it("returns a Uint8Array starting with the %PDF- header", () => {
|
||||
const { t } = getServerTranslator("de");
|
||||
const bytes = renderDoctorReportPdfBytes(makeData(), {
|
||||
t,
|
||||
locale: "de",
|
||||
now: FIXED_NOW,
|
||||
});
|
||||
expect(bytes).toBeInstanceOf(Uint8Array);
|
||||
const header = String.fromCharCode(...bytes.slice(0, 5));
|
||||
expect(header).toBe("%PDF-");
|
||||
});
|
||||
|
||||
it("produces a non-trivial document (> 1 KB)", () => {
|
||||
const { t } = getServerTranslator("de");
|
||||
const bytes = renderDoctorReportPdfBytes(makeData(), {
|
||||
t,
|
||||
locale: "de",
|
||||
now: FIXED_NOW,
|
||||
});
|
||||
expect(bytes.byteLength).toBeGreaterThan(1024);
|
||||
});
|
||||
|
||||
it("is structurally deterministic for the same input + fixed timestamp", () => {
|
||||
// jsPDF embeds a per-invocation file-id in the trailer (random UUID-like
|
||||
// hex). The visible content is fully determined by the inputs, so we
|
||||
// assert byte-length equality + identical content sections instead of a
|
||||
// strict bytewise compare.
|
||||
const { t } = getServerTranslator("de");
|
||||
const a = renderDoctorReportPdfBytes(makeData(), {
|
||||
t,
|
||||
locale: "de",
|
||||
now: FIXED_NOW,
|
||||
});
|
||||
const b = renderDoctorReportPdfBytes(makeData(), {
|
||||
t,
|
||||
locale: "de",
|
||||
now: FIXED_NOW,
|
||||
});
|
||||
expect(a.byteLength).toBe(b.byteLength);
|
||||
// Compare the first 95% of the document — file-id and trailer differ.
|
||||
const cmp = Math.floor(a.byteLength * 0.95);
|
||||
expect(
|
||||
Buffer.from(a.slice(0, cmp)).equals(Buffer.from(b.slice(0, cmp))),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("renders both DE and EN locales without errors", () => {
|
||||
const de = renderDoctorReportPdfBytes(makeData(), {
|
||||
t: getServerTranslator("de").t,
|
||||
locale: "de",
|
||||
now: FIXED_NOW,
|
||||
});
|
||||
const en = renderDoctorReportPdfBytes(makeData(), {
|
||||
t: getServerTranslator("en").t,
|
||||
locale: "en",
|
||||
now: FIXED_NOW,
|
||||
});
|
||||
expect(de.byteLength).toBeGreaterThan(1024);
|
||||
expect(en.byteLength).toBeGreaterThan(1024);
|
||||
});
|
||||
|
||||
it("handles empty stats / no mood without throwing", () => {
|
||||
const empty = makeData({
|
||||
stats: {},
|
||||
glucoseStats: {},
|
||||
glucoseRanges: {},
|
||||
compliance: {},
|
||||
mood: null,
|
||||
bmi: null,
|
||||
});
|
||||
const bytes = renderDoctorReportPdfBytes(empty, {
|
||||
t: getServerTranslator("de").t,
|
||||
locale: "de",
|
||||
now: FIXED_NOW,
|
||||
});
|
||||
const header = String.fromCharCode(...bytes.slice(0, 5));
|
||||
expect(header).toBe("%PDF-");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDoctorReportPdfDocument", () => {
|
||||
it("returns a jsPDF doc with at least one page", () => {
|
||||
const doc = buildDoctorReportPdfDocument(makeData(), {
|
||||
t: getServerTranslator("de").t,
|
||||
locale: "de",
|
||||
now: FIXED_NOW,
|
||||
});
|
||||
expect(doc.getNumberOfPages()).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
// Audit-2026-05-07 / phase P0 / closes audit C-9: server-side PDF used a
|
||||
// stale type map and silently dropped body composition while the browser
|
||||
// PDF rendered them. These tests pin the contract.
|
||||
describe("doctor-report-pdf-core type-map coverage", () => {
|
||||
it("exposes body composition (TOTAL_BODY_WATER, BONE_MASS) as vital types", () => {
|
||||
expect(DOCTOR_REPORT_VITAL_TYPES).toContain("TOTAL_BODY_WATER");
|
||||
expect(DOCTOR_REPORT_VITAL_TYPES).toContain("BONE_MASS");
|
||||
});
|
||||
|
||||
it("provides a label key + unit for every vital type", () => {
|
||||
for (const type of DOCTOR_REPORT_VITAL_TYPES) {
|
||||
expect(DOCTOR_REPORT_TYPE_LABEL_KEYS[type]).toBeTruthy();
|
||||
const unit = DOCTOR_REPORT_TYPE_UNIT_KEYS[type];
|
||||
expect(
|
||||
unit === null || (typeof unit === "string" && unit.length > 0),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("vital types are a subset of the canonical measurement enum", () => {
|
||||
const enumSet = new Set<string>(measurementTypeEnum.options);
|
||||
for (const type of DOCTOR_REPORT_VITAL_TYPES) {
|
||||
expect(enumSet.has(type), `${type} not in measurementTypeEnum`).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("renders body composition rows with their German labels in the document text", async () => {
|
||||
const data = makeData({
|
||||
stats: {
|
||||
WEIGHT: { avg: 80, min: 79, max: 81, count: 5, latest: 80 },
|
||||
TOTAL_BODY_WATER: {
|
||||
avg: 42,
|
||||
min: 40,
|
||||
max: 44,
|
||||
count: 5,
|
||||
latest: 42,
|
||||
},
|
||||
BONE_MASS: { avg: 3.2, min: 3.1, max: 3.3, count: 5, latest: 3.2 },
|
||||
},
|
||||
});
|
||||
const bytes = renderDoctorReportPdfBytes(data, {
|
||||
t: getServerTranslator("de").t,
|
||||
locale: "de",
|
||||
now: FIXED_NOW,
|
||||
});
|
||||
const text = await extractText(bytes);
|
||||
expect(text).toContain("Gesamtkörperwasser");
|
||||
expect(text).toContain("Knochenmasse");
|
||||
expect(text).toContain("42,0");
|
||||
expect(text).toContain("3,2");
|
||||
});
|
||||
|
||||
it("renders an OXYGEN_SATURATION row with the SpO2 label when stats are supplied", async () => {
|
||||
const data = makeData({
|
||||
stats: {
|
||||
WEIGHT: { avg: 80, min: 79, max: 81, count: 5, latest: 80 },
|
||||
OXYGEN_SATURATION: {
|
||||
avg: 97.4,
|
||||
min: 95,
|
||||
max: 99,
|
||||
count: 12,
|
||||
latest: 98,
|
||||
},
|
||||
},
|
||||
});
|
||||
const bytes = renderDoctorReportPdfBytes(data, {
|
||||
t: getServerTranslator("de").t,
|
||||
locale: "de",
|
||||
now: FIXED_NOW,
|
||||
});
|
||||
const text = await extractText(bytes);
|
||||
expect(text).toContain("Sauerstoffsättigung");
|
||||
expect(text).toContain("97,4");
|
||||
expect(text).toContain("%");
|
||||
});
|
||||
|
||||
it("renders body composition rows in English when locale is en", async () => {
|
||||
const data = makeData({
|
||||
stats: {
|
||||
WEIGHT: { avg: 80, min: 79, max: 81, count: 5, latest: 80 },
|
||||
TOTAL_BODY_WATER: { avg: 42, min: 40, max: 44, count: 5, latest: 42 },
|
||||
BONE_MASS: { avg: 3.2, min: 3.1, max: 3.3, count: 5, latest: 3.2 },
|
||||
OXYGEN_SATURATION: {
|
||||
avg: 97.4,
|
||||
min: 95,
|
||||
max: 99,
|
||||
count: 12,
|
||||
latest: 98,
|
||||
},
|
||||
},
|
||||
});
|
||||
const bytes = renderDoctorReportPdfBytes(data, {
|
||||
t: getServerTranslator("en").t,
|
||||
locale: "en",
|
||||
now: FIXED_NOW,
|
||||
});
|
||||
const text = await extractText(bytes);
|
||||
expect(text).toContain("Total body water");
|
||||
expect(text).toContain("Bone mass");
|
||||
expect(text).toContain("Oxygen saturation");
|
||||
});
|
||||
});
|
||||
@@ -66,6 +66,26 @@ describe("formatMeasurementsForExport", () => {
|
||||
measuredAt: "2025-01-15T08:00:00.000Z",
|
||||
source: "MANUAL",
|
||||
notes: "",
|
||||
glucoseContext: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("round-trips glucoseContext on BLOOD_GLUCOSE rows", () => {
|
||||
const measurements = [
|
||||
{
|
||||
type: "BLOOD_GLUCOSE",
|
||||
value: 92,
|
||||
unit: "mg/dL",
|
||||
measuredAt: new Date("2025-01-15T08:00:00Z"),
|
||||
source: "MANUAL",
|
||||
notes: null,
|
||||
glucoseContext: "FASTING",
|
||||
},
|
||||
];
|
||||
const result = formatMeasurementsForExport(measurements);
|
||||
expect(result[0]).toMatchObject({
|
||||
type: "BLOOD_GLUCOSE",
|
||||
glucoseContext: "FASTING",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
delete process.env.IP_GEO_LOOKUP_URL;
|
||||
delete process.env.IP_GEO_LOOKUP_DISABLED;
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = ORIGINAL_ENV;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// V3 audit: /api/auth events were leaking IP + timestamp via plaintext
|
||||
// HTTP to ip-api.com (GDPR Art. 32 + Art. 44). The lookup helper now
|
||||
// (a) only egresses over HTTPS, (b) supports an opt-out env, and
|
||||
// (c) accepts both ipwho.is and ip-api.com pro response shapes.
|
||||
describe("lookupIpLocation IP-geolocation HTTPS guard", () => {
|
||||
it("returns null for private addresses without making any request", async () => {
|
||||
const fetchSpy = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const { lookupIpLocation } = await import("../geo");
|
||||
|
||||
expect(await lookupIpLocation("10.0.0.5")).toBeNull();
|
||||
expect(await lookupIpLocation("127.0.0.1")).toBeNull();
|
||||
expect(await lookupIpLocation("::1")).toBeNull();
|
||||
expect(await lookupIpLocation(null)).toBeNull();
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns null when IP_GEO_LOOKUP_DISABLED=1", async () => {
|
||||
process.env.IP_GEO_LOOKUP_DISABLED = "1";
|
||||
const fetchSpy = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const { lookupIpLocation } = await import("../geo");
|
||||
|
||||
expect(await lookupIpLocation("8.8.8.8")).toBeNull();
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses HTTPS by default (ipwho.is)", async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, city: "Berlin", country_code: "DE" }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const { lookupIpLocation } = await import("../geo");
|
||||
|
||||
expect(await lookupIpLocation("8.8.8.8")).toBe("Berlin, DE");
|
||||
const url = fetchSpy.mock.calls[0]?.[0] as string;
|
||||
expect(url.startsWith("https://")).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses to call non-HTTPS configured providers", async () => {
|
||||
process.env.IP_GEO_LOOKUP_URL = "http://ip-api.com/json";
|
||||
const fetchSpy = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
json: async () => ({}),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const { lookupIpLocation } = await import("../geo");
|
||||
|
||||
const result = await lookupIpLocation("8.8.8.8");
|
||||
expect(result).toBeNull();
|
||||
const url = fetchSpy.mock.calls[0]?.[0] as string;
|
||||
// Even when an operator misconfigures HTTP, the helper rewrites the
|
||||
// URL to a deliberately invalid HTTPS URL so the egress is HTTPS-only.
|
||||
expect(url.startsWith("https://")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts the ip-api pro response shape (status:'success', countryCode)", async () => {
|
||||
process.env.IP_GEO_LOOKUP_URL = "https://pro.ip-api.com/json";
|
||||
const fetchSpy = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
status: "success",
|
||||
city: "Hamburg",
|
||||
countryCode: "DE",
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const { lookupIpLocation } = await import("../geo");
|
||||
|
||||
expect(await lookupIpLocation("8.8.8.8")).toBe("Hamburg, DE");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { getClientIp } from "../api-response";
|
||||
|
||||
const ORIGINAL_ENV = process.env.TRUST_PROXY_HOPS;
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.TRUST_PROXY_HOPS;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_ENV === undefined) delete process.env.TRUST_PROXY_HOPS;
|
||||
else process.env.TRUST_PROXY_HOPS = ORIGINAL_ENV;
|
||||
});
|
||||
|
||||
function makeRequest(headers: Record<string, string>): Request {
|
||||
return new Request("https://example.com/", { headers });
|
||||
}
|
||||
|
||||
// V3 audit: client-supplied X-Forwarded-For was being trusted blindly,
|
||||
// allowing a per-request `XFF: 1.2.3.<rand>` to rotate the bucket of any
|
||||
// per-IP rate-limit. The new contract reads XFF right-to-left with a
|
||||
// configurable number of trusted hops (Express semantics).
|
||||
describe("getClientIp trusted-proxy semantics (V3 audit)", () => {
|
||||
it("defaults to 1 trusted hop — returns the rightmost XFF entry", () => {
|
||||
const ip = getClientIp(
|
||||
makeRequest({ "x-forwarded-for": "9.9.9.9, 1.2.3.4, 5.6.7.8" }),
|
||||
);
|
||||
expect(ip).toBe("5.6.7.8");
|
||||
});
|
||||
|
||||
it("with TRUST_PROXY_HOPS=2 returns the second-from-rightmost", () => {
|
||||
process.env.TRUST_PROXY_HOPS = "2";
|
||||
const ip = getClientIp(
|
||||
makeRequest({ "x-forwarded-for": "9.9.9.9, 1.2.3.4, 5.6.7.8" }),
|
||||
);
|
||||
expect(ip).toBe("1.2.3.4");
|
||||
});
|
||||
|
||||
it("with TRUST_PROXY_HOPS=2 and a chain shorter than 2 returns null (fixes review M-3)", () => {
|
||||
process.env.TRUST_PROXY_HOPS = "2";
|
||||
// Misconfigured deployment: claims 2 trusted hops but only 1 proxy is
|
||||
// in the chain. Falling back to the leftmost (attacker-controlled)
|
||||
// entry would re-introduce XFF rotation. Refuse the chain entirely.
|
||||
const ip = getClientIp(makeRequest({ "x-forwarded-for": "5.6.7.8" }));
|
||||
expect(ip).toBeNull();
|
||||
});
|
||||
|
||||
it("throws at boot when TRUST_PROXY_HOPS is unparseable (fixes review L-1)", () => {
|
||||
process.env.TRUST_PROXY_HOPS = "garbage";
|
||||
expect(() =>
|
||||
getClientIp(makeRequest({ "x-forwarded-for": "5.6.7.8" })),
|
||||
).toThrow(/TRUST_PROXY_HOPS/);
|
||||
});
|
||||
|
||||
it("with TRUST_PROXY_HOPS=0 ignores XFF entirely", () => {
|
||||
process.env.TRUST_PROXY_HOPS = "0";
|
||||
const ip = getClientIp(
|
||||
makeRequest({
|
||||
"x-forwarded-for": "9.9.9.9, 1.2.3.4",
|
||||
"x-real-ip": "8.8.8.8",
|
||||
}),
|
||||
);
|
||||
expect(ip).toBe("8.8.8.8");
|
||||
});
|
||||
|
||||
it("with TRUST_PROXY_HOPS=0 and no x-real-ip returns null", () => {
|
||||
process.env.TRUST_PROXY_HOPS = "0";
|
||||
const ip = getClientIp(
|
||||
makeRequest({ "x-forwarded-for": "9.9.9.9, 1.2.3.4" }),
|
||||
);
|
||||
expect(ip).toBeNull();
|
||||
});
|
||||
|
||||
it("XFF rotation attack — caller cannot bypass per-IP rate-limit by changing leftmost entry", () => {
|
||||
// Attacker sends successive requests with rotating leftmost XFF; the
|
||||
// proxy still appends the real client IP at the end, so the helper
|
||||
// returns the same IP regardless of attacker chosen XFF.
|
||||
const a = getClientIp(
|
||||
makeRequest({ "x-forwarded-for": "1.1.1.1, 203.0.113.5" }),
|
||||
);
|
||||
const b = getClientIp(
|
||||
makeRequest({ "x-forwarded-for": "8.8.8.8, 203.0.113.5" }),
|
||||
);
|
||||
expect(a).toBe("203.0.113.5");
|
||||
expect(b).toBe("203.0.113.5");
|
||||
});
|
||||
|
||||
it("rejects malformed entries in the chain", () => {
|
||||
const ip = getClientIp(
|
||||
makeRequest({ "x-forwarded-for": "garbage,, 5.6.7.8" }),
|
||||
);
|
||||
expect(ip).toBe("5.6.7.8");
|
||||
});
|
||||
|
||||
it("falls back to x-real-ip if XFF missing", () => {
|
||||
const ip = getClientIp(makeRequest({ "x-real-ip": "5.6.7.8" }));
|
||||
expect(ip).toBe("5.6.7.8");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,312 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
idempotencyKey: {
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
apiToken: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({
|
||||
getSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/hmac", () => ({
|
||||
hashToken: vi.fn((raw: string) => `hashed:${raw}`),
|
||||
}));
|
||||
|
||||
import {
|
||||
withIdempotency,
|
||||
defaultUserIdResolver,
|
||||
isCachableStatus,
|
||||
} from "../idempotency";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
function makeRequest(
|
||||
method: string,
|
||||
headers: Record<string, string> = {},
|
||||
): NextRequest {
|
||||
return new NextRequest("http://localhost/api/example", {
|
||||
method,
|
||||
headers: { "content-type": "application/json", ...headers },
|
||||
body: method === "GET" ? undefined : JSON.stringify({ ok: true }),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.idempotencyKey.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(prisma.idempotencyKey.create).mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
describe("withIdempotency", () => {
|
||||
it("passes through when no Idempotency-Key header is present", async () => {
|
||||
const handler = vi.fn(async () =>
|
||||
NextResponse.json({ data: { ok: true }, error: null }, { status: 201 }),
|
||||
);
|
||||
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
|
||||
const res = await wrapped(makeRequest("POST"));
|
||||
expect(res.status).toBe(201);
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.idempotencyKey.findUnique).not.toHaveBeenCalled();
|
||||
expect(prisma.idempotencyKey.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("caches the response and replays it on the second call", async () => {
|
||||
let callCount = 0;
|
||||
const handler = vi.fn(async () => {
|
||||
callCount += 1;
|
||||
return NextResponse.json(
|
||||
{ data: { result: callCount }, error: null },
|
||||
{ status: 201 },
|
||||
);
|
||||
});
|
||||
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
|
||||
|
||||
// First call: nothing cached → handler runs → response stored.
|
||||
const req1 = makeRequest("POST", { "idempotency-key": "abc-12345678" });
|
||||
const res1 = await wrapped(req1);
|
||||
const body1 = await res1.json();
|
||||
expect(res1.status).toBe(201);
|
||||
expect(body1).toEqual({ data: { result: 1 }, error: null });
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.idempotencyKey.create).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Capture the persisted body for replay.
|
||||
const persistedBody = (
|
||||
vi.mocked(prisma.idempotencyKey.create).mock.calls[0][0] as {
|
||||
data: { responseBody: string; responseStatus: number };
|
||||
}
|
||||
).data;
|
||||
|
||||
// Second call: cache hit returns persisted envelope.
|
||||
vi.mocked(prisma.idempotencyKey.findUnique).mockResolvedValueOnce({
|
||||
id: "idem-1",
|
||||
userId: "u-1",
|
||||
key: "abc-12345678",
|
||||
method: "POST",
|
||||
path: "/api/example",
|
||||
responseStatus: persistedBody.responseStatus,
|
||||
responseBody: persistedBody.responseBody,
|
||||
expiresAt: new Date(Date.now() + 86_400_000),
|
||||
createdAt: new Date(),
|
||||
} as never);
|
||||
|
||||
const req2 = makeRequest("POST", { "idempotency-key": "abc-12345678" });
|
||||
const res2 = await wrapped(req2);
|
||||
expect(res2.headers.get("X-Idempotent-Replay")).toBe("true");
|
||||
expect(res2.status).toBe(201);
|
||||
const body2 = await res2.json();
|
||||
expect(body2).toEqual({ data: { result: 1 }, error: null });
|
||||
expect(handler).toHaveBeenCalledTimes(1); // not called again
|
||||
});
|
||||
|
||||
it("ignores expired cache rows and re-runs the handler", async () => {
|
||||
const handler = vi.fn(async () =>
|
||||
NextResponse.json({ data: "fresh", error: null }, { status: 200 }),
|
||||
);
|
||||
vi.mocked(prisma.idempotencyKey.findUnique).mockResolvedValueOnce({
|
||||
id: "idem-old",
|
||||
userId: "u-1",
|
||||
key: "abc-12345678",
|
||||
method: "POST",
|
||||
path: "/api/example",
|
||||
responseStatus: 200,
|
||||
responseBody: '{"data":"stale","error":null}',
|
||||
expiresAt: new Date(Date.now() - 1000),
|
||||
createdAt: new Date(),
|
||||
} as never);
|
||||
vi.mocked(prisma.idempotencyKey.delete).mockResolvedValue({} as never);
|
||||
|
||||
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
|
||||
const res = await wrapped(
|
||||
makeRequest("POST", { "idempotency-key": "abc-12345678" }),
|
||||
);
|
||||
const body = await res.json();
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(body).toEqual({ data: "fresh", error: null });
|
||||
expect(prisma.idempotencyKey.delete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores malformed Idempotency-Key headers", async () => {
|
||||
const handler = vi.fn(async () =>
|
||||
NextResponse.json({ data: "ok", error: null }),
|
||||
);
|
||||
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
|
||||
await wrapped(makeRequest("POST", { "idempotency-key": "!!" }));
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.idempotencyKey.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing for GET requests", async () => {
|
||||
const handler = vi.fn(async () =>
|
||||
NextResponse.json({ data: "ok", error: null }),
|
||||
);
|
||||
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
|
||||
await wrapped(makeRequest("GET", { "idempotency-key": "abc-12345678" }));
|
||||
expect(prisma.idempotencyKey.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("defaults to the cookie session when no resolver is given", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue({
|
||||
session: { id: "s-1", expiresAt: new Date(Date.now() + 60_000) },
|
||||
user: { id: "u-default" },
|
||||
} as never);
|
||||
const handler = vi.fn(async () =>
|
||||
NextResponse.json({ data: "ok", error: null }, { status: 201 }),
|
||||
);
|
||||
const wrapped = withIdempotency<[NextRequest]>(handler);
|
||||
await wrapped(makeRequest("POST", { "idempotency-key": "abc-12345678" }));
|
||||
expect(getSession).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.idempotencyKey.create).toHaveBeenCalledTimes(1);
|
||||
const persisted = vi.mocked(prisma.idempotencyKey.create).mock
|
||||
.calls[0][0] as { data: { userId: string } };
|
||||
expect(persisted.data.userId).toBe("u-default");
|
||||
});
|
||||
|
||||
it("skips caching when the default resolver finds no session", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
vi.mocked(headers).mockResolvedValue({
|
||||
get: vi.fn().mockReturnValue(null),
|
||||
} as unknown as ReturnType<typeof headers> extends Promise<infer T>
|
||||
? T
|
||||
: never);
|
||||
const handler = vi.fn(async () =>
|
||||
NextResponse.json({ data: "ok", error: null }, { status: 201 }),
|
||||
);
|
||||
const wrapped = withIdempotency<[NextRequest]>(handler);
|
||||
await wrapped(makeRequest("POST", { "idempotency-key": "abc-12345678" }));
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.idempotencyKey.findUnique).not.toHaveBeenCalled();
|
||||
expect(prisma.idempotencyKey.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// Audit C-4 / phase P2: defaultUserIdResolver must support both cookie
|
||||
// sessions AND Bearer tokens. Without the Bearer fallback, idempotency
|
||||
// silently turned off for the iOS / external-ingest paths it was built for.
|
||||
describe("defaultUserIdResolver (audit C-4)", () => {
|
||||
function mockHeader(value: string | null) {
|
||||
vi.mocked(headers).mockResolvedValue({
|
||||
get: vi.fn().mockImplementation((name: string) =>
|
||||
name.toLowerCase() === "authorization" ? value : null,
|
||||
),
|
||||
} as unknown as ReturnType<typeof headers> extends Promise<infer T>
|
||||
? T
|
||||
: never);
|
||||
}
|
||||
|
||||
it("returns the session user id when a cookie session is present", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue({
|
||||
session: { id: "s-1" },
|
||||
user: { id: "u-cookie", role: "USER" },
|
||||
} as Awaited<ReturnType<typeof getSession>>);
|
||||
mockHeader(null);
|
||||
expect(await defaultUserIdResolver()).toBe("u-cookie");
|
||||
});
|
||||
|
||||
it("falls back to Bearer-token resolution when no cookie session", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
mockHeader("Bearer hlk_abcdef");
|
||||
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
|
||||
userId: "u-bearer",
|
||||
revoked: false,
|
||||
expiresAt: null,
|
||||
} as never);
|
||||
expect(await defaultUserIdResolver()).toBe("u-bearer");
|
||||
// V3 audit: assert the where-clause used the hashed token, not the
|
||||
// raw bearer. The hashToken mock returns "hashed:<raw>" — the lookup
|
||||
// MUST be against that, otherwise we are storing recoverable secrets.
|
||||
expect(prisma.apiToken.findUnique).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { tokenHash: "hashed:hlk_abcdef" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects revoked Bearer tokens", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
mockHeader("Bearer hlk_abcdef");
|
||||
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
|
||||
userId: "u-bearer",
|
||||
revoked: true,
|
||||
expiresAt: null,
|
||||
} as never);
|
||||
expect(await defaultUserIdResolver()).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects expired Bearer tokens", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
mockHeader("Bearer hlk_abcdef");
|
||||
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
|
||||
userId: "u-bearer",
|
||||
revoked: false,
|
||||
expiresAt: new Date(Date.now() - 1000),
|
||||
} as never);
|
||||
expect(await defaultUserIdResolver()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when no auth method is provided", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
mockHeader(null);
|
||||
expect(await defaultUserIdResolver()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// V3 audit STILL-V2-NEW: the cachable-status filter (do-not-cache for
|
||||
// 401/403/408/429/5xx) had zero tests, so a regression that re-cached an
|
||||
// expired bearer token's 401 would have been silent.
|
||||
describe("isCachableStatus do-not-cache rules (V3 audit)", () => {
|
||||
it("caches 2xx success responses", () => {
|
||||
expect(isCachableStatus(200)).toBe(true);
|
||||
expect(isCachableStatus(201)).toBe(true);
|
||||
expect(isCachableStatus(204)).toBe(true);
|
||||
});
|
||||
|
||||
it("caches 4xx validation responses (so retries don't re-execute side-effects)", () => {
|
||||
expect(isCachableStatus(400)).toBe(true);
|
||||
expect(isCachableStatus(404)).toBe(true);
|
||||
expect(isCachableStatus(409)).toBe(true);
|
||||
expect(isCachableStatus(422)).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT cache 401 — the token may have been refreshed between attempts", () => {
|
||||
expect(isCachableStatus(401)).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT cache 403 — authorization can change between attempts", () => {
|
||||
expect(isCachableStatus(403)).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT cache 408 — caller-side timeout deserves a fresh attempt", () => {
|
||||
expect(isCachableStatus(408)).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT cache 429 — caller deserves a fresh window-check on retry", () => {
|
||||
expect(isCachableStatus(429)).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT cache any 5xx — server fault must not lock the user out", () => {
|
||||
expect(isCachableStatus(500)).toBe(false);
|
||||
expect(isCachableStatus(502)).toBe(false);
|
||||
expect(isCachableStatus(503)).toBe(false);
|
||||
expect(isCachableStatus(504)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||||
import {
|
||||
DOCTOR_REPORT_VITAL_TYPES,
|
||||
DOCTOR_REPORT_TYPE_LABEL_KEYS,
|
||||
DOCTOR_REPORT_TYPE_UNIT_KEYS,
|
||||
} from "@/lib/doctor-report-pdf-core";
|
||||
|
||||
// Single source of truth for which measurement types exist.
|
||||
// V3 audit "enum drift cousins": 7 module-level hardcoded arrays were
|
||||
// silently dropping new types (SpO2, TBW, BoneMass, BloodGlucose) from
|
||||
// dashboard / analytics / AI insights / iOS adapters / import.
|
||||
//
|
||||
// All ingest, analytics and reporting paths are now derived from
|
||||
// `measurementTypeEnum.options`, so adding a new type only needs touching
|
||||
// the enum. This test asserts that contract.
|
||||
const EXPECTED_TYPES = [
|
||||
"WEIGHT",
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
"PULSE",
|
||||
"BODY_FAT",
|
||||
"SLEEP_DURATION",
|
||||
"ACTIVITY_STEPS",
|
||||
"BLOOD_GLUCOSE",
|
||||
"TOTAL_BODY_WATER",
|
||||
"BONE_MASS",
|
||||
"OXYGEN_SATURATION",
|
||||
] as const;
|
||||
|
||||
describe("measurementTypeEnum coverage", () => {
|
||||
it("exposes the 11 canonical measurement types", () => {
|
||||
expect([...measurementTypeEnum.options].sort()).toEqual(
|
||||
[...EXPECTED_TYPES].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
// Documented exclusions from the doctor-report main vitals table:
|
||||
// - BLOOD_GLUCOSE renders through the per-context `glucoseStats` section
|
||||
// - SLEEP_DURATION + ACTIVITY_STEPS are intentionally omitted from the
|
||||
// clinical PDF (lifestyle, not a vital sign — see source comment).
|
||||
// Updates to this set MUST be paired with a comment in
|
||||
// doctor-report-pdf-core.ts so the rationale stays discoverable.
|
||||
const PDF_VITAL_EXCLUSIONS = new Set([
|
||||
"BLOOD_GLUCOSE",
|
||||
"SLEEP_DURATION",
|
||||
"ACTIVITY_STEPS",
|
||||
]);
|
||||
|
||||
it("doctor-report PDF vital types cover the canonical enum minus documented exclusions", () => {
|
||||
const expected = measurementTypeEnum.options.filter(
|
||||
(t) => !PDF_VITAL_EXCLUSIONS.has(t),
|
||||
);
|
||||
expect([...DOCTOR_REPORT_VITAL_TYPES].sort()).toEqual([...expected].sort());
|
||||
});
|
||||
|
||||
it("doctor-report PDF has a label key for every renderable type", () => {
|
||||
for (const type of DOCTOR_REPORT_VITAL_TYPES) {
|
||||
expect(
|
||||
DOCTOR_REPORT_TYPE_LABEL_KEYS[type],
|
||||
`missing label key for ${type}`,
|
||||
).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("doctor-report PDF has a unit key for every renderable type", () => {
|
||||
for (const type of DOCTOR_REPORT_VITAL_TYPES) {
|
||||
const unit = DOCTOR_REPORT_TYPE_UNIT_KEYS[type];
|
||||
expect(
|
||||
unit === null || (typeof unit === "string" && unit.length > 0),
|
||||
`missing unit for ${type}`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
encryptMoodLogSecret,
|
||||
readMoodLogSecret,
|
||||
isLegacyPlaintext,
|
||||
rotateLegacyMoodLogSecrets,
|
||||
} from "../moodlog-secret";
|
||||
|
||||
const ORIGINAL_ENCRYPTION_KEY = process.env.ENCRYPTION_KEY;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.ENCRYPTION_KEY = "a".repeat(64);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_ENCRYPTION_KEY === undefined) delete process.env.ENCRYPTION_KEY;
|
||||
else process.env.ENCRYPTION_KEY = ORIGINAL_ENCRYPTION_KEY;
|
||||
});
|
||||
|
||||
// V3 audit STILL-V2-C-2: mood_log_webhook_secret was stored in plaintext.
|
||||
// The new contract encrypts at rest with AES-256-GCM and tolerates legacy
|
||||
// plaintext rows during the transition window.
|
||||
describe("moodLog secret encrypt-at-rest", () => {
|
||||
it("encrypts and decrypts a generated secret round-trip", () => {
|
||||
const plaintext = "mb_" + "f".repeat(64);
|
||||
const encrypted = encryptMoodLogSecret(plaintext);
|
||||
expect(encrypted).not.toBe(plaintext);
|
||||
expect(readMoodLogSecret(encrypted)).toBe(plaintext);
|
||||
});
|
||||
|
||||
it("treats unrecognised values as legacy plaintext (transitional grace)", () => {
|
||||
expect(readMoodLogSecret("mb_legacyplaintextsecret")).toBe(
|
||||
"mb_legacyplaintextsecret",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null for null/empty stored values", () => {
|
||||
expect(readMoodLogSecret(null)).toBeNull();
|
||||
expect(readMoodLogSecret("")).toBeNull();
|
||||
});
|
||||
|
||||
it("isLegacyPlaintext detects rotation status correctly", () => {
|
||||
const enc = encryptMoodLogSecret("mb_test");
|
||||
expect(isLegacyPlaintext(enc)).toBe(false);
|
||||
expect(isLegacyPlaintext("mb_test")).toBe(true);
|
||||
expect(isLegacyPlaintext(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("rotateLegacyMoodLogSecrets encrypts only legacy rows and is idempotent", async () => {
|
||||
const enc = encryptMoodLogSecret("mb_alreadyencrypted");
|
||||
const rows = [
|
||||
{ id: "u1", moodLogWebhookSecret: "mb_legacy1" },
|
||||
{ id: "u2", moodLogWebhookSecret: enc },
|
||||
{ id: "u3", moodLogWebhookSecret: "mb_legacy3" },
|
||||
{ id: "u4", moodLogWebhookSecret: null },
|
||||
];
|
||||
const updates: Array<{ id: string; encrypted: string }> = [];
|
||||
|
||||
const rotated = await rotateLegacyMoodLogSecrets({
|
||||
findLegacy: async () => rows,
|
||||
rotate: async (id, encrypted) => {
|
||||
updates.push({ id, encrypted });
|
||||
},
|
||||
});
|
||||
|
||||
expect(rotated).toBe(2);
|
||||
expect(updates.map((u) => u.id).sort()).toEqual(["u1", "u3"]);
|
||||
for (const update of updates) {
|
||||
// Each rotation must produce a value that decrypts back to the
|
||||
// original legacy plaintext — no data loss.
|
||||
const original = rows.find((r) => r.id === update.id)!.moodLogWebhookSecret;
|
||||
expect(readMoodLogSecret(update.encrypted)).toBe(original);
|
||||
}
|
||||
|
||||
// Re-running on the now-encrypted store rotates nothing.
|
||||
const next = await rotateLegacyMoodLogSecrets({
|
||||
findLegacy: async () => [
|
||||
{ id: "u1", moodLogWebhookSecret: updates[0].encrypted },
|
||||
{ id: "u2", moodLogWebhookSecret: enc },
|
||||
],
|
||||
rotate: async () => {
|
||||
throw new Error("should not be called");
|
||||
},
|
||||
});
|
||||
expect(next).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// --- Mocks must be hoisted before importing the module under test. ---
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
apiToken: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({
|
||||
getSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/hmac", () => ({
|
||||
hashToken: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({
|
||||
emitIfSampled: vi.fn(),
|
||||
}));
|
||||
|
||||
const headersGet = vi.fn<(name: string) => string | null>();
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: headersGet })),
|
||||
cookies: vi.fn(async () => ({ get: () => undefined, set: () => {}, delete: () => {} })),
|
||||
}));
|
||||
|
||||
// --- Imports use the mocked modules above. ---
|
||||
|
||||
import { requireAuth, HttpError } from "../api-handler";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { hashToken } from "@/lib/auth/hmac";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
const FAKE_HASH = "deadbeefcafef00d";
|
||||
const RAW_TOKEN = "hlk_" + "a".repeat(64);
|
||||
|
||||
const FAKE_USER = {
|
||||
id: "user-1",
|
||||
role: "USER" as const,
|
||||
username: "marc",
|
||||
email: "marc@example.com",
|
||||
};
|
||||
|
||||
function setBearerHeader(value: string | null): void {
|
||||
headersGet.mockReset();
|
||||
headersGet.mockImplementation((name: string) =>
|
||||
name.toLowerCase() === "authorization" ? value : null,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(hashToken).mockReturnValue(FAKE_HASH);
|
||||
vi.mocked(prisma.apiToken.update).mockResolvedValue({} as never);
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined as never);
|
||||
});
|
||||
|
||||
describe("requireAuth — Bearer token path", () => {
|
||||
it("authenticates a valid Bearer token and returns AuthContext", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
setBearerHeader(`Bearer ${RAW_TOKEN}`);
|
||||
|
||||
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
|
||||
id: "token-1",
|
||||
userId: "user-1",
|
||||
permissions: ["medication:ingest"],
|
||||
revoked: false,
|
||||
expiresAt,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(FAKE_USER as never);
|
||||
|
||||
const ctx = await requireAuth();
|
||||
|
||||
expect(hashToken).toHaveBeenCalledWith(RAW_TOKEN);
|
||||
// V3 audit: pin the where-clause shape — a regression that switches
|
||||
// back to raw-token comparison (where: { tokenHash: RAW_TOKEN }) would
|
||||
// be a CRITICAL leak of stored hashes. Tests must enforce the hash.
|
||||
expect(prisma.apiToken.findUnique).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { tokenHash: FAKE_HASH } }),
|
||||
);
|
||||
expect(ctx.user.id).toBe("user-1");
|
||||
expect(ctx.session.id).toBe("token-1");
|
||||
expect(ctx.session.expiresAt).toEqual(expiresAt);
|
||||
|
||||
// lastUsedAt refresh is fire-and-forget but should still have been triggered.
|
||||
expect(prisma.apiToken.update).toHaveBeenCalledWith({
|
||||
where: { id: "token-1" },
|
||||
data: { lastUsedAt: expect.any(Date) },
|
||||
});
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"auth.bearer.success",
|
||||
expect.objectContaining({ userId: "user-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a revoked token with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
setBearerHeader(`Bearer ${RAW_TOKEN}`);
|
||||
|
||||
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
|
||||
id: "token-2",
|
||||
userId: "user-1",
|
||||
permissions: [],
|
||||
revoked: true,
|
||||
expiresAt: null,
|
||||
} as never);
|
||||
|
||||
await expect(requireAuth()).rejects.toMatchObject({
|
||||
statusCode: 401,
|
||||
} satisfies Partial<HttpError>);
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"auth.bearer.failure",
|
||||
expect.objectContaining({
|
||||
details: expect.objectContaining({ reason: "revoked" }),
|
||||
}),
|
||||
);
|
||||
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an expired token with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
setBearerHeader(`Bearer ${RAW_TOKEN}`);
|
||||
|
||||
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
|
||||
id: "token-3",
|
||||
userId: "user-1",
|
||||
permissions: [],
|
||||
revoked: false,
|
||||
expiresAt: new Date(Date.now() - 60_000),
|
||||
} as never);
|
||||
|
||||
await expect(requireAuth()).rejects.toMatchObject({ statusCode: 401 });
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"auth.bearer.failure",
|
||||
expect.objectContaining({
|
||||
details: expect.objectContaining({ reason: "expired" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an unknown token with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
setBearerHeader(`Bearer ${RAW_TOKEN}`);
|
||||
|
||||
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue(null);
|
||||
|
||||
await expect(requireAuth()).rejects.toMatchObject({ statusCode: 401 });
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"auth.bearer.failure",
|
||||
expect.objectContaining({
|
||||
details: expect.objectContaining({ reason: "unknown_token" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a Bearer token missing the requested permission with 403", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
setBearerHeader(`Bearer ${RAW_TOKEN}`);
|
||||
|
||||
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
|
||||
id: "token-4",
|
||||
userId: "user-1",
|
||||
permissions: ["something:else"],
|
||||
revoked: false,
|
||||
expiresAt: null,
|
||||
} as never);
|
||||
|
||||
await expect(requireAuth("medication:ingest")).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
});
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"auth.bearer.failure",
|
||||
expect.objectContaining({
|
||||
details: expect.objectContaining({
|
||||
reason: "insufficient_permissions",
|
||||
required: "medication:ingest",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 401 when neither cookie nor Bearer is present", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
setBearerHeader(null);
|
||||
|
||||
await expect(requireAuth()).rejects.toMatchObject({ statusCode: 401 });
|
||||
expect(prisma.apiToken.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("requireAuth — cookie path remains intact", () => {
|
||||
it("returns the session payload without consulting the Bearer header", async () => {
|
||||
const cookieSession = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3600_000) },
|
||||
user: { ...FAKE_USER, role: "USER" as const },
|
||||
};
|
||||
vi.mocked(getSession).mockResolvedValue(cookieSession as never);
|
||||
// Even if a Bearer header is present, the cookie wins (existing behaviour).
|
||||
setBearerHeader(`Bearer ${RAW_TOKEN}`);
|
||||
|
||||
const ctx = await requireAuth("medication:ingest");
|
||||
|
||||
expect(ctx).toEqual(cookieSession);
|
||||
// Cookie short-circuits before any token logic runs.
|
||||
expect(prisma.apiToken.findUnique).not.toHaveBeenCalled();
|
||||
expect(hashToken).not.toHaveBeenCalled();
|
||||
expect(auditLog).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -88,6 +88,108 @@ describe("getAllEffectiveRanges", () => {
|
||||
const keys = Object.keys(ranges);
|
||||
expect(keys).toContain("WEIGHT");
|
||||
expect(keys).toContain("BLOOD_GLUCOSE_FASTING");
|
||||
expect(keys.length).toBeGreaterThanOrEqual(11);
|
||||
expect(keys).toContain("TOTAL_BODY_WATER");
|
||||
expect(keys).toContain("BONE_MASS");
|
||||
expect(keys).toContain("OXYGEN_SATURATION");
|
||||
expect(keys.length).toBeGreaterThanOrEqual(14);
|
||||
});
|
||||
});
|
||||
|
||||
// Audit-2026-05-07 / phase P0 / closes audit C-15: TOTAL_BODY_WATER and
|
||||
// BONE_MASS lacked threshold definitions. Severity logic returned `nominal`
|
||||
// for any value, so users would see "all healthy" regardless of input.
|
||||
describe("body composition thresholds", () => {
|
||||
it("TOTAL_BODY_WATER has a sensible non-null green band", () => {
|
||||
const result = getEffectiveRange("TOTAL_BODY_WATER", baseProfile, null);
|
||||
expect(result.range).not.toBeNull();
|
||||
expect(result.range!.greenMin).toBeGreaterThan(0);
|
||||
expect(result.range!.greenMax).toBeGreaterThan(result.range!.greenMin);
|
||||
expect(result.range!.orangeMin).toBeLessThan(result.range!.greenMin);
|
||||
expect(result.range!.orangeMax).toBeGreaterThan(result.range!.greenMax);
|
||||
});
|
||||
|
||||
it("BONE_MASS has a sensible non-null green band", () => {
|
||||
const result = getEffectiveRange("BONE_MASS", baseProfile, null);
|
||||
expect(result.range).not.toBeNull();
|
||||
expect(result.range!.greenMin).toBeGreaterThan(0);
|
||||
expect(result.range!.greenMax).toBeGreaterThan(result.range!.greenMin);
|
||||
});
|
||||
|
||||
it("respects user override for body water", () => {
|
||||
const overrides: ThresholdOverridesJson = {
|
||||
TOTAL_BODY_WATER: { min: 30, max: 45 },
|
||||
};
|
||||
const result = getEffectiveRange(
|
||||
"TOTAL_BODY_WATER",
|
||||
baseProfile,
|
||||
overrides,
|
||||
);
|
||||
expect(result.isOverride).toBe(true);
|
||||
expect(result.range!.greenMin).toBe(30);
|
||||
expect(result.range!.greenMax).toBe(45);
|
||||
});
|
||||
|
||||
it("METRIC_BOUNDS for body composition match VALUE_RANGES in validation", () => {
|
||||
expect(METRIC_BOUNDS.TOTAL_BODY_WATER).toEqual({
|
||||
min: 5,
|
||||
max: 100,
|
||||
unit: "kg",
|
||||
});
|
||||
expect(METRIC_BOUNDS.BONE_MASS).toEqual({ min: 0.5, max: 8, unit: "kg" });
|
||||
});
|
||||
});
|
||||
|
||||
// Audit-2026-05-07 / v1.3.3: SpO2 (pulse oximetry) added as a single-value
|
||||
// metric with lower-only severity (saturation cannot exceed 100% by definition).
|
||||
// Defaults follow BTS Guideline 2017 (target 94–98%) and ATS clinical practice
|
||||
// (≥95% at rest is healthy). Below 92% triggers orange band; below 88% in
|
||||
// real-world clinical literature is the ER threshold.
|
||||
describe("OXYGEN_SATURATION thresholds", () => {
|
||||
it("has a non-null green band centred on the BTS healthy range", () => {
|
||||
const result = getEffectiveRange("OXYGEN_SATURATION", baseProfile, null);
|
||||
expect(result.range).not.toBeNull();
|
||||
expect(result.range!.greenMin).toBe(95);
|
||||
expect(result.range!.greenMax).toBe(100);
|
||||
});
|
||||
|
||||
it("collapses upper orange wing to greenMax (no upper concern)", () => {
|
||||
const result = getEffectiveRange("OXYGEN_SATURATION", baseProfile, null);
|
||||
expect(result.range!.orangeMax).toBe(result.range!.greenMax);
|
||||
});
|
||||
|
||||
it("respects user override (e.g. COPD baseline 88-92)", () => {
|
||||
const overrides: ThresholdOverridesJson = {
|
||||
OXYGEN_SATURATION: { min: 88, max: 92 },
|
||||
};
|
||||
const result = getEffectiveRange(
|
||||
"OXYGEN_SATURATION",
|
||||
baseProfile,
|
||||
overrides,
|
||||
);
|
||||
expect(result.isOverride).toBe(true);
|
||||
expect(result.range!.greenMin).toBe(88);
|
||||
expect(result.range!.greenMax).toBe(92);
|
||||
});
|
||||
|
||||
it("clamps override orangeMax to physiological 100% (no impossible saturations)", () => {
|
||||
// Without the bounds clamp this used to emit orangeMax = 100.75.
|
||||
const overrides: ThresholdOverridesJson = {
|
||||
OXYGEN_SATURATION: { min: 95, max: 100 },
|
||||
};
|
||||
const result = getEffectiveRange(
|
||||
"OXYGEN_SATURATION",
|
||||
baseProfile,
|
||||
overrides,
|
||||
);
|
||||
expect(result.range!.orangeMax).toBeLessThanOrEqual(100);
|
||||
expect(result.range!.orangeMin).toBeGreaterThanOrEqual(50);
|
||||
});
|
||||
|
||||
it("METRIC_BOUNDS plausibility floor is 50% (incompatible-with-life below)", () => {
|
||||
expect(METRIC_BOUNDS.OXYGEN_SATURATION).toEqual({
|
||||
min: 50,
|
||||
max: 100,
|
||||
unit: "%",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -37,7 +37,10 @@ export type ThresholdMetric =
|
||||
| "BLOOD_GLUCOSE_FASTING"
|
||||
| "BLOOD_GLUCOSE_POSTPRANDIAL"
|
||||
| "BLOOD_GLUCOSE_RANDOM"
|
||||
| "BLOOD_GLUCOSE_BEDTIME";
|
||||
| "BLOOD_GLUCOSE_BEDTIME"
|
||||
| "TOTAL_BODY_WATER"
|
||||
| "BONE_MASS"
|
||||
| "OXYGEN_SATURATION";
|
||||
|
||||
export interface ThresholdOverride {
|
||||
min: number;
|
||||
@@ -79,6 +82,14 @@ export const METRIC_BOUNDS: Record<
|
||||
BLOOD_GLUCOSE_POSTPRANDIAL: { min: 40, max: 500, unit: "mg/dL" },
|
||||
BLOOD_GLUCOSE_RANDOM: { min: 40, max: 500, unit: "mg/dL" },
|
||||
BLOOD_GLUCOSE_BEDTIME: { min: 40, max: 400, unit: "mg/dL" },
|
||||
// Body composition (Withings type 77 / 88, stored canonically in kg).
|
||||
// Bounds match VALUE_RANGES in src/lib/validations/measurement.ts.
|
||||
TOTAL_BODY_WATER: { min: 5, max: 100, unit: "kg" },
|
||||
BONE_MASS: { min: 0.5, max: 8, unit: "kg" },
|
||||
// Pulse oximetry (Withings ScanWatch reports type 54). Plausibility floor
|
||||
// 50% to allow truly critical readings to be logged; saturation cannot
|
||||
// exceed 100% by physical definition.
|
||||
OXYGEN_SATURATION: { min: 50, max: 100, unit: "%" },
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -163,8 +174,11 @@ function defaultRange(
|
||||
// AASM: 7–9h for adults; warning yellow either side.
|
||||
return { greenMin: 7, greenMax: 9, orangeMin: 6, orangeMax: 10 };
|
||||
case "ACTIVITY_STEPS":
|
||||
// WHO: ≥8000 steps/day; no upper bound in reality, but orange over 25k
|
||||
// for edge detection.
|
||||
// ≥8000 steps/day per Saint-Maurice et al., JAMA 2020 (mortality
|
||||
// plateau 8000–12000 steps). WHO 2020 PA guidelines publish minutes
|
||||
// per week (150–300 min moderate / 75–150 min vigorous) — *not* a
|
||||
// step quota. No upper bound in reality, orange over 25k caps edge
|
||||
// detection.
|
||||
return { greenMin: 8000, greenMax: 15000, orangeMin: 5000, orangeMax: 25000 };
|
||||
case "BLOOD_GLUCOSE_FASTING":
|
||||
return glucoseRange(GLUCOSE_DEFAULTS.FASTING, 125); // pre-diabetes upper bound
|
||||
@@ -177,6 +191,33 @@ function defaultRange(
|
||||
GLUCOSE_DEFAULTS.BEDTIME,
|
||||
GLUCOSE_DEFAULTS.BEDTIME.max + 30,
|
||||
);
|
||||
case "TOTAL_BODY_WATER":
|
||||
// Adult total body water typically ~50% of body weight in kg
|
||||
// (Watson formula / ICRP Reference Man: ~42 L male, ~30 L female).
|
||||
// Without per-user weight context the gender-neutral band is wide
|
||||
// by design; users tighten via threshold override.
|
||||
return { greenMin: 28, greenMax: 50, orangeMin: 22, orangeMax: 55 };
|
||||
case "BONE_MASS":
|
||||
// Bioimpedance-estimated bone mass from a Withings-class scale —
|
||||
// NOT DEXA-comparable (BIA-derived values typically run 5–7% below
|
||||
// DEXA bone-mineral-content). Adult BIA-typical 2.0–4.0 kg (women
|
||||
// slightly lower than men). Wide orange band before surfacing as a
|
||||
// concern given BIA's intrinsic noise.
|
||||
return { greenMin: 2.0, greenMax: 4.0, orangeMin: 1.5, orangeMax: 5.0 };
|
||||
case "OXYGEN_SATURATION":
|
||||
// Conservative band aligned with consumer pulse-oximeter consensus
|
||||
// (≥95% normal at rest); slightly tighter than the BTS Emergency
|
||||
// Oxygen Guideline 2017 explicit treatment target of 94–98%, looser
|
||||
// than the WHO ≥90% acute-care floor. Lower-only concern: ≤92% is
|
||||
// the NICE NG115 escalation threshold ("call provider"); ≤88%
|
||||
// (BMJ panel / FDA pulse-oximeter labelling) is the ER threshold.
|
||||
// Saturation is bounded above by 100% physically, so we collapse
|
||||
// the upper orange wing onto greenMax — severity logic only fires
|
||||
// for hypoxemia.
|
||||
// COPD / chronic-respiratory-failure / high-altitude users
|
||||
// typically run 88–92% and should personalise via the
|
||||
// threshold-override UI (saved in User.thresholdsJson).
|
||||
return { greenMin: 95, greenMax: 100, orangeMin: 92, orangeMax: 100 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,7 +262,10 @@ export function getEffectiveRange(
|
||||
return { range: fallback, isOverride: false, default: fallback, bounds };
|
||||
}
|
||||
|
||||
// User override replaces the green band. Orange wings stretch 10% below/above.
|
||||
// User override replaces the green band. Orange wings stretch 15% below/above,
|
||||
// clamped to the metric's physiological bounds — without the clamp, an
|
||||
// override like SpO2 {95,100} would emit orangeMax = 100.75 (impossible
|
||||
// saturation) and BODY_FAT {2,80} would emit orangeMin = -9.7.
|
||||
const span = Math.max(1, override.max - override.min);
|
||||
const orangeWidth = span * 0.15;
|
||||
|
||||
@@ -229,8 +273,8 @@ export function getEffectiveRange(
|
||||
range: {
|
||||
greenMin: override.min,
|
||||
greenMax: override.max,
|
||||
orangeMin: override.min - orangeWidth,
|
||||
orangeMax: override.max + orangeWidth,
|
||||
orangeMin: Math.max(bounds.min, override.min - orangeWidth),
|
||||
orangeMax: Math.min(bounds.max, override.max + orangeWidth),
|
||||
},
|
||||
isOverride: true,
|
||||
default: fallback,
|
||||
|
||||
+199
-26
@@ -1,8 +1,13 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { headers } from "next/headers";
|
||||
import type { User } from "@/generated/prisma/client";
|
||||
import { WideEventBuilder } from "./logging/event-builder";
|
||||
import { eventStorage, getEvent } from "./logging/context";
|
||||
import { emitIfSampled } from "./logging/transports";
|
||||
import { getSession } from "./auth/session";
|
||||
import { hashToken } from "./auth/hmac";
|
||||
import { prisma } from "./db";
|
||||
import { auditLog } from "./auth/audit";
|
||||
|
||||
/**
|
||||
* Custom error class for HTTP errors with status codes.
|
||||
@@ -98,17 +103,187 @@ export function apiHandler<T extends (...args: any[]) => Promise<Response>>(
|
||||
}
|
||||
|
||||
/**
|
||||
* Require an authenticated session. Throws HttpError(401) if not authenticated.
|
||||
* Authenticated request context. Returned by both session-cookie and Bearer-token
|
||||
* authentication paths. The `session.id` is the session-record id for cookie auth
|
||||
* and the `ApiToken` id for bearer auth — callers must not assume the id refers
|
||||
* to a Session row.
|
||||
*/
|
||||
export type AuthContext = {
|
||||
session: { id: string; expiresAt: Date };
|
||||
user: User;
|
||||
};
|
||||
|
||||
/**
|
||||
* Require an authenticated request. Throws HttpError(401) / HttpError(403) on failure.
|
||||
*
|
||||
* Auth precedence (cookie-first, never both):
|
||||
* 1. Valid session cookie → cookie path (existing behaviour, requiredPermission ignored).
|
||||
* 2. No cookie + `Authorization: Bearer hlk_<...>` → API token path.
|
||||
* 3. Neither → 401.
|
||||
*
|
||||
* @param requiredPermission Optional permission scope. Only enforced for Bearer
|
||||
* auth — cookie sessions always pass (full user access). When set and missing
|
||||
* from `ApiToken.permissions`, throws HttpError(403).
|
||||
*/
|
||||
export async function requireAuth(
|
||||
requiredPermission?: string,
|
||||
): Promise<AuthContext> {
|
||||
// 1. Session cookie path — unchanged.
|
||||
const sessionData = await getSession();
|
||||
if (sessionData) {
|
||||
const evt = getEvent();
|
||||
if (evt) {
|
||||
evt.setAuth({
|
||||
user_id: sessionData.user.id,
|
||||
user_role: sessionData.user.role,
|
||||
auth_method: "session",
|
||||
});
|
||||
}
|
||||
return sessionData;
|
||||
}
|
||||
|
||||
// 2. Bearer-token path.
|
||||
// `headers()` is only valid inside a Next.js request scope. Outside one
|
||||
// (e.g. during direct unit tests of legacy routes that pre-date Bearer auth)
|
||||
// we treat the absence of a header context as "no Bearer present" and fall
|
||||
// through to the unauthenticated case below.
|
||||
let authHeader: string | null = null;
|
||||
try {
|
||||
const headerList = await headers();
|
||||
authHeader = headerList.get("authorization");
|
||||
} catch {
|
||||
authHeader = null;
|
||||
}
|
||||
if (authHeader?.startsWith("Bearer ")) {
|
||||
return await authenticateBearer(authHeader.slice(7), requiredPermission);
|
||||
}
|
||||
|
||||
// 3. No credentials.
|
||||
throw new HttpError(401, "Not authenticated");
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate a raw Bearer token against `ApiToken`.
|
||||
* Annotates the Wide Event with `auth_method: "api_key"` and writes audit-log
|
||||
* entries for both success and failure.
|
||||
*/
|
||||
async function authenticateBearer(
|
||||
rawToken: string,
|
||||
requiredPermission: string | undefined,
|
||||
): Promise<AuthContext> {
|
||||
const tokenHashValue = hashToken(rawToken);
|
||||
|
||||
const apiToken = await prisma.apiToken.findUnique({
|
||||
where: { tokenHash: tokenHashValue },
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
permissions: true,
|
||||
revoked: true,
|
||||
expiresAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!apiToken) {
|
||||
auditLog("auth.bearer.failure", {
|
||||
details: { reason: "unknown_token" },
|
||||
}).catch(() => {});
|
||||
throw new HttpError(401, "Invalid token");
|
||||
}
|
||||
|
||||
if (apiToken.revoked) {
|
||||
auditLog("auth.bearer.failure", {
|
||||
userId: apiToken.userId,
|
||||
details: { reason: "revoked", tokenId: apiToken.id },
|
||||
}).catch(() => {});
|
||||
throw new HttpError(401, "Invalid token");
|
||||
}
|
||||
|
||||
if (apiToken.expiresAt && apiToken.expiresAt <= new Date()) {
|
||||
auditLog("auth.bearer.failure", {
|
||||
userId: apiToken.userId,
|
||||
details: { reason: "expired", tokenId: apiToken.id },
|
||||
}).catch(() => {});
|
||||
throw new HttpError(401, "Token expired");
|
||||
}
|
||||
|
||||
// Audit V3 NEW-V3-1 fix: `["*"]` is a real wildcard — it grants the
|
||||
// session-equivalent scope (the iOS app receives this on login). Without
|
||||
// the wildcard branch, EVERY future requireAuth("scope:name") call would
|
||||
// 403 every iOS-issued token because string-literal `.includes("*"...)`
|
||||
// never matches. Worse: today many sensitive routes call requireAuth()
|
||||
// *without* a requiredPermission, so a leaked iOS token can act as a
|
||||
// full-scope token (account delete, settings wipe). Once those routes
|
||||
// adopt requireAuth("scope:name"), the wildcard handling here keeps
|
||||
// the iOS app working while narrower-scoped tokens (e.g. ["medication:
|
||||
// ingest"]) get correctly 403'd.
|
||||
if (
|
||||
requiredPermission &&
|
||||
!apiToken.permissions.includes("*") &&
|
||||
!apiToken.permissions.includes(requiredPermission)
|
||||
) {
|
||||
auditLog("auth.bearer.failure", {
|
||||
userId: apiToken.userId,
|
||||
details: {
|
||||
reason: "insufficient_permissions",
|
||||
tokenId: apiToken.id,
|
||||
required: requiredPermission,
|
||||
},
|
||||
}).catch(() => {});
|
||||
throw new HttpError(403, "Insufficient permissions");
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: apiToken.userId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
auditLog("auth.bearer.failure", {
|
||||
userId: apiToken.userId,
|
||||
details: { reason: "user_missing", tokenId: apiToken.id },
|
||||
}).catch(() => {});
|
||||
throw new HttpError(401, "Invalid token");
|
||||
}
|
||||
|
||||
// Fire-and-forget: refresh lastUsedAt without blocking the request.
|
||||
prisma.apiToken
|
||||
.update({
|
||||
where: { id: apiToken.id },
|
||||
data: { lastUsedAt: new Date() },
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
auditLog("auth.bearer.success", {
|
||||
userId: user.id,
|
||||
details: { tokenId: apiToken.id },
|
||||
}).catch(() => {});
|
||||
|
||||
const evt = getEvent();
|
||||
if (evt) {
|
||||
evt.setAuth({
|
||||
user_id: user.id,
|
||||
user_role: user.role,
|
||||
auth_method: "bearer",
|
||||
});
|
||||
}
|
||||
|
||||
// Use the token expiry as the session expiry; fall back to a 30-day window if
|
||||
// the token has no fixed expiry so the contract `{ expiresAt: Date }` holds.
|
||||
const expiresAt =
|
||||
apiToken.expiresAt ?? new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
return {
|
||||
session: { id: apiToken.id, expiresAt },
|
||||
user,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Require an authenticated admin user. Throws HttpError(401) or HttpError(403).
|
||||
* Cookie-only — Bearer tokens never elevate to admin (security boundary).
|
||||
* Automatically annotates the Wide Event with auth context.
|
||||
*/
|
||||
export async function requireAuth(): Promise<{
|
||||
session: { id: string; expiresAt: Date };
|
||||
user: Awaited<ReturnType<typeof getSession>> extends infer R
|
||||
? R extends { user: infer U }
|
||||
? U
|
||||
: never
|
||||
: never;
|
||||
}> {
|
||||
export async function requireAdmin(): Promise<AuthContext> {
|
||||
const sessionData = await getSession();
|
||||
if (!sessionData) throw new HttpError(401, "Not authenticated");
|
||||
|
||||
@@ -121,22 +296,6 @@ export async function requireAuth(): Promise<{
|
||||
});
|
||||
}
|
||||
|
||||
return sessionData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Require an authenticated admin user. Throws HttpError(401) or HttpError(403).
|
||||
* Automatically annotates the Wide Event with auth context.
|
||||
*/
|
||||
export async function requireAdmin(): Promise<{
|
||||
session: { id: string; expiresAt: Date };
|
||||
user: Awaited<ReturnType<typeof getSession>> extends infer R
|
||||
? R extends { user: infer U }
|
||||
? U
|
||||
: never
|
||||
: never;
|
||||
}> {
|
||||
const sessionData = await requireAuth();
|
||||
if (sessionData.user.role !== "ADMIN") {
|
||||
throw new HttpError(403, "Admin access required");
|
||||
}
|
||||
@@ -168,6 +327,20 @@ async function reportToGlitchtip(
|
||||
// Skip expected errors from bot scanners (malformed JSON bodies)
|
||||
if (err instanceof SyntaxError) return;
|
||||
|
||||
// Audit H-B7 / phase P2: strip the query string before forwarding to
|
||||
// GlitchTip. Withings legacy callbacks ship `?secret=…` (see C-3) and
|
||||
// OAuth callbacks ship `?code=…&state=…`; if any of those error we
|
||||
// don't want their secrets in someone's incident UI.
|
||||
let scrubbedUrl = request.url;
|
||||
try {
|
||||
const u = new URL(request.url);
|
||||
u.search = "";
|
||||
scrubbedUrl = u.toString();
|
||||
} catch {
|
||||
// Invalid URL — fall through with the raw value (only happens in
|
||||
// degenerate test fixtures).
|
||||
}
|
||||
|
||||
await sendGlitchtipEvent({
|
||||
dsn: settings.glitchtipDsn,
|
||||
input: {
|
||||
@@ -176,7 +349,7 @@ async function reportToGlitchtip(
|
||||
level: "error",
|
||||
type: err.name || "Error",
|
||||
stack: err.stack,
|
||||
url: request.url,
|
||||
url: scrubbedUrl,
|
||||
sourceTag: "healthlog-api-handler",
|
||||
requestId: evt.getRequestId(),
|
||||
},
|
||||
|
||||
+57
-3
@@ -27,8 +27,62 @@ export async function safeJson<T = unknown>(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the real client IP from a request, respecting trusted-proxy
|
||||
* configuration. V3 audit: previously took the leftmost XFF entry blindly,
|
||||
* letting a client rotate `X-Forwarded-For: 1.2.3.4` per request to defeat
|
||||
* IP-based rate-limits.
|
||||
*
|
||||
* Trust model (`TRUST_PROXY_HOPS` env):
|
||||
* - "0" → ignore XFF entirely, fall back to x-real-ip / null
|
||||
* (use this if HealthLog is internet-facing without a
|
||||
* reverse proxy you control)
|
||||
* - "1" (default)→ trust exactly one hop (typical Coolify / Caddy /
|
||||
* Cloudflare-Tunnel single-proxy deployment); read the
|
||||
* rightmost XFF entry which is the IP your proxy
|
||||
* observed when the request arrived.
|
||||
* - "N" (>1) → trust N hops; read the Nth-from-rightmost XFF entry.
|
||||
*
|
||||
* Returns the resolved IP or null when no trusted source is available.
|
||||
*/
|
||||
function looksLikeIp(s: string): boolean {
|
||||
return /^[0-9a-fA-F.:]+$/.test(s) && s.length >= 3 && s.length <= 45;
|
||||
}
|
||||
|
||||
function parseTrustProxyHops(raw: string | undefined): number {
|
||||
if (raw === undefined) return 1;
|
||||
const trimmed = raw.trim();
|
||||
if (!/^\d+$/.test(trimmed)) {
|
||||
// Reject explicitly-invalid values so an operator typo doesn't silently
|
||||
// switch a real-proxy deployment to "no XFF trust" mode (review
|
||||
// finding L-1: TRUST_PROXY_HOPS=garbage degraded to hops=0 silently).
|
||||
throw new Error(
|
||||
`TRUST_PROXY_HOPS must be a non-negative integer, got: ${JSON.stringify(raw)}`,
|
||||
);
|
||||
}
|
||||
return parseInt(trimmed, 10);
|
||||
}
|
||||
|
||||
export function getClientIp(request: Request): string | null {
|
||||
const forwarded = request.headers.get("x-forwarded-for");
|
||||
if (forwarded) return forwarded.split(",")[0].trim();
|
||||
return request.headers.get("x-real-ip");
|
||||
const hops = parseTrustProxyHops(process.env.TRUST_PROXY_HOPS);
|
||||
|
||||
if (hops > 0) {
|
||||
const forwarded = request.headers.get("x-forwarded-for");
|
||||
if (forwarded) {
|
||||
const chain = forwarded
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(looksLikeIp);
|
||||
// Review finding M-3: when the chain is shorter than the configured
|
||||
// hops count, the operator misconfigured TRUST_PROXY_HOPS or a
|
||||
// proxy was bypassed. Falling back to the leftmost (now
|
||||
// attacker-controlled) entry would re-introduce the very rotation
|
||||
// attack TRUST_PROXY_HOPS was meant to close. Refuse to read XFF.
|
||||
if (chain.length >= hops) {
|
||||
return chain[chain.length - hops];
|
||||
}
|
||||
}
|
||||
}
|
||||
const realIp = request.headers.get("x-real-ip");
|
||||
return realIp && looksLikeIp(realIp) ? realIp : null;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Helper for issuing an `ApiToken` for a user — currently used by the
|
||||
* native-client auto-login flow (login + passkey login-verify) so the iOS
|
||||
* app can talk to bearer-protected routes without juggling the session
|
||||
* cookie. The raw `hlk_<hex>` value is returned exactly once.
|
||||
*/
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { hashToken } from "@/lib/auth/hmac";
|
||||
|
||||
export interface IssuedToken {
|
||||
token: string;
|
||||
expiresAt: Date;
|
||||
tokenId: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface IssueTokenOptions {
|
||||
userId: string;
|
||||
/** Token row name (also surfaced to the user under /api/tokens). */
|
||||
name: string;
|
||||
/** Default permissions. `["*"]` = unrestricted (matches cookie scope). */
|
||||
permissions?: string[];
|
||||
/** Lifetime in days. */
|
||||
expiresInDays?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new `hlk_<64 hex>` token, store its hash, and return the raw
|
||||
* value to the caller. The plaintext token is never persisted and must
|
||||
* never be logged.
|
||||
*/
|
||||
export async function issueApiToken(
|
||||
opts: IssueTokenOptions,
|
||||
): Promise<IssuedToken> {
|
||||
const rawToken = `hlk_${randomBytes(32).toString("hex")}`;
|
||||
const tokenHashValue = hashToken(rawToken);
|
||||
const expiresAt = new Date(
|
||||
Date.now() + (opts.expiresInDays ?? 90) * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
|
||||
const created = await prisma.apiToken.create({
|
||||
data: {
|
||||
userId: opts.userId,
|
||||
name: opts.name,
|
||||
tokenHash: tokenHashValue,
|
||||
permissions: opts.permissions ?? ["*"],
|
||||
expiresAt,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
return {
|
||||
token: rawToken,
|
||||
expiresAt,
|
||||
tokenId: created.id,
|
||||
name: opts.name,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether the request is from a native client and should receive a
|
||||
* bearer token in the login response. Two opt-in signals:
|
||||
* - `X-Client-Type: native`
|
||||
* - `User-Agent: HealthLog-iOS/...`
|
||||
*/
|
||||
export function isNativeClientRequest(headers: Headers): boolean {
|
||||
const xClient = headers.get("x-client-type");
|
||||
if (xClient && xClient.toLowerCase() === "native") return true;
|
||||
|
||||
const ua = headers.get("user-agent") ?? "";
|
||||
if (ua.startsWith("HealthLog-iOS")) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Shared profile-update logic. The web client hits `/api/auth/profile` PUT
|
||||
* and the iOS client hits `/api/user/profile` PATCH — both funnel through
|
||||
* `applyProfileUpdate` so we don't fork the validation/audit path.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { profileSchema } from "@/lib/validations/auth";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
const extendedProfileSchema = profileSchema.extend({
|
||||
displayName: z.string().min(1).max(80).nullable().optional(),
|
||||
locale: z.enum(["de", "en"]).nullable().optional(),
|
||||
timezone: z.string().min(1).max(64).optional(),
|
||||
});
|
||||
|
||||
export type ApplyProfileInput = z.infer<typeof extendedProfileSchema>;
|
||||
|
||||
export interface ApplyProfileResult {
|
||||
ok: true;
|
||||
user: {
|
||||
id: string;
|
||||
username: string;
|
||||
displayName: string | null;
|
||||
email: string | null;
|
||||
role: string;
|
||||
heightCm: number | null;
|
||||
dateOfBirth: Date | null;
|
||||
gender: string | null;
|
||||
timezone: string;
|
||||
locale: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ApplyProfileError {
|
||||
ok: false;
|
||||
status: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and persist profile updates for `userId`. Returns either an
|
||||
* `ApplyProfileResult` on success or `ApplyProfileError` with a status
|
||||
* code so callers can shape the wire response themselves.
|
||||
*/
|
||||
export async function applyProfileUpdate(
|
||||
userId: string,
|
||||
body: unknown,
|
||||
ipAddress?: string | null,
|
||||
): Promise<ApplyProfileResult | ApplyProfileError> {
|
||||
const parsed = extendedProfileSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 422,
|
||||
message: parsed.error.issues[0]?.message ?? "Validation error",
|
||||
};
|
||||
}
|
||||
|
||||
const data = parsed.data;
|
||||
const normalizedEmail = data.email ? data.email.trim().toLowerCase() : null;
|
||||
|
||||
if (data.email !== undefined && normalizedEmail) {
|
||||
const existing = await prisma.user.findUnique({
|
||||
where: { email: normalizedEmail },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing && existing.id !== userId) {
|
||||
return { ok: false, status: 409, message: "Email already in use" };
|
||||
}
|
||||
}
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (data.email !== undefined) updates.email = normalizedEmail;
|
||||
if (data.heightCm !== undefined) updates.heightCm = data.heightCm ?? null;
|
||||
if (data.dateOfBirth !== undefined) {
|
||||
updates.dateOfBirth = data.dateOfBirth ? new Date(data.dateOfBirth) : null;
|
||||
}
|
||||
if (data.gender !== undefined) updates.gender = data.gender;
|
||||
if (data.displayName !== undefined) {
|
||||
updates.displayName =
|
||||
data.displayName === null || data.displayName === ""
|
||||
? null
|
||||
: data.displayName.trim();
|
||||
}
|
||||
if (data.locale !== undefined) updates.locale = data.locale;
|
||||
if (data.timezone !== undefined) updates.timezone = data.timezone;
|
||||
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: updates,
|
||||
});
|
||||
|
||||
await auditLog("profile.update", {
|
||||
userId: updatedUser.id,
|
||||
ipAddress: ipAddress ?? null,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
user: {
|
||||
id: updatedUser.id,
|
||||
username: updatedUser.username,
|
||||
displayName: updatedUser.displayName,
|
||||
email: updatedUser.email,
|
||||
role: updatedUser.role,
|
||||
heightCm: updatedUser.heightCm,
|
||||
dateOfBirth: updatedUser.dateOfBirth,
|
||||
gender: updatedUser.gender,
|
||||
timezone: updatedUser.timezone,
|
||||
locale: updatedUser.locale,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -17,7 +17,8 @@ export type DashboardWidgetId =
|
||||
| "glucose"
|
||||
| "totalBodyWater"
|
||||
| "boneMass"
|
||||
| "bpInTarget";
|
||||
| "bpInTarget"
|
||||
| "oxygenSaturation";
|
||||
|
||||
export interface DashboardWidgetConfig {
|
||||
id: DashboardWidgetId;
|
||||
@@ -52,6 +53,7 @@ export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayout = {
|
||||
{ id: "glucose", visible: false, order: 9 },
|
||||
{ id: "totalBodyWater", visible: false, order: 10 },
|
||||
{ id: "boneMass", visible: false, order: 11 },
|
||||
{ id: "oxygenSaturation", visible: false, order: 12 },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* Server-side aggregator for doctor-report data.
|
||||
*
|
||||
* Single source of truth for the aggregated payload consumed by both
|
||||
* `/api/doctor-report` (JSON, client renders PDF) and
|
||||
* `/api/doctor-report/pdf` (server-rendered PDF). Keeps the two endpoints
|
||||
* structurally identical so visual parity between client- and server-rendered
|
||||
* PDFs is guaranteed by construction, not by drift-prone copy-paste.
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
getEffectiveRange,
|
||||
type ThresholdOverridesJson,
|
||||
} from "@/lib/analytics/effective-range";
|
||||
import {
|
||||
resolveGlucoseUnit,
|
||||
thresholdMetricForContext,
|
||||
type GlucoseUnit,
|
||||
} from "@/lib/glucose";
|
||||
import type { GlucoseContext } from "@/generated/prisma/client";
|
||||
|
||||
export interface DoctorReportStats {
|
||||
avg: number;
|
||||
min: number;
|
||||
max: number;
|
||||
count: number;
|
||||
latest: number;
|
||||
}
|
||||
|
||||
export interface DoctorReportCompliance {
|
||||
total: number;
|
||||
taken: number;
|
||||
skipped: number;
|
||||
missed: number;
|
||||
}
|
||||
|
||||
export interface DoctorReportMood {
|
||||
avg: number;
|
||||
min: number;
|
||||
max: number;
|
||||
count: number;
|
||||
distribution: Record<number, number>;
|
||||
}
|
||||
|
||||
export interface DoctorReportData {
|
||||
period: { days: number; since: string };
|
||||
patient: {
|
||||
username: string | null;
|
||||
dateOfBirth: string | null;
|
||||
gender: string | null;
|
||||
heightCm: number | null;
|
||||
};
|
||||
measurements: Record<string, Array<{ value: number; measuredAt: string }>>;
|
||||
stats: Record<string, DoctorReportStats>;
|
||||
glucoseStats: Record<string, DoctorReportStats>;
|
||||
glucoseRanges: Record<string, { min: number; max: number }>;
|
||||
glucoseUnit: GlucoseUnit;
|
||||
bmi: number | null;
|
||||
compliance: Record<string, DoctorReportCompliance>;
|
||||
medications: Array<{
|
||||
name: string;
|
||||
dose: string;
|
||||
schedules: Array<{
|
||||
windowStart: string;
|
||||
windowEnd: string;
|
||||
label: string | null;
|
||||
}>;
|
||||
}>;
|
||||
mood: DoctorReportMood | null;
|
||||
}
|
||||
|
||||
const GLUCOSE_CONTEXTS: GlucoseContext[] = [
|
||||
"FASTING",
|
||||
"POSTPRANDIAL",
|
||||
"RANDOM",
|
||||
"BEDTIME",
|
||||
];
|
||||
|
||||
/**
|
||||
* Validate and normalise the requested reporting window.
|
||||
* Accepts an unknown value (typically `body.days`); falls back to 90.
|
||||
*/
|
||||
export function normaliseDays(value: unknown, fallback = 90): number {
|
||||
if (
|
||||
typeof value === "number" &&
|
||||
Number.isInteger(value) &&
|
||||
value >= 1 &&
|
||||
value <= 365
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate the doctor-report payload for a user over the last `days` days.
|
||||
* Pure data assembly — no auth, no rate-limit, no audit. Idempotent.
|
||||
*/
|
||||
export async function collectDoctorReportData(
|
||||
userId: string,
|
||||
days: number,
|
||||
): Promise<DoctorReportData> {
|
||||
const since = new Date();
|
||||
since.setDate(since.getDate() - days);
|
||||
|
||||
const [measurements, medications, intakeEvents, moodEntries, userProfile] =
|
||||
await Promise.all([
|
||||
prisma.measurement.findMany({
|
||||
where: { userId, measuredAt: { gte: since } },
|
||||
orderBy: { measuredAt: "asc" },
|
||||
}),
|
||||
prisma.medication.findMany({
|
||||
where: { userId, active: true },
|
||||
include: { schedules: true },
|
||||
}),
|
||||
prisma.medicationIntakeEvent.findMany({
|
||||
where: { userId, scheduledFor: { gte: since } },
|
||||
include: { medication: { select: { name: true } } },
|
||||
orderBy: { scheduledFor: "asc" },
|
||||
}),
|
||||
prisma.moodEntry.findMany({
|
||||
where: { userId, moodLoggedAt: { gte: since } },
|
||||
orderBy: { moodLoggedAt: "asc" },
|
||||
}),
|
||||
prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
username: true,
|
||||
dateOfBirth: true,
|
||||
gender: true,
|
||||
heightCm: true,
|
||||
glucoseUnit: true,
|
||||
thresholdsJson: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
// Group measurements by type.
|
||||
const byType: Record<
|
||||
string,
|
||||
Array<{ value: number; measuredAt: string }>
|
||||
> = {};
|
||||
for (const m of measurements) {
|
||||
if (!byType[m.type]) byType[m.type] = [];
|
||||
byType[m.type].push({
|
||||
value: m.value,
|
||||
measuredAt: m.measuredAt.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Per-type stats.
|
||||
const stats: Record<string, DoctorReportStats> = {};
|
||||
for (const [type, entries] of Object.entries(byType)) {
|
||||
const values = entries.map((e) => e.value);
|
||||
stats[type] = {
|
||||
avg: values.reduce((a, b) => a + b, 0) / values.length,
|
||||
min: Math.min(...values),
|
||||
max: Math.max(...values),
|
||||
count: values.length,
|
||||
latest: values[values.length - 1],
|
||||
};
|
||||
}
|
||||
|
||||
// Medication compliance.
|
||||
const compliance: Record<string, DoctorReportCompliance> = {};
|
||||
for (const event of intakeEvents) {
|
||||
const name = event.medication.name;
|
||||
if (!compliance[name]) {
|
||||
compliance[name] = { total: 0, taken: 0, skipped: 0, missed: 0 };
|
||||
}
|
||||
compliance[name].total++;
|
||||
if (event.takenAt) {
|
||||
compliance[name].taken++;
|
||||
} else if (event.skipped) {
|
||||
compliance[name].skipped++;
|
||||
} else {
|
||||
compliance[name].missed++;
|
||||
}
|
||||
}
|
||||
|
||||
// Mood summary.
|
||||
const moodScores = moodEntries.map((e) => e.score);
|
||||
const mood: DoctorReportMood | null =
|
||||
moodScores.length > 0
|
||||
? {
|
||||
avg: moodScores.reduce((a, b) => a + b, 0) / moodScores.length,
|
||||
min: Math.min(...moodScores),
|
||||
max: Math.max(...moodScores),
|
||||
count: moodScores.length,
|
||||
distribution: {
|
||||
1: moodScores.filter((s) => s === 1).length,
|
||||
2: moodScores.filter((s) => s === 2).length,
|
||||
3: moodScores.filter((s) => s === 3).length,
|
||||
4: moodScores.filter((s) => s === 4).length,
|
||||
5: moodScores.filter((s) => s === 5).length,
|
||||
},
|
||||
}
|
||||
: null;
|
||||
|
||||
// BMI from latest weight + profile height.
|
||||
const weightStats = stats.WEIGHT;
|
||||
const bmiRaw =
|
||||
weightStats && userProfile?.heightCm
|
||||
? weightStats.latest / (userProfile.heightCm / 100) ** 2
|
||||
: null;
|
||||
const bmi = bmiRaw !== null ? Math.round(bmiRaw * 10) / 10 : null;
|
||||
|
||||
// Per-context glucose stats + effective ranges (canonical mg/dL).
|
||||
const glucoseStats: Record<string, DoctorReportStats> = {};
|
||||
const glucoseRanges: Record<string, { min: number; max: number }> = {};
|
||||
const glucoseRows = measurements.filter((m) => m.type === "BLOOD_GLUCOSE");
|
||||
const overrides = (userProfile?.thresholdsJson ??
|
||||
null) as ThresholdOverridesJson | null;
|
||||
const profileForRange = {
|
||||
heightCm: userProfile?.heightCm ?? null,
|
||||
dateOfBirth: userProfile?.dateOfBirth ?? null,
|
||||
gender: userProfile?.gender ?? null,
|
||||
};
|
||||
for (const ctx of GLUCOSE_CONTEXTS) {
|
||||
const rows = glucoseRows.filter((m) => m.glucoseContext === ctx);
|
||||
if (rows.length === 0) continue;
|
||||
const values = rows.map((r) => r.value);
|
||||
glucoseStats[ctx] = {
|
||||
avg: values.reduce((a, b) => a + b, 0) / values.length,
|
||||
min: Math.min(...values),
|
||||
max: Math.max(...values),
|
||||
count: values.length,
|
||||
latest: values[values.length - 1],
|
||||
};
|
||||
const eff = getEffectiveRange(
|
||||
thresholdMetricForContext(ctx),
|
||||
profileForRange,
|
||||
overrides,
|
||||
);
|
||||
if (eff.range) {
|
||||
glucoseRanges[ctx] = { min: eff.range.greenMin, max: eff.range.greenMax };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
period: { days, since: since.toISOString() },
|
||||
patient: {
|
||||
username: userProfile?.username ?? null,
|
||||
dateOfBirth: userProfile?.dateOfBirth
|
||||
? userProfile.dateOfBirth.toISOString()
|
||||
: null,
|
||||
gender: userProfile?.gender ?? null,
|
||||
heightCm: userProfile?.heightCm ?? null,
|
||||
},
|
||||
measurements: byType,
|
||||
stats,
|
||||
glucoseStats,
|
||||
glucoseRanges,
|
||||
glucoseUnit: resolveGlucoseUnit(userProfile?.glucoseUnit ?? null),
|
||||
bmi,
|
||||
compliance,
|
||||
medications: medications.map((m) => ({
|
||||
name: m.name,
|
||||
dose: m.dose,
|
||||
schedules: m.schedules.map((s) => ({
|
||||
windowStart: s.windowStart,
|
||||
windowEnd: s.windowEnd,
|
||||
label: s.label,
|
||||
})),
|
||||
})),
|
||||
mood,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
/**
|
||||
* Isomorphic PDF renderer for the doctor report.
|
||||
*
|
||||
* Runs identically in the browser (settings page download) and in Node
|
||||
* (server-rendered `/api/doctor-report/pdf` endpoint). All locale-sensitive
|
||||
* strings and number/date formatting are driven by the injected
|
||||
* `{ t, locale }` so DE and EN output match the user's UI language.
|
||||
*
|
||||
* jsPDF is fully isomorphic: `doc.output("arraybuffer")` returns a valid
|
||||
* `%PDF-` byte stream in both environments.
|
||||
*/
|
||||
import { jsPDF } from "jspdf";
|
||||
import autoTable from "jspdf-autotable";
|
||||
import { makeFormatters } from "./format-locale";
|
||||
import type { Locale } from "./i18n/config";
|
||||
import { convertGlucose, resolveGlucoseUnit } from "./glucose";
|
||||
import type { DoctorReportData } from "./doctor-report-data";
|
||||
|
||||
type T = (key: string, params?: Record<string, string | number>) => string;
|
||||
|
||||
export interface DoctorReportRenderOptions {
|
||||
t: T;
|
||||
locale: Locale;
|
||||
/**
|
||||
* Optional fixed timestamp for "createdOn"/footer. Useful for deterministic
|
||||
* tests; defaults to `new Date()`.
|
||||
*/
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-vital label keys. Exported for coverage tests (issue #109 / phase P0)
|
||||
* so a future enum addition is caught by a unit test rather than reaching
|
||||
* production as a raw enum string in the PDF.
|
||||
*/
|
||||
export const DOCTOR_REPORT_TYPE_LABEL_KEYS: Record<string, string> = {
|
||||
WEIGHT: "doctorReport.typeWeight",
|
||||
BLOOD_PRESSURE_SYS: "doctorReport.typeBpSys",
|
||||
BLOOD_PRESSURE_DIA: "doctorReport.typeBpDia",
|
||||
PULSE: "doctorReport.typePulse",
|
||||
BODY_FAT: "doctorReport.typeBodyFat",
|
||||
SLEEP_DURATION: "doctorReport.typeSleep",
|
||||
ACTIVITY_STEPS: "doctorReport.typeSteps",
|
||||
TOTAL_BODY_WATER: "doctorReport.typeTotalBodyWater",
|
||||
BONE_MASS: "doctorReport.typeBoneMass",
|
||||
OXYGEN_SATURATION: "doctorReport.typeOxygenSaturation",
|
||||
};
|
||||
|
||||
export const DOCTOR_REPORT_TYPE_UNIT_KEYS: Record<string, string | null> = {
|
||||
WEIGHT: "kg",
|
||||
BLOOD_PRESSURE_SYS: "mmHg",
|
||||
BLOOD_PRESSURE_DIA: "mmHg",
|
||||
PULSE: "bpm",
|
||||
BODY_FAT: "%",
|
||||
SLEEP_DURATION: "h",
|
||||
ACTIVITY_STEPS: null, // translated unit
|
||||
TOTAL_BODY_WATER: "kg",
|
||||
BONE_MASS: "kg",
|
||||
OXYGEN_SATURATION: "%",
|
||||
};
|
||||
|
||||
/**
|
||||
* Vital types rendered in the main vitals table. Body composition
|
||||
* (TOTAL_BODY_WATER, BONE_MASS) ships alongside body fat — Withings
|
||||
* smart scales report all three together. SpO2 (Withings ScanWatch type
|
||||
* 54, HealthKit, n8n / Health Connect) is rendered last in the same
|
||||
* table for clinical readability. Glucose ships separately via
|
||||
* per-context `glucoseStats`. Sleep + activity are intentionally
|
||||
* excluded from a clinical-focused report.
|
||||
*/
|
||||
export const DOCTOR_REPORT_VITAL_TYPES = [
|
||||
"WEIGHT",
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
"PULSE",
|
||||
"BODY_FAT",
|
||||
"TOTAL_BODY_WATER",
|
||||
"BONE_MASS",
|
||||
"OXYGEN_SATURATION",
|
||||
] as const;
|
||||
|
||||
const MOOD_LABEL_KEYS: Record<number, string> = {
|
||||
1: "doctorReport.moodAwful",
|
||||
2: "doctorReport.moodBad",
|
||||
3: "doctorReport.moodNeutral",
|
||||
4: "doctorReport.moodGood",
|
||||
5: "doctorReport.moodGreat",
|
||||
};
|
||||
|
||||
const GLUCOSE_LABEL_KEYS = {
|
||||
FASTING: "doctorReport.typeGlucoseFasting",
|
||||
POSTPRANDIAL: "doctorReport.typeGlucosePostprandial",
|
||||
RANDOM: "doctorReport.typeGlucoseRandom",
|
||||
BEDTIME: "doctorReport.typeGlucoseBedtime",
|
||||
} as const;
|
||||
|
||||
const DEFAULT_GLUCOSE_RANGES = {
|
||||
FASTING: { min: 70, max: 99 },
|
||||
POSTPRANDIAL: { min: 70, max: 140 },
|
||||
RANDOM: { min: 70, max: 140 },
|
||||
BEDTIME: { min: 90, max: 150 },
|
||||
} as const;
|
||||
|
||||
const GLUCOSE_CONTEXTS: Array<keyof typeof GLUCOSE_LABEL_KEYS> = [
|
||||
"FASTING",
|
||||
"POSTPRANDIAL",
|
||||
"RANDOM",
|
||||
"BEDTIME",
|
||||
];
|
||||
|
||||
function getBmiClassificationKey(bmi: number): string {
|
||||
if (bmi < 18.5) return "doctorReport.bmiUnderweight";
|
||||
if (bmi < 25) return "doctorReport.bmiNormal";
|
||||
if (bmi < 30) return "doctorReport.bmiOverweight";
|
||||
if (bmi < 35) return "doctorReport.bmiObeseGrade1";
|
||||
if (bmi < 40) return "doctorReport.bmiObeseGrade2";
|
||||
return "doctorReport.bmiObeseGrade3";
|
||||
}
|
||||
|
||||
function getBpClassificationKey(sys: number, dia: number): string {
|
||||
if (sys < 120 && dia < 80) return "doctorReport.bpOptimal";
|
||||
if (sys < 130 && dia < 85) return "doctorReport.bpNormal";
|
||||
if (sys < 140 && dia < 90) return "doctorReport.bpHighNormal";
|
||||
if (sys < 160 && dia < 100) return "doctorReport.bpHypertensionGrade1";
|
||||
if (sys < 180 && dia < 110) return "doctorReport.bpHypertensionGrade2";
|
||||
return "doctorReport.bpHypertensionGrade3";
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the doctor report into a `jsPDF` instance.
|
||||
*
|
||||
* Used internally by both the client wrapper (which calls `.save()` on the
|
||||
* returned doc) and the server renderer (which calls `.output("arraybuffer")`
|
||||
* via `renderDoctorReportPdfBytes`).
|
||||
*/
|
||||
export function buildDoctorReportPdfDocument(
|
||||
data: DoctorReportData,
|
||||
options: DoctorReportRenderOptions,
|
||||
): jsPDF {
|
||||
const { t, locale, now = new Date() } = options;
|
||||
const formatters = makeFormatters(locale);
|
||||
const num = (value: number, decimals = 1) =>
|
||||
formatters.number(value, decimals);
|
||||
const fmtDate = (iso: string) => formatters.date(iso);
|
||||
|
||||
const unitFor = (type: string): string => {
|
||||
const staticUnit = DOCTOR_REPORT_TYPE_UNIT_KEYS[type];
|
||||
if (staticUnit === null && type === "ACTIVITY_STEPS") {
|
||||
return t("doctorReport.unitSteps");
|
||||
}
|
||||
return staticUnit ?? "";
|
||||
};
|
||||
|
||||
const doc = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4" });
|
||||
const pageWidth = doc.internal.pageSize.getWidth();
|
||||
const margin = 20;
|
||||
let y = margin;
|
||||
|
||||
doc.setFontSize(22);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(30, 30, 30);
|
||||
doc.text(t("doctorReport.title"), margin, y);
|
||||
y += 8;
|
||||
|
||||
doc.setFontSize(10);
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.setTextColor(100, 100, 100);
|
||||
doc.text(t("doctorReport.subtitle"), margin, y);
|
||||
y += 6;
|
||||
|
||||
doc.setDrawColor(200, 200, 200);
|
||||
doc.setLineWidth(0.5);
|
||||
doc.line(margin, y, pageWidth - margin, y);
|
||||
y += 8;
|
||||
|
||||
doc.setFontSize(9);
|
||||
doc.setTextColor(80, 80, 80);
|
||||
const patientInfo: string[] = [];
|
||||
if (data.patient.username) {
|
||||
patientInfo.push(`${t("doctorReport.patient")}: ${data.patient.username}`);
|
||||
}
|
||||
if (data.patient.dateOfBirth) {
|
||||
patientInfo.push(
|
||||
`${t("doctorReport.dateOfBirth")}: ${fmtDate(data.patient.dateOfBirth)}`,
|
||||
);
|
||||
}
|
||||
if (data.patient.gender) {
|
||||
const genderKeys: Record<string, string> = {
|
||||
MALE: "doctorReport.genderMale",
|
||||
FEMALE: "doctorReport.genderFemale",
|
||||
};
|
||||
const genderKey =
|
||||
genderKeys[data.patient.gender] ?? "doctorReport.genderOther";
|
||||
patientInfo.push(`${t("doctorReport.gender")}: ${t(genderKey)}`);
|
||||
}
|
||||
if (data.patient.heightCm) {
|
||||
patientInfo.push(
|
||||
`${t("doctorReport.height")}: ${data.patient.heightCm} cm`,
|
||||
);
|
||||
}
|
||||
patientInfo.push(
|
||||
`${t("doctorReport.period")}: ${fmtDate(data.period.since)} — ${fmtDate(now.toISOString())}`,
|
||||
);
|
||||
patientInfo.push(
|
||||
`${t("doctorReport.createdOn")}: ${fmtDate(now.toISOString())}`,
|
||||
);
|
||||
|
||||
for (const line of patientInfo) {
|
||||
doc.text(line, margin, y);
|
||||
y += 4.5;
|
||||
}
|
||||
y += 4;
|
||||
|
||||
doc.setFontSize(14);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(30, 30, 30);
|
||||
doc.text(t("doctorReport.vitalsTitle"), margin, y);
|
||||
y += 6;
|
||||
|
||||
const vitalRows: string[][] = [];
|
||||
|
||||
for (const type of DOCTOR_REPORT_VITAL_TYPES) {
|
||||
const s = data.stats[type];
|
||||
if (!s) continue;
|
||||
const unit = unitFor(type);
|
||||
vitalRows.push([
|
||||
t(DOCTOR_REPORT_TYPE_LABEL_KEYS[type] ?? ""),
|
||||
`${num(s.latest)} ${unit}`.trim(),
|
||||
`${num(s.avg)} ${unit}`.trim(),
|
||||
num(s.min),
|
||||
num(s.max),
|
||||
String(s.count),
|
||||
]);
|
||||
}
|
||||
|
||||
// Per-context glucose rows. Values stored canonically in mg/dL — convert to
|
||||
// the user's display unit. Reference ranges come from the server-side
|
||||
// `getEffectiveRange()` (already baked into `data.glucoseRanges`).
|
||||
const glucoseUnit = resolveGlucoseUnit(data.glucoseUnit ?? null);
|
||||
for (const ctx of GLUCOSE_CONTEXTS) {
|
||||
const s = data.glucoseStats?.[ctx];
|
||||
if (!s) continue;
|
||||
const conv = (v: number) => convertGlucose(v, glucoseUnit);
|
||||
vitalRows.push([
|
||||
t(GLUCOSE_LABEL_KEYS[ctx]),
|
||||
`${num(conv(s.latest))} ${glucoseUnit}`.trim(),
|
||||
`${num(conv(s.avg))} ${glucoseUnit}`.trim(),
|
||||
num(conv(s.min)),
|
||||
num(conv(s.max)),
|
||||
String(s.count),
|
||||
]);
|
||||
}
|
||||
|
||||
if (vitalRows.length > 0) {
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
head: [
|
||||
[
|
||||
t("doctorReport.colParameter"),
|
||||
t("doctorReport.colCurrent"),
|
||||
t("doctorReport.colAvgPeriod"),
|
||||
t("doctorReport.colMin"),
|
||||
t("doctorReport.colMax"),
|
||||
t("doctorReport.colN"),
|
||||
],
|
||||
],
|
||||
body: vitalRows,
|
||||
theme: "grid",
|
||||
styles: {
|
||||
fontSize: 9,
|
||||
cellPadding: 3,
|
||||
textColor: [30, 30, 30],
|
||||
lineColor: [200, 200, 200],
|
||||
lineWidth: 0.3,
|
||||
},
|
||||
headStyles: {
|
||||
fillColor: [245, 245, 245],
|
||||
textColor: [30, 30, 30],
|
||||
fontStyle: "bold",
|
||||
},
|
||||
alternateRowStyles: { fillColor: [252, 252, 252] },
|
||||
margin: { left: margin, right: margin },
|
||||
});
|
||||
y =
|
||||
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
|
||||
.finalY + 8;
|
||||
}
|
||||
|
||||
const sysStat = data.stats.BLOOD_PRESSURE_SYS;
|
||||
const diaStat = data.stats.BLOOD_PRESSURE_DIA;
|
||||
if (sysStat && diaStat) {
|
||||
doc.setFontSize(12);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.text(t("doctorReport.bpClassificationTitle"), margin, y);
|
||||
y += 5;
|
||||
|
||||
doc.setFontSize(9);
|
||||
doc.setFont("helvetica", "normal");
|
||||
const bpClass = t(getBpClassificationKey(sysStat.avg, diaStat.avg));
|
||||
doc.text(
|
||||
`${t("doctorReport.avgBp")}: ${num(sysStat.avg, 0)}/${num(diaStat.avg, 0)} mmHg — ${t("doctorReport.classification")}: ${bpClass}`,
|
||||
margin,
|
||||
y,
|
||||
);
|
||||
y += 8;
|
||||
}
|
||||
|
||||
if (data.bmi) {
|
||||
doc.setFontSize(12);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.text(t("doctorReport.bmiTitle"), margin, y);
|
||||
y += 5;
|
||||
|
||||
doc.setFontSize(9);
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.text(
|
||||
t("doctorReport.bmiRow", {
|
||||
bmi: num(data.bmi, 1),
|
||||
class: t(getBmiClassificationKey(data.bmi)),
|
||||
}),
|
||||
margin,
|
||||
y,
|
||||
);
|
||||
y += 8;
|
||||
}
|
||||
|
||||
// Glucose classification — one line per logged context.
|
||||
const loggedGlucose = GLUCOSE_CONTEXTS.filter(
|
||||
(ctx) => data.glucoseStats?.[ctx],
|
||||
);
|
||||
if (loggedGlucose.length > 0) {
|
||||
if (y > 240) {
|
||||
doc.addPage();
|
||||
y = margin;
|
||||
}
|
||||
doc.setFontSize(12);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.text(t("doctorReport.glucoseClassificationTitle"), margin, y);
|
||||
y += 5;
|
||||
doc.setFontSize(9);
|
||||
doc.setFont("helvetica", "normal");
|
||||
for (const ctx of loggedGlucose) {
|
||||
const s = data.glucoseStats[ctx];
|
||||
if (!s) continue;
|
||||
const range = data.glucoseRanges?.[ctx] ?? DEFAULT_GLUCOSE_RANGES[ctx];
|
||||
const conv = (v: number) => convertGlucose(v, glucoseUnit);
|
||||
const inRange = s.avg >= range.min && s.avg <= range.max;
|
||||
const classKey = inRange
|
||||
? "doctorReport.glucoseInTarget"
|
||||
: s.avg < range.min
|
||||
? "doctorReport.glucoseBelowTarget"
|
||||
: "doctorReport.glucoseAboveTarget";
|
||||
doc.text(
|
||||
t("doctorReport.glucoseRow", {
|
||||
label: t(GLUCOSE_LABEL_KEYS[ctx]),
|
||||
avg: num(conv(s.avg), 1),
|
||||
unit: glucoseUnit,
|
||||
rangeMin: num(conv(range.min)),
|
||||
rangeMax: num(conv(range.max)),
|
||||
class: t(classKey),
|
||||
}),
|
||||
margin,
|
||||
y,
|
||||
);
|
||||
y += 5;
|
||||
}
|
||||
y += 3;
|
||||
}
|
||||
|
||||
const complianceEntries = Object.entries(data.compliance);
|
||||
if (complianceEntries.length > 0) {
|
||||
if (y > 240) {
|
||||
doc.addPage();
|
||||
y = margin;
|
||||
}
|
||||
|
||||
doc.setFontSize(14);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.text(t("doctorReport.complianceTitle"), margin, y);
|
||||
y += 6;
|
||||
|
||||
const compRows = complianceEntries.map(([name, c]) => {
|
||||
const rate = c.total > 0 ? `${num((c.taken / c.total) * 100, 1)}%` : "—";
|
||||
return [
|
||||
name,
|
||||
String(c.taken),
|
||||
String(c.skipped),
|
||||
String(c.missed),
|
||||
String(c.total),
|
||||
rate,
|
||||
];
|
||||
});
|
||||
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
head: [
|
||||
[
|
||||
t("doctorReport.colMedication"),
|
||||
t("doctorReport.colTaken"),
|
||||
t("doctorReport.colSkipped"),
|
||||
t("doctorReport.colMissed"),
|
||||
t("doctorReport.colTotal"),
|
||||
t("doctorReport.colComplianceRate"),
|
||||
],
|
||||
],
|
||||
body: compRows,
|
||||
theme: "grid",
|
||||
styles: {
|
||||
fontSize: 9,
|
||||
cellPadding: 3,
|
||||
textColor: [30, 30, 30],
|
||||
lineColor: [200, 200, 200],
|
||||
lineWidth: 0.3,
|
||||
},
|
||||
headStyles: {
|
||||
fillColor: [245, 245, 245],
|
||||
textColor: [30, 30, 30],
|
||||
fontStyle: "bold",
|
||||
},
|
||||
alternateRowStyles: { fillColor: [252, 252, 252] },
|
||||
margin: { left: margin, right: margin },
|
||||
});
|
||||
y =
|
||||
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
|
||||
.finalY + 8;
|
||||
}
|
||||
|
||||
if (data.mood) {
|
||||
if (y > 240) {
|
||||
doc.addPage();
|
||||
y = margin;
|
||||
}
|
||||
|
||||
doc.setFontSize(14);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.text(t("doctorReport.moodTitle"), margin, y);
|
||||
y += 6;
|
||||
|
||||
doc.setFontSize(9);
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.text(
|
||||
t("doctorReport.moodSummary", {
|
||||
avg: num(data.mood.avg, 1),
|
||||
count: data.mood.count,
|
||||
min: num(data.mood.min, 0),
|
||||
max: num(data.mood.max, 0),
|
||||
}),
|
||||
margin,
|
||||
y,
|
||||
);
|
||||
y += 6;
|
||||
|
||||
const distRows = Object.entries(data.mood.distribution).map(
|
||||
([score, count]) => [
|
||||
t(MOOD_LABEL_KEYS[Number(score)] ?? "doctorReport.moodNeutral"),
|
||||
String(count),
|
||||
data.mood && data.mood.count > 0
|
||||
? `${num((count / data.mood.count) * 100, 1)}%`
|
||||
: "0%",
|
||||
],
|
||||
);
|
||||
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
head: [
|
||||
[
|
||||
t("doctorReport.colMood"),
|
||||
t("doctorReport.colCount"),
|
||||
t("doctorReport.colShare"),
|
||||
],
|
||||
],
|
||||
body: distRows,
|
||||
theme: "grid",
|
||||
styles: {
|
||||
fontSize: 9,
|
||||
cellPadding: 3,
|
||||
textColor: [30, 30, 30],
|
||||
lineColor: [200, 200, 200],
|
||||
lineWidth: 0.3,
|
||||
},
|
||||
headStyles: {
|
||||
fillColor: [245, 245, 245],
|
||||
textColor: [30, 30, 30],
|
||||
fontStyle: "bold",
|
||||
},
|
||||
margin: { left: margin, right: margin },
|
||||
});
|
||||
y =
|
||||
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
|
||||
.finalY + 8;
|
||||
}
|
||||
|
||||
const addFooter = (pageDoc: jsPDF) => {
|
||||
const pageHeight = pageDoc.internal.pageSize.getHeight();
|
||||
pageDoc.setFontSize(7);
|
||||
pageDoc.setFont("helvetica", "italic");
|
||||
pageDoc.setTextColor(140, 140, 140);
|
||||
pageDoc.text(t("doctorReport.footerDisclaimer1"), margin, pageHeight - 14);
|
||||
pageDoc.text(t("doctorReport.footerDisclaimer2"), margin, pageHeight - 10);
|
||||
pageDoc.text(
|
||||
t("doctorReport.footerSource", {
|
||||
timestamp: formatters.dateTime(now),
|
||||
}),
|
||||
margin,
|
||||
pageHeight - 6,
|
||||
);
|
||||
};
|
||||
|
||||
const totalPages = doc.getNumberOfPages();
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
doc.setPage(i);
|
||||
addFooter(doc);
|
||||
}
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the doctor report and return the PDF as a `Uint8Array`.
|
||||
* Isomorphic — works in browser and Node.
|
||||
*/
|
||||
export function renderDoctorReportPdfBytes(
|
||||
data: DoctorReportData,
|
||||
options: DoctorReportRenderOptions,
|
||||
): Uint8Array {
|
||||
const doc = buildDoctorReportPdfDocument(data, options);
|
||||
const ab = doc.output("arraybuffer");
|
||||
return new Uint8Array(ab);
|
||||
}
|
||||
@@ -77,6 +77,7 @@ const TYPE_LABEL_KEYS: Record<string, string> = {
|
||||
ACTIVITY_STEPS: "doctorReport.typeSteps",
|
||||
TOTAL_BODY_WATER: "doctorReport.typeTotalBodyWater",
|
||||
BONE_MASS: "doctorReport.typeBoneMass",
|
||||
OXYGEN_SATURATION: "doctorReport.typeOxygenSaturation",
|
||||
};
|
||||
|
||||
const TYPE_UNIT_KEYS: Record<string, string | null> = {
|
||||
@@ -89,6 +90,7 @@ const TYPE_UNIT_KEYS: Record<string, string | null> = {
|
||||
ACTIVITY_STEPS: null, // translated unit
|
||||
TOTAL_BODY_WATER: "kg",
|
||||
BONE_MASS: "kg",
|
||||
OXYGEN_SATURATION: "%",
|
||||
};
|
||||
|
||||
const MOOD_LABEL_KEYS: Record<number, string> = {
|
||||
@@ -211,6 +213,7 @@ export function generateDoctorReportPDF(
|
||||
"BODY_FAT",
|
||||
"TOTAL_BODY_WATER",
|
||||
"BONE_MASS",
|
||||
"OXYGEN_SATURATION",
|
||||
];
|
||||
|
||||
for (const type of vitalTypes) {
|
||||
|
||||
@@ -43,6 +43,7 @@ export function formatMeasurementsForExport(
|
||||
measuredAt: Date;
|
||||
source: string;
|
||||
notes: string | null;
|
||||
glucoseContext?: string | null;
|
||||
}>,
|
||||
): ExportableRecord[] {
|
||||
return measurements.map((m) => ({
|
||||
@@ -52,6 +53,7 @@ export function formatMeasurementsForExport(
|
||||
measuredAt: m.measuredAt.toISOString(),
|
||||
source: m.source,
|
||||
notes: m.notes ?? "",
|
||||
glucoseContext: m.glucoseContext ?? "",
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
+52
-14
@@ -1,36 +1,74 @@
|
||||
/**
|
||||
* IP geolocation lookup using ip-api.com (free, no API key needed).
|
||||
* Returns "City, CC" string or null on failure.
|
||||
* Only used for audit log enrichment — fire-and-forget, non-blocking.
|
||||
* IP geolocation lookup for audit-log enrichment.
|
||||
*
|
||||
* Default provider is ipwho.is (HTTPS, free, no key). Both the response
|
||||
* shape from ipwho.is and the fallback ip-api.com pro endpoint are
|
||||
* accepted, so swapping providers via IP_GEO_LOOKUP_URL only requires
|
||||
* matching one of those response shapes.
|
||||
*
|
||||
* Setting IP_GEO_LOOKUP_DISABLED=1 disables lookup entirely — used by
|
||||
* deployments that do not want any IP egress to a third-party service
|
||||
* (V3 audit: GDPR Art. 32 + Art. 44, plaintext HTTP IP egress).
|
||||
*/
|
||||
|
||||
interface IpApiResponse {
|
||||
status: "success" | "fail";
|
||||
interface IpwhoIsResponse {
|
||||
success?: boolean;
|
||||
city?: string;
|
||||
country_code?: string;
|
||||
}
|
||||
|
||||
interface IpApiProResponse {
|
||||
status?: "success" | "fail";
|
||||
city?: string;
|
||||
countryCode?: string;
|
||||
}
|
||||
|
||||
type GeoResponse = IpwhoIsResponse & IpApiProResponse;
|
||||
|
||||
const PRIVATE_IP =
|
||||
/^(127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|::1|fc|fd|fe80|localhost|unknown)/;
|
||||
|
||||
const DEFAULT_GEO_URL = "https://ipwho.is";
|
||||
|
||||
function buildLookupUrl(ip: string): string {
|
||||
const base = (process.env.IP_GEO_LOOKUP_URL ?? DEFAULT_GEO_URL).replace(
|
||||
/\/+$/,
|
||||
"",
|
||||
);
|
||||
if (!base.startsWith("https://")) {
|
||||
// V3 audit: never leak audit-event IPs over plaintext HTTP. Reject any
|
||||
// configuration that would do so by upgrading to https; if the operator
|
||||
// has explicitly opted into HTTP via env, we still refuse and return a
|
||||
// dummy URL the parser will fail on.
|
||||
return `https://invalid.invalid/refused-non-https/${encodeURIComponent(ip)}`;
|
||||
}
|
||||
return `${base}/${encodeURIComponent(ip)}`;
|
||||
}
|
||||
|
||||
export async function lookupIpLocation(
|
||||
ip: string | null,
|
||||
): Promise<string | null> {
|
||||
if (!ip || PRIVATE_IP.test(ip)) return null;
|
||||
if (process.env.IP_GEO_LOOKUP_DISABLED === "1") return null;
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`http://ip-api.com/json/${encodeURIComponent(ip)}?fields=status,city,countryCode`,
|
||||
{ signal: AbortSignal.timeout(3000) },
|
||||
);
|
||||
|
||||
const res = await fetch(buildLookupUrl(ip), {
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
|
||||
const data = (await res.json()) as IpApiResponse;
|
||||
if (data.status !== "success" || !data.city || !data.countryCode)
|
||||
return null;
|
||||
const data = (await res.json()) as GeoResponse;
|
||||
const ok =
|
||||
data.success === true ||
|
||||
data.status === "success" ||
|
||||
(data.city && (data.country_code ?? data.countryCode));
|
||||
if (!ok) return null;
|
||||
|
||||
return `${data.city}, ${data.countryCode}`;
|
||||
const city = data.city;
|
||||
const country = data.country_code ?? data.countryCode;
|
||||
if (!city || !country) return null;
|
||||
|
||||
return `${city}, ${country}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* Idempotency-Key support for write endpoints (POST/PUT/PATCH/DELETE).
|
||||
*
|
||||
* Mobile clients send `Idempotency-Key: <uuid>`. The first request runs
|
||||
* the handler normally and the response (status + body) is cached. Any
|
||||
* retry within the TTL (24h) for the same `(userId, key, method, path)`
|
||||
* tuple returns the cached envelope as a 200 (carrying the original
|
||||
* status inside the JSON if needed) — no second side-effect.
|
||||
*/
|
||||
import type { NextRequest } from "next/server";
|
||||
import { headers } from "next/headers";
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { hashToken } from "@/lib/auth/hmac";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
const TTL_MS = 24 * 60 * 60 * 1000;
|
||||
const SUPPORTED_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
||||
|
||||
const KEY_REGEX = /^[A-Za-z0-9_\-:.]{8,128}$/;
|
||||
|
||||
export interface IdempotencyContext {
|
||||
userId: string;
|
||||
key: string;
|
||||
method: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
function getIdempotencyKey(request: Request | NextRequest): string | null {
|
||||
const raw = request.headers.get("idempotency-key");
|
||||
if (!raw) return null;
|
||||
const trimmed = raw.trim();
|
||||
if (!KEY_REGEX.test(trimmed)) return null;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a cached response for a (userId, key, method, path) tuple.
|
||||
* Returns the cached NextResponse or null.
|
||||
*/
|
||||
async function findCached(
|
||||
ctx: IdempotencyContext,
|
||||
): Promise<NextResponse | null> {
|
||||
const row = await prisma.idempotencyKey.findUnique({
|
||||
where: {
|
||||
userId_key_method_path: {
|
||||
userId: ctx.userId,
|
||||
key: ctx.key,
|
||||
method: ctx.method,
|
||||
path: ctx.path,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!row) return null;
|
||||
if (row.expiresAt <= new Date()) {
|
||||
// Stale — purge and fall through.
|
||||
await prisma.idempotencyKey
|
||||
.delete({ where: { id: row.id } })
|
||||
.catch(() => {});
|
||||
return null;
|
||||
}
|
||||
|
||||
let parsed: unknown = null;
|
||||
try {
|
||||
parsed = JSON.parse(row.responseBody);
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "idempotency.replay" },
|
||||
meta: { method: ctx.method, path: ctx.path },
|
||||
});
|
||||
|
||||
// Replay original status to keep client behaviour byte-identical.
|
||||
return NextResponse.json(parsed, {
|
||||
status: row.responseStatus,
|
||||
headers: {
|
||||
"X-Idempotent-Replay": "true",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function persistCached(
|
||||
ctx: IdempotencyContext,
|
||||
response: Response,
|
||||
preReadBody?: string,
|
||||
): Promise<void> {
|
||||
const body =
|
||||
preReadBody ??
|
||||
(await response
|
||||
.clone()
|
||||
.text()
|
||||
.catch(() => ""));
|
||||
|
||||
await prisma.idempotencyKey
|
||||
.create({
|
||||
data: {
|
||||
userId: ctx.userId,
|
||||
key: ctx.key,
|
||||
method: ctx.method,
|
||||
path: ctx.path,
|
||||
responseStatus: response.status,
|
||||
responseBody: body,
|
||||
expiresAt: new Date(Date.now() + TTL_MS),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
// Concurrent insert with the same key — the other writer wins.
|
||||
// Ignore because a future replay will hit their cached value.
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a response with the given HTTP status should be cached for
|
||||
* idempotent replay.
|
||||
*
|
||||
* Cached: any 2xx/3xx, plus 4xx-validation (so the same broken request
|
||||
* doesn't re-execute side-effects). Specifically NOT cached:
|
||||
* 401 — token may have expired between the original call and the retry
|
||||
* 403 — likewise authorization can change
|
||||
* 408 — caller timed out, retry deserves a fresh attempt
|
||||
* 429 — caller hit a rate-limit, retry deserves a fresh window check
|
||||
* 5xx — server fault, retry must not be locked into a bogus result
|
||||
*
|
||||
* Exported so the do-not-cache contract is unit-tested independently of
|
||||
* the database-backed wrapper.
|
||||
*/
|
||||
export function isCachableStatus(status: number): boolean {
|
||||
if (status < 400) return true;
|
||||
if (status >= 500) return false;
|
||||
if (status === 401 || status === 403 || status === 408 || status === 429) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default resolver: cookie session first, then Bearer token. The Bearer
|
||||
* fallback is what makes idempotency actually fire for native iOS / n8n
|
||||
* /external clients — without it, every Bearer-authed retry was running
|
||||
* the handler again and creating duplicate measurements (audit C-4).
|
||||
*
|
||||
* Exported for unit testing; production callers should let
|
||||
* `withIdempotency()` pick this up automatically via its default arg.
|
||||
*/
|
||||
export async function defaultUserIdResolver(): Promise<string | null> {
|
||||
const session = await getSession().catch(() => null);
|
||||
if (session) return session.user.id;
|
||||
|
||||
let authHeader: string | null = null;
|
||||
try {
|
||||
const headerList = await headers();
|
||||
authHeader = headerList.get("authorization");
|
||||
} catch {
|
||||
authHeader = null;
|
||||
}
|
||||
if (!authHeader?.startsWith("Bearer ")) return null;
|
||||
|
||||
const tokenHashValue = hashToken(authHeader.slice(7));
|
||||
const apiToken = await prisma.apiToken
|
||||
.findUnique({
|
||||
where: { tokenHash: tokenHashValue },
|
||||
select: { userId: true, revoked: true, expiresAt: true },
|
||||
})
|
||||
.catch(() => null);
|
||||
|
||||
if (!apiToken || apiToken.revoked) return null;
|
||||
if (apiToken.expiresAt && apiToken.expiresAt <= new Date()) return null;
|
||||
return apiToken.userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a write handler so a repeat call with the same `Idempotency-Key`
|
||||
* (and same userId/method/path) returns the originally cached response.
|
||||
*
|
||||
* The wrapped handler is responsible for authentication itself — this
|
||||
* helper only triggers for methods in {POST, PUT, PATCH, DELETE} and only
|
||||
* once `userIdResolver` returns a non-null value. The default resolver
|
||||
* supports both cookie sessions and Bearer-token clients; pass a custom
|
||||
* resolver only for routes that authenticate via something exotic.
|
||||
*
|
||||
* No-op when the header is missing or the value is malformed.
|
||||
*/
|
||||
export function withIdempotency<
|
||||
Args extends [Request | NextRequest, ...unknown[]],
|
||||
>(
|
||||
handler: (...args: Args) => Promise<Response>,
|
||||
userIdResolver: (...args: Args) => Promise<string | null> = defaultUserIdResolver,
|
||||
): (...args: Args) => Promise<Response> {
|
||||
return async (...args: Args): Promise<Response> => {
|
||||
const request = args[0];
|
||||
if (!SUPPORTED_METHODS.has(request.method)) {
|
||||
return handler(...args);
|
||||
}
|
||||
|
||||
const key = getIdempotencyKey(request);
|
||||
if (!key) return handler(...args);
|
||||
|
||||
const userId = await userIdResolver(...args);
|
||||
if (!userId) return handler(...args);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const ctx: IdempotencyContext = {
|
||||
userId,
|
||||
key,
|
||||
method: request.method,
|
||||
path: url.pathname,
|
||||
};
|
||||
|
||||
const cached = await findCached(ctx);
|
||||
if (cached) return cached;
|
||||
|
||||
const response = await handler(...args);
|
||||
|
||||
if (isCachableStatus(response.status)) {
|
||||
// Defence-in-depth: never persist a body that carries a freshly-issued
|
||||
// bearer token. Auth routes shouldn't be wrapped in withIdempotency to
|
||||
// begin with, but if a future caller forgets, we refuse to leak.
|
||||
const cloned = response.clone();
|
||||
const text = await cloned.text();
|
||||
if (!text.includes("hlk_")) {
|
||||
await persistCached(ctx, response, text);
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { resolveProvider } from "@/lib/ai/provider";
|
||||
import { getGeneralStatusSystemPrompt, getGeneralStatusUserPrompt } from "@/lib/ai/prompts/general-status";
|
||||
import { getBpTargets } from "@/lib/analytics/bp-targets";
|
||||
import { getNoKeyGeneralStatusText } from "@/lib/insights/no-key-fallbacks";
|
||||
import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||||
|
||||
const GENERAL_STATUS_POINTS = 30;
|
||||
|
||||
@@ -15,15 +16,9 @@ const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
||||
|
||||
type SupportedLocale = "de" | "en";
|
||||
|
||||
const MEASUREMENT_TYPES = [
|
||||
"WEIGHT",
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
"PULSE",
|
||||
"BODY_FAT",
|
||||
"SLEEP_DURATION",
|
||||
"ACTIVITY_STEPS",
|
||||
] as const;
|
||||
// Derived from canonical enum so a new measurement type is auto-included
|
||||
// in the AI general-status fetch (V3 audit: enum drift cousins).
|
||||
const MEASUREMENT_TYPES = measurementTypeEnum.options;
|
||||
|
||||
function toBerlinDayKey(date: Date): string {
|
||||
const parts = BERLIN_DAY_FORMATTER.formatToParts(date);
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import {
|
||||
cleanupOldAuditLogs,
|
||||
getAuditLogRetentionDays,
|
||||
DEFAULT_AUDIT_LOG_RETENTION_DAYS,
|
||||
} from "../audit-log-cleanup";
|
||||
|
||||
function makePrismaMock(deletedCount: number) {
|
||||
return {
|
||||
auditLog: {
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: deletedCount }),
|
||||
},
|
||||
} as unknown as PrismaClient;
|
||||
}
|
||||
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
delete process.env.AUDIT_LOG_RETENTION_DAYS;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = ORIGINAL_ENV;
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("getAuditLogRetentionDays", () => {
|
||||
it("returns 365 when env is unset", () => {
|
||||
expect(getAuditLogRetentionDays()).toBe(DEFAULT_AUDIT_LOG_RETENTION_DAYS);
|
||||
});
|
||||
|
||||
it("respects a valid override", () => {
|
||||
process.env.AUDIT_LOG_RETENTION_DAYS = "180";
|
||||
expect(getAuditLogRetentionDays()).toBe(180);
|
||||
});
|
||||
|
||||
it("ignores nonsensical values (NaN, 0, negative)", () => {
|
||||
process.env.AUDIT_LOG_RETENTION_DAYS = "0";
|
||||
expect(getAuditLogRetentionDays()).toBe(DEFAULT_AUDIT_LOG_RETENTION_DAYS);
|
||||
process.env.AUDIT_LOG_RETENTION_DAYS = "-100";
|
||||
expect(getAuditLogRetentionDays()).toBe(DEFAULT_AUDIT_LOG_RETENTION_DAYS);
|
||||
process.env.AUDIT_LOG_RETENTION_DAYS = "not a number";
|
||||
expect(getAuditLogRetentionDays()).toBe(DEFAULT_AUDIT_LOG_RETENTION_DAYS);
|
||||
});
|
||||
|
||||
it("rejects too-short retention (< 7 days) to guard misconfigs", () => {
|
||||
process.env.AUDIT_LOG_RETENTION_DAYS = "3";
|
||||
expect(getAuditLogRetentionDays()).toBe(DEFAULT_AUDIT_LOG_RETENTION_DAYS);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleanupOldAuditLogs", () => {
|
||||
it("deletes audit log rows older than 365 days by default", async () => {
|
||||
const prisma = makePrismaMock(42);
|
||||
const now = new Date("2026-05-04T00:00:00Z");
|
||||
const deleted = await cleanupOldAuditLogs(prisma, now);
|
||||
|
||||
expect(deleted).toBe(42);
|
||||
expect(prisma.auditLog.deleteMany).toHaveBeenCalledTimes(1);
|
||||
const cutoff = new Date(now.getTime() - 365 * 86_400_000);
|
||||
expect(prisma.auditLog.deleteMany).toHaveBeenCalledWith({
|
||||
where: { createdAt: { lt: cutoff } },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the configured retention", async () => {
|
||||
process.env.AUDIT_LOG_RETENTION_DAYS = "90";
|
||||
const prisma = makePrismaMock(7);
|
||||
const now = new Date("2026-05-04T00:00:00Z");
|
||||
await cleanupOldAuditLogs(prisma, now);
|
||||
|
||||
const cutoff = new Date(now.getTime() - 90 * 86_400_000);
|
||||
expect(prisma.auditLog.deleteMany).toHaveBeenCalledWith({
|
||||
where: { createdAt: { lt: cutoff } },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { cleanupExpiredIdempotencyKeys } from "../idempotency-cleanup";
|
||||
|
||||
function makePrismaMock(deletedCount: number) {
|
||||
return {
|
||||
idempotencyKey: {
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: deletedCount }),
|
||||
},
|
||||
} as unknown as PrismaClient;
|
||||
}
|
||||
|
||||
describe("cleanupExpiredIdempotencyKeys", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-04T03:00:00Z"));
|
||||
});
|
||||
|
||||
it("deletes all rows whose expires_at is in the past and returns the count", async () => {
|
||||
const prisma = makePrismaMock(100);
|
||||
const deleted = await cleanupExpiredIdempotencyKeys(prisma);
|
||||
|
||||
expect(deleted).toBe(100);
|
||||
expect(prisma.idempotencyKey.deleteMany).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.idempotencyKey.deleteMany).toHaveBeenCalledWith({
|
||||
where: { expiresAt: { lt: new Date("2026-05-04T03:00:00Z") } },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 0 when no rows are expired", async () => {
|
||||
const prisma = makePrismaMock(0);
|
||||
const deleted = await cleanupExpiredIdempotencyKeys(prisma);
|
||||
expect(deleted).toBe(0);
|
||||
});
|
||||
|
||||
it("propagates errors from prisma so the handler can warn", async () => {
|
||||
const prisma = {
|
||||
idempotencyKey: {
|
||||
deleteMany: vi.fn().mockRejectedValue(new Error("connection lost")),
|
||||
},
|
||||
} as unknown as PrismaClient;
|
||||
|
||||
await expect(cleanupExpiredIdempotencyKeys(prisma)).rejects.toThrow(
|
||||
"connection lost",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Daily cleanup for the `audit_logs` table.
|
||||
*
|
||||
* V3 audit (GDPR Art. 5(1)(e) "storage limitation"): audit log accumulates
|
||||
* IP + city + login events forever. Without retention, a self-hosted
|
||||
* deployment is non-compliant with the principle that personal data must
|
||||
* not be stored "longer than is necessary".
|
||||
*
|
||||
* Default retention is 365 days (configurable via AUDIT_LOG_RETENTION_DAYS
|
||||
* env). Rows older than the cutoff are deleted in a single bulk
|
||||
* `deleteMany`; runs daily via pg-boss.
|
||||
*/
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
export const DEFAULT_AUDIT_LOG_RETENTION_DAYS = 365;
|
||||
|
||||
export function getAuditLogRetentionDays(): number {
|
||||
const raw = process.env.AUDIT_LOG_RETENTION_DAYS;
|
||||
if (raw === undefined) return DEFAULT_AUDIT_LOG_RETENTION_DAYS;
|
||||
const parsed = parseInt(raw, 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return DEFAULT_AUDIT_LOG_RETENTION_DAYS;
|
||||
}
|
||||
// Refuse very-short retention windows accidentally set to seconds — we
|
||||
// don't want a misconfig nuking a fresh audit table.
|
||||
if (parsed < 7) return DEFAULT_AUDIT_LOG_RETENTION_DAYS;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export async function cleanupOldAuditLogs(
|
||||
prisma: PrismaClient,
|
||||
now: Date = new Date(),
|
||||
): Promise<number> {
|
||||
const days = getAuditLogRetentionDays();
|
||||
const cutoff = new Date(now.getTime() - days * 86_400_000);
|
||||
const { count } = await prisma.auditLog.deleteMany({
|
||||
where: { createdAt: { lt: cutoff } },
|
||||
});
|
||||
return count;
|
||||
}
|
||||
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user