mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 12:22:03 +00:00
Compare commits
No files matched your search
@@ -104,3 +104,41 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
|
||||
# LOKI_ENDPOINT="http://loki:3100"
|
||||
# LOKI_USERNAME=""
|
||||
# LOKI_PASSWORD=""
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Process split (optional, single-container deploys ignore this)
|
||||
# -----------------------------------------------------------------------------
|
||||
# `all` — single container runs HTTP + reminder worker (default, legacy).
|
||||
# `web` — HTTP only; pair with a separate `app-worker` service.
|
||||
# `worker`— pg-boss only; rejects HTTP traffic with a 503.
|
||||
# See docs/self-hosting/scaling.md for the split-stack guide.
|
||||
# HEALTHLOG_PROCESS_TYPE="all"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Encryption-key versioning (optional — for at-rest key rotation)
|
||||
# -----------------------------------------------------------------------------
|
||||
# JSON map of key id → 64-hex-char or base64 32-byte key. When set, it
|
||||
# supersedes ENCRYPTION_KEY for new writes; old rows continue to decrypt under
|
||||
# their original id. ENCRYPTION_ACTIVE_KEY_ID picks which entry encrypt() uses.
|
||||
# Run `pnpm tsx scripts/rotate-encryption-key.ts <new-id>` to re-encrypt every
|
||||
# existing row under the new key. See docs/ops/encryption-key-rotation.md.
|
||||
# ENCRYPTION_KEYS='{"v1":"<old-key>","v2":"<new-key>"}'
|
||||
# ENCRYPTION_ACTIVE_KEY_ID="v2"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Off-host backup (optional — daily encrypted dumps to an S3-compatible bucket)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Required ALL-OR-NONE: setting any of these without setting the rest disables
|
||||
# the backup silently. The backup runs at 02:30 Europe/Berlin via pg-boss cron.
|
||||
# Restore with `pnpm tsx scripts/restore-backup.ts <userId> <YYYY-MM-DD>`.
|
||||
# See docs/ops/backup-restore.md for the format spec + step-by-step.
|
||||
# BACKUP_S3_ENDPOINT="https://<account>.r2.cloudflarestorage.com"
|
||||
# BACKUP_S3_BUCKET="healthlog-backups"
|
||||
# BACKUP_S3_ACCESS_KEY=""
|
||||
# BACKUP_S3_SECRET_KEY=""
|
||||
# BACKUP_S3_REGION="auto" # "auto" for Cloudflare R2; real region for AWS S3
|
||||
# BACKUP_RETENTION_DAYS="30" # objects older than this are pruned (best-effort; prefer a bucket lifecycle rule)
|
||||
# BACKUP_ENCRYPTION_KEY="" # 64-hex-char or base64 32-byte; SEPARATE from ENCRYPTION_KEY
|
||||
@@ -0,0 +1,83 @@
|
||||
name: e2e
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
# Cancel in-progress runs when a new commit lands on the same PR.
|
||||
concurrency:
|
||||
group: e2e-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_USER: healthlog
|
||||
POSTGRES_PASSWORD: healthlog
|
||||
POSTGRES_DB: healthlog
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U healthlog"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
|
||||
env:
|
||||
DATABASE_URL: postgresql://healthlog:healthlog@localhost:5432/healthlog?schema=public
|
||||
ENCRYPTION_KEYS: '{"v1":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}'
|
||||
ENCRYPTION_ACTIVE_KEY_ID: v1
|
||||
API_TOKEN_HMAC_KEY: ci-hmac-key-32-bytes-min-padding-x
|
||||
AUTH_RP_NAME: HealthLog
|
||||
AUTH_RP_ID: localhost
|
||||
AUTH_RP_ORIGIN: http://localhost:3000
|
||||
NODE_ENV: production
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- run: pnpm db:generate
|
||||
|
||||
- run: pnpm db:migrate:deploy
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
playwright-${{ runner.os }}-
|
||||
|
||||
- run: pnpm exec playwright install --with-deps chromium
|
||||
|
||||
- run: pnpm exec next build
|
||||
|
||||
- run: pnpm exec playwright test
|
||||
env:
|
||||
E2E_BASE_URL: http://localhost:3000
|
||||
|
||||
- name: Upload Playwright HTML report on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 7
|
||||
@@ -0,0 +1,34 @@
|
||||
name: Integration tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
integration:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- run: pnpm db:generate
|
||||
|
||||
- run: pnpm test:integration
|
||||
env:
|
||||
# The integration suite boots a Postgres testcontainer and points
|
||||
# Prisma at it via DATABASE_URL set inside `tests/integration/setup.ts`.
|
||||
# These env vars satisfy code paths that read them at module load.
|
||||
API_TOKEN_HMAC_KEY: integration-tests-do-not-use-in-prod
|
||||
ENCRYPTION_KEY: 0000000000000000000000000000000000000000000000000000000000000000
|
||||
SESSION_SECRET: integration-tests
|
||||
@@ -30,11 +30,13 @@ jobs:
|
||||
|
||||
- name: ESLint
|
||||
run: pnpm lint
|
||||
# Non-blocking on the existing settings page monolith — its set-state-
|
||||
# in-effect violations need the 1.4.0 settings-split refactor to fix
|
||||
# cleanly. Will flip to blocking once settings/page.tsx is broken up.
|
||||
continue-on-error: true
|
||||
|
||||
- name: TypeScript strict check
|
||||
run: pnpm typecheck
|
||||
continue-on-error: true
|
||||
|
||||
- name: Unit tests
|
||||
run: pnpm test
|
||||
|
||||
+12
@@ -11,6 +11,11 @@
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# playwright e2e
|
||||
/test-results
|
||||
/playwright-report
|
||||
/playwright/.cache
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
@@ -39,3 +44,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 = []
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"shadcn": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "shadcn@latest", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,21 +6,33 @@ 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.4.0 (released) → v1.4.1 in progress. v1.4.0 shipped
|
||||
the UI guidelines + Skeleton/EmptyState primitives, medical-citations
|
||||
consolidation, the per-route `/settings/[section]` split, the
|
||||
status-first admin grid + aggregator endpoint, five new "Test
|
||||
connection" buttons, AI insights with inline charts via allowlisted
|
||||
metric tokens, off-host encrypted S3 backup, encryption-key
|
||||
versioning + rotation CLI, optional `HEALTHLOG_PROCESS_TYPE` worker /
|
||||
web split, and short-lived 24h access tokens with refresh-token
|
||||
rotation for native API clients. v1.4.1 follows up with per-section
|
||||
admin component extraction (the inner monolith into one file per
|
||||
panel), a Postgres testcontainers integration test suite, and a
|
||||
Playwright + axe-core E2E foundation. See GitHub Releases +
|
||||
CHANGELOG.md for the full feature timeline.
|
||||
|
||||
## 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 |
|
||||
@@ -66,7 +78,7 @@ src/
|
||||
│ ├── layout.tsx # Root layout (viewport-fit: cover for PWA)
|
||||
│ ├── page.tsx # Dashboard (/) with quick entry dropdown
|
||||
│ ├── globals.css # Dracula theme CSS variables
|
||||
│ ├── admin/page.tsx # Admin panel
|
||||
│ ├── admin/page.tsx # Admin shell — 77 LOC, mounts the per-section components from src/components/admin/
|
||||
│ ├── auth/login/page.tsx # Login
|
||||
│ ├── auth/register/page.tsx # Registration
|
||||
│ ├── achievements/page.tsx # Gamification achievements
|
||||
@@ -76,10 +88,11 @@ 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 # 308-redirects /settings → /settings/account
|
||||
│ ├── settings/[section]/page.tsx # Per-section route shell (account, integrations, notifications, dashboard, ai, api, advanced, about)
|
||||
│ ├── 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 +106,10 @@ 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/ # Per-route Settings section components (one per /settings/[section])
|
||||
│ ├── admin/ # Per-route Admin section components (system-status, integrations, monitoring, reminders, users, audit, danger-zone, feedback)
|
||||
│ └── monitoring/ # Umami, GlitchTip bootstrap
|
||||
├── lib/
|
||||
│ ├── db.ts # Prisma client singleton
|
||||
@@ -109,6 +125,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 +140,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 (26 models)
|
||||
└── migrations/ # Migration files (0001–0025; latest: refresh_tokens + user_locale_drift_fix)
|
||||
prisma.config.ts # Prisma config (DB URL lives here, NOT in schema)
|
||||
public/
|
||||
├── sw.js # Service worker (Web Push + offline caching)
|
||||
@@ -165,17 +188,17 @@ These are hard-won lessons. Ignoring them will cause errors:
|
||||
- **Zod v4**: Import from `"zod/v4"`, not `"zod"`.
|
||||
- **jsPDF**: Client-side only. Import dynamically in browser context. Used with `jspdf-autotable` plugin.
|
||||
|
||||
### Settings Page
|
||||
### Settings & Admin (per-route layout, v1.4)
|
||||
|
||||
- One large file (~2100 lines), 13 sections. Sidebar switches to "settings mode" showing section shortcuts.
|
||||
- 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`.
|
||||
- **Settings**: 8 routes under `/settings/[section]` — `account`, `integrations`, `notifications`, `dashboard`, `ai`, `api`, `advanced`, `about`. The legacy `/settings` 308-redirects to `/settings/account`. Sidebar deep-links and the `<a href="/settings#anchor">` patterns from 1.3 still resolve via the redirect.
|
||||
- **Admin**: `src/app/admin/page.tsx` is now a 77-LOC shell that mounts the per-section components in `src/components/admin/`. Status-card grid lives in `status-card-grid.tsx`. The aggregator endpoint `/api/admin/status-overview` returns the six-card summaries in one batched query.
|
||||
- ESLint `react-hooks/set-state-in-effect` is **strict** now (was non-blocking when the settings monolith carried inline-effect state-setters). Use lazy `useState(() => …)` for localStorage reads, TanStack Query for data fetches.
|
||||
|
||||
### 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 +215,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 +230,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`.
|
||||
26 models: `User`, `Passkey`, `Session`, `AuthChallenge`, `Measurement`, `Medication`, `MedicationSchedule`, `MedicationIntakeEvent`, `ReminderPhaseConfig`, `TelegramReminderMessage`, `TelegramScheduledDeletion`, `ApiToken`, `RefreshToken`, `WithingsConnection`, `MoodEntry`, `AppSettings`, `Feedback`, `AuditLog`, `NotificationChannel`, `NotificationPreference`, `PushSubscription`, `DataBackup`, `UserAchievement`, `RateLimit`, `Device`, `IdempotencyKey`. (`RefreshToken` added in v1.4.0 alongside the native-client 24h access-token / refresh-token rotation flow.)
|
||||
|
||||
## When Making Changes
|
||||
|
||||
|
||||
+661
@@ -1,5 +1,666 @@
|
||||
# Changelog
|
||||
|
||||
## [1.4.2] — 2026-05-08
|
||||
|
||||
### Fixed — Production deploy hotfixes
|
||||
|
||||
- **Dashboard and Insights pages no longer crash for users without
|
||||
weight data.** `data?.summaries.WEIGHT` only protected the outer
|
||||
object — the optional chain stopped one level too early, so
|
||||
brand-new users (where `summaries` is undefined) hit
|
||||
`TypeError: undefined is not an object (evaluating 'E?.summaries.WEIGHT')`
|
||||
on first load. Now `data?.summaries?.WEIGHT`.
|
||||
- **Container healthcheck uses `127.0.0.1` instead of `localhost`.**
|
||||
busybox-`wget` in Alpine resolves `localhost` to IPv6 `::1` first,
|
||||
but Next.js standalone listens on IPv4 `0.0.0.0:3000` only — so the
|
||||
healthcheck always returned ECONNREFUSED, Docker marked the
|
||||
container unhealthy, and Traefik returned 503 from the public URL
|
||||
even though the app was actually running. The Dockerfile-level
|
||||
`HEALTHCHECK` already used 127.0.0.1; the `docker-compose.yml`
|
||||
override was the one that drifted to `localhost`. Fixed.
|
||||
|
||||
### Notes
|
||||
|
||||
- The 1.4.1 GHCR image never published cleanly (the docker-publish
|
||||
workflow reported success in GH Actions but the
|
||||
`ghcr.io/mbombeck/healthlog:1.4.1` tag returned `manifest unknown`
|
||||
when Coolify tried to pull). The 1.4.2 release supersedes 1.4.1
|
||||
and includes everything 1.4.1 was supposed to ship — the v1.4.1
|
||||
source on `main` was always healthy; only the Coolify deploy
|
||||
surface was broken.
|
||||
|
||||
## [1.4.1] — 2026-05-08
|
||||
|
||||
### Security
|
||||
|
||||
- **moodLog integration no longer accepts internal-network URLs.** A
|
||||
user could previously save `http://169.254.169.254/` (cloud-metadata)
|
||||
or any RFC1918 address as their moodLog instance; the daily sync
|
||||
worker would then fetch from that target with the user's API key in
|
||||
the Authorization header. The credentials write path now refuses
|
||||
non-public hosts, the sync worker re-checks the URL at the actual
|
||||
fetch site (so legacy rows stored before the guard are also
|
||||
refused), and the fetch is now `redirect: "manual"` so a public
|
||||
host cannot 302 to an internal target with the bearer on the
|
||||
redirect hop.
|
||||
- **Error reports never echo bearer tokens, Telegram bot tokens, or
|
||||
query-string secrets.** `WideEventBuilder.setError()` and the
|
||||
Glitchtip incident path now run every error message and stack
|
||||
trace through a central `redactSecrets()` filter that scrubs
|
||||
`Bearer …`, Telegram `bot<digits>:<token>` URLs, and `?secret=`,
|
||||
`?code=`, `?token=`, `?api_key=` query strings. The substitution
|
||||
is generic `[REDACTED]` so partial entropy is never revealed.
|
||||
|
||||
### Fixed — Citation accuracy
|
||||
|
||||
- **Blood-pressure classification now cites ESH 2023.** The dashboard
|
||||
tile, the doctor-report PDF, and the inline analytics comments
|
||||
used to label the band as "ESC/ESH 2018". The numbers haven't
|
||||
changed (the 2023 ESH update kept the 2018 thresholds), but the
|
||||
joint authoring did — ESC withdrew from the 2023 document, so the
|
||||
correct citation is "ESH 2023" alone.
|
||||
- **Steps target source label is `Saint-Maurice JAMA 2020`** instead
|
||||
of `WHO`. Every other surface in the app (AI prompts, inline
|
||||
comments, drift tests) already enforced this attribution; the
|
||||
insights/targets surface was the last "WHO" label in the tree.
|
||||
WHO publishes physical-activity _time_, not a step quota.
|
||||
- **Saint-Maurice "mortality plateau 8000–12000" attribution
|
||||
softened.** The original JAMA 2020 paper reports continued
|
||||
dose-response benefit (HR 0.49 at 8k, HR 0.35 at 12k) — not a
|
||||
plateau. The plateau-shaped finding belongs to Paluch 2022
|
||||
_Lancet Public Health_ (PMID 35247352), not Saint-Maurice. The
|
||||
inline comments and AI prompts now say "continued dose-response
|
||||
benefit through ~12,000 steps/day" instead.
|
||||
|
||||
### Added — CI safety nets
|
||||
|
||||
- **Postgres-backed integration test suite is now executable.** The
|
||||
testcontainers infrastructure shipped in 1.4.0; this release wires
|
||||
the per-test boilerplate through vitest's `globalSetup` so all
|
||||
four files share one container. `pnpm test:integration` runs ten
|
||||
tests (rate-limit race, idempotency replay-attack contract, GDPR
|
||||
Article-17 cascade delete, session create / read / expire) against
|
||||
a real Postgres in under four seconds. CI runs the suite on every
|
||||
PR.
|
||||
- **Playwright + axe-core E2E foundation.** A new `pnpm e2e` runs
|
||||
five public-surface specs (version endpoint, proxy auth-redirect,
|
||||
login form autofill hints, DE/EN locale switch, axe-core
|
||||
accessibility gate) against the production build in CI. Authenticated
|
||||
flow specs (quick-entry, doctor-report, settings round-trip,
|
||||
test-buttons, onboarding) ride a follow-up release because they
|
||||
need a seeded test user; the foundation makes adding them a
|
||||
one-PR step.
|
||||
|
||||
### Changed — Admin internals
|
||||
|
||||
- **Admin page is now per-section components.** The status-card grid
|
||||
shipped in 1.4.0 sat on top of a 2,700-line monolith; that monolith
|
||||
is now 14 focused files in `src/components/admin/` with a 77-line
|
||||
`src/app/admin/page.tsx` shell that mounts them. Every section
|
||||
keeps the same DOM, ids, query keys, and i18n keys — no
|
||||
user-visible change.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Final ESLint error is gone.** The medications page's "API
|
||||
endpoint" dialog ran its initial-load fetch through a `useCallback`
|
||||
paired with `useEffect` and triggered the strict
|
||||
`react-hooks/set-state-in-effect` rule. Refactored to TanStack
|
||||
Query — same network calls, no effect, lint count is now zero on
|
||||
`main`.
|
||||
|
||||
### Documentation
|
||||
|
||||
- **Repo-internal docs synced for v1.4.** README adds the
|
||||
Multi-tenant ready and Test connection buttons feature blocks, the
|
||||
API reference table includes the eleven new v1.4 endpoints, and
|
||||
the model count is corrected to 26 (RefreshToken). AGENTS.md and
|
||||
CLAUDE.md reflect the per-route `/settings/[section]` layout and
|
||||
the per-section admin layout. `docs/api/openapi.yaml` documents
|
||||
the new endpoints (version, refresh, refresh/revoke,
|
||||
status-overview, backup/test, the five test-connection probes).
|
||||
`docs/migration/v1.3-to-v1.4.md` corrects the now-wrong "no
|
||||
migrations" claim and adds full env-var sections for the
|
||||
worker/web split, encryption-key versioning, and off-host backup
|
||||
target.
|
||||
|
||||
### Notes
|
||||
|
||||
- No database migration in 1.4.1.
|
||||
- No environment-variable change required to upgrade.
|
||||
- No API contract change — every route added in 1.4.0 is still
|
||||
there; no shapes or status codes flipped.
|
||||
- The audit pass that drove this release identified five medium
|
||||
security items and three P0 performance items that warrant
|
||||
deeper architectural work; those are tracked in
|
||||
`docs/ops/v141-followup-issues.md` and ride a future release.
|
||||
|
||||
## [1.4.0] — 2026-05-08
|
||||
|
||||
### Added — Foundation, safer ranges, and a faster dashboard
|
||||
|
||||
- **UI guidelines, design tokens, and shared primitives.** A new
|
||||
`docs/ui-guidelines.md` is the single source of truth for spacing,
|
||||
typography, button hierarchy, dialog-vs-sheet decisions, accessibility
|
||||
baseline (WCAG 2.1 AA), and the autofill / honeypot pattern for
|
||||
health-data forms. Two new shadcn primitives — `<Skeleton>` and
|
||||
`<EmptyState>` — replace the previous mix of spinners and "No data"
|
||||
placeholder strings. Future v1.4.x components reference the doc; the
|
||||
primitives ship with screen-reader-aware semantics and respect
|
||||
`prefers-reduced-motion`.
|
||||
- **`/api/version` public endpoint** exposing the build's version,
|
||||
optional Git SHA / build timestamp, license, and canonical links.
|
||||
Wires the future Settings → About surface and a thin "Check for
|
||||
updates" UX. Static-cached so the route adds zero DB load.
|
||||
- **`src/lib/medical-citations.ts`** — single source of truth for
|
||||
cited medical guidelines (id, name, year, URL, caveat). Future
|
||||
medical surfaces import these constants instead of duplicating
|
||||
strings in code, prompts, and `messages/*.json`. A new drift-test
|
||||
asserts every entry has a non-empty URL + caveat and that the
|
||||
recurring "WHO ≥ N steps" hallucination cannot reappear as a constant.
|
||||
|
||||
### Fixed — Patient safety and citation accuracy
|
||||
|
||||
- **Diastolic blood-pressure orange band no longer reaches 60 mmHg.**
|
||||
With the default age-based targets (DBP 70–79), the lower orange
|
||||
wing was computed as `diaLow − 10 = 60`. A reading of 60 mmHg landed
|
||||
in "mildly low" yellow instead of red even though that level is the
|
||||
general-adult hypotension threshold and the J-curve risk floor in
|
||||
ESH 2023 for treated hypertensives. Orange floor is now clamped at
|
||||
65 mmHg, so 60 mmHg lands in red. The user-override path stays
|
||||
intact and remains audit-logged.
|
||||
- **BP guideline citations consolidated on ESH 2023.** The codebase
|
||||
had a mix of "ESC/ESH 2018" (analytics) and "ESC/ESH 2023" (AI
|
||||
prompts). The 2023 hypertension document is ESH-only — ESC withdrew
|
||||
from the joint authoring — so neither label was correct. Every site
|
||||
now cites "ESH 2023" with the published source URL. Numbers
|
||||
unchanged.
|
||||
- **"WHO ≥ 8 000 steps/day" hallucination fully removed.** WHO
|
||||
publishes activity _time_ (150–300 min/wk moderate), not a step
|
||||
quota. The v1.3.3 fix only landed in `effective-range.ts`; four AI
|
||||
prompt strings and the `getStepsRange()` helper carried the old
|
||||
wording forward. Saint-Maurice et al., JAMA 2020 (mortality plateau
|
||||
8 000–12 000) is now cited everywhere and the two surfaces agree on
|
||||
the band. Sleep target moves from "ESC" (no adult sleep guideline)
|
||||
to AASM 2015.
|
||||
- **Body-fat ACE bands corrected and three-way drift resolved.** The
|
||||
classifier used `essential = 6 (M) / 14 (F)` as the floor — but
|
||||
that's actually ACE's _Athletes_ lower bound. Readings below were
|
||||
mislabelled "Essential" instead of "Below essential" (a danger
|
||||
band). Six-band classifier now mirrors the ACE table, and the three
|
||||
sites that had three different green-band numbers
|
||||
(`value-bands.ts`, `targets/route.ts`, `classifications.ts`) all
|
||||
derive from `getBodyFatTargetRange` (ACE fitness + acceptable bands).
|
||||
- **Bedtime-glucose citation softened.** ADA Standards 2024 §6
|
||||
publishes pre-prandial 80–130 and post-prandial <180 — no published
|
||||
adult bedtime target. The 90–150 mg/dL band stays (reasonable adult
|
||||
overnight band) but the inline citation now states the absence
|
||||
explicitly and references ISPAD 2022 (pediatric) as the closest
|
||||
comparator.
|
||||
|
||||
### Fixed — Localisation reaches the notification path
|
||||
|
||||
- **Medication reminders now follow the user's locale.** Telegram,
|
||||
ntfy, and Web Push reminders previously read "Erinnerung", "Bald
|
||||
fällig", etc. regardless of the user's stored language. Templates
|
||||
for every phase (`green`/`yellow`/`orange`/`red`) and every keyboard
|
||||
button now resolve from `messages/{de,en}.json` per
|
||||
`med.user.locale`. Telegram callback IDs stay stable English
|
||||
identifiers so the dispatcher keeps matching across locale changes.
|
||||
- **Dashboard greeting and streak label** are localised server-side.
|
||||
Previously hard-coded `"Hi, ${name}"` and `"Tage in Folge"` — both
|
||||
now i18n-key-resolved.
|
||||
- **Mixed-locale Zod validation messages unified to English.** Two
|
||||
measurement-form messages and four admin-validation messages
|
||||
flipped between German and English depending on which schema fired.
|
||||
All consolidated on English (the app is English-first; the German
|
||||
UI maps field labels client-side).
|
||||
|
||||
### Fixed — Chart math edge cases
|
||||
|
||||
- **`summarize` and `trendSlope` use the same time anchor.** Averages
|
||||
snapped to `Date.now()`; slopes snapped to the latest point in the
|
||||
series. A stale series reported a trend even though the dashboard
|
||||
tile correctly hid the average. Both now anchor on `Date.now()`, so
|
||||
a stale series returns `null` consistently from every windowed stat.
|
||||
- **`summarize([])` returns `null` for `min`/`max`/`mean`** instead
|
||||
of zeros that leaked into chart axes and AI feature bundles as
|
||||
fake readings.
|
||||
- **`weeklyAverages` is Berlin-timezone aware.** A Sunday-evening
|
||||
Berlin reading bucketed into the next week on the UTC production
|
||||
container because `Date.getDay()` was system-local. ISO-Monday key
|
||||
now resolves via `Intl.DateTimeFormat({ timeZone: "Europe/Berlin" })`.
|
||||
- **`pairByTimestamp` JSDoc** documents the greedy nearest-match
|
||||
heuristic and when a Hungarian-style match would matter (sparse
|
||||
health data is well below that bar).
|
||||
|
||||
### Fixed — Hidden friction
|
||||
|
||||
- **AI provider connection-test honours the unsaved selection.**
|
||||
Changing the AI provider in `/settings`, then clicking "Verbindung
|
||||
testen" without saving first, used to silently run the test against
|
||||
the stored provider — surfacing as a confusing OK / failure unrelated
|
||||
to what the user had on screen. Plaintext keys never persist; the
|
||||
existing SSRF guard, rate limit, and V3 error-leak shielding stay in
|
||||
place.
|
||||
- **Health-data inputs no longer autofill the user's account
|
||||
password.** The base `<Input>` primitive defaults to
|
||||
`autoComplete="off"` plus the LastPass / 1Password ignore attributes
|
||||
whenever the caller doesn't pass a semantic value. Auth and profile
|
||||
forms continue to autofill normally because they pass an explicit
|
||||
`autoComplete` (`"username"`, `"email"`, `"current-password"`,
|
||||
`"new-password"`).
|
||||
- **Step-range target aligned across two callsites.**
|
||||
`getStepsRange()` returned `{7000, 10000}` while
|
||||
`effective-range.ts` returned `{8000, 15000}`; two surfaces showed
|
||||
different "green" bands to the same user. Both now use
|
||||
`{8000, 15000}`, anchored on Saint-Maurice 2020.
|
||||
|
||||
### Performance
|
||||
|
||||
- **Two more N+1 queries closed.** `extractFeatures` (used by every
|
||||
AI-insight route) issued one `prisma.medicationIntakeEvent.findMany`
|
||||
per active medication; replaced with a single batched query and an
|
||||
in-memory group. `/api/insights/targets` issued one `findFirst` per
|
||||
measurement type; replaced with a single `distinct: ["type"]` query.
|
||||
Same shape as the v1.3.0 fix to `/api/insights/comprehensive`.
|
||||
|
||||
### Changed — Dashboard
|
||||
|
||||
- **Tile strip is always one row.** Replaces the wrapping
|
||||
`grid-cols-2 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5` layout
|
||||
with a `flex snap-x snap-mandatory overflow-x-auto` strip. When the
|
||||
user enables more tiles than fit the viewport, the strip
|
||||
horizontal-scrolls instead of wrapping; the user trims the set in
|
||||
Settings → Dashboard Layout.
|
||||
|
||||
### Added — Settings, integrations, and operations
|
||||
|
||||
- **Settings page now lives at `/settings/[section]`.** Eight focused
|
||||
routes (`account`, `integrations`, `notifications`, `dashboard`,
|
||||
`ai`, `api`, `advanced`, `about`) replace the single 3,000-line page.
|
||||
Existing `/settings#anchor` links 308-redirect; the side-bar, in-app
|
||||
deep links, and the AI / Withings / Codex callbacks all follow the
|
||||
new structure.
|
||||
- **About page** lists the running version, build SHA, license,
|
||||
repository link, CHANGELOG link, docs link, and a "Check for
|
||||
updates" button that pings the public GitHub releases API. Backed by
|
||||
the `/api/version` endpoint shipped earlier in 1.4.0.
|
||||
- **Admin console** is built around a status-first card grid (Users,
|
||||
Integrations, Monitoring, Backups, Maintenance, Audit Log) with each
|
||||
area in a focused panel beneath. Per-section extraction of the old
|
||||
inline panels is tracked for v1.4.1 — the v1.4.0 admin page already
|
||||
routes through the new aggregator endpoint and the status-card
|
||||
grid.
|
||||
- **Five new "Test connection" buttons in Settings.** Withings,
|
||||
moodLog, Web Push, Glitchtip, and Umami now ship with one-click
|
||||
connection probes — same pattern as the existing AI / Telegram /
|
||||
ntfy tests, with per-button rate limit, sanitised error reporting,
|
||||
redirect-follow SSRF guard, and an `errorCode` in the response
|
||||
envelope so the UI can localise the message.
|
||||
- **AI insights can reference any of your charts inline.** When a
|
||||
finding centres on a single metric (e.g. systolic blood pressure),
|
||||
the corresponding chart renders directly under the explanation.
|
||||
Server-side allow-list — only the allowed metric tokens render; any
|
||||
other model emission drops silently.
|
||||
- **Off-host backup target.** Daily encrypted JSON dumps to any
|
||||
S3-compatible bucket. Worker-side IAM grant is intentionally
|
||||
PutObject + GetObject only — retention is the bucket's
|
||||
lifecycle-rule job, so a compromised worker cannot wipe the backup
|
||||
history. Restore script + step-by-step doc shipped under
|
||||
`docs/ops/backup-restore.md`, and an admin "Backup target" test
|
||||
button validates the configuration.
|
||||
- **Encryption-key versioning.** Rotate the at-rest encryption key
|
||||
without downtime via `pnpm tsx scripts/rotate-encryption-key.ts <id>`.
|
||||
Existing data keeps decrypting under its original key while the new
|
||||
one is rolled out. Walk-through + rollback notes in
|
||||
`docs/ops/encryption-key-rotation.md`.
|
||||
- **Worker / web split.** Optional
|
||||
`HEALTHLOG_PROCESS_TYPE=web|worker|all` (default `all` for the
|
||||
single-container setup) lets you scale background jobs and HTTP
|
||||
traffic independently. The proxy refuses HTTP traffic with a 503 +
|
||||
`X-HealthLog-Process-Type: worker` header in worker mode so a
|
||||
misrouted request fails loudly instead of a silent half-served
|
||||
response.
|
||||
- **Native API clients now get short-lived 24-hour access tokens with
|
||||
refresh-token rotation.** The browser keeps the existing 90-day
|
||||
Bearer. Reuse-detection (presenting a refresh token a second time)
|
||||
revokes every refresh token for the user — the small cost of a
|
||||
forced re-login on the legitimate device buys defense-in-depth
|
||||
against an undetected stolen-token replay.
|
||||
- **Critical-path coverage on Telegram / Withings / moodLog /
|
||||
Glitchtip webhook handlers + the four admin routes lifted to ≥80%
|
||||
line coverage,** plus `src/lib/auth/audit.ts`. ~+100 new tests.
|
||||
|
||||
### Fixed — Operational hardening from the v1.4 review pass
|
||||
|
||||
- **Container time zone is correct.** Alpine images ship without
|
||||
`tzdata`; the daily backup cron `30 2 * * *` Europe/Berlin was
|
||||
silently falling back to UTC. The runner stage now installs
|
||||
`tzdata` and exports `TZ=Europe/Berlin` so schedules fire at the
|
||||
documented local time.
|
||||
- **Compose healthcheck uses `wget --spider /api/version`** — `/api/version`
|
||||
is now in the proxy's public-paths allowlist, so the healthcheck no
|
||||
longer 302-redirects through the auth gate (which was accepting the
|
||||
login page as a 200 success).
|
||||
- **Idempotency replay-cache no longer caches refresh tokens.** The
|
||||
guard already blocked the `hlk_` access-token prefix; the new
|
||||
`hlr_` refresh tokens are blocked too.
|
||||
- **Logout-on-device revokes the paired access token.** Calling
|
||||
`/api/auth/refresh` with `revoke: true` now flips both the refresh
|
||||
row and the matching `ApiToken` row to revoked, so a leaked access
|
||||
token cannot outlive its refresh-token sibling.
|
||||
- **`users.locale` migration drift backfilled.** The column had been
|
||||
on `schema.prisma` since the v1.3 locale-aware reminder work but
|
||||
never landed in the migration history (it must have been applied
|
||||
via `prisma db push` to dev/prod). Any environment built strictly
|
||||
from `prisma/migrations/` (CI testcontainers, brand-new self-host
|
||||
installs) is now consistent. Migration is `ADD COLUMN IF NOT
|
||||
EXISTS`, so it's a clean add on a fresh database and a safe no-op
|
||||
against any environment that was already kept in sync.
|
||||
|
||||
### Notes
|
||||
|
||||
- Largely additive release. Existing API contracts (response
|
||||
envelopes, OpenAPI 3.1 spec) are unchanged. New endpoints surface
|
||||
optional fields; no breaking changes.
|
||||
- New migration `0025_refresh_tokens` adds the rotating refresh-token
|
||||
table; new migration `0025_user_locale_drift_fix` backfills the
|
||||
schema-vs-migrations drift on `users.locale`. Both are
|
||||
forward-compatible — `IF NOT EXISTS` guards make them idempotent on
|
||||
any environment already pushed-to.
|
||||
- Operators of the off-host backup feature must configure a bucket
|
||||
lifecycle policy for retention. The worker has no DeleteObject
|
||||
grant by design.
|
||||
- Native API clients (iOS, n8n, Health Connect) need to update their
|
||||
login flow: native logins now return both a 24-hour access token
|
||||
and a refresh token. The browser flow is unchanged.
|
||||
- **Tracked for v1.4.1:** per-section admin panel extraction (the
|
||||
status-card grid + aggregator already ship in 1.4.0; the inner
|
||||
per-section file split is structural cleanup), the Postgres-backed
|
||||
integration test suite (testcontainers infrastructure ships in this
|
||||
release; the four integration tests themselves need a follow-up
|
||||
pass against the merged schema), and Playwright E2E + axe-core CI
|
||||
gates.
|
||||
|
||||
## [1.3.3] — 2026-05-08
|
||||
|
||||
### Added
|
||||
|
||||
- **Pulse oximetry as a first-class measurement type (`OXYGEN_SATURATION`).**
|
||||
Closes the SpO2 part of #109. Migration `0024_oxygen_saturation` extends
|
||||
the `MeasurementType` enum. Plausibility range 50–100% (below 50% is
|
||||
incompatible with sustained life and almost certainly a faulty sensor;
|
||||
upper bound 100% is physical). Default severity bands follow BTS Guideline
|
||||
2017 + ATS clinical practice: green 95–100%, orange 92–94%, red <92% —
|
||||
lower-only concern (the upper orange wing collapses onto greenMax since
|
||||
saturation cannot physically exceed 100%). COPD / chronic-respiratory
|
||||
users with a doctor-set baseline of 88–92% can personalize via the
|
||||
threshold-override UI. Wired through Withings (ScanWatch type 54),
|
||||
measurement form, list, charts, doctor PDF, OpenAPI spec, and i18n (DE +
|
||||
EN). iOS DTO already declared `OXYGEN_SATURATION` from a prior commit;
|
||||
the server enum addition closes the long-standing drift.
|
||||
- **Body composition surfaces (TOTAL_BODY_WATER, BONE_MASS, BLOOD_GLUCOSE)
|
||||
in the measurements list filter, badge, mobile icon, edit dialog, and
|
||||
server-rendered doctor-report PDF** — closes the UI side of #109. Root
|
||||
cause was three local maps in `measurement-list.tsx` that drifted from
|
||||
the v1.3 server enum; extracted to `measurement-list-meta.ts` with
|
||||
fail-fast coverage tests so future enum additions are caught at build
|
||||
time. Server-side PDF used a separately-drifted type map vs. the
|
||||
browser-side renderer; both are now in sync.
|
||||
- **Effective-range thresholds for `TOTAL_BODY_WATER` and `BONE_MASS`** —
|
||||
severity logic was returning `nominal` for any value because no defaults
|
||||
existed.
|
||||
|
||||
### Changed
|
||||
|
||||
- **OpenAPI `MeasurementType` enum extended + spec version bumped 1.3.0 →
|
||||
1.3.3** to match the actual app. Spec was lagging by two minor releases.
|
||||
- **Withings webhook secret now reads from `X-Withings-Webhook-Secret`
|
||||
header** in preference to the legacy `?secret=…` URL query parameter.
|
||||
Closes the URL-leak-via-access-logs vector flagged in audit C-3. Legacy
|
||||
query-param path is retained for backwards compatibility and emits a
|
||||
Wide Event warning so operators can spot still-using-the-old-flow
|
||||
integrators. Plan: remove the query fallback in 1.4.x once warnings drain.
|
||||
- **Idempotency `defaultUserIdResolver` now supports Bearer tokens.**
|
||||
Cookie sessions tried first, then Bearer-token via `hashToken` lookup.
|
||||
Without the Bearer fallback, every iOS / external-ingest retry was
|
||||
hitting the handler again and creating duplicate measurements (audit
|
||||
C-4 — the exact use case `withIdempotency` was built for).
|
||||
- **GlitchTip URL stripping** — `reportToGlitchtip` now strips the URL
|
||||
query string before forwarding so Withings legacy `?secret=…` and OAuth
|
||||
`?code=…` callbacks cannot leak via the error tracker (audit H-B7).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Migration `0022_body_composition_metrics` unit comment lied** —
|
||||
claimed `TOTAL_BODY_WATER: percent of body weight (%)` while every other
|
||||
surface (validators, Withings client, doctor PDF) treated it as `kg`.
|
||||
Comment corrected to match reality.
|
||||
|
||||
### Security
|
||||
|
||||
- **Bearer-scope wildcard handling (CRITICAL — V3-1).** `requireAuth()`
|
||||
previously accepted any non-admin token regardless of declared
|
||||
permission scope, so a token with `permissions:["medication:ingest"]`
|
||||
could DELETE the user account. Spec now requires `permissions:["*"]`
|
||||
or the explicit required permission.
|
||||
- **Account-deletion completeness (CRITICAL — V3-2 / GDPR Art. 17).**
|
||||
Cascades through `Feedback` + `AuditLog` rows so user-erasure is
|
||||
actually total. Daily retention job sweeps orphaned audit rows after
|
||||
90 days as a defence-in-depth.
|
||||
- **Withings webhook secret header migration (audit C-3)**, idempotency
|
||||
Bearer-resolver (audit C-4), GlitchTip URL strip (audit H-B7).
|
||||
- **Truthfulness pass on medical citations** — SpO2 normal-range source
|
||||
is now consumer-pulse-oximeter consensus + NICE NG115 + FDA labelling
|
||||
(BTS-2017 was for clinical hypoxaemia thresholds, not consumer
|
||||
monitoring); body-composition metrics are explicitly labelled
|
||||
"bioimpedance-estimated, not DEXA-comparable" in the doctor PDF;
|
||||
TBW citation now references the Watson formula / ICRP Reference Man
|
||||
(was misattributed to ESPEN 2017); steps target now references
|
||||
Saint-Maurice JAMA 2020 (WHO publishes minutes/week, not steps).
|
||||
- **SpO2 user-override clamp** — overrides could emit physical
|
||||
impossibilities (e.g. `orangeMax = 100.75`); clamped to METRIC_BOUNDS
|
||||
for SpO2 + BODY_FAT.
|
||||
- **moodLog webhook secret encrypted at rest with AES-256-GCM** (V3
|
||||
STILL-V2-C-2). Read path tolerates legacy plaintext rows during the
|
||||
transition window; one-shot startup migration in the worker rotates
|
||||
any leftover plaintext rows.
|
||||
- **CSP tightening** — `chatgpt.com` + `api.openai.com` `connect-src`
|
||||
now gated to `/settings/ai/**` (was a global blanket on every page,
|
||||
including `/auth/login` → DOM-XSS exfil channel).
|
||||
- **Web-Push subscription endpoint SSRF guard** — `endpoint` now
|
||||
requires HTTPS + passes `isPublicUrl()` (was `z.url()` only).
|
||||
Side-fix: `isPublicUrl()` no longer falsely classifies DNS labels
|
||||
starting with `fc`/`fd` (e.g. `fcm.googleapis.com`) as IPv6
|
||||
unique-local; the IPv6 check is now gated on a colon being present.
|
||||
- **IP-geolocation lookup is now HTTPS-only.** Default provider is
|
||||
`ipwho.is` (free, HTTPS, no key). Existing `ip-api.com` plaintext
|
||||
HTTP path leaked auth-event IP + timestamp on every login (GDPR Art.
|
||||
32 + Art. 44). Operators can override via `IP_GEO_LOOKUP_URL` (HTTPS
|
||||
only) or disable entirely with `IP_GEO_LOOKUP_DISABLED=1`.
|
||||
- **`/api/ai/test` no longer returns provider error message + body
|
||||
excerpt to the client.** Diagnostics land server-side via Wide Events
|
||||
(annotate); client gets a categorised generic message. Closes provider
|
||||
URL / partial key / internal header leak.
|
||||
- **`/api/import` rate-limit added** — 5 imports/hour/user. Was
|
||||
unlimited (bulk-injection vector).
|
||||
- **Trusted-proxy XFF semantics** — `getClientIp()` now reads
|
||||
`X-Forwarded-For` right-to-left with a configurable
|
||||
`TRUST_PROXY_HOPS` (default 1, matches typical single-proxy
|
||||
self-host). Closes XFF rotation bypass of per-IP rate-limits.
|
||||
- **Audit-log retention job** — `audit_logs` rows older than
|
||||
`AUDIT_LOG_RETENTION_DAYS` (default 365) are purged daily. Closes
|
||||
GDPR Art. 5(1)(e) "storage limitation" gap.
|
||||
- **Idempotency cachable-status filter** is now an exported, unit-tested
|
||||
function — pins the do-not-cache contract for 401/403/408/429/5xx.
|
||||
- **Bearer mock tightening** in `require-auth-bearer.test.ts` +
|
||||
`idempotency.test.ts`: `apiToken.findUnique` calls are now asserted
|
||||
to use `where: { tokenHash: <hashed> }`, so a regression to raw-token
|
||||
comparison would break the suite immediately.
|
||||
|
||||
### Internal
|
||||
|
||||
- **Server-side enum drift cousins closed.** Five module-level
|
||||
hardcoded type-arrays in `/api/insights/comprehensive`,
|
||||
`/api/dashboard/summary`, `/api/analytics`, `/lib/insights/general-status`,
|
||||
`/api/import` are now derived from `measurementTypeEnum.options`.
|
||||
External-contract enums extended additively:
|
||||
`/api/measurements/series` (`oxygen`, `totalBodyWater`, `boneMass`),
|
||||
`/api/dashboard/widgets` (`oxygenSaturation`), `DashboardWidgetId` +
|
||||
`DEFAULT_DASHBOARD_LAYOUT`. New coverage test asserts the canonical
|
||||
enum stays the source of truth.
|
||||
- **Doctor-PDF text-content tests** — replaced bytes-only "renders body
|
||||
composition rows" theatre with `pdf-parse`-driven assertions on the
|
||||
actual rendered DE + EN labels and values. Adds dev dep `pdf-parse`.
|
||||
|
||||
## [1.3.2] — 2026-04-28
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Glucose tiles on the dashboard rendered the raw i18n key
|
||||
`targets.glucoseFasting` instead of the translated label** (closes
|
||||
#108). Both `messages/en.json` and `messages/de.json` had two
|
||||
top-level `targets` blocks; `JSON.parse` silently keeps the last
|
||||
occurrence, and that block was missing the four glucose labels. The
|
||||
duplicate is now collapsed into a single block. A duplicate
|
||||
`bugreport.bugTitlePlaceholder` shadowed inside `bugreport` was
|
||||
cleaned up too. Two further keys (`dashboard.sleep`,
|
||||
`dashboard.steps`) were missing from both locales and were falling
|
||||
back to hard-coded English; both are now translated.
|
||||
- **New i18n locale-integrity test**
|
||||
(`src/lib/__tests__/i18n-locale-integrity.test.ts`) fails the build
|
||||
on duplicate keys at any nesting depth and on key drift between
|
||||
`en` and `de` — closes the structural gap that let the duplicate
|
||||
`targets` block ship in the first place.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Screenshot upload removed from the bug-report form** (also part
|
||||
of #108). The form previously accepted an image attachment that
|
||||
was stored in the local DB but never reached the published GitHub
|
||||
issue — GitHub does not accept inline base64 data URIs in issue
|
||||
bodies and offers no public API to attach images to an issue
|
||||
programmatically. Rather than ship misleading
|
||||
"a screenshot was attached" placeholder text in the resulting
|
||||
issue, the upload UI is now gone and the placeholder note is no
|
||||
longer added when promoting feedback. The `screenshotBase64`
|
||||
column and the admin-side preview of previously-submitted
|
||||
screenshots are unchanged — existing reports keep their
|
||||
attachments locally. We plan to revisit a real screenshot pipeline
|
||||
in a future release.
|
||||
|
||||
## [1.3.1] — 2026-04-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Compose env-var validation no longer breaks Coolify-style deploys.**
|
||||
`docker-compose.yml` previously used `${VAR:?required}` shell-parameter
|
||||
syntax for the four secrets and `POSTGRES_PASSWORD`. Some hosting
|
||||
platforms (Coolify in particular) parse compose files eagerly and
|
||||
store the _fallback error string_ (`"POSTGRES_PASSWORD is required"`)
|
||||
as the literal env-var value when `POSTGRES_PASSWORD` was unset,
|
||||
which then collided with `DATABASE_URL` and broke the running app
|
||||
with `P1000: Authentication failed`. Compose now uses plain `${VAR}`
|
||||
interpolation; validation moved into `docker-entrypoint.sh`, which
|
||||
fails fast with a clear stderr message listing the unset variables.
|
||||
|
||||
### Notes
|
||||
|
||||
If you upgraded an existing Compose stack from 1.2.x → 1.3.0 and hit
|
||||
the `POSTGRES_PASSWORD is required` literal-as-value bug, set
|
||||
`POSTGRES_PASSWORD` in your environment to whatever your existing
|
||||
Postgres data volume was originally initialised with (likely
|
||||
`healthlog` if you started from a pre-1.2.1 release), then redeploy.
|
||||
Postgres only honours `POSTGRES_PASSWORD` on first volume init — the
|
||||
existing user keeps the original password regardless of env changes.
|
||||
|
||||
## [1.3.0] — 2026-04-27
|
||||
|
||||
### Added — Body composition + targeted hardening
|
||||
|
||||
- **Total body water and bone mass as measurement types** (closes #89). New
|
||||
enum values `TOTAL_BODY_WATER` and `BONE_MASS`, both stored canonically
|
||||
in kilograms (matches Withings hydration/bone-mass measures and Health
|
||||
Connect's `TotalBodyWaterRecord` / `BoneMassRecord`). Migration is
|
||||
purely additive (`ALTER TYPE ... ADD VALUE`) — safe to apply against
|
||||
any 1.2.x database without downtime.
|
||||
- **Withings sync picks both up automatically.** The Withings client now
|
||||
maps measure type `77` (hydration / water mass) and `88` (bone mass).
|
||||
Anyone with a Withings Body+ scale and an active connection will see
|
||||
the new metrics flowing in on the next sync without any extra config.
|
||||
- **Doctor-report PDF includes both new types** in the vital-signs table
|
||||
when data exists, with locale-aware labels in English and German.
|
||||
- **Dashboard widgets registered for both** (default-invisible — opt in
|
||||
via Settings → Dashboard layout).
|
||||
|
||||
### Security
|
||||
|
||||
- **SSRF guard hardened** (`isPublicUrl`). The previous implementation
|
||||
used `parseInt` with permissive prefix checks like `h.startsWith("10.")`
|
||||
which let `010.0.0.1` slip through — and worse, the WHATWG URL parser
|
||||
silently normalises `010.0.0.1` to `8.0.0.1` (octal interpretation), a
|
||||
real bypass on naive checks. The new guard adds a pre-URL leading-zero
|
||||
check on the raw input, a strict IPv4 parser, and proper IPv6
|
||||
bracket / loopback / link-local handling. Now blocks `127.0.0.0/8`,
|
||||
`0.0.0.0/8`, `10.0.0.0/8`, `169.254.0.0/16`, `172.16.0.0/12`,
|
||||
`192.168.0.0/16`, `100.64.0.0/10` (CGNAT), `::1`, `fe80::/10`,
|
||||
`fc00::/7`. Comprehensive regression tests included.
|
||||
- **GitHub PAT redacted from logged error bodies.** When a feedback
|
||||
escalation to GitHub fails, the response body was being passed
|
||||
verbatim into `getEvent()?.addWarning(...)` — flowing to Loki. The
|
||||
body is now stripped of the configured token before logging.
|
||||
- **Per-user threshold writes are rate-limited** (30 writes / 5 min) to
|
||||
make audit-log enumeration unattractive. Audit-logging itself was
|
||||
already there.
|
||||
|
||||
### Performance
|
||||
|
||||
- **N+1 in `/api/insights/comprehensive` fixed.** The medication-compliance
|
||||
loop hit Postgres once per active medication. It now batches into a
|
||||
single `medicationId IN (...)` query and groups in memory. Latency
|
||||
improvement scales linearly with the user's active medication count.
|
||||
|
||||
### Reliability
|
||||
|
||||
- **pg-boss draining on SIGTERM/SIGINT.** `docker stop`, Coolify redeploys,
|
||||
and Kubernetes pod terminations now trigger `boss.stop({ graceful: true,
|
||||
timeout: 30s })` so in-flight handlers finish instead of being killed.
|
||||
Previously, pending handlers could be lost or replayed on restart.
|
||||
- **CI now blocks on TypeScript strict checks.** `continue-on-error: true`
|
||||
removed for typecheck. Tests already were blocking. ESLint stays
|
||||
non-blocking for now — the existing `settings/page.tsx` monolith has
|
||||
long-standing `react-hooks/set-state-in-effect` violations whose proper
|
||||
fix lives in the 1.4.0 settings-split refactor; a blocking lint here
|
||||
would just gate every PR until that refactor lands. Required cleaning
|
||||
up two `any` types in `api-handler.ts` (justified inline — Next.js
|
||||
variadic handler signature constrained by `Promise<Response>` return).
|
||||
|
||||
### Polish
|
||||
|
||||
- **Bottom-nav touch targets** sized to WCAG 2.5.5 minimum (44×44 CSS px).
|
||||
Visual icon stays at 20 px so the design doesn't shift.
|
||||
- **Phase-config dialog** marks the decorative coloured dot `aria-hidden`
|
||||
because the redundant text label already conveys the phase to screen
|
||||
readers. No more meaningless `image` node announcements.
|
||||
- **Admin-status labels** for "Web Push" and "Bug Report" now go through
|
||||
`t()` (new `admin.integrationWebPush` / `admin.integrationBugReport`
|
||||
keys in en + de). They were the last hard-coded English strings on
|
||||
the admin page.
|
||||
|
||||
### What's _not_ in this release (tracked for later)
|
||||
|
||||
- **Onboarding redesign** (dashboard-first empty-state flow + persistent
|
||||
Getting Started checklist) and the **typed `apiClient` wrapper** that
|
||||
underpins it are tracked for a focused 1.4.0 cycle. The 1.2.1 patch
|
||||
already closed the acute symptoms of #87 (silent-failure toast +
|
||||
default schedule), so the redesign is now a proper UI investment
|
||||
rather than a bug-fix.
|
||||
- **Withings sync is mapping both new measures**, but **a dedicated
|
||||
Bearer-auth ingest endpoint for external pipelines** (n8n + Health
|
||||
Connect, requested in #89) ships in 1.4.0 alongside the API-token
|
||||
flow.
|
||||
|
||||
## [1.2.1] — 2026-04-27
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -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 (26 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
|
||||
@@ -68,6 +68,8 @@ docker compose logs -f app # Tail app logs
|
||||
- `src/components/measurements/` — measurement form, list
|
||||
- `src/components/mood/` — mood form, mood list
|
||||
- `src/components/charts/` — Recharts wrappers
|
||||
- `src/components/settings/` — per-route Settings section components (one per `/settings/[section]`)
|
||||
- `src/components/admin/` — per-route Admin section components (system-status, integrations, monitoring, users, audit, danger-zone, feedback)
|
||||
- `src/lib/logging/` — Wide Events structured logging (types, config, event-builder, context, sampler, transports, background)
|
||||
- `src/lib/api-handler.ts` — apiHandler wrapper, requireAuth(), requireAdmin(), HttpError
|
||||
- `src/lib/` — server utilities (db, crypto, auth, analytics, export, rate-limit, gravatar)
|
||||
@@ -81,12 +83,14 @@ 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 (26 models)
|
||||
- `tests/integration/` — Postgres testcontainers integration suite (rate-limit race, idempotency replay, GDPR cascade delete, session lifecycle); `pnpm test:integration`
|
||||
- `e2e/` — Playwright + axe-core suite for public smoke checks (version, auth-redirect, login, locale-switch, a11y); `pnpm e2e`
|
||||
- `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 +111,14 @@ 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`
|
||||
- **Multi-tenant prep (v1.4)**: `HEALTHLOG_PROCESS_TYPE=web|worker|all` (default `all`) splits HTTP and pg-boss workloads; the proxy refuses HTTP traffic with 503 + `X-HealthLog-Process-Type: worker` in worker mode. `ENCRYPTION_KEYS` is a JSON map of versioned keys (`{"v1": "...", "v2": "..."}`) plus `ENCRYPTION_ACTIVE_KEY_ID` for new writes; rotation via `pnpm tsx scripts/rotate-encryption-key.ts <id>`. `BACKUP_S3_*` env block configures off-host weekly encrypted backups (PutObject + GetObject only — retention is the bucket's lifecycle policy, never the worker).
|
||||
- **Native API clients (v1.4)**: `POST /api/auth/login` and `/api/auth/passkey/login-verify` issue a 24h access token (`hlk_<64hex>`) AND a refresh token (`hlr_<64hex>`) when `X-Client-Type: native` or the User-Agent starts with `HealthLog-iOS/`. The browser flow is unchanged. Refresh-token reuse-detection revokes every refresh token for the user. The idempotency replay-cache rejects bodies containing `hlk_` OR `hlr_` so cached responses can never echo a token back.
|
||||
|
||||
## 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
|
||||
|
||||
+6
-1
@@ -23,12 +23,17 @@ RUN pnpm build
|
||||
|
||||
# ── Stage 3: Production runner ─────────────────────────────
|
||||
FROM node:22-alpine AS runner
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
# `tzdata` is required so Europe/Berlin schedules (pg-boss cron, locale-aware
|
||||
# timestamp formatting) resolve to the actual offset instead of silently
|
||||
# falling back to UTC on Alpine images that ship without it.
|
||||
RUN apk add --no-cache tzdata && \
|
||||
corepack enable && corepack prepare pnpm@latest --activate
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV NODE_PATH="/opt/pg-boss/node_modules"
|
||||
ENV TZ=Europe/Berlin
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nextjs
|
||||
|
||||
@@ -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, and blood glucose (fasting/postprandial/random/bedtime, mg/dL ↔ mmol/L) with interactive trend charts, moving averages, and traffic-light ranges based on ESC/ESH 2018 and ADA 2024 guidelines.
|
||||
**Health Metrics** -- Track weight, blood pressure, pulse, body fat, sleep, steps, blood glucose (fasting/postprandial/random/bedtime, mg/dL ↔ mmol/L), total body water, bone mass, and pulse oximetry (SpO₂) with interactive trend charts, moving averages, and traffic-light ranges based on ESC/ESH 2018, ADA 2024, and consensus pulse-oximeter guidance (NICE NG115). Body-composition + SpO₂ metrics sync automatically from Withings Body+ scales and ScanWatch devices.
|
||||
|
||||
**Custom Thresholds** -- Override the computed default ranges per metric with the targets your clinician set. Audit-logged. Doctor Report PDF prints both your target and the standard reference.
|
||||
|
||||
@@ -59,9 +59,13 @@ 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.
|
||||
|
||||
**Multi-tenant ready** _(v1.4)_ — Off-host AES-GCM-encrypted weekly backups to any S3-compatible bucket (R2, B2, MinIO, AWS), encryption-key versioning + zero-downtime rotation CLI, optional `HEALTHLOG_PROCESS_TYPE=web|worker|all` so HTTP and pg-boss can scale independently, and short-lived 24h access tokens with refresh-token rotation for native API clients. The browser cookie session is unchanged.
|
||||
|
||||
**Test connection buttons** _(v1.4)_ — One-click probes for Withings, moodLog.app, Web Push, Glitchtip, and Umami in addition to the existing AI / Telegram / ntfy tests. Each one rate-limited, sanitised against SSRF redirects, and surfaces a localisable `errorCode` so the UI can render the failure in the user's language.
|
||||
|
||||
---
|
||||
|
||||
@@ -102,7 +106,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 (26 models) |
|
||||
| Job Queue | pg-boss 12 (reminders, insights, backups) |
|
||||
| UI | shadcn/ui, Tailwind CSS 4, Radix UI, Lucide Icons |
|
||||
| Charts | Recharts 3 |
|
||||
@@ -163,30 +167,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 +220,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 +279,101 @@ 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 |
|
||||
| `GET` | `/api/admin/status-overview` | Aggregated status for the 6-card grid |
|
||||
| `POST` | `/api/admin/backup/test` | Probe S3-compatible backup target |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Public + v1.4 additions</strong></summary>
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| ------ | ---------------------------------- | ----------------------------------------------- |
|
||||
| `GET` | `/api/version` | Public — version + build SHA + license, no auth |
|
||||
| `POST` | `/api/integrations/withings/test` | Probe a saved Withings connection |
|
||||
| `POST` | `/api/integrations/moodlog/test` | Probe moodLog.app webhook reachability |
|
||||
| `POST` | `/api/notifications/web-push/test` | Send a test Web Push to the current user |
|
||||
| `POST` | `/api/monitoring/glitchtip/test` | Trigger a Glitchtip ingest probe |
|
||||
| `POST` | `/api/monitoring/umami/test` | Verify Umami script + website ID resolve |
|
||||
| `POST` | `/api/auth/refresh` | Native client refresh-token rotation |
|
||||
| `POST` | `/api/auth/refresh/revoke` | Revoke an issued refresh token |
|
||||
|
||||
</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.
|
||||
+68
-15
@@ -1,37 +1,90 @@
|
||||
services:
|
||||
# Single-container default (HEALTHLOG_PROCESS_TYPE=all). Existing
|
||||
# self-host deployments stay working with `docker compose up`.
|
||||
# For horizontal scaling switch to the web/worker split below
|
||||
# (HEALTHLOG_PROCESS_TYPE=web on this service + uncomment app-worker).
|
||||
app:
|
||||
# Pre-built multi-arch images are published to GHCR by
|
||||
# .github/workflows/docker-publish.yml on every push to main and on
|
||||
# tagged releases. Pin to a specific version in production:
|
||||
# e.g. ghcr.io/mbombeck/healthlog:1.2.0
|
||||
image: ghcr.io/mbombeck/healthlog:latest
|
||||
# Fallback build context: if the image is not yet in the registry,
|
||||
# or you want to test local changes, run `docker compose up --build`.
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
# Pinned to GHCR :1.3.3 multi-arch image (gestern released, audit-tested).
|
||||
# Production on apps-01 has been 503-ing since today's deploys; rolling back
|
||||
# to the last release that's verifiably running on self-host installs to
|
||||
# rule out runtime / Docker-network issues. No `build:` block on purpose:
|
||||
# we want Coolify to PULL the prebuilt image, not build from source.
|
||||
image: ghcr.io/mbombeck/healthlog:1.4.0
|
||||
container_name: healthlog-app
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
DATABASE_URL: "postgresql://healthlog:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}@db:5432/healthlog?schema=public"
|
||||
DATABASE_URL: "postgresql://healthlog:${POSTGRES_PASSWORD}@db:5432/healthlog?schema=public"
|
||||
NODE_ENV: production
|
||||
SESSION_SECRET: "${SESSION_SECRET:?SESSION_SECRET is required}"
|
||||
ENCRYPTION_KEY: "${ENCRYPTION_KEY:?ENCRYPTION_KEY is required}"
|
||||
API_TOKEN_HMAC_KEY: "${API_TOKEN_HMAC_KEY:?API_TOKEN_HMAC_KEY is required}"
|
||||
SESSION_SECRET: "${SESSION_SECRET}"
|
||||
ENCRYPTION_KEY: "${ENCRYPTION_KEY}"
|
||||
ENCRYPTION_KEYS: "${ENCRYPTION_KEYS:-}"
|
||||
ENCRYPTION_ACTIVE_KEY_ID: "${ENCRYPTION_ACTIVE_KEY_ID:-}"
|
||||
API_TOKEN_HMAC_KEY: "${API_TOKEN_HMAC_KEY}"
|
||||
NEXT_PUBLIC_APP_URL: "${NEXT_PUBLIC_APP_URL:-http://localhost:3000}"
|
||||
APP_URL: "${APP_URL:-http://localhost:3000}"
|
||||
# all = bundle web + worker in this container (default, single-host)
|
||||
# web = serve HTTP only; pair with a separate app-worker service
|
||||
HEALTHLOG_PROCESS_TYPE: "${HEALTHLOG_PROCESS_TYPE:-all}"
|
||||
BACKUP_ENCRYPTION_KEY: "${BACKUP_ENCRYPTION_KEY:-}"
|
||||
BACKUP_S3_ENDPOINT: "${BACKUP_S3_ENDPOINT:-}"
|
||||
BACKUP_S3_BUCKET: "${BACKUP_S3_BUCKET:-}"
|
||||
BACKUP_S3_ACCESS_KEY: "${BACKUP_S3_ACCESS_KEY:-}"
|
||||
BACKUP_S3_SECRET_KEY: "${BACKUP_S3_SECRET_KEY:-}"
|
||||
BACKUP_S3_REGION: "${BACKUP_S3_REGION:-auto}"
|
||||
BACKUP_RETENTION_DAYS: "${BACKUP_RETENTION_DAYS:-30}"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/api/version || exit 1",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
# Optional dedicated worker container — uncomment + set
|
||||
# HEALTHLOG_PROCESS_TYPE=web on `app` to split the deployment.
|
||||
# See docs/self-hosting/scaling.md for the full guide.
|
||||
#
|
||||
# app-worker:
|
||||
# image: ghcr.io/mbombeck/healthlog:latest
|
||||
# build:
|
||||
# context: .
|
||||
# dockerfile: Dockerfile
|
||||
# container_name: healthlog-worker
|
||||
# environment:
|
||||
# DATABASE_URL: "postgresql://healthlog:${POSTGRES_PASSWORD}@db:5432/healthlog?schema=public"
|
||||
# NODE_ENV: production
|
||||
# SESSION_SECRET: "${SESSION_SECRET}"
|
||||
# ENCRYPTION_KEY: "${ENCRYPTION_KEY}"
|
||||
# ENCRYPTION_KEYS: "${ENCRYPTION_KEYS:-}"
|
||||
# ENCRYPTION_ACTIVE_KEY_ID: "${ENCRYPTION_ACTIVE_KEY_ID:-}"
|
||||
# API_TOKEN_HMAC_KEY: "${API_TOKEN_HMAC_KEY}"
|
||||
# APP_URL: "${APP_URL:-http://localhost:3000}"
|
||||
# HEALTHLOG_PROCESS_TYPE: "worker"
|
||||
# BACKUP_ENCRYPTION_KEY: "${BACKUP_ENCRYPTION_KEY:-}"
|
||||
# BACKUP_S3_ENDPOINT: "${BACKUP_S3_ENDPOINT:-}"
|
||||
# BACKUP_S3_BUCKET: "${BACKUP_S3_BUCKET:-}"
|
||||
# BACKUP_S3_ACCESS_KEY: "${BACKUP_S3_ACCESS_KEY:-}"
|
||||
# BACKUP_S3_SECRET_KEY: "${BACKUP_S3_SECRET_KEY:-}"
|
||||
# BACKUP_S3_REGION: "${BACKUP_S3_REGION:-auto}"
|
||||
# depends_on:
|
||||
# db:
|
||||
# condition: service_healthy
|
||||
# restart: unless-stopped
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: healthlog-db
|
||||
environment:
|
||||
POSTGRES_USER: healthlog
|
||||
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}"
|
||||
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"
|
||||
POSTGRES_DB: healthlog
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
|
||||
+21
-1
@@ -1,6 +1,26 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# ── Validate required env vars ───────────────────────────────
|
||||
# We do this in the entrypoint instead of in docker-compose.yml's
|
||||
# environment block (which used to use the ${VAR:?error} syntax)
|
||||
# because some hosting platforms parse compose files eagerly and
|
||||
# end up storing the fallback error string as the literal value.
|
||||
# Validating here guarantees a clear, deploy-platform-agnostic
|
||||
# failure message before we touch the database.
|
||||
missing=""
|
||||
for var in DATABASE_URL SESSION_SECRET ENCRYPTION_KEY API_TOKEN_HMAC_KEY; do
|
||||
eval "value=\$$var"
|
||||
if [ -z "$value" ]; then
|
||||
missing="$missing $var"
|
||||
fi
|
||||
done
|
||||
if [ -n "$missing" ]; then
|
||||
echo "HealthLog: ERROR — required env vars not set:$missing" >&2
|
||||
echo "HealthLog: see .env.example for the full list and how to generate each value." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Wait for PostgreSQL ──────────────────────────────────────
|
||||
MAX_RETRIES=30
|
||||
RETRY=0
|
||||
@@ -15,7 +35,7 @@ until node -e "
|
||||
" 2>/dev/null; do
|
||||
RETRY=$((RETRY + 1))
|
||||
if [ "$RETRY" -ge "$MAX_RETRIES" ]; then
|
||||
echo "HealthLog: ERROR — Database not reachable after ${MAX_RETRIES}s, aborting."
|
||||
echo "HealthLog: ERROR — Database not reachable after ${MAX_RETRIES}s, aborting." >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
|
||||
@@ -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`).
|
||||
@@ -0,0 +1,232 @@
|
||||
# Migrating from HealthLog 1.3 to 1.4
|
||||
|
||||
v1.4.0 is **drop-in for self-hosters running the GHCR image** — the
|
||||
two new migrations (`0025_refresh_tokens`, `0025_user_locale_drift_fix`)
|
||||
both apply on container start via `prisma migrate deploy`, and every
|
||||
new env var in §Environment variables below has a safe default. This
|
||||
guide documents what changes for operators and integrators, in case
|
||||
you're extending the API or scaling the deployment.
|
||||
|
||||
## TL;DR
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
That's it. The migrations are forward-compatible (`IF NOT EXISTS`
|
||||
guards), no env vars are _required_ to be added, and no API contract
|
||||
changes shape or status code.
|
||||
|
||||
## Database
|
||||
|
||||
- **`0025_refresh_tokens`** _(new)_ — adds the `refresh_tokens` table
|
||||
for the native-client 24h access-token / refresh-token rotation
|
||||
flow. Browser cookie sessions don't use it.
|
||||
- **`0025_user_locale_drift_fix`** _(new)_ — `ADD COLUMN IF NOT
|
||||
EXISTS users.locale TEXT` to backfill an environment that was kept
|
||||
in sync via `prisma db push` rather than `migrate deploy`. No-op
|
||||
on installations that were already current.
|
||||
- Both migrations are idempotent. A rollback to 1.3.3 keeps the
|
||||
schema usable; the dropped tables/columns are simply unused.
|
||||
|
||||
## Environment variables
|
||||
|
||||
### About surface
|
||||
|
||||
- **`NEXT_PUBLIC_APP_BUILD_SHA`** _(optional, new)_ — short Git SHA
|
||||
exposed through `/api/version`, rendered on Settings → About.
|
||||
- **`NEXT_PUBLIC_APP_BUILT_AT`** _(optional, new)_ — ISO-8601 build
|
||||
timestamp.
|
||||
|
||||
Both are optional; without them, the About surface renders
|
||||
"development" wording.
|
||||
|
||||
### Worker / web split
|
||||
|
||||
- **`HEALTHLOG_PROCESS_TYPE`** _(optional, default `all`)_ — set to
|
||||
`web` to disable the pg-boss worker (jobs queue but don't run), or
|
||||
`worker` to refuse all HTTP traffic with `503 + X-HealthLog-Process-Type:
|
||||
worker`. Default `all` keeps the single-container behaviour.
|
||||
Only relevant if you're scaling HTTP and the worker independently.
|
||||
|
||||
### Encryption-key versioning
|
||||
|
||||
- **`ENCRYPTION_KEYS`** _(optional, new)_ — JSON object mapping
|
||||
versioned key IDs to 64-char hex keys, e.g. `{"v1": "...", "v2":
|
||||
"..."}`. When present, ciphertexts are written with the
|
||||
`<keyId>.<base64>` format and decrypted by looking up the key ID.
|
||||
Existing 1.3-format ciphertexts (no key prefix) keep decrypting
|
||||
with `ENCRYPTION_KEY` indefinitely.
|
||||
- **`ENCRYPTION_ACTIVE_KEY_ID`** _(optional, new)_ — the key ID used
|
||||
for new writes. Must be a key in `ENCRYPTION_KEYS`. Defaults to
|
||||
the legacy single-key behaviour if either is unset.
|
||||
- **`ENCRYPTION_KEY`** _(unchanged)_ — still honoured as a fallback
|
||||
when `ENCRYPTION_KEYS` is empty. No action needed for existing
|
||||
installs.
|
||||
- Rotation: `pnpm tsx scripts/rotate-encryption-key.ts <newKeyId>`
|
||||
re-encrypts every ciphertext under the new key without downtime.
|
||||
Walk-through in `docs/ops/encryption-key-rotation.md`.
|
||||
|
||||
### Off-host backup target _(optional)_
|
||||
|
||||
If unset, the existing local `DataBackup` storage continues to work.
|
||||
|
||||
- **`BACKUP_S3_ENDPOINT`** — S3-compatible endpoint URL. Leave blank
|
||||
to disable off-host backup.
|
||||
- **`BACKUP_S3_BUCKET`** — destination bucket name.
|
||||
- **`BACKUP_S3_ACCESS_KEY`**, **`BACKUP_S3_SECRET_KEY`** — IAM
|
||||
credentials. Worker only requests `PutObject` + `GetObject` —
|
||||
never `DeleteObject`. Retention is the bucket's lifecycle policy.
|
||||
- **`BACKUP_S3_REGION`** _(default `auto`)_.
|
||||
- **`BACKUP_RETENTION_DAYS`** _(default `30`)_ — surfaced in the
|
||||
admin UI; the lifecycle rule on your bucket is the actual enforcer.
|
||||
- **`BACKUP_ENCRYPTION_KEY`** — separate 64-char hex key for the
|
||||
AES-GCM envelope of each upload. **Keep offline / in a vault.**
|
||||
Lose this and the off-host backups are unreadable.
|
||||
|
||||
### API token hashing _(unchanged but worth re-confirming)_
|
||||
|
||||
- **`API_TOKEN_HMAC_KEY`** — 64-char hex string. Used to HMAC
|
||||
Bearer tokens before storage so a DB leak can't be reversed via
|
||||
rainbow table. Same key now also keys the new `RefreshToken` row
|
||||
hashes. **Do not rotate without invalidating every native client.**
|
||||
|
||||
## API
|
||||
|
||||
### New
|
||||
|
||||
- **`GET /api/version`** — public, static, returns
|
||||
`{ version, buildSha, builtAt, license, repository, changelog, docs }`.
|
||||
No authentication.
|
||||
- **`POST /api/integrations/withings/test`** — admin-only probe
|
||||
against the saved Withings OAuth connection. Returns `{ ok, latency_ms }`
|
||||
on success.
|
||||
- **`POST /api/integrations/moodlog/test`** — admin-only HEAD
|
||||
request against `https://moodlog.app/api/health`. SSRF-guarded.
|
||||
- **`POST /api/notifications/web-push/test`** — sends a smoke push
|
||||
notification to the current user's subscribed devices.
|
||||
- **`POST /api/monitoring/glitchtip/test`** — fires a benign event
|
||||
at the configured DSN.
|
||||
- **`POST /api/monitoring/umami/test`** — verifies the script URL +
|
||||
website ID resolve and respond with the expected payload.
|
||||
- **`POST /api/admin/backup/test`** — admin-only round-trip against
|
||||
the configured `BACKUP_S3_*` credentials.
|
||||
- **`GET /api/admin/status-overview`** — admin-only aggregator
|
||||
returning the six-card summary the new admin top strip renders in
|
||||
one batched query.
|
||||
- **`POST /api/auth/refresh`** — native-client refresh-token
|
||||
rotation. Cookie-session browsers don't use this.
|
||||
- **`POST /api/auth/refresh/revoke`** — paired logout-on-device that
|
||||
flips both the refresh row and the matching `ApiToken` row to
|
||||
revoked.
|
||||
|
||||
All new admin endpoints are hidden from anonymous traffic by the
|
||||
proxy and rate-limited.
|
||||
|
||||
### Behaviour-only changes
|
||||
|
||||
- **`POST /api/ai/test`** now accepts an optional JSON body that
|
||||
mirrors the AI dropdown in `/settings`. Empty body keeps the
|
||||
pre-1.4 behaviour (test the saved provider). Non-empty body tests
|
||||
the unsaved selection without persisting plaintext keys. The
|
||||
existing rate limit (5/min) and error-leak shielding stay in place.
|
||||
- **`GET /api/dashboard/summary`** localises the `streak.label` and
|
||||
`greeting.salutation` fields against the user's stored locale.
|
||||
- **`POST /api/insights/targets`** uses a single batched query for
|
||||
per-type latest readings; returns identical payloads.
|
||||
|
||||
No `/api/...` route changed shape, status codes, or auth rules. iOS,
|
||||
n8n, and Health Connect integrations need no change.
|
||||
|
||||
## UI / UX
|
||||
|
||||
### Dashboard
|
||||
|
||||
- **Tile strip is always one row.** Previously the four-to-five-tile
|
||||
grid wrapped to a second row when many widgets were enabled. v1.4
|
||||
uses a horizontal-scroll strip — total width caps at the chart
|
||||
width below, snap points keep the touch experience deliberate. To
|
||||
reduce the visible tile count, open Settings → Dashboard Layout
|
||||
and toggle widgets off.
|
||||
|
||||
### Forms
|
||||
|
||||
- **Password managers no longer autofill health-data inputs.**
|
||||
Browsers and LastPass / 1Password were happily filling the user's
|
||||
saved account password into measurement notes, AI tokens, and
|
||||
similar free-text fields. The `<Input>` primitive now defaults to
|
||||
`autoComplete="off"` plus the LastPass / 1Password ignore
|
||||
attributes whenever the caller doesn't pass a semantic value. Auth
|
||||
and profile forms (login, register, change-email) keep autofilling
|
||||
normally because they pass an explicit `autoComplete` (e.g.
|
||||
`"username"`, `"email"`, `"new-password"`).
|
||||
|
||||
### Settings
|
||||
|
||||
- **AI provider connection-test honours the unsaved selection.** Pick
|
||||
a provider in the dropdown and click "Verbindung testen" without
|
||||
saving first — the test now runs against your in-form selection
|
||||
instead of the saved provider.
|
||||
|
||||
### Notifications
|
||||
|
||||
- **Telegram, ntfy, and Web Push reminders follow your locale.**
|
||||
Switching the user's locale to English now produces English
|
||||
reminders; the previous build always sent German strings regardless.
|
||||
Telegram callback IDs stay stable (English) so the bot dispatcher
|
||||
keeps matching them.
|
||||
|
||||
## Medical defaults
|
||||
|
||||
- **Diastolic blood-pressure orange band no longer reaches 60 mmHg.**
|
||||
A reading of 60 mmHg now lands in red instead of yellow. The
|
||||
user-override path is intact — if you set a personal threshold via
|
||||
`/settings#thresholds`, it still applies and is audit-logged.
|
||||
- **Body-fat classifier mirrors the ACE table** — readings below the
|
||||
ACE essential floor (M < 2 / F < 10) now classify as
|
||||
"Below essential" (danger), the actual essential band (M 2–5 /
|
||||
F 10–13) as warning, and the realistic healthy range
|
||||
(M 14–24 / F 21–31) as the green band on the dashboard target tile.
|
||||
- **Step target unified at 8 000–15 000.** The two surfaces that
|
||||
previously disagreed (7 000–10 000 in `getStepsRange()` versus
|
||||
8 000–15 000 in the effective-range resolver) now use the same
|
||||
band, anchored on Saint-Maurice et al., JAMA 2020.
|
||||
|
||||
## URL changes
|
||||
|
||||
- **Settings has moved to per-section routes.** `/settings#integrations`
|
||||
→ `/settings/integrations`, etc. The legacy `/settings#anchor` URLs
|
||||
still 308-redirect to the new routes; sidebar deep-links and the
|
||||
`<a href="/settings#thresholds">` patterns from 1.3 keep working.
|
||||
|
||||
## What landed in v1.4 vs v1.4.1
|
||||
|
||||
The v1.3.3 ecosystem audit identified architectural rewrites tracked
|
||||
through v1.4. Status as of the v1.4.1 cycle:
|
||||
|
||||
| Bucket | Status |
|
||||
| -------------------------------------------------------------------------------------- | ------------------- |
|
||||
| Settings page split into `/settings/[section]` | ✓ shipped in v1.4.0 |
|
||||
| Admin status-first card grid + aggregator endpoint | ✓ shipped in v1.4.0 |
|
||||
| AI insights rework — severity hero + allowlisted inline charts | ✓ shipped in v1.4.0 |
|
||||
| Multi-tenant prep (off-host backups, key versioning, worker/web split, refresh tokens) | ✓ shipped in v1.4.0 |
|
||||
| Per-section admin component extraction (inner monolith → one file per panel) | ✓ shipped in v1.4.1 |
|
||||
| Postgres-backed integration test suite (testcontainers) | ✓ shipped in v1.4.1 |
|
||||
| Playwright + axe-core E2E foundation | ✓ shipped in v1.4.1 |
|
||||
|
||||
## Rollback
|
||||
|
||||
If 1.4.0 surfaces a regression on your install, redeploy the previous
|
||||
1.3.3 image:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
docker pull ghcr.io/mbombeck/healthlog:1.3.3
|
||||
sed -i 's/healthlog:1.4.0/healthlog:1.3.3/' docker-compose.yml
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The schema is unchanged so a rollback does not require a database
|
||||
restore. File a GitHub issue with the symptom — the v1.4 cycle ran
|
||||
multi-agent QA per phase, but no migration is risk-free.
|
||||
@@ -0,0 +1,87 @@
|
||||
# Off-host backup & restore (v1.4 G1)
|
||||
|
||||
HealthLog ships with an optional daily off-host backup that ships every
|
||||
user's JSON dump, encrypted with AES-256-GCM under a SEPARATE key
|
||||
(`BACKUP_ENCRYPTION_KEY`), to any S3-compatible bucket — Cloudflare R2,
|
||||
AWS S3, Backblaze B2, MinIO, etc.
|
||||
|
||||
The backup runs at **02:30 Europe/Berlin** every day from the worker
|
||||
container (queue `data-backup-offhost`). Object key layout:
|
||||
|
||||
```
|
||||
<bucket>/YYYY-MM-DD/user-<userId>.json.enc
|
||||
```
|
||||
|
||||
## Wire format (binary)
|
||||
|
||||
```
|
||||
magic = "HLBK" (4 bytes, ASCII)
|
||||
version = 0x01 (1 byte)
|
||||
iv = 12 random bytes (AES-GCM nonce)
|
||||
authTag = 16 bytes (AES-GCM tag)
|
||||
ciphertext = N bytes (AES-256-GCM, key = BACKUP_ENCRYPTION_KEY)
|
||||
plaintext = JSON dump (UTF-8)
|
||||
```
|
||||
|
||||
## Required env vars
|
||||
|
||||
| Var | Required | Notes |
|
||||
| ----------------------- | -------- | -------------------------------------------------------------------- |
|
||||
| `BACKUP_ENCRYPTION_KEY` | yes | 64 hex chars or 32-byte base64. **Different from `ENCRYPTION_KEY`.** |
|
||||
| `BACKUP_S3_ENDPOINT` | yes | e.g. `https://<account>.r2.cloudflarestorage.com` |
|
||||
| `BACKUP_S3_BUCKET` | yes | |
|
||||
| `BACKUP_S3_ACCESS_KEY` | yes | |
|
||||
| `BACKUP_S3_SECRET_KEY` | yes | |
|
||||
| `BACKUP_S3_REGION` | no | defaults to `auto` (Cloudflare R2) |
|
||||
| `BACKUP_RETENTION_DAYS` | no | defaults to `30` |
|
||||
|
||||
## Bucket lifecycle (recommended)
|
||||
|
||||
The worker prunes objects older than `BACKUP_RETENTION_DAYS`, but the
|
||||
storage provider's lifecycle rule is the canonical safety net:
|
||||
|
||||
```
|
||||
Filter: "" (all objects)
|
||||
Action: Expire after 30 days
|
||||
```
|
||||
|
||||
For Cloudflare R2 add this from the bucket's **Settings → Lifecycle**.
|
||||
|
||||
## Smoke test
|
||||
|
||||
After deploying, hit `POST /api/admin/backup/test` (admin-only). It
|
||||
performs a 1-byte PUT + GET round-trip and returns:
|
||||
|
||||
```json
|
||||
{
|
||||
"endpoint": "https://...r2.cloudflarestorage.com",
|
||||
"bucket": "healthlog-backups",
|
||||
"region": "auto",
|
||||
"putLatencyMs": 142,
|
||||
"getLatencyMs": 38,
|
||||
"ok": true
|
||||
}
|
||||
```
|
||||
|
||||
The credentials are never returned.
|
||||
|
||||
## Restore
|
||||
|
||||
Pick a key (e.g. `2026-05-08/user-clx123.json.enc`) from the bucket
|
||||
and run:
|
||||
|
||||
```bash
|
||||
BACKUP_S3_ENDPOINT=https://...r2.cloudflarestorage.com \
|
||||
BACKUP_S3_BUCKET=healthlog-backups \
|
||||
BACKUP_S3_ACCESS_KEY=... \
|
||||
BACKUP_S3_SECRET_KEY=... \
|
||||
BACKUP_S3_REGION=auto \
|
||||
BACKUP_ENCRYPTION_KEY=$(openssl rand -hex 32) \
|
||||
pnpm tsx scripts/restore-backup.ts \
|
||||
2026-05-08/user-clx123.json.enc \
|
||||
/tmp/restored.json
|
||||
```
|
||||
|
||||
The script downloads the object, decrypts it, and writes the JSON dump
|
||||
to disk. Importing the JSON back into a HealthLog instance is left to
|
||||
the operator (use `prisma db seed` or a custom script).
|
||||
@@ -0,0 +1,107 @@
|
||||
# Encryption-key rotation
|
||||
|
||||
HealthLog encrypts sensitive at-rest data (Withings tokens, AI provider keys,
|
||||
notification channel configs, web-push subscription secrets, VAPID private
|
||||
keys, etc.) with AES-256-GCM under a per-deployment key. v1.4 introduces a
|
||||
versioned key format so the key can be rotated without downtime and without
|
||||
re-encrypting every row by hand.
|
||||
|
||||
## Format
|
||||
|
||||
| Layout | Marker | Where |
|
||||
| --------------- | ----------------------------------------------- | --------------------------- |
|
||||
| Versioned (new) | `<keyId>.<base64(iv \|\| tag \|\| ciphertext)>` | All new writes |
|
||||
| Legacy (v1.3.x) | `<base64(iv \|\| tag \|\| ciphertext)>` | Existing rows until rotated |
|
||||
|
||||
`<keyId>` matches `[A-Za-z0-9_-]{1,32}` and indexes into the `ENCRYPTION_KEYS`
|
||||
JSON map. Decryption tries the versioned format first; if no `.` is present
|
||||
or the prefix isn't a known id, the row is treated as legacy and decrypted
|
||||
under `v1` (the synthetic id assigned to the existing `ENCRYPTION_KEY`).
|
||||
|
||||
> **Why a separate `v1` is required.** Legacy ciphertexts have no key id, so
|
||||
> the only way to identify them is "no `.` in the value". If you remove the
|
||||
> `v1` entry from `ENCRYPTION_KEYS` _before_ every legacy row has been
|
||||
> rotated, those rows can't be decrypted any more — the active key won't
|
||||
> match the original ciphertext. The decrypt path now refuses to silently
|
||||
> fall back to the active key in that scenario; it throws a clear error.
|
||||
|
||||
## Rotating from v1.3.x to a new key
|
||||
|
||||
1. **Generate the new key** on a machine with `openssl`:
|
||||
```
|
||||
openssl rand -hex 32
|
||||
```
|
||||
2. **Update environment variables.** Keep the existing `ENCRYPTION_KEY` in
|
||||
place — it's still needed to decrypt legacy rows during the transition:
|
||||
```
|
||||
ENCRYPTION_KEY="<old key, unchanged>"
|
||||
ENCRYPTION_KEYS='{"v1":"<old key>","v2":"<new key>"}'
|
||||
ENCRYPTION_ACTIVE_KEY_ID="v2"
|
||||
```
|
||||
Restart the app. New writes are now encrypted under `v2`; existing
|
||||
`v1`-keyed and legacy bare rows still decrypt because the `v1` entry is
|
||||
retained.
|
||||
3. **Run the rotation script** to re-encrypt every existing encrypted column
|
||||
under the new active key:
|
||||
```
|
||||
pnpm tsx scripts/rotate-encryption-key.ts v2
|
||||
```
|
||||
The script is idempotent — running it again is a no-op for rows already
|
||||
prefixed with `v2.`. It rotates User, WithingsConnection, AppSettings,
|
||||
NotificationChannel, and PushSubscription rows. The summary line at the
|
||||
end shows `scanned`, `rotated`, and `errors` per table+field; treat
|
||||
`errors > 0` as a hard failure and re-run after fixing the cause.
|
||||
4. **Drop the old key (optional, recommended).** Once the script has run
|
||||
cleanly:
|
||||
```
|
||||
ENCRYPTION_KEYS='{"v2":"<new key>"}'
|
||||
ENCRYPTION_ACTIVE_KEY_ID="v2"
|
||||
# ENCRYPTION_KEY can now be removed
|
||||
```
|
||||
Restart. The legacy single-key fallback is now disconnected; only `v2`
|
||||
exists.
|
||||
|
||||
## Adding a third key (v2 → v3)
|
||||
|
||||
Same procedure, just shift the labels — keep `v2` in the map until the
|
||||
script reports zero `v2.`-prefixed rows remaining.
|
||||
|
||||
```
|
||||
ENCRYPTION_KEYS='{"v2":"<old>","v3":"<new>"}'
|
||||
ENCRYPTION_ACTIVE_KEY_ID="v3"
|
||||
pnpm tsx scripts/rotate-encryption-key.ts v3
|
||||
ENCRYPTION_KEYS='{"v3":"<new>"}'
|
||||
```
|
||||
|
||||
## Rollback
|
||||
|
||||
> **Important.** Once the rotation script has run, ciphertexts in the
|
||||
> database start with `v2.` (or whatever the active id is). The pre-PR
|
||||
> v1.3.x image cannot read that prefix — it expects bare base64 — and
|
||||
> calling `decrypt()` on those rows will throw.
|
||||
|
||||
If you need to revert to the pre-rotation image:
|
||||
|
||||
- Either keep the new image. The new code reads both formats, so most
|
||||
rollback scenarios don't need to undo rotation.
|
||||
- Or, if you must run the old code, restore a database backup taken
|
||||
_before_ the rotation script ran. There is no script to convert
|
||||
`v2.`-prefixed rows back to legacy format — by design, rotation is a
|
||||
forward-only operation.
|
||||
|
||||
This is why we recommend running the rotation in a window where you have a
|
||||
fresh DB snapshot and the new image has been smoke-tested in production for
|
||||
at least 24 hours.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- `Encryption key id 'v1' is not configured` — the database still contains
|
||||
`v1.`-prefixed rows but `v1` was removed from `ENCRYPTION_KEYS`. Re-add
|
||||
the key, run the rotation script, then remove again.
|
||||
- `Found a legacy-format ciphertext but no v1 key is configured` — same
|
||||
cause for legacy bare-base64 rows. Restore `ENCRYPTION_KEY` (or add a
|
||||
`v1` entry to `ENCRYPTION_KEYS`) and run the rotation script before
|
||||
removing it.
|
||||
- `Refusing to rotate: argv key id ... does not match the currently active
|
||||
id ...` — pass the same id you set in `ENCRYPTION_ACTIVE_KEY_ID`. The
|
||||
guard prevents accidental re-encryption to a non-current key.
|
||||
@@ -0,0 +1,6 @@
|
||||
-- 0025_refresh_tokens — down migration (v1.4 G4 rollback).
|
||||
-- Forward-compatible: the up migration only ADDS a table, so this is a
|
||||
-- safe DROP. Any in-flight refresh tokens are invalidated; native clients
|
||||
-- need to re-authenticate.
|
||||
|
||||
DROP TABLE IF EXISTS "refresh_tokens";
|
||||
@@ -0,0 +1,260 @@
|
||||
# v1.4.x follow-up issues
|
||||
|
||||
Items identified by the v1.4.1 audit pass that warrant deeper work
|
||||
than a hardening release should land. Each one carries enough context
|
||||
that a future contributor can pick it up without re-running the audit.
|
||||
|
||||
## Security — deferred
|
||||
|
||||
### S-FOLLOW-1 — Idempotency-Key concurrent duplicate side-effects (HIGH)
|
||||
|
||||
Audit reference: `~/infra/reports/v14-security-audit.md` H1.
|
||||
|
||||
The `withIdempotency()` wrapper currently does
|
||||
`findCached → handler → persistCached`. Two requests with the same
|
||||
`Idempotency-Key` arriving in parallel both miss the SELECT, both
|
||||
execute the handler (duplicate measurements / intake events), and
|
||||
one INSERT loses on the unique constraint silently. The CLAUDE.md
|
||||
contract ("retries with the same Idempotency-Key … replay the
|
||||
original response — no second side-effect") is therefore violated
|
||||
whenever a flaky mobile client double-sends.
|
||||
|
||||
**Fix shape**: acquire the slot before the handler runs.
|
||||
`INSERT … ON CONFLICT DO NOTHING RETURNING id` reserving the
|
||||
`(userId, key, method, path)` row with a sentinel
|
||||
`responseStatus = 0`, then UPDATE on completion; concurrent losers
|
||||
SELECT-loop with backoff for the row to be filled. Or wrap the
|
||||
whole request in a Postgres advisory lock keyed by hash(userId, key)
|
||||
via `pg_advisory_xact_lock`.
|
||||
|
||||
Why deferred: needs a careful integration test against the new
|
||||
testcontainers suite (two concurrent inserts → exactly one handler
|
||||
invocation) and a public-API contract check that the
|
||||
sentinel-status row never bleeds into a 2xx replay. Fits a v1.4.2
|
||||
PR scope on its own.
|
||||
|
||||
---
|
||||
|
||||
### S-FOLLOW-2 — Encryption key fallback NODE_ENV gate (MEDIUM)
|
||||
|
||||
Audit reference: `~/infra/reports/v14-security-audit.md` M1.
|
||||
|
||||
`src/lib/crypto.ts:61-74` only requires the 64-hex production key
|
||||
when `process.env.NODE_ENV === "production"`. A fresh Docker run
|
||||
that forgets to set `NODE_ENV=production` (raw `node …` invocations,
|
||||
some operator setups) silently accepts a 32-character key and pads
|
||||
it deterministically with a SHA-256 of itself, halving the entropy
|
||||
and making the resulting key recoverable from any leaked partial.
|
||||
|
||||
**Fix shape**: invert the gate to `NODE_ENV !== "development" &&
|
||||
NODE_ENV !== "test"` — fail-closed by default — or require an
|
||||
explicit `ALLOW_WEAK_DEV_KEYS=1` opt-in.
|
||||
|
||||
Why deferred: needs a migration note for self-hosters whose `.env`
|
||||
files lack `NODE_ENV` (default Coolify deploys do set it; bespoke
|
||||
setups may not). Worth a release note + .env.example call-out.
|
||||
|
||||
---
|
||||
|
||||
### S-FOLLOW-3 — Refresh-token reuse-detection serialisation (MEDIUM)
|
||||
|
||||
Audit reference: `~/infra/reports/v14-security-audit.md` M2.
|
||||
|
||||
`src/lib/auth/refresh-token.ts` reuse-detection is a `findUnique`
|
||||
followed by a non-transactional `findMany + updateMany`. Concurrent
|
||||
first-time refreshes can briefly have both clients holding
|
||||
unrevoked access tokens before the loser's are revoked, and a
|
||||
parallel reuse attempt doubles the audit-log noise.
|
||||
|
||||
**Fix shape**: wrap the rotation in a single `prisma.$transaction`
|
||||
with `Serializable` isolation, OR reorder to "claim first, mint
|
||||
second": `prisma.refreshToken.update({ where: { id, usedAt: null } })`
|
||||
fails atomically if the row was already consumed; only after the
|
||||
update succeeds, call `issueAccessAndRefresh`.
|
||||
|
||||
Why deferred: needs an integration test that fires N parallel
|
||||
refreshes for the same token and asserts exactly one new pair is
|
||||
issued. The testcontainers suite is the right home; v1.4.2 PR.
|
||||
|
||||
---
|
||||
|
||||
### S-FOLLOW-4 — moodLog webhook HMAC lookup column (MEDIUM)
|
||||
|
||||
Audit reference: `~/infra/reports/v14-security-audit.md` M3.
|
||||
|
||||
`src/app/api/integrations/moodlog/webhook/route.ts` performs an
|
||||
O(n) AES-GCM decrypt sweep across all enabled users for every
|
||||
webhook hit. Side-channels: timing oracle for ordering, DoS
|
||||
amplification (30/min IP rate-limit × n decrypts).
|
||||
|
||||
**Fix shape**: add a `moodLogWebhookSecretLookupHash` column on
|
||||
`User` (HMAC-SHA-256 keyed by `API_TOKEN_HMAC_KEY`, populated
|
||||
alongside the encrypted secret on every write). Webhook does a
|
||||
single indexed `findUnique` on the lookup hash, bypassing the
|
||||
candidate iteration. The encryption stays as defence-in-depth.
|
||||
|
||||
Why deferred: schema migration required. Backfill needs a one-off
|
||||
script that decrypts every existing row and writes the lookup
|
||||
hash. Owners need to approve the migration window. v1.4.2 or
|
||||
v1.5.
|
||||
|
||||
---
|
||||
|
||||
### S-FOLLOW-5 — moodLog `readMoodLogSecret` legacy fallback (MEDIUM)
|
||||
|
||||
Audit reference: `~/infra/reports/v14-security-audit.md` M4.
|
||||
|
||||
If `decrypt()` throws inside `src/lib/moodlog-secret.ts:27-37`, the
|
||||
function returns the stored value as-is. Intent: legacy plaintext
|
||||
rows keep working. Side effect: an attacker with read access to
|
||||
the DB obtains the ciphertext blob and can submit it as the literal
|
||||
webhook secret.
|
||||
|
||||
**Fix shape**: detect "looks like a v1.4 envelope"
|
||||
(`/^[A-Za-z0-9_-]{1,32}\..+$/`) and refuse the legacy fallback for
|
||||
envelope-shaped values. Log a metric so the rotation-on-write
|
||||
contract can be verified to drain.
|
||||
|
||||
Why deferred: needs a small data-audit run to confirm no production
|
||||
row has a non-envelope value before the strict check goes live.
|
||||
|
||||
---
|
||||
|
||||
### S-FOLLOW-6 — Restore script writes decrypted JSON with default permissions (MEDIUM)
|
||||
|
||||
Audit reference: `~/infra/reports/v14-security-audit.md` M5.
|
||||
|
||||
`scripts/restore-backup.ts:73-122` writes the decrypted JSON via
|
||||
`writeFileSync(out, plaintext, "utf8")` with default mode (0644
|
||||
typical). The file contains decrypted PHI for the restored user.
|
||||
|
||||
**Fix shape**:
|
||||
`writeFileSync(out, plaintext, { encoding: "utf8", mode: 0o600 })`.
|
||||
Optional: warn if `out` is in a world-readable directory.
|
||||
|
||||
Why deferred: tiny one-line fix; bundling with the next ops-script
|
||||
update keeps the PR coherent.
|
||||
|
||||
---
|
||||
|
||||
## Performance — deferred
|
||||
|
||||
### P-FOLLOW-1 — Recharts top-level import on `/insights` (P0)
|
||||
|
||||
Audit reference: `~/infra/reports/v14-performance-audit.md` P0.
|
||||
|
||||
`src/app/insights/page.tsx:42-50` static-imports
|
||||
`{ ScatterChart, Scatter, XAxis, … } from "recharts"` even though
|
||||
`HealthChart` and `MoodChart` 8 lines above are already wrapped in
|
||||
`dynamic(…, { ssr: false })`. The static import pulls the whole
|
||||
recharts bundle into the route's first JS payload, defeating the
|
||||
dynamic split.
|
||||
|
||||
**Fix shape**: extract the BP-mood scatter into
|
||||
`src/components/charts/bp-mood-scatter.tsx` and import it via
|
||||
`dynamic(…, { ssr: false })` like the others.
|
||||
|
||||
Why deferred: trivial mechanical change but needs a Lighthouse
|
||||
before/after to quantify the bundle-size win for the v1.4.2
|
||||
release notes.
|
||||
|
||||
---
|
||||
|
||||
### P-FOLLOW-2 — `/api/insights/targets` unbounded glucose history scan (P0)
|
||||
|
||||
Audit reference: `~/infra/reports/v14-performance-audit.md` P0.
|
||||
|
||||
`src/app/api/insights/targets/route.ts:600-604` pulls every glucose
|
||||
measurement the user ever logged (no `gte: thirtyDaysAgo`) and
|
||||
filters in JS. Diabetic users with multi-year history will
|
||||
materialise tens of thousands of rows on every targets fetch.
|
||||
|
||||
**Fix shape**: split into "latest per context"
|
||||
(`distinct: ["glucoseContext"], orderBy: { measuredAt: "desc" }`)
|
||||
and a `measuredAt: { gte: thirtyDaysAgo }` window query, mirroring
|
||||
the pattern at lines 102-121 for `latestEverByType`.
|
||||
|
||||
Why deferred: needs a per-context test asserting the new query
|
||||
returns the same payload shape. Pairs nicely with the MoodEntry
|
||||
index migration (P-FOLLOW-3).
|
||||
|
||||
---
|
||||
|
||||
### P-FOLLOW-3 — `MoodEntry @@index` is on `(userId, date)` not `(userId, moodLoggedAt)` (P0)
|
||||
|
||||
Audit reference: `~/infra/reports/v14-performance-audit.md` P0.
|
||||
|
||||
`prisma/schema.prisma:438` declares `@@index([userId, date])`, but
|
||||
active queries sort/filter by `moodLoggedAt`:
|
||||
|
||||
- `src/app/api/insights/targets/route.ts:495`
|
||||
- `src/app/api/insights/comprehensive/route.ts:52`
|
||||
- `src/app/api/export/route.ts:87`
|
||||
|
||||
The unique constraint `@@unique([userId, date, moodLoggedAt])` is
|
||||
not selectable by Postgres for windowed `moodLoggedAt`-range reads.
|
||||
|
||||
**Fix shape**: add `@@index([userId, moodLoggedAt])` (do NOT drop
|
||||
the existing index — the unique still serves the moodlog webhook
|
||||
upsert path). New migration `0026_moodentry_moodloggedat_index`.
|
||||
|
||||
Why deferred: schema migration. Owner approval for the migration
|
||||
window.
|
||||
|
||||
---
|
||||
|
||||
### P-FOLLOW-4 to -7 — P1 / P2 perf items
|
||||
|
||||
- N+1 count() per medication on admin reminder-check
|
||||
(`src/app/api/admin/notifications/reminder-check/route.ts:84`).
|
||||
Replace with `groupBy` on `medicationIntakeEvent`.
|
||||
- `/api/analytics` 8-parallel full-history scan
|
||||
(`src/app/api/analytics/route.ts:20-40`). Single `findMany` with
|
||||
`type: { in: types }` and JS-side group.
|
||||
- `/api/import` sequential `prisma.measurement.create` per row
|
||||
(`src/app/api/import/route.ts:77-96`). Replace with
|
||||
`createMany({ skipDuplicates: true })`.
|
||||
- `pairByTimestamp` not used in `/api/analytics` BP correlation
|
||||
(`src/app/api/analytics/route.ts:80-86`). Replace the quadratic
|
||||
loop with the helper from `@/lib/analytics/correlations`.
|
||||
|
||||
---
|
||||
|
||||
## Test-quality polish (5 minor items)
|
||||
|
||||
Audit reference: `~/infra/reports/v14-test-theatre-audit.md`.
|
||||
|
||||
- `src/app/api/gamification/__tests__/ios-format.test.ts` L111-112:
|
||||
upgrade `toBeDefined()` to shape predicates.
|
||||
- `src/lib/analytics/__tests__/classifications.test.ts` L169-170:
|
||||
add `toMatch(/60/)` body-content assertion to compliance-alert tests.
|
||||
- `src/lib/__tests__/idempotency.test.ts` L145: upgrade bare
|
||||
`toHaveBeenCalled()` to `toHaveBeenCalledWith({ where: …key… })`.
|
||||
- `src/app/api/telegram/webhook/__tests__/route.test.ts` L457: same
|
||||
shape — upgrade or drop.
|
||||
|
||||
---
|
||||
|
||||
## i18n hardcoded strings
|
||||
|
||||
Audit reference: `~/infra/reports/v14-i18n-drift-audit.md`.
|
||||
|
||||
35+ hardcoded strings in `src/components/settings/ai-section.tsx`
|
||||
need to be moved into `messages/{en,de}.json` keys. The settings
|
||||
section was extracted in #143 with strings inline as a known
|
||||
follow-up; this is the cleanup PR.
|
||||
|
||||
Why deferred: low blast-radius (only the AI provider settings page
|
||||
shows English to a German user; the dashboard, dialogs, and
|
||||
critical UX paths all flow through `t(…)`). v1.4.x or v1.5.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
These are the items the v1.4.1 audit identified that the hardening
|
||||
release deliberately did NOT take. The audit also found 0 hallucinated
|
||||
medical claims, 0 i18n key-parity gaps, and the test theatre rate
|
||||
across the entire codebase was low (5 minor patterns). The codebase
|
||||
is in healthy shape; this list is the punch-list for the next
|
||||
proactive iteration, not a backlog of bugs we know about and ignore.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Scaling: web/worker container split (v1.4 G3)
|
||||
|
||||
The default `docker compose up` launches a single container that runs
|
||||
both the Next.js HTTP server AND the pg-boss worker. That works fine
|
||||
for a personal deployment but is hard to scale horizontally because
|
||||
every replica also runs the full reminder/insight cron schedule.
|
||||
|
||||
v1.4 introduces an environment-variable gate so the same image can be
|
||||
deployed in three modes:
|
||||
|
||||
| `HEALTHLOG_PROCESS_TYPE` | Web | Worker | Use when |
|
||||
| ------------------------ | --- | ------ | ------------------------- |
|
||||
| `all` (default) | ✅ | ✅ | single-host self-hosting |
|
||||
| `web` | ✅ | ❌ | scaling HTTP horizontally |
|
||||
| `worker` | ❌ | ✅ | dedicated job worker |
|
||||
|
||||
## How to split
|
||||
|
||||
1. Edit `docker-compose.yml`: set `HEALTHLOG_PROCESS_TYPE=web` on the
|
||||
`app` service and uncomment the `app-worker` service block at the
|
||||
bottom (it ships with `HEALTHLOG_PROCESS_TYPE=worker` baked in).
|
||||
2. Make sure both containers see the same `DATABASE_URL`,
|
||||
`ENCRYPTION_KEY` (or `ENCRYPTION_KEYS`), `API_TOKEN_HMAC_KEY`,
|
||||
and `SESSION_SECRET`.
|
||||
3. `docker compose up -d --build`.
|
||||
|
||||
Both containers connect to the same Postgres. pg-boss claims jobs
|
||||
atomically via row-level locking, so it is safe to run multiple worker
|
||||
replicas — they will share the load.
|
||||
|
||||
## Healthchecks
|
||||
|
||||
- `app` (web) healthcheck: `wget /api/version` every 30s.
|
||||
- `app-worker` does not expose a port; its liveness is reported into
|
||||
the `worker_status` table on every reminder pass and surfaced through
|
||||
`/api/admin/worker-status`.
|
||||
|
||||
The web container does NOT depend on the worker, and vice versa, so
|
||||
neither container's startup can deadlock the other. The shared
|
||||
dependency is Postgres; both wait on `db: condition: service_healthy`.
|
||||
|
||||
## Caveats
|
||||
|
||||
- Background tasks that touch user-scoped Wide Events (e.g. reminder
|
||||
notifications) emit telemetry from the worker; configure `LOKI_*`
|
||||
env vars on the worker if you want them.
|
||||
- The off-host backup job runs in the worker only. The admin
|
||||
`POST /api/admin/backup/test` endpoint runs in the web container and
|
||||
exercises the same S3 credentials — set `BACKUP_*` env vars on BOTH.
|
||||
- The startup gate (`assertSubsystemEnabled`) refuses to boot the
|
||||
reminder worker when `HEALTHLOG_PROCESS_TYPE=web`, so an accidental
|
||||
cross-mode invocation aborts immediately instead of silently doubling
|
||||
cron load.
|
||||
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.
|
||||
@@ -0,0 +1,464 @@
|
||||
# HealthLog UI Guidelines
|
||||
|
||||
> Single source of truth for visual & interaction patterns in the HealthLog
|
||||
> PWA. Consult this document before adding a new component, page, or
|
||||
> setting. If a pattern below doesn't fit your case, propose an addition
|
||||
> here in the same PR.
|
||||
|
||||
Last updated: 2026-05-08 (v1.4 cycle)
|
||||
|
||||
---
|
||||
|
||||
## 1. Foundations
|
||||
|
||||
### 1.1 Theme
|
||||
|
||||
HealthLog is a Dracula-themed app with a light counterpart (Alucard). Both
|
||||
themes share the same six-color signal palette (purple, pink, green, cyan,
|
||||
orange, red, yellow, lavender), exposed as CSS variables and as Tailwind
|
||||
`dracula-*` color tokens. Use the semantic tokens (`--primary`,
|
||||
`--destructive`, `--card`, …) for chrome and the `--dracula-*` tokens for
|
||||
data viz only.
|
||||
|
||||
| Surface | Token |
|
||||
| ---------------------------- | ------------------ |
|
||||
| App background | `--background` |
|
||||
| Card / popover surface | `--card` |
|
||||
| Primary action | `--primary` |
|
||||
| Destructive action | `--destructive` |
|
||||
| Secondary / muted background | `--secondary` |
|
||||
| Border | `--border` |
|
||||
| Focus ring | `--ring` |
|
||||
| Chart series 1–5 | `--chart-1`…`-5` |
|
||||
| Brand signal: purple | `--dracula-purple` |
|
||||
| Brand signal: green (good) | `--dracula-green` |
|
||||
| Brand signal: orange (warn) | `--dracula-orange` |
|
||||
| Brand signal: red (alert) | `--dracula-red` |
|
||||
| Brand signal: cyan (info) | `--dracula-cyan` |
|
||||
|
||||
Dark mode is the default. The theme switcher in the sidebar three-dot menu
|
||||
toggles `.dark` / `.light` on `:root`. Always test both modes before
|
||||
shipping a component.
|
||||
|
||||
### 1.2 Spacing
|
||||
|
||||
Tailwind's default 4-pixel scale. Reach for these stops first:
|
||||
|
||||
| Use | Token | Pixels |
|
||||
| ------------------------------------ | ------------- | ------ |
|
||||
| Inline gap between icon and label | `gap-1` | 4 |
|
||||
| Tight stack inside a card | `gap-2` | 8 |
|
||||
| Default form-field stack | `space-y-3` | 12 |
|
||||
| Section block (heading + body) | `space-y-4` | 16 |
|
||||
| Page section separator | `space-y-6` | 24 |
|
||||
| Dashboard tile-row gap, hero spacing | `gap-4` | 16 |
|
||||
| Page-edge padding (mobile / desktop) | `px-4 / px-6` | |
|
||||
|
||||
Avoid arbitrary `space-y-5`, `gap-3.5`, etc. unless aligning to a fixed
|
||||
external constraint.
|
||||
|
||||
### 1.3 Typography
|
||||
|
||||
The app uses the Tailwind defaults inherited via shadcn/ui. Reach for
|
||||
these classes first:
|
||||
|
||||
| Use | Class |
|
||||
| ---------------------------------- | --------------------------------------- |
|
||||
| Page title (h1) | `text-2xl font-semibold tracking-tight` |
|
||||
| Section heading (h2) | `text-lg font-semibold` |
|
||||
| Card title | `text-base font-medium` |
|
||||
| Body text | `text-sm` |
|
||||
| Muted helper / metadata | `text-sm text-muted-foreground` |
|
||||
| Microcopy / footer | `text-xs text-muted-foreground` |
|
||||
| Tabular data (latency, large nums) | `font-mono tabular-nums` |
|
||||
|
||||
Never use `font-bold` for emphasis — bump to `font-semibold` instead and
|
||||
let the design system breathe.
|
||||
|
||||
### 1.4 Radius and elevation
|
||||
|
||||
`--radius: 0.625rem` (10px) is the default. The Tailwind tokens
|
||||
`rounded-md` (`--radius-md` ≈ 8px) and `rounded-lg` (`--radius` ≈ 10px)
|
||||
cover 95% of cases. Use `rounded-2xl` only for hero cards or large
|
||||
modals.
|
||||
|
||||
Elevation is achieved via `shadow-xs`, `shadow-sm`, or
|
||||
`shadow-[0_1px_0_rgba(255,255,255,0.04)]` for very subtle separators on
|
||||
dark backgrounds. Avoid Tailwind's heavy shadows (`shadow-lg`+) in chrome.
|
||||
|
||||
---
|
||||
|
||||
## 2. Components
|
||||
|
||||
This section is **prescriptive, not exhaustive**. Each entry pairs a
|
||||
Radix/shadcn primitive with the cases it's the right answer for and the
|
||||
cases where another primitive should win.
|
||||
|
||||
### 2.1 Buttons
|
||||
|
||||
Every button comes from `@/components/ui/button`. Pick by purpose:
|
||||
|
||||
| Purpose | Variant | Size |
|
||||
| ----------------------------------------------------- | ------------- | ----------------- |
|
||||
| Primary action on a page or dialog | `default` | `default` |
|
||||
| Cancel, dismiss, secondary action next to primary | `outline` | `default` |
|
||||
| Tertiary in-row action (edit, copy, snooze) | `ghost` | `sm` |
|
||||
| Destructive (delete, revoke, disconnect) | `destructive` | `default` or `sm` |
|
||||
| Icon-only action (toolbar, chart toggles, table rows) | `ghost` | `icon-sm` |
|
||||
| Inline link to external help docs | `link` | `sm` |
|
||||
| Toolbar / table row action when space is tight | `outline` | `xs` |
|
||||
|
||||
**Position rules.**
|
||||
|
||||
- Forms (full-width or modal): primary on the **right**, cancel/back on
|
||||
the **left**. Match shadcn dialog defaults.
|
||||
- Settings sections: a single sticky `Save` button at the **right end**
|
||||
of the section header _or_ per-row inline `Save`. Never both in the
|
||||
same section.
|
||||
- Test buttons (Telegram test, AI ping, etc.): immediately to the right
|
||||
of the related Save / Apply control. Never below — keeps the eye
|
||||
motion horizontal.
|
||||
- Destructive actions live in their own confirm dialog with a
|
||||
type-to-confirm input when the action is irreversible (delete account,
|
||||
revoke API token, drop user data).
|
||||
|
||||
**Disabled state.** A disabled button must always come with a tooltip or
|
||||
helper text explaining why. Silent disabled buttons frustrate users.
|
||||
|
||||
### 2.2 Inputs and forms
|
||||
|
||||
We use `react-hook-form` + Zod schemas from `src/lib/validations/`.
|
||||
Wrap inputs in `<label>` (visible) plus `aria-describedby` for helper text.
|
||||
|
||||
- **Text / number / email**: `<Input>`. Always set `autocomplete`. See §4.4.
|
||||
- **Multi-line**: shadcn `<Textarea>` (add via `pnpm dlx shadcn@latest add textarea` if missing).
|
||||
- **Single choice ≤4 options**: prefer `<RadioGroup>` or a SegmentedControl
|
||||
pattern using `<Tabs>`. Never use `<Select>` for tiny enums — extra clicks.
|
||||
- **Single choice 5–15 options**: `<Select>` (shadcn).
|
||||
- **Single choice >15 options**: `<Combobox>` (shadcn `<Command>` + `<Popover>`)
|
||||
with type-ahead.
|
||||
- **Multi choice**: `<Checkbox>` group, or shadcn `<MultiSelect>` when 7+ choices.
|
||||
- **Date / datetime**: shadcn `<Calendar>` inside a `<Popover>`. Always
|
||||
label the timezone (Europe/Berlin).
|
||||
- **Numeric range**: `<Slider>` only when the value space is continuous
|
||||
_and_ the user routinely sweeps. For most thresholds and counts, an
|
||||
`<Input type="number">` plus stepper buttons is faster and less
|
||||
fiddly. See §6 (Slider audit) for the migration path.
|
||||
- **Boolean toggle**: `<Switch>` for "applies immediately" settings (e.g.
|
||||
enable Telegram). `<Checkbox>` for "applies on Save" lists (e.g. select
|
||||
measurements to export).
|
||||
|
||||
**Validation.** Inline messages below the field. Field gets
|
||||
`aria-invalid="true"` and a destructive border. Form-level errors land
|
||||
in a `<Alert variant="destructive">` at the top. Never show a toast for
|
||||
a field-level error.
|
||||
|
||||
### 2.3 Dialog vs Sheet vs Drawer
|
||||
|
||||
The wrong choice here is the most visible UX mistake we make. Pick by
|
||||
intent, not by hand-feel.
|
||||
|
||||
| Use | Primitive | Rationale |
|
||||
| --------------------------------------------------------------- | ------------------------------ | ---------------------------------------------------------------- |
|
||||
| Confirm a destructive or irreversible action | `Dialog` (`AlertDialog` shape) | Modal blocks accidental dismiss; user must read. |
|
||||
| Inspect a single item's details with no edit | `Dialog` | Centered, focused, dismissible. |
|
||||
| Edit a single item with ≤6 fields | `Dialog` | Tight focus, quick save. |
|
||||
| Edit a single item with 7+ fields, or with deep sub-sections | `Sheet` (right) | Wide working surface, scrollable, doesn't crop on tall content. |
|
||||
| Mobile bottom-up sub-action (filter, sort, multi-select picker) | `Sheet` (bottom) | Thumb-friendly, dismiss by swipe. |
|
||||
| Multi-step wizard | Full route | Page transitions communicate progress better than a stuck modal. |
|
||||
| Side-by-side compare or trend deep-dive | Full route | Charts need the viewport. |
|
||||
|
||||
Never nest Dialogs. If a Dialog needs another modal action, escalate the
|
||||
inner step to a separate route or use shadcn's `<AlertDialog>` chained on
|
||||
top of a `<Sheet>`.
|
||||
|
||||
### 2.4 Cards and lists
|
||||
|
||||
- **Card**: a self-contained unit of content. Use `<Card>` from
|
||||
shadcn. Header, body, footer follow Pretendard-style hierarchy.
|
||||
- **Tile**: a _small_ card showing a single metric on the dashboard.
|
||||
See §3.2 for the one-row constraint. Tiles must include current value,
|
||||
unit, optional sparkline, and a single tap-target area.
|
||||
- **List row**: `<div className="flex items-center justify-between gap-3 px-3 py-2 hover:bg-accent/40 rounded-md">` — left side has icon + primary
|
||||
- meta, right side has trailing controls. Never put primary actions in
|
||||
the right-side ellipsis menu — only secondary or destructive.
|
||||
- **Empty state**: every list and tile has an explicit empty-state
|
||||
component with an icon, a one-sentence explanation, and a single
|
||||
primary action ("Add measurement", "Connect Withings"). See §5.
|
||||
|
||||
### 2.5 Tables
|
||||
|
||||
`<Table>` (shadcn) for tabular data only — measurements list, audit log,
|
||||
admin views. Default to `<Card>` + list rows for everything else;
|
||||
tables on mobile collapse poorly. When you must use a table on mobile,
|
||||
swap to a vertical list at `sm:` breakpoint.
|
||||
|
||||
### 2.6 Status indicators
|
||||
|
||||
Use the same color taxonomy across the app:
|
||||
|
||||
| State | Color token | Examples |
|
||||
| --------- | -------------------- | ---------------------------------------------- |
|
||||
| Good | `--dracula-green` | Connected, in target, healthy |
|
||||
| Info | `--dracula-cyan` | Up-to-date, neutral signal |
|
||||
| Caution | `--dracula-orange` | Mildly out of range, expiring token, slow sync |
|
||||
| Alert | `--dracula-red` | Out of range, sync failed, critical |
|
||||
| Pending | `--muted-foreground` | Loading, indeterminate |
|
||||
| Highlight | `--dracula-purple` | New / featured / promoted |
|
||||
|
||||
Show a colored dot **plus** a label. Never rely on color alone.
|
||||
|
||||
---
|
||||
|
||||
## 3. Layouts
|
||||
|
||||
### 3.1 Page shell
|
||||
|
||||
Every authenticated page is wrapped by `<TopBar>` + `<SidebarNav>` (left)
|
||||
|
||||
- `<BottomNav>` (mobile). Don't reinvent these on a per-page basis.
|
||||
|
||||
* Mobile breakpoint: `<sm` shows bottom-nav, hides sidebar.
|
||||
* `sm`–`md`: sidebar in icons-only mode.
|
||||
* `md+`: sidebar full mode unless user has collapsed it
|
||||
(`localStorage:healthlog-sidebar-collapsed`).
|
||||
|
||||
Page content goes in `<main className="mx-auto max-w-5xl px-4 sm:px-6 py-6 space-y-6">` unless the page is a full-bleed dashboard or settings shell.
|
||||
|
||||
### 3.2 Dashboard tiles — one-row rule
|
||||
|
||||
Tiles at the top of the dashboard live in a **single row**. Width caps
|
||||
at the chart's width directly below. **Never** wrap to a second row.
|
||||
|
||||
If the user enables more tiles than fit, the dashboard surfaces a banner
|
||||
inviting them to deactivate or reorder via Settings → Dashboard. The
|
||||
layout editor in Settings shows a live preview of the constraint and
|
||||
flags overflow before save.
|
||||
|
||||
CSS: `grid-cols-N` where N = active tile count, capped by
|
||||
`Math.min(activeTiles, maxTilesForViewport(width))`. Drag-reorder via
|
||||
`@dnd-kit/sortable`. Toggle-show via the layout editor only — not in the
|
||||
tile itself, to avoid accidental hide.
|
||||
|
||||
### 3.3 Settings — `/settings/[section]`
|
||||
|
||||
The 1.4 release splits the legacy 3150-LOC settings monolith into one
|
||||
route per top-level section:
|
||||
|
||||
- `/settings/account` — profile, password, passkeys, account deletion.
|
||||
- `/settings/integrations` — Withings, moodLog, Telegram, ntfy, Web Push.
|
||||
- `/settings/notifications` — channel matrix + quiet hours + reminders.
|
||||
- `/settings/dashboard` — tile layout editor, default ranges, threshold
|
||||
overrides.
|
||||
- `/settings/ai` — AI provider selection + connection + test.
|
||||
- `/settings/api` — API tokens for headless ingest + Bearer issuance.
|
||||
- `/settings/advanced` — exports, imports, danger zone.
|
||||
- `/settings/about` — version, build, license, links to docs/changelog.
|
||||
|
||||
Each section's component lives at `src/components/settings/<section>.tsx`
|
||||
and stays under 400 LOC. Cross-section search lives in the section
|
||||
sidebar header.
|
||||
|
||||
The legacy `/settings` route 301-redirects to `/settings/account`.
|
||||
|
||||
### 3.4 Admin — `/admin/[section]`
|
||||
|
||||
Same shape as Settings, with the right-hand sidebar listing:
|
||||
|
||||
- Users
|
||||
- Integrations status
|
||||
- Monitoring (Wide Events, GlitchTip, Umami)
|
||||
- Backups
|
||||
- Maintenance
|
||||
- Audit log
|
||||
|
||||
Status badges use the §2.6 color taxonomy. Each section opens to a card
|
||||
grid, never a long fluid list.
|
||||
|
||||
---
|
||||
|
||||
## 4. Patterns
|
||||
|
||||
### 4.1 Loading states
|
||||
|
||||
- **Page-level**: a `<Skeleton>` matching the final layout. Never show a
|
||||
full-screen spinner. Mount the skeleton inside the same `<main>` shell
|
||||
the loaded page renders into.
|
||||
- **Inline**: button loading state shows a spinner + label change
|
||||
("Saving…", "Testing…"). Disable other form controls while pending.
|
||||
- **Long-running**: progress bar with a remaining-step count; for
|
||||
background tasks (export, backup), surface in a toast that links to
|
||||
the operation log instead of blocking the UI.
|
||||
|
||||
Skeletons must respect `prefers-reduced-motion: reduce`.
|
||||
|
||||
### 4.2 Empty states
|
||||
|
||||
A reusable `<EmptyState>` shape:
|
||||
|
||||
```tsx
|
||||
<EmptyState
|
||||
icon={<Plus className="size-6" />}
|
||||
title={t("empty.measurements.title")}
|
||||
description={t("empty.measurements.description")}
|
||||
action={<Button>{t("empty.measurements.add")}</Button>}
|
||||
/>
|
||||
```
|
||||
|
||||
Every list, every chart, every dashboard tile that can be empty has an
|
||||
explicit, translated empty-state. No "No data" placeholder strings.
|
||||
|
||||
### 4.3 Error states
|
||||
|
||||
- **Form errors**: inline below the offending field; aggregate at the
|
||||
top of the form for global errors (e.g. "Server unreachable").
|
||||
- **Page errors**: Next.js `error.tsx` boundaries with a friendly
|
||||
recovery action ("Retry", "Go home"). Send the error to GlitchTip
|
||||
through the existing client.
|
||||
- **Network errors during save**: `<Alert variant="destructive">` at
|
||||
the top of the form _plus_ a retry button. Never silently fail.
|
||||
|
||||
### 4.4 Forms and password managers
|
||||
|
||||
- Measurement, medication, and mood form fields use
|
||||
`autocomplete="off"` on every input that browsers keep guessing wrong.
|
||||
- API-token inputs and AI-provider key inputs use
|
||||
`autocomplete="new-password"` so the browser doesn't autofill the
|
||||
user's account password.
|
||||
- Honey-pot fields (offscreen `<input>` with `autocomplete="email"` and
|
||||
`tabIndex={-1}`) sit at the top of measurement forms to catch
|
||||
aggressive autofill.
|
||||
- Forms always include a hidden `<input type="text" name="username"
|
||||
autocomplete="username" value={user.email} readOnly tabIndex={-1}>`
|
||||
inside password change and passkey screens so password managers
|
||||
attribute saved credentials correctly.
|
||||
|
||||
### 4.5 Test buttons (every integration)
|
||||
|
||||
Each configurable integration ships a Test button next to its Save
|
||||
control. Behavior:
|
||||
|
||||
- **Trigger**: ad-hoc API call to the integration with a small,
|
||||
identifiable payload ("HealthLog test ping").
|
||||
- **State**: button shows a spinner with localized "Testing…" label.
|
||||
Result lands inline in a `<StatusCallout>` directly below — green for
|
||||
success with the relevant detail (last sync, message ID, response
|
||||
status) or red for the error (sanitized message — never leak the
|
||||
upstream URL or API key).
|
||||
- **History**: each test writes a row to a tiny per-integration log
|
||||
visible in the section. Last 5 tests, timestamp + result.
|
||||
- **Coverage**: required for Telegram, ntfy, Web Push, AI provider,
|
||||
Withings, moodLog, GlitchTip, Umami, email (when wired), backup
|
||||
target. Headless E2E asserts presence + working call for each.
|
||||
|
||||
### 4.6 Server actions and mutations
|
||||
|
||||
We use `react-hook-form` + TanStack Query mutations, never raw fetch
|
||||
calls in components. Every mutation:
|
||||
|
||||
1. Optimistically updates the cache with a rollback handler.
|
||||
2. Toast on success ("Saved" — translated) and on error (with retry).
|
||||
3. Re-validates queries it touches.
|
||||
|
||||
For multi-step flows, write a typed `apiClient` helper in
|
||||
`src/lib/api/clients/` rather than re-doing fetch in three places.
|
||||
|
||||
### 4.7 Inline help
|
||||
|
||||
A small `?` `<Tooltip>` next to the label is the default for non-trivial
|
||||
fields (e.g. "Why does this default to ESH 2023?"). Tooltips must be
|
||||
keyboard-accessible (Radix handles this) and translated.
|
||||
|
||||
For long explanations, link out to `https://docs.healthlog.dev/...` —
|
||||
never embed multi-paragraph help inline.
|
||||
|
||||
---
|
||||
|
||||
## 5. Accessibility
|
||||
|
||||
We aim for **WCAG 2.1 AA**, with the following non-negotiables:
|
||||
|
||||
- Color contrast ≥ 4.5:1 for normal text, 3:1 for large text and UI
|
||||
components. Verified by `@axe-core/playwright` in CI on every page.
|
||||
- Touch targets ≥ 44×44 CSS px on bottom-nav and primary action buttons
|
||||
(WCAG 2.5.5).
|
||||
- Focus is always visible: shadcn's `focus-visible:ring` is preserved on
|
||||
every customized component.
|
||||
- Every interactive element has an accessible name. Icon-only buttons
|
||||
get `<span className="sr-only">` labels and `aria-label`.
|
||||
- `prefers-reduced-motion: reduce` disables animations beyond a 200 ms
|
||||
fade.
|
||||
- Charts include text-equivalent summaries (`aria-describedby` linked to
|
||||
a `<p className="sr-only">` summary) and exposed data via the visible
|
||||
legend / table view toggle.
|
||||
|
||||
CI fails on any `serious` or `critical` axe-core violation.
|
||||
|
||||
---
|
||||
|
||||
## 6. Slider audit and migration
|
||||
|
||||
Sliders look pretty but are a poor fit for most numeric inputs because
|
||||
they sacrifice precision. The 1.4 audit catalogues every `<Slider>` in
|
||||
the codebase and migrates the inappropriate ones:
|
||||
|
||||
| Setting | Current control | Better control |
|
||||
| ----------------------------------- | --------------- | ------------------------------- |
|
||||
| Mood intensity (0–10) | Slider | SegmentedControl |
|
||||
| Notification quiet-hours range | Two sliders | Two `<Input type="time">` |
|
||||
| Threshold-override numeric values | Slider | `<Input type="number">` + steps |
|
||||
| AI insight depth (1–5 levels) | Slider | `<RadioGroup>` cards |
|
||||
| Export retention days (7/30/90/365) | Slider | `<Select>` with preset options |
|
||||
|
||||
A slider stays appropriate for "scrub through history" or "set chart
|
||||
zoom range" — operations where the value is continuous and live preview
|
||||
is the point.
|
||||
|
||||
---
|
||||
|
||||
## 7. Internationalization
|
||||
|
||||
All user-facing strings come from `messages/en.json` (default) and
|
||||
`messages/de.json`. Use `useTranslations()` on the client and
|
||||
`getServerTranslator()` (`src/lib/i18n/server-translator.ts`) on the
|
||||
server. Numbers, dates, currencies, and units always go through
|
||||
`useFormatters()` — never hand-roll `Intl.NumberFormat(...)` with a
|
||||
fixed locale.
|
||||
|
||||
Server-rendered content (PDFs, notifications, server-issued category
|
||||
labels) must use the locale of the user the content is for, not the
|
||||
locale of the request that triggered the job.
|
||||
|
||||
---
|
||||
|
||||
## 8. Motion
|
||||
|
||||
Default transitions: `transition-all duration-150 ease-out`. Heavier
|
||||
animations (insight cards, dialog enter/exit) use the shadcn defaults.
|
||||
Reduce-motion always wins.
|
||||
|
||||
Loading skeletons pulse via Tailwind `animate-pulse`. Don't introduce
|
||||
custom keyframes without adding a `prefers-reduced-motion` fallback.
|
||||
|
||||
---
|
||||
|
||||
## 9. Component checklist (for new features)
|
||||
|
||||
Before opening a PR for a new component or page, walk through:
|
||||
|
||||
- [ ] Uses tokens from §1, not hard-coded hex/rgba.
|
||||
- [ ] Buttons follow §2.1 variant + position rules.
|
||||
- [ ] Inputs follow §2.2; correct `autocomplete`; honey-pots where needed.
|
||||
- [ ] Dialog vs Sheet vs Drawer per §2.3.
|
||||
- [ ] Loading, empty, and error states from §4.1–4.3 implemented.
|
||||
- [ ] All strings via `t()`; numbers via formatters.
|
||||
- [ ] Mobile viewport tested (Chrome DevTools 375×667 and 414×896).
|
||||
- [ ] Light + Dark mode tested.
|
||||
- [ ] Keyboard navigation: Tab order, Enter to submit, Esc to dismiss.
|
||||
- [ ] axe-core clean (no serious/critical).
|
||||
- [ ] Headless screenshot test added if UI is non-trivial.
|
||||
|
||||
---
|
||||
|
||||
_Maintained as part of the v1.4 release cycle. Propose changes alongside
|
||||
the code that motivates them._
|
||||
@@ -0,0 +1,44 @@
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Accessibility regression gate — fails the build if any WCAG 2.1 AA
|
||||
* violation lands in the `serious` or `critical` bucket on a public
|
||||
* surface. Authenticated pages are out of scope here (need seeded
|
||||
* data); they're covered by the per-flow specs that already touch
|
||||
* those routes.
|
||||
*/
|
||||
test.describe("axe-core public surfaces", () => {
|
||||
for (const path of ["/auth/login"]) {
|
||||
test(`${path} has no serious or critical a11y violations`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(path);
|
||||
// Wait briefly for any reduced-motion fades to settle.
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const results = await new AxeBuilder({ page })
|
||||
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
|
||||
.analyze();
|
||||
|
||||
const blocking = results.violations.filter(
|
||||
(v) => v.impact === "serious" || v.impact === "critical",
|
||||
);
|
||||
|
||||
if (blocking.length > 0) {
|
||||
// Pretty-print so failures are actionable in CI logs.
|
||||
console.log(
|
||||
"axe violations:\n" +
|
||||
blocking
|
||||
.map(
|
||||
(v) =>
|
||||
` - [${v.impact}] ${v.id}: ${v.help}\n ${v.nodes.length} node(s)\n ${v.helpUrl}`,
|
||||
)
|
||||
.join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
expect(blocking).toHaveLength(0);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* The proxy at src/proxy.ts is the single gate enforcing auth on every
|
||||
* non-public path. A regression that accidentally adds a route to
|
||||
* PUBLIC_PATHS, or breaks the redirect, would expose unauthenticated
|
||||
* surfaces. Verify a few representative routes round-trip to /auth/login.
|
||||
*/
|
||||
test.describe("proxy auth gate", () => {
|
||||
for (const path of [
|
||||
"/",
|
||||
"/dashboard",
|
||||
"/medications",
|
||||
"/admin",
|
||||
"/insights",
|
||||
]) {
|
||||
test(`${path} redirects to /auth/login when no session`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const response = await page.goto(path, { waitUntil: "domcontentloaded" });
|
||||
expect(response).not.toBeNull();
|
||||
// Either the proxy hands back a 307/308, or the navigation lands
|
||||
// at /auth/login after redirect; both are acceptable proof.
|
||||
expect(page.url()).toMatch(/\/auth\/login(\?|$)/);
|
||||
});
|
||||
}
|
||||
|
||||
test("public paths are reachable without a session", async ({ page }) => {
|
||||
// /auth/login is in PUBLIC_PATHS — must be served, not bounced anywhere.
|
||||
await page.goto("/auth/login");
|
||||
expect(page.url()).toMatch(/\/auth\/login(\?|$)/);
|
||||
await expect(page.locator("body")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Locale-key drift smoke check — switch the cookie between EN and DE
|
||||
* and assert the login page (the only public surface that's translated)
|
||||
* never shows raw i18n keys like `auth.welcomeBack`.
|
||||
*
|
||||
* Drift catches: a typo'd t("foo.bar") with no key in either locale
|
||||
* file would make the raw key surface in the DOM. messages/de.json and
|
||||
* messages/en.json key parity is enforced by unit tests; this is the
|
||||
* runtime cousin.
|
||||
*/
|
||||
test.describe("locale switch", () => {
|
||||
for (const locale of ["en", "de"] as const) {
|
||||
test(`renders /auth/login in ${locale} without raw i18n keys`, async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await context.addCookies([
|
||||
{
|
||||
name: "healthlog_locale",
|
||||
value: locale,
|
||||
url: page.url() || "http://localhost:3000",
|
||||
},
|
||||
]);
|
||||
await page.goto("/auth/login");
|
||||
|
||||
const bodyText = await page.locator("body").innerText();
|
||||
|
||||
// i18n keys look like `section.subkey` — letters, numbers,
|
||||
// underscores, dots — and are never wrapped in normal sentences,
|
||||
// so a regex match anywhere in body text is a sign the lookup
|
||||
// fell through.
|
||||
const rawKeyPattern = /\b[a-z]+(?:[A-Z][a-z]+)?\.[a-z][A-Za-z0-9_.]+\b/;
|
||||
const match = bodyText.match(rawKeyPattern);
|
||||
|
||||
// Whitelist: filenames or version strings can look like keys,
|
||||
// skip those by requiring at least one camelCase segment.
|
||||
if (match) {
|
||||
const candidate = match[0];
|
||||
const looksLikeKey = /\.[a-z][A-Z]/.test(candidate);
|
||||
expect(
|
||||
looksLikeKey,
|
||||
`Possible raw i18n key in ${locale}: ${candidate}`,
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Smoke checks on the login surface — must render, must wire the
|
||||
* password-manager autofill hints (autoComplete="username" /
|
||||
* "current-password"), must let the user submit.
|
||||
*
|
||||
* These do NOT log in (no DB seed required); the password-flow
|
||||
* roundtrip is exercised by the integration test in
|
||||
* tests/integration/auth-flow.test.ts.
|
||||
*/
|
||||
test.describe("login page", () => {
|
||||
test("renders username + password inputs with the right autoComplete", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/auth/login");
|
||||
|
||||
const username = page.getByLabel(/username|benutzername/i).first();
|
||||
await expect(username).toBeVisible();
|
||||
await expect(username).toHaveAttribute("autoComplete", /username|email/);
|
||||
|
||||
const password = page.locator('input[type="password"]').first();
|
||||
await expect(password).toBeVisible();
|
||||
await expect(password).toHaveAttribute("autoComplete", "current-password");
|
||||
});
|
||||
|
||||
test("rejects an obviously-wrong credential pair", async ({ page }) => {
|
||||
await page.goto("/auth/login");
|
||||
|
||||
await page
|
||||
.getByLabel(/username|benutzername/i)
|
||||
.first()
|
||||
.fill("nobody-here");
|
||||
await page.locator('input[type="password"]').first().fill("not-the-pw");
|
||||
|
||||
// Intercept the API call so the test does not depend on a real
|
||||
// backend rejecting the credentials. The login form's error
|
||||
// surfacing is what we actually want to prove here.
|
||||
await page.route("**/api/auth/login", (route) =>
|
||||
route.fulfill({
|
||||
status: 401,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ error: "Invalid credentials" }),
|
||||
}),
|
||||
);
|
||||
|
||||
await page.getByRole("button", { name: /login|anmelden|sign in/i }).click();
|
||||
|
||||
await expect(
|
||||
page.getByText(/invalid credentials|ungültig|falsch/i),
|
||||
).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* /api/version is the public, unauthenticated endpoint that drives
|
||||
* the Settings → About surface and acts as the container's healthcheck
|
||||
* target. If this breaks, every deployed image's healthcheck fails and
|
||||
* Coolify pulls the container out of rotation.
|
||||
*/
|
||||
test.describe("public version endpoint", () => {
|
||||
test("returns the running version + license", async ({ request }) => {
|
||||
const res = await request.get("/api/version");
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const json = await res.json();
|
||||
expect(json.data).toBeDefined();
|
||||
expect(typeof json.data.version).toBe("string");
|
||||
expect(json.data.version).toMatch(/^\d+\.\d+\.\d+/);
|
||||
expect(json.data.license).toBeDefined();
|
||||
});
|
||||
|
||||
test("does not require authentication", async ({ request }) => {
|
||||
// No cookie, no Authorization header — must still respond 200 so
|
||||
// the docker healthcheck and the in-app "check for updates" button
|
||||
// both work without a session.
|
||||
const res = await request.get("/api/version", {
|
||||
headers: { "X-Client-Type": "anonymous" },
|
||||
});
|
||||
expect(res.status()).toBe(200);
|
||||
});
|
||||
});
|
||||
+187
-49
@@ -62,7 +62,7 @@
|
||||
"colMin": "Min",
|
||||
"colMax": "Max",
|
||||
"colN": "n",
|
||||
"bpClassificationTitle": "Blutdruck — ESC/ESH-Klassifikation (2018)",
|
||||
"bpClassificationTitle": "Blutdruck — ESH-Klassifikation (2023)",
|
||||
"avgBp": "Durchschnittlicher Blutdruck",
|
||||
"classification": "Klassifikation",
|
||||
"bmiTitle": "Body-Mass-Index (BMI)",
|
||||
@@ -89,6 +89,9 @@
|
||||
"typeBodyFat": "Körperfettanteil",
|
||||
"typeSleep": "Schlafdauer",
|
||||
"typeSteps": "Aktivität (Schritte)",
|
||||
"typeTotalBodyWater": "Gesamtkörperwasser",
|
||||
"typeBoneMass": "Knochenmasse",
|
||||
"typeOxygenSaturation": "Sauerstoffsättigung",
|
||||
"unitSteps": "Schritte",
|
||||
"bmiUnderweight": "Untergewicht",
|
||||
"bmiNormal": "Normalgewicht",
|
||||
@@ -191,12 +194,16 @@
|
||||
},
|
||||
"welcomeBack": "{greeting}, willkommen zurück.",
|
||||
"welcomeBackWithName": "{greeting} {name}, willkommen zurück.",
|
||||
"greetingSalutation": "Hi, {name}",
|
||||
"streakLabel": "Tage in Folge",
|
||||
"weight": "Gewicht",
|
||||
"bloodPressure": "Blutdruck",
|
||||
"bloodPressureSys": "Blutdruck (Sys)",
|
||||
"bloodPressureDia": "Blutdruck (Dia)",
|
||||
"pulse": "Puls",
|
||||
"bodyFat": "Körperfett",
|
||||
"sleep": "Schlaf",
|
||||
"steps": "Schritte",
|
||||
"bpInTarget": "BD im Zielbereich",
|
||||
"days30Short": "30T",
|
||||
"medications": "Medikamente",
|
||||
@@ -243,6 +250,9 @@
|
||||
"typeSleep": "Schlaf",
|
||||
"typeSteps": "Schritte",
|
||||
"typeBloodGlucose": "Blutzucker",
|
||||
"typeTotalBodyWater": "Gesamtkörperwasser",
|
||||
"typeBoneMass": "Knochenmasse",
|
||||
"typeOxygenSaturation": "Sauerstoffsättigung",
|
||||
"glucoseContext": "Kontext",
|
||||
"glucoseContextFasting": "Nüchtern (≥8 h ohne Nahrung)",
|
||||
"glucoseContextPostprandial": "Postprandial (2 h nach dem Essen)",
|
||||
@@ -532,6 +542,9 @@
|
||||
"pointsAllTitle": "Alle verfügbaren Messpunkte",
|
||||
"systolic": "Systolisch",
|
||||
"diastolic": "Diastolisch",
|
||||
"bodyWater": "Körperwasser",
|
||||
"boneMass": "Knochenmasse",
|
||||
"spo2": "SpO₂",
|
||||
"movingAverage7d": "7T-Schnitt",
|
||||
"avg7dShort": "7T",
|
||||
"avg30dShort": "30T",
|
||||
@@ -589,6 +602,10 @@
|
||||
"refreshAnalysis": "Analyse aktualisieren",
|
||||
"keyTakeaway": "Das Wichtigste",
|
||||
"findingsTitle": "Befunde",
|
||||
"heroFindingPositive": "Wichtigster Befund",
|
||||
"heroFindingNeutral": "Wichtigster Befund",
|
||||
"heroFindingAttention": "Beachten",
|
||||
"heroFindingWarning": "Wichtigster Alarm",
|
||||
"correlationsTitle": "Zusammenhänge",
|
||||
"recommendationsTitle": "Empfehlungen",
|
||||
"dataFoundation": "Datengrundlage",
|
||||
@@ -617,7 +634,7 @@
|
||||
"inTargetRange": "im Zielbereich",
|
||||
"bpTarget": "Ziel: {range}",
|
||||
"noDobSet": "Geburtsdatum nicht hinterlegt.",
|
||||
"avg30dEsc": "30T-Durchschnitt · ESC/ESH 2018",
|
||||
"avg30dEsc": "30T-Durchschnitt · ESH 2023",
|
||||
"whoClassification": "WHO-Klassifikation",
|
||||
"correlations": "Korrelationen",
|
||||
"weightVsBp": "Gewicht vs. Blutdruck (Systolisch)",
|
||||
@@ -754,53 +771,12 @@
|
||||
"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"
|
||||
},
|
||||
"targets": {
|
||||
"title": "Zielwerte",
|
||||
"subtitle": "Persönliche Referenzbereiche basierend auf deinem Profil",
|
||||
"introText": "Diese Zielwerte sind persönliche Referenzbereiche, die aus deinem Profil und anerkannten medizinischen Richtwerten abgeleitet werden. Sie helfen dir, deine wichtigsten Gesundheitswerte schnell einzuordnen und Entwicklungen früh zu erkennen. Die Bereiche werden für dich individuell berechnet, zum Beispiel anhand von Alter, Geschlecht und Körpergröße. Zu jedem Wert siehst du direkt, ob du im Zielbereich liegst oder darüber beziehungsweise darunter. So bekommst du eine kompakte Übersicht darüber, welche Zielwerte du aktuell gut einhältst und wo noch Potenzial besteht.",
|
||||
"current": "Aktuell",
|
||||
"average30d": "30-Tage-Durchschnitt:",
|
||||
"targetRange": "Zielbereich",
|
||||
"classification": "Bewertung",
|
||||
"source": "Quelle",
|
||||
"sourceLabel": "Quelle: {source}",
|
||||
"trend": "Trend",
|
||||
"weight": "Gewicht",
|
||||
"bloodPressure": "Blutdruck",
|
||||
"pulse": "Ruhepuls",
|
||||
"sleep": "Schlafdauer",
|
||||
"bmi": "BMI",
|
||||
"bodyFat": "Körperfett",
|
||||
"steps": "Schritte/Tag",
|
||||
"compliance": "Einnahmetreue",
|
||||
"compliancePerMedication": "Pro Medikament",
|
||||
"diastolic": "Diastolisch",
|
||||
"noProfile": "Bitte fülle dein Profil aus, um personalisierte Zielwerte zu erhalten.",
|
||||
"profileIncomplete": "Profil unvollständig",
|
||||
"profileIncompleteHeightAge": "Hinterlege deine Größe und dein Geburtsdatum im Profil, um personalisierte Zielbereiche zu erhalten.",
|
||||
"profileIncompleteHeight": "Hinterlege deine Größe im Profil, um den Gewichts-Zielbereich zu berechnen.",
|
||||
"profileIncompleteAge": "Hinterlege dein Geburtsdatum im Profil, um altersbasierte Zielbereiche zu erhalten.",
|
||||
"noMeasurementData": "Noch keine Messdaten vorhanden.",
|
||||
"noMeasurementYet": "Noch keine Messung vorhanden",
|
||||
"currentValue": "Aktuell: {value} {unit}",
|
||||
"targetRangeValue": "Zielbereich: {min}–{max} {unit}",
|
||||
"inTarget": "Im Zielbereich",
|
||||
"belowTarget": "{delta} {unit} unter Ziel",
|
||||
"aboveTarget": "{delta} {unit} über Ziel",
|
||||
"loadError": "Fehler beim Laden",
|
||||
"classificationOptimal": "Optimal",
|
||||
"classificationNormal": "Normal",
|
||||
"classificationElevated": "Erhöht",
|
||||
"classificationHigh": "Hoch",
|
||||
"classificationLow": "Niedrig",
|
||||
"glucoseFasting": "Glukose — nüchtern",
|
||||
"glucosePostprandial": "Glukose — postprandial",
|
||||
"glucoseRandom": "Glukose — beliebig",
|
||||
"glucoseBedtime": "Glukose — vor dem Schlafen"
|
||||
},
|
||||
"onboarding": {
|
||||
"welcome": "Willkommen bei HealthLog!",
|
||||
"setupDescription": "Richte dein Profil ein, um loszulegen. Du kannst das auch später in den Einstellungen ändern.",
|
||||
@@ -824,11 +800,119 @@
|
||||
"errorMedication": "Das Medikament „{name}\" konnte nicht angelegt werden. Bitte erneut versuchen oder später unter „Medikamente\" hinzufügen.",
|
||||
"errorTargets": "Deine Zielwerte konnten nicht gespeichert werden. Bitte erneut versuchen.",
|
||||
"errorGeneric": "Beim Abschluss des Setups ist etwas schiefgelaufen. Bitte erneut versuchen.",
|
||||
"medScheduleHint": "Wir legen ein Standard-Erinnerungsfenster an (08:00–09:00 täglich). Du kannst es jederzeit unter „Medikamente\" anpassen."
|
||||
"medScheduleHint": "Wir legen ein Standard-Erinnerungsfenster an (08:00–09:00 täglich). Du kannst es jederzeit unter „Medikamente\" anpassen.",
|
||||
"v2": {
|
||||
"title": "Willkommen bei HealthLog",
|
||||
"subtitle": "Drei schnelle Schritte. Alles weitere kannst du später in den Einstellungen erledigen.",
|
||||
"stepOf": "Schritt {current} von {total}",
|
||||
"back": "Zurück",
|
||||
"continue": "Weiter",
|
||||
"finish": "Setup abschließen",
|
||||
"skipStep": "Schritt überspringen — du kannst das Setup später in den Einstellungen abschließen.",
|
||||
"step1": {
|
||||
"title": "Über dich",
|
||||
"description": "Wird für Blutdruck-Zielwerte, BMI und passende Erinnerungen genutzt.",
|
||||
"displayNameLabel": "Anzeigename",
|
||||
"displayNameHint": "Erscheint in der Dashboard-Begrüßung und in Berichten.",
|
||||
"languageLabel": "Sprache",
|
||||
"languageHint": "Wird für App, PDFs und Benachrichtigungen verwendet."
|
||||
},
|
||||
"step2": {
|
||||
"title": "Lege deine erste Messung an",
|
||||
"description": "Gewicht, Blutdruck oder Puls — was du gerade zur Hand hast. Schon eine Messung lässt das Dashboard lebendig wirken.",
|
||||
"added": "Top — weitere Messungen kannst du jederzeit über das Dashboard hinzufügen.",
|
||||
"skipHint": "Keine Messung zur Hand? Überspringe und logge eine, wenn du soweit bist."
|
||||
},
|
||||
"step3": {
|
||||
"title": "Wähle einen Benachrichtigungs-Kanal",
|
||||
"description": "Wir senden nur, was du auch willst. Du kannst weitere Kanäle und Events später feinjustieren.",
|
||||
"channelTelegramTitle": "Telegram-Bot",
|
||||
"channelTelegramHint": "Erinnerungen mit Snooze-Buttons direkt in Telegram.",
|
||||
"channelWebPushTitle": "Web Push",
|
||||
"channelWebPushHint": "Native Browser-Benachrichtigungen auf diesem Gerät.",
|
||||
"channelNtfyTitle": "ntfy.sh",
|
||||
"channelNtfyHint": "Open-Source-Push an Handy oder Desktop, ohne Account.",
|
||||
"channelNoneTitle": "Vorerst überspringen",
|
||||
"channelNoneHint": "Ich richte Benachrichtigungen später in den Einstellungen ein.",
|
||||
"openSettings": "In den Einstellungen fortsetzen"
|
||||
},
|
||||
"doneToast": "Alles eingerichtet — willkommen an Bord."
|
||||
}
|
||||
},
|
||||
"gettingStarted": {
|
||||
"title": "Erste Schritte",
|
||||
"subtitle": "Quick Wins, damit du das Beste aus HealthLog herausholst.",
|
||||
"progress": "{done} von {total} erledigt",
|
||||
"dismiss": "Ausblenden",
|
||||
"dismissAll": "Checkliste ausblenden",
|
||||
"dismissTooltip": "Diesen Punkt ausblenden",
|
||||
"restoreHint": "Du kannst die Checkliste in den Einstellungen wiederherstellen.",
|
||||
"items": {
|
||||
"profileTitle": "Profil einrichten",
|
||||
"profileDescription": "Größe, Geburtsdatum und Geschlecht schalten personalisierte Zielwerte frei.",
|
||||
"profileCta": "Profil öffnen",
|
||||
"measurementTitle": "Erste Messung anlegen",
|
||||
"measurementDescription": "Eine Messung reicht, um deine erste Trend-Kachel zu zeichnen.",
|
||||
"measurementCta": "Messung hinzufügen",
|
||||
"medicationTitle": "Medikament hinzufügen",
|
||||
"medicationDescription": "Verfolge Compliance und erhalte Erinnerungen genau dann, wenn du sie brauchst.",
|
||||
"medicationCta": "Medikament hinzufügen",
|
||||
"withingsTitle": "Withings verbinden (optional)",
|
||||
"withingsDescription": "Synchronisiere Gewicht, Blutdruck und Puls automatisch aus deinen Withings-Geräten.",
|
||||
"withingsCta": "Verbinden",
|
||||
"notificationsTitle": "Benachrichtigungen einrichten",
|
||||
"notificationsDescription": "Wähle, wie HealthLog dich erinnert — Telegram, ntfy oder Web Push.",
|
||||
"notificationsCta": "Benachrichtigungen öffnen"
|
||||
}
|
||||
},
|
||||
"trendHints": {
|
||||
"title": "Erster Trend nach 5 Messungen",
|
||||
"remainingOne": "Noch 1 Messung bis zum Trend.",
|
||||
"remainingMany": "Noch {count} Messungen bis zum Trend."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Einstellungen",
|
||||
"subtitle": "Profil, Sicherheit & Verbindungen",
|
||||
"shell": {
|
||||
"sectionsNav": "Einstellungs-Bereiche"
|
||||
},
|
||||
"sections": {
|
||||
"account": {
|
||||
"title": "Konto",
|
||||
"description": "Profil, Anmeldemethoden und Konto-Löschung."
|
||||
},
|
||||
"integrations": {
|
||||
"title": "Integrationen",
|
||||
"description": "Withings, moodLog und weitere verbundene Dienste."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Benachrichtigungen",
|
||||
"description": "Telegram, ntfy, Web Push."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Übersicht",
|
||||
"description": "Kachel-Layout, Schwellwerte."
|
||||
},
|
||||
"ai": {
|
||||
"title": "KI-Auswertungen",
|
||||
"description": "Provider, Modell, API-Key."
|
||||
},
|
||||
"api": {
|
||||
"title": "API & Tokens",
|
||||
"description": "Bearer-Tokens für Drittanbieter-Clients."
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Erweitert",
|
||||
"description": "Export, Import, Daten löschen."
|
||||
},
|
||||
"about": {
|
||||
"title": "Über",
|
||||
"description": "Version, Lizenz, Links."
|
||||
},
|
||||
"placeholder": {
|
||||
"coming_soon": "Wird mit dem nächsten Release-Update hierher umziehen."
|
||||
}
|
||||
},
|
||||
"sectionsTitle": "Settings",
|
||||
"categoryGeneral": "Allgemein",
|
||||
"categoryNotifications": "Benachrichtigungen",
|
||||
@@ -1069,7 +1153,41 @@
|
||||
"moodLogSyncResult": "{count} Einträge synchronisiert",
|
||||
"moodLogSyncFailed": "Sync fehlgeschlagen",
|
||||
"moodLogSaved": "moodLog-Verbindung gespeichert",
|
||||
"moodLogDisconnected": "moodLog getrennt"
|
||||
"moodLogDisconnected": "moodLog getrennt",
|
||||
"about": {
|
||||
"version": "App-Version",
|
||||
"gitSha": "Build",
|
||||
"builtAt": "Erstellt {time}",
|
||||
"license": "Lizenz",
|
||||
"repository": "Quellcode",
|
||||
"changelog": "Changelog",
|
||||
"docs": "Dokumentation",
|
||||
"checkUpdates": "Auf Updates prüfen",
|
||||
"upToDate": "Du nutzt die aktuelle Version",
|
||||
"newerAvailable": "Neue Version: {tag}"
|
||||
},
|
||||
"testConnection": {
|
||||
"test": "Verbindung testen",
|
||||
"testing": "Teste…",
|
||||
"ok": "Verbunden (Latenz {latency} ms)",
|
||||
"errors": {
|
||||
"credentials_rejected": "Zugangsdaten abgelehnt — Token prüfen",
|
||||
"rate_limited": "Vom Upstream rate-limitiert",
|
||||
"timeout": "Request hat das Zeitlimit überschritten",
|
||||
"upstream_error": "Upstream hat einen Fehler gemeldet",
|
||||
"connection_failed": "Verbindung fehlgeschlagen",
|
||||
"not_configured": "Nicht konfiguriert",
|
||||
"url_not_public": "URL ist kein öffentlicher Endpunkt",
|
||||
"url_invalid": "URL ist ungültig",
|
||||
"redirected": "Endpunkt leitet weiter — URL prüfen",
|
||||
"endpoint_not_found": "Endpunkt unter der URL nicht gefunden",
|
||||
"credentials_unreadable": "Zugangsdaten nicht entschlüsselbar",
|
||||
"upstream_invalid_json": "Upstream-Antwort ist kein JSON",
|
||||
"vapid_not_configured": "Web Push nicht konfiguriert (VAPID-Keys fehlen)",
|
||||
"rate_limited_self": "Zu viele Test-Anfragen",
|
||||
"generic": "Test fehlgeschlagen"
|
||||
}
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
@@ -1103,6 +1221,8 @@
|
||||
"telegramGlobal": "Telegram-Benachrichtigungen systemweit erlauben.",
|
||||
"ntfyGlobal": "ntfy-Benachrichtigungen systemweit erlauben.",
|
||||
"webPushGlobal": "Browser-Push-Benachrichtigungen systemweit erlauben.",
|
||||
"integrationWebPush": "Web Push",
|
||||
"integrationBugReport": "Bug-Reports",
|
||||
"apiGlobal": "API-Endpunkte und API-Tokens systemweit erlauben.",
|
||||
"moodLogGlobal": "moodLog-Integration systemweit erlauben.",
|
||||
"sectionGeneral": "Allgemein",
|
||||
@@ -1455,7 +1575,6 @@
|
||||
"subtitle": "Sende eine Fehlermeldung, einen Feature-Wunsch oder eine Frage. Der Server-Administrator sieht deine Einreichung.",
|
||||
"loginRequired": "Bitte anmelden, um einen Bug zu melden.",
|
||||
"bugTitle": "Titel",
|
||||
"bugTitlePlaceholder": "Kurze Beschreibung des Problems",
|
||||
"description": "Beschreibung",
|
||||
"descriptionPlaceholder": "Beschreibe den Fehler möglichst genau. Was hast du getan? Was ist passiert? Was hättest du erwartet?",
|
||||
"screenshot": "Screenshot (optional)",
|
||||
@@ -1621,6 +1740,10 @@
|
||||
"profileIncompleteAge": "Trage dein Geburtsdatum im Profil ein, um altersspezifische Ziele zu aktivieren.",
|
||||
"profileIncompleteHeightAge": "Trage Körpergröße und Geburtsdatum im Profil ein, um personalisierte Ziele zu sehen.",
|
||||
"compliancePerMedication": "Einnahmetreue pro Medikament",
|
||||
"glucoseFasting": "Glukose — nüchtern",
|
||||
"glucosePostprandial": "Glukose — postprandial",
|
||||
"glucoseRandom": "Glukose — beliebig",
|
||||
"glucoseBedtime": "Glukose — vor dem Schlafen",
|
||||
"loadError": "Zielwerte konnten nicht geladen werden"
|
||||
},
|
||||
"passwordStrength": {
|
||||
@@ -1654,5 +1777,20 @@
|
||||
"medicationNotFoundExact": "Medikament nicht gefunden. Bitte Namen exakt wie in HealthLog senden.",
|
||||
"helpHeader": "<b>Verfügbare Befehle:</b>",
|
||||
"helpBody": "/help — Diese Hilfe anzeigen\n/add — Einnahme erfassen\n/start — Bot starten\n\n<b>Textbefehle:</b>\ngenommen <Name> — Einnahme bestätigen\n\n<b>Über die Buttons in Erinnerungen:</b>\n• Genommen — Einnahme bestätigen\n• 🕐 1h / 🕐 3h — Erinnerung verschieben\n• ⏭ Überspringen — Einnahme überspringen\n• ✓ Bestätigen — Verpasste Einnahme bestätigen"
|
||||
},
|
||||
"medicationReminders": {
|
||||
"phaseGreenTitle": "🟢 Erinnerung: {medName}",
|
||||
"phaseGreenMessage": "🟢 Erinnerung:\n<b>{medName}</b> ({doseInfo}, {timeWindow})\nZeitfenster endet in {minutes} Min.",
|
||||
"phaseYellowTitle": "🟡 Bald fällig: {medName}",
|
||||
"phaseYellowMessage": "🟡 Bald fällig:\n<b>{medName}</b> ({doseInfo}, {timeWindow})\nNoch {minutes} Min. Zeit.",
|
||||
"phaseOrangeTitle": "🟠 Überfällig: {medName}",
|
||||
"phaseOrangeMessage": "🟠 Überfällig:\n<b>{medName}</b> ({doseInfo}, {timeWindow})\nSeit {minutes} Min. überfällig.",
|
||||
"phaseRedTitle": "🔴 Verpasst: {medName}",
|
||||
"phaseRedMessage": "🔴 Verpasst:\n<b>{medName}</b> ({doseInfo}, {timeWindow})\nAls verpasst markiert.",
|
||||
"buttonTaken": "Genommen",
|
||||
"buttonConfirm": "✓ Bestätigen",
|
||||
"buttonSnoozeOneHour": "🕐 1h",
|
||||
"buttonSnoozeThreeHours": "🕐 3h",
|
||||
"buttonSkip": "⏭ Überspringen"
|
||||
}
|
||||
}
|
||||
+187
-49
@@ -62,7 +62,7 @@
|
||||
"colMin": "Min",
|
||||
"colMax": "Max",
|
||||
"colN": "n",
|
||||
"bpClassificationTitle": "Blood pressure — ESC/ESH classification (2018)",
|
||||
"bpClassificationTitle": "Blood pressure — ESH classification (2023)",
|
||||
"avgBp": "Average blood pressure",
|
||||
"classification": "Classification",
|
||||
"bmiTitle": "Body Mass Index (BMI)",
|
||||
@@ -89,6 +89,9 @@
|
||||
"typeBodyFat": "Body fat",
|
||||
"typeSleep": "Sleep duration",
|
||||
"typeSteps": "Activity (steps)",
|
||||
"typeTotalBodyWater": "Total body water",
|
||||
"typeBoneMass": "Bone mass",
|
||||
"typeOxygenSaturation": "Oxygen saturation",
|
||||
"unitSteps": "steps",
|
||||
"bmiUnderweight": "Underweight",
|
||||
"bmiNormal": "Normal weight",
|
||||
@@ -191,12 +194,16 @@
|
||||
},
|
||||
"welcomeBack": "{greeting}, welcome back.",
|
||||
"welcomeBackWithName": "{greeting} {name}, welcome back.",
|
||||
"greetingSalutation": "Hi, {name}",
|
||||
"streakLabel": "days in a row",
|
||||
"weight": "Weight",
|
||||
"bloodPressure": "Blood Pressure",
|
||||
"bloodPressureSys": "Blood Pressure (Sys)",
|
||||
"bloodPressureDia": "Blood Pressure (Dia)",
|
||||
"pulse": "Pulse",
|
||||
"bodyFat": "Body Fat",
|
||||
"sleep": "Sleep",
|
||||
"steps": "Steps",
|
||||
"bpInTarget": "BP in Target",
|
||||
"days30Short": "30d",
|
||||
"medications": "Medications",
|
||||
@@ -243,6 +250,9 @@
|
||||
"typeSleep": "Sleep",
|
||||
"typeSteps": "Steps",
|
||||
"typeBloodGlucose": "Blood glucose",
|
||||
"typeTotalBodyWater": "Total body water",
|
||||
"typeBoneMass": "Bone mass",
|
||||
"typeOxygenSaturation": "Oxygen saturation",
|
||||
"glucoseContext": "Context",
|
||||
"glucoseContextFasting": "Fasting (≥8h without food)",
|
||||
"glucoseContextPostprandial": "Post-meal (2h after eating)",
|
||||
@@ -532,6 +542,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",
|
||||
@@ -589,6 +602,10 @@
|
||||
"refreshAnalysis": "Refresh analysis",
|
||||
"keyTakeaway": "Key Takeaway",
|
||||
"findingsTitle": "Findings",
|
||||
"heroFindingPositive": "Top finding",
|
||||
"heroFindingNeutral": "Top finding",
|
||||
"heroFindingAttention": "Worth attention",
|
||||
"heroFindingWarning": "Top alert",
|
||||
"correlationsTitle": "Correlations",
|
||||
"recommendationsTitle": "Recommendations",
|
||||
"dataFoundation": "Data coverage",
|
||||
@@ -617,7 +634,7 @@
|
||||
"inTargetRange": "in target range",
|
||||
"bpTarget": "Target: {range}",
|
||||
"noDobSet": "Date of birth not set.",
|
||||
"avg30dEsc": "30d average · ESC/ESH 2018",
|
||||
"avg30dEsc": "30d average · ESH 2023",
|
||||
"whoClassification": "WHO Classification",
|
||||
"correlations": "Correlations",
|
||||
"weightVsBp": "Weight vs. Blood Pressure (Systolic)",
|
||||
@@ -754,53 +771,12 @@
|
||||
"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"
|
||||
},
|
||||
"targets": {
|
||||
"title": "Targets",
|
||||
"subtitle": "Personal reference ranges based on your profile",
|
||||
"introText": "These targets are personal reference ranges derived from your profile and established medical guidelines. They help you quickly classify your key health values and detect changes early. The ranges are calculated individually for you, for example based on age, sex, and height. For each value, you can immediately see whether you are within the target range or above or below it. This gives you a compact overview of which targets you are currently meeting well and where improvement is still possible.",
|
||||
"current": "Current",
|
||||
"average30d": "30-day average:",
|
||||
"targetRange": "Target range",
|
||||
"classification": "Rating",
|
||||
"source": "Source",
|
||||
"sourceLabel": "Source: {source}",
|
||||
"trend": "Trend",
|
||||
"weight": "Weight",
|
||||
"bloodPressure": "Blood Pressure",
|
||||
"pulse": "Resting Pulse",
|
||||
"sleep": "Sleep Duration",
|
||||
"bmi": "BMI",
|
||||
"bodyFat": "Body Fat",
|
||||
"steps": "Steps/Day",
|
||||
"compliance": "Compliance",
|
||||
"compliancePerMedication": "Per medication",
|
||||
"diastolic": "Diastolic",
|
||||
"noProfile": "Please complete your profile to get personalized targets.",
|
||||
"profileIncomplete": "Profile incomplete",
|
||||
"profileIncompleteHeightAge": "Add your height and date of birth in your profile to get personalized target ranges.",
|
||||
"profileIncompleteHeight": "Add your height in your profile to calculate weight target range.",
|
||||
"profileIncompleteAge": "Add your date of birth in your profile to get age-based target ranges.",
|
||||
"noMeasurementData": "No measurement data available yet.",
|
||||
"noMeasurementYet": "No measurement available yet",
|
||||
"currentValue": "Current: {value} {unit}",
|
||||
"targetRangeValue": "Target range: {min}–{max} {unit}",
|
||||
"inTarget": "In target range",
|
||||
"belowTarget": "{delta} {unit} below target",
|
||||
"aboveTarget": "{delta} {unit} above target",
|
||||
"loadError": "Error loading data",
|
||||
"classificationOptimal": "Optimal",
|
||||
"classificationNormal": "Normal",
|
||||
"classificationElevated": "Elevated",
|
||||
"classificationHigh": "High",
|
||||
"classificationLow": "Low",
|
||||
"glucoseFasting": "Glucose — fasting",
|
||||
"glucosePostprandial": "Glucose — post-meal",
|
||||
"glucoseRandom": "Glucose — random",
|
||||
"glucoseBedtime": "Glucose — bedtime"
|
||||
},
|
||||
"onboarding": {
|
||||
"welcome": "Welcome to HealthLog!",
|
||||
"setupDescription": "Set up your profile to get started. You can change this later in Settings.",
|
||||
@@ -824,11 +800,119 @@
|
||||
"errorMedication": "Could not add the medication \"{name}\". Please try again or add it later in Medications.",
|
||||
"errorTargets": "Could not save your target values. Please try again.",
|
||||
"errorGeneric": "Something went wrong while finishing setup. Please try again.",
|
||||
"medScheduleHint": "We'll add a default reminder window (08:00–09:00 daily). You can adjust it anytime in Medications."
|
||||
"medScheduleHint": "We'll add a default reminder window (08:00–09:00 daily). You can adjust it anytime in Medications.",
|
||||
"v2": {
|
||||
"title": "Welcome to HealthLog",
|
||||
"subtitle": "Three quick steps. You can finish anything later from Settings.",
|
||||
"stepOf": "Step {current} of {total}",
|
||||
"back": "Back",
|
||||
"continue": "Continue",
|
||||
"finish": "Finish setup",
|
||||
"skipStep": "Skip this step — you can finish setup later from Settings.",
|
||||
"step1": {
|
||||
"title": "About you",
|
||||
"description": "Used for blood-pressure targets, BMI, and the right reminders.",
|
||||
"displayNameLabel": "Display name",
|
||||
"displayNameHint": "Shown on the dashboard greeting and in reports.",
|
||||
"languageLabel": "Language",
|
||||
"languageHint": "We'll use this for the app, PDFs, and notifications."
|
||||
},
|
||||
"step2": {
|
||||
"title": "Log your first measurement",
|
||||
"description": "Weight, blood pressure, or pulse — whatever you have right now. The dashboard comes alive with one entry.",
|
||||
"added": "Got it — you can add more anytime from the dashboard.",
|
||||
"skipHint": "No measurement at hand? Skip and log one when you're ready."
|
||||
},
|
||||
"step3": {
|
||||
"title": "Pick one notification channel",
|
||||
"description": "We'll only send what you ask for. You can add more channels or fine-tune events later.",
|
||||
"channelTelegramTitle": "Telegram bot",
|
||||
"channelTelegramHint": "Reminders with snooze buttons inside Telegram.",
|
||||
"channelWebPushTitle": "Web Push",
|
||||
"channelWebPushHint": "Native browser notifications on this device.",
|
||||
"channelNtfyTitle": "ntfy.sh",
|
||||
"channelNtfyHint": "Open-source push to phone or desktop, no account.",
|
||||
"channelNoneTitle": "Skip for now",
|
||||
"channelNoneHint": "I'll set up notifications later from Settings.",
|
||||
"openSettings": "Continue in Settings"
|
||||
},
|
||||
"doneToast": "You're all set — welcome aboard."
|
||||
}
|
||||
},
|
||||
"gettingStarted": {
|
||||
"title": "Getting started",
|
||||
"subtitle": "Quick wins to get the most out of HealthLog.",
|
||||
"progress": "{done} of {total} done",
|
||||
"dismiss": "Dismiss",
|
||||
"dismissAll": "Hide checklist",
|
||||
"dismissTooltip": "Hide this item",
|
||||
"restoreHint": "You can restore the checklist from Settings.",
|
||||
"items": {
|
||||
"profileTitle": "Set your profile",
|
||||
"profileDescription": "Height, date of birth and gender unlock personalised targets.",
|
||||
"profileCta": "Open profile",
|
||||
"measurementTitle": "Log your first measurement",
|
||||
"measurementDescription": "One reading is enough to draw your first trend tile.",
|
||||
"measurementCta": "Add measurement",
|
||||
"medicationTitle": "Add a medication",
|
||||
"medicationDescription": "Track compliance and get reminders right when you need them.",
|
||||
"medicationCta": "Add medication",
|
||||
"withingsTitle": "Connect Withings (optional)",
|
||||
"withingsDescription": "Auto-sync weight, BP and pulse from your Withings devices.",
|
||||
"withingsCta": "Connect",
|
||||
"notificationsTitle": "Configure notifications",
|
||||
"notificationsDescription": "Choose how HealthLog should remind you — Telegram, ntfy or Web Push.",
|
||||
"notificationsCta": "Open notifications"
|
||||
}
|
||||
},
|
||||
"trendHints": {
|
||||
"title": "First trend after 5 readings",
|
||||
"remainingOne": "1 more reading to go.",
|
||||
"remainingMany": "{count} more readings to go."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"subtitle": "Profile, Security & Connections",
|
||||
"shell": {
|
||||
"sectionsNav": "Settings sections"
|
||||
},
|
||||
"sections": {
|
||||
"account": {
|
||||
"title": "Account",
|
||||
"description": "Profile, sign-in methods, and account deletion."
|
||||
},
|
||||
"integrations": {
|
||||
"title": "Integrations",
|
||||
"description": "Withings, moodLog, and other connected services."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"description": "Telegram, ntfy, Web Push."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"description": "Tile layout, thresholds."
|
||||
},
|
||||
"ai": {
|
||||
"title": "AI Insights",
|
||||
"description": "Provider, model, key."
|
||||
},
|
||||
"api": {
|
||||
"title": "API & Tokens",
|
||||
"description": "Bearer tokens for headless clients."
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Advanced",
|
||||
"description": "Export, import, danger zone."
|
||||
},
|
||||
"about": {
|
||||
"title": "About",
|
||||
"description": "Version, license, links."
|
||||
},
|
||||
"placeholder": {
|
||||
"coming_soon": "Moving here with the next release update."
|
||||
}
|
||||
},
|
||||
"sectionsTitle": "Settings",
|
||||
"categoryGeneral": "General",
|
||||
"categoryNotifications": "Notifications",
|
||||
@@ -1069,7 +1153,41 @@
|
||||
"moodLogSyncResult": "{count} entries synchronized",
|
||||
"moodLogSyncFailed": "Sync failed",
|
||||
"moodLogSaved": "moodLog connection saved",
|
||||
"moodLogDisconnected": "moodLog disconnected"
|
||||
"moodLogDisconnected": "moodLog disconnected",
|
||||
"about": {
|
||||
"version": "App version",
|
||||
"gitSha": "Build",
|
||||
"builtAt": "Built {time}",
|
||||
"license": "License",
|
||||
"repository": "Source code",
|
||||
"changelog": "Changelog",
|
||||
"docs": "Documentation",
|
||||
"checkUpdates": "Check for updates",
|
||||
"upToDate": "You are up to date",
|
||||
"newerAvailable": "Newer version: {tag}"
|
||||
},
|
||||
"testConnection": {
|
||||
"test": "Test connection",
|
||||
"testing": "Testing…",
|
||||
"ok": "Connected (latency {latency} ms)",
|
||||
"errors": {
|
||||
"credentials_rejected": "Credentials rejected — check your token",
|
||||
"rate_limited": "Rate-limited by the upstream",
|
||||
"timeout": "Request timed out",
|
||||
"upstream_error": "Upstream returned an error",
|
||||
"connection_failed": "Connection failed",
|
||||
"not_configured": "Not configured",
|
||||
"url_not_public": "URL is not a public endpoint",
|
||||
"url_invalid": "URL is invalid",
|
||||
"redirected": "Endpoint redirects — check the URL",
|
||||
"endpoint_not_found": "Endpoint not found at the URL",
|
||||
"credentials_unreadable": "Credentials cannot be decrypted",
|
||||
"upstream_invalid_json": "Upstream sent invalid JSON",
|
||||
"vapid_not_configured": "Web Push not configured (VAPID keys missing)",
|
||||
"rate_limited_self": "Too many test requests",
|
||||
"generic": "Test failed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
@@ -1103,6 +1221,8 @@
|
||||
"telegramGlobal": "Allow Telegram notifications system-wide.",
|
||||
"ntfyGlobal": "Allow ntfy notifications system-wide.",
|
||||
"webPushGlobal": "Allow browser push notifications system-wide.",
|
||||
"integrationWebPush": "Web Push",
|
||||
"integrationBugReport": "Bug Report",
|
||||
"apiGlobal": "Allow API endpoints and API tokens system-wide.",
|
||||
"moodLogGlobal": "Allow moodLog integration system-wide.",
|
||||
"sectionGeneral": "General",
|
||||
@@ -1455,7 +1575,6 @@
|
||||
"subtitle": "Send a bug report, feature request, or question. The server administrator will see your submission.",
|
||||
"loginRequired": "Please sign in to report a bug.",
|
||||
"bugTitle": "Title",
|
||||
"bugTitlePlaceholder": "Brief description of the problem",
|
||||
"description": "Description",
|
||||
"descriptionPlaceholder": "Describe the bug in detail. What did you do? What happened? What did you expect?",
|
||||
"screenshot": "Screenshot (optional)",
|
||||
@@ -1621,6 +1740,10 @@
|
||||
"profileIncompleteAge": "Add your date of birth in profile settings for age-specific targets.",
|
||||
"profileIncompleteHeightAge": "Add your height and date of birth in profile settings for personalized targets.",
|
||||
"compliancePerMedication": "Compliance per medication",
|
||||
"glucoseFasting": "Glucose — fasting",
|
||||
"glucosePostprandial": "Glucose — post-meal",
|
||||
"glucoseRandom": "Glucose — random",
|
||||
"glucoseBedtime": "Glucose — bedtime",
|
||||
"loadError": "Failed to load targets"
|
||||
},
|
||||
"passwordStrength": {
|
||||
@@ -1654,5 +1777,20 @@
|
||||
"medicationNotFoundExact": "Medication not found. Please send the name exactly as in HealthLog.",
|
||||
"helpHeader": "<b>Available commands:</b>",
|
||||
"helpBody": "/help — show this help\n/add — record an intake\n/start — start the bot\n\n<b>Text commands:</b>\ntaken <name> — confirm an intake\n\n<b>Buttons in reminders:</b>\n• Taken — confirm intake\n• 🕐 1h / 🕐 3h — postpone reminder\n• ⏭ Skip — skip intake\n• ✓ Confirm — confirm a missed intake"
|
||||
},
|
||||
"medicationReminders": {
|
||||
"phaseGreenTitle": "🟢 Reminder: {medName}",
|
||||
"phaseGreenMessage": "🟢 Reminder:\n<b>{medName}</b> ({doseInfo}, {timeWindow})\nWindow closes in {minutes} min.",
|
||||
"phaseYellowTitle": "🟡 Due soon: {medName}",
|
||||
"phaseYellowMessage": "🟡 Due soon:\n<b>{medName}</b> ({doseInfo}, {timeWindow})\n{minutes} min left.",
|
||||
"phaseOrangeTitle": "🟠 Overdue: {medName}",
|
||||
"phaseOrangeMessage": "🟠 Overdue:\n<b>{medName}</b> ({doseInfo}, {timeWindow})\nOverdue by {minutes} min.",
|
||||
"phaseRedTitle": "🔴 Missed: {medName}",
|
||||
"phaseRedMessage": "🔴 Missed:\n<b>{medName}</b> ({doseInfo}, {timeWindow})\nMarked as missed.",
|
||||
"buttonTaken": "Taken",
|
||||
"buttonConfirm": "✓ Confirm",
|
||||
"buttonSnoozeOneHour": "🕐 1h",
|
||||
"buttonSnoozeThreeHours": "🕐 3h",
|
||||
"buttonSkip": "⏭ Skip"
|
||||
}
|
||||
}
|
||||
+19
-9
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.2.1",
|
||||
"version": "1.4.2",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.31.0",
|
||||
"scripts": {
|
||||
@@ -17,50 +17,60 @@
|
||||
"format:check": "prettier --check .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
"test:watch": "vitest",
|
||||
"test:integration": "vitest run --config vitest.integration.config.mts",
|
||||
"e2e": "playwright test",
|
||||
"e2e:ui": "playwright test --ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1045.0",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"@prisma/adapter-pg": "^7.8.0",
|
||||
"@prisma/client": "^7.8.0",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@simplewebauthn/server": "^13.3.0",
|
||||
"@tanstack/react-query": "^5.100.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": {
|
||||
"@axe-core/playwright": "^4.11.3",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@testcontainers/postgresql": "^11.14.0",
|
||||
"@types/node": "^25",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
"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",
|
||||
"testcontainers": "^11.14.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^6",
|
||||
"vitest": "^4.1.5"
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Playwright configuration for the HealthLog E2E suite.
|
||||
*
|
||||
* The suite covers the smoke-level user paths (auth redirect, login
|
||||
* form, public version endpoint, locale switch, axe-core) without
|
||||
* needing seeded data — every spec either runs against an unauthed
|
||||
* surface or uses route interception to stub out the API. Specs that
|
||||
* need a logged-in user are kept narrow and flagged in their describe
|
||||
* block; CI runs them against a worker that seeds a deterministic test
|
||||
* user on startup.
|
||||
*
|
||||
* To run locally: `pnpm dlx playwright install --with-deps chromium`
|
||||
* once, then `pnpm e2e`.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
timeout: 30_000,
|
||||
expect: { timeout: 5_000 },
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
workers: process.env.CI ? 2 : undefined,
|
||||
reporter: process.env.CI
|
||||
? [["github"], ["html", { open: "never" }]]
|
||||
: [["list"], ["html", { open: "never" }]],
|
||||
|
||||
use: {
|
||||
baseURL: process.env.E2E_BASE_URL ?? "http://localhost:3000",
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
video: "retain-on-failure",
|
||||
},
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: "chromium-desktop",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
viewport: { width: 1280, height: 720 },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "chromium-mobile",
|
||||
use: {
|
||||
...devices["iPhone 13"],
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
// Spin up the production build for E2E. `pnpm build` is run
|
||||
// separately by CI before the suite — locally, set E2E_SKIP_WEB_SERVER=1
|
||||
// to point at an already-running dev server.
|
||||
webServer: process.env.E2E_SKIP_WEB_SERVER
|
||||
? undefined
|
||||
: {
|
||||
command: "pnpm exec next start --port 3000",
|
||||
url: "http://localhost:3000/api/version",
|
||||
timeout: 60_000,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
stdout: "ignore",
|
||||
stderr: "pipe",
|
||||
},
|
||||
});
|
||||
Generated
+2594
-133
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,17 @@
|
||||
-- v1.3: Add body-composition measurement types
|
||||
--
|
||||
-- Closes #89: total body water and bone mass as first-class measurement types.
|
||||
-- Both are reported by Withings body-composition scales and can also be ingested
|
||||
-- via the API by external pipelines (e.g. n8n + Health Connect).
|
||||
--
|
||||
-- Storage units (canonical, both kg — matches VALUE_RANGES in
|
||||
-- src/lib/validations/measurement.ts and Withings client type-77/type-88
|
||||
-- mapping). Adult plausibility ranges: water 30–55 kg, bone 2.5–4.5 kg.
|
||||
-- TOTAL_BODY_WATER: kilograms (kg) — total body water content
|
||||
-- BONE_MASS: kilograms (kg) — DEXA-equivalent bone mineral content
|
||||
--
|
||||
-- Both are unconditional value types — no companion enum (unlike BLOOD_GLUCOSE,
|
||||
-- which carries a glucose_context). The migration is purely additive.
|
||||
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'TOTAL_BODY_WATER';
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'BONE_MASS';
|
||||
@@ -0,0 +1,55 @@
|
||||
-- v1.3: iOS adapter endpoints + idempotency cache + device registration
|
||||
--
|
||||
-- Purely additive:
|
||||
-- • users.display_name, users.healthkit_config_json, users.healthkit_last_synced_at
|
||||
-- • new tables: idempotency_keys, devices
|
||||
|
||||
-- ── users new columns ─────────────────────────────────────────────────
|
||||
|
||||
ALTER TABLE "users"
|
||||
ADD COLUMN "display_name" TEXT,
|
||||
ADD COLUMN "healthkit_config_json" JSONB,
|
||||
ADD COLUMN "healthkit_last_synced_at" TIMESTAMP(3);
|
||||
|
||||
-- ── idempotency_keys ──────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE "idempotency_keys" (
|
||||
"id" TEXT PRIMARY KEY,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
"method" TEXT NOT NULL,
|
||||
"path" TEXT NOT NULL,
|
||||
"response_status" INTEGER NOT NULL,
|
||||
"response_body" TEXT NOT NULL,
|
||||
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "idempotency_keys_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "idempotency_keys_user_id_key_method_path_key"
|
||||
ON "idempotency_keys" ("user_id", "key", "method", "path");
|
||||
|
||||
CREATE INDEX "idempotency_keys_expires_at_idx"
|
||||
ON "idempotency_keys" ("expires_at");
|
||||
|
||||
-- ── devices ───────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE "devices" (
|
||||
"id" TEXT PRIMARY KEY,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"platform" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"bundle_id" TEXT NOT NULL,
|
||||
"locale" TEXT,
|
||||
"app_version" TEXT,
|
||||
"model" TEXT,
|
||||
"last_seen" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "devices_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "devices_token_key" ON "devices" ("token");
|
||||
CREATE INDEX "devices_user_id_idx" ON "devices" ("user_id");
|
||||
@@ -0,0 +1,12 @@
|
||||
-- v1.3.3: Add OXYGEN_SATURATION (SpO2) measurement type
|
||||
--
|
||||
-- Closes #109 feature request. Single-value type (no per-context split like
|
||||
-- BLOOD_GLUCOSE — context goes into the free-text `notes` field).
|
||||
--
|
||||
-- Storage unit: percent (0..100). Plausibility range 50..100 in
|
||||
-- src/lib/validations/measurement.ts (anything below 50% is incompatible with
|
||||
-- sustained life and almost certainly a faulty sensor).
|
||||
--
|
||||
-- Withings devices that support SpO2 (e.g. ScanWatch) report measure type 54.
|
||||
-- See src/lib/withings/client.ts MEASURE_TYPE_MAP.
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'OXYGEN_SATURATION';
|
||||
@@ -0,0 +1,47 @@
|
||||
-- 0025_refresh_tokens
|
||||
-- Native-client short-lived access + rotating refresh tokens (v1.4 G4).
|
||||
-- Forward-compatible: this migration only ADDS a table. No DROP COLUMN, no
|
||||
-- type changes on existing tables. To roll back, the down migration in
|
||||
-- docs/ops/migrations/0025-down.sql DROPs the table.
|
||||
--
|
||||
-- All statements use IF NOT EXISTS so a partial-deploy retry is a no-op
|
||||
-- instead of "relation already exists". Wrapped in a single transaction
|
||||
-- so a mid-statement failure leaves no half-built table behind.
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "refresh_tokens" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"token_hash" TEXT NOT NULL,
|
||||
"device_id" TEXT,
|
||||
"access_token_hash" TEXT,
|
||||
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"revoked_at" TIMESTAMP(3),
|
||||
"used_at" TIMESTAMP(3),
|
||||
"replaced_by_id" TEXT,
|
||||
"user_agent" TEXT,
|
||||
"ip_address" TEXT,
|
||||
CONSTRAINT "refresh_tokens_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "refresh_tokens_token_hash_key" ON "refresh_tokens"("token_hash");
|
||||
CREATE INDEX IF NOT EXISTS "refresh_tokens_user_id_idx" ON "refresh_tokens"("user_id");
|
||||
CREATE INDEX IF NOT EXISTS "refresh_tokens_user_id_device_id_idx" ON "refresh_tokens"("user_id", "device_id");
|
||||
CREATE INDEX IF NOT EXISTS "refresh_tokens_expires_at_idx" ON "refresh_tokens"("expires_at");
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE constraint_name = 'refresh_tokens_user_id_fkey'
|
||||
) THEN
|
||||
ALTER TABLE "refresh_tokens"
|
||||
ADD CONSTRAINT "refresh_tokens_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,14 @@
|
||||
-- 0025_user_locale_drift_fix
|
||||
-- The `users.locale` column has been on `prisma/schema.prisma` since the
|
||||
-- locale-aware reminder work in v1.3 but never landed in the migration
|
||||
-- history (it must have been applied via `prisma db push` to dev / prod
|
||||
-- but not committed as a migration). The schema drift is invisible until
|
||||
-- a fresh database is built from migrations alone — at which point the
|
||||
-- Prisma client tries to read `users.locale` and Postgres reports
|
||||
-- `column users.locale does not exist`.
|
||||
--
|
||||
-- This migration backfills the column with `IF NOT EXISTS` so it is a
|
||||
-- safe no-op against any database that was kept in sync via `db push`,
|
||||
-- and a clean add against any environment built strictly from this
|
||||
-- migrations directory (CI test containers, fresh deploys, etc.).
|
||||
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "locale" TEXT;
|
||||
@@ -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[]
|
||||
@@ -82,6 +90,7 @@ model User {
|
||||
medications Medication[]
|
||||
intakeEvents MedicationIntakeEvent[]
|
||||
apiTokens ApiToken[]
|
||||
refreshTokens RefreshToken[]
|
||||
auditLogs AuditLog[]
|
||||
withingsConnection WithingsConnection?
|
||||
moodEntries MoodEntry[]
|
||||
@@ -90,6 +99,8 @@ model User {
|
||||
dataBackups DataBackup[]
|
||||
achievements UserAchievement[]
|
||||
feedback Feedback[]
|
||||
idempotencyKeys IdempotencyKey[]
|
||||
devices Device[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@@ -150,6 +161,9 @@ enum MeasurementType {
|
||||
SLEEP_DURATION
|
||||
ACTIVITY_STEPS
|
||||
BLOOD_GLUCOSE // Canonical storage: mg/dL. Display unit (mg/dL or mmol/L) is user preference.
|
||||
TOTAL_BODY_WATER // Canonical storage: kilograms (kg of water). Matches Withings' hydration measure (type 77) and Health Connect's TotalBodyWaterRecord. Display can compute % from same-day weight if desired.
|
||||
BONE_MASS // Canonical storage: kilograms (kg). Matches Withings' bone mass measure (type 88).
|
||||
OXYGEN_SATURATION // Canonical storage: percent (0..100). Withings ScanWatch reports type 54. Health Connect's OxygenSaturationRecord uses Percentage (already 0..100). HealthKit uses HKQuantityTypeIdentifierOxygenSaturation as a fraction (0..1) — iOS DTO must multiply by 100 before submitting.
|
||||
|
||||
@@map("measurement_type")
|
||||
}
|
||||
@@ -344,6 +358,47 @@ model ApiToken {
|
||||
@@map("api_tokens")
|
||||
}
|
||||
|
||||
// ─── Refresh Tokens (native client short-lived access + rotating refresh) ─
|
||||
|
||||
model RefreshToken {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
tokenHash String @unique @map("token_hash")
|
||||
/// Optional device fingerprint — links the refresh token to a specific
|
||||
/// `Device` row when the caller advertised one (X-Device-Id header). Used
|
||||
/// to revoke all tokens issued for a stolen/lost device.
|
||||
deviceId String? @map("device_id")
|
||||
/// HMAC-SHA-256 (matching `ApiToken.tokenHash` format, keyed by
|
||||
/// `API_TOKEN_HMAC_KEY`) of the access token issued in the same response.
|
||||
/// When the refresh token is rotated we revoke the matching access token
|
||||
/// via `ApiToken.tokenHash`. Null if the access token was issued
|
||||
/// out-of-band.
|
||||
accessTokenHash String? @map("access_token_hash")
|
||||
expiresAt DateTime @map("expires_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
revokedAt DateTime? @map("revoked_at")
|
||||
/// When this token was used (one-time-use). Set during rotation; a second
|
||||
/// use of the same token is a hard fail and triggers reuse-detection.
|
||||
usedAt DateTime? @map("used_at")
|
||||
/// Pointer to the row that replaced this one during rotation. Helps audit
|
||||
/// trail and reuse-detection ("this token was already exchanged into X").
|
||||
replacedById String? @map("replaced_by_id")
|
||||
userAgent String? @map("user_agent")
|
||||
ipAddress String? @map("ip_address")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
/// Composite index — every reuse-detection / device-scoped lookup filters
|
||||
/// on (userId, deviceId), so a single-column device_id index would never
|
||||
/// be used while costing writes. Multiple live rows per (userId, deviceId)
|
||||
/// are intentional: rotated rows linger in `revokedAt != null` state for
|
||||
/// audit and reuse-detection.
|
||||
@@index([userId, deviceId])
|
||||
@@index([expiresAt])
|
||||
@@map("refresh_tokens")
|
||||
}
|
||||
|
||||
// ─── Withings ──────────────────────────────────────────────
|
||||
|
||||
model WithingsConnection {
|
||||
@@ -557,6 +612,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,128 @@
|
||||
/**
|
||||
* scripts/restore-backup.ts <s3-key> [output-file]
|
||||
*
|
||||
* Downloads a single off-host backup object, decrypts it with
|
||||
* `BACKUP_ENCRYPTION_KEY`, and writes the JSON dump to disk.
|
||||
*
|
||||
* Wire format (binary):
|
||||
* magic(4)="HLBK" || version(1)=0x01 || iv(12) || tag(16) || ciphertext
|
||||
*
|
||||
* Usage:
|
||||
* BACKUP_S3_ENDPOINT=... \
|
||||
* BACKUP_S3_BUCKET=... \
|
||||
* BACKUP_S3_ACCESS_KEY=... \
|
||||
* BACKUP_S3_SECRET_KEY=... \
|
||||
* BACKUP_S3_REGION=auto \
|
||||
* BACKUP_ENCRYPTION_KEY=<hex64> \
|
||||
* pnpm tsx scripts/restore-backup.ts 2026-05-08/user-clx123.json.enc /tmp/restored.json
|
||||
*/
|
||||
import "dotenv/config";
|
||||
import { writeFileSync } from "node:fs";
|
||||
import {
|
||||
loadOffhostConfig,
|
||||
getS3Client,
|
||||
decryptBackup,
|
||||
OffhostBackupNotConfiguredError,
|
||||
} from "@/lib/jobs/offhost-backup";
|
||||
|
||||
/**
|
||||
* Schema-validate the decrypted JSON before writing it to disk. The wire
|
||||
* format from `runOffhostBackup` always carries `userId` and the four data
|
||||
* arrays — anything else means the operator may be looking at a tampered or
|
||||
* mis-targeted object. We reject rather than silently writing a body that a
|
||||
* later import script would trust.
|
||||
*/
|
||||
function validateBackupShape(payload: unknown): {
|
||||
exportedAt: string;
|
||||
userId: string;
|
||||
measurements: unknown[];
|
||||
medications: unknown[];
|
||||
intakeEvents: unknown[];
|
||||
moodEntries: unknown[];
|
||||
} {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
throw new Error("backup payload is not a JSON object");
|
||||
}
|
||||
const p = payload as Record<string, unknown>;
|
||||
const required = [
|
||||
"exportedAt",
|
||||
"userId",
|
||||
"measurements",
|
||||
"medications",
|
||||
"intakeEvents",
|
||||
"moodEntries",
|
||||
] as const;
|
||||
for (const k of required) {
|
||||
if (!(k in p)) throw new Error(`backup is missing required field: ${k}`);
|
||||
}
|
||||
if (typeof p.exportedAt !== "string")
|
||||
throw new Error("backup.exportedAt must be string");
|
||||
if (typeof p.userId !== "string" || p.userId.length === 0)
|
||||
throw new Error("backup.userId must be a non-empty string");
|
||||
for (const k of [
|
||||
"measurements",
|
||||
"medications",
|
||||
"intakeEvents",
|
||||
"moodEntries",
|
||||
] as const) {
|
||||
if (!Array.isArray(p[k])) throw new Error(`backup.${k} must be an array`);
|
||||
}
|
||||
return p as ReturnType<typeof validateBackupShape>;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const key = process.argv[2];
|
||||
const out = process.argv[3] ?? "./restored.json";
|
||||
// Optional positional arg: --user-id=<id> requires the dump's `userId`
|
||||
// field to match. Defends against an operator pointing the script at the
|
||||
// wrong S3 key (e.g. another tenant's dump under a similar path) and
|
||||
// silently importing the wrong user's data downstream.
|
||||
const expectedUserId = process.argv
|
||||
.find((a) => a.startsWith("--user-id="))
|
||||
?.slice("--user-id=".length);
|
||||
if (!key) {
|
||||
console.error(
|
||||
"Usage: pnpm tsx scripts/restore-backup.ts <s3-key> [output-file] [--user-id=<id>]",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const cfg = loadOffhostConfig();
|
||||
if (!cfg) {
|
||||
throw new OffhostBackupNotConfiguredError(
|
||||
"Off-host backup is not configured. Set BACKUP_* env vars.",
|
||||
);
|
||||
}
|
||||
|
||||
const s3 = await getS3Client(cfg);
|
||||
console.log(
|
||||
`Downloading s3://${cfg.bucket}/${key} from ${cfg.endpoint} (region=${cfg.region})`,
|
||||
);
|
||||
const ciphertext = await s3.getObject(key);
|
||||
const plaintext = decryptBackup(ciphertext, cfg.encryptionKey);
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(plaintext);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Decrypted payload is not valid JSON: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
const body = validateBackupShape(parsed);
|
||||
if (expectedUserId && body.userId !== expectedUserId) {
|
||||
throw new Error(
|
||||
`Refusing to write: backup.userId='${body.userId}' does not match --user-id='${expectedUserId}'`,
|
||||
);
|
||||
}
|
||||
|
||||
writeFileSync(out, plaintext, "utf8");
|
||||
console.log(
|
||||
`Restored userId=${body.userId} (${plaintext.length} bytes) -> ${out}`,
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Restore failed:", err);
|
||||
process.exit(2);
|
||||
});
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* scripts/rotate-encryption-key.ts <new-key-id>
|
||||
*
|
||||
* Re-encrypts every encrypted column in the database with the currently
|
||||
* active key (`ENCRYPTION_ACTIVE_KEY_ID`). Idempotent: rows whose ciphertext
|
||||
* already carries the active key id prefix are skipped.
|
||||
*
|
||||
* Usage:
|
||||
* ENCRYPTION_KEYS='{"v1":"<old>","v2":"<new>"}' \
|
||||
* ENCRYPTION_ACTIVE_KEY_ID=v2 \
|
||||
* pnpm tsx scripts/rotate-encryption-key.ts v2
|
||||
*/
|
||||
import "dotenv/config";
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import { decrypt, encrypt, extractKeyId, getActiveKeyId } from "@/lib/crypto";
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
console.error("DATABASE_URL must be set");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const targetKeyId = process.argv[2] ?? getActiveKeyId();
|
||||
if (targetKeyId !== getActiveKeyId()) {
|
||||
console.error(
|
||||
`Refusing to rotate: argv key id '${targetKeyId}' does not match the ` +
|
||||
`currently active id '${getActiveKeyId()}'. Set ENCRYPTION_ACTIVE_KEY_ID first.`,
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const prisma = new PrismaClient({
|
||||
adapter: new PrismaPg({ connectionString: DATABASE_URL }),
|
||||
});
|
||||
|
||||
interface RotationResult {
|
||||
table: string;
|
||||
field: string;
|
||||
scanned: number;
|
||||
rotated: number;
|
||||
errors: number;
|
||||
}
|
||||
|
||||
function shouldRotate(value: string | null): boolean {
|
||||
if (!value) return false;
|
||||
const id = extractKeyId(value);
|
||||
// null = legacy/unversioned (rotate); else rotate if not already on active.
|
||||
return id !== getActiveKeyId();
|
||||
}
|
||||
|
||||
async function rotateField<T extends { id: string }>(
|
||||
table: string,
|
||||
field: string,
|
||||
rows: T[],
|
||||
fieldGetter: (row: T) => string | null,
|
||||
updater: (id: string, ciphertext: string) => Promise<void>,
|
||||
): Promise<RotationResult> {
|
||||
const result: RotationResult = {
|
||||
table,
|
||||
field,
|
||||
scanned: rows.length,
|
||||
rotated: 0,
|
||||
errors: 0,
|
||||
};
|
||||
for (const row of rows) {
|
||||
const v = fieldGetter(row);
|
||||
if (!shouldRotate(v)) continue;
|
||||
try {
|
||||
const re = encrypt(decrypt(v as string));
|
||||
await updater(row.id, re);
|
||||
result.rotated++;
|
||||
} catch (err) {
|
||||
result.errors++;
|
||||
console.error(
|
||||
`[${table}.${field}] row ${row.id}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`Rotating encrypted rows to active key id: ${targetKeyId}`);
|
||||
const results: RotationResult[] = [];
|
||||
|
||||
// ───── User table ─────
|
||||
const users = await prisma.user.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
codexAccessTokenEncrypted: true,
|
||||
codexRefreshTokenEncrypted: true,
|
||||
aiAnthropicKeyEncrypted: true,
|
||||
aiLocalKeyEncrypted: true,
|
||||
moodLogWebhookSecret: true,
|
||||
telegramBotToken: true,
|
||||
moodLogUrlEncrypted: true,
|
||||
moodLogApiKeyEncrypted: true,
|
||||
withingsClientIdEncrypted: true,
|
||||
withingsClientSecretEncrypted: true,
|
||||
},
|
||||
});
|
||||
|
||||
const userFields: Array<keyof (typeof users)[number]> = [
|
||||
"codexAccessTokenEncrypted",
|
||||
"codexRefreshTokenEncrypted",
|
||||
"aiAnthropicKeyEncrypted",
|
||||
"aiLocalKeyEncrypted",
|
||||
"moodLogWebhookSecret",
|
||||
"telegramBotToken",
|
||||
"moodLogUrlEncrypted",
|
||||
"moodLogApiKeyEncrypted",
|
||||
"withingsClientIdEncrypted",
|
||||
"withingsClientSecretEncrypted",
|
||||
];
|
||||
for (const field of userFields) {
|
||||
const r = await rotateField(
|
||||
"User",
|
||||
field as string,
|
||||
users,
|
||||
(u) => (u as Record<string, unknown>)[field as string] as string | null,
|
||||
async (id, ciphertext) => {
|
||||
await prisma.user.update({
|
||||
where: { id },
|
||||
data: { [field]: ciphertext } as Record<string, string>,
|
||||
});
|
||||
},
|
||||
);
|
||||
results.push(r);
|
||||
}
|
||||
|
||||
// ───── WithingsConnection table (accessToken / refreshToken) ─────
|
||||
const withings = await prisma.withingsConnection.findMany({
|
||||
select: { id: true, accessToken: true, refreshToken: true },
|
||||
});
|
||||
for (const field of ["accessToken", "refreshToken"] as const) {
|
||||
const r = await rotateField(
|
||||
"WithingsConnection",
|
||||
field,
|
||||
withings,
|
||||
(w) => w[field],
|
||||
async (id, ciphertext) => {
|
||||
await prisma.withingsConnection.update({
|
||||
where: { id },
|
||||
data: { [field]: ciphertext } as Record<string, string>,
|
||||
});
|
||||
},
|
||||
);
|
||||
results.push(r);
|
||||
}
|
||||
|
||||
// ───── AppSettings table (singleton typically) ─────
|
||||
const settings = await prisma.appSettings.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
adminAiKeyEncrypted: true,
|
||||
webPushVapidPrivateKeyEncrypted: true,
|
||||
githubIssueTokenEncrypted: true,
|
||||
},
|
||||
});
|
||||
const appFields = [
|
||||
"adminAiKeyEncrypted",
|
||||
"webPushVapidPrivateKeyEncrypted",
|
||||
"githubIssueTokenEncrypted",
|
||||
] as const;
|
||||
for (const field of appFields) {
|
||||
const r = await rotateField(
|
||||
"AppSettings",
|
||||
field,
|
||||
settings,
|
||||
(s) => s[field],
|
||||
async (id, ciphertext) => {
|
||||
await prisma.appSettings.update({
|
||||
where: { id },
|
||||
data: { [field]: ciphertext } as Record<string, string>,
|
||||
});
|
||||
},
|
||||
);
|
||||
results.push(r);
|
||||
}
|
||||
|
||||
// ───── NotificationChannel.config ─────
|
||||
// Channel config (Telegram chat id, ntfy topic, etc.) is encrypted JSON.
|
||||
// Skipping these on rotation would leave channels permanently undecryptable
|
||||
// once the operator drops `v1` from the key map.
|
||||
const channels = await prisma.notificationChannel.findMany({
|
||||
select: { id: true, config: true },
|
||||
});
|
||||
results.push(
|
||||
await rotateField(
|
||||
"NotificationChannel",
|
||||
"config",
|
||||
channels,
|
||||
(c) => c.config,
|
||||
async (id, ciphertext) => {
|
||||
await prisma.notificationChannel.update({
|
||||
where: { id },
|
||||
data: { config: ciphertext },
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// ───── PushSubscription.{p256dh, auth} ─────
|
||||
// Web-push routing secrets — without these, the push endpoint is reachable
|
||||
// but the browser ignores the message (auth tag mismatch).
|
||||
const subs = await prisma.pushSubscription.findMany({
|
||||
select: { id: true, p256dh: true, auth: true },
|
||||
});
|
||||
for (const field of ["p256dh", "auth"] as const) {
|
||||
results.push(
|
||||
await rotateField(
|
||||
"PushSubscription",
|
||||
field,
|
||||
subs,
|
||||
(s) => s[field],
|
||||
async (id, ciphertext) => {
|
||||
await prisma.pushSubscription.update({
|
||||
where: { id },
|
||||
data: { [field]: ciphertext } as Record<string, string>,
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
console.log("\n=== Rotation summary ===");
|
||||
let totalRotated = 0;
|
||||
let totalErrors = 0;
|
||||
for (const r of results) {
|
||||
console.log(
|
||||
`${r.table}.${r.field}: scanned=${r.scanned} rotated=${r.rotated} errors=${r.errors}`,
|
||||
);
|
||||
totalRotated += r.rotated;
|
||||
totalErrors += r.errors;
|
||||
}
|
||||
console.log(`\nTOTAL rotated=${totalRotated} errors=${totalErrors}`);
|
||||
await prisma.$disconnect();
|
||||
process.exit(totalErrors > 0 ? 3 : 0);
|
||||
}
|
||||
|
||||
main().catch(async (err) => {
|
||||
console.error("Rotation failed:", err);
|
||||
await prisma.$disconnect();
|
||||
process.exit(1);
|
||||
});
|
||||
+17
-2626
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* POST /api/admin/backup/test — admin-only smoke test for the off-host
|
||||
* backup target. Issues a 1-byte PUT + GET roundtrip and returns the
|
||||
* endpoint, bucket, region, and per-call latency. Credentials are NEVER
|
||||
* returned; only the endpoint host + bucket name are surfaced.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
runOffhostRoundtripTest,
|
||||
OffhostBackupNotConfiguredError,
|
||||
} from "@/lib/jobs/offhost-backup";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
void request;
|
||||
await requireAdmin();
|
||||
try {
|
||||
const report = await runOffhostRoundtripTest();
|
||||
annotate({
|
||||
action: { name: "admin.backup.test" },
|
||||
meta: {
|
||||
ok: report.ok,
|
||||
put_latency_ms: report.putLatencyMs,
|
||||
get_latency_ms: report.getLatencyMs,
|
||||
},
|
||||
});
|
||||
return apiSuccess(report);
|
||||
} catch (err) {
|
||||
if (err instanceof OffhostBackupNotConfiguredError) {
|
||||
return apiError(err.message, 400);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
@@ -62,7 +62,6 @@ export const POST = apiHandler(
|
||||
subject: feedback.subject,
|
||||
description: feedback.description,
|
||||
username: feedback.user?.username ?? "anonymous",
|
||||
hasScreenshot: Boolean(feedback.screenshotBase64),
|
||||
metadata: feedback.metadata,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
appSettings: {
|
||||
findUnique: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/lib/api-handler")>(
|
||||
"@/lib/api-handler",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
|
||||
h: T,
|
||||
): T => h,
|
||||
requireAdmin: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
encrypt: vi.fn((value: string) => `enc(${value})`),
|
||||
decrypt: vi.fn((value: string) => value.replace(/^enc\(|\)$/g, "")),
|
||||
}));
|
||||
|
||||
import { GET, PUT } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAdmin, HttpError } from "@/lib/api-handler";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
const ADMIN_CTX = {
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "admin-1",
|
||||
username: "admin",
|
||||
role: "ADMIN",
|
||||
} as never,
|
||||
};
|
||||
|
||||
function jsonReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/admin/settings", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
|
||||
});
|
||||
|
||||
describe("GET /api/admin/settings", () => {
|
||||
it("rejects with 401 when no session", async () => {
|
||||
vi.mocked(requireAdmin).mockRejectedValue(
|
||||
new HttpError(401, "Not authenticated"),
|
||||
);
|
||||
await expect(GET()).rejects.toThrow("Not authenticated");
|
||||
});
|
||||
|
||||
it("rejects with 403 for non-admin", async () => {
|
||||
vi.mocked(requireAdmin).mockRejectedValue(
|
||||
new HttpError(403, "Admin access required"),
|
||||
);
|
||||
await expect(GET()).rejects.toThrow("Admin access required");
|
||||
});
|
||||
|
||||
it("reads the singleton row (regression: not the default-typo)", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null);
|
||||
await GET();
|
||||
expect(prisma.appSettings.findUnique).toHaveBeenCalledTimes(1);
|
||||
const args = vi.mocked(prisma.appSettings.findUnique).mock.calls[0]?.[0];
|
||||
// Must be "singleton" — the test-endpoints PR previously typo'd this as "default".
|
||||
expect(args).toEqual({ where: { id: "singleton" } });
|
||||
});
|
||||
|
||||
it("returns sane defaults when no settings row exists", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null);
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: Record<string, unknown> };
|
||||
expect(body.data.registrationEnabled).toBe(true);
|
||||
expect(body.data.defaultLocale).toBe("de");
|
||||
expect(body.data.webPushVapidConfigured).toBe(false);
|
||||
expect(body.data.bugReportConfigured).toBe(false);
|
||||
expect(body.data.glitchtipEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it("never leaks the encrypted private key fields", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
id: "singleton",
|
||||
registrationEnabled: false,
|
||||
defaultLocale: "en",
|
||||
telegramGlobal: true,
|
||||
ntfyGlobal: true,
|
||||
webPushGlobal: true,
|
||||
webPushVapidPublicKey: "pub",
|
||||
webPushVapidSubject: "mailto:a@b.com",
|
||||
webPushVapidPrivateKeyEncrypted: "secret-blob",
|
||||
apiGlobal: true,
|
||||
umamiEnabled: false,
|
||||
umamiScriptUrl: null,
|
||||
umamiWebsiteId: null,
|
||||
glitchtipEnabled: false,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: "production",
|
||||
githubIssueRepo: "owner/repo",
|
||||
githubIssueTokenEncrypted: "token-blob",
|
||||
reminderLateMinutes: 120,
|
||||
reminderMissedMinutes: 240,
|
||||
moodLogGlobal: true,
|
||||
} as never);
|
||||
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as { data: Record<string, unknown> };
|
||||
// The two configured-flags must be true …
|
||||
expect(body.data.webPushVapidConfigured).toBe(true);
|
||||
expect(body.data.bugReportConfigured).toBe(true);
|
||||
// … but raw encrypted blobs must never reach the client.
|
||||
const serialized = JSON.stringify(body.data);
|
||||
expect(serialized).not.toContain("secret-blob");
|
||||
expect(serialized).not.toContain("token-blob");
|
||||
expect(body.data).not.toHaveProperty("webPushVapidPrivateKeyEncrypted");
|
||||
expect(body.data).not.toHaveProperty("githubIssueTokenEncrypted");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/admin/settings", () => {
|
||||
it("rejects with 403 for non-admin", async () => {
|
||||
vi.mocked(requireAdmin).mockRejectedValue(
|
||||
new HttpError(403, "Admin access required"),
|
||||
);
|
||||
await expect(PUT(jsonReq({}))).rejects.toThrow("Admin access required");
|
||||
});
|
||||
|
||||
it("returns 422 for an unknown field (strict schema)", async () => {
|
||||
const res = await PUT(jsonReq({ totallyBogus: true }));
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.appSettings.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 422 for invalid Glitchtip DSN URL", async () => {
|
||||
const res = await PUT(jsonReq({ glitchtipDsn: "not a url" }));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("returns 422 when the body has no actionable fields", async () => {
|
||||
const res = await PUT(jsonReq({}));
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toBe("No valid fields");
|
||||
});
|
||||
|
||||
it("encrypts the VAPID private key before persisting", async () => {
|
||||
vi.mocked(prisma.appSettings.upsert).mockResolvedValue({
|
||||
id: "singleton",
|
||||
registrationEnabled: true,
|
||||
defaultLocale: "en",
|
||||
telegramGlobal: true,
|
||||
ntfyGlobal: true,
|
||||
webPushGlobal: true,
|
||||
webPushVapidPublicKey: null,
|
||||
webPushVapidSubject: null,
|
||||
webPushVapidPrivateKeyEncrypted: "enc(plain-private)",
|
||||
apiGlobal: true,
|
||||
umamiEnabled: false,
|
||||
umamiScriptUrl: null,
|
||||
umamiWebsiteId: null,
|
||||
glitchtipEnabled: false,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: "production",
|
||||
githubIssueRepo: null,
|
||||
githubIssueTokenEncrypted: null,
|
||||
reminderLateMinutes: 120,
|
||||
reminderMissedMinutes: 240,
|
||||
moodLogGlobal: true,
|
||||
} as never);
|
||||
|
||||
const res = await PUT(jsonReq({ webPushVapidPrivateKey: "plain-private" }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(encrypt).toHaveBeenCalledWith("plain-private");
|
||||
|
||||
const upsertArgs = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0];
|
||||
expect(upsertArgs?.where).toEqual({ id: "singleton" });
|
||||
expect(upsertArgs?.update).toEqual({
|
||||
webPushVapidPrivateKeyEncrypted: "enc(plain-private)",
|
||||
});
|
||||
// Audit detail must NOT carry the plaintext private key.
|
||||
expect(auditLog).toHaveBeenCalled();
|
||||
const auditDetails = vi.mocked(auditLog).mock.calls[0]?.[1]
|
||||
?.details as Record<string, unknown>;
|
||||
expect(JSON.stringify(auditDetails)).not.toContain("plain-private");
|
||||
expect(auditDetails.webPushVapidPrivateKeyUpdated).toBe(true);
|
||||
});
|
||||
|
||||
it("clears the encrypted private key when clear flag is set", async () => {
|
||||
vi.mocked(prisma.appSettings.upsert).mockResolvedValue({
|
||||
id: "singleton",
|
||||
registrationEnabled: true,
|
||||
defaultLocale: "en",
|
||||
telegramGlobal: true,
|
||||
ntfyGlobal: true,
|
||||
webPushGlobal: true,
|
||||
webPushVapidPublicKey: null,
|
||||
webPushVapidSubject: null,
|
||||
webPushVapidPrivateKeyEncrypted: null,
|
||||
apiGlobal: true,
|
||||
umamiEnabled: false,
|
||||
umamiScriptUrl: null,
|
||||
umamiWebsiteId: null,
|
||||
glitchtipEnabled: false,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: null,
|
||||
githubIssueRepo: null,
|
||||
githubIssueTokenEncrypted: null,
|
||||
reminderLateMinutes: 120,
|
||||
reminderMissedMinutes: 240,
|
||||
moodLogGlobal: true,
|
||||
} as never);
|
||||
const res = await PUT(jsonReq({ clearWebPushVapidPrivateKey: true }));
|
||||
expect(res.status).toBe(200);
|
||||
const args = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0];
|
||||
expect(args?.update).toEqual({ webPushVapidPrivateKeyEncrypted: null });
|
||||
});
|
||||
|
||||
it("normalises a Umami URL with no path to /script.js", async () => {
|
||||
vi.mocked(prisma.appSettings.upsert).mockResolvedValue({
|
||||
id: "singleton",
|
||||
registrationEnabled: true,
|
||||
defaultLocale: "en",
|
||||
telegramGlobal: true,
|
||||
ntfyGlobal: true,
|
||||
webPushGlobal: true,
|
||||
webPushVapidPublicKey: null,
|
||||
webPushVapidSubject: null,
|
||||
webPushVapidPrivateKeyEncrypted: null,
|
||||
apiGlobal: true,
|
||||
umamiEnabled: false,
|
||||
umamiScriptUrl: "https://analytics.example.com/script.js",
|
||||
umamiWebsiteId: null,
|
||||
glitchtipEnabled: false,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: null,
|
||||
githubIssueRepo: null,
|
||||
githubIssueTokenEncrypted: null,
|
||||
reminderLateMinutes: 120,
|
||||
reminderMissedMinutes: 240,
|
||||
moodLogGlobal: true,
|
||||
} as never);
|
||||
|
||||
const res = await PUT(
|
||||
jsonReq({ umamiScriptUrl: "https://analytics.example.com" }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const args = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0];
|
||||
expect(args?.update).toEqual({
|
||||
umamiScriptUrl: "https://analytics.example.com/script.js",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalises and persists every Zod-allowed field in a single PUT", async () => {
|
||||
vi.mocked(prisma.appSettings.upsert).mockResolvedValue({
|
||||
id: "singleton",
|
||||
registrationEnabled: false,
|
||||
defaultLocale: "en",
|
||||
telegramGlobal: false,
|
||||
ntfyGlobal: false,
|
||||
webPushGlobal: false,
|
||||
webPushVapidPublicKey: "vapid-pub",
|
||||
webPushVapidSubject: "mailto:ops@healthlog.dev",
|
||||
webPushVapidPrivateKeyEncrypted: "enc(secret)",
|
||||
apiGlobal: false,
|
||||
umamiEnabled: true,
|
||||
umamiScriptUrl: "https://analytics.example.com/custom.js",
|
||||
umamiWebsiteId: "site-uuid",
|
||||
glitchtipEnabled: true,
|
||||
glitchtipDsn: "https://abc@glitchtip.example.com/1",
|
||||
glitchtipEnvironment: "staging",
|
||||
githubIssueRepo: "owner/repo",
|
||||
githubIssueTokenEncrypted: "enc(gh-token)",
|
||||
reminderLateMinutes: 30,
|
||||
reminderMissedMinutes: 90,
|
||||
moodLogGlobal: false,
|
||||
} as never);
|
||||
|
||||
const res = await PUT(
|
||||
jsonReq({
|
||||
registrationEnabled: false,
|
||||
defaultLocale: "en",
|
||||
telegramGlobal: false,
|
||||
ntfyGlobal: false,
|
||||
webPushGlobal: false,
|
||||
apiGlobal: false,
|
||||
umamiEnabled: true,
|
||||
moodLogGlobal: false,
|
||||
glitchtipEnabled: true,
|
||||
webPushVapidPublicKey: "vapid-pub",
|
||||
webPushVapidSubject: "mailto:ops@healthlog.dev",
|
||||
webPushVapidPrivateKey: "secret",
|
||||
umamiScriptUrl: "https://analytics.example.com/custom.js",
|
||||
umamiWebsiteId: "site-uuid",
|
||||
glitchtipDsn: "https://abc@glitchtip.example.com/1",
|
||||
glitchtipEnvironment: "staging",
|
||||
bugReportRepo: "owner/repo",
|
||||
bugReportToken: "gh-token",
|
||||
reminderLateMinutes: 30,
|
||||
reminderMissedMinutes: 90,
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const upsert = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0];
|
||||
expect(upsert?.update).toMatchObject({
|
||||
registrationEnabled: false,
|
||||
defaultLocale: "en",
|
||||
telegramGlobal: false,
|
||||
apiGlobal: false,
|
||||
glitchtipEnabled: true,
|
||||
umamiEnabled: true,
|
||||
umamiScriptUrl: "https://analytics.example.com/custom.js",
|
||||
umamiWebsiteId: "site-uuid",
|
||||
glitchtipDsn: "https://abc@glitchtip.example.com/1",
|
||||
glitchtipEnvironment: "staging",
|
||||
githubIssueRepo: "owner/repo",
|
||||
githubIssueTokenEncrypted: "enc(gh-token)",
|
||||
webPushVapidPublicKey: "vapid-pub",
|
||||
webPushVapidSubject: "mailto:ops@healthlog.dev",
|
||||
webPushVapidPrivateKeyEncrypted: "enc(secret)",
|
||||
reminderLateMinutes: 30,
|
||||
reminderMissedMinutes: 90,
|
||||
});
|
||||
expect(encrypt).toHaveBeenCalledWith("secret");
|
||||
expect(encrypt).toHaveBeenCalledWith("gh-token");
|
||||
});
|
||||
|
||||
it("clears nullable string fields when an empty string is submitted", async () => {
|
||||
vi.mocked(prisma.appSettings.upsert).mockResolvedValue({
|
||||
id: "singleton",
|
||||
registrationEnabled: true,
|
||||
defaultLocale: "en",
|
||||
telegramGlobal: true,
|
||||
ntfyGlobal: true,
|
||||
webPushGlobal: true,
|
||||
webPushVapidPublicKey: null,
|
||||
webPushVapidSubject: null,
|
||||
webPushVapidPrivateKeyEncrypted: null,
|
||||
apiGlobal: true,
|
||||
umamiEnabled: false,
|
||||
umamiScriptUrl: null,
|
||||
umamiWebsiteId: null,
|
||||
glitchtipEnabled: false,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: null,
|
||||
githubIssueRepo: null,
|
||||
githubIssueTokenEncrypted: null,
|
||||
reminderLateMinutes: 120,
|
||||
reminderMissedMinutes: 240,
|
||||
moodLogGlobal: true,
|
||||
} as never);
|
||||
|
||||
await PUT(
|
||||
jsonReq({
|
||||
webPushVapidPublicKey: "",
|
||||
webPushVapidSubject: "",
|
||||
umamiScriptUrl: "",
|
||||
umamiWebsiteId: "",
|
||||
glitchtipDsn: "",
|
||||
glitchtipEnvironment: "",
|
||||
bugReportRepo: "",
|
||||
}),
|
||||
);
|
||||
const upsert = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0];
|
||||
expect(upsert?.update).toEqual({
|
||||
webPushVapidPublicKey: null,
|
||||
webPushVapidSubject: null,
|
||||
umamiScriptUrl: null,
|
||||
umamiWebsiteId: null,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: null,
|
||||
githubIssueRepo: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("clears the encrypted bug-report token when clear flag is set", async () => {
|
||||
vi.mocked(prisma.appSettings.upsert).mockResolvedValue({
|
||||
id: "singleton",
|
||||
registrationEnabled: true,
|
||||
defaultLocale: "en",
|
||||
telegramGlobal: true,
|
||||
ntfyGlobal: true,
|
||||
webPushGlobal: true,
|
||||
webPushVapidPublicKey: null,
|
||||
webPushVapidSubject: null,
|
||||
webPushVapidPrivateKeyEncrypted: null,
|
||||
apiGlobal: true,
|
||||
umamiEnabled: false,
|
||||
umamiScriptUrl: null,
|
||||
umamiWebsiteId: null,
|
||||
glitchtipEnabled: false,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: null,
|
||||
githubIssueRepo: null,
|
||||
githubIssueTokenEncrypted: null,
|
||||
reminderLateMinutes: 120,
|
||||
reminderMissedMinutes: 240,
|
||||
moodLogGlobal: true,
|
||||
} as never);
|
||||
await PUT(jsonReq({ clearBugReportToken: true }));
|
||||
const upsert = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0];
|
||||
expect(upsert?.update).toEqual({ githubIssueTokenEncrypted: null });
|
||||
});
|
||||
|
||||
it("returns 415 when content-type is not JSON", async () => {
|
||||
const r = new NextRequest("http://localhost/api/admin/settings", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "text/plain" },
|
||||
body: "{}",
|
||||
});
|
||||
const res = await PUT(r);
|
||||
expect(res.status).toBe(415);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { count: vi.fn() },
|
||||
withingsConnection: { count: vi.fn() },
|
||||
notificationChannel: { count: vi.fn() },
|
||||
pushSubscription: { count: vi.fn() },
|
||||
appSettings: { findUnique: vi.fn() },
|
||||
auditLog: { findFirst: vi.fn(), count: vi.fn() },
|
||||
dataBackup: { findFirst: vi.fn(), findMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/lib/api-handler")>(
|
||||
"@/lib/api-handler",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
|
||||
h: T,
|
||||
): T => h,
|
||||
requireAdmin: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/jobs/worker-status", () => ({
|
||||
getWorkerStatus: vi.fn(() => ({
|
||||
running: true,
|
||||
startedAt: new Date(Date.now() - 3_600_000).toISOString(),
|
||||
lastHeartbeat: null,
|
||||
lastReminderCheck: null,
|
||||
lastWithingsSync: null,
|
||||
lastInsightsRun: null,
|
||||
jobsProcessed: 0,
|
||||
errors: 0,
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
const mocks = prisma as unknown as {
|
||||
user: { count: ReturnType<typeof vi.fn> };
|
||||
withingsConnection: { count: ReturnType<typeof vi.fn> };
|
||||
notificationChannel: { count: ReturnType<typeof vi.fn> };
|
||||
pushSubscription: { count: ReturnType<typeof vi.fn> };
|
||||
appSettings: { findUnique: ReturnType<typeof vi.fn> };
|
||||
auditLog: {
|
||||
findFirst: ReturnType<typeof vi.fn>;
|
||||
count: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
dataBackup: {
|
||||
findFirst: ReturnType<typeof vi.fn>;
|
||||
findMany: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
};
|
||||
|
||||
describe("GET /api/admin/status-overview", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// default returns
|
||||
mocks.user.count.mockResolvedValue(0);
|
||||
mocks.withingsConnection.count.mockResolvedValue(0);
|
||||
mocks.notificationChannel.count.mockResolvedValue(0);
|
||||
mocks.pushSubscription.count.mockResolvedValue(0);
|
||||
mocks.appSettings.findUnique.mockResolvedValue(null);
|
||||
mocks.auditLog.findFirst.mockResolvedValue(null);
|
||||
mocks.auditLog.count.mockResolvedValue(0);
|
||||
mocks.dataBackup.findFirst.mockResolvedValue(null);
|
||||
mocks.dataBackup.findMany.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("returns the 6-section overview shape", async () => {
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as { data: Record<string, unknown> };
|
||||
expect(body.data).toHaveProperty("users");
|
||||
expect(body.data).toHaveProperty("integrations");
|
||||
expect(body.data).toHaveProperty("monitoring");
|
||||
expect(body.data).toHaveProperty("backups");
|
||||
expect(body.data).toHaveProperty("maintenance");
|
||||
expect(body.data).toHaveProperty("auditLog");
|
||||
});
|
||||
|
||||
it("batches DB calls in a single Promise.all (no N+1)", async () => {
|
||||
await GET();
|
||||
// 3 user.count + 1 withings + 1 ntfy notifications + 1 webPush + 3
|
||||
// moodLog/telegram done via user.count → expect user.count called 4 times
|
||||
// (total, admins, newThisWeek, moodLog, telegram)
|
||||
expect(mocks.user.count).toHaveBeenCalledTimes(5);
|
||||
expect(mocks.withingsConnection.count).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.notificationChannel.count).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.pushSubscription.count).toHaveBeenCalledTimes(1);
|
||||
// The route does not loop — every aggregate is a single query.
|
||||
expect(mocks.dataBackup.findMany).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("flags backups as alert when none exist", async () => {
|
||||
mocks.dataBackup.findFirst.mockResolvedValue(null);
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as {
|
||||
data: { backups: { severity: string } };
|
||||
};
|
||||
expect(body.data.backups.severity).toBe("alert");
|
||||
});
|
||||
|
||||
it("flags backups as good when fresh (<8d)", async () => {
|
||||
mocks.dataBackup.findFirst.mockResolvedValue({
|
||||
createdAt: new Date(Date.now() - 24 * 60 * 60 * 1000),
|
||||
});
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as {
|
||||
data: { backups: { severity: string } };
|
||||
};
|
||||
expect(body.data.backups.severity).toBe("good");
|
||||
});
|
||||
|
||||
it("flags monitoring as alert on a recent error (<24h)", async () => {
|
||||
mocks.auditLog.findFirst.mockImplementation(
|
||||
({ where }: { where: { action: unknown } }) => {
|
||||
const action = where.action as { startsWith?: string } | string;
|
||||
if (
|
||||
typeof action === "object" &&
|
||||
action.startsWith === "system.error"
|
||||
) {
|
||||
return Promise.resolve({ createdAt: new Date(Date.now() - 60_000) });
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
},
|
||||
);
|
||||
mocks.appSettings.findUnique.mockResolvedValue({
|
||||
glitchtipEnabled: true,
|
||||
umamiEnabled: true,
|
||||
});
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as {
|
||||
data: { monitoring: { severity: string } };
|
||||
};
|
||||
expect(body.data.monitoring.severity).toBe("alert");
|
||||
});
|
||||
|
||||
it("flags maintenance as alert when worker is stopped", async () => {
|
||||
const wsModule = await import("@/lib/jobs/worker-status");
|
||||
vi.mocked(wsModule.getWorkerStatus).mockReturnValue({
|
||||
running: false,
|
||||
startedAt: null,
|
||||
lastHeartbeat: null,
|
||||
lastReminderCheck: null,
|
||||
lastWithingsSync: null,
|
||||
lastInsightsRun: null,
|
||||
jobsProcessed: 0,
|
||||
errors: 0,
|
||||
});
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as {
|
||||
data: { maintenance: { severity: string; workerRunning: boolean } };
|
||||
};
|
||||
expect(body.data.maintenance.severity).toBe("alert");
|
||||
expect(body.data.maintenance.workerRunning).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { getWorkerStatus } from "@/lib/jobs/worker-status";
|
||||
|
||||
/**
|
||||
* Severity tokens map onto §2.6 status taxonomy of `docs/ui-guidelines.md`.
|
||||
* Returned as plain strings so the client renders dots + labels — not RAW
|
||||
* colours — and never leans on color alone for state.
|
||||
*/
|
||||
export type StatusSeverity = "good" | "info" | "caution" | "alert" | "pending";
|
||||
|
||||
export interface StatusOverview {
|
||||
users: {
|
||||
severity: StatusSeverity;
|
||||
total: number;
|
||||
admins: number;
|
||||
newThisWeek: number;
|
||||
};
|
||||
integrations: {
|
||||
severity: StatusSeverity;
|
||||
withings: number;
|
||||
moodLog: number;
|
||||
telegram: number;
|
||||
ntfy: number;
|
||||
webPush: number;
|
||||
};
|
||||
monitoring: {
|
||||
severity: StatusSeverity;
|
||||
glitchtipEnabled: boolean;
|
||||
umamiEnabled: boolean;
|
||||
wideEventsEnabled: boolean;
|
||||
lastErrorAt: string | null;
|
||||
};
|
||||
backups: {
|
||||
severity: StatusSeverity;
|
||||
lastBackupAt: string | null;
|
||||
backedUpUsers: number;
|
||||
retentionDays: number;
|
||||
};
|
||||
maintenance: {
|
||||
severity: StatusSeverity;
|
||||
workerRunning: boolean;
|
||||
workerUptimeSeconds: number | null;
|
||||
lastIdempotencyCleanup: string | null;
|
||||
lastAuditLogCleanup: string | null;
|
||||
};
|
||||
auditLog: {
|
||||
severity: StatusSeverity;
|
||||
eventsLast30d: number;
|
||||
lastLoginAt: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function severityForBackup(lastBackupAt: Date | null): StatusSeverity {
|
||||
if (!lastBackupAt) return "alert";
|
||||
const ageMs = Date.now() - lastBackupAt.getTime();
|
||||
if (ageMs > 14 * ONE_DAY_MS) return "alert";
|
||||
if (ageMs > 8 * ONE_DAY_MS) return "caution";
|
||||
return "good";
|
||||
}
|
||||
|
||||
function severityForMaintenance(workerRunning: boolean): StatusSeverity {
|
||||
return workerRunning ? "good" : "alert";
|
||||
}
|
||||
|
||||
function severityForMonitoring(
|
||||
glitchtipEnabled: boolean,
|
||||
umamiEnabled: boolean,
|
||||
recentErrorAt: Date | null,
|
||||
): StatusSeverity {
|
||||
if (recentErrorAt && Date.now() - recentErrorAt.getTime() < ONE_DAY_MS) {
|
||||
return "alert";
|
||||
}
|
||||
if (glitchtipEnabled && umamiEnabled) return "good";
|
||||
if (glitchtipEnabled || umamiEnabled) return "caution";
|
||||
return "info";
|
||||
}
|
||||
|
||||
function severityForIntegrations(totalConfigured: number): StatusSeverity {
|
||||
if (totalConfigured === 0) return "info";
|
||||
return "good";
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
await requireAdmin();
|
||||
annotate({ action: { name: "admin.status-overview" } });
|
||||
|
||||
const now = new Date();
|
||||
const sevenDaysAgo = new Date(now.getTime() - 7 * ONE_DAY_MS);
|
||||
const thirtyDaysAgo = new Date(now.getTime() - 30 * ONE_DAY_MS);
|
||||
|
||||
// Single batched roundtrip — no N+1.
|
||||
const [
|
||||
userTotal,
|
||||
userAdmins,
|
||||
userNewThisWeek,
|
||||
withingsCount,
|
||||
moodLogCount,
|
||||
telegramCount,
|
||||
ntfyCount,
|
||||
webPushCount,
|
||||
appSettings,
|
||||
lastErrorEntry,
|
||||
latestBackup,
|
||||
backedUpUsers,
|
||||
last30dEvents,
|
||||
lastLoginEntry,
|
||||
lastIdempotencyCleanup,
|
||||
lastAuditLogCleanup,
|
||||
] = await Promise.all([
|
||||
prisma.user.count(),
|
||||
prisma.user.count({ where: { role: "ADMIN" } }),
|
||||
prisma.user.count({ where: { createdAt: { gte: sevenDaysAgo } } }),
|
||||
prisma.withingsConnection.count(),
|
||||
prisma.user.count({ where: { moodLogEnabled: true } }),
|
||||
prisma.user.count({ where: { telegramEnabled: true } }),
|
||||
prisma.notificationChannel.count({
|
||||
where: { type: "NTFY", enabled: true },
|
||||
}),
|
||||
prisma.pushSubscription.count(),
|
||||
prisma.appSettings.findUnique({ where: { id: "singleton" } }),
|
||||
prisma.auditLog.findFirst({
|
||||
where: { action: { startsWith: "system.error" } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true },
|
||||
}),
|
||||
prisma.dataBackup.findFirst({
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true },
|
||||
}),
|
||||
prisma.dataBackup
|
||||
.findMany({ select: { userId: true }, distinct: ["userId"] })
|
||||
.then((rows) => rows.length),
|
||||
prisma.auditLog.count({ where: { createdAt: { gte: thirtyDaysAgo } } }),
|
||||
prisma.auditLog.findFirst({
|
||||
where: { action: "auth.login" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true },
|
||||
}),
|
||||
prisma.auditLog.findFirst({
|
||||
where: { action: "system.cleanup.idempotency" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true },
|
||||
}),
|
||||
prisma.auditLog.findFirst({
|
||||
where: { action: "system.cleanup.audit-log" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const worker = getWorkerStatus();
|
||||
const workerUptimeSeconds = worker.startedAt
|
||||
? Math.max(
|
||||
0,
|
||||
Math.floor(
|
||||
(now.getTime() - new Date(worker.startedAt).getTime()) / 1000,
|
||||
),
|
||||
)
|
||||
: null;
|
||||
|
||||
const integrationsTotal =
|
||||
withingsCount + moodLogCount + telegramCount + ntfyCount + webPushCount;
|
||||
|
||||
const overview: StatusOverview = {
|
||||
users: {
|
||||
severity: "good",
|
||||
total: userTotal,
|
||||
admins: userAdmins,
|
||||
newThisWeek: userNewThisWeek,
|
||||
},
|
||||
integrations: {
|
||||
severity: severityForIntegrations(integrationsTotal),
|
||||
withings: withingsCount,
|
||||
moodLog: moodLogCount,
|
||||
telegram: telegramCount,
|
||||
ntfy: ntfyCount,
|
||||
webPush: webPushCount,
|
||||
},
|
||||
monitoring: {
|
||||
severity: severityForMonitoring(
|
||||
Boolean(appSettings?.glitchtipEnabled),
|
||||
Boolean(appSettings?.umamiEnabled),
|
||||
lastErrorEntry?.createdAt ?? null,
|
||||
),
|
||||
glitchtipEnabled: Boolean(appSettings?.glitchtipEnabled),
|
||||
umamiEnabled: Boolean(appSettings?.umamiEnabled),
|
||||
wideEventsEnabled: true,
|
||||
lastErrorAt: lastErrorEntry?.createdAt.toISOString() ?? null,
|
||||
},
|
||||
backups: {
|
||||
severity: severityForBackup(latestBackup?.createdAt ?? null),
|
||||
lastBackupAt: latestBackup?.createdAt.toISOString() ?? null,
|
||||
backedUpUsers,
|
||||
retentionDays: 90,
|
||||
},
|
||||
maintenance: {
|
||||
severity: severityForMaintenance(worker.running),
|
||||
workerRunning: worker.running,
|
||||
workerUptimeSeconds,
|
||||
lastIdempotencyCleanup:
|
||||
lastIdempotencyCleanup?.createdAt.toISOString() ?? null,
|
||||
lastAuditLogCleanup: lastAuditLogCleanup?.createdAt.toISOString() ?? null,
|
||||
},
|
||||
auditLog: {
|
||||
severity: "info",
|
||||
eventsLast30d: last30dEvents,
|
||||
lastLoginAt: lastLoginEntry?.createdAt.toISOString() ?? null,
|
||||
},
|
||||
};
|
||||
|
||||
return apiSuccess(overview);
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { count: vi.fn() },
|
||||
measurement: { count: vi.fn() },
|
||||
medication: { count: vi.fn() },
|
||||
medicationIntakeEvent: { count: vi.fn() },
|
||||
apiToken: { count: vi.fn() },
|
||||
session: { count: vi.fn() },
|
||||
appSettings: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/lib/api-handler")>(
|
||||
"@/lib/api-handler",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
|
||||
h: T,
|
||||
): T => h,
|
||||
requireAdmin: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/jobs/worker-status", () => ({
|
||||
getWorkerStatus: vi.fn(() => ({
|
||||
running: true,
|
||||
startedAt: new Date(Date.now() - 60_000).toISOString(),
|
||||
lastHeartbeat: null,
|
||||
lastReminderCheck: null,
|
||||
lastWithingsSync: null,
|
||||
lastInsightsRun: null,
|
||||
jobsProcessed: 0,
|
||||
errors: 0,
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAdmin, HttpError } from "@/lib/api-handler";
|
||||
|
||||
const ADMIN_CTX = {
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "admin-1",
|
||||
username: "admin",
|
||||
role: "ADMIN",
|
||||
} as never,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
|
||||
vi.mocked(prisma.user.count).mockResolvedValue(0);
|
||||
vi.mocked(prisma.measurement.count).mockResolvedValue(0);
|
||||
vi.mocked(prisma.medication.count).mockResolvedValue(0);
|
||||
vi.mocked(prisma.medicationIntakeEvent.count).mockResolvedValue(0);
|
||||
vi.mocked(prisma.apiToken.count).mockResolvedValue(0);
|
||||
vi.mocked(prisma.session.count).mockResolvedValue(0);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null);
|
||||
});
|
||||
|
||||
describe("GET /api/admin/status (integration health summary)", () => {
|
||||
it("rejects non-admin callers with 403", async () => {
|
||||
vi.mocked(requireAdmin).mockRejectedValue(
|
||||
new HttpError(403, "Admin access required"),
|
||||
);
|
||||
await expect(GET()).rejects.toThrow("Admin access required");
|
||||
});
|
||||
|
||||
it("returns null integration entries when no app settings exist", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null);
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { integrations: Record<string, unknown> };
|
||||
};
|
||||
expect(body.data.integrations).toEqual({
|
||||
umami: null,
|
||||
glitchtip: null,
|
||||
webPush: null,
|
||||
bugReport: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("aggregates configured + enabled flags for umami / glitchtip / web-push / bugReport", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
umamiScriptUrl: "https://x/script.js",
|
||||
umamiWebsiteId: "site-1",
|
||||
umamiEnabled: true,
|
||||
glitchtipDsn: "https://abc@x/123",
|
||||
glitchtipEnabled: false,
|
||||
webPushVapidPublicKey: "pub",
|
||||
webPushVapidPrivateKeyEncrypted: "secret-blob",
|
||||
webPushVapidSubject: "mailto:a@b.com",
|
||||
githubIssueRepo: "owner/repo",
|
||||
githubIssueTokenEncrypted: "token-blob",
|
||||
} as never);
|
||||
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as {
|
||||
data: { integrations: Record<string, Record<string, unknown> | null> };
|
||||
};
|
||||
expect(body.data.integrations.umami).toEqual({
|
||||
configured: true,
|
||||
enabled: true,
|
||||
});
|
||||
// glitchtip is configured but the global toggle is off
|
||||
expect(body.data.integrations.glitchtip).toEqual({
|
||||
configured: true,
|
||||
enabled: false,
|
||||
});
|
||||
expect(body.data.integrations.webPush).toEqual({ configured: true });
|
||||
expect(body.data.integrations.bugReport).toEqual({ configured: true });
|
||||
|
||||
// Regression: encrypted blobs MUST never appear in the response envelope.
|
||||
const serialized = JSON.stringify(body);
|
||||
expect(serialized).not.toContain("secret-blob");
|
||||
expect(serialized).not.toContain("token-blob");
|
||||
});
|
||||
|
||||
it("does not mark webPush configured when only the public key is present", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
webPushVapidPublicKey: "pub",
|
||||
webPushVapidPrivateKeyEncrypted: null,
|
||||
webPushVapidSubject: null,
|
||||
} as never);
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as {
|
||||
data: { integrations: { webPush: unknown } };
|
||||
};
|
||||
expect(body.data.integrations.webPush).toBeNull();
|
||||
});
|
||||
|
||||
it("returns aggregate counts and the worker status block", async () => {
|
||||
vi.mocked(prisma.user.count).mockResolvedValue(7);
|
||||
vi.mocked(prisma.measurement.count).mockResolvedValue(123);
|
||||
vi.mocked(prisma.apiToken.count).mockResolvedValue(2);
|
||||
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
counts: Record<string, number>;
|
||||
worker: { running: boolean };
|
||||
database: string;
|
||||
};
|
||||
};
|
||||
expect(body.data.counts).toEqual({
|
||||
users: 7,
|
||||
measurements: 123,
|
||||
medications: 0,
|
||||
intakeEvents: 0,
|
||||
activeTokens: 2,
|
||||
activeSessions: 0,
|
||||
});
|
||||
expect(body.data.worker.running).toBe(true);
|
||||
expect(body.data.database).toBe("connected");
|
||||
|
||||
// Regression: only NON-revoked tokens are counted as "active".
|
||||
const tokenCallArgs = vi.mocked(prisma.apiToken.count).mock.calls[0]?.[0];
|
||||
expect(tokenCallArgs).toEqual({ where: { revoked: false } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
count: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/lib/api-handler")>(
|
||||
"@/lib/api-handler",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
|
||||
h: T,
|
||||
): T => h,
|
||||
requireAdmin: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
import { PUT } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAdmin, HttpError } from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
const ADMIN_CTX = {
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "admin-1",
|
||||
username: "admin",
|
||||
role: "ADMIN",
|
||||
} as never,
|
||||
};
|
||||
|
||||
function jsonReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/admin/users/u1", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function params(id: string) {
|
||||
return { params: Promise.resolve({ id }) };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
|
||||
});
|
||||
|
||||
describe("PUT /api/admin/users/[id]", () => {
|
||||
it("rejects with 401 when unauthenticated", async () => {
|
||||
vi.mocked(requireAdmin).mockRejectedValue(
|
||||
new HttpError(401, "Not authenticated"),
|
||||
);
|
||||
await expect(
|
||||
PUT(jsonReq({ username: "marc" }), params("u1")),
|
||||
).rejects.toThrow("Not authenticated");
|
||||
});
|
||||
|
||||
it("rejects with 403 when caller is not admin", async () => {
|
||||
vi.mocked(requireAdmin).mockRejectedValue(
|
||||
new HttpError(403, "Admin access required"),
|
||||
);
|
||||
await expect(
|
||||
PUT(jsonReq({ username: "marc" }), params("u1")),
|
||||
).rejects.toThrow("Admin access required");
|
||||
});
|
||||
|
||||
it("returns 422 when payload fails Zod validation (short username)", async () => {
|
||||
const res = await PUT(jsonReq({ username: "ab" }), params("u1"));
|
||||
expect(res.status).toBe(422);
|
||||
expect(vi.mocked(prisma.user.findUnique)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 422 when role is unknown", async () => {
|
||||
const res = await PUT(jsonReq({ role: "SUPERUSER" }), params("u1"));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("returns 404 when target user does not exist", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
|
||||
const res = await PUT(jsonReq({ username: "marc" }), params("u-missing"));
|
||||
expect(res.status).toBe(404);
|
||||
expect(vi.mocked(prisma.user.update)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forbids demoting the last remaining admin", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
id: "u1",
|
||||
role: "ADMIN",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.count).mockResolvedValue(1);
|
||||
const res = await PUT(jsonReq({ role: "USER" }), params("u1"));
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toMatch(/last admin/i);
|
||||
expect(vi.mocked(prisma.user.update)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows demoting an admin while another admin exists", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
id: "u1",
|
||||
role: "ADMIN",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.count).mockResolvedValue(2);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({
|
||||
id: "u1",
|
||||
username: "alice",
|
||||
email: "a@b.com",
|
||||
role: "USER",
|
||||
createdAt: new Date("2024-01-01T00:00:00Z"),
|
||||
} as never);
|
||||
const res = await PUT(jsonReq({ role: "USER" }), params("u1"));
|
||||
expect(res.status).toBe(200);
|
||||
const updateArgs = vi.mocked(prisma.user.update).mock.calls[0]?.[0];
|
||||
expect(updateArgs?.where).toEqual({ id: "u1" });
|
||||
expect(updateArgs?.data).toEqual({ role: "USER" });
|
||||
});
|
||||
|
||||
it("persists username + email + role and writes an audit-log entry", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
id: "u1",
|
||||
role: "USER",
|
||||
} as never);
|
||||
const updated = {
|
||||
id: "u1",
|
||||
username: "renamed",
|
||||
email: "new@example.com",
|
||||
role: "ADMIN" as const,
|
||||
createdAt: new Date("2024-01-01T00:00:00Z"),
|
||||
};
|
||||
vi.mocked(prisma.user.update).mockResolvedValue(updated as never);
|
||||
|
||||
const res = await PUT(
|
||||
jsonReq({
|
||||
username: "renamed",
|
||||
email: "new@example.com",
|
||||
role: "ADMIN",
|
||||
}),
|
||||
params("u1"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: Record<string, unknown> };
|
||||
expect(body.data.username).toBe("renamed");
|
||||
expect(body.data.role).toBe("ADMIN");
|
||||
expect(body.data).not.toHaveProperty("passwordHash");
|
||||
|
||||
const updateArgs = vi.mocked(prisma.user.update).mock.calls[0]?.[0];
|
||||
expect(updateArgs?.data).toEqual({
|
||||
username: "renamed",
|
||||
email: "new@example.com",
|
||||
role: "ADMIN",
|
||||
});
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"admin.user.update",
|
||||
expect.objectContaining({
|
||||
userId: "admin-1",
|
||||
details: expect.objectContaining({ targetUserId: "u1" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("supports clearing email by passing null", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
id: "u1",
|
||||
role: "USER",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({
|
||||
id: "u1",
|
||||
username: "alice",
|
||||
email: null,
|
||||
role: "USER",
|
||||
createdAt: new Date(),
|
||||
} as never);
|
||||
const res = await PUT(jsonReq({ email: null }), params("u1"));
|
||||
expect(res.status).toBe(200);
|
||||
const updateArgs = vi.mocked(prisma.user.update).mock.calls[0]?.[0];
|
||||
expect(updateArgs?.data).toEqual({ email: null });
|
||||
});
|
||||
|
||||
it("returns 415 when content-type is not JSON", async () => {
|
||||
const r = new NextRequest("http://localhost/api/admin/users/u1", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "text/plain" },
|
||||
body: "{}",
|
||||
});
|
||||
const res = await PUT(r, params("u1"));
|
||||
expect(res.status).toBe(415);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/lib/api-handler")>(
|
||||
"@/lib/api-handler",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
|
||||
h: T,
|
||||
): T => h,
|
||||
requireAdmin: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAdmin, HttpError } from "@/lib/api-handler";
|
||||
|
||||
const mockUserFindMany = vi.mocked(prisma.user.findMany);
|
||||
const mockRequireAdmin = vi.mocked(requireAdmin);
|
||||
|
||||
describe("GET /api/admin/users", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockRequireAdmin.mockResolvedValue({
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "admin-1",
|
||||
username: "admin",
|
||||
role: "ADMIN",
|
||||
} as never,
|
||||
});
|
||||
mockUserFindMany.mockResolvedValue([] as never);
|
||||
});
|
||||
|
||||
it("rejects with 401 when no session", async () => {
|
||||
mockRequireAdmin.mockRejectedValue(new HttpError(401, "Not authenticated"));
|
||||
await expect(GET()).rejects.toThrow("Not authenticated");
|
||||
});
|
||||
|
||||
it("rejects with 403 when caller is not admin", async () => {
|
||||
mockRequireAdmin.mockRejectedValue(
|
||||
new HttpError(403, "Admin access required"),
|
||||
);
|
||||
await expect(GET()).rejects.toThrow("Admin access required");
|
||||
});
|
||||
|
||||
it("returns users sorted by createdAt ascending with passkey counts", async () => {
|
||||
const created = new Date("2024-01-01T00:00:00Z");
|
||||
mockUserFindMany.mockResolvedValue([
|
||||
{
|
||||
id: "u1",
|
||||
username: "alice",
|
||||
email: "alice@example.com",
|
||||
role: "ADMIN",
|
||||
createdAt: created,
|
||||
_count: { passkeys: 2 },
|
||||
},
|
||||
{
|
||||
id: "u2",
|
||||
username: "bob",
|
||||
email: null,
|
||||
role: "USER",
|
||||
createdAt: created,
|
||||
_count: { passkeys: 0 },
|
||||
},
|
||||
] as never);
|
||||
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: Array<Record<string, unknown>>;
|
||||
};
|
||||
expect(body.data).toHaveLength(2);
|
||||
expect(body.data[0]).toEqual({
|
||||
id: "u1",
|
||||
username: "alice",
|
||||
email: "alice@example.com",
|
||||
role: "ADMIN",
|
||||
createdAt: created.toISOString(),
|
||||
passkeyCount: 2,
|
||||
});
|
||||
expect(body.data[1].passkeyCount).toBe(0);
|
||||
expect(body.data[1].email).toBeNull();
|
||||
});
|
||||
|
||||
it("queries Prisma with the safe select shape (no password / sensitive cols)", async () => {
|
||||
await GET();
|
||||
expect(mockUserFindMany).toHaveBeenCalledTimes(1);
|
||||
const args = mockUserFindMany.mock.calls[0]?.[0] as {
|
||||
select: Record<string, unknown>;
|
||||
orderBy: { createdAt: "asc" | "desc" };
|
||||
};
|
||||
// Regression: the route must NOT pull password hash / token columns.
|
||||
expect(args.select).toEqual({
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
role: true,
|
||||
createdAt: true,
|
||||
_count: { select: { passkeys: true } },
|
||||
});
|
||||
expect(args.select).not.toHaveProperty("passwordHash");
|
||||
expect(args.orderBy).toEqual({ createdAt: "asc" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
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", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/lib/ai/provider")>(
|
||||
"@/lib/ai/provider",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
resolveProviderForTest: 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 { resolveProviderForTest } from "@/lib/ai/provider";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(resolveProviderForTest).mockReset();
|
||||
});
|
||||
|
||||
interface ApiErrorEnvelope {
|
||||
data: null;
|
||||
error: string;
|
||||
}
|
||||
|
||||
interface ApiSuccessEnvelope<T> {
|
||||
data: T;
|
||||
error: null;
|
||||
}
|
||||
|
||||
function emptyRequest(): Request {
|
||||
return new Request("http://localhost/api/ai/test", {
|
||||
method: "POST",
|
||||
headers: { "content-length": "0" },
|
||||
});
|
||||
}
|
||||
|
||||
function jsonRequest(body: unknown): Request {
|
||||
const text = JSON.stringify(body);
|
||||
return new Request("http://localhost/api/ai/test", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"content-length": String(text.length),
|
||||
},
|
||||
body: text,
|
||||
});
|
||||
}
|
||||
|
||||
// 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(resolveProviderForTest).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(emptyRequest() as never);
|
||||
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(emptyRequest() as never);
|
||||
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(emptyRequest() as never);
|
||||
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(emptyRequest() as never);
|
||||
expect(((await response.json()) as ApiErrorEnvelope).error).toBe(
|
||||
"Provider connection failed",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// v1.4 fix: the dropdown in /settings was not honoured — the test always
|
||||
// ran against the SAVED provider (Marc reported "wirft was Komisches raus").
|
||||
// The route now accepts a JSON override body and forwards the user's
|
||||
// unsaved selection to resolveProviderForTest().
|
||||
describe("POST /api/ai/test — dropdown-aware override", () => {
|
||||
function makeProviderThatSucceeds(model: string) {
|
||||
vi.mocked(resolveProviderForTest).mockResolvedValue({
|
||||
type: "anthropic",
|
||||
generateCompletion: vi.fn(async () => ({
|
||||
content: '{"ok":true}',
|
||||
providerType: "anthropic",
|
||||
model,
|
||||
tokensUsed: 5,
|
||||
})),
|
||||
} as never);
|
||||
}
|
||||
|
||||
it("forwards a dropdown override to resolveProviderForTest", async () => {
|
||||
makeProviderThatSucceeds("claude-3-5-sonnet-latest");
|
||||
const response = await POST(
|
||||
jsonRequest({
|
||||
provider: "ANTHROPIC",
|
||||
model: "claude-3-5-sonnet-latest",
|
||||
}) as never,
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
expect(resolveProviderForTest).toHaveBeenCalledWith("u-1", {
|
||||
provider: "ANTHROPIC",
|
||||
model: "claude-3-5-sonnet-latest",
|
||||
});
|
||||
const body = (await response.json()) as ApiSuccessEnvelope<{
|
||||
providerType: string;
|
||||
model: string;
|
||||
}>;
|
||||
expect(body.data.providerType).toBe("anthropic");
|
||||
expect(body.data.model).toBe("claude-3-5-sonnet-latest");
|
||||
});
|
||||
|
||||
it("rejects unknown provider values with 422", async () => {
|
||||
const response = await POST(
|
||||
jsonRequest({ provider: "MALICIOUS" }) as never,
|
||||
);
|
||||
expect(response.status).toBe(422);
|
||||
expect(resolveProviderForTest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the persisted config when the body is empty", async () => {
|
||||
makeProviderThatSucceeds("claude-3-5-sonnet-latest");
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(200);
|
||||
expect(resolveProviderForTest).toHaveBeenCalledWith("u-1", {});
|
||||
});
|
||||
|
||||
it("surfaces config errors as the same status as the resolver throws", async () => {
|
||||
const { AITestConfigError } = await import("@/lib/ai/provider");
|
||||
vi.mocked(resolveProviderForTest).mockRejectedValueOnce(
|
||||
new AITestConfigError(422, "Anthropic API key not configured"),
|
||||
);
|
||||
const response = await POST(
|
||||
jsonRequest({ provider: "ANTHROPIC" }) as never,
|
||||
);
|
||||
expect(response.status).toBe(422);
|
||||
const body = (await response.json()) as ApiErrorEnvelope;
|
||||
expect(body.error).toBe("Anthropic API key not configured");
|
||||
});
|
||||
});
|
||||
@@ -1,19 +1,62 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { resolveProvider } from "@/lib/ai/provider";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import {
|
||||
resolveProviderForTest,
|
||||
AITestConfigError,
|
||||
type AITestOverride,
|
||||
} from "@/lib/ai/provider";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const POST = apiHandler(async () => {
|
||||
const overrideSchema = z
|
||||
.object({
|
||||
provider: z
|
||||
.enum(["OPENAI", "ANTHROPIC", "LOCAL", "CHATGPT_OAUTH"])
|
||||
.optional()
|
||||
.nullable(),
|
||||
model: z.string().min(1).max(120).optional().nullable(),
|
||||
baseUrl: z.string().url().max(2048).optional().nullable(),
|
||||
anthropicKey: z.string().min(1).max(500).optional().nullable(),
|
||||
localKey: z.string().min(1).max(500).optional().nullable(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "ai.test" } });
|
||||
|
||||
const rl = await checkRateLimit(`ai-test:${user.id}`, 5, 60_000);
|
||||
if (!rl.allowed) return apiError("Too many test requests", 429);
|
||||
|
||||
const provider = await resolveProvider(user.id);
|
||||
// Body is optional. Empty body → behaves like before (test the saved
|
||||
// config). Non-empty body → tests the unsaved selection without
|
||||
// mutating the user row. Plaintext keys never persist.
|
||||
let override: AITestOverride = {};
|
||||
const contentLength = Number(request.headers.get("content-length") ?? "0");
|
||||
if (contentLength > 0) {
|
||||
const { data, error } = await safeJson<unknown>(request);
|
||||
if (error) return error;
|
||||
const parsed = overrideSchema.safeParse(data);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid override payload", 422);
|
||||
}
|
||||
override = parsed.data;
|
||||
}
|
||||
|
||||
let provider;
|
||||
try {
|
||||
provider = await resolveProviderForTest(user.id, override);
|
||||
} catch (e) {
|
||||
if (e instanceof AITestConfigError) {
|
||||
return apiError(e.message, e.status);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
if (provider.type === "none") {
|
||||
return apiError("No AI provider configured", 422);
|
||||
}
|
||||
@@ -34,10 +77,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) =>
|
||||
|
||||
@@ -14,7 +14,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const rl = await checkRateLimit(`codex-callback:${user.id}`, 10, 60_000);
|
||||
if (!rl.allowed) {
|
||||
const appUrl = process.env.APP_URL || process.env.NEXT_PUBLIC_APP_URL;
|
||||
return NextResponse.redirect(`${appUrl}/settings?codex_error=rate_limited`);
|
||||
return NextResponse.redirect(`${appUrl}/settings/integrations?codex_error=rate_limited`);
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
@@ -29,13 +29,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
if (error) {
|
||||
annotate({ meta: { oauth_error: error } });
|
||||
return NextResponse.redirect(
|
||||
`${appUrl}/settings?codex_error=${encodeURIComponent(error)}`,
|
||||
`${appUrl}/settings/integrations?codex_error=${encodeURIComponent(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
return NextResponse.redirect(
|
||||
`${appUrl}/settings?codex_error=missing_params`,
|
||||
`${appUrl}/settings/integrations?codex_error=missing_params`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
if (!storedState || !storedVerifier || state !== storedState) {
|
||||
return NextResponse.redirect(
|
||||
`${appUrl}/settings?codex_error=invalid_state`,
|
||||
`${appUrl}/settings/integrations?codex_error=invalid_state`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,9 +70,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
data: {
|
||||
codexAccessTokenEncrypted: encrypted.accessEncrypted,
|
||||
codexRefreshTokenEncrypted: encrypted.refreshEncrypted,
|
||||
codexTokenExpiresAt: new Date(
|
||||
Date.now() + tokens.expires_in * 1000,
|
||||
),
|
||||
codexTokenExpiresAt: new Date(Date.now() + tokens.expires_in * 1000),
|
||||
codexConnectedAt: new Date(),
|
||||
codexConnectionStatus: "connected",
|
||||
insightsCachedAt: null,
|
||||
@@ -86,12 +84,14 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
annotate({ action: { name: "codex.oauth.callback.success" } });
|
||||
|
||||
return NextResponse.redirect(`${appUrl}/settings?codex_connected=true`);
|
||||
return NextResponse.redirect(
|
||||
`${appUrl}/settings/integrations?codex_connected=true`,
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "unknown";
|
||||
annotate({ meta: { codex_token_error: message } });
|
||||
return NextResponse.redirect(
|
||||
`${appUrl}/settings?codex_error=token_exchange_failed`,
|
||||
`${appUrl}/settings/integrations?codex_error=token_exchange_failed`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
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() },
|
||||
refreshToken: { 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((raw: string) => `hashed:${raw}`),
|
||||
}));
|
||||
|
||||
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(prisma.refreshToken.create).mockResolvedValue({
|
||||
id: "rt-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);
|
||||
const createArgs = vi.mocked(prisma.apiToken.create).mock.calls[0][0];
|
||||
expect(createArgs.data.tokenHash).toBe(`hashed:${body.data.token}`);
|
||||
} 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_/);
|
||||
const createArgs = vi.mocked(prisma.apiToken.create).mock.calls[0][0];
|
||||
expect(createArgs.data.tokenHash).toBe(`hashed:${body.data.token}`);
|
||||
} finally {
|
||||
process.env.API_TOKEN_HMAC_KEY = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -3,12 +3,23 @@ import { loginPasswordSchema } from "@/lib/validations/auth";
|
||||
import { verifyPassword } from "@/lib/auth/password";
|
||||
import { createSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { ensureDbCompatibility } from "@/lib/db-compat";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { issueApiToken, isNativeClientRequest } from "@/lib/auth/issue-token";
|
||||
import {
|
||||
resolveTokenPolicy,
|
||||
shouldIssueBearerToken,
|
||||
} from "@/lib/auth/native-client";
|
||||
import { issueAccessAndRefresh } from "@/lib/auth/refresh-token";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const ip = getClientIp(request) ?? "unknown";
|
||||
@@ -74,6 +85,71 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
annotate({ action: { name: "auth.login.password" } });
|
||||
|
||||
// v1.4 G4: Native callers (iOS, n8n, Health-Connect, unrecognised UAs)
|
||||
// get a 24h access token + 60d rotating refresh token. Web UAs keep the
|
||||
// legacy 90d Bearer (issued only when X-Client-Type: native is set —
|
||||
// browser flows continue to use the session cookie).
|
||||
if (
|
||||
shouldIssueBearerToken(request.headers) ||
|
||||
isNativeClientRequest(request.headers)
|
||||
) {
|
||||
const policy = resolveTokenPolicy(request.headers);
|
||||
const deviceId = request.headers.get("x-device-id");
|
||||
|
||||
if (policy.refreshTokenDays !== null) {
|
||||
const bundle = await issueAccessAndRefresh({
|
||||
userId: user.id,
|
||||
policy,
|
||||
deviceId,
|
||||
userAgent: ua,
|
||||
ipAddress: ip,
|
||||
source: "login.password",
|
||||
});
|
||||
await auditLog("auth.token.autoissue.native", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: { source: "login.password", policy: "native" },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.token.autoissue.native" },
|
||||
meta: { token_policy: "native" },
|
||||
});
|
||||
return apiSuccess({
|
||||
user: { id: user.id, username: user.username },
|
||||
token: bundle.accessToken,
|
||||
tokenExpiresAt: bundle.accessTokenExpiresAt.toISOString(),
|
||||
refreshToken: bundle.refreshToken,
|
||||
refreshTokenExpiresAt: bundle.refreshTokenExpiresAt.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Web policy with explicit X-Client-Type:native (legacy iOS auto-login)
|
||||
const issued = await issueApiToken({
|
||||
userId: user.id,
|
||||
name: `web auto-login ${new Date().toISOString()}`,
|
||||
permissions: ["*"],
|
||||
expiresInDays: policy.accessTokenDays,
|
||||
});
|
||||
await auditLog("auth.token.autoissue.native", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: {
|
||||
tokenId: issued.tokenId,
|
||||
source: "login.password",
|
||||
policy: "web",
|
||||
},
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.token.autoissue.native" },
|
||||
meta: { token_policy: "web" },
|
||||
});
|
||||
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,126 @@
|
||||
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() },
|
||||
refreshToken: { 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((raw: string) => `hashed:${raw}`),
|
||||
}));
|
||||
|
||||
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(prisma.refreshToken.create).mockResolvedValue({
|
||||
id: "rt-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);
|
||||
const createArgs = vi.mocked(prisma.apiToken.create).mock.calls[0][0];
|
||||
expect(createArgs.data.tokenHash).toBe(`hashed:${body.data.token}`);
|
||||
} 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();
|
||||
});
|
||||
});
|
||||
@@ -1,24 +1,40 @@
|
||||
import { verifyAuthentication } from "@/lib/auth/passkey";
|
||||
import { createSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { ensureDbCompatibility } from "@/lib/db-compat";
|
||||
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";
|
||||
import {
|
||||
resolveTokenPolicy,
|
||||
shouldIssueBearerToken,
|
||||
} from "@/lib/auth/native-client";
|
||||
import { issueAccessAndRefresh } from "@/lib/auth/refresh-token";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
await ensureDbCompatibility();
|
||||
|
||||
const ip = getClientIp(request);
|
||||
const rl = await checkRateLimit(`auth:passkey-verify:${ip}`, 10, 15 * 60 * 1000);
|
||||
const rl = await checkRateLimit(
|
||||
`auth:passkey-verify:${ip}`,
|
||||
10,
|
||||
15 * 60 * 1000,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many attempts. Please wait 15 minutes.", 429);
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson<Record<string, unknown>>(request);
|
||||
const { data: body, error: jsonError } =
|
||||
await safeJson<Record<string, unknown>>(request);
|
||||
|
||||
if (jsonError) return jsonError;
|
||||
const challengeId = body.challengeId as string | undefined;
|
||||
@@ -59,6 +75,66 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
annotate({ action: { name: "auth.login.passkey" } });
|
||||
|
||||
if (
|
||||
shouldIssueBearerToken(request.headers) ||
|
||||
isNativeClientRequest(request.headers)
|
||||
) {
|
||||
const policy = resolveTokenPolicy(request.headers);
|
||||
const deviceId = request.headers.get("x-device-id");
|
||||
|
||||
if (policy.refreshTokenDays !== null) {
|
||||
const bundle = await issueAccessAndRefresh({
|
||||
userId: user.id,
|
||||
policy,
|
||||
deviceId,
|
||||
userAgent: ua,
|
||||
ipAddress: ip ?? null,
|
||||
source: "login.passkey",
|
||||
});
|
||||
await auditLog("auth.token.autoissue.native", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: { source: "login.passkey", policy: "native" },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.token.autoissue.native" },
|
||||
meta: { token_policy: "native" },
|
||||
});
|
||||
return apiSuccess({
|
||||
user: { id: user.id, username: user.username },
|
||||
token: bundle.accessToken,
|
||||
tokenExpiresAt: bundle.accessTokenExpiresAt.toISOString(),
|
||||
refreshToken: bundle.refreshToken,
|
||||
refreshTokenExpiresAt: bundle.refreshTokenExpiresAt.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
const issued = await issueApiToken({
|
||||
userId: user.id,
|
||||
name: `web auto-login ${new Date().toISOString()}`,
|
||||
permissions: ["*"],
|
||||
expiresInDays: policy.accessTokenDays,
|
||||
});
|
||||
await auditLog("auth.token.autoissue.native", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: {
|
||||
tokenId: issued.tokenId,
|
||||
source: "login.passkey",
|
||||
policy: "web",
|
||||
},
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.token.autoissue.native" },
|
||||
meta: { token_policy: "web" },
|
||||
});
|
||||
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,101 @@
|
||||
/**
|
||||
* POST /api/auth/refresh — exchange a one-time-use refresh token for a new
|
||||
* access + refresh pair. Audited on every call (success + failure).
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { resolveTokenPolicy } from "@/lib/auth/native-client";
|
||||
import {
|
||||
rotateRefreshToken,
|
||||
revokeRefreshToken,
|
||||
} from "@/lib/auth/refresh-token";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const ip = getClientIp(request) ?? "unknown";
|
||||
const rl = await checkRateLimit(`auth:refresh:${ip}`, 60, 15 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many refresh attempts. Please retry later.", 429);
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson<{
|
||||
refreshToken?: string;
|
||||
revoke?: boolean;
|
||||
}>(request);
|
||||
if (jsonError) return jsonError;
|
||||
if (!body.refreshToken || typeof body.refreshToken !== "string") {
|
||||
return apiError("refreshToken required", 422);
|
||||
}
|
||||
|
||||
// logout-on-device path: client wants to invalidate the refresh token.
|
||||
if (body.revoke) {
|
||||
const ok = await revokeRefreshToken(body.refreshToken);
|
||||
annotate({
|
||||
action: { name: "auth.token.refresh.revoke" },
|
||||
meta: { refresh_revoked: ok },
|
||||
});
|
||||
await auditLog("auth.token.refresh.revoke", {
|
||||
ipAddress: ip,
|
||||
details: { ok },
|
||||
});
|
||||
return apiSuccess({ revoked: ok });
|
||||
}
|
||||
|
||||
const policy = resolveTokenPolicy(request.headers);
|
||||
// Refresh always uses the native policy: a refresh token only exists for
|
||||
// native callers, and we never want to upgrade it back to a 90d token.
|
||||
if (policy.refreshTokenDays === null) {
|
||||
policy.refreshTokenDays = 60;
|
||||
policy.accessTokenDays = 1;
|
||||
}
|
||||
|
||||
const result = await rotateRefreshToken({
|
||||
refreshToken: body.refreshToken,
|
||||
policy,
|
||||
deviceId: request.headers.get("x-device-id"),
|
||||
userAgent: request.headers.get("user-agent"),
|
||||
ipAddress: ip,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
await auditLog("auth.token.refresh.failed", {
|
||||
ipAddress: ip,
|
||||
details: { reason: result.reason },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.token.refresh.failed" },
|
||||
meta: { refresh_failure: result.reason },
|
||||
});
|
||||
if (result.reason === "already_used") {
|
||||
return apiError(
|
||||
"Refresh token reuse detected — please log in again.",
|
||||
401,
|
||||
);
|
||||
}
|
||||
return apiError("Invalid refresh token", 401);
|
||||
}
|
||||
|
||||
await auditLog("auth.token.refresh", {
|
||||
ipAddress: ip,
|
||||
details: { policy: policy.policy },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.token.refresh" },
|
||||
meta: { token_policy: policy.policy },
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
token: result.bundle.accessToken,
|
||||
tokenExpiresAt: result.bundle.accessTokenExpiresAt.toISOString(),
|
||||
refreshToken: result.bundle.refreshToken,
|
||||
refreshTokenExpiresAt: result.bundle.refreshTokenExpiresAt.toISOString(),
|
||||
});
|
||||
});
|
||||
@@ -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,362 @@
|
||||
/**
|
||||
* 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";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { defaultLocale, type Locale } from "@/lib/i18n/config";
|
||||
|
||||
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;
|
||||
const locale: Locale =
|
||||
user.locale === "de" || user.locale === "en" ? user.locale : defaultLocale;
|
||||
const t = getServerTranslator(locale).t;
|
||||
|
||||
return apiSuccess({
|
||||
greeting: {
|
||||
salutation: t("dashboard.greetingSalutation", { name: greetingName }),
|
||||
date: now.toISOString(),
|
||||
},
|
||||
streak: {
|
||||
currentDays: streak.currentDays,
|
||||
longest: streak.longest,
|
||||
label: t("dashboard.streakLabel"),
|
||||
},
|
||||
compliance: {
|
||||
scheduledToday,
|
||||
takenToday,
|
||||
},
|
||||
highlightInsight: null,
|
||||
metrics,
|
||||
lastUpdated: now.toISOString(),
|
||||
});
|
||||
});
|
||||
@@ -29,7 +29,10 @@ const widgetIdEnum = z.enum([
|
||||
"sleep",
|
||||
"steps",
|
||||
"glucose",
|
||||
"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 } },
|
||||
@@ -216,15 +212,30 @@ export const GET = apiHandler(async () => {
|
||||
const bpMedications = medications.filter(
|
||||
(med) => (categoryMap[med.id] ?? "OTHER") === "BLOOD_PRESSURE",
|
||||
);
|
||||
// Single round-trip for all medications instead of N+1: one query keyed
|
||||
// on `medicationId IN (...)`, then group in memory. The previous loop
|
||||
// hit Postgres once per medication, which scales poorly for users with
|
||||
// many active meds.
|
||||
const allEvents = medications.length
|
||||
? await prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
medicationId: { in: medications.map((m) => m.id) },
|
||||
userId,
|
||||
scheduledFor: { gte: ninetyDaysAgo },
|
||||
},
|
||||
orderBy: { scheduledFor: "desc" },
|
||||
})
|
||||
: [];
|
||||
|
||||
const eventsByMed = new Map<string, typeof allEvents>();
|
||||
for (const ev of allEvents) {
|
||||
const list = eventsByMed.get(ev.medicationId);
|
||||
if (list) list.push(ev);
|
||||
else eventsByMed.set(ev.medicationId, [ev]);
|
||||
}
|
||||
|
||||
for (const med of medications) {
|
||||
const events = await prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
medicationId: med.id,
|
||||
userId,
|
||||
scheduledFor: { gte: ninetyDaysAgo },
|
||||
},
|
||||
orderBy: { scheduledFor: "desc" },
|
||||
});
|
||||
const events = eventsByMed.get(med.id) ?? [];
|
||||
const mapped = events.map((e) => ({
|
||||
takenAt: e.takenAt,
|
||||
skipped: e.skipped,
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -17,13 +17,17 @@ import {
|
||||
classifyPulseByTarget,
|
||||
getPersonalizedPulseTarget,
|
||||
} from "@/lib/analytics/pulse-targets";
|
||||
import type { MeasurementType, GlucoseContext } from "@/generated/prisma/client";
|
||||
import type {
|
||||
MeasurementType,
|
||||
GlucoseContext,
|
||||
} from "@/generated/prisma/client";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
getEffectiveRange,
|
||||
type ThresholdOverridesJson,
|
||||
} from "@/lib/analytics/effective-range";
|
||||
import { getBodyFatTargetRange } from "@/lib/analytics/value-bands";
|
||||
import { thresholdMetricForContext, resolveGlucoseUnit } from "@/lib/glucose";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -105,20 +109,24 @@ export const GET = apiHandler(async () => {
|
||||
select: { type: true, value: true, measuredAt: true },
|
||||
});
|
||||
|
||||
// Also get the absolute latest measurement per type (even if older than 30 days)
|
||||
// Also get the absolute latest measurement per type (even if older than 30 days).
|
||||
// Single grouped query replaces the previous N×findFirst loop — DISTINCT ON
|
||||
// (Postgres) emulated via a windowed orderBy + take-1 per type would still be
|
||||
// N round-trips, so we sort once on the wide query and pick per-type below.
|
||||
const latestEverByType = await prisma.measurement.findMany({
|
||||
where: { userId, type: { in: types } },
|
||||
orderBy: { measuredAt: "desc" },
|
||||
distinct: ["type"],
|
||||
select: { type: true, value: true },
|
||||
});
|
||||
|
||||
const latestByType: Record<string, number | null> = {};
|
||||
const avg30ByType: Record<string, number | null> = {};
|
||||
|
||||
for (const t of types) {
|
||||
// Latest value
|
||||
const latest = await prisma.measurement.findFirst({
|
||||
where: { userId, type: t },
|
||||
orderBy: { measuredAt: "desc" },
|
||||
select: { value: true },
|
||||
});
|
||||
const latest = latestEverByType.find((row) => row.type === t);
|
||||
latestByType[t] = latest?.value ?? null;
|
||||
|
||||
// 30-day average
|
||||
const recentOfType = recentMeasurements.filter((m) => m.type === t);
|
||||
if (recentOfType.length > 0) {
|
||||
const sum = recentOfType.reduce((acc, m) => acc + m.value, 0);
|
||||
@@ -200,7 +208,7 @@ export const GET = apiHandler(async () => {
|
||||
unit: "mmHg",
|
||||
range: bpRange ? { min: bpRange.sysLow, max: bpRange.sysHigh } : null,
|
||||
classification: bpClassification,
|
||||
source: "ESC/ESH 2018",
|
||||
source: "ESH 2023",
|
||||
// Extra fields for diastolic
|
||||
} as TargetItem);
|
||||
|
||||
@@ -248,7 +256,7 @@ export const GET = apiHandler(async () => {
|
||||
? { category: "Moderate", color: "#f1fa8c" }
|
||||
: { category: "Low", color: "#ff5555" }
|
||||
: null,
|
||||
source: "ESC/ESH 2018",
|
||||
source: "ESH 2023",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -330,16 +338,12 @@ export const GET = apiHandler(async () => {
|
||||
const cls = classifyBodyFat(latestByType.BODY_FAT, gender, age);
|
||||
bodyFatClassification = { category: cls.category, color: cls.color };
|
||||
}
|
||||
// Body fat ranges depend on gender/age; provide simplified ranges
|
||||
let bodyFatRange: { min: number; max: number } | null = null;
|
||||
if (gender === "MALE") {
|
||||
bodyFatRange = { min: 14, max: 24 };
|
||||
} else if (gender === "FEMALE") {
|
||||
bodyFatRange = { min: 21, max: 31 };
|
||||
} else if (age != null) {
|
||||
// Average of male/female fitness+average ranges
|
||||
bodyFatRange = { min: 17.5, max: 27.5 };
|
||||
}
|
||||
// Single source of truth for the body-fat green band lives in
|
||||
// `value-bands.ts`. v1.3.3 had three different hardcoded ranges across
|
||||
// value-bands.ts / targets/route.ts / classifications.ts; the v1.4
|
||||
// marathon consolidated them onto `getBodyFatTargetRange` (ACE
|
||||
// fitness + acceptable bands).
|
||||
const bodyFatRange = age != null ? getBodyFatTargetRange(gender) : null;
|
||||
targets.push({
|
||||
type: "BODY_FAT",
|
||||
label: "Body fat",
|
||||
@@ -368,7 +372,13 @@ export const GET = apiHandler(async () => {
|
||||
unit: "steps",
|
||||
range: stepsRange,
|
||||
classification: stepsClassification,
|
||||
source: "WHO",
|
||||
// WHO publishes activity *time* (150–300 min/wk moderate),
|
||||
// not a step quota. The closest peer-reviewed dose-response
|
||||
// for the 8 000–15 000 band is Saint-Maurice JAMA 2020. The
|
||||
// AI prompts at src/lib/insights/prompts/{base-system,
|
||||
// general-status}.ts already enforce this attribution; this
|
||||
// surface label was the last "WHO" mislabel in the codebase.
|
||||
source: "Saint-Maurice JAMA 2020",
|
||||
});
|
||||
|
||||
// 8. Medication Compliance (average across active medications)
|
||||
@@ -585,7 +595,8 @@ export const GET = apiHandler(async () => {
|
||||
"BEDTIME",
|
||||
];
|
||||
const glucoseUnit = resolveGlucoseUnit(dbUser?.glucoseUnit ?? null);
|
||||
const overrides = (dbUser?.thresholdsJson ?? null) as ThresholdOverridesJson | null;
|
||||
const overrides = (dbUser?.thresholdsJson ??
|
||||
null) as ThresholdOverridesJson | null;
|
||||
const profileForRange = {
|
||||
heightCm,
|
||||
dateOfBirth: dbUser?.dateOfBirth ?? null,
|
||||
@@ -651,7 +662,10 @@ export const GET = apiHandler(async () => {
|
||||
});
|
||||
}
|
||||
|
||||
annotate({ action: { name: "insights.targets" }, meta: { targetCount: targets.length } });
|
||||
annotate({
|
||||
action: { name: "insights.targets" },
|
||||
meta: { targetCount: targets.length },
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
targets,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } 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: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: vi.fn((x: string) => x),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
interface ApiErrorEnvelope {
|
||||
data: null;
|
||||
error: string;
|
||||
}
|
||||
interface ApiSuccessEnvelope<T> {
|
||||
data: T;
|
||||
error: null;
|
||||
}
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 5,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findUnique).mockReset();
|
||||
global.fetch = vi.fn() as never;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
function emptyRequest(): Request {
|
||||
return new Request("http://localhost/api/integrations/moodlog/test", {
|
||||
method: "POST",
|
||||
headers: { "content-length": "0" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("POST /api/integrations/moodlog/test", () => {
|
||||
it("happy path returns ok with shape", async () => {
|
||||
const lastSyncedAt = new Date("2026-05-01T10:00:00.000Z");
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
moodLogUrlEncrypted: "https://example.com",
|
||||
moodLogApiKeyEncrypted: "ml-key-123",
|
||||
moodLogLastSyncedAt: lastSyncedAt,
|
||||
} as never);
|
||||
(global.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ entries: [] }), { status: 200 }),
|
||||
);
|
||||
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as ApiSuccessEnvelope<{
|
||||
ok: boolean;
|
||||
statusCode: number;
|
||||
latencyMs: number;
|
||||
lastSyncedAt: string;
|
||||
}>;
|
||||
expect(body.data.ok).toBe(true);
|
||||
expect(body.data.statusCode).toBe(200);
|
||||
expect(typeof body.data.latencyMs).toBe("number");
|
||||
expect(body.data.lastSyncedAt).toBe(lastSyncedAt.toISOString());
|
||||
|
||||
// Probe must hit the actual sync endpoint with Bearer auth, not a bare HEAD
|
||||
// against the configured URL.
|
||||
const fetchCall = (global.fetch as unknown as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0];
|
||||
const init = fetchCall[1] as RequestInit;
|
||||
expect(init.method).toBe("GET");
|
||||
const headers = init.headers as Record<string, string>;
|
||||
expect(headers.Authorization).toBe("Bearer ml-key-123");
|
||||
expect(init.redirect).toBe("manual");
|
||||
});
|
||||
|
||||
it("rate-limit denial returns 429 with no upstream call", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(429);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 422 when no moodLog URL configured", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
moodLogUrlEncrypted: null,
|
||||
moodLogApiKeyEncrypted: null,
|
||||
moodLogLastSyncedAt: null,
|
||||
} as never);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(422);
|
||||
const body = (await response.json()) as ApiErrorEnvelope;
|
||||
expect(body.error).toMatch(/not configured/i);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects SSRF (private IP) with 422", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
moodLogUrlEncrypted: "http://127.0.0.1/moodlog",
|
||||
moodLogApiKeyEncrypted: "ml-key-123",
|
||||
moodLogLastSyncedAt: null,
|
||||
} as never);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(422);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not leak upstream URL or Bearer token on upstream rejection", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
moodLogUrlEncrypted: "https://example.com",
|
||||
moodLogApiKeyEncrypted: "sk-secret",
|
||||
moodLogLastSyncedAt: null,
|
||||
} as never);
|
||||
(global.fetch as unknown as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
||||
new Error(
|
||||
"Authorization: Bearer sk-secret echoed from https://example.com/api/integrations/health-log/mood",
|
||||
),
|
||||
);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
const text = await response.text();
|
||||
expect(response.status).toBe(502);
|
||||
expect(text).not.toMatch(/sk-/);
|
||||
expect(text).not.toMatch(/example\.com/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { isPublicUrl } from "@/lib/validations/notifications";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const TIMEOUT_MS = 5_000;
|
||||
|
||||
function categoriseHttpStatus(status: number): {
|
||||
code: string;
|
||||
message: string;
|
||||
} {
|
||||
if (status === 401 || status === 403) {
|
||||
return {
|
||||
code: "credentials_rejected",
|
||||
message: "moodLog rejected the API key",
|
||||
};
|
||||
}
|
||||
if (status === 404) {
|
||||
return {
|
||||
code: "endpoint_not_found",
|
||||
message: "moodLog endpoint not found at the configured URL",
|
||||
};
|
||||
}
|
||||
if (status === 429) {
|
||||
return {
|
||||
code: "rate_limited",
|
||||
message: "moodLog rate-limited the request",
|
||||
};
|
||||
}
|
||||
if (status >= 500) {
|
||||
return {
|
||||
code: "upstream_error",
|
||||
message: "moodLog returned a server error",
|
||||
};
|
||||
}
|
||||
return { code: "connection_failed", message: "moodLog connection failed" };
|
||||
}
|
||||
|
||||
function redact(text: string): string {
|
||||
return text
|
||||
.replace(/Bearer\s+\S+/gi, "Bearer [REDACTED]")
|
||||
.replace(/https?:\/\/\S+/gi, "[url]");
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async (_request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "integrations.moodlog.test" } });
|
||||
|
||||
const rl = await checkRateLimit(`moodlog-test:${user.id}`, 5, 60_000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many test requests", 429, {
|
||||
errorCode: "rate_limited_self",
|
||||
});
|
||||
}
|
||||
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
moodLogUrlEncrypted: true,
|
||||
moodLogApiKeyEncrypted: true,
|
||||
moodLogLastSyncedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!dbUser?.moodLogUrlEncrypted || !dbUser.moodLogApiKeyEncrypted) {
|
||||
return apiError("moodLog not configured", 422, {
|
||||
errorCode: "not_configured",
|
||||
});
|
||||
}
|
||||
|
||||
let baseUrl: string;
|
||||
let apiKey: string;
|
||||
try {
|
||||
baseUrl = decrypt(dbUser.moodLogUrlEncrypted);
|
||||
apiKey = decrypt(dbUser.moodLogApiKeyEncrypted);
|
||||
} catch {
|
||||
return apiError("moodLog credentials unreadable", 422, {
|
||||
errorCode: "credentials_unreadable",
|
||||
});
|
||||
}
|
||||
|
||||
if (!isPublicUrl(baseUrl)) {
|
||||
return apiError("moodLog URL must be a public HTTP(S) endpoint", 422, {
|
||||
errorCode: "url_not_public",
|
||||
});
|
||||
}
|
||||
|
||||
// Probe the actual sync endpoint with the actual auth header — a HEAD on the
|
||||
// bare URL would return 200 from the static landing page even if the API key
|
||||
// is invalid. Use a 1-day window to keep the upstream payload small.
|
||||
let probeUrl: URL;
|
||||
try {
|
||||
probeUrl = new URL("/api/integrations/health-log/mood", baseUrl);
|
||||
} catch {
|
||||
return apiError("moodLog URL is invalid", 422, {
|
||||
errorCode: "url_invalid",
|
||||
});
|
||||
}
|
||||
const yesterday = new Date(Date.now() - 24 * 3600 * 1000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
probeUrl.searchParams.set("from", yesterday);
|
||||
probeUrl.searchParams.set("to", today);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
const start = performance.now();
|
||||
|
||||
try {
|
||||
const res = await fetch(probeUrl.toString(), {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
signal: controller.signal,
|
||||
cache: "no-store",
|
||||
// `manual` neutralises the redirect-follow SSRF where a public host
|
||||
// serves a 302 to 169.254.169.254 (cloud metadata) or RFC1918 ranges.
|
||||
redirect: "manual",
|
||||
});
|
||||
const latencyMs = Math.round(performance.now() - start);
|
||||
|
||||
// 3xx in `manual` redirect mode → we never followed; treat as success
|
||||
// failure rather than chase the Location header.
|
||||
if (res.status >= 300 && res.status < 400) {
|
||||
annotate({ meta: { moodlog_test_status: res.status } });
|
||||
return apiError("moodLog redirected — check the configured URL", 502, {
|
||||
errorCode: "redirected",
|
||||
});
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const cat = categoriseHttpStatus(res.status);
|
||||
annotate({
|
||||
meta: {
|
||||
moodlog_test_status: res.status,
|
||||
moodlog_test_code: cat.code,
|
||||
moodlog_test_latency_ms: latencyMs,
|
||||
},
|
||||
});
|
||||
return apiError(cat.message, 502, { errorCode: cat.code });
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
ok: true,
|
||||
statusCode: res.status,
|
||||
latencyMs,
|
||||
lastSyncedAt: dbUser.moodLogLastSyncedAt,
|
||||
});
|
||||
} catch (e) {
|
||||
const err = e as Error;
|
||||
const isAbort = err.name === "AbortError";
|
||||
const code = isAbort ? "timeout" : "connection_failed";
|
||||
annotate({
|
||||
meta: {
|
||||
moodlog_test_code: code,
|
||||
moodlog_test_error: redact(err.message).slice(0, 300),
|
||||
},
|
||||
});
|
||||
return apiError(
|
||||
isAbort ? "moodLog request timed out" : "moodLog connection failed",
|
||||
502,
|
||||
{ errorCode: code },
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
// --- Module-boundary mocks must come before importing the route. ---
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
appSettings: { findUnique: vi.fn() },
|
||||
user: { findMany: vi.fn() },
|
||||
moodEntry: {
|
||||
upsert: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/moodlog-secret", () => ({
|
||||
readMoodLogSecret: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: vi.fn(() => ({ setAuth: vi.fn(), addWarning: vi.fn() })),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { readMoodLogSecret } from "@/lib/moodlog-secret";
|
||||
|
||||
const VALID_PAYLOAD = {
|
||||
event: "mood.created" as const,
|
||||
timestamp: "2026-05-08T12:00:00.000Z",
|
||||
entry: {
|
||||
date: "2026-05-08",
|
||||
time: "2026-05-08T12:00:00.000Z",
|
||||
mood: "GUT" as const,
|
||||
score: 4,
|
||||
tags: ["walk", "sun"],
|
||||
loggedVia: "WEB" as const,
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 30,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
moodLogGlobal: true,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findMany).mockResolvedValue([
|
||||
{ id: "user-1", moodLogWebhookSecret: "encrypted-blob-1" },
|
||||
] as never);
|
||||
vi.mocked(readMoodLogSecret).mockReturnValue("plaintext-secret");
|
||||
vi.mocked(prisma.moodEntry.upsert).mockResolvedValue({} as never);
|
||||
vi.mocked(prisma.moodEntry.deleteMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
});
|
||||
|
||||
function jsonRequest(
|
||||
body: unknown,
|
||||
headers: Record<string, string> = {},
|
||||
): NextRequest {
|
||||
return new NextRequest("http://localhost/api/integrations/moodlog/webhook", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", ...headers },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
describe("POST /api/integrations/moodlog/webhook", () => {
|
||||
it("returns 401 when X-Webhook-Secret header is missing", async () => {
|
||||
const res = await POST(jsonRequest(VALID_PAYLOAD));
|
||||
expect(res.status).toBe(401);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toMatch(/missing/i);
|
||||
expect(prisma.moodEntry.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 401 when no enabled user matches the supplied secret", async () => {
|
||||
// The single candidate decrypts to a different plaintext.
|
||||
vi.mocked(readMoodLogSecret).mockReturnValue("different-plaintext");
|
||||
|
||||
const res = await POST(
|
||||
jsonRequest(VALID_PAYLOAD, { "x-webhook-secret": "plaintext-secret" }),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
expect(readMoodLogSecret).toHaveBeenCalledWith("encrypted-blob-1");
|
||||
expect(prisma.moodEntry.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("decrypts each candidate via readMoodLogSecret and matches timing-safely", async () => {
|
||||
vi.mocked(prisma.user.findMany).mockResolvedValueOnce([
|
||||
{ id: "user-A", moodLogWebhookSecret: "blob-A" },
|
||||
{ id: "user-B", moodLogWebhookSecret: "blob-B" },
|
||||
] as never);
|
||||
|
||||
// First candidate decrypts to a wrong value, second decrypts to the
|
||||
// submitted secret. The route must walk the list and pick user-B.
|
||||
vi.mocked(readMoodLogSecret)
|
||||
.mockReturnValueOnce("nope")
|
||||
.mockReturnValueOnce("plaintext-secret");
|
||||
|
||||
const res = await POST(
|
||||
jsonRequest(VALID_PAYLOAD, { "x-webhook-secret": "plaintext-secret" }),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(readMoodLogSecret).toHaveBeenNthCalledWith(1, "blob-A");
|
||||
expect(readMoodLogSecret).toHaveBeenNthCalledWith(2, "blob-B");
|
||||
const upsertArgs = vi.mocked(prisma.moodEntry.upsert).mock.calls[0][0];
|
||||
// The route must use the matched user's id, not the first candidate's.
|
||||
expect(
|
||||
(upsertArgs.where as { userId_date_moodLoggedAt: { userId: string } })
|
||||
.userId_date_moodLoggedAt.userId,
|
||||
).toBe("user-B");
|
||||
});
|
||||
|
||||
it("happy path: valid secret + mood.created upserts a mood entry tagged MOODLOG", async () => {
|
||||
const res = await POST(
|
||||
jsonRequest(VALID_PAYLOAD, { "x-webhook-secret": "plaintext-secret" }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(prisma.moodEntry.upsert).toHaveBeenCalledTimes(1);
|
||||
const args = vi.mocked(prisma.moodEntry.upsert).mock.calls[0][0];
|
||||
expect(args).toMatchObject({
|
||||
where: {
|
||||
userId_date_moodLoggedAt: {
|
||||
userId: "user-1",
|
||||
date: "2026-05-08",
|
||||
moodLoggedAt: new Date("2026-05-08T12:00:00.000Z"),
|
||||
},
|
||||
},
|
||||
update: {
|
||||
mood: "GUT",
|
||||
score: 4,
|
||||
tags: JSON.stringify(["walk", "sun"]),
|
||||
source: "MOODLOG",
|
||||
},
|
||||
create: {
|
||||
userId: "user-1",
|
||||
date: "2026-05-08",
|
||||
mood: "GUT",
|
||||
score: 4,
|
||||
tags: JSON.stringify(["walk", "sun"]),
|
||||
source: "MOODLOG",
|
||||
moodLoggedAt: new Date("2026-05-08T12:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("idempotency: a duplicate mood.updated event hits upsert with the same composite key, so the DB no-ops", async () => {
|
||||
const updated = { ...VALID_PAYLOAD, event: "mood.updated" as const };
|
||||
|
||||
const r1 = await POST(
|
||||
jsonRequest(updated, { "x-webhook-secret": "plaintext-secret" }),
|
||||
);
|
||||
const r2 = await POST(
|
||||
jsonRequest(updated, { "x-webhook-secret": "plaintext-secret" }),
|
||||
);
|
||||
|
||||
expect(r1.status).toBe(200);
|
||||
expect(r2.status).toBe(200);
|
||||
expect(prisma.moodEntry.upsert).toHaveBeenCalledTimes(2);
|
||||
const wheres = vi.mocked(prisma.moodEntry.upsert).mock.calls.map(
|
||||
(c) =>
|
||||
(
|
||||
c[0].where as {
|
||||
userId_date_moodLoggedAt: {
|
||||
userId: string;
|
||||
date: string;
|
||||
moodLoggedAt: Date;
|
||||
};
|
||||
}
|
||||
).userId_date_moodLoggedAt,
|
||||
);
|
||||
// Both calls target the same composite key — Postgres ON CONFLICT
|
||||
// makes the second upsert a no-op write.
|
||||
expect(wheres[0]).toEqual(wheres[1]);
|
||||
});
|
||||
|
||||
it("mood.deleted removes any matching entry rather than upserting", async () => {
|
||||
const deletedPayload = {
|
||||
...VALID_PAYLOAD,
|
||||
event: "mood.deleted" as const,
|
||||
};
|
||||
const res = await POST(
|
||||
jsonRequest(deletedPayload, {
|
||||
"x-webhook-secret": "plaintext-secret",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.moodEntry.upsert).not.toHaveBeenCalled();
|
||||
expect(prisma.moodEntry.deleteMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
userId: "user-1",
|
||||
date: "2026-05-08",
|
||||
moodLoggedAt: new Date("2026-05-08T12:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("webhook.test ping short-circuits to 200 with no DB writes", async () => {
|
||||
const res = await POST(
|
||||
jsonRequest(
|
||||
{ event: "webhook.test" },
|
||||
{ "x-webhook-secret": "plaintext-secret" },
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.moodEntry.upsert).not.toHaveBeenCalled();
|
||||
expect(prisma.moodEntry.deleteMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects payloads that don't match the schema with 400", async () => {
|
||||
const bad = {
|
||||
event: "mood.created",
|
||||
timestamp: "2026-05-08T12:00:00.000Z",
|
||||
// entry missing required fields
|
||||
entry: { date: "not-a-date", time: "x", mood: "BAD", score: 0 },
|
||||
};
|
||||
const res = await POST(
|
||||
jsonRequest(bad, { "x-webhook-secret": "plaintext-secret" }),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toMatch(/invalid payload/i);
|
||||
expect(prisma.moodEntry.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 403 when the global moodLog toggle is disabled", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
|
||||
moodLogGlobal: false,
|
||||
} as never);
|
||||
const res = await POST(
|
||||
jsonRequest(VALID_PAYLOAD, { "x-webhook-secret": "plaintext-secret" }),
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
expect(prisma.user.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 429 when rate limit is exceeded — no secret lookup performed", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
const res = await POST(
|
||||
jsonRequest(VALID_PAYLOAD, { "x-webhook-secret": "plaintext-secret" }),
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
expect(prisma.user.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } 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: {
|
||||
withingsConnection: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: vi.fn((x: string) => x),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/withings/sync", () => ({
|
||||
getValidToken: vi.fn(),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { getValidToken } from "@/lib/withings/sync";
|
||||
|
||||
interface ApiErrorEnvelope {
|
||||
data: null;
|
||||
error: string;
|
||||
}
|
||||
interface ApiSuccessEnvelope<T> {
|
||||
data: T;
|
||||
error: null;
|
||||
}
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 5,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
vi.mocked(prisma.withingsConnection.findUnique).mockReset();
|
||||
vi.mocked(getValidToken).mockReset();
|
||||
global.fetch = vi.fn() as never;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
function emptyRequest(): Request {
|
||||
return new Request("http://localhost/api/integrations/withings/test", {
|
||||
method: "POST",
|
||||
headers: { "content-length": "0" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("POST /api/integrations/withings/test", () => {
|
||||
it("happy path returns ok with shape", async () => {
|
||||
const lastSyncedAt = new Date("2026-05-01T10:00:00.000Z");
|
||||
vi.mocked(getValidToken).mockResolvedValueOnce({
|
||||
accessToken: "plain-access",
|
||||
connection: { id: "wc-1", withingsUserId: "wu-1" },
|
||||
});
|
||||
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValueOnce({
|
||||
lastSyncedAt,
|
||||
} as never);
|
||||
(global.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ status: 0, body: {} }), { status: 200 }),
|
||||
);
|
||||
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as ApiSuccessEnvelope<{
|
||||
ok: boolean;
|
||||
lastSyncedAt: string;
|
||||
latencyMs: number;
|
||||
}>;
|
||||
expect(body.data.ok).toBe(true);
|
||||
expect(typeof body.data.latencyMs).toBe("number");
|
||||
expect(body.data.lastSyncedAt).toBe(lastSyncedAt.toISOString());
|
||||
});
|
||||
|
||||
it("rate-limit denial returns 429 with no upstream call", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(429);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 422 when no Withings connection exists", async () => {
|
||||
vi.mocked(getValidToken).mockResolvedValueOnce(null);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(422);
|
||||
const body = (await response.json()) as ApiErrorEnvelope;
|
||||
expect(body.error).toMatch(/not connected/i);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sanitises 401 upstream — does not leak Bearer token or upstream URL", async () => {
|
||||
vi.mocked(getValidToken).mockResolvedValueOnce({
|
||||
accessToken: "sk-secret",
|
||||
connection: { id: "wc-1", withingsUserId: "wu-1" },
|
||||
});
|
||||
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValueOnce({
|
||||
lastSyncedAt: null,
|
||||
} as never);
|
||||
(global.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
error: "Authorization: Bearer sk-secret echoed",
|
||||
url: "https://wbsapi.withings.net/measure",
|
||||
}),
|
||||
{ status: 401 },
|
||||
),
|
||||
);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
const text = await response.text();
|
||||
expect(response.status).toBe(502);
|
||||
expect(text).not.toMatch(/sk-/);
|
||||
expect(text).not.toMatch(/wbsapi\.withings\.net/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getValidToken } from "@/lib/withings/sync";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const WITHINGS_MEASURE_URL = "https://wbsapi.withings.net/measure";
|
||||
const TIMEOUT_MS = 5_000;
|
||||
|
||||
type CategorisedError = { code: string; message: string };
|
||||
|
||||
function categoriseHttpStatus(status: number): CategorisedError {
|
||||
if (status === 401 || status === 403) {
|
||||
return {
|
||||
code: "credentials_rejected",
|
||||
message: "Withings rejected the credentials",
|
||||
};
|
||||
}
|
||||
if (status === 429) {
|
||||
return {
|
||||
code: "rate_limited",
|
||||
message: "Withings rate-limited the request",
|
||||
};
|
||||
}
|
||||
if (status >= 500) {
|
||||
return {
|
||||
code: "upstream_error",
|
||||
message: "Withings returned a server error",
|
||||
};
|
||||
}
|
||||
return { code: "connection_failed", message: "Withings connection failed" };
|
||||
}
|
||||
|
||||
// Withings returns HTTP 200 with `status` in the JSON body to signal API-level
|
||||
// errors. Reference: https://developer.withings.com/api-reference/#section/Response-status
|
||||
function categoriseApiStatus(apiStatus: number): CategorisedError {
|
||||
switch (apiStatus) {
|
||||
case 100:
|
||||
case 101:
|
||||
case 102:
|
||||
case 401:
|
||||
return {
|
||||
code: "credentials_rejected",
|
||||
message: "Withings rejected the credentials",
|
||||
};
|
||||
case 601:
|
||||
return {
|
||||
code: "rate_limited",
|
||||
message: "Withings rate-limited the request",
|
||||
};
|
||||
case 503:
|
||||
return {
|
||||
code: "upstream_error",
|
||||
message: "Withings reported a temporary server error",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
code: "upstream_error",
|
||||
message: "Withings returned an error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async (_request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "integrations.withings.test" } });
|
||||
|
||||
const rl = await checkRateLimit(`withings-test:${user.id}`, 5, 60_000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many test requests", 429, {
|
||||
errorCode: "rate_limited_self",
|
||||
});
|
||||
}
|
||||
|
||||
const tokenInfo = await getValidToken(user.id);
|
||||
if (!tokenInfo) {
|
||||
return apiError("Withings not connected", 422, {
|
||||
errorCode: "not_configured",
|
||||
});
|
||||
}
|
||||
|
||||
const connection = await prisma.withingsConnection.findUnique({
|
||||
where: { userId: user.id },
|
||||
select: { lastSyncedAt: true },
|
||||
});
|
||||
|
||||
const params = new URLSearchParams({
|
||||
action: "getmeas",
|
||||
meastypes: "1",
|
||||
lastupdate: Math.floor(Date.now() / 1000 - 60).toString(),
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
const start = performance.now();
|
||||
|
||||
try {
|
||||
const res = await fetch(WITHINGS_MEASURE_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Authorization: `Bearer ${tokenInfo.accessToken}`,
|
||||
},
|
||||
body: params.toString(),
|
||||
signal: controller.signal,
|
||||
redirect: "manual",
|
||||
});
|
||||
const latencyMs = Math.round(performance.now() - start);
|
||||
|
||||
if (!res.ok) {
|
||||
const cat = categoriseHttpStatus(res.status);
|
||||
annotate({
|
||||
meta: {
|
||||
withings_test_status: res.status,
|
||||
withings_test_code: cat.code,
|
||||
withings_test_latency_ms: latencyMs,
|
||||
},
|
||||
});
|
||||
return apiError(cat.message, 502, { errorCode: cat.code });
|
||||
}
|
||||
|
||||
let json: { status?: number } = {};
|
||||
try {
|
||||
json = (await res.json()) as { status?: number };
|
||||
} catch {
|
||||
annotate({
|
||||
meta: { withings_test_code: "upstream_invalid_json" },
|
||||
});
|
||||
return apiError("Withings returned an unparseable response", 502, {
|
||||
errorCode: "upstream_invalid_json",
|
||||
});
|
||||
}
|
||||
|
||||
if (json.status !== 0) {
|
||||
const apiStatus = json.status ?? -1;
|
||||
const cat = categoriseApiStatus(apiStatus);
|
||||
annotate({
|
||||
meta: {
|
||||
withings_test_api_status: apiStatus,
|
||||
withings_test_code: cat.code,
|
||||
withings_test_latency_ms: latencyMs,
|
||||
},
|
||||
});
|
||||
return apiError(cat.message, 502, { errorCode: cat.code });
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
ok: true,
|
||||
lastSyncedAt: connection?.lastSyncedAt ?? null,
|
||||
latencyMs,
|
||||
});
|
||||
} catch (e) {
|
||||
const err = e as Error;
|
||||
const isAbort = err.name === "AbortError";
|
||||
const code = isAbort ? "timeout" : "connection_failed";
|
||||
annotate({
|
||||
meta: {
|
||||
withings_test_code: code,
|
||||
withings_test_error: redact(err.message).slice(0, 300),
|
||||
},
|
||||
});
|
||||
return apiError(
|
||||
isAbort ? "Withings request timed out" : "Withings connection failed",
|
||||
502,
|
||||
{ errorCode: code },
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
});
|
||||
|
||||
// Strip any `Bearer <token>` substring before annotating; the catch path can
|
||||
// see DNS / TLS errors that include the request init via err.cause in some
|
||||
// runtimes, and we never want a token in Loki.
|
||||
function redact(text: string): string {
|
||||
return text.replace(/Bearer\s+\S+/gi, "Bearer [REDACTED]");
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Loaded 100 of 291 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user