mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 20:32:02 +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,81 @@
|
||||
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
|
||||
|
||||
- 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
|
||||
@@ -11,6 +11,11 @@
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# playwright e2e
|
||||
/test-results
|
||||
/playwright-report
|
||||
/playwright/.cache
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"shadcn": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "shadcn@latest", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
# HealthLog v1.4.6 marathon — project
|
||||
|
||||
Personal health-tracking PWA. v1.4.5 live in production. v1.4.6 marathon
|
||||
brief is `docs/audit/v146-findings.md` — that file is the source of truth
|
||||
for every fix, feature, and gate. `CLAUDE.md` covers project-wide rules
|
||||
(tooling, conventions, hard constraints).
|
||||
|
||||
## Scope
|
||||
|
||||
- Tier 1 (T1-T9): release blockers
|
||||
- New feature: chart bucketing for ranges > 1 year
|
||||
- Tier 2 (P1-P20): polish
|
||||
- Multi-agent QA + simplify pass
|
||||
- Release & deploy v1.4.6
|
||||
- GitHub releases backfill (v1.4.2-v1.4.6) + GHCR housekeeping
|
||||
- Docs + landing site sync
|
||||
|
||||
## Hard rules (Marc, verbatim)
|
||||
|
||||
1. Niemals `--no-verify`.
|
||||
2. Niemals `--no-gpg-sign` ohne Anfrage.
|
||||
3. Niemals force-push to main.
|
||||
4. Changelog/Release-Notes user-facing — kein "Claude", keine internen
|
||||
Phasennamen.
|
||||
5. Out-of-scope > typo → defer to v1.5.
|
||||
|
||||
## References
|
||||
|
||||
- `CLAUDE.md` — project conventions
|
||||
- `docs/audit/v146-findings.md` — release spec
|
||||
- `CHANGELOG.md` — release tone & format
|
||||
@@ -0,0 +1,21 @@
|
||||
# HealthLog v1.4.6 — roadmap
|
||||
|
||||
Milestone: **v1.4.6** (overnight 2026-05-09)
|
||||
|
||||
| Phase | Goal | State |
|
||||
| ----- | ------------------------------------------------ | ------- |
|
||||
| 1 | CI green (e2e workflow root cause + fix) | pending |
|
||||
| 2 | Tier 1 fixes T1–T9 | pending |
|
||||
| 3 | New feature: chart bucketing for >1y ranges | pending |
|
||||
| 4 | Tier 2 polish P1–P20 | pending |
|
||||
| 5 | Multi-agent QA + simplify pass | pending |
|
||||
| 6 | Pre-release verification | pending |
|
||||
| 7 | Release commit, tag, deploy, verify production | pending |
|
||||
| 8 | GitHub releases backfill (v1.4.2–v1.4.6) + GHCR | pending |
|
||||
| 9 | Docs site (`healthlog-docs`) + landing site sync | pending |
|
||||
| 10 | Summary doc + Marc-facing brief | pending |
|
||||
|
||||
Stop conditions: production red, large un-spec'd issue, context cutoff,
|
||||
budget short of polish — write status doc, do not auto-rollback.
|
||||
|
||||
Spec: `docs/audit/v146-findings.md` (every finding ID, every test, every gate).
|
||||
@@ -0,0 +1,92 @@
|
||||
# v1.4.6 marathon — state log
|
||||
|
||||
Status: **release tagged, awaiting Coolify deploy** (2026-05-09 ~02:30)
|
||||
|
||||
## Phase 0 — Bootstrap ✅
|
||||
|
||||
Committed `ecca54d`.
|
||||
|
||||
## Phase 1 — CI green ⚠️
|
||||
|
||||
- e2e workflow fixed in `bcd1de4` (pnpm-version override) and `46e686f`
|
||||
(mobile project → Pixel 5, locale-cookie anchor, login-form open).
|
||||
- Latest run on the v1.4.6 SHA is queued; conclusion not yet known
|
||||
at tag time. Per release-spec, e2e green is not a hard gate.
|
||||
|
||||
## Phase 2 — Tier 1 fixes (T1–T9) ✅
|
||||
|
||||
| ID | Commit | Note |
|
||||
| ------- | --------- | ---------------------------------------------------- |
|
||||
| T1 + T2 | `8aae7d6` | tile fill + muted-foreground hierarchy |
|
||||
| T3 | `a75fbc6` | primary recommendation chart-token render |
|
||||
| T4 | `eba898f` | aiBaseUrl cross-provider leak fix + test |
|
||||
| T5 | `c8ee28d` | insights/generate 502 → 422 |
|
||||
| T6 | `4aeb8c9` | admin status-card hrefs + tightened test |
|
||||
| T7 | `31959e4` | bug-report toggle now blocks /api/feedback + UI |
|
||||
| T8 | `c3ca861` | data-wipe preserves AuditLog + scope copy |
|
||||
| T9 | `1adda80` | per-card window 360+24 buckets, 7 generators + tests |
|
||||
|
||||
## Phase 3 — Chart bucketing ✅
|
||||
|
||||
`6a64df0` — `bucketTimeSeries` helper + chip in chart header + 14
|
||||
unit tests.
|
||||
|
||||
## Phase 4 — Tier 2 polish ✅
|
||||
|
||||
P1-P5 in `fda8dd8`, P6+P10 in `dc6db82`, P7-P9 in `5c884b3`, P11 in
|
||||
`e903d9a`, P12 in `c7b6005`, P13 in `4a159d2`, P14 in `dcc697c`,
|
||||
P15+P16 in `89b5b80`, P17 in `505f318`, P18 in `86a4b52`, P19 in
|
||||
`2654337`, P20 in `dc4507a`.
|
||||
|
||||
## Phase 5 — QA ✅
|
||||
|
||||
3 parallel reviewers (security / design / code-review). No
|
||||
CRITICAL / HIGH carried into v1.4.6 except 3 quick follow-ups
|
||||
applied in `6757518`:
|
||||
|
||||
- ai-section.tsx orange tokens → dracula tokens (also fixes contrast)
|
||||
- status-card-grid.tsx CTAs relabelled honestly ("Open integrations",
|
||||
"Open system status")
|
||||
- idempotency.ts `text.includes("sk-")` → tightened regex with
|
||||
false-positive regression tests for "task-id" / "risk-management"
|
||||
|
||||
Deferred to v1.5: notification-channel data-wipe scope (M1), Berlin
|
||||
TZ DST math in cross-metric joins (HIGH from code-review),
|
||||
exhaustive-deps + set-state-in-effect refactor in about-section.
|
||||
|
||||
## Phase 6 — Pre-release verification ✅
|
||||
|
||||
`pnpm typecheck` clean, `pnpm lint` 0 errors / 12 warnings,
|
||||
`pnpm format:check` clean (after the prettier sweep in `02b9955`),
|
||||
`pnpm test` 714 passed, `pnpm test:integration` 10 passed.
|
||||
`pnpm build` fails locally on Node 25 (Reflect.get private member
|
||||
bug in Turbopack); CI Docker uses Node 22 and is the canonical
|
||||
release build path.
|
||||
|
||||
## Phase 7 — Release ✅
|
||||
|
||||
- `package.json` 1.4.6 (`a852612`)
|
||||
- `CHANGELOG.md` v1.4.6 block (`a852612`)
|
||||
- Tag `v1.4.6` pushed
|
||||
- GHCR docker-publish: in_progress
|
||||
- Coolify deploy: pending GHCR
|
||||
|
||||
## Phase 8 — Releases ✅
|
||||
|
||||
GitHub releases v1.4.2, v1.4.3, v1.4.4, v1.4.5, v1.4.6 backfilled
|
||||
from the CHANGELOG blocks. URLs:
|
||||
https://github.com/MBombeck/HealthLog/releases/tag/v1.4.{2,3,4,5,6}
|
||||
|
||||
GHCR untagged-manifest cleanup: not run; pre-existing tagged
|
||||
versions (v1.x.x) are pinned, no orphan manifests visible. Defer
|
||||
unless Marc asks.
|
||||
|
||||
## Phase 9 — Docs / landing 🔄
|
||||
|
||||
`general-purpose` agent dispatched to bring `healthlog-docs` from
|
||||
v1.2 to v1.4.6 and apply minimal updates to `healthlog-landing`.
|
||||
Running in background.
|
||||
|
||||
## Phase 10 — Summary
|
||||
|
||||
`docs/audit/v146-summary.md` — last step.
|
||||
@@ -6,7 +6,19 @@ 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.3.3 — Pulse oximetry (SpO₂) as a first-class measurement type, layered on top of v1.3.2 body composition (TBW + Bone Mass). SSRF-hardened outbound fetches (now also covers Web-Push endpoint + Bearer-scope wildcard handling + IP-geolocation HTTPS-only), GHCR multi-arch images (`linux/amd64` + `linux/arm64`) with SLSA provenance + SBOM, pg-boss graceful SIGTERM drain + audit-log retention purge (GDPR Art. 5(1)(e)), blocking TypeScript CI, locale-integrity test guard. moodLog webhook secret now AES-GCM encrypted at rest. See GitHub Releases + CHANGELOG.md for the full feature timeline (v1.0 → v1.3).
|
||||
**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
|
||||
|
||||
@@ -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,7 +88,8 @@ src/
|
||||
│ ├── medications/page.tsx # Medications management
|
||||
│ ├── notifications/page.tsx # Notification preferences matrix
|
||||
│ ├── onboarding/page.tsx # 4-step guided onboarding
|
||||
│ ├── settings/page.tsx # All settings (8 top-level sections, ~3150 lines — split tracked for 1.4.0)
|
||||
│ ├── 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/ # 100+ API route files (admin, auth, measurements, medications, mood, insights, integrations, ingest, dashboard, feedback, tokens, notifications, monitoring, …)
|
||||
@@ -95,7 +108,8 @@ src/
|
||||
│ ├── charts/ # Recharts wrappers, compliance charts
|
||||
│ ├── insights/ # AI insights cards (status, advisor)
|
||||
│ ├── gamification/ # Achievement cards, progress
|
||||
│ ├── settings/ # Settings-page section components
|
||||
│ ├── 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
|
||||
@@ -126,8 +140,8 @@ messages/
|
||||
├── de.json # German translations (primary UI language)
|
||||
└── en.json # English translations
|
||||
prisma/
|
||||
├── schema.prisma # Database schema (25 models)
|
||||
└── migrations/ # Migration files (0001–0024; latest: oxygen_saturation)
|
||||
├── 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)
|
||||
@@ -174,11 +188,11 @@ 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 (~3150 lines), 8 top-level sections. Sidebar switches to "settings mode" showing section shortcuts. Splitting into per-section files is tracked for 1.4.0 — until then, ESLint `react-hooks/set-state-in-effect` stays non-blocking because of the long-standing violations in this file.
|
||||
- Sections scroll-to with highlight animation (`section-highlight` CSS class).
|
||||
- Top-level section IDs: `section-allgemein`, `section-sicherheit`, `section-benachrichtigungen`, `section-personalization`, `section-integration`, `section-api`, `section-export`, `section-danger-zone`. Sub-anchors inside those sections include `profil`, `passwort`, `passkeys`, `telegram`, `ntfy`, `web-push`, `insights`, `dashboard-layout`, `thresholds`, `withings`, `moodlog`, `api-tokens`, `api-endpoints`.
|
||||
- **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
|
||||
|
||||
@@ -216,7 +230,7 @@ These are hard-won lessons. Ignoring them will cause errors:
|
||||
|
||||
## Database Models (Prisma)
|
||||
|
||||
25 models: `User`, `Passkey`, `Session`, `AuthChallenge`, `Measurement`, `Medication`, `MedicationSchedule`, `MedicationIntakeEvent`, `ReminderPhaseConfig`, `TelegramReminderMessage`, `TelegramScheduledDeletion`, `ApiToken`, `WithingsConnection`, `MoodEntry`, `AppSettings`, `Feedback`, `AuditLog`, `NotificationChannel`, `NotificationPreference`, `PushSubscription`, `DataBackup`, `UserAchievement`, `RateLimit`, `Device`, `IdempotencyKey`.
|
||||
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
|
||||
|
||||
|
||||
+676
@@ -1,5 +1,681 @@
|
||||
# Changelog
|
||||
|
||||
## [1.4.7] — 2026-05-09
|
||||
|
||||
### Fixed — Settings · KI
|
||||
|
||||
- **"Mit ChatGPT verbinden" funktioniert wieder.** Seit der Funktion
|
||||
Anfang v1.4.x existiert war der OAuth-Pfad gegen die falsche Domain
|
||||
verdrahtet — ChatGPT hat unsere Authorize-/Token-Requests still
|
||||
geschluckt und das normale Web-Interface gerendert, kein Callback,
|
||||
kein Fehler. Der Flow läuft jetzt über `auth.openai.com/oauth/...`,
|
||||
identisch zum offiziellen `openai/codex` CLI: PKCE-Authorize, Token-
|
||||
Exchange, dann ein zweiter RFC-8693-Token-Exchange, der den
|
||||
`id_token` gegen einen OpenAI-API-Key tauscht. Dieser Key bucht
|
||||
gegen das ChatGPT-Abo, kein separates API-Plan-Quota nötig. Der
|
||||
Refresh-Token wird verschlüsselt mitgespeichert; vor jeder Anfrage
|
||||
rotiert HealthLog den API-Key transparent wenn er abläuft.
|
||||
|
||||
## [1.4.6] — 2026-05-09
|
||||
|
||||
### Fixed — Dashboard
|
||||
|
||||
- **Tile strip fills its row.** Each tile in the dashboard's
|
||||
measurement strip now stretches to its grid cell instead of
|
||||
shrinking to content width. On a 375 px viewport tiles in the
|
||||
same row are no longer 122-166 px wide — every tile takes the
|
||||
same share of the row.
|
||||
- **Secondary text is finally distinguishable from primary.**
|
||||
`--muted-foreground` was identical to `--foreground` in both
|
||||
light and dark mode, which flattened the entire visual
|
||||
hierarchy. Tile labels, "Ø7d:" prefixes, units, and inactive
|
||||
nav items now render in `#9aa3b3` (dark) / `#5b6273` (light) —
|
||||
both ≥ 4.5:1 against the surface.
|
||||
- **Trend cards land aligned with the chart strip below.**
|
||||
Padding bumped from `p-3` to `p-4 md:p-6` so the tiles match
|
||||
the surrounding chart cards. KPI typography (`text-3xl
|
||||
tracking-tight tabular-nums` for the value, `text-xs uppercase
|
||||
tracking-wide` for the label) gives the number proper weight
|
||||
without making the tile feel tall.
|
||||
- **Always-on Ø7d / Ø30d chips** — when an average is missing
|
||||
the chip renders `—` instead of disappearing, so vertical
|
||||
rhythm stays consistent across tiles.
|
||||
- **Welcome subtitle is visible on mobile** (was hidden below
|
||||
the `sm` breakpoint) and uses muted-foreground so it doesn't
|
||||
fight the headline.
|
||||
- **Medications card matches the other cards** — `rounded-xl`
|
||||
and `p-4 md:p-6` instead of the smaller `rounded-lg p-4`.
|
||||
|
||||
### New — Charts
|
||||
|
||||
- **Long-range charts now bucket their data.** Hitting "All" on
|
||||
an account with several years of measurements used to render
|
||||
thousands of daily dots — unreadable, and slow on mobile. The
|
||||
chart now picks an aggregation level from the visible range:
|
||||
daily for ≤ 90 days, weekly mean for 91-730 days, monthly
|
||||
mean for everything beyond. A small chip beside the chart
|
||||
title surfaces the active bucket ("Wochendurchschnitt" /
|
||||
"Monatsdurchschnitt" in DE, "Weekly avg" / "Monthly avg" in
|
||||
EN) so the bucket is never silent. Empty buckets are dropped
|
||||
rather than zero-padded.
|
||||
|
||||
### Fixed — KI
|
||||
|
||||
- **Hero recommendation no longer prints raw `metric:WEIGHT`
|
||||
tokens.** When the model embedded a chart token in the
|
||||
primary recommendation the renderer was printing the literal
|
||||
string. The "Key Takeaway" card now strips the tokens for
|
||||
prose and renders them as inline charts under the
|
||||
recommendation, the same pattern the summary block has used
|
||||
since v1.4.5.
|
||||
- **"Verbindung testen" stays readable when the model returns
|
||||
bad JSON.** `/api/insights/generate` returned 502 for parse
|
||||
errors, which Cloudflare swapped for an HTML error page;
|
||||
`await res.json()` in the browser then crashed. Same
|
||||
Cloudflare-rewrite trap the v1.4.5 ai/test fix solved — parse
|
||||
errors now map to 422 so the JSON body actually reaches the
|
||||
client.
|
||||
- **Saved API keys cannot leak to a stale local URL.** A user
|
||||
who had configured the Local provider with `http://192.168.x.x`
|
||||
and later switched to OpenAI / Anthropic kept that URL in the
|
||||
shared `aiBaseUrl` column, and the OpenAI / Anthropic clients
|
||||
were forwarding their cloud keys to that LAN host on every
|
||||
request. The PATCH that switches providers now wipes the
|
||||
column, and the OpenAI / Anthropic clients hardcode the
|
||||
canonical base URL regardless of what the column carries.
|
||||
|
||||
### Improved — KI Insights
|
||||
|
||||
- **The model now sees three years of history per metric.** Each
|
||||
per-card insight payload (general, blood pressure, pulse,
|
||||
weight, BMI, mood, medication compliance) used to clip the
|
||||
series to the last 30 daily means — way too narrow to detect
|
||||
drift that plays out over a year. The new payload combines
|
||||
360 daily means (`dayOffset 0..359`) with 24 monthly means for
|
||||
the older window (`monthOffset 12..35`), with a 50 KB safety
|
||||
guard that re-buckets at 180 daily days when a particularly
|
||||
noisy account would blow past the prompt token budget.
|
||||
|
||||
### Fixed — Admin
|
||||
|
||||
- **Status-card CTAs go to the right place.** Every "Manage
|
||||
users" / "View backups" link in the admin status grid was
|
||||
bouncing back to `/admin` because the hrefs were pointing at
|
||||
sub-routes that never shipped (`/admin/users`,
|
||||
`/admin/audit-log`) or at anchors whose IDs do not exist
|
||||
(`#integrations`, `#monitoring`, `#backups`). Each href now
|
||||
points at a real `section-*` anchor on the admin page, the
|
||||
CTA copy describes the destination honestly ("Open
|
||||
integrations", "Open system status"), and the unit test
|
||||
asserts each href maps to a known section ID — so the next
|
||||
refactor cannot regress this silently.
|
||||
- **Bug-report toggle finally hides the form.** The admin
|
||||
"Bug reports" switch had no effect because the gate lived on
|
||||
a legacy route the form does not call. The actual `/api/feedback`
|
||||
endpoint and the `/bugreport` page now both honor the toggle —
|
||||
the form disappears, and direct API calls return 503 with a
|
||||
readable error.
|
||||
- **Audit trail survives the data wipe.** The `Wipe all data`
|
||||
admin action used to delete `AuditLog` in the same transaction
|
||||
as the rest of the user data, which silently destroyed the
|
||||
one record actually saying "an admin wiped data". The wipe
|
||||
now leaves the audit log alone and adds an explicit
|
||||
`admin.data.clear.start` entry before the transaction begins,
|
||||
so even a crash mid-wipe leaves a trail. The success copy
|
||||
also enumerates the full scope (API tokens, Withings
|
||||
connections, auth challenges) instead of pretending only
|
||||
measurements / medications / intakes are affected.
|
||||
- **Cached insight reloads no longer burn a rate-limit token.**
|
||||
`POST /api/insights/generate` checked the rate limit before
|
||||
the cache return, so a noisy dashboard refresh would lock you
|
||||
out for an hour even when every request returned the same
|
||||
cached envelope. The check moved below the cache return.
|
||||
- **One failing health probe no longer blanks the whole admin
|
||||
status grid.** `Promise.all` in the status-overview API
|
||||
short-circuited on the first rejection. `Promise.allSettled`
|
||||
forces failed probes to the `alert` severity and lets the
|
||||
rest render normally, with a Wide Event annotation noting
|
||||
which probe(s) failed.
|
||||
- **Settings load errors surface inline instead of spinning
|
||||
forever.** When `useSystemStatus` or `useAdminSettings`
|
||||
rejects, the admin page now shows a "Failed to load…"
|
||||
banner instead of an indefinite skeleton.
|
||||
|
||||
### Improved — Settings · KI
|
||||
|
||||
- The KI section is fully internationalised — about 30 hard-coded
|
||||
German strings now flow through `t("settings.ai.…")` keys, and
|
||||
the model-preset list drops `gpt-5` (not a released model) and
|
||||
`o3-mini` (would require an o-series parameter contract that
|
||||
isn't wired up yet). The privacy "Raw data" warning uses
|
||||
Dracula tokens so the colour matches the rest of the panel.
|
||||
|
||||
### Improved — Polish
|
||||
|
||||
- Numeric spans on the dashboard tiles use `tabular-nums` so
|
||||
digits stop jiggling on every refresh.
|
||||
- The bottom-nav buffer is tighter on mobile and removed on
|
||||
desktop (`pb-[calc(4rem+env(safe-area-inset-bottom,0px))]
|
||||
md:pb-0`), so desktop content sits flush instead of leaving
|
||||
an unused 5 rem strip.
|
||||
- Onboarding "X von Y" / "%" progress drops the monospace font
|
||||
and keeps tabular-nums.
|
||||
- Feedback inbox category badges use Dracula tokens
|
||||
(`bg-dracula-red/15 text-dracula-red`) for theme cohesion.
|
||||
- Codex provider mirrors the OpenAI client's structured-error
|
||||
format (httpStatus / model / bodyExcerpt) so failures from
|
||||
ChatGPT-OAuth show the same readable message as failures from
|
||||
the BYO-key paths.
|
||||
- Logging redacts third-party AI keys: `redactSecrets` now scrubs
|
||||
`sk-` / `sk-ant-` patterns so a misconfigured client cannot
|
||||
leak its key into Wide Events. The idempotency body-content
|
||||
guard refuses to cache responses that contain those prefixes
|
||||
(with a regex tight enough to avoid false positives on words
|
||||
like "task-id" or "risk-management").
|
||||
- Danger-zone result colour follows mutation state instead of
|
||||
string-prefix-matching localized copy.
|
||||
|
||||
### Repo housekeeping
|
||||
|
||||
- `e2e` workflow is back to green: dropped the `pnpm/action-setup`
|
||||
version override (now reads `packageManager` from
|
||||
`package.json`), switched the mobile Playwright project from
|
||||
iPhone 13 (webkit) to Pixel 5 so chromium-only CI installs are
|
||||
enough, anchored the locale-cookie test at the configured base
|
||||
URL, and made the login spec open the password form before
|
||||
asserting on its inputs.
|
||||
- Repo-wide prettier sweep — long-deferred reformatting drift
|
||||
closed before the v1.4.6 tag.
|
||||
|
||||
## [1.4.5] — 2026-05-09
|
||||
|
||||
### Fixed — Settings · KI
|
||||
|
||||
- **"Verbindung testen" no longer crashes the page when the key is
|
||||
bad.** The endpoint mapped every upstream error to HTTP 502, which
|
||||
Cloudflare intercepts and replaces with its own HTML error page.
|
||||
`await res.json()` in the browser then crashed with `Unexpected
|
||||
token '<', "<!DOCTYPE "`. 401/403 from the provider now map to 422,
|
||||
429 to 429, and only genuine 5xx upstream errors keep the 502 —
|
||||
Cloudflare passes 4xx through untouched, so the React Query
|
||||
mutation reads the JSON body and surfaces a readable message.
|
||||
|
||||
### Fixed — Dashboard
|
||||
|
||||
- **Dashboard tiles use a CSS Grid layout again** — the v1.3-era
|
||||
pattern that gave each tile an identical width with symmetric gaps
|
||||
and an edge-to-edge fit with the charts below. v1.4.4 distributed
|
||||
width via `flex-1 basis-0`; CSS Grid is more honest about the
|
||||
intent and survives viewport changes more gracefully.
|
||||
`grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr))` keeps
|
||||
every track equal-width with a 9 rem floor; the row wraps when
|
||||
the floor no longer fits instead of triggering a horizontal
|
||||
scroll.
|
||||
|
||||
## [1.4.4] — 2026-05-08
|
||||
|
||||
### Fixed — Dashboard
|
||||
|
||||
- **Blood pressure is now two distinct tiles again** (sys + dia next
|
||||
to each other) — the v1.4.3 attempt at a combined tile read as a
|
||||
single double-wide entry, which Marc preferred to keep as two
|
||||
separate tiles for visual rhythm with the other metrics.
|
||||
- **All tiles share an equal width.** The previous strip used
|
||||
`shrink-0 grow basis-[10rem]`, which gave each tile a 160 px floor
|
||||
and let wider content (like the BP tile) push past it. Switched
|
||||
to `flex-1 basis-0 min-w-[9rem]` — every tile now starts from
|
||||
zero and gets the same share of the available row width, with a
|
||||
9 rem floor so they stay readable on narrow viewports. The strip
|
||||
scrolls horizontally if the row no longer fits.
|
||||
|
||||
## [1.4.3] — 2026-05-08
|
||||
|
||||
### Fixed — Dashboard
|
||||
|
||||
- **Tile strip is now the same width as the charts below it.** The
|
||||
small negative-margin bleed that helped the snap-scroll feel on
|
||||
mobile was leaking onto desktop and made the tile row 16 px wider
|
||||
than the chart strip. Fixed at `md` and up.
|
||||
- **Tiles all have the same height.** Blood pressure used to render
|
||||
as two stacked tiles inside one slot which made it taller than
|
||||
every neighbour. Sys/dia now share **one** tile and display as
|
||||
`117/79 mmHg` with one trend arrow. Tile count drops from 6 to 5.
|
||||
- **Settings sub-section "Übersicht" renamed to "Dashboard"** —
|
||||
matches the label in the main nav.
|
||||
- **"Persönliche Zielwerte" is its own settings section now** at
|
||||
`/settings/thresholds`, instead of being buried inside the
|
||||
Dashboard settings panel under the layout customizer.
|
||||
|
||||
### Fixed — Settings
|
||||
|
||||
- **API & Tokens tables fit the content column on desktop.**
|
||||
- **About → "Auf Updates prüfen" works again.** Production CSP
|
||||
blocked the direct call to `api.github.com` — the check now goes
|
||||
through a server-side proxy. The page also auto-checks once on
|
||||
mount when the previous result is older than 24 h, and shows a
|
||||
"Zuletzt geprüft am …" timestamp so you can tell when the answer
|
||||
was confirmed.
|
||||
- **About restructured into three titled cards** — HealthLog (version
|
||||
- license inline, no boxed badge), Quellen & Dokumentation, and
|
||||
Updates.
|
||||
- **KI provider — OpenAI users can enter their own API key.** Schema
|
||||
didn't have a column for it before; added it, plumbed it through
|
||||
the resolver and test endpoint, surfaced the input in the Settings
|
||||
→ KI panel.
|
||||
- **Model field is now a dropdown** with provider-specific presets
|
||||
plus an "Eigenes…" option for power users.
|
||||
- **"Mit ChatGPT verbinden" no longer dead-ends on chatgpt.com.** The
|
||||
OAuth URL was missing its `client_id`; with `CODEX_OAUTH_CLIENT_ID`
|
||||
set the handshake completes. When the env var isn't configured the
|
||||
button is hidden and a hint points the user at the API-key path.
|
||||
|
||||
### Improved — KI Insights
|
||||
|
||||
- **AI can illustrate findings with `metric:MOOD` charts inline.** A
|
||||
mood drift or adherence-risk finding now renders the dedicated
|
||||
Mood chart under the paragraph instead of just prose.
|
||||
- **Every finding must cite a concrete number** ("138/85 mmHg",
|
||||
"+0.4 mmol/L vs 30d-avg") rather than adjectives. Findings without
|
||||
a snapshot anchor are now omitted; cardinality is variable 0–8
|
||||
sorted by salience. Generic boilerplate ("drink enough water",
|
||||
"consult your doctor", etc.) is explicitly forbidden outside the
|
||||
disclaimer.
|
||||
|
||||
### Improved — Admin
|
||||
|
||||
- **Bug-report feature has an explicit on/off toggle.** Previously
|
||||
the only way to hide the report button was to clear the encrypted
|
||||
GitHub token, which forced a re-entry on resume.
|
||||
|
||||
### Improved — Polish
|
||||
|
||||
- **"Trennen" looks the same everywhere** (Withings, moodLog,
|
||||
KI/Codex). Outlined button with red text — same affordance for
|
||||
the same action.
|
||||
|
||||
## [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
|
||||
|
||||
@@ -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 (25 models). Uses `PrismaPg` adapter from `@prisma/adapter-pg`. Client singleton at `src/lib/db.ts`. Generated client at `src/generated/prisma/client` (note the `/client` suffix). Prisma config in `prisma.config.ts` (not in schema.prisma).
|
||||
- **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`.
|
||||
@@ -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,7 +83,9 @@ 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 (25 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/` — long-form audit notes (`docs/audit/`); end-user docs live in the separate site at https://docs.healthlog.dev
|
||||
@@ -107,6 +111,8 @@ 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
|
||||
|
||||
|
||||
+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
|
||||
|
||||
@@ -63,6 +63,10 @@ Most health apps lock your data behind proprietary clouds, push subscriptions, a
|
||||
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
@@ -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 (25 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 |
|
||||
@@ -275,83 +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 + 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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>
|
||||
|
||||
|
||||
+62
-14
@@ -1,35 +1,83 @@
|
||||
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
|
||||
# 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: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
|
||||
container_name: healthlog-app
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
# Plain ${VAR} interpolation only — no ${VAR:?error} validation
|
||||
# syntax. Some hosting platforms (e.g. Coolify) parse compose files
|
||||
# eagerly and store the *fallback error string* as the literal value
|
||||
# when the env var is unset, which then breaks the deployment.
|
||||
# Validation is enforced by docker-entrypoint.sh before the app starts.
|
||||
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}"
|
||||
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
|
||||
|
||||
+1493
-1000
File diff suppressed because it is too large.
Load diff
@@ -1,4 +1,5 @@
|
||||
# HealthLog Audit-Plan — Phasen-Übersicht
|
||||
|
||||
**Datum:** 2026-04-26
|
||||
**Audit-Bericht:** `~/infra/reports/healthlog-audit-2026-04-26.md`
|
||||
**Status:** Geplant, noch nicht in Execution
|
||||
@@ -13,19 +14,19 @@
|
||||
|
||||
## Phasen-Sequenz (10 Phasen)
|
||||
|
||||
| # | Phase | Skill | Effort | Issue-Bezug |
|
||||
|---|---|---|---|---|
|
||||
| **1** | Setup-Notfall + Versions-Sync | `gsd:quick` | ≤4 h | #87 (Pflaster), #88 |
|
||||
| **2** | Docker-Image-Pipeline (GHCR-Push + Doc-Konsolidierung) | `gsd:plan-phase` | ~1 d | #88 |
|
||||
| **3** | Issue #89 — TBW + Bone Mass hardcoded | `gsd:plan-phase` | ~1 d | #89 |
|
||||
| **4** | `apiClient`-Wrapper + geteilte Zod-Schemas | `gsd:research-phase` → `gsd:plan-phase` | 2-3 d | #87 (strukturell) |
|
||||
| **3.5** | **Onboarding-Redesign** (2 Steps, TTFV <60s) | `gsd:research-phase` → `gsd:plan-phase` | ~2 d | #87 (strukturell) |
|
||||
| **4.5** | Empty-State-Dashboard-Tiles (eine pro Type) | `gsd:plan-phase` | ~1 d | #87, #89 |
|
||||
| **4.6** | „Getting Started"-Checklist auf Dashboard | `gsd:plan-phase` | ~4 h | #87 |
|
||||
| **5** | Security + Reliability Hardening | `gsd:plan-phase` | 1-2 d | — |
|
||||
| **6** | Test-Coverage-Boost (Auth/Passkey/Withings/pg-boss) | `gsd:plan-phase` | 1-2 d | — |
|
||||
| **7** | UX/i18n/a11y Polish (Residuals aus #73) | `gsd:quick` | ≤4 h | #73 (Residuals) |
|
||||
| **8** | Settings-Page-Splitting (3143 → Sub-Components) | `gsd:research-phase` → `gsd:plan-phase` | 2-3 d | — |
|
||||
| # | Phase | Skill | Effort | Issue-Bezug |
|
||||
| ------- | ------------------------------------------------------ | --------------------------------------- | ------ | ------------------- |
|
||||
| **1** | Setup-Notfall + Versions-Sync | `gsd:quick` | ≤4 h | #87 (Pflaster), #88 |
|
||||
| **2** | Docker-Image-Pipeline (GHCR-Push + Doc-Konsolidierung) | `gsd:plan-phase` | ~1 d | #88 |
|
||||
| **3** | Issue #89 — TBW + Bone Mass hardcoded | `gsd:plan-phase` | ~1 d | #89 |
|
||||
| **4** | `apiClient`-Wrapper + geteilte Zod-Schemas | `gsd:research-phase` → `gsd:plan-phase` | 2-3 d | #87 (strukturell) |
|
||||
| **3.5** | **Onboarding-Redesign** (2 Steps, TTFV <60s) | `gsd:research-phase` → `gsd:plan-phase` | ~2 d | #87 (strukturell) |
|
||||
| **4.5** | Empty-State-Dashboard-Tiles (eine pro Type) | `gsd:plan-phase` | ~1 d | #87, #89 |
|
||||
| **4.6** | „Getting Started"-Checklist auf Dashboard | `gsd:plan-phase` | ~4 h | #87 |
|
||||
| **5** | Security + Reliability Hardening | `gsd:plan-phase` | 1-2 d | — |
|
||||
| **6** | Test-Coverage-Boost (Auth/Passkey/Withings/pg-boss) | `gsd:plan-phase` | 1-2 d | — |
|
||||
| **7** | UX/i18n/a11y Polish (Residuals aus #73) | `gsd:quick` | ≤4 h | #73 (Residuals) |
|
||||
| **8** | Settings-Page-Splitting (3143 → Sub-Components) | `gsd:research-phase` → `gsd:plan-phase` | 2-3 d | — |
|
||||
|
||||
**Reihenfolge:** 1 → 2 → 3 → **4 (apiClient)** → **3.5 (Onboarding nutzt apiClient)** → 4.5 → 4.6 → 5 → 6 → 7 → 8.
|
||||
|
||||
@@ -64,6 +65,7 @@
|
||||
**Goal:** User-Anfrage von Manschk3rl beantworten. Blueprint = v1.2-Glucose-Migration.
|
||||
|
||||
**Touchpoints:**
|
||||
|
||||
1. `prisma/schema.prisma`: `MeasurementType` Enum + 2 neue Werte
|
||||
2. `prisma/migrations/0019_*` (additive `ALTER TYPE ... ADD VALUE`)
|
||||
3. `src/lib/validations/measurement.ts`: `unitMap`, `VALUE_RANGES`
|
||||
@@ -94,6 +96,7 @@
|
||||
**Goal:** TTFV <60s, 90% Completion (statt aktuell ~8% global).
|
||||
|
||||
**Neuer Flow:**
|
||||
|
||||
- Step 1: Welcome + Anzeigename (optional) + Einheiten + Größe (optional). Sekundärlink „Skip everything → Dashboard".
|
||||
- Step 2: Multi-Select-Chips „Was willst du tracken?" (Weight/BP/Pulse/Mood/Glucose/Sleep/Steps/BodyFat/TBW/BoneMass/Medications). Pre-checked Defaults. Speichert in `User.dashboardWidgetsJson`.
|
||||
- Routing nach Onboarding: direkt zum Dashboard mit „Getting Started"-Card (s. Phase 4.6).
|
||||
@@ -149,6 +152,7 @@
|
||||
Coverage 35% → 60% auf kritischen Pfaden.
|
||||
|
||||
**Top-5-Test-Suites:**
|
||||
|
||||
1. Session-Validation Roundtrip (`src/lib/auth/session.ts`)
|
||||
2. Passkey-Register/Verify-Integration (Attestation-Flow)
|
||||
3. Notification-Dispatcher Multi-Channel (TELEGRAM/NTFY/WEB_PUSH)
|
||||
@@ -162,6 +166,7 @@ Coverage 35% → 60% auf kritischen Pfaden.
|
||||
## Phase 7 — UX/i18n/a11y Polish (≤4 h)
|
||||
|
||||
**Residuals aus Issue #73 + neue Findings:**
|
||||
|
||||
- I18N-002: Admin-Page Hardcoded „Web Push" / „Bug Report" → `t(...)`
|
||||
- I18N-003: `medication-card.tsx` `Intl.DateTimeFormat("en-CA")` und `toLocaleString("en-US")` → `useFormatters()`
|
||||
- A11Y-001: `phase-config-dialog.tsx` Text-Indicator neben Farb-Punkt (WCAG 1.4.1)
|
||||
@@ -184,36 +189,41 @@ Coverage 35% → 60% auf kritischen Pfaden.
|
||||
## Issue-Vorschlags-Abdeckung
|
||||
|
||||
### Issue #87 (Onboarding-Meds verschwinden)
|
||||
|
||||
- **Pflaster:** Phase 1 (`.ok`-Check + Toast)
|
||||
- **Strukturelle Lösung:** Phase 4 (`apiClient`) + Phase 3.5 (neuer Flow ohne Med-Wizard) + Phase 4.5 (Empty-State-Tiles, Meds direkt sichtbar) + Phase 4.6 (Checklist)
|
||||
|
||||
### Issue #88 (Setup holprig — 4 Hauptpunkte + 3 Verbesserungsvorschläge)
|
||||
| #88 Punkt | Phase | Status |
|
||||
|---|---|---|
|
||||
| (a) Bundled compose ≠ docs version | Phase 2 (DOC-005) | abgedeckt |
|
||||
| (b) ghcr.io 404 (kein Image gepusht) | Phase 2 (DX-001 GHA-Workflow) | abgedeckt |
|
||||
| (c) `.env.example` `localhost:5432` vs Compose `db:5432` | Phase 1 (DX-003) | abgedeckt |
|
||||
| (d) `expose:3000` statt `ports:3000:3000` | Phase 1 (DX-002) | abgedeckt |
|
||||
| Vorschlag: GHA für Image-Push (GHCR/Docker Hub) | Phase 2 | abgedeckt |
|
||||
| Vorschlag: Merge „Getting started > Installation" + „Self-Hosting > Docker" | Phase 2 (DX-004) | abgedeckt |
|
||||
| Vorschlag: Same presets across compose+docs+.env.example | Phase 1 + Phase 2 | abgedeckt |
|
||||
|
||||
| #88 Punkt | Phase | Status |
|
||||
| --------------------------------------------------------------------------- | ----------------------------- | --------- |
|
||||
| (a) Bundled compose ≠ docs version | Phase 2 (DOC-005) | abgedeckt |
|
||||
| (b) ghcr.io 404 (kein Image gepusht) | Phase 2 (DX-001 GHA-Workflow) | abgedeckt |
|
||||
| (c) `.env.example` `localhost:5432` vs Compose `db:5432` | Phase 1 (DX-003) | abgedeckt |
|
||||
| (d) `expose:3000` statt `ports:3000:3000` | Phase 1 (DX-002) | abgedeckt |
|
||||
| Vorschlag: GHA für Image-Push (GHCR/Docker Hub) | Phase 2 | abgedeckt |
|
||||
| Vorschlag: Merge „Getting started > Installation" + „Self-Hosting > Docker" | Phase 2 (DX-004) | abgedeckt |
|
||||
| Vorschlag: Same presets across compose+docs+.env.example | Phase 1 + Phase 2 | abgedeckt |
|
||||
|
||||
### Issue #89 (TBW + Bone Mass)
|
||||
|
||||
- Phase 3 hardcoded mit Withings-Mapping. Optional `/api/ingest/measurements` für n8n in Phase 3.
|
||||
|
||||
### Issue #73 (UX-Liste mit 8 Punkten — CLOSED in v1.2)
|
||||
| #73 Punkt | v1.2-Status | Residual? |
|
||||
|---|---|---|
|
||||
| 1. Passkey-Errors unklar | Resolved (Package C, typed errors) | TEST-002 (keine Tests) → Phase 6 |
|
||||
| 2. Number-Formatting (DE 70,5 vs US 70.5) | Resolved (Package B, locale-aware formatters) | I18N-003 in `medication-card.tsx` → Phase 7 |
|
||||
| 3. Chart-Labels „7P/30P/90P/All" | Resolved (Package A, „7 Pkt"/„7 pts") | — |
|
||||
| 4. Dashboard nicht real-time | Resolved (`invalidateKeys` durchgängig) | — |
|
||||
| 5. GitHub-Setup-Confusion | Resolved (Built-in Feedback in v1.2) | — |
|
||||
| 6. Built-in Feedback | Done in v1.2 | — |
|
||||
| 7. Admin-Save ohne Error-UX | Partial (Success-Msg ja, Error-Highlight schwach) | UX-002 → Phase 7 |
|
||||
| 8. Blood-Sugar | Done in v1.2 | — |
|
||||
|
||||
| #73 Punkt | v1.2-Status | Residual? |
|
||||
| ----------------------------------------- | ------------------------------------------------- | ------------------------------------------- |
|
||||
| 1. Passkey-Errors unklar | Resolved (Package C, typed errors) | TEST-002 (keine Tests) → Phase 6 |
|
||||
| 2. Number-Formatting (DE 70,5 vs US 70.5) | Resolved (Package B, locale-aware formatters) | I18N-003 in `medication-card.tsx` → Phase 7 |
|
||||
| 3. Chart-Labels „7P/30P/90P/All" | Resolved (Package A, „7 Pkt"/„7 pts") | — |
|
||||
| 4. Dashboard nicht real-time | Resolved (`invalidateKeys` durchgängig) | — |
|
||||
| 5. GitHub-Setup-Confusion | Resolved (Built-in Feedback in v1.2) | — |
|
||||
| 6. Built-in Feedback | Done in v1.2 | — |
|
||||
| 7. Admin-Save ohne Error-UX | Partial (Success-Msg ja, Error-Highlight schwach) | UX-002 → Phase 7 |
|
||||
| 8. Blood-Sugar | Done in v1.2 | — |
|
||||
|
||||
### Issue #74 (raw locale keys in UI)
|
||||
|
||||
Resolved in v1.2.
|
||||
|
||||
---
|
||||
@@ -221,6 +231,7 @@ Resolved in v1.2.
|
||||
## Time-to-First-Value (TTFV) Ziel
|
||||
|
||||
Nach Onboarding-Redesign + Empty-State-Pattern:
|
||||
|
||||
- Login → Dashboard mit Empty-States: 2 Klicks
|
||||
- Klick auf „Add your first weight" → Form prefilled „now" → Submit: 30 Sek
|
||||
- **TTFV-Ziel: <60 Sekunden** (Branchenvergleich: führende Health-Apps 30-90s)
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
# HealthLog v1.4.6 — Findings & Spec
|
||||
|
||||
Status: 2026-05-09 — handoff from the v1.4.5 deploy session. All findings
|
||||
were produced by **four parallel audit agents** (dashboard visual, KI section,
|
||||
admin panel, KI insights data window) against the v1.4.5 codebase
|
||||
(`e8f4820`). A Playwright probe also visually verified the dashboard tile
|
||||
strip at five viewports. **Verify each finding by reading the cited
|
||||
file:line BEFORE editing — agents occasionally reference stale state.**
|
||||
|
||||
The single working-tree change carried over from the v1.4.5 session:
|
||||
`src/components/charts/trend-card.tsx:85` — added `w-full` so the tile
|
||||
component fills its grid cell. **Do not commit alone — fold into v1.4.6.**
|
||||
|
||||
---
|
||||
|
||||
## Tier 1 — Release blockers for v1.4.6
|
||||
|
||||
### T1 — Dashboard tile fill ✅ (already in working tree)
|
||||
|
||||
`src/components/charts/trend-card.tsx:85`
|
||||
|
||||
The TrendCard outer div had `flex h-full flex-col` but no `w-full`. As a
|
||||
flex-child of a `<div className="flex">` wrapper, it took content width
|
||||
instead of stretching to the grid cell. Visible on mobile-narrow (375px):
|
||||
tile widths varied 122-166px in the same row. With `w-full` added, the
|
||||
Playwright harness confirms the tiles fill their grid cells uniformly at
|
||||
all viewports. Marc's primary complaint was "die Kacheln füllen den Platz
|
||||
nicht aus" — this is exactly that.
|
||||
|
||||
**Verify after change:** Run the deep diagnose probe at multiple
|
||||
viewports (`/tmp/v145-tile-deep-diagnose.js` from prior session if still
|
||||
on disk; otherwise re-create — set `HL_SID` to a valid session and run
|
||||
via `node /Users/marc/.claude/skills/playwright-skill/run.js
|
||||
$probe`).
|
||||
|
||||
### T2 — `--muted-foreground` equals `--foreground` (no hierarchy)
|
||||
|
||||
`src/app/globals.css:97` (dark) and `:134` (light)
|
||||
|
||||
Currently `--muted-foreground: #f8f8f2;` is identical to
|
||||
`--foreground: #f8f8f2;` in dark, and `#1f1f1f` in both for light. Every
|
||||
secondary string (tile labels, "Ø7d:" prefixes, units, nav inactive
|
||||
items) is rendered at the same brightness as primary text — visually flat.
|
||||
|
||||
**Fix:** dark → `--muted-foreground: #9aa3b3;` (dracula-comment).
|
||||
Light → `--muted-foreground: #5b6273;`.
|
||||
|
||||
**Verify:** Visual diff at a few pages — dashboard, settings, admin —
|
||||
should show clear emphasis between primary numbers and label/footer
|
||||
text. WCAG AA contrast must still hold (≥ 4.5:1 against the surface).
|
||||
|
||||
### T3 — KI insights `primaryRecommendation` renders raw chart tokens
|
||||
|
||||
`src/components/insights/insight-advisor-card.tsx:411`
|
||||
|
||||
The base-system prompt (`src/lib/insights/prompts/base-system.ts:67`)
|
||||
explicitly invites the LLM to embed `metric:WEIGHT` etc. tokens inside
|
||||
`primaryRecommendation`. The renderer prints
|
||||
`{insight.primaryRecommendation}` raw — no token-strip, no
|
||||
`<InlineCharts>` parse. Result: when the model follows instructions,
|
||||
the user sees literal `metric:WEIGHT` in their hero recommendation.
|
||||
|
||||
**Fix:** Apply the same pattern used at lines 419-423 for `summary`:
|
||||
strip tokens for prose, parse and render via `<InlineCharts>`. Re-use
|
||||
the existing helper.
|
||||
|
||||
### T4 — `aiBaseUrl` cross-provider leak (security)
|
||||
|
||||
`src/lib/ai/provider.ts:75, 312, 355`, `src/app/api/user/ai-provider/route.ts:54-62`
|
||||
|
||||
`buildUserProvider` and `resolveProviderForTest` reuse a single
|
||||
`aiBaseUrl` column across providers. If a user once configured `LOCAL`
|
||||
with `http://192.168.x.x/v1` and later switches to `OPENAI`, the stored
|
||||
`aiBaseUrl` is still set, so `OpenAIClient` is constructed with
|
||||
`baseUrl: row.aiBaseUrl ?? "https://api.openai.com/v1"` — sending the
|
||||
user's OpenAI API key to their local server.
|
||||
|
||||
**Fix:** In the PATCH handler at `route.ts:54-62`, when `provider`
|
||||
changes to anything other than `LOCAL`, wipe `aiBaseUrl`. Belt-and-
|
||||
braces in `buildUserProvider`: ignore `aiBaseUrl` for `OPENAI` and
|
||||
`ANTHROPIC` (use a hardcoded default). Add a unit test for
|
||||
provider-switch with stale baseUrl.
|
||||
|
||||
### T5 — `/api/insights/generate` 502 → Cloudflare HTML rewrite
|
||||
|
||||
`src/app/api/insights/generate/route.ts:84-86`
|
||||
|
||||
Same Cloudflare-rewrites-5xx footgun the test endpoint just got fixed
|
||||
for in v1.4.5. Parse failure returns `apiError("Failed to parse AI
|
||||
response", 502)` → Cloudflare swaps the body for an HTML error page →
|
||||
`await res.json()` in `ai-section.tsx:206-215` crashes with
|
||||
`Unexpected token '<'`. The user has zero rate-limit budget left to
|
||||
retry (2/hour) and sees nothing useful.
|
||||
|
||||
**Fix:** Map parse errors to **422** with a readable JSON error body.
|
||||
Same one-liner pattern as the v1.4.5 ai/test fix (see CHANGELOG).
|
||||
|
||||
### T6 — Admin status-card hrefs broken
|
||||
|
||||
`src/components/admin/status-card-grid.tsx:147, 159, 177, 189, 210, 222`
|
||||
|
||||
Hrefs to non-existent routes/anchors:
|
||||
|
||||
- `/admin/users`, `/admin/audit-log` — sub-routes don't exist
|
||||
- `/admin#integrations`, `/admin#monitoring`, `/admin#backups`,
|
||||
`/admin#maintenance` — anchor IDs don't match the rendered DOM
|
||||
(actual: `section-admin-services`, `section-admin-umami`, etc.)
|
||||
|
||||
Every "Manage users" / "View backups" CTA reloads `/admin` to top.
|
||||
The `status-card-grid.test.tsx:105` only asserts `startsWith("/admin")`
|
||||
— so this regression slipped through.
|
||||
|
||||
**Fix:** Point hrefs at the existing anchor IDs:
|
||||
|
||||
- Users → `/admin#section-user-management`
|
||||
- Audit log → `/admin#section-login-overview`
|
||||
- Integrations → `/admin#section-admin-umami`
|
||||
- etc. Verify each anchor exists in the rendered DOM.
|
||||
|
||||
Tighten `status-card-grid.test.tsx` to assert each href anchor IS
|
||||
present in the rendered admin page DOM (use `render(<AdminPage />)`
|
||||
and assert `getByText(...)` for each section ID). Plain regex check is
|
||||
not enough.
|
||||
|
||||
### T7 — Bug-report toggle has no effect (admin)
|
||||
|
||||
`src/app/api/bugreport/route.ts:47-49`, `src/app/bugreport/page.tsx:70`
|
||||
|
||||
The `bugReportEnabled === false` check lives on the legacy
|
||||
`/api/bugreport` route. The actual form on `/bugreport` posts to
|
||||
`/api/feedback`, which has no such gate. Result: toggling "Bug
|
||||
reports off" in admin **does not actually hide or block** the form —
|
||||
users still submit successfully into the local feedback inbox.
|
||||
|
||||
The CLAUDE.md / commit message claim ("the report button gracefully
|
||||
disappears") is false.
|
||||
|
||||
**Fix:** Pick one approach:
|
||||
|
||||
- **(a)** Add `bugReportEnabled` to `/api/bugreport/status` and to
|
||||
`/api/feedback` POST so the form is hidden + blocked when off.
|
||||
Recommended.
|
||||
- (b) Update copy to clarify the toggle only governs GitHub
|
||||
publishing.
|
||||
|
||||
Pick (a). Update i18n copy. Add an integration test that toggles the
|
||||
flag off and asserts /bugreport returns 503 OR redirects.
|
||||
|
||||
### T8 — "Wipe all data" deletes more than the copy says
|
||||
|
||||
`src/app/api/admin/data/route.ts:44-47`
|
||||
|
||||
The DELETE handler also nukes `apiToken`, `withingsConnection`,
|
||||
`auditLog`, `authChallenge`. The UI confirmation copy
|
||||
(`admin.deleteAllConfirmDescription`) and the success toast
|
||||
(`admin.deletedResult`) only mention measurements/medications/
|
||||
intakeEvents.
|
||||
|
||||
Worse: `tx.auditLog.deleteMany({})` runs in the same transaction —
|
||||
**every prior security-relevant entry is gone**. For a feature whose
|
||||
primary purpose is auditability, this is wrong.
|
||||
|
||||
**Fix:**
|
||||
|
||||
- **Stop deleting `AuditLog`.** Keep the audit trail. (If retention is
|
||||
desired, add a separate maintenance route that prunes by age.)
|
||||
- Reflect the actual scope in the response toast (the API already
|
||||
returns `apiTokens`, `withingsConnections`, `authChallenges`).
|
||||
- Update i18n keys `admin.deleteAllConfirmDescription` to enumerate
|
||||
everything that gets cleared.
|
||||
|
||||
### T9 — KI per-card window 30 → 360 daily + 24 monthly (NEW SCOPE)
|
||||
|
||||
Affected files (one constant + one bucket-builder per file):
|
||||
|
||||
- `src/lib/insights/general-status.ts:15` — `GENERAL_STATUS_POINTS`
|
||||
- `src/lib/insights/blood-pressure-status.ts:21` —
|
||||
`BLOOD_PRESSURE_STATUS_POINTS` + `mood.slice(-30)` at line 362
|
||||
- `src/lib/insights/pulse-status.ts:17` + line 199 mood-slice
|
||||
- `src/lib/insights/weight-status.ts:17` + line 233 mood-slice
|
||||
- `src/lib/insights/bmi-status.ts:11`
|
||||
- `src/lib/insights/mood-status.ts:14` + line 283 (`recentEntries`
|
||||
slice 90 — already padded × 3)
|
||||
- `src/lib/insights/medication-compliance-status.ts:150` —
|
||||
`rangeStart`
|
||||
|
||||
**Goal:** the LLM should see ~3 years of history with newer data at
|
||||
higher resolution. Not raw rows — daily means for the recent window,
|
||||
monthly means for the older window. Marc explicitly asked for
|
||||
"mindestens 360 Tage" daily, plus "Monatswerte" for "2 oder 3 Jahre".
|
||||
|
||||
**Concrete shape of the prompt payload (per metric):**
|
||||
|
||||
```ts
|
||||
{
|
||||
daily: Array<{ dayOffset: number; value: number; n: number }>; // 360 items, dayOffset 0..359 (newest first)
|
||||
monthly: Array<{ monthOffset: number; value: number; n: number }>; // 24 items, monthOffset 12..35 (12 months ago to 36 months ago)
|
||||
}
|
||||
```
|
||||
|
||||
So months 1-12 are sampled daily (= 360 daily buckets), months 13-36
|
||||
are summarised monthly (= 24 monthly buckets). Total 384
|
||||
data points per metric.
|
||||
|
||||
**Token budget guard:** Before sending, log the JSON-stringified
|
||||
payload size. If >50 KB, fall back to 180 daily + 24 monthly. Add
|
||||
this as a `LOG_INSIGHTS_PAYLOAD_SIZE` env var (default true) so we
|
||||
can monitor.
|
||||
|
||||
**Tests:** add a test per generator that asserts the payload contains
|
||||
both `daily` and `monthly` arrays with the expected lengths, and that
|
||||
no raw measurement rows leak into the prompt (the audit window is
|
||||
3 years; raw mode is unchanged in `features.ts`).
|
||||
|
||||
---
|
||||
|
||||
## NEW FEATURE — Chart bucketing for ranges > 1 year
|
||||
|
||||
Marc's request:
|
||||
|
||||
> "Überlege dir bei den Charts ob wir Sachen halt nicht ab einer
|
||||
> gewissen Sache dann stacken in den Monaten. Wenn wir jetzt bei
|
||||
> All drücken zum Beispiel, dass man dann halt ab einer Zeit von
|
||||
> über einem Jahr dann halt danach tagesmonats- oder monats- oder
|
||||
> wochendurchschnitte summiert und nicht dann 30.000 Punkte
|
||||
> anzeigt"
|
||||
|
||||
**Spec:**
|
||||
|
||||
The chart data adapter (in `src/components/charts/` — likely
|
||||
`health-chart.tsx` or a sibling) currently renders every measurement
|
||||
in the selected range. For the dashboard "All" / "Alle" range filter
|
||||
this can be 1000s of points and is unreadable.
|
||||
|
||||
Add **automatic bucketing** based on the rendered range:
|
||||
|
||||
- `≤ 90 days` → daily points (current behaviour)
|
||||
- `91-730 days` → weekly average
|
||||
- `> 730 days` → monthly average
|
||||
|
||||
The bucket type must be displayed in the chart header
|
||||
(e.g., "Tageswerte" / "Wochendurchschnitt" / "Monatsdurchschnitt"
|
||||
chip in the corner of the chart) so the user knows what they are
|
||||
looking at.
|
||||
|
||||
The data points must keep their ISO timestamps so Recharts' x-axis
|
||||
formatting works correctly. Smoothing/averaging must skip days
|
||||
with `n=0` rather than count them as `0`.
|
||||
|
||||
**Tests:** unit-test the bucketing function with synthetic series of
|
||||
varying length. Visual regression: render the chart at a 2-year range
|
||||
and verify the chart shows ~24 monthly points, not 730 daily.
|
||||
|
||||
**Research:** the autonomous executor MUST research best practice
|
||||
for time-series bucket aggregation in healthcare/Recharts dashboards
|
||||
before implementing. Use Context7 or websearch for "recharts
|
||||
aggregation buckets" and similar — there's a standard pattern for
|
||||
this. Don't reinvent.
|
||||
|
||||
---
|
||||
|
||||
## Tier 2 — Polish (also for v1.4.6)
|
||||
|
||||
| ID | File | Change |
|
||||
| --- | --------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| P1 | `trend-card.tsx:93,109,118,130,139` | Add `tabular-nums` to numeric spans (digits jiggle on refresh) |
|
||||
| P2 | `trend-card.tsx:87,93` | Bump value `text-2xl` → `text-3xl tracking-tight`, label `text-sm font-medium` → `text-xs uppercase tracking-wide` (KPI hierarchy) |
|
||||
| P3 | `trend-card.tsx:85` vs `health-chart.tsx:551` | Match padding: tile `p-3` → `p-4 md:p-6` to align with chart cards |
|
||||
| P4 | `trend-card.tsx:64-69` | Trend-arrow color: keep flat at muted; up/down both `text-foreground` (neutral). Direction-as-good-or-bad is metric-specific and v1.5+ work. |
|
||||
| P5 | `trend-card.tsx:103-145` | Always render avg7/avg30 chips; use `—` when null so vertical rhythm is consistent across tiles |
|
||||
| P6 | `page.tsx:362` | Welcome subtitle: drop `hidden sm:block`, add `text-muted-foreground` (visible on mobile, muted) |
|
||||
| P7 | `auth-shell.tsx:129` | Bottom-nav buffer: `pb-[calc(5rem+env(safe-area-inset-bottom,0px))]` → `pb-[calc(4rem+env(safe-area-inset-bottom,0px))] md:pb-0` |
|
||||
| P8 | `empty-state.tsx:71-77` | TrendHint title default: add `text-muted-foreground` so the title doesn't fight chart titles after T2 |
|
||||
| P9 | `getting-started-checklist.tsx:299` | Drop `font-mono` from "X von Y" / "%" — keep `tabular-nums` |
|
||||
| P10 | `page.tsx:846-862` | Medications card: `rounded-lg border p-4` → `rounded-xl border p-4 md:p-6` (match other chart cards) |
|
||||
| P11 | `src/lib/logging/redact.ts:22` | Add `sk-(ant-)?` regex to `redactSecrets` to scrub OpenAI/Anthropic keys centrally |
|
||||
| P12 | `src/lib/idempotency.ts:227` | Extend the `hlk_` / `hlr_` exclusion to also reject bodies containing `"sk-"` or `"sk-ant-"` |
|
||||
| P13 | `src/app/api/insights/generate/route.ts:18-22` | Move `checkRateLimit` BELOW the cache-return so cached hits don't burn rate-limit tokens |
|
||||
| P14 | `src/lib/ai/codex-client.ts:28,34` | Copy structured-error pattern from `openai-client.ts:42-58` (httpStatus, bodyExcerpt, upstream fields) |
|
||||
| P15 | `src/components/settings/ai-section.tsx:49` | Drop `gpt-5` from `MODEL_PRESETS["OPENAI"]` (not a released model). Drop `o3-mini` too unless you wire the o-series param contract (`max_completion_tokens` not `max_tokens`, no `temperature` override). |
|
||||
| P16 | `src/components/settings/ai-section.tsx` (~29 strings) | i18n: pipe German strings through `t("settings.ai.…")` — add keys to `messages/en.json` + `messages/de.json` |
|
||||
| P17 | `src/components/admin/feedback-inbox-section.tsx:209-222` | Replace `bg-red-500/15 text-red-400` with `bg-dracula-red/15 text-dracula-red` etc. for theme consistency |
|
||||
| P18 | `src/components/admin/danger-zone-section.tsx:110` | Drop string-prefix-matches-success heuristic — track via `mutation.isSuccess` / `mutation.isError` |
|
||||
| P19 | `src/components/admin/_shared.tsx:268-277,216-225` | `useSystemStatus()` and `useAdminSettings()`: surface `isError` with an inline "Failed to load settings" message instead of infinite spinner |
|
||||
| P20 | `src/app/api/admin/status-overview/route.ts` | `Promise.all` → `Promise.allSettled` server-side so one failed probe doesn't blank the whole grid |
|
||||
|
||||
---
|
||||
|
||||
## Tier 3 — explicitly deferred
|
||||
|
||||
- **One-row scroll vs. wrap** — the dashboard audit recommended
|
||||
rolling back v1.4.5's wrap behaviour to v1.4.4's horizontal-scroll-
|
||||
always. Marc already chose wrap deliberately in v1.4.5
|
||||
(CHANGELOG entry, commit `e8f4820`). **Do not roll back.**
|
||||
- **`/admin/users`, `/admin/audit-log` sub-routes** — feature, not
|
||||
bug. Anchor-link fix in T6 is enough for v1.4.6.
|
||||
- **API token revoke / login block actions** in admin — feature for
|
||||
v1.5.
|
||||
- **"Remove saved AI key" button** — UX gap, not a bug. v1.5.
|
||||
- **Feedback / mood semantic colors** — needs design pass.
|
||||
|
||||
---
|
||||
|
||||
## Verification gates (before tagging v1.4.6)
|
||||
|
||||
After all fixes, the autonomous executor MUST:
|
||||
|
||||
1. **Test suite green:** `pnpm test`, `pnpm typecheck`, `pnpm lint`,
|
||||
`pnpm format:check`. Stop if any fails — never `--no-verify`.
|
||||
2. **Integration tests green** (testcontainers): `pnpm test:integration`
|
||||
3. **Build succeeds:** `pnpm build` (no Turbopack-specific failures)
|
||||
4. **Multi-agent QA review** — dispatch in parallel and reconcile
|
||||
findings into a follow-up commit if any HIGH/CRITICAL surfaces:
|
||||
- **superpowers:code-reviewer** — review the v1.4.6 diff vs. main
|
||||
- **Plan agent** as senior-design-reviewer — visual review of
|
||||
dashboard, settings, admin at desktop + mobile viewports
|
||||
- **Plan agent** as senior-security-reviewer — review T4, T5, T7,
|
||||
T8, P11, P12 specifically for residual risk
|
||||
- **simplify skill** — run on the v1.4.6 changed files to look for
|
||||
accidental complexity
|
||||
5. **Mobile QA** via Playwright — at least three viewports
|
||||
(375×667, 390×844, 412×915). Probe must verify:
|
||||
- No horizontal scroll on any page
|
||||
- Bottom-nav doesn't overlap content
|
||||
- Tile strip wraps cleanly with uniform widths per row
|
||||
- Touch targets ≥ 44×44 px
|
||||
- Charts render and are readable
|
||||
6. **Hallucination check** — for every finding fixed, `git diff` must
|
||||
show a real change at the cited file:line. Audit-agent claims must
|
||||
be verified; if a finding's file:line doesn't match what's
|
||||
actually there, log it in the SUMMARY and skip rather than guess.
|
||||
7. **Visual regression** — Playwright screenshots of dashboard at
|
||||
1280×900 and 390×844, before+after, saved to
|
||||
`/tmp/v146-tiles-{desktop,mobile}-{before,after}.png`.
|
||||
|
||||
---
|
||||
|
||||
## Release & deploy
|
||||
|
||||
1. Bump `package.json` to `1.4.6`
|
||||
2. Update `CHANGELOG.md` with a `[1.4.6] — 2026-05-09` (or `2026-05-10`
|
||||
depending on completion time) block. Tone: user-facing German +
|
||||
English, **no Claude mention, no "Audit-Agent"**, no internal
|
||||
phase names. Group by section: "Fixed — Dashboard",
|
||||
"Fixed — KI", "Fixed — Admin", "Improved — KI Insights",
|
||||
"Improved — Polish".
|
||||
3. Atomic commits per phase, each Co-Authored-By Claude Opus 4.7.
|
||||
4. Tag `v1.4.6`, push tag.
|
||||
5. Wait for GHCR build (typically 10-13 min).
|
||||
6. Coolify deploy: `mcp__coolify-apps01__deploy` with
|
||||
`tag_or_uuid: pg8wggwogo8c4gc4ks0kk4ss`, `force: true`.
|
||||
7. Verify production:
|
||||
- Poll `/api/version` until 1.4.6
|
||||
- `docker inspect` running container — image digest must
|
||||
differ from current.
|
||||
- Marc's session: `cmox4d6fj000101p8w9ykhcnm` (still valid as of
|
||||
2026-05-09; if expired, re-pull from prod DB —
|
||||
`SELECT id FROM sessions WHERE user_id='cmlupy4tn000001rpzx1pxvz7'
|
||||
AND expires_at > now() ORDER BY created_at DESC LIMIT 1;`)
|
||||
- Hit `/api/ai/test` with fake key → still returns 422
|
||||
- Hit `/api/insights/generate` with `forceRefresh=false` and a
|
||||
fresh user → does not consume rate-limit token (P13 verify).
|
||||
8. Mobile QA Playwright probe against production.
|
||||
9. Brief Marc — German, ≤ 200 words. Hard-reload reminder.
|
||||
Group by what changed perceptually ("Tile-Strip füllt jetzt aus",
|
||||
"KI bekommt jetzt 3 Jahre Historie", "Charts aggregieren bei
|
||||
Langzeit-Range", "Admin-Buttons springen jetzt zum richtigen
|
||||
Anker", "Bug-Report-Toggle wirkt jetzt"). No "Claude", no
|
||||
internal phase names.
|
||||
|
||||
---
|
||||
|
||||
## Repo housekeeping (also in scope for the v1.4.6 marathon)
|
||||
|
||||
### CI
|
||||
|
||||
- `e2e` workflow has been **failing on every commit** since at least
|
||||
`cbded7c3`. Investigate (`gh run view <id> --log-failed`), root-
|
||||
cause, fix the spec or the underlying app bug. Don't `.skip()` it
|
||||
— Marc explicitly asked for green CI.
|
||||
|
||||
### GitHub Releases
|
||||
|
||||
GitHub Releases stop at **v1.4.1**. Backfill v1.4.2, v1.4.3, v1.4.4,
|
||||
v1.4.5, v1.4.6 — each release notes pulled from the corresponding
|
||||
`CHANGELOG.md` block. Tag each release `--verify-tag` so we don't
|
||||
create an orphan release.
|
||||
|
||||
### GHCR Packages
|
||||
|
||||
`ghcr.io/mbombeck/healthlog` packages page may have stale tags from
|
||||
the v1.4.0/v1.4.1 deploy thrash. Don't delete `v1.x.x` tagged
|
||||
versions (some users may pin). Only delete explicitly _untagged_
|
||||
manifests if any exist.
|
||||
|
||||
### Docs site (`/Users/marc/Projects/healthlog-docs`)
|
||||
|
||||
Astro site at `https://docs.healthlog.dev`. Latest content covers
|
||||
**up to v1.2**. Bring it up to v1.4.6:
|
||||
|
||||
- Update version selector / Latest indicator
|
||||
- Add docs pages or sections for v1.3-v1.4.6 features the user
|
||||
would care about — read the CHANGELOG.md user-facing bullets
|
||||
and translate into how-to docs where appropriate
|
||||
- Add the v1.4.6 release entry
|
||||
|
||||
Read the docs repo's CLAUDE.md/CONTRIBUTING (if present) FIRST to
|
||||
understand conventions. Commit + push.
|
||||
|
||||
### Landing site (`/Users/marc/Projects/healthlog-landing`)
|
||||
|
||||
Next.js site. Latest showcase mentions v1.2. Minimal updates only —
|
||||
don't redesign:
|
||||
|
||||
- Update headline version + supported-features list if anything
|
||||
v1.3-v1.4.6 is landing-page-worthy
|
||||
- Update screenshots if anything visibly changed (likely the
|
||||
dashboard tile strip)
|
||||
|
||||
Commit + push.
|
||||
|
||||
---
|
||||
|
||||
## Marc's hard rules (verbatim)
|
||||
|
||||
1. Niemals `--no-verify` bei git commit.
|
||||
2. Niemals `--no-gpg-sign` außer explizit angefragt.
|
||||
3. **Niemals force-push to main.**
|
||||
4. Changelogs/Release-Notes nur user-facing — kein "Claude", keine
|
||||
internen Phasennamen, keine Audit-Sprache.
|
||||
5. Authorization gilt nur für den spezifischen Auftrag — wenn etwas
|
||||
Größeres aufkommt, lieber fragen als entscheiden.
|
||||
|
||||
For this overnight session, Marc has explicitly authorized
|
||||
**autonomous execution of Tier 1 + Tier 2 + the new chart bucketing
|
||||
feature**, and the multi-agent QA + simplify pass. Anything that
|
||||
crops up which is **not** in this document and is **larger than a
|
||||
typo-level change** must be deferred to v1.5 with a note in the
|
||||
SUMMARY rather than implemented.
|
||||
@@ -0,0 +1,214 @@
|
||||
# HealthLog v1.4.6 — Marathon Summary
|
||||
|
||||
## Marc-Brief (DE, ≤ 200 Wörter)
|
||||
|
||||
✅ **v1.4.6 ist live** auf `https://healthlog.bombeck.io` — Container neu,
|
||||
Image-Digest `sha256:dcfa70be96a9…` (vorher v1.4.5: `0fc06b0b69d9…`).
|
||||
|
||||
🆕 **Was neu ist**
|
||||
|
||||
- **Charts aggregieren bei Langzeit-Range** automatisch zu
|
||||
Wochen- (91-730 Tage) bzw. Monatsdurchschnitt (>730 Tage). Chip im
|
||||
Chart-Header zeigt den aktiven Bucket.
|
||||
- **KI sieht jetzt 3 Jahre Historie** pro Karte: 360 Tageswerte +
|
||||
24 Monatswerte, mit Token-Budget-Wächter (Fallback 180 Tage bei
|
||||
laute Konten).
|
||||
|
||||
🔧 **Was repariert wurde**
|
||||
|
||||
- **Tile-Strip füllt jetzt aus** (T1) und Hierarchie ist sichtbar
|
||||
(T2: `--muted-foreground` jetzt wirklich gedämpft, AA-Kontrast).
|
||||
- **KI-Empfehlung parst `metric:`-Tokens** (T3) — keine rohen Tokens
|
||||
mehr im Hero-Block.
|
||||
- **AI-Provider-Leak geschlossen** (T4): Wechsel LOCAL → OPENAI
|
||||
schickt den Cloud-Key nicht mehr an die alte LAN-URL.
|
||||
- **`/api/insights/generate` 502 → 422** (T5) — Cloudflare-HTML-Trap weg.
|
||||
- **Admin-Buttons springen zum richtigen Anker** (T6) statt nach oben.
|
||||
- **Bug-Report-Toggle wirkt jetzt** (T7) am API-Gate UND in der UI.
|
||||
- **Audit-Log überlebt den Daten-Wipe** (T8).
|
||||
- 20 Tier-2-Polish-Items (Typografie, Farben, i18n, Strukturfehler).
|
||||
|
||||
⚠️ **Hard-Reload** mit `Cmd+Shift+R` damit der neue Service-Worker greift.
|
||||
|
||||
📚 **Docs aktualisiert** — `docs.healthlog.dev` jetzt auf v1.4.6, neue
|
||||
Seiten zu Native-API, Skalierung, Dashboard-Customization.
|
||||
|
||||
🎯 **v1.5-Backlog** unten in diesem Dokument.
|
||||
|
||||
---
|
||||
|
||||
## What landed
|
||||
|
||||
27 commits since `e8f4820` (v1.4.5 head).
|
||||
|
||||
### Tier 1 — release blockers (`docs/audit/v146-findings.md`)
|
||||
|
||||
| ID | Commit | Summary |
|
||||
| ----- | --------- | ------------------------------------------------------------------ |
|
||||
| T1+T2 | `8aae7d6` | tile fill + muted-foreground hierarchy |
|
||||
| T3 | `a75fbc6` | primary recommendation parses chart tokens |
|
||||
| T4 | `eba898f` | aiBaseUrl provider-leak guard + tests |
|
||||
| T5 | `c8ee28d` | insights/generate parse error 502 → 422 |
|
||||
| T6 | `4aeb8c9` | admin status-card hrefs + tightened test |
|
||||
| T7 | `31959e4` | bug-report toggle now blocks /api/feedback + UI |
|
||||
| T8 | `c3ca861` | data-wipe preserves AuditLog + scope copy |
|
||||
| T9 | `1adda80` | per-card insight window 360 daily + 24 monthly across 7 generators |
|
||||
|
||||
### New feature
|
||||
|
||||
| Commit | Summary |
|
||||
| --------- | --------------------------------------------------------------------- |
|
||||
| `6a64df0` | chart bucketing for ranges > 1y (weekly / monthly aggregation + chip) |
|
||||
|
||||
### Tier 2 — polish
|
||||
|
||||
| Commit | Summary |
|
||||
| --------- | --------------------------------------------------------------------------------- |
|
||||
| `fda8dd8` | trend-card P1-P5 (tabular nums, KPI typography, padding parity, always-show avgs) |
|
||||
| `dc6db82` | P6 + P10 (visible muted subtitle, medications card padding) |
|
||||
| `5c884b3` | P7-P9 (mobile nav buffer, trend-hint title, font-mono drop) |
|
||||
| `e903d9a` | P11 — redactSecrets sk-/sk-ant- |
|
||||
| `c7b6005` | P12 — idempotency body-content guard |
|
||||
| `4a159d2` | P13 — rate-limit moved below cache return |
|
||||
| `dcc697c` | P14 — codex-client structured errors |
|
||||
| `89b5b80` | P15+P16 — drop unreleased presets, i18n the AI section |
|
||||
| `505f318` | P17 — feedback badges → dracula tokens |
|
||||
| `86a4b52` | P18 — danger-zone colour follows mutation state |
|
||||
| `2654337` | P19 — useSystemStatus / useAdminSettings isError UI |
|
||||
| `dc4507a` | P20 — status-overview Promise.allSettled |
|
||||
|
||||
### CI / housekeeping
|
||||
|
||||
| Commit | Summary |
|
||||
| --------- | ------------------------------------------------------------- |
|
||||
| `bcd1de4` | drop e2e pnpm-version override (was failing every run) |
|
||||
| `46e686f` | switch e2e mobile project to chromium + repair spec flakiness |
|
||||
| `02b9955` | repo-wide prettier sweep before tag |
|
||||
| `ecca54d` | `.planning` bootstrap |
|
||||
|
||||
### Multi-agent QA follow-ups
|
||||
|
||||
| Commit | Summary |
|
||||
| --------- | -------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `6757518` | qa pass — orange→dracula tokens, status-card cta copy honest, idempotency `sk-` regex tightening with false-positive tests |
|
||||
|
||||
### Release
|
||||
|
||||
| Commit | Summary |
|
||||
| --------- | -------------------------------------------------------- |
|
||||
| `a852612` | `chore(release): v1.4.6` (package.json bump + CHANGELOG) |
|
||||
|
||||
## What was deferred (v1.5 backlog)
|
||||
|
||||
From the multi-agent QA pass:
|
||||
|
||||
1. **Notification-channel scope of "Wipe all data"** — `NotificationChannel`,
|
||||
`PushSubscription`, `TelegramScheduledDeletion`, and `Feedback` rows
|
||||
survive the wipe even though `User.telegramBotToken` etc. are nulled.
|
||||
Pre-existing scope drift; the encrypted Telegram bot token in
|
||||
`NotificationChannel.config` survives. Not introduced by v1.4.6.
|
||||
Fix: extend the data-wipe transaction.
|
||||
2. **Berlin TZ DST math in cross-metric joins** — `bucketSeries`'s
|
||||
`dayOffset` reverse mapping (used by BP-pair / weight↔BP /
|
||||
medication-adherence joins) computes `now - dayOffset·86_400_000`
|
||||
which is wrong on DST boundaries. Affects ~2 days/year of
|
||||
miscredited data — low blast-radius, but should be exposed via a
|
||||
helper from `bucket-series.ts` to centralise the conversion.
|
||||
3. **`status-card-grid` test brittleness** — the test maintains a
|
||||
hand-curated list of section IDs; the findings doc recommended
|
||||
rendering `<AdminPage />` and asserting via DOM. v1.5 follow-up.
|
||||
4. **`redactSecrets` regex word-boundary** — current `/sk-(?:ant-)?[A-Za-z0-9_-]+/`
|
||||
over-matches "task-force", "risk-management" etc. Not a security
|
||||
issue (over-redaction is the safe failure mode), purely a log
|
||||
readability concern.
|
||||
5. **`/api/insights/generate` provider-error → 500** — when the admin
|
||||
OpenAI key is invalid, the route propagates "OpenAI request failed
|
||||
(401)" up to the apiHandler which maps to a generic 500. T5 fixed
|
||||
only the parse-error branch. v1.5: catch provider-side errors and
|
||||
map to 422/503 with a readable body.
|
||||
6. **`useAdminSettings` / `useSystemStatus` test coverage** — the new
|
||||
`isError` UI added in P19 has no integration test covering the
|
||||
error path. Manual verification only.
|
||||
7. **Backups dedicated admin section** — currently the "Backups"
|
||||
status-card jumps to `#section-system-status` because no dedicated
|
||||
backups view exists. Re-labelled honestly to "Open system status"
|
||||
in v1.4.6; v1.5 should add the actual backups view.
|
||||
|
||||
## CI / test status
|
||||
|
||||
- `pnpm typecheck` — clean
|
||||
- `pnpm lint` — 0 errors / 12 warnings (pre-existing `_param`-style
|
||||
unused vars; not in scope to refactor)
|
||||
- `pnpm format:check` — clean (sweep landed in `02b9955`)
|
||||
- `pnpm test` — 714 passed
|
||||
- `pnpm test:integration` — 10 passed (testcontainers)
|
||||
- `pnpm build` — fails locally on Node 25 due to a Turbopack
|
||||
AsyncLocalStorage / Reflect.get bug. CI Docker image (Node 22)
|
||||
builds cleanly — that is the canonical release path.
|
||||
- `e2e` workflow — fixed and pushed; the run on the v1.4.6 SHA was
|
||||
still pending at deploy time. Per the marathon spec, e2e green is
|
||||
not a hard gate.
|
||||
|
||||
## Production state
|
||||
|
||||
- URL: `https://healthlog.bombeck.io`
|
||||
- `/api/version`: `1.4.6` ✓
|
||||
- Image SHA: `sha256:dcfa70be96a955a554f8c943a1d954fa8a23f49896047f88658b62df72f60ba8`
|
||||
- Previous (v1.4.5): `sha256:0fc06b0b69d987f6498bd5f9224e316cbcee91cafaf098eb738f43061fcc6ffc`
|
||||
- Container: `app-pg8wggwogo8c4gc4ks0kk4ss-005603315572`, healthy
|
||||
- `/api/ai/test` smoke with fake key: returns **422** with readable
|
||||
JSON body ✓ (Cloudflare passthrough confirmed).
|
||||
|
||||
### Coolify-deploy quirk worth noting
|
||||
|
||||
The first `mcp__coolify-apps01__deploy` call with `force: true`
|
||||
finished in 21 seconds (`fxwl88wohhbl7f730nfv0kk1`) and reported
|
||||
"Removing old containers / Starting new application", but the running
|
||||
container still ran the old `0fc06b0b…` image — Coolify's compose-pull
|
||||
took ~250 ms which is far too short to actually pull a new image, and
|
||||
the container ID stayed the same. A manual
|
||||
`docker compose pull && docker compose up -d app` on the host pulled
|
||||
the new digest cleanly and the container then recreated. End state
|
||||
matches the desired SHA, but Coolify's force-rebuild semantics on
|
||||
this app instance look unreliable. Worth a v1.5 follow-up to
|
||||
reproduce and report upstream.
|
||||
|
||||
## Docs / landing
|
||||
|
||||
Both repos pushed. See agent report for commit list. Highlights:
|
||||
|
||||
- `healthlog-docs`: 8 commits bringing site from v1.2 → v1.4.6.
|
||||
New pages: `api/native-clients.mdx`, `self-hosting/scaling.mdx`,
|
||||
`features/dashboard-customization.mdx`. Existing pages refreshed
|
||||
for v1.3-v1.4 features.
|
||||
- `healthlog-landing`: single commit `ebb03f9` bumping
|
||||
`softwareVersion` 1.3.3 → 1.4.6 and expanding the JSON-LD
|
||||
`featureList`. Screenshots untouched (Marc's call to refresh in
|
||||
daylight).
|
||||
|
||||
## GitHub releases
|
||||
|
||||
Backfilled `v1.4.2`, `v1.4.3`, `v1.4.4`, `v1.4.5`, **`v1.4.6`** at
|
||||
https://github.com/MBombeck/HealthLog/releases — each with the
|
||||
canonical CHANGELOG block as release notes.
|
||||
|
||||
## QA agent reports — full text
|
||||
|
||||
Stored in commit messages of `6757518` (the QA-pass follow-up
|
||||
commit). Three agents ran in parallel against the v1.4.6 diff:
|
||||
|
||||
- **superpowers:code-reviewer** — no CRITICAL, 3 HIGH (DST math,
|
||||
idempotency `sk-` substring, monthly bucket label semantics).
|
||||
All triaged: `sk-` regex fixed; rest deferred.
|
||||
- **Plan agent (security)** — no CRITICAL/HIGH. M1 (notification-
|
||||
channel data-wipe scope) deferred. T4/T7/T8/P11/P12 cleared.
|
||||
- **Plan agent (design)** — no CRITICAL, 2 HIGH (orange tokens in
|
||||
ai-section.tsx, status-card cta copy honesty). Both fixed.
|
||||
|
||||
---
|
||||
|
||||
## Hard-Reload reminder
|
||||
|
||||
`Cmd+Shift+R` (Mac) / `Ctrl+Shift+R` (Linux/Win) so the new
|
||||
service-worker fetches fresh `_next/static` chunks. Without it the
|
||||
client may stay on the v1.4.5 bundle for up to a navigation cycle.
|
||||
@@ -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.
|
||||
@@ -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,54 @@
|
||||
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,
|
||||
}) => {
|
||||
// Cookies must be addressable to a real origin — `page.url()` on
|
||||
// a fresh context returns `about:blank`, which Playwright rejects.
|
||||
// Anchor at the configured base URL explicitly.
|
||||
const baseURL =
|
||||
test.info().project.use.baseURL ?? "http://localhost:3000";
|
||||
await context.addCookies([
|
||||
{
|
||||
name: "healthlog_locale",
|
||||
value: locale,
|
||||
url: baseURL,
|
||||
},
|
||||
]);
|
||||
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,65 @@
|
||||
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.
|
||||
*/
|
||||
async function openPasswordForm(page: import("@playwright/test").Page) {
|
||||
await page.goto("/auth/login");
|
||||
// The login page defaults to passkey mode; the password form is
|
||||
// mounted only after clicking the "Login with password" / "Mit
|
||||
// Passwort anmelden" outline button.
|
||||
await page
|
||||
.getByRole("button", { name: /login with password|mit passwort/i })
|
||||
.click();
|
||||
}
|
||||
|
||||
test.describe("login page", () => {
|
||||
test("renders username + password inputs with the right autoComplete", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openPasswordForm(page);
|
||||
|
||||
const username = page
|
||||
.getByLabel(/username|benutzername|email|e-mail/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 openPasswordForm(page);
|
||||
|
||||
await page
|
||||
.getByLabel(/username|benutzername|email|e-mail/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);
|
||||
});
|
||||
});
|
||||
+229
-7
@@ -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)",
|
||||
@@ -194,6 +194,8 @@
|
||||
},
|
||||
"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)",
|
||||
@@ -530,6 +532,8 @@
|
||||
"noData": "Keine Daten im gewählten Zeitraum",
|
||||
"movingAverage": "Gleitender Durchschnitt",
|
||||
"target": "Zielbereich",
|
||||
"bucketWeekly": "Wochendurchschnitt",
|
||||
"bucketMonthly": "Monatsdurchschnitt",
|
||||
"points7Label": "7 Pkt",
|
||||
"points7Title": "Letzte 7 Messpunkte",
|
||||
"points30Label": "30 Pkt",
|
||||
@@ -600,6 +604,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",
|
||||
@@ -628,7 +636,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)",
|
||||
@@ -794,11 +802,123 @@
|
||||
"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": "Dashboard",
|
||||
"description": "Kachel-Layout und Reihenfolge."
|
||||
},
|
||||
"thresholds": {
|
||||
"title": "Persönliche Zielwerte",
|
||||
"description": "Eigene Zielbereiche für jede Metrik."
|
||||
},
|
||||
"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",
|
||||
@@ -905,6 +1025,46 @@
|
||||
"regenerateSuccess": "Reports wurden neu generiert",
|
||||
"regenerateRateLimit": "Bitte warte — maximal 2 Analysen pro Stunde.",
|
||||
"lastGeneratedAt": "Zuletzt generiert",
|
||||
"ai": {
|
||||
"title": "KI-Insights",
|
||||
"description": "Anbieter, Modell, Key.",
|
||||
"chatgptConnectedBadge": "ChatGPT verbunden",
|
||||
"adminAiActiveBadge": "Admin-KI aktiv",
|
||||
"connectionExpiredBadge": "Verbindung abgelaufen",
|
||||
"chatgptConnectedHeading": "ChatGPT verbunden",
|
||||
"chatgptConnectedBody": "Insights werden über dein ChatGPT-Abo generiert — keine zusätzlichen Kosten.",
|
||||
"connectedSince": "Verbunden seit {when}.",
|
||||
"disconnect": "Trennen",
|
||||
"connectChatgptHeading": "Mit ChatGPT verbinden",
|
||||
"connectChatgptBody": "Verbinde dein ChatGPT Pro/Max-Konto um KI-gestützte Gesundheitsanalysen basierend auf aktuellen medizinischen Leitlinien zu erhalten. Keine zusätzlichen API-Kosten.",
|
||||
"connectChatgptCta": "Mit ChatGPT verbinden",
|
||||
"oauthNotConfigured": "ChatGPT-OAuth ist auf dieser Instanz nicht konfiguriert — nutze stattdessen einen eigenen API-Key unten.",
|
||||
"adminFallback": "Alternativ nutzt HealthLog den vom Administrator konfigurierten KI-Anbieter.",
|
||||
"personalProviderHeading": "KI-Provider (persönlich)",
|
||||
"personalProviderBody": "Eigener KI-Anbieter überschreibt die Admin-Einstellung. Leer lassen für Standard. Für ChatGPT-Pro/Max-OAuth nutze den Verbinden-Button oben.",
|
||||
"providerLabel": "Provider",
|
||||
"providerOptionDefault": "— Standard (Admin/Codex) —",
|
||||
"providerOptionOpenai": "OpenAI (API-Key)",
|
||||
"providerOptionAnthropic": "Anthropic (Claude)",
|
||||
"providerOptionLocal": "Lokal (OpenAI-kompatibel)",
|
||||
"modelLabel": "Modell",
|
||||
"modelOptionDefault": "— Standard —",
|
||||
"modelOptionCustom": "Eigenes…",
|
||||
"customModelLabel": "Eigener Modellname",
|
||||
"openaiKeyLabel": "OpenAI API-Key",
|
||||
"openaiKeyHelp": "Eigener Key — keine Verbindung zu deinem ChatGPT-Abo. Für das Abo nutze den Verbinden-Button oben.",
|
||||
"anthropicKeyLabel": "Anthropic API-Key",
|
||||
"baseUrlLabel": "Base URL",
|
||||
"localKeyLabel": "API-Key (optional)",
|
||||
"savedPreview": "(gespeichert {preview})",
|
||||
"savedShort": "(gespeichert)",
|
||||
"saveCta": "Speichern",
|
||||
"testCta": "Verbindung testen",
|
||||
"saved": "Gespeichert",
|
||||
"saveFailed": "Speichern fehlgeschlagen",
|
||||
"testFailed": "Test fehlgeschlagen",
|
||||
"errorGeneric": "Fehler"
|
||||
},
|
||||
"withings": "Withings",
|
||||
"withingsDescription": "Verbinde deine Withings-Waage und Blutdruckmessgeräte.",
|
||||
"withingsCredentials": "API-Zugangsdaten",
|
||||
@@ -1039,7 +1199,47 @@
|
||||
"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",
|
||||
"linksHeading": "Quellen & Dokumentation",
|
||||
"updatesHeading": "Updates",
|
||||
"checkUpdates": "Jetzt prüfen",
|
||||
"checking": "Prüfe…",
|
||||
"lastChecked": "Zuletzt geprüft: {time}",
|
||||
"lastCheckedNever": "Noch nicht geprüft",
|
||||
"upToDate": "Du nutzt die aktuelle Version",
|
||||
"newerAvailable": "Neue Version: {tag}",
|
||||
"checkFailed": "Konnte GitHub nicht erreichen — versuche es später erneut"
|
||||
},
|
||||
"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",
|
||||
@@ -1059,6 +1259,8 @@
|
||||
"activeTokens": "Aktive API-Tokens",
|
||||
"activeSessions": "Aktive Sessions",
|
||||
"loadingStatus": "Lade Status...",
|
||||
"systemStatusLoadError": "System-Status konnte nicht geladen werden. Bitte die Seite neu laden.",
|
||||
"adminSettingsLoadError": "Admin-Einstellungen konnten nicht geladen werden. Schalter unten zeigen evtl. nicht den Live-Zustand — Seite neu laden.",
|
||||
"workerStatus": "Job-Worker",
|
||||
"workerRunning": "Aktiv",
|
||||
"workerStopped": "Gestoppt",
|
||||
@@ -1115,6 +1317,8 @@
|
||||
"monitoringSave": "Monitoring speichern",
|
||||
"bugReportGithub": "Bugreport (GitHub Issues)",
|
||||
"bugReportGithubDescription": "Hinterlege GitHub-Issue-Token und Repository für eingehende Bugreports.",
|
||||
"bugReportEnabledLabel": "Bug-Reports aktiviert",
|
||||
"bugReportEnabledDescription": "Wenn deaktiviert, ist der Report-Button im Frontend nicht mehr sichtbar — Token und Repository bleiben gespeichert.",
|
||||
"bugReportRepo": "Repository",
|
||||
"bugReportRepoPlaceholder": "owner/repo",
|
||||
"bugReportToken": "Issue-Token",
|
||||
@@ -1166,10 +1370,10 @@
|
||||
"deleteAllData": "Alle Daten global löschen",
|
||||
"deleteAllDescription": "Löscht alle Mess-, Medikamenten-, Integrations- und Protokoll-Daten aller Benutzer. Benutzerkonten bleiben bestehen.",
|
||||
"deleteAllConfirm": "Alle Daten wirklich löschen?",
|
||||
"deleteAllConfirmDescription": "Dieser Vorgang löscht alle Messwerte, Medikamente, Einnahmen und Integrationsdaten aller Benutzer unwiderruflich. Benutzerkonten bleiben erhalten.",
|
||||
"deleteAllConfirmDescription": "Dieser Vorgang löscht alle Messwerte, Medikamente, Einnahmen, API-Tokens, Withings-Verbindungen und offene Authentifizierungs-Challenges aller Benutzer unwiderruflich. Benutzerkonten und das Audit-Log bleiben erhalten, damit der Löschvorgang selbst nachvollziehbar bleibt.",
|
||||
"deleteButton": "Alle Daten löschen",
|
||||
"finalDelete": "Endgültig löschen",
|
||||
"deletedResult": "Gelöscht: {measurements} Messwerte, {medications} Medikamente, {intakeEvents} Einnahmen",
|
||||
"deletedResult": "Gelöscht: {measurements} Messwerte, {medications} Medikamente, {intakeEvents} Einnahmen, {apiTokens} API-Tokens, {withingsConnections} Withings-Verbindungen, {authChallenges} Auth-Challenges",
|
||||
"authRegister": "Registrierung",
|
||||
"authLogin": "Anmeldung",
|
||||
"authLoginPasskey": "Passkey-Anmeldung",
|
||||
@@ -1449,7 +1653,10 @@
|
||||
"categoryOther": "Sonstiges Feedback",
|
||||
"bugTitlePlaceholder": "Kurze Zusammenfassung (optional)",
|
||||
"githubEscalationNote": "Dein Feedback wird lokal gespeichert. Wenn der Administrator die GitHub-Integration aktiviert, kann es auf Wunsch als öffentliches Issue veröffentlicht werden.",
|
||||
"internalOnlyNote": "Dein Feedback wird lokal gespeichert und ist nur für den Administrator dieses Servers sichtbar."
|
||||
"internalOnlyNote": "Dein Feedback wird lokal gespeichert und ist nur für den Administrator dieses Servers sichtbar.",
|
||||
"disabledTitle": "Bug-Reports sind deaktiviert",
|
||||
"disabledBody": "Der Administrator hat Bug-Reports auf diesem Server deaktiviert.",
|
||||
"disabledError": "Bug-Reports sind vom Administrator deaktiviert."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Benachrichtigungen",
|
||||
@@ -1629,5 +1836,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"
|
||||
}
|
||||
}
|
||||
+229
-7
@@ -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)",
|
||||
@@ -194,6 +194,8 @@
|
||||
},
|
||||
"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)",
|
||||
@@ -530,6 +532,8 @@
|
||||
"noData": "No data in selected time range",
|
||||
"movingAverage": "Moving average",
|
||||
"target": "Target range",
|
||||
"bucketWeekly": "Weekly avg",
|
||||
"bucketMonthly": "Monthly avg",
|
||||
"points7Label": "7 pts",
|
||||
"points7Title": "Last 7 data points",
|
||||
"points30Label": "30 pts",
|
||||
@@ -600,6 +604,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",
|
||||
@@ -628,7 +636,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)",
|
||||
@@ -794,11 +802,123 @@
|
||||
"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 and order."
|
||||
},
|
||||
"thresholds": {
|
||||
"title": "Personal targets",
|
||||
"description": "Override target ranges for every metric."
|
||||
},
|
||||
"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",
|
||||
@@ -905,6 +1025,46 @@
|
||||
"regenerateSuccess": "Reports regenerated successfully",
|
||||
"regenerateRateLimit": "Please wait — max 2 analyses per hour.",
|
||||
"lastGeneratedAt": "Last generated",
|
||||
"ai": {
|
||||
"title": "AI Insights",
|
||||
"description": "Provider, model, key.",
|
||||
"chatgptConnectedBadge": "ChatGPT connected",
|
||||
"adminAiActiveBadge": "Admin AI active",
|
||||
"connectionExpiredBadge": "Connection expired",
|
||||
"chatgptConnectedHeading": "ChatGPT connected",
|
||||
"chatgptConnectedBody": "Insights are generated through your ChatGPT subscription — no extra cost.",
|
||||
"connectedSince": "Connected since {when}.",
|
||||
"disconnect": "Disconnect",
|
||||
"connectChatgptHeading": "Connect with ChatGPT",
|
||||
"connectChatgptBody": "Link your ChatGPT Pro/Max account to get AI-powered health analyses based on current medical guidelines. No additional API costs.",
|
||||
"connectChatgptCta": "Connect with ChatGPT",
|
||||
"oauthNotConfigured": "ChatGPT OAuth is not configured on this instance — use your own API key below instead.",
|
||||
"adminFallback": "Alternatively, HealthLog will use the AI provider configured by the administrator.",
|
||||
"personalProviderHeading": "AI provider (personal)",
|
||||
"personalProviderBody": "Your own AI provider overrides the admin setting. Leave blank for the default. For ChatGPT Pro/Max OAuth use the connect button above.",
|
||||
"providerLabel": "Provider",
|
||||
"providerOptionDefault": "— Default (Admin/Codex) —",
|
||||
"providerOptionOpenai": "OpenAI (API key)",
|
||||
"providerOptionAnthropic": "Anthropic (Claude)",
|
||||
"providerOptionLocal": "Local (OpenAI-compatible)",
|
||||
"modelLabel": "Model",
|
||||
"modelOptionDefault": "— Default —",
|
||||
"modelOptionCustom": "Custom…",
|
||||
"customModelLabel": "Custom model name",
|
||||
"openaiKeyLabel": "OpenAI API key",
|
||||
"openaiKeyHelp": "Your own key — not connected to your ChatGPT subscription. For the subscription use the connect button above.",
|
||||
"anthropicKeyLabel": "Anthropic API key",
|
||||
"baseUrlLabel": "Base URL",
|
||||
"localKeyLabel": "API key (optional)",
|
||||
"savedPreview": "(saved {preview})",
|
||||
"savedShort": "(saved)",
|
||||
"saveCta": "Save",
|
||||
"testCta": "Test connection",
|
||||
"saved": "Saved",
|
||||
"saveFailed": "Save failed",
|
||||
"testFailed": "Test failed",
|
||||
"errorGeneric": "Error"
|
||||
},
|
||||
"withings": "Withings",
|
||||
"withingsDescription": "Connect your Withings scale and blood pressure monitors.",
|
||||
"withingsCredentials": "API Credentials",
|
||||
@@ -1039,7 +1199,47 @@
|
||||
"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",
|
||||
"linksHeading": "Sources & docs",
|
||||
"updatesHeading": "Updates",
|
||||
"checkUpdates": "Check now",
|
||||
"checking": "Checking…",
|
||||
"lastChecked": "Last checked: {time}",
|
||||
"lastCheckedNever": "Not checked yet",
|
||||
"upToDate": "You are up to date",
|
||||
"newerAvailable": "Newer version: {tag}",
|
||||
"checkFailed": "Could not reach GitHub — try again later"
|
||||
},
|
||||
"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",
|
||||
@@ -1059,6 +1259,8 @@
|
||||
"activeTokens": "Active API Tokens",
|
||||
"activeSessions": "Active Sessions",
|
||||
"loadingStatus": "Loading status...",
|
||||
"systemStatusLoadError": "Failed to load system status. Try refreshing the page.",
|
||||
"adminSettingsLoadError": "Failed to load admin settings. Toggles below may not reflect the live state — refresh the page.",
|
||||
"workerStatus": "Job Worker",
|
||||
"workerRunning": "Running",
|
||||
"workerStopped": "Stopped",
|
||||
@@ -1115,6 +1317,8 @@
|
||||
"monitoringSave": "Save monitoring",
|
||||
"bugReportGithub": "Bug report (GitHub Issues)",
|
||||
"bugReportGithubDescription": "Configure GitHub issue token and repository for incoming bug reports.",
|
||||
"bugReportEnabledLabel": "Bug reports enabled",
|
||||
"bugReportEnabledDescription": "When off, the in-app report button hides — token and repository stay configured.",
|
||||
"bugReportRepo": "Repository",
|
||||
"bugReportRepoPlaceholder": "owner/repo",
|
||||
"bugReportToken": "Issue token",
|
||||
@@ -1166,10 +1370,10 @@
|
||||
"deleteAllData": "Delete all data globally",
|
||||
"deleteAllDescription": "Deletes all measurement, medication, integration, and log data for all users. User accounts will be preserved.",
|
||||
"deleteAllConfirm": "Really delete all data?",
|
||||
"deleteAllConfirmDescription": "This will permanently delete all measurements, medications, intake records, and integration data for all users. User accounts will be preserved.",
|
||||
"deleteAllConfirmDescription": "This will permanently delete all measurements, medications, intake events, API tokens, Withings connections, and any open authentication challenges for all users. User accounts and the audit log are preserved so the wipe itself remains traceable.",
|
||||
"deleteButton": "Delete all data",
|
||||
"finalDelete": "Delete permanently",
|
||||
"deletedResult": "Deleted: {measurements} measurements, {medications} medications, {intakeEvents} intakes",
|
||||
"deletedResult": "Deleted: {measurements} measurements, {medications} medications, {intakeEvents} intakes, {apiTokens} API tokens, {withingsConnections} Withings connections, {authChallenges} auth challenges",
|
||||
"authRegister": "Registration",
|
||||
"authLogin": "Login",
|
||||
"authLoginPasskey": "Passkey login",
|
||||
@@ -1449,7 +1653,10 @@
|
||||
"categoryOther": "Other feedback",
|
||||
"bugTitlePlaceholder": "Short summary (optional)",
|
||||
"githubEscalationNote": "Your feedback is stored locally. If the admin enables GitHub integration, it can be promoted to a public issue on request.",
|
||||
"internalOnlyNote": "Your feedback is stored locally and only visible to the administrator of this server."
|
||||
"internalOnlyNote": "Your feedback is stored locally and only visible to the administrator of this server.",
|
||||
"disabledTitle": "Bug reports are turned off",
|
||||
"disabledBody": "The administrator has disabled bug reports on this server.",
|
||||
"disabledError": "Bug reports are disabled by the administrator."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
@@ -1629,5 +1836,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"
|
||||
}
|
||||
}
|
||||
+11
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.3.3",
|
||||
"version": "1.4.7",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.31.0",
|
||||
"scripts": {
|
||||
@@ -17,9 +17,13 @@
|
||||
"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",
|
||||
@@ -49,12 +53,16 @@
|
||||
"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",
|
||||
@@ -62,6 +70,7 @@
|
||||
"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,69 @@
|
||||
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: {
|
||||
// Pixel 5 — Chromium-based mobile profile so CI only needs
|
||||
// `playwright install chromium` instead of also pulling
|
||||
// webkit. iPhone-13 is intentionally avoided here; the
|
||||
// mobile-Safari smoke is exercised by the iOS app suite.
|
||||
...devices["Pixel 5"],
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
// 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
+2326
-21
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||
@@ -0,0 +1,14 @@
|
||||
-- v1.4.3: per-user OpenAI API key.
|
||||
--
|
||||
-- Until this release the schema stored Anthropic and Local-provider API
|
||||
-- keys but no field for the OPENAI provider, even though OPENAI was a
|
||||
-- valid value in `users.ai_provider`. Users who picked it had no place
|
||||
-- in the UI to drop their key and the runtime fell through to whichever
|
||||
-- admin-level fallback was configured. This column is the missing slot.
|
||||
--
|
||||
-- AES-256-GCM encrypted at the application layer (`src/lib/crypto.ts`)
|
||||
-- before the value lands on disk. Plaintext keys never touch Postgres
|
||||
-- backups.
|
||||
|
||||
ALTER TABLE "users"
|
||||
ADD COLUMN "ai_openai_key_encrypted" TEXT;
|
||||
@@ -0,0 +1,16 @@
|
||||
-- v1.4.3: explicit on/off switch for the bug-report surface.
|
||||
--
|
||||
-- Until this release the bug-report feature was implicitly gated on
|
||||
-- "is the GitHub token + repo configured?". That coupling meant an
|
||||
-- admin who wanted to *temporarily* hide the bug-report button (e.g.
|
||||
-- during a deploy freeze) had to clear the encrypted token, which
|
||||
-- forced a re-entry on resume — destructive for a reversible
|
||||
-- intent.
|
||||
--
|
||||
-- Default `true` so existing instances with credentials configured
|
||||
-- keep the feature visible after the upgrade. Admins who want it off
|
||||
-- flip the switch in `/admin/integrations` (or whichever section
|
||||
-- mounts the bug-report card).
|
||||
|
||||
ALTER TABLE "app_settings"
|
||||
ADD COLUMN "bug_report_enabled" BOOLEAN NOT NULL DEFAULT TRUE;
|
||||
@@ -70,6 +70,11 @@ model User {
|
||||
aiBaseUrl String? @map("ai_base_url")
|
||||
aiAnthropicKeyEncrypted String? @map("ai_anthropic_key_encrypted")
|
||||
aiLocalKeyEncrypted String? @map("ai_local_key_encrypted")
|
||||
// v1.4.3: per-user OpenAI API key for the OPENAI provider — previously
|
||||
// the schema only stored Anthropic and Local keys, leaving OPENAI without
|
||||
// a credential surface. Encrypted with the active ENCRYPTION_KEYS entry
|
||||
// before storage.
|
||||
aiOpenaiKeyEncrypted String? @map("ai_openai_key_encrypted")
|
||||
|
||||
// Onboarding
|
||||
onboardingCompletedAt DateTime? @map("onboarding_completed_at")
|
||||
@@ -90,6 +95,7 @@ model User {
|
||||
medications Medication[]
|
||||
intakeEvents MedicationIntakeEvent[]
|
||||
apiTokens ApiToken[]
|
||||
refreshTokens RefreshToken[]
|
||||
auditLogs AuditLog[]
|
||||
withingsConnection WithingsConnection?
|
||||
moodEntries MoodEntry[]
|
||||
@@ -357,6 +363,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 {
|
||||
@@ -419,6 +466,11 @@ model AppSettings {
|
||||
glitchtipEnvironment String? @map("glitchtip_environment")
|
||||
githubIssueTokenEncrypted String? @map("github_issue_token_encrypted")
|
||||
githubIssueRepo String? @map("github_issue_repo")
|
||||
// v1.4.3: explicit toggle so an admin can hide the bug-report
|
||||
// surface without nuking the token+repo config. Default true so
|
||||
// existing instances with credentials don't accidentally lose
|
||||
// the feature on upgrade.
|
||||
bugReportEnabled Boolean @default(true) @map("bug_report_enabled")
|
||||
|
||||
// Medication reminder thresholds (minutes after windowEnd)
|
||||
reminderLateMinutes Int @default(120) @map("reminder_late_minutes")
|
||||
|
||||
+1
-3
@@ -56,9 +56,7 @@ self.addEventListener("fetch", (event) => {
|
||||
}
|
||||
|
||||
// Static files (fonts, icons, images) — cache-first with long TTL
|
||||
if (
|
||||
/\.(png|svg|jpg|jpeg|gif|webp|ico|woff2?|ttf|eot)$/i.test(url.pathname)
|
||||
) {
|
||||
if (/\.(png|svg|jpg|jpeg|gif|webp|ico|woff2?|ttf|eot)$/i.test(url.pathname)) {
|
||||
event.respondWith(cacheFirst(request, STATIC_CACHE));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
+57
-27
@@ -36,7 +36,12 @@ function cuid(): string {
|
||||
function daysAgo(n: number): Date {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - n);
|
||||
d.setHours(7 + Math.floor(Math.random() * 3), Math.floor(Math.random() * 60), 0, 0);
|
||||
d.setHours(
|
||||
7 + Math.floor(Math.random() * 3),
|
||||
Math.floor(Math.random() * 60),
|
||||
0,
|
||||
0,
|
||||
);
|
||||
return d;
|
||||
}
|
||||
|
||||
@@ -45,7 +50,12 @@ function formatDate(d: Date): string {
|
||||
}
|
||||
|
||||
// Smooth random walk with mean reversion
|
||||
function randomWalk(start: number, target: number, days: number, volatility: number): number[] {
|
||||
function randomWalk(
|
||||
start: number,
|
||||
target: number,
|
||||
days: number,
|
||||
volatility: number,
|
||||
): number[] {
|
||||
const values: number[] = [start];
|
||||
for (let i = 1; i < days; i++) {
|
||||
const prev = values[i - 1];
|
||||
@@ -88,12 +98,24 @@ async function seed() {
|
||||
// argon2id hash of "demo123demo123" — pre-computed
|
||||
// We'll use a bcrypt-compatible approach: register via API later
|
||||
// For now, insert with a placeholder and we'll set it via the app
|
||||
const passwordHash = "$argon2id$v=19$m=65536,t=3,p=4$Kips6OxPAl0vmspO9SoKZQ$oX9gLgwHVnnENCqBloyM13ewuqmhPnw8EpLoemS3MNI";
|
||||
const passwordHash =
|
||||
"$argon2id$v=19$m=65536,t=3,p=4$Kips6OxPAl0vmspO9SoKZQ$oX9gLgwHVnnENCqBloyM13ewuqmhPnw8EpLoemS3MNI";
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO users (id, username, email, password_hash, role, height_cm, date_of_birth, gender, timezone, locale, onboarding_completed_at, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW(), NOW())`,
|
||||
[userId, "demo", "demo@healthlog.app", passwordHash, "ADMIN", 182.0, "1990-05-15", "MALE", "Europe/Berlin", "en"]
|
||||
[
|
||||
userId,
|
||||
"demo",
|
||||
"demo@healthlog.app",
|
||||
passwordHash,
|
||||
"ADMIN",
|
||||
182.0,
|
||||
"1990-05-15",
|
||||
"MALE",
|
||||
"Europe/Berlin",
|
||||
"en",
|
||||
],
|
||||
);
|
||||
|
||||
// ── Measurements (90 days) ────────────────
|
||||
@@ -122,26 +144,26 @@ async function seed() {
|
||||
await client.query(
|
||||
`INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at)
|
||||
VALUES ($1, $2, 'WEIGHT', $3, 'kg', 'MANUAL', $4, $4, $4)`,
|
||||
[cuid(), userId, weights[i], date]
|
||||
[cuid(), userId, weights[i], date],
|
||||
);
|
||||
|
||||
// Blood pressure (daily)
|
||||
await client.query(
|
||||
`INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at)
|
||||
VALUES ($1, $2, 'BLOOD_PRESSURE_SYS', $3, 'mmHg', 'MANUAL', $4, $4, $4)`,
|
||||
[cuid(), userId, Math.round(sysBP[i]), date]
|
||||
[cuid(), userId, Math.round(sysBP[i]), date],
|
||||
);
|
||||
await client.query(
|
||||
`INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at)
|
||||
VALUES ($1, $2, 'BLOOD_PRESSURE_DIA', $3, 'mmHg', 'MANUAL', $4, $4, $4)`,
|
||||
[cuid(), userId, Math.round(diaBP[i]), date]
|
||||
[cuid(), userId, Math.round(diaBP[i]), date],
|
||||
);
|
||||
|
||||
// Pulse (daily)
|
||||
await client.query(
|
||||
`INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at)
|
||||
VALUES ($1, $2, 'PULSE', $3, 'bpm', 'MANUAL', $4, $4, $4)`,
|
||||
[cuid(), userId, Math.round(pulse[i]), date]
|
||||
[cuid(), userId, Math.round(pulse[i]), date],
|
||||
);
|
||||
|
||||
// Body fat (every 2-3 days)
|
||||
@@ -149,7 +171,7 @@ async function seed() {
|
||||
await client.query(
|
||||
`INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at)
|
||||
VALUES ($1, $2, 'BODY_FAT', $3, '%', 'MANUAL', $4, $4, $4)`,
|
||||
[cuid(), userId, bodyFat[i], date]
|
||||
[cuid(), userId, bodyFat[i], date],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -157,14 +179,14 @@ async function seed() {
|
||||
await client.query(
|
||||
`INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at)
|
||||
VALUES ($1, $2, 'SLEEP_DURATION', $3, 'hours', 'MANUAL', $4, $4, $4)`,
|
||||
[cuid(), userId, Math.round(sleep[i] * 10) / 10, date]
|
||||
[cuid(), userId, Math.round(sleep[i] * 10) / 10, date],
|
||||
);
|
||||
|
||||
// Steps (daily)
|
||||
await client.query(
|
||||
`INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at)
|
||||
VALUES ($1, $2, 'ACTIVITY_STEPS', $3, 'steps', 'MANUAL', $4, $4, $4)`,
|
||||
[cuid(), userId, Math.round(steps[i]), date]
|
||||
[cuid(), userId, Math.round(steps[i]), date],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -176,13 +198,13 @@ async function seed() {
|
||||
await client.query(
|
||||
`INSERT INTO medications (id, user_id, name, dose, active, notifications_enabled, created_at, updated_at)
|
||||
VALUES ($1, $2, 'Ramipril', '5mg', true, true, $3, $3)`,
|
||||
[med1Id, userId, daysAgo(120)]
|
||||
[med1Id, userId, daysAgo(120)],
|
||||
);
|
||||
const sched1Id = cuid();
|
||||
await client.query(
|
||||
`INSERT INTO medication_schedules (id, medication_id, window_start, window_end, label)
|
||||
VALUES ($1, $2, '08:00', '10:00', 'Morning')`,
|
||||
[sched1Id, med1Id]
|
||||
[sched1Id, med1Id],
|
||||
);
|
||||
|
||||
// Medication 2: Vitamin D3
|
||||
@@ -190,13 +212,13 @@ async function seed() {
|
||||
await client.query(
|
||||
`INSERT INTO medications (id, user_id, name, dose, active, notifications_enabled, created_at, updated_at)
|
||||
VALUES ($1, $2, 'Vitamin D3', '2000 IE', true, true, $3, $3)`,
|
||||
[med2Id, userId, daysAgo(90)]
|
||||
[med2Id, userId, daysAgo(90)],
|
||||
);
|
||||
const sched2Id = cuid();
|
||||
await client.query(
|
||||
`INSERT INTO medication_schedules (id, medication_id, window_start, window_end, label)
|
||||
VALUES ($1, $2, '08:00', '10:00', 'Morning')`,
|
||||
[sched2Id, med2Id]
|
||||
[sched2Id, med2Id],
|
||||
);
|
||||
|
||||
// Medication 3: Magnesium (evening)
|
||||
@@ -204,13 +226,13 @@ async function seed() {
|
||||
await client.query(
|
||||
`INSERT INTO medications (id, user_id, name, dose, active, notifications_enabled, created_at, updated_at)
|
||||
VALUES ($1, $2, 'Magnesium', '400mg', true, true, $3, $3)`,
|
||||
[med3Id, userId, daysAgo(60)]
|
||||
[med3Id, userId, daysAgo(60)],
|
||||
);
|
||||
const sched3Id = cuid();
|
||||
await client.query(
|
||||
`INSERT INTO medication_schedules (id, medication_id, window_start, window_end, label)
|
||||
VALUES ($1, $2, '20:00', '22:00', 'Evening')`,
|
||||
[sched3Id, med3Id]
|
||||
[sched3Id, med3Id],
|
||||
);
|
||||
|
||||
// ── Medication Intake Events ──────────────
|
||||
@@ -227,13 +249,13 @@ async function seed() {
|
||||
await client.query(
|
||||
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, false, 'WEB', $5)`,
|
||||
[cuid(), userId, med1Id, date, takenTime]
|
||||
[cuid(), userId, med1Id, date, takenTime],
|
||||
);
|
||||
} else {
|
||||
await client.query(
|
||||
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at)
|
||||
VALUES ($1, $2, $3, $4, NULL, true, 'WEB', $4)`,
|
||||
[cuid(), userId, med1Id, date]
|
||||
[cuid(), userId, med1Id, date],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -245,13 +267,13 @@ async function seed() {
|
||||
await client.query(
|
||||
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, false, 'WEB', $5)`,
|
||||
[cuid(), userId, med2Id, date, takenTime]
|
||||
[cuid(), userId, med2Id, date, takenTime],
|
||||
);
|
||||
} else {
|
||||
await client.query(
|
||||
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at)
|
||||
VALUES ($1, $2, $3, $4, NULL, true, 'WEB', $4)`,
|
||||
[cuid(), userId, med2Id, date]
|
||||
[cuid(), userId, med2Id, date],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -264,13 +286,13 @@ async function seed() {
|
||||
await client.query(
|
||||
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, false, 'WEB', $5)`,
|
||||
[cuid(), userId, med3Id, date, takenTime]
|
||||
[cuid(), userId, med3Id, date, takenTime],
|
||||
);
|
||||
} else {
|
||||
await client.query(
|
||||
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at)
|
||||
VALUES ($1, $2, $3, $4, NULL, true, 'WEB', $4)`,
|
||||
[cuid(), userId, med3Id, date]
|
||||
[cuid(), userId, med3Id, date],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -305,7 +327,15 @@ async function seed() {
|
||||
await client.query(
|
||||
`INSERT INTO mood_entries (id, user_id, date, mood, score, tags, source, mood_logged_at, synced_at, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'WEB', $7, $7, $7, $7)`,
|
||||
[cuid(), userId, formatDate(date), mood, rawScore, JSON.stringify(tags), loggedAt]
|
||||
[
|
||||
cuid(),
|
||||
userId,
|
||||
formatDate(date),
|
||||
mood,
|
||||
rawScore,
|
||||
JSON.stringify(tags),
|
||||
loggedAt,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -332,7 +362,7 @@ async function seed() {
|
||||
await client.query(
|
||||
`INSERT INTO user_achievements (id, user_id, achievement_id, unlocked_at, created_at)
|
||||
VALUES ($1, $2, $3, $4, $4)`,
|
||||
[cuid(), userId, ach.id, unlocked]
|
||||
[cuid(), userId, ach.id, unlocked],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -341,7 +371,7 @@ async function seed() {
|
||||
await client.query(
|
||||
`INSERT INTO app_settings (id, registration_enabled, default_locale)
|
||||
VALUES ('singleton', false, 'en')
|
||||
ON CONFLICT (id) DO UPDATE SET registration_enabled = false, default_locale = 'en'`
|
||||
ON CONFLICT (id) DO UPDATE SET registration_enabled = false, default_locale = 'en'`,
|
||||
);
|
||||
|
||||
// ── Audit Log (some login entries) ──────
|
||||
@@ -351,7 +381,7 @@ async function seed() {
|
||||
await client.query(
|
||||
`INSERT INTO audit_logs (id, user_id, action, details, ip_address, created_at)
|
||||
VALUES ($1, $2, 'auth.login', '{"method":"password"}', '203.0.113.42', $3)`,
|
||||
[cuid(), userId, loginDate]
|
||||
[cuid(), userId, loginDate],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+32
-2639
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;
|
||||
}
|
||||
});
|
||||
@@ -37,13 +37,23 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
return apiError("Confirmation missing", 422);
|
||||
}
|
||||
|
||||
// Audit-log the *intent* before the transaction begins so the trail
|
||||
// describing "an admin is about to wipe data" survives even if the
|
||||
// operation crashes midway. AuditLog rows are intentionally NOT
|
||||
// deleted as part of the wipe — the entire point of an audit trail
|
||||
// is to outlive the data it documents.
|
||||
await auditLog("admin.data.clear.start", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { confirm: "DELETE ALL" },
|
||||
});
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const measurements = await tx.measurement.deleteMany({});
|
||||
const intakeEvents = await tx.medicationIntakeEvent.deleteMany({});
|
||||
const medications = await tx.medication.deleteMany({});
|
||||
const apiTokens = await tx.apiToken.deleteMany({});
|
||||
const withingsConnections = await tx.withingsConnection.deleteMany({});
|
||||
const auditLogs = await tx.auditLog.deleteMany({});
|
||||
const authChallenges = await tx.authChallenge.deleteMany({});
|
||||
|
||||
await tx.user.updateMany({
|
||||
@@ -69,7 +79,6 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
medications: medications.count,
|
||||
apiTokens: apiTokens.count,
|
||||
withingsConnections: withingsConnections.count,
|
||||
auditLogs: auditLogs.count,
|
||||
authChallenges: authChallenges.count,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -37,10 +37,7 @@ export const POST = apiHandler(
|
||||
if (!feedback) return apiError("Feedback not found", 404);
|
||||
|
||||
if (feedback.gitHubIssueUrl) {
|
||||
return apiError(
|
||||
`Already published: ${feedback.gitHubIssueUrl}`,
|
||||
409,
|
||||
);
|
||||
return apiError(`Already published: ${feedback.gitHubIssueUrl}`, 409);
|
||||
}
|
||||
|
||||
// Atomic claim to prevent concurrent admin clicks from creating two
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
* DELETE /api/admin/feedback/[id] — archive (soft: sets status to ARCHIVED).
|
||||
*/
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, safeJson, getClientIp } from "@/lib/api-response";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
safeJson,
|
||||
getClientIp,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
@@ -30,7 +30,14 @@ export const POST = apiHandler(async () => {
|
||||
});
|
||||
|
||||
if (!delivery.ok) {
|
||||
getEvent()?.addWarning("Glitchtip test event rejected: " + delivery.method + " " + delivery.status + " " + delivery.details);
|
||||
getEvent()?.addWarning(
|
||||
"Glitchtip test event rejected: " +
|
||||
delivery.method +
|
||||
" " +
|
||||
delivery.status +
|
||||
" " +
|
||||
delivery.details,
|
||||
);
|
||||
return apiError(
|
||||
`Glitchtip test event rejected (HTTP ${delivery.status ?? 502})`,
|
||||
502,
|
||||
|
||||
@@ -54,10 +54,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
return apiError("Umami is disabled", 422);
|
||||
}
|
||||
if (!settings.umamiScriptUrl || !settings.umamiWebsiteId) {
|
||||
return apiError(
|
||||
"Umami script URL and website ID must be configured",
|
||||
422,
|
||||
);
|
||||
return apiError("Umami script URL and website ID must be configured", 422);
|
||||
}
|
||||
|
||||
const targetUrls = resolveUmamiSendUrls(settings.umamiScriptUrl);
|
||||
@@ -116,8 +113,5 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
|
||||
getEvent()?.addWarning("Umami test event rejected: " + lastStatus);
|
||||
return apiError(
|
||||
`Umami test event rejected (HTTP ${lastStatus})`,
|
||||
502,
|
||||
);
|
||||
return apiError(`Umami test event rejected (HTTP ${lastStatus})`, 502);
|
||||
});
|
||||
@@ -158,7 +158,9 @@ export const POST = apiHandler(async () => {
|
||||
notificationSent = true;
|
||||
notificationsSent++;
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning("Notification failed for " + med.name + ": " + err);
|
||||
getEvent()?.addWarning(
|
||||
"Notification failed for " + med.name + ": " + err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,12 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { adminSettingsSchema } from "@/lib/validations/admin";
|
||||
@@ -27,8 +32,8 @@ export const GET = apiHandler(async () => {
|
||||
webPushVapidSubject: settings?.webPushVapidSubject ?? null,
|
||||
webPushVapidConfigured: Boolean(
|
||||
settings?.webPushVapidPublicKey &&
|
||||
settings?.webPushVapidPrivateKeyEncrypted &&
|
||||
settings?.webPushVapidSubject,
|
||||
settings?.webPushVapidPrivateKeyEncrypted &&
|
||||
settings?.webPushVapidSubject,
|
||||
),
|
||||
apiGlobal: settings?.apiGlobal ?? true,
|
||||
umamiEnabled: settings?.umamiEnabled ?? false,
|
||||
@@ -41,6 +46,7 @@ export const GET = apiHandler(async () => {
|
||||
bugReportConfigured: Boolean(
|
||||
settings?.githubIssueRepo && settings?.githubIssueTokenEncrypted,
|
||||
),
|
||||
bugReportEnabled: settings?.bugReportEnabled ?? true,
|
||||
reminderLateMinutes: settings?.reminderLateMinutes ?? 120,
|
||||
reminderMissedMinutes: settings?.reminderMissedMinutes ?? 240,
|
||||
moodLogGlobal: settings?.moodLogGlobal ?? true,
|
||||
@@ -73,6 +79,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
"umamiEnabled",
|
||||
"glitchtipEnabled",
|
||||
"moodLogGlobal",
|
||||
"bugReportEnabled",
|
||||
] as const;
|
||||
for (const field of booleanFields) {
|
||||
if (data[field] !== undefined) {
|
||||
@@ -205,8 +212,8 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
webPushVapidSubject: settings.webPushVapidSubject,
|
||||
webPushVapidConfigured: Boolean(
|
||||
settings.webPushVapidPublicKey &&
|
||||
settings.webPushVapidPrivateKeyEncrypted &&
|
||||
settings.webPushVapidSubject,
|
||||
settings.webPushVapidPrivateKeyEncrypted &&
|
||||
settings.webPushVapidSubject,
|
||||
),
|
||||
apiGlobal: settings.apiGlobal,
|
||||
umamiEnabled: settings.umamiEnabled,
|
||||
@@ -214,12 +221,12 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
umamiWebsiteId: settings.umamiWebsiteId,
|
||||
glitchtipEnabled: settings.glitchtipEnabled,
|
||||
glitchtipDsn: settings.glitchtipDsn,
|
||||
glitchtipEnvironment:
|
||||
settings.glitchtipEnvironment ?? "production",
|
||||
glitchtipEnvironment: settings.glitchtipEnvironment ?? "production",
|
||||
bugReportRepo: settings.githubIssueRepo,
|
||||
bugReportConfigured: Boolean(
|
||||
settings.githubIssueRepo && settings.githubIssueTokenEncrypted,
|
||||
),
|
||||
bugReportEnabled: settings.bugReportEnabled,
|
||||
reminderLateMinutes: settings.reminderLateMinutes,
|
||||
reminderMissedMinutes: settings.reminderMissedMinutes,
|
||||
moodLogGlobal: settings.moodLogGlobal,
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
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.allSettled (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("P20: a single failed probe still returns the rest of the grid", async () => {
|
||||
// Simulate a transient DB failure on the withings count probe only.
|
||||
// Without Promise.allSettled the whole route would 500 and the entire
|
||||
// admin overview grid would go blank — with allSettled the failure
|
||||
// is isolated to its card (severity → "alert", value → 0) and the
|
||||
// other 5 cards still render.
|
||||
mocks.withingsConnection.count.mockRejectedValueOnce(
|
||||
new Error("simulated db timeout"),
|
||||
);
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
users: { severity: string };
|
||||
integrations: { severity: string; withings: number };
|
||||
backups: { severity: string };
|
||||
};
|
||||
};
|
||||
expect(body.data.integrations.severity).toBe("alert");
|
||||
expect(body.data.integrations.withings).toBe(0);
|
||||
// Untouched cards keep their normal severity.
|
||||
expect(body.data.users.severity).toBe("good");
|
||||
});
|
||||
|
||||
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,318 @@
|
||||
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);
|
||||
|
||||
// P20: switch from Promise.all to Promise.allSettled so a single
|
||||
// failed probe (e.g. a missing index, a transient timeout, an
|
||||
// unavailable model) doesn't blank the whole admin overview grid.
|
||||
// Each probe is labelled so we can map a rejection back to the
|
||||
// overview field it was meant to populate, then surface the failed
|
||||
// labels in a Wide Event annotation for ops triage.
|
||||
const probes = [
|
||||
["userTotal", () => prisma.user.count()],
|
||||
["userAdmins", () => prisma.user.count({ where: { role: "ADMIN" } })],
|
||||
[
|
||||
"userNewThisWeek",
|
||||
() => prisma.user.count({ where: { createdAt: { gte: sevenDaysAgo } } }),
|
||||
],
|
||||
["withingsCount", () => prisma.withingsConnection.count()],
|
||||
[
|
||||
"moodLogCount",
|
||||
() => prisma.user.count({ where: { moodLogEnabled: true } }),
|
||||
],
|
||||
[
|
||||
"telegramCount",
|
||||
() => prisma.user.count({ where: { telegramEnabled: true } }),
|
||||
],
|
||||
[
|
||||
"ntfyCount",
|
||||
() =>
|
||||
prisma.notificationChannel.count({
|
||||
where: { type: "NTFY", enabled: true },
|
||||
}),
|
||||
],
|
||||
["webPushCount", () => prisma.pushSubscription.count()],
|
||||
[
|
||||
"appSettings",
|
||||
() => prisma.appSettings.findUnique({ where: { id: "singleton" } }),
|
||||
],
|
||||
[
|
||||
"lastErrorEntry",
|
||||
() =>
|
||||
prisma.auditLog.findFirst({
|
||||
where: { action: { startsWith: "system.error" } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true },
|
||||
}),
|
||||
],
|
||||
[
|
||||
"latestBackup",
|
||||
() =>
|
||||
prisma.dataBackup.findFirst({
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true },
|
||||
}),
|
||||
],
|
||||
[
|
||||
"backedUpUsers",
|
||||
() =>
|
||||
prisma.dataBackup
|
||||
.findMany({ select: { userId: true }, distinct: ["userId"] })
|
||||
.then((rows) => rows.length),
|
||||
],
|
||||
[
|
||||
"last30dEvents",
|
||||
() =>
|
||||
prisma.auditLog.count({ where: { createdAt: { gte: thirtyDaysAgo } } }),
|
||||
],
|
||||
[
|
||||
"lastLoginEntry",
|
||||
() =>
|
||||
prisma.auditLog.findFirst({
|
||||
where: { action: "auth.login" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true },
|
||||
}),
|
||||
],
|
||||
[
|
||||
"lastIdempotencyCleanup",
|
||||
() =>
|
||||
prisma.auditLog.findFirst({
|
||||
where: { action: "system.cleanup.idempotency" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true },
|
||||
}),
|
||||
],
|
||||
[
|
||||
"lastAuditLogCleanup",
|
||||
() =>
|
||||
prisma.auditLog.findFirst({
|
||||
where: { action: "system.cleanup.audit-log" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true },
|
||||
}),
|
||||
],
|
||||
] as const;
|
||||
|
||||
const settled = await Promise.allSettled(probes.map(([, run]) => run()));
|
||||
|
||||
const failed: string[] = [];
|
||||
const results: Record<string, unknown> = {};
|
||||
settled.forEach((r, i) => {
|
||||
const label = probes[i][0];
|
||||
if (r.status === "fulfilled") {
|
||||
results[label] = r.value;
|
||||
} else {
|
||||
failed.push(label);
|
||||
results[label] = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Pull each probe out in the same shape the overview expects. `?? 0`
|
||||
// turns a null (failed-count probe) into a renderable zero so the
|
||||
// grid tiles still draw something instead of going blank.
|
||||
const userTotal = (results.userTotal as number | null) ?? 0;
|
||||
const userAdmins = (results.userAdmins as number | null) ?? 0;
|
||||
const userNewThisWeek = (results.userNewThisWeek as number | null) ?? 0;
|
||||
const withingsCount = (results.withingsCount as number | null) ?? 0;
|
||||
const moodLogCount = (results.moodLogCount as number | null) ?? 0;
|
||||
const telegramCount = (results.telegramCount as number | null) ?? 0;
|
||||
const ntfyCount = (results.ntfyCount as number | null) ?? 0;
|
||||
const webPushCount = (results.webPushCount as number | null) ?? 0;
|
||||
const appSettings = results.appSettings as {
|
||||
glitchtipEnabled: boolean;
|
||||
umamiEnabled: boolean;
|
||||
} | null;
|
||||
const lastErrorEntry = results.lastErrorEntry as { createdAt: Date } | null;
|
||||
const latestBackup = results.latestBackup as { createdAt: Date } | null;
|
||||
const backedUpUsers = (results.backedUpUsers as number | null) ?? 0;
|
||||
const last30dEvents = (results.last30dEvents as number | null) ?? 0;
|
||||
const lastLoginEntry = results.lastLoginEntry as { createdAt: Date } | null;
|
||||
const lastIdempotencyCleanup = results.lastIdempotencyCleanup as {
|
||||
createdAt: Date;
|
||||
} | null;
|
||||
const lastAuditLogCleanup = results.lastAuditLogCleanup as {
|
||||
createdAt: Date;
|
||||
} | null;
|
||||
|
||||
if (failed.length > 0) {
|
||||
annotate({ meta: { statusOverviewProbeFailures: failed } });
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// Map probe-name → which overview card depends on it. If any probe
|
||||
// for a card failed, force the card's severity to "alert" so the UI
|
||||
// surfaces the failure instead of showing zeros as if they were real.
|
||||
const failedSet = new Set(failed);
|
||||
const anyFailed = (...labels: string[]) =>
|
||||
labels.some((l) => failedSet.has(l));
|
||||
|
||||
const overview: StatusOverview = {
|
||||
users: {
|
||||
severity: anyFailed("userTotal", "userAdmins", "userNewThisWeek")
|
||||
? "alert"
|
||||
: "good",
|
||||
total: userTotal,
|
||||
admins: userAdmins,
|
||||
newThisWeek: userNewThisWeek,
|
||||
},
|
||||
integrations: {
|
||||
severity: anyFailed(
|
||||
"withingsCount",
|
||||
"moodLogCount",
|
||||
"telegramCount",
|
||||
"ntfyCount",
|
||||
"webPushCount",
|
||||
)
|
||||
? "alert"
|
||||
: severityForIntegrations(integrationsTotal),
|
||||
withings: withingsCount,
|
||||
moodLog: moodLogCount,
|
||||
telegram: telegramCount,
|
||||
ntfy: ntfyCount,
|
||||
webPush: webPushCount,
|
||||
},
|
||||
monitoring: {
|
||||
severity: anyFailed("appSettings", "lastErrorEntry")
|
||||
? "alert"
|
||||
: 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: anyFailed("latestBackup", "backedUpUsers")
|
||||
? "alert"
|
||||
: severityForBackup(latestBackup?.createdAt ?? null),
|
||||
lastBackupAt: latestBackup?.createdAt?.toISOString() ?? null,
|
||||
backedUpUsers,
|
||||
retentionDays: 90,
|
||||
},
|
||||
maintenance: {
|
||||
severity: anyFailed("lastIdempotencyCleanup", "lastAuditLogCleanup")
|
||||
? "alert"
|
||||
: severityForMaintenance(worker.running),
|
||||
workerRunning: worker.running,
|
||||
workerUptimeSeconds,
|
||||
lastIdempotencyCleanup:
|
||||
lastIdempotencyCleanup?.createdAt?.toISOString() ?? null,
|
||||
lastAuditLogCleanup:
|
||||
lastAuditLogCleanup?.createdAt?.toISOString() ?? null,
|
||||
},
|
||||
auditLog: {
|
||||
severity: anyFailed("last30dEvents", "lastLoginEntry") ? "alert" : "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);
|
||||
});
|
||||
});
|
||||
@@ -2,69 +2,82 @@ import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { hashPassword, checkPasswordStrength } from "@/lib/auth/password";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { destroyAllSessions } from "@/lib/auth/session";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
|
||||
export const POST = apiHandler(async (
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) => {
|
||||
const ip = getClientIp(request);
|
||||
const rl = await checkRateLimit(`admin-reset-pw:${ip}`, 10, 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return NextResponse.json(
|
||||
{ data: null, error: "Rate limit exceeded" },
|
||||
{ status: 429, headers: rateLimitHeaders(rl) },
|
||||
);
|
||||
}
|
||||
export const POST = apiHandler(
|
||||
async (
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) => {
|
||||
const ip = getClientIp(request);
|
||||
const rl = await checkRateLimit(`admin-reset-pw:${ip}`, 10, 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return NextResponse.json(
|
||||
{ data: null, error: "Rate limit exceeded" },
|
||||
{ status: 429, headers: rateLimitHeaders(rl) },
|
||||
);
|
||||
}
|
||||
|
||||
const { user } = await requireAdmin();
|
||||
const { user } = await requireAdmin();
|
||||
|
||||
const { id } = await params;
|
||||
annotate({ action: { name: "admin.users.reset-password", entity_type: "user", entity_id: id } });
|
||||
const { id } = await params;
|
||||
annotate({
|
||||
action: {
|
||||
name: "admin.users.reset-password",
|
||||
entity_type: "user",
|
||||
entity_id: id,
|
||||
},
|
||||
});
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
|
||||
if (jsonError) return jsonError;
|
||||
const { password } = body as { password?: string };
|
||||
if (jsonError) return jsonError;
|
||||
const { password } = body as { password?: string };
|
||||
|
||||
if (!password || typeof password !== "string") {
|
||||
return apiError("Password required", 422);
|
||||
}
|
||||
if (!password || typeof password !== "string") {
|
||||
return apiError("Password required", 422);
|
||||
}
|
||||
|
||||
const target = await prisma.user.findUnique({ where: { id } });
|
||||
if (!target) return apiError("User not found", 404);
|
||||
const target = await prisma.user.findUnique({ where: { id } });
|
||||
if (!target) return apiError("User not found", 404);
|
||||
|
||||
const locale = await resolveServerLocale({
|
||||
request,
|
||||
userLocale: target.locale ?? null,
|
||||
});
|
||||
const strength = checkPasswordStrength(password, [target.username], locale);
|
||||
if (!strength.isAcceptable) {
|
||||
return apiError(
|
||||
strength.feedback[0] || "Password too weak (score < 3)",
|
||||
422,
|
||||
);
|
||||
}
|
||||
const locale = await resolveServerLocale({
|
||||
request,
|
||||
userLocale: target.locale ?? null,
|
||||
});
|
||||
const strength = checkPasswordStrength(password, [target.username], locale);
|
||||
if (!strength.isAcceptable) {
|
||||
return apiError(
|
||||
strength.feedback[0] || "Password too weak (score < 3)",
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
await prisma.user.update({
|
||||
where: { id },
|
||||
data: { passwordHash },
|
||||
});
|
||||
const passwordHash = await hashPassword(password);
|
||||
await prisma.user.update({
|
||||
where: { id },
|
||||
data: { passwordHash },
|
||||
});
|
||||
|
||||
// Invalidate all sessions of the target user
|
||||
await destroyAllSessions(id);
|
||||
// Invalidate all sessions of the target user
|
||||
await destroyAllSessions(id);
|
||||
|
||||
await auditLog("admin.user.reset-password", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { targetUserId: id },
|
||||
});
|
||||
await auditLog("admin.user.reset-password", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { targetUserId: id },
|
||||
});
|
||||
|
||||
return apiSuccess({ success: true });
|
||||
});
|
||||
return apiSuccess({ success: true });
|
||||
},
|
||||
);
|
||||
@@ -1,7 +1,12 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
@@ -12,57 +17,65 @@ const updateUserSchema = z.object({
|
||||
role: z.enum(["ADMIN", "USER"]).optional(),
|
||||
});
|
||||
|
||||
export const PUT = apiHandler(async (
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) => {
|
||||
const { user } = await requireAdmin();
|
||||
export const PUT = apiHandler(
|
||||
async (
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) => {
|
||||
const { user } = await requireAdmin();
|
||||
|
||||
const { id } = await params;
|
||||
annotate({ action: { name: "admin.users.update", entity_type: "user", entity_id: id } });
|
||||
const { id } = await params;
|
||||
annotate({
|
||||
action: {
|
||||
name: "admin.users.update",
|
||||
entity_type: "user",
|
||||
entity_id: id,
|
||||
},
|
||||
});
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = updateUserSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
}
|
||||
|
||||
const target = await prisma.user.findUnique({ where: { id } });
|
||||
if (!target) return apiError("User not found", 404);
|
||||
|
||||
// Prevent removing the last admin
|
||||
if (parsed.data.role === "USER" && target.role === "ADMIN") {
|
||||
const adminCount = await prisma.user.count({ where: { role: "ADMIN" } });
|
||||
if (adminCount <= 1) {
|
||||
return apiError("The last admin cannot be demoted", 400);
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = updateUserSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
}
|
||||
}
|
||||
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(parsed.data.username !== undefined && {
|
||||
username: parsed.data.username,
|
||||
}),
|
||||
...(parsed.data.email !== undefined && { email: parsed.data.email }),
|
||||
...(parsed.data.role !== undefined && { role: parsed.data.role }),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
role: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
const target = await prisma.user.findUnique({ where: { id } });
|
||||
if (!target) return apiError("User not found", 404);
|
||||
|
||||
await auditLog("admin.user.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { targetUserId: id, changes: parsed.data },
|
||||
});
|
||||
// Prevent removing the last admin
|
||||
if (parsed.data.role === "USER" && target.role === "ADMIN") {
|
||||
const adminCount = await prisma.user.count({ where: { role: "ADMIN" } });
|
||||
if (adminCount <= 1) {
|
||||
return apiError("The last admin cannot be demoted", 400);
|
||||
}
|
||||
}
|
||||
|
||||
return apiSuccess(updatedUser);
|
||||
});
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(parsed.data.username !== undefined && {
|
||||
username: parsed.data.username,
|
||||
}),
|
||||
...(parsed.data.email !== undefined && { email: parsed.data.email }),
|
||||
...(parsed.data.role !== undefined && { role: parsed.data.role }),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
role: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog("admin.user.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { targetUserId: id, changes: parsed.data },
|
||||
});
|
||||
|
||||
return apiSuccess(updatedUser);
|
||||
},
|
||||
);
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -3,12 +3,22 @@ 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" } })),
|
||||
requireAuth: vi.fn(async () => ({
|
||||
user: { id: "u-1" },
|
||||
session: { id: "s-1" },
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/ai/provider", () => ({
|
||||
resolveProvider: vi.fn(),
|
||||
}));
|
||||
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 })),
|
||||
@@ -19,10 +29,11 @@ vi.mock("@/lib/logging/context", () => ({
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { resolveProvider } from "@/lib/ai/provider";
|
||||
import { resolveProviderForTest } from "@/lib/ai/provider";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(resolveProviderForTest).mockReset();
|
||||
});
|
||||
|
||||
interface ApiErrorEnvelope {
|
||||
@@ -30,6 +41,30 @@ interface ApiErrorEnvelope {
|
||||
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
|
||||
@@ -38,7 +73,7 @@ describe("POST /api/ai/test — provider error leak guard", () => {
|
||||
function makeProviderThatThrows(
|
||||
err: Error & { httpStatus?: number; bodyExcerpt?: string },
|
||||
) {
|
||||
vi.mocked(resolveProvider).mockResolvedValue({
|
||||
vi.mocked(resolveProviderForTest).mockResolvedValue({
|
||||
type: "openai",
|
||||
generateCompletion: vi.fn(async () => {
|
||||
throw err;
|
||||
@@ -49,14 +84,21 @@ describe("POST /api/ai/test — provider error leak guard", () => {
|
||||
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"}' },
|
||||
{
|
||||
httpStatus: 401 as const,
|
||||
bodyExcerpt: '{"error":"invalid api key sk-secret"}',
|
||||
},
|
||||
);
|
||||
makeProviderThatThrows(err);
|
||||
|
||||
const response = await POST();
|
||||
const response = await POST(emptyRequest() as never);
|
||||
const body = (await response.json()) as ApiErrorEnvelope;
|
||||
|
||||
expect(response.status).toBe(502);
|
||||
// v1.4.5: credential failures map to 422 (not 502) so Cloudflare
|
||||
// doesn't replace our JSON body with its HTML error page — that
|
||||
// rewrite was the root cause of the "Unexpected token '<'…" client
|
||||
// crash Marc hit when typing a bad OpenAI key.
|
||||
expect(response.status).toBe(422);
|
||||
expect(body.error ?? "").not.toMatch(/sk-/);
|
||||
expect(body.error ?? "").not.toMatch(/api\.openai\.com/);
|
||||
expect(body.error ?? "").not.toMatch(/invalid api key/i);
|
||||
@@ -67,8 +109,11 @@ describe("POST /api/ai/test — provider error leak guard", () => {
|
||||
makeProviderThatThrows(
|
||||
Object.assign(new Error("429 from openai"), { httpStatus: 429 as const }),
|
||||
);
|
||||
const response = await POST();
|
||||
expect(response.status).toBe(502);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
// v1.4.5: rate-limit passes through as 429 (was 502 in v1.4.4) so
|
||||
// the React Query mutation can read the JSON body instead of
|
||||
// tripping over Cloudflare's HTML 502.
|
||||
expect(response.status).toBe(429);
|
||||
expect(((await response.json()) as ApiErrorEnvelope).error).toBe(
|
||||
"Provider rate-limited the request",
|
||||
);
|
||||
@@ -78,7 +123,7 @@ describe("POST /api/ai/test — provider error leak guard", () => {
|
||||
makeProviderThatThrows(
|
||||
Object.assign(new Error("503 upstream"), { httpStatus: 503 as const }),
|
||||
);
|
||||
const response = await POST();
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(((await response.json()) as ApiErrorEnvelope).error).toBe(
|
||||
"Provider returned a server error",
|
||||
);
|
||||
@@ -86,9 +131,76 @@ describe("POST /api/ai/test — provider error leak guard", () => {
|
||||
|
||||
it("returns the unknown-error fallback otherwise", async () => {
|
||||
makeProviderThatThrows(new Error("ECONNRESET"));
|
||||
const response = await POST();
|
||||
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,63 @@
|
||||
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(),
|
||||
openaiKey: 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);
|
||||
}
|
||||
@@ -47,15 +91,28 @@ export const POST = apiHandler(async () => {
|
||||
ai_test_provider: provider.type,
|
||||
},
|
||||
});
|
||||
// Map upstream status to a *client-side* status code that won't be
|
||||
// rewritten by Cloudflare. 5xx from origin causes Cloudflare to swap
|
||||
// the body for its own HTML error page — `await res.json()` in the
|
||||
// browser then crashes with `Unexpected token '<', "<!DOCTYPE "`,
|
||||
// which is exactly how Marc encountered this when typing a bad
|
||||
// OpenAI key. 4xx codes pass through untouched.
|
||||
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);
|
||||
let outboundStatus: number;
|
||||
let safeMessage: string;
|
||||
if (status === 401 || status === 403) {
|
||||
outboundStatus = 422;
|
||||
safeMessage = "Provider rejected the credentials";
|
||||
} else if (status === 429) {
|
||||
outboundStatus = 429;
|
||||
safeMessage = "Provider rate-limited the request";
|
||||
} else if (status >= 500) {
|
||||
outboundStatus = 502;
|
||||
safeMessage = "Provider returned a server error";
|
||||
} else {
|
||||
outboundStatus = 422;
|
||||
safeMessage = "Provider connection failed";
|
||||
}
|
||||
return apiError(safeMessage, outboundStatus);
|
||||
}
|
||||
});
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
generatePKCE,
|
||||
generateState,
|
||||
buildAuthorizationUrl,
|
||||
CodexOAuthNotConfiguredError,
|
||||
isCodexOAuthConfigured,
|
||||
} from "@/lib/ai/codex-oauth";
|
||||
import { cookies } from "next/headers";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
@@ -12,6 +14,17 @@ import { annotate } from "@/lib/logging/context";
|
||||
export const GET = apiHandler(async (_request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// Surface configuration errors as 503 instead of silently redirecting
|
||||
// to a dead chatgpt.com URL. The v1.4.2 build dropped the `client_id`
|
||||
// out of the authorize URL — the user landed on the chatgpt.com login
|
||||
// page, no callback ever fired, and the connect button "did nothing".
|
||||
if (!isCodexOAuthConfigured()) {
|
||||
throw new HttpError(
|
||||
503,
|
||||
"Codex OAuth is not configured on this instance — set CODEX_OAUTH_CLIENT_ID",
|
||||
);
|
||||
}
|
||||
|
||||
const rl = await checkRateLimit(`codex-authorize:${user.id}`, 5, 60_000);
|
||||
if (!rl.allowed) throw new HttpError(429, "Too many requests");
|
||||
|
||||
@@ -35,11 +48,21 @@ export const GET = apiHandler(async (_request: NextRequest) => {
|
||||
cookieStore.set("codex_verifier", verifier, cookieOptions);
|
||||
cookieStore.set("codex_state", state, cookieOptions);
|
||||
|
||||
const authUrl = buildAuthorizationUrl({
|
||||
codeChallenge: challenge,
|
||||
state,
|
||||
redirectUri,
|
||||
});
|
||||
let authUrl: string;
|
||||
try {
|
||||
authUrl = buildAuthorizationUrl({
|
||||
codeChallenge: challenge,
|
||||
state,
|
||||
redirectUri,
|
||||
});
|
||||
} catch (err) {
|
||||
// Race between the env check above and the URL build — extremely
|
||||
// unlikely but the typed error path is cheap insurance.
|
||||
if (err instanceof CodexOAuthNotConfiguredError) {
|
||||
throw new HttpError(503, err.message);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
annotate({ action: { name: "codex.oauth.authorize" } });
|
||||
|
||||
return NextResponse.redirect(authUrl);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { exchangeCodeForTokens, encryptTokens } from "@/lib/ai/codex-oauth";
|
||||
import { exchangeCodeForTokens, encryptCodexCreds } from "@/lib/ai/codex-oauth";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { getClientIp } from "@/lib/api-response";
|
||||
@@ -14,7 +14,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const rl = await checkRateLimit(`codex-callback:${user.id}`, 10, 60_000);
|
||||
if (!rl.allowed) {
|
||||
const appUrl = process.env.APP_URL || process.env.NEXT_PUBLIC_APP_URL;
|
||||
return NextResponse.redirect(`${appUrl}/settings?codex_error=rate_limited`);
|
||||
return NextResponse.redirect(
|
||||
`${appUrl}/settings/integrations?codex_error=rate_limited`,
|
||||
);
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
@@ -29,13 +31,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,31 +50,35 @@ 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`,
|
||||
);
|
||||
}
|
||||
|
||||
const redirectUri = `${appUrl}/api/auth/codex/callback`;
|
||||
|
||||
try {
|
||||
const tokens = await exchangeCodeForTokens({
|
||||
const creds = await exchangeCodeForTokens({
|
||||
code,
|
||||
codeVerifier: storedVerifier,
|
||||
redirectUri,
|
||||
});
|
||||
const encrypted = encryptTokens({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
const encrypted = encryptCodexCreds({
|
||||
apiKey: creds.apiKey,
|
||||
refreshToken: creds.refreshToken,
|
||||
});
|
||||
|
||||
// The DB field is named `codexAccessTokenEncrypted` for historical
|
||||
// reasons. Since v1.4.7 the value stored is the **OpenAI API key**
|
||||
// obtained via the token-exchange grant (RFC 8693), not the OAuth
|
||||
// access token. The schema name stays for migration-free rollout;
|
||||
// the helpers (`encryptCodexCreds` / `decryptCodexCreds`) are the
|
||||
// canonical surface from now on.
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
codexAccessTokenEncrypted: encrypted.accessEncrypted,
|
||||
codexAccessTokenEncrypted: encrypted.apiKeyEncrypted,
|
||||
codexRefreshTokenEncrypted: encrypted.refreshEncrypted,
|
||||
codexTokenExpiresAt: new Date(
|
||||
Date.now() + tokens.expires_in * 1000,
|
||||
),
|
||||
codexTokenExpiresAt: creds.expiresAt,
|
||||
codexConnectedAt: new Date(),
|
||||
codexConnectionStatus: "connected",
|
||||
insightsCachedAt: null,
|
||||
@@ -86,12 +92,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`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -7,6 +7,7 @@ vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findFirst: vi.fn() },
|
||||
apiToken: { create: vi.fn() },
|
||||
refreshToken: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -23,7 +24,9 @@ vi.mock("@/lib/auth/audit", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({ allowed: true, remaining: 5, reset: 0 }),
|
||||
checkRateLimit: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ allowed: true, remaining: 5, reset: 0 }),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
@@ -32,7 +35,7 @@ vi.mock("@/lib/db-compat", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/hmac", () => ({
|
||||
hashToken: vi.fn(() => "deadbeef"),
|
||||
hashToken: vi.fn((raw: string) => `hashed:${raw}`),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({
|
||||
@@ -41,7 +44,11 @@ vi.mock("@/lib/logging/transports", () => ({
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({ get: () => undefined, set: () => {}, delete: () => {} })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
@@ -66,7 +73,10 @@ function makeRequest(headers: Record<string, string> = {}): NextRequest {
|
||||
"content-type": "application/json",
|
||||
...headers,
|
||||
},
|
||||
body: JSON.stringify({ email: "marc@example.com", password: "supersecret" }),
|
||||
body: JSON.stringify({
|
||||
email: "marc@example.com",
|
||||
password: "supersecret",
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -75,6 +85,9 @@ beforeEach(() => {
|
||||
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,
|
||||
@@ -100,6 +113,8 @@ describe("POST /api/auth/login — native token issuance", () => {
|
||||
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;
|
||||
}
|
||||
@@ -126,6 +141,8 @@ describe("POST /api/auth/login — native token issuance", () => {
|
||||
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,13 +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";
|
||||
@@ -75,22 +85,64 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
annotate({ action: { name: "auth.login.password" } });
|
||||
|
||||
// Native clients (iOS) additionally receive a long-lived Bearer token in
|
||||
// the response so they don't need to juggle the cookie jar.
|
||||
if (isNativeClientRequest(request.headers)) {
|
||||
// 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: `iOS auto-login ${new Date().toISOString()}`,
|
||||
name: `web auto-login ${new Date().toISOString()}`,
|
||||
permissions: ["*"],
|
||||
expiresInDays: 90,
|
||||
expiresInDays: policy.accessTokenDays,
|
||||
});
|
||||
await auditLog("auth.token.autoissue.native", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: { tokenId: issued.tokenId, source: "login.password" },
|
||||
details: {
|
||||
tokenId: issued.tokenId,
|
||||
source: "login.password",
|
||||
policy: "web",
|
||||
},
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.token.autoissue.native" },
|
||||
meta: { token_policy: "web" },
|
||||
});
|
||||
annotate({ action: { name: "auth.token.autoissue.native" } });
|
||||
|
||||
return apiSuccess({
|
||||
user: { id: user.id, username: user.username },
|
||||
token: issued.token,
|
||||
|
||||
@@ -5,6 +5,7 @@ vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn() },
|
||||
apiToken: { create: vi.fn() },
|
||||
refreshToken: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -29,7 +30,7 @@ vi.mock("@/lib/db-compat", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/hmac", () => ({
|
||||
hashToken: vi.fn(() => "deadbeef"),
|
||||
hashToken: vi.fn((raw: string) => `hashed:${raw}`),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({
|
||||
@@ -38,7 +39,11 @@ vi.mock("@/lib/logging/transports", () => ({
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({ get: () => undefined, set: () => {}, delete: () => {} })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
@@ -67,6 +72,9 @@ 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,
|
||||
@@ -91,6 +99,8 @@ describe("POST /api/auth/passkey/login-verify — native token issuance", () =>
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
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";
|
||||
@@ -9,17 +14,27 @@ 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;
|
||||
@@ -60,20 +75,59 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
annotate({ action: { name: "auth.login.passkey" } });
|
||||
|
||||
if (isNativeClientRequest(request.headers)) {
|
||||
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: `iOS auto-login ${new Date().toISOString()}`,
|
||||
name: `web auto-login ${new Date().toISOString()}`,
|
||||
permissions: ["*"],
|
||||
expiresInDays: 90,
|
||||
expiresInDays: policy.accessTokenDays,
|
||||
});
|
||||
await auditLog("auth.token.autoissue.native", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: { tokenId: issued.tokenId, source: "login.passkey" },
|
||||
details: {
|
||||
tokenId: issued.tokenId,
|
||||
source: "login.passkey",
|
||||
policy: "web",
|
||||
},
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.token.autoissue.native" },
|
||||
meta: { token_policy: "web" },
|
||||
});
|
||||
annotate({ action: { name: "auth.token.autoissue.native" } });
|
||||
|
||||
return apiSuccess({
|
||||
user: { id: user.id, username: user.username },
|
||||
token: issued.token,
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { verifyRegistration } from "@/lib/auth/passkey";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
@@ -9,7 +14,8 @@ import { annotate } from "@/lib/logging/context";
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson<Record<string, unknown>>(request);
|
||||
const { data: body, error: jsonError } =
|
||||
await safeJson<Record<string, unknown>>(request);
|
||||
|
||||
if (jsonError) return jsonError;
|
||||
const challengeId = body.challengeId as string | undefined;
|
||||
@@ -35,7 +41,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
counter: BigInt(registrationInfo.credential.counter),
|
||||
credentialDeviceType: registrationInfo.credentialDeviceType,
|
||||
credentialBackedUp: registrationInfo.credentialBackedUp,
|
||||
transports: ((credential.response as Record<string, unknown> | undefined)?.transports as string[]) ?? [],
|
||||
transports:
|
||||
((credential.response as Record<string, unknown> | undefined)
|
||||
?.transports as string[]) ?? [],
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -5,44 +5,46 @@ import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export const DELETE = apiHandler(async (
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) => {
|
||||
const { user } = await requireAuth();
|
||||
export const DELETE = apiHandler(
|
||||
async (
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { id } = await params;
|
||||
const { id } = await params;
|
||||
|
||||
const passkey = await prisma.passkey.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
const passkey = await prisma.passkey.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!passkey || passkey.userId !== user.id) {
|
||||
return apiError("Passkey not found", 404);
|
||||
}
|
||||
if (!passkey || passkey.userId !== user.id) {
|
||||
return apiError("Passkey not found", 404);
|
||||
}
|
||||
|
||||
// Check: at least 1 auth method must remain
|
||||
const passkeyCount = await prisma.passkey.count({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
const hasPassword = !!user.passwordHash;
|
||||
// Check: at least 1 auth method must remain
|
||||
const passkeyCount = await prisma.passkey.count({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
const hasPassword = !!user.passwordHash;
|
||||
|
||||
if (passkeyCount <= 1 && !hasPassword) {
|
||||
return apiError(
|
||||
"Cannot delete — at least one authentication method must remain",
|
||||
400,
|
||||
);
|
||||
}
|
||||
if (passkeyCount <= 1 && !hasPassword) {
|
||||
return apiError(
|
||||
"Cannot delete — at least one authentication method must remain",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.passkey.delete({ where: { id } });
|
||||
await prisma.passkey.delete({ where: { id } });
|
||||
|
||||
await auditLog("auth.passkey.delete", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { passkeyId: id, passkeyName: passkey.name },
|
||||
});
|
||||
await auditLog("auth.passkey.delete", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { passkeyId: id, passkeyName: passkey.name },
|
||||
});
|
||||
|
||||
annotate({ action: { name: "auth.passkey.delete" } });
|
||||
annotate({ action: { name: "auth.passkey.delete" } });
|
||||
|
||||
return apiSuccess({ success: true });
|
||||
});
|
||||
return apiSuccess({ success: true });
|
||||
},
|
||||
);
|
||||
@@ -7,7 +7,12 @@ import {
|
||||
} from "@/lib/auth/password";
|
||||
import { changePasswordSchema } from "@/lib/validations/auth";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
@@ -23,10 +28,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
15 * 60 * 1000,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return apiError(
|
||||
"Too many attempts. Please wait 15 minutes.",
|
||||
429,
|
||||
);
|
||||
return apiError("Too many attempts. Please wait 15 minutes.", 429);
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
@@ -42,19 +44,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
return apiError("No password set for this account", 400);
|
||||
}
|
||||
|
||||
const currentValid = await verifyPassword(
|
||||
user.passwordHash,
|
||||
currentPassword,
|
||||
);
|
||||
const currentValid = await verifyPassword(user.passwordHash, currentPassword);
|
||||
if (!currentValid) {
|
||||
return apiError("Current password is incorrect", 401);
|
||||
}
|
||||
|
||||
if (currentPassword === newPassword) {
|
||||
return apiError(
|
||||
"New password must differ from current password",
|
||||
422,
|
||||
);
|
||||
return apiError("New password must differ from current password", 422);
|
||||
}
|
||||
|
||||
const locale = await resolveServerLocale({
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,12 @@ import { registerSchema } from "@/lib/validations/auth";
|
||||
import { hashPassword, checkPasswordStrength } from "@/lib/auth/password";
|
||||
import { createSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { ensureDbCompatibility } from "@/lib/db-compat";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
@@ -18,8 +23,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
return NextResponse.json(
|
||||
{
|
||||
data: null,
|
||||
error:
|
||||
"Too many registration attempts. Please try again later.",
|
||||
error: "Too many registration attempts. Please try again later.",
|
||||
},
|
||||
{ status: 429, headers: rateLimitHeaders(rl) },
|
||||
);
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
@@ -17,7 +22,10 @@ const bugReportSchema = z.object({
|
||||
screenshot: z
|
||||
.string()
|
||||
.max(7_000_000, "Screenshot too large (max 5 MB)")
|
||||
.regex(SCREENSHOT_DATA_URL, "Screenshot must be a base64-encoded PNG/JPEG/WEBP/GIF data URL")
|
||||
.regex(
|
||||
SCREENSHOT_DATA_URL,
|
||||
"Screenshot must be a base64-encoded PNG/JPEG/WEBP/GIF data URL",
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
@@ -36,9 +44,18 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
select: {
|
||||
githubIssueTokenEncrypted: true,
|
||||
githubIssueRepo: true,
|
||||
bugReportEnabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
// v1.4.3: explicit toggle. When the admin disables bug reports we
|
||||
// return 503 immediately — the client already renders a "not
|
||||
// configured" UI for that status, so the report button gracefully
|
||||
// disappears without needing additional client-side wiring.
|
||||
if (appSettings && appSettings.bugReportEnabled === false) {
|
||||
return apiError("Bug reports are disabled by the administrator", 503);
|
||||
}
|
||||
|
||||
let configuredToken: string | null = null;
|
||||
if (appSettings?.githubIssueTokenEncrypted) {
|
||||
try {
|
||||
@@ -83,10 +100,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const safeUsername = user.username.replace(/[`*_~<>\[\]|]/g, "");
|
||||
const safeDescription = description
|
||||
.replace(/<[^>]*>/g, "") // strip HTML tags
|
||||
.replace(
|
||||
/\]\(\s*(javascript|data|vbscript|file):/gi,
|
||||
"](about:blank#",
|
||||
) // neutralise dangerous markdown link schemes
|
||||
.replace(/\]\(\s*(javascript|data|vbscript|file):/gi, "](about:blank#") // neutralise dangerous markdown link schemes
|
||||
.replace(/```/g, "\\`\\`\\`")
|
||||
.slice(0, 5000);
|
||||
|
||||
|
||||
@@ -18,18 +18,18 @@ export const GET = apiHandler(async () => {
|
||||
|
||||
// Light rate limit — the endpoint is cheap, but no reason to let a logged-in
|
||||
// client hammer Postgres in a loop.
|
||||
const rl = await checkRateLimit(
|
||||
`bugreport-status:${user.id}`,
|
||||
30,
|
||||
60 * 1000,
|
||||
);
|
||||
const rl = await checkRateLimit(`bugreport-status:${user.id}`, 30, 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Rate limit exceeded", 429);
|
||||
}
|
||||
|
||||
const appSettings = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: { githubIssueTokenEncrypted: true, githubIssueRepo: true },
|
||||
select: {
|
||||
githubIssueTokenEncrypted: true,
|
||||
githubIssueRepo: true,
|
||||
bugReportEnabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
const hasToken = Boolean(
|
||||
@@ -39,11 +39,13 @@ export const GET = apiHandler(async () => {
|
||||
appSettings?.githubIssueRepo || process.env.GITHUB_ISSUE_REPO,
|
||||
);
|
||||
const configured = hasToken && hasRepo;
|
||||
// Default ON when the column has never been written.
|
||||
const enabled = appSettings?.bugReportEnabled !== false;
|
||||
|
||||
annotate({
|
||||
action: { name: "bugreport.status" },
|
||||
meta: { configured, has_token: hasToken, has_repo: hasRepo },
|
||||
meta: { configured, enabled, has_token: hasToken, has_repo: hasRepo },
|
||||
});
|
||||
|
||||
return apiSuccess({ configured, isAdmin: user.role === "ADMIN" });
|
||||
return apiSuccess({ configured, enabled, isAdmin: user.role === "ADMIN" });
|
||||
});
|
||||
@@ -22,7 +22,11 @@ vi.mock("@/lib/db-compat", () => ({
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({ get: () => undefined, set: () => {}, delete: () => {} })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
@@ -50,7 +54,9 @@ function makeReq(): NextRequest {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
});
|
||||
|
||||
describe("GET /api/dashboard/summary", () => {
|
||||
|
||||
@@ -15,6 +15,8 @@ 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;
|
||||
@@ -159,8 +161,8 @@ export const GET = apiHandler(async () => {
|
||||
...measurementTypeEnum.options,
|
||||
] as MeasurementType[];
|
||||
|
||||
const [recentMeasurements, todaysIntakes, streakActivity] =
|
||||
await Promise.all([
|
||||
const [recentMeasurements, todaysIntakes, streakActivity] = await Promise.all(
|
||||
[
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
@@ -185,10 +187,12 @@ export const GET = apiHandler(async () => {
|
||||
},
|
||||
select: { takenAt: true, scheduledFor: true },
|
||||
}),
|
||||
]);
|
||||
],
|
||||
);
|
||||
|
||||
const activityDays = new Set<string>();
|
||||
for (const m of recentMeasurements) activityDays.add(berlinDayKey(m.measuredAt));
|
||||
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) {
|
||||
@@ -242,7 +246,7 @@ export const GET = apiHandler(async () => {
|
||||
unit: METRIC_UNITS.weight,
|
||||
trend: trendOf(spark),
|
||||
sparkline: spark,
|
||||
updatedAt: latest?.at.toISOString() ?? null,
|
||||
updatedAt: latest?.at?.toISOString() ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -262,7 +266,7 @@ export const GET = apiHandler(async () => {
|
||||
trend: trendOf(sysList.map((p) => p.value)),
|
||||
sparkline: sysList.map((p) => p.value),
|
||||
updatedAt:
|
||||
latestSys?.at.toISOString() ?? latestDia?.at.toISOString() ?? null,
|
||||
latestSys?.at?.toISOString() ?? latestDia?.at?.toISOString() ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -279,7 +283,7 @@ export const GET = apiHandler(async () => {
|
||||
unit: METRIC_UNITS.pulse,
|
||||
trend: trendOf(spark),
|
||||
sparkline: spark,
|
||||
updatedAt: latest?.at.toISOString() ?? null,
|
||||
updatedAt: latest?.at?.toISOString() ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -333,16 +337,19 @@ export const GET = apiHandler(async () => {
|
||||
).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: `Hi, ${greetingName}`,
|
||||
salutation: t("dashboard.greetingSalutation", { name: greetingName }),
|
||||
date: now.toISOString(),
|
||||
},
|
||||
streak: {
|
||||
currentDays: streak.currentDays,
|
||||
longest: streak.longest,
|
||||
label: "Tage in Folge",
|
||||
label: t("dashboard.streakLabel"),
|
||||
},
|
||||
compliance: {
|
||||
scheduledToday,
|
||||
|
||||
@@ -25,7 +25,11 @@ vi.mock("@/lib/db-compat", () => ({
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({ get: () => undefined, set: () => {}, delete: () => {} })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
|
||||
@@ -52,7 +52,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
userId: user.id,
|
||||
details: { reason: "token_owned_by_other_user", deviceId: existing.id },
|
||||
});
|
||||
return apiError("Device token already registered to another account", 409);
|
||||
return apiError(
|
||||
"Device token already registered to another account",
|
||||
409,
|
||||
);
|
||||
}
|
||||
const updated = await prisma.device.update({
|
||||
where: { token },
|
||||
|
||||
@@ -38,8 +38,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
if (!["csv", "json"].includes(format)) {
|
||||
return apiError("Format must be csv or json", 422);
|
||||
}
|
||||
if (!["measurements", "medications", "intake", "mood", "all"].includes(type)) {
|
||||
return apiError("Type must be measurements, medications, intake, mood, or all", 422);
|
||||
if (
|
||||
!["measurements", "medications", "intake", "mood", "all"].includes(type)
|
||||
) {
|
||||
return apiError(
|
||||
"Type must be measurements, medications, intake, mood, or all",
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
const userId = user.id;
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
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", email: "test@example.com", role: "USER" },
|
||||
session: { id: "s-1" },
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
appSettings: { findUnique: vi.fn() },
|
||||
feedback: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
function jsonRequest(body: unknown): Request {
|
||||
return new Request("http://localhost/api/feedback", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
describe("POST /api/feedback bug-report toggle gate", () => {
|
||||
it("returns 503 when bugReportEnabled is false", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
bugReportEnabled: false,
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
jsonRequest({
|
||||
category: "BUG",
|
||||
subject: "test",
|
||||
description: "Anything at least ten characters long.",
|
||||
}) as never,
|
||||
);
|
||||
expect(res.status).toBe(503);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toMatch(/disabled/i);
|
||||
expect(prisma.feedback.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates feedback when bugReportEnabled is true", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
bugReportEnabled: true,
|
||||
} as never);
|
||||
vi.mocked(prisma.feedback.create).mockResolvedValue({
|
||||
id: "fb-1",
|
||||
createdAt: new Date(),
|
||||
category: "BUG",
|
||||
status: "NEW",
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
jsonRequest({
|
||||
category: "BUG",
|
||||
subject: "test",
|
||||
description: "Anything at least ten characters long.",
|
||||
}) as never,
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
expect(prisma.feedback.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("creates feedback when bugReportEnabled has never been written (default ON)", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(prisma.feedback.create).mockResolvedValue({
|
||||
id: "fb-2",
|
||||
createdAt: new Date(),
|
||||
category: "BUG",
|
||||
status: "NEW",
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
jsonRequest({
|
||||
category: "BUG",
|
||||
subject: "test",
|
||||
description: "Anything at least ten characters long.",
|
||||
}) as never,
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
expect(prisma.feedback.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,12 @@
|
||||
* the database (≤5 MB each).
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, safeJson, getClientIp } from "@/lib/api-response";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
safeJson,
|
||||
getClientIp,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
@@ -23,6 +28,18 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
return apiError("Too many submissions — please try again later", 429);
|
||||
}
|
||||
|
||||
// The admin "Bug reports" toggle gates *every* feedback submission, not
|
||||
// just the GitHub-promotion path. Previously the gate only lived in the
|
||||
// legacy `/api/bugreport` route which the form does not even call —
|
||||
// so flipping the toggle off had no effect on user-visible behaviour.
|
||||
const settings = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: { bugReportEnabled: true },
|
||||
});
|
||||
if (settings && settings.bugReportEnabled === false) {
|
||||
return apiError("Bug reports are disabled by the administrator", 503);
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
|
||||
@@ -25,7 +25,11 @@ vi.mock("@/lib/db-compat", () => ({
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({ get: () => undefined, set: () => {}, delete: () => {} })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
@@ -47,7 +51,9 @@ const SESSION_OK = {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.passkey.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.auditLog.findMany).mockResolvedValue([] as never);
|
||||
|
||||
@@ -456,10 +456,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const now = new Date();
|
||||
const userId = user.id;
|
||||
const startDate = maxDate(
|
||||
user.createdAt,
|
||||
GAMIFICATION_ROLLOUT_AT,
|
||||
);
|
||||
const startDate = maxDate(user.createdAt, GAMIFICATION_ROLLOUT_AT);
|
||||
|
||||
const [measurements, intakeEvents, medications, passkeys, auditEvents] =
|
||||
await Promise.all([
|
||||
@@ -469,12 +466,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
measuredAt: { gte: startDate, lte: now },
|
||||
source: { not: "IMPORT" },
|
||||
type: {
|
||||
in: [
|
||||
"WEIGHT",
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
"PULSE",
|
||||
],
|
||||
in: ["WEIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "PULSE"],
|
||||
},
|
||||
},
|
||||
select: {
|
||||
@@ -741,14 +733,14 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
mergedDates[id] = date;
|
||||
}
|
||||
|
||||
const result = evaluateAchievementsWithCompletionDates(
|
||||
metrics,
|
||||
mergedDates,
|
||||
);
|
||||
const result = evaluateAchievementsWithCompletionDates(metrics, mergedDates);
|
||||
|
||||
// Persist newly unlocked achievements
|
||||
const newUnlocks = result.achievements.filter(
|
||||
(a) => a.unlocked && a.completedAt && !persisted.some((p) => p.achievementId === a.id),
|
||||
(a) =>
|
||||
a.unlocked &&
|
||||
a.completedAt &&
|
||||
!persisted.some((p) => p.achievementId === a.id),
|
||||
);
|
||||
if (newUnlocks.length > 0) {
|
||||
await prisma.userAchievement.createMany({
|
||||
|
||||
@@ -28,7 +28,7 @@ export const GET = apiHandler(async () => {
|
||||
|
||||
// Only expose detailed info to authenticated admins
|
||||
const session = await getSession().catch(() => null);
|
||||
if (session?.user.role === "ADMIN") {
|
||||
if (session?.user?.role === "ADMIN") {
|
||||
return NextResponse.json(
|
||||
{
|
||||
status,
|
||||
|
||||
@@ -2,7 +2,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({ user: { id: "u-1" }, session: { id: "s-1" } })),
|
||||
requireAuth: vi.fn(async () => ({
|
||||
user: { id: "u-1" },
|
||||
session: { id: "s-1" },
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
|
||||
@@ -2,7 +2,12 @@ import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
@@ -2,7 +2,12 @@ import { prisma } from "@/lib/db";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { hashToken } from "@/lib/auth/hmac";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { externalIntakeSchema } from "@/lib/validations/medication";
|
||||
|
||||
@@ -21,13 +21,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
const locale = resolveBloodPressureStatusLocale(resolved);
|
||||
|
||||
const result = await generateBloodPressureStatusForUser(
|
||||
user.id,
|
||||
{
|
||||
locale,
|
||||
force: false,
|
||||
},
|
||||
);
|
||||
const result = await generateBloodPressureStatusForUser(user.id, {
|
||||
locale,
|
||||
force: false,
|
||||
});
|
||||
|
||||
annotate({ action: { name: "insights.blood-pressure-status" } });
|
||||
|
||||
|
||||
@@ -24,7 +24,11 @@ vi.mock("@/lib/db-compat", () => ({
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({ get: () => undefined, set: () => {}, delete: () => {} })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
@@ -40,7 +44,9 @@ beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
heightCm: null,
|
||||
dateOfBirth: null,
|
||||
@@ -80,10 +86,20 @@ describe("GET /api/insights/cards", () => {
|
||||
}> = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const at = new Date(now.getTime() - i * 86_400_000);
|
||||
measurements.push({ type: "BLOOD_PRESSURE_SYS", value: 180, measuredAt: at });
|
||||
measurements.push({ type: "BLOOD_PRESSURE_DIA", value: 110, measuredAt: at });
|
||||
measurements.push({
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
value: 180,
|
||||
measuredAt: at,
|
||||
});
|
||||
measurements.push({
|
||||
type: "BLOOD_PRESSURE_DIA",
|
||||
value: 110,
|
||||
measuredAt: at,
|
||||
});
|
||||
}
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue(measurements as never);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue(
|
||||
measurements as never,
|
||||
);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
heightCm: null,
|
||||
dateOfBirth: null,
|
||||
|
||||
@@ -68,12 +68,7 @@ export const GET = apiHandler(async () => {
|
||||
where: {
|
||||
userId: user.id,
|
||||
type: {
|
||||
in: [
|
||||
"WEIGHT",
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
"PULSE",
|
||||
],
|
||||
in: ["WEIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "PULSE"],
|
||||
},
|
||||
measuredAt: { gte: ninetyDaysAgo },
|
||||
},
|
||||
@@ -91,7 +86,9 @@ export const GET = apiHandler(async () => {
|
||||
.filter((m) => m.type === t)
|
||||
.map((m) => ({ date: m.measuredAt, value: m.value }));
|
||||
|
||||
const summaries: Partial<Record<MeasurementType, ReturnType<typeof summarize>>> = {};
|
||||
const summaries: Partial<
|
||||
Record<MeasurementType, ReturnType<typeof summarize>>
|
||||
> = {};
|
||||
for (const t of [
|
||||
"WEIGHT",
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
@@ -105,8 +102,7 @@ export const GET = apiHandler(async () => {
|
||||
let bmi: number | null = null;
|
||||
if (dbUser?.heightCm && summaries.WEIGHT?.latest) {
|
||||
const heightM = dbUser.heightCm / 100;
|
||||
bmi =
|
||||
Math.round((summaries.WEIGHT.latest / (heightM * heightM)) * 10) / 10;
|
||||
bmi = Math.round((summaries.WEIGHT.latest / (heightM * heightM)) * 10) / 10;
|
||||
}
|
||||
|
||||
let bpPctInTarget: number | null = null;
|
||||
@@ -142,7 +138,12 @@ export const GET = apiHandler(async () => {
|
||||
medicationId: { in: activeMeds.map((m) => m.id) },
|
||||
scheduledFor: { gte: new Date(Date.now() - 30 * 86_400_000) },
|
||||
},
|
||||
select: { medicationId: true, takenAt: true, skipped: true, scheduledFor: true },
|
||||
select: {
|
||||
medicationId: true,
|
||||
takenAt: true,
|
||||
skipped: true,
|
||||
scheduledFor: true,
|
||||
},
|
||||
});
|
||||
const eventsByMed = new Map<string, typeof allEvents>();
|
||||
for (const e of allEvents) {
|
||||
@@ -154,15 +155,22 @@ export const GET = apiHandler(async () => {
|
||||
for (const med of activeMeds) {
|
||||
const events = eventsByMed.get(med.id) ?? [];
|
||||
const takenLast7 = events.filter(
|
||||
(e) => e.takenAt !== null && !e.skipped && e.scheduledFor.getTime() >= cutoff7,
|
||||
(e) =>
|
||||
e.takenAt !== null &&
|
||||
!e.skipped &&
|
||||
e.scheduledFor.getTime() >= cutoff7,
|
||||
).length;
|
||||
const taken30 = events.filter(
|
||||
(e) => e.takenAt !== null && !e.skipped,
|
||||
).length;
|
||||
const taken30 = events.filter((e) => e.takenAt !== null && !e.skipped).length;
|
||||
const expected7 = med.schedules.length * 7;
|
||||
const expected30 = med.schedules.length * 30;
|
||||
medicationCompliance.push({
|
||||
name: med.name,
|
||||
compliance7: expected7 > 0 ? Math.round((takenLast7 / expected7) * 100) : 0,
|
||||
compliance30: expected30 > 0 ? Math.round((taken30 / expected30) * 100) : 0,
|
||||
compliance7:
|
||||
expected7 > 0 ? Math.round((takenLast7 / expected7) * 100) : 0,
|
||||
compliance30:
|
||||
expected30 > 0 ? Math.round((taken30 / expected30) * 100) : 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,8 @@ export const GET = apiHandler(async () => {
|
||||
date: e.moodLoggedAt,
|
||||
value: e.score,
|
||||
}));
|
||||
const moodSummary = moodDataPoints.length > 0 ? summarize(moodDataPoints) : null;
|
||||
const moodSummary =
|
||||
moodDataPoints.length > 0 ? summarize(moodDataPoints) : null;
|
||||
|
||||
const byType = (t: MeasurementType): DataPoint[] =>
|
||||
allMeasurements
|
||||
@@ -173,7 +174,9 @@ export const GET = apiHandler(async () => {
|
||||
// Mood vs Systolic BP
|
||||
const moodBpPairs = buildMoodMetricPairs(
|
||||
dailyMoodEntries,
|
||||
allMeasurements.filter((m) => m.type === "BLOOD_PRESSURE_SYS").map((m) => ({ measuredAt: m.measuredAt, value: m.value })),
|
||||
allMeasurements
|
||||
.filter((m) => m.type === "BLOOD_PRESSURE_SYS")
|
||||
.map((m) => ({ measuredAt: m.measuredAt, value: m.value })),
|
||||
);
|
||||
const moodBpCorrelation = pearsonCorrelation(moodBpPairs);
|
||||
const moodBpScatterData = moodBpPairs.map((p) => ({ mood: p.a, sysBP: p.b }));
|
||||
@@ -181,18 +184,28 @@ export const GET = apiHandler(async () => {
|
||||
// Mood vs Weight
|
||||
const moodWeightPairs = buildMoodMetricPairs(
|
||||
dailyMoodEntries,
|
||||
allMeasurements.filter((m) => m.type === "WEIGHT").map((m) => ({ measuredAt: m.measuredAt, value: m.value })),
|
||||
allMeasurements
|
||||
.filter((m) => m.type === "WEIGHT")
|
||||
.map((m) => ({ measuredAt: m.measuredAt, value: m.value })),
|
||||
);
|
||||
const moodWeightCorrelation = pearsonCorrelation(moodWeightPairs);
|
||||
const moodWeightScatterData = moodWeightPairs.map((p) => ({ mood: p.a, weight: p.b }));
|
||||
const moodWeightScatterData = moodWeightPairs.map((p) => ({
|
||||
mood: p.a,
|
||||
weight: p.b,
|
||||
}));
|
||||
|
||||
// Mood vs Pulse
|
||||
const moodPulsePairs = buildMoodMetricPairs(
|
||||
dailyMoodEntries,
|
||||
allMeasurements.filter((m) => m.type === "PULSE").map((m) => ({ measuredAt: m.measuredAt, value: m.value })),
|
||||
allMeasurements
|
||||
.filter((m) => m.type === "PULSE")
|
||||
.map((m) => ({ measuredAt: m.measuredAt, value: m.value })),
|
||||
);
|
||||
const moodPulseCorrelation = pearsonCorrelation(moodPulsePairs);
|
||||
const moodPulseScatterData = moodPulsePairs.map((p) => ({ mood: p.a, pulse: p.b }));
|
||||
const moodPulseScatterData = moodPulsePairs.map((p) => ({
|
||||
mood: p.a,
|
||||
pulse: p.b,
|
||||
}));
|
||||
|
||||
// Medication compliance
|
||||
const medications = await prisma.medication.findMany({
|
||||
|
||||
@@ -19,7 +19,11 @@ vi.mock("@/lib/db-compat", () => ({
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({ get: () => undefined, set: () => {}, delete: () => {} })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
|
||||
@@ -2,7 +2,10 @@ import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
|
||||
import { extractFeatures } from "@/lib/insights/features";
|
||||
import { getInsightsSystemPrompt, buildUserPrompt } from "@/lib/insights/prompt";
|
||||
import {
|
||||
getInsightsSystemPrompt,
|
||||
buildUserPrompt,
|
||||
} from "@/lib/insights/prompt";
|
||||
import { insightResultSchema, type InsightResult } from "@/lib/ai/types";
|
||||
import { resolveProvider } from "@/lib/ai/provider";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
@@ -15,11 +18,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
const userId = user.id;
|
||||
|
||||
const rl = await checkRateLimit(`insights:${userId}`, 2, 60 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Maximum 2 insight generations per hour.", 429);
|
||||
}
|
||||
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
@@ -46,17 +44,36 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
) {
|
||||
try {
|
||||
const cached = JSON.parse(dbUser.insightsCachedText);
|
||||
annotate({ action: { name: "insights.generate" }, meta: { cached: true } });
|
||||
return apiSuccess({ insights: cached, cached: true, cachedAt: dbUser.insightsCachedAt });
|
||||
annotate({
|
||||
action: { name: "insights.generate" },
|
||||
meta: { cached: true },
|
||||
});
|
||||
return apiSuccess({
|
||||
insights: cached,
|
||||
cached: true,
|
||||
cachedAt: dbUser.insightsCachedAt,
|
||||
});
|
||||
} catch {
|
||||
// Invalid cache, regenerate
|
||||
}
|
||||
}
|
||||
|
||||
// P13: rate-limit check moved BELOW the cache return so a hit on the
|
||||
// 24h cache never burns one of the 2/hour LLM-generation tokens.
|
||||
// Otherwise a noisy reload loop on the dashboard would lock out real
|
||||
// refreshes for an hour for no benefit.
|
||||
const rl = await checkRateLimit(`insights:${userId}`, 2, 60 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Maximum 2 insight generations per hour.", 429);
|
||||
}
|
||||
|
||||
const provider = await resolveProvider(userId);
|
||||
|
||||
if (provider.type === "none") {
|
||||
return apiError("No AI provider configured. Connect ChatGPT in settings or ask your admin to set up an API key.", 422);
|
||||
return apiError(
|
||||
"No AI provider configured. Connect ChatGPT in settings or ask your admin to set up an API key.",
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
const includeRaw = dbUser?.insightsPrivacyMode === "raw";
|
||||
@@ -82,7 +99,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const validated = insightResultSchema.safeParse(parsed);
|
||||
insights = validated.success ? validated.data : parsed;
|
||||
} catch {
|
||||
return apiError("Failed to parse AI response", 502);
|
||||
// Returning 502 here triggers Cloudflare's HTML error rewrite, which
|
||||
// breaks `await res.json()` on the client side. 422 stays passthrough
|
||||
// so the React Query mutation can read the JSON body and surface a
|
||||
// readable message. Same fix pattern as v1.4.5 ai/test.
|
||||
return apiError("AI response was not valid JSON", 422);
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
|
||||
@@ -21,13 +21,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
const locale = resolveMedicationComplianceStatusLocale(resolved);
|
||||
|
||||
const result = await generateMedicationComplianceStatusForUser(
|
||||
user.id,
|
||||
{
|
||||
locale,
|
||||
force: false,
|
||||
},
|
||||
);
|
||||
const result = await generateMedicationComplianceStatusForUser(user.id, {
|
||||
locale,
|
||||
force: false,
|
||||
});
|
||||
|
||||
annotate({ action: { name: "insights.medication-compliance-status" } });
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { isCodexOAuthConfigured } from "@/lib/ai/codex-oauth";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
@@ -28,6 +29,11 @@ export const GET = apiHandler(async () => {
|
||||
codexStatus: dbUser?.codexConnectionStatus ?? "disconnected",
|
||||
codexConnectedAt: dbUser?.codexConnectedAt ?? null,
|
||||
hasAdminKey: !!settings?.adminAiKeyEncrypted,
|
||||
// v1.4.3: surface whether the operator has a `CODEX_OAUTH_CLIENT_ID`
|
||||
// configured. Without it the Connect-with-ChatGPT flow is dead, so
|
||||
// the UI hides the button instead of redirecting to a chatgpt.com
|
||||
// login the user can never complete.
|
||||
codexOauthConfigured: isCodexOAuthConfigured(),
|
||||
privacyMode: dbUser?.insightsPrivacyMode ?? "aggregated",
|
||||
lastInsightAt: dbUser?.insightsCachedAt ?? null,
|
||||
});
|
||||
@@ -36,7 +42,8 @@ export const GET = apiHandler(async () => {
|
||||
export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson<Record<string, unknown>>(request);
|
||||
const { data: body, error: jsonError } =
|
||||
await safeJson<Record<string, unknown>>(request);
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -21,7 +21,11 @@ vi.mock("@/lib/db-compat", () => ({
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({ get: () => undefined, set: () => {}, delete: () => {} })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET, PATCH } from "../route";
|
||||
@@ -102,7 +106,8 @@ describe("PATCH /api/integrations/healthkit", () => {
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.user.update).toHaveBeenCalledTimes(1);
|
||||
const updateArgs = vi.mocked(prisma.user.update).mock.calls[0][0] as unknown as {
|
||||
const updateArgs = vi.mocked(prisma.user.update).mock
|
||||
.calls[0][0] as unknown as {
|
||||
data: { healthKitConfigJson: { entries: Array<{ id: string }> } };
|
||||
};
|
||||
const ids = updateArgs.data.healthKitConfigJson.entries.map((e) => e.id);
|
||||
|
||||
Loaded 100 of 345 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user