mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 20:32:02 +00:00
Compare commits
No files matched your search
+130
-23
@@ -1,37 +1,144 @@
|
||||
# Database
|
||||
DATABASE_URL="postgresql://healthlog:healthlog@localhost:5432/healthlog?schema=public"
|
||||
# =============================================================================
|
||||
# HealthLog — Environment Configuration
|
||||
# =============================================================================
|
||||
# Copy this file to .env and fill in the values.
|
||||
# $ cp .env.example .env
|
||||
#
|
||||
# Required variables: DATABASE_URL or POSTGRES_PASSWORD, SESSION_SECRET,
|
||||
# ENCRYPTION_KEY, API_TOKEN_HMAC_KEY
|
||||
#
|
||||
# Generate the three secrets with:
|
||||
# $ openssl rand -hex 32 # any UNIX shell
|
||||
# $ node -e 'console.log(require("crypto").randomBytes(32).toString("hex"))'
|
||||
#
|
||||
# All secrets must be 64-character hex strings (= 32 bytes).
|
||||
# =============================================================================
|
||||
|
||||
# App
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Database
|
||||
# -----------------------------------------------------------------------------
|
||||
# When using docker-compose.yml: the app container reaches Postgres via the
|
||||
# service name "db", so the hostname is "db" — NOT "localhost".
|
||||
# When developing without Docker (`pnpm dev` against a host-installed Postgres):
|
||||
# change the hostname to "localhost" and adjust the credentials.
|
||||
DATABASE_URL="postgresql://healthlog:CHANGE-ME@db:5432/healthlog?schema=public"
|
||||
|
||||
# Postgres password — used both by the db service AND by DATABASE_URL above.
|
||||
# Pick a strong random password, e.g. `openssl rand -base64 24`.
|
||||
POSTGRES_PASSWORD="CHANGE-ME"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# App URL
|
||||
# -----------------------------------------------------------------------------
|
||||
# The public URL where users reach the app. Behind a reverse proxy with TLS,
|
||||
# this looks like https://healthlog.example.com. For local dev, leave default.
|
||||
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
||||
APP_URL="http://localhost:3000"
|
||||
|
||||
# Node environment: "development" for `pnpm dev`, "production" for Docker.
|
||||
NODE_ENV="development"
|
||||
|
||||
# Session
|
||||
SESSION_SECRET="change-me-to-a-random-64-char-hex-string"
|
||||
|
||||
# Encryption (for tokens, sensitive notes)
|
||||
ENCRYPTION_KEY="change-me-to-a-random-64-char-hex-string"
|
||||
# -----------------------------------------------------------------------------
|
||||
# Required Secrets — generate with `openssl rand -hex 32`
|
||||
# -----------------------------------------------------------------------------
|
||||
# Signs session cookies. Rotating it logs out everyone.
|
||||
SESSION_SECRET="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
|
||||
|
||||
# HMAC key for API token hashing
|
||||
API_TOKEN_HMAC_KEY="change-me-to-a-random-64-char-hex-string"
|
||||
# AES-256-GCM key for encrypting Withings tokens, GitHub PATs, AI provider
|
||||
# keys, and VAPID private keys at rest. Rotate carefully — old ciphertexts
|
||||
# need re-encryption.
|
||||
ENCRYPTION_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
|
||||
|
||||
# Withings OAuth (M5)
|
||||
# HMAC-SHA256 key for hashing external-API Bearer tokens before storing them
|
||||
# in the database. Rotating it invalidates all issued tokens.
|
||||
API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Withings OAuth (optional — for automatic scale/BP/activity sync)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Register an app at https://developer.withings.com/ and paste the credentials
|
||||
# here. Per-user OAuth credentials can also be configured in Settings.
|
||||
# WITHINGS_CLIENT_ID=""
|
||||
# WITHINGS_CLIENT_SECRET=""
|
||||
# WITHINGS_REDIRECT_URI=""
|
||||
# WITHINGS_WEBHOOK_SECRET="" # random secret for webhook callback URL hardening
|
||||
# WITHINGS_REDIRECT_URI="https://healthlog.example.com/api/withings/callback"
|
||||
# WITHINGS_WEBHOOK_SECRET="" # any random string — used to harden the webhook URL
|
||||
|
||||
# Telegram webhook hardening (optional but recommended)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Telegram (optional — webhook hardening)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Set to verify incoming Telegram updates. The bot token itself is configured
|
||||
# per-user in Settings (encrypted at rest).
|
||||
# TELEGRAM_WEBHOOK_SECRET=""
|
||||
|
||||
# OpenAI (optional, user-provided keys stored encrypted)
|
||||
# No server-side key needed - users bring their own
|
||||
|
||||
# Structured Logging (Wide Events)
|
||||
# LOG_LEVEL="info" # debug | info | warn | error
|
||||
# LOG_SAMPLE_RATE="1.0" # 0.0–1.0, fraction of successful requests to log
|
||||
# LOG_SLOW_THRESHOLD_MS="3000" # Requests slower than this are always logged
|
||||
# LOG_INCLUDE_STACK="true" # Include stack traces in error events
|
||||
# LOKI_ENDPOINT="" # Optional: Loki Push API (e.g. http://loki:3100)
|
||||
# LOKI_USERNAME="" # Optional: Loki basic auth username
|
||||
# LOKI_PASSWORD="" # Optional: Loki basic auth password
|
||||
# -----------------------------------------------------------------------------
|
||||
# AI Provider (optional)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Users bring their own API keys via Settings. No server-side key needed by
|
||||
# default. The admin can configure a fallback key in the Admin Panel.
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Structured Logging — Wide Events (optional, sensible defaults)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Levels: debug | info | warn | error
|
||||
# LOG_LEVEL="info"
|
||||
|
||||
# Sample rate for successful requests (0.0 = sample nothing, 1.0 = log all).
|
||||
# Errors and slow requests are ALWAYS logged regardless of this setting.
|
||||
# LOG_SAMPLE_RATE="1.0"
|
||||
|
||||
# Slow-request threshold in milliseconds — anything slower is always kept.
|
||||
# LOG_SLOW_THRESHOLD_MS="3000"
|
||||
|
||||
# Include stack traces on error events. Disable in environments where logs
|
||||
# leave the host unencrypted.
|
||||
# LOG_INCLUDE_STACK="true"
|
||||
|
||||
# Optional: ship Wide Events to Loki. Leave blank for stdout-only logging.
|
||||
# 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,78 @@
|
||||
name: Build & Publish Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
# Cancel in-progress runs when a new commit is pushed to the same ref.
|
||||
concurrency:
|
||||
group: docker-publish-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
name: Build & push multi-arch image
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU (multi-arch)
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# Skip login on PRs from forks — they don't have package:write.
|
||||
# Image is built but not pushed in that case.
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Compute image tags & labels
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=sha,prefix=sha-,format=short
|
||||
labels: |
|
||||
org.opencontainers.image.title=HealthLog
|
||||
org.opencontainers.image.description=Self-hosted health-tracking PWA
|
||||
org.opencontainers.image.licenses=AGPL-3.0-only
|
||||
org.opencontainers.image.source=https://github.com/${{ github.repository }}
|
||||
|
||||
- name: Build & push image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
provenance: true
|
||||
sbom: true
|
||||
@@ -0,0 +1,83 @@
|
||||
name: e2e
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
# Cancel in-progress runs when a new commit lands on the same PR.
|
||||
concurrency:
|
||||
group: e2e-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_USER: healthlog
|
||||
POSTGRES_PASSWORD: healthlog
|
||||
POSTGRES_DB: healthlog
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U healthlog"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
|
||||
env:
|
||||
DATABASE_URL: postgresql://healthlog:healthlog@localhost:5432/healthlog?schema=public
|
||||
ENCRYPTION_KEYS: '{"v1":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}'
|
||||
ENCRYPTION_ACTIVE_KEY_ID: v1
|
||||
API_TOKEN_HMAC_KEY: ci-hmac-key-32-bytes-min-padding-x
|
||||
AUTH_RP_NAME: HealthLog
|
||||
AUTH_RP_ID: localhost
|
||||
AUTH_RP_ORIGIN: http://localhost:3000
|
||||
NODE_ENV: production
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- run: pnpm db:generate
|
||||
|
||||
- run: pnpm db:migrate:deploy
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
playwright-${{ runner.os }}-
|
||||
|
||||
- run: pnpm exec playwright install --with-deps chromium
|
||||
|
||||
- run: pnpm exec next build
|
||||
|
||||
- run: pnpm exec playwright test
|
||||
env:
|
||||
E2E_BASE_URL: http://localhost:3000
|
||||
|
||||
- name: Upload Playwright HTML report on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 7
|
||||
@@ -0,0 +1,34 @@
|
||||
name: Integration tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
integration:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- run: pnpm db:generate
|
||||
|
||||
- run: pnpm test:integration
|
||||
env:
|
||||
# The integration suite boots a Postgres testcontainer and points
|
||||
# Prisma at it via DATABASE_URL set inside `tests/integration/setup.ts`.
|
||||
# These env vars satisfy code paths that read them at module load.
|
||||
API_TOKEN_HMAC_KEY: integration-tests-do-not-use-in-prod
|
||||
ENCRYPTION_KEY: 0000000000000000000000000000000000000000000000000000000000000000
|
||||
SESSION_SECRET: integration-tests
|
||||
@@ -30,11 +30,13 @@ jobs:
|
||||
|
||||
- name: ESLint
|
||||
run: pnpm lint
|
||||
# Non-blocking on the existing settings page monolith — its set-state-
|
||||
# in-effect violations need the 1.4.0 settings-split refactor to fix
|
||||
# cleanly. Will flip to blocking once settings/page.tsx is broken up.
|
||||
continue-on-error: true
|
||||
|
||||
- name: TypeScript strict check
|
||||
run: pnpm typecheck
|
||||
continue-on-error: true
|
||||
|
||||
- name: Unit tests
|
||||
run: pnpm test
|
||||
|
||||
+12
@@ -11,6 +11,11 @@
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# playwright e2e
|
||||
/test-results
|
||||
/playwright-report
|
||||
/playwright/.cache
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
@@ -39,3 +44,10 @@ next-env.d.ts
|
||||
|
||||
# prisma generated
|
||||
/src/generated/prisma
|
||||
|
||||
# AI session artefacts (kept locally, not committed)
|
||||
/docs/superpowers/
|
||||
.claude/
|
||||
.cursor/
|
||||
.codex/
|
||||
.aider*
|
||||
@@ -0,0 +1,30 @@
|
||||
# Gitleaks configuration. Extends the built-in default ruleset and adds
|
||||
# a project-specific allowlist for false-positives that gitleaks 8.x flags
|
||||
# as "generic-api-key" inside test fixtures.
|
||||
[extend]
|
||||
useDefault = true
|
||||
|
||||
[allowlist]
|
||||
description = "Project-wide ignores for test fixtures and known non-secret tokens"
|
||||
|
||||
paths = [
|
||||
# All test files contain hardcoded fake tokens / idempotency-keys.
|
||||
'''(^|/)__tests__/''',
|
||||
'''\.test\.ts$''',
|
||||
# OpenAPI spec contains example tokens (`hlk_example…`) used to document the schema.
|
||||
'''^docs/api/openapi\.yaml$''',
|
||||
]
|
||||
|
||||
# Substring matches — anything matching is treated as non-secret regardless of file.
|
||||
regexes = [
|
||||
# Idempotency-Key request-header values are random UUIDs / opaque strings,
|
||||
# not secrets. The header name itself just trips the generic-api-key rule.
|
||||
'''idempotency-key''',
|
||||
# Allow the placeholder prefix used in OpenAPI examples + docs.
|
||||
'''hlk_example''',
|
||||
]
|
||||
|
||||
# Specific commit fingerprints from prior false-positive findings (kept for reproducibility).
|
||||
# Empty by default; populate via `gitleaks detect --report-path` if a future regression
|
||||
# triggers the same false-positive on a stable commit hash.
|
||||
stopwords = []
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"shadcn": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "shadcn@latest", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,21 +6,33 @@ Instructions for AI coding agents (OpenAI Codex, Claude Code, Cursor, etc.) work
|
||||
|
||||
**HealthLog** — a personal health-tracking web app (weight, blood pressure, pulse, mood, medication compliance) with Withings integration, moodLog.app sync, Dracula-themed UI, mobile-first PWA design.
|
||||
|
||||
**Status**: Feature-complete (v1.0.0). See GitHub Issues for open tasks.
|
||||
**Status**: v1.4.0 (released) → v1.4.1 in progress. v1.4.0 shipped
|
||||
the UI guidelines + Skeleton/EmptyState primitives, medical-citations
|
||||
consolidation, the per-route `/settings/[section]` split, the
|
||||
status-first admin grid + aggregator endpoint, five new "Test
|
||||
connection" buttons, AI insights with inline charts via allowlisted
|
||||
metric tokens, off-host encrypted S3 backup, encryption-key
|
||||
versioning + rotation CLI, optional `HEALTHLOG_PROCESS_TYPE` worker /
|
||||
web split, and short-lived 24h access tokens with refresh-token
|
||||
rotation for native API clients. v1.4.1 follows up with per-section
|
||||
admin component extraction (the inner monolith into one file per
|
||||
panel), a Postgres testcontainers integration test suite, and a
|
||||
Playwright + axe-core E2E foundation. See GitHub Releases +
|
||||
CHANGELOG.md for the full feature timeline.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology | Version | Notes |
|
||||
| --------------- | -------------------- | ------- | ----------------------------------------------------------------------- |
|
||||
| Framework | Next.js (App Router) | 16 | TypeScript strict, RSC default, `"use client"` only for interactivity |
|
||||
| ORM | Prisma | 7.4 | Uses `PrismaPg` adapter, **not** `url` in schema — see gotchas below |
|
||||
| ORM | Prisma | 7.8 | Uses `PrismaPg` adapter, **not** `url` in schema — see gotchas below |
|
||||
| Database | PostgreSQL | 16 | Docker Compose service, port 5432, user `healthlog` |
|
||||
| UI | shadcn/ui (new-york) | latest | Components in `src/components/ui/` |
|
||||
| Theme | Dracula | — | CSS variables in `globals.css`, dark mode default, `--dracula-*` tokens |
|
||||
| CSS | Tailwind | 4 | CSS-first config (`@import "tailwindcss"` syntax) |
|
||||
| Data fetching | TanStack Query | 5 | Provider in `src/components/providers.tsx` |
|
||||
| Validation | Zod | v4 | Import as `zod/v4` (not `zod`) |
|
||||
| Testing | Vitest | latest | Config in `vitest.config.ts` |
|
||||
| Testing | Vitest | latest | Config in `vitest.config.mts` |
|
||||
| Package manager | pnpm | latest | **Not** npm or yarn |
|
||||
| Node | 20.x | via nvm | |
|
||||
| Job queue | pg-boss | 12 | Named import `{ PgBoss }`, see gotchas |
|
||||
@@ -66,7 +78,7 @@ src/
|
||||
│ ├── layout.tsx # Root layout (viewport-fit: cover for PWA)
|
||||
│ ├── page.tsx # Dashboard (/) with quick entry dropdown
|
||||
│ ├── globals.css # Dracula theme CSS variables
|
||||
│ ├── admin/page.tsx # Admin panel
|
||||
│ ├── admin/page.tsx # Admin shell — 77 LOC, mounts the per-section components from src/components/admin/
|
||||
│ ├── auth/login/page.tsx # Login
|
||||
│ ├── auth/register/page.tsx # Registration
|
||||
│ ├── achievements/page.tsx # Gamification achievements
|
||||
@@ -76,9 +88,11 @@ src/
|
||||
│ ├── medications/page.tsx # Medications management
|
||||
│ ├── notifications/page.tsx # Notification preferences matrix
|
||||
│ ├── onboarding/page.tsx # 4-step guided onboarding
|
||||
│ ├── settings/page.tsx # All settings (13 sections, ~2100 lines)
|
||||
│ ├── zielwerte/page.tsx # Target values dashboard
|
||||
│ └── api/ # ~60 API route files (see docs/api.md)
|
||||
│ ├── 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, …)
|
||||
│ ├── mood-entries/ # Mood CRUD
|
||||
│ ├── import/ # JSON data import
|
||||
│ ├── doctor-report/ # Doctor report data aggregation
|
||||
@@ -92,7 +106,10 @@ src/
|
||||
│ ├── measurements/ # Measurement form, list
|
||||
│ ├── mood/ # Mood form, mood list
|
||||
│ ├── charts/ # Recharts wrappers, compliance charts
|
||||
│ ├── insights/ # AI insights card
|
||||
│ ├── insights/ # AI insights cards (status, advisor)
|
||||
│ ├── gamification/ # Achievement cards, progress
|
||||
│ ├── settings/ # Per-route Settings section components (one per /settings/[section])
|
||||
│ ├── admin/ # Per-route Admin section components (system-status, integrations, monitoring, reminders, users, audit, danger-zone, feedback)
|
||||
│ └── monitoring/ # Umami, GlitchTip bootstrap
|
||||
├── lib/
|
||||
│ ├── db.ts # Prisma client singleton
|
||||
@@ -108,6 +125,13 @@ src/
|
||||
│ ├── notifications/ # Dispatcher, types, senders (telegram, ntfy, web-push)
|
||||
│ ├── jobs/ # pg-boss worker (reminders, insights, backups)
|
||||
│ ├── validations/ # Zod schemas (shared between API + client)
|
||||
│ ├── ai/ # Multi-provider client (OpenAI, Anthropic, local OpenAI-compat)
|
||||
│ ├── feedback/ # In-app feedback + optional GitHub escalation
|
||||
│ ├── gamification/ # Achievement definitions + progress
|
||||
│ ├── insights/ # Insight pipeline + prompts
|
||||
│ ├── logging/ # Wide Events: builder, context, sampler, transports, background
|
||||
│ ├── moodlog/ # moodLog.app webhook + sync
|
||||
│ ├── monitoring/ # Umami / GlitchTip server-side hooks
|
||||
│ └── withings/ # Withings OAuth client + sync service
|
||||
├── hooks/
|
||||
│ └── use-auth.ts # useAuth(), useLogout() hooks
|
||||
@@ -116,8 +140,8 @@ messages/
|
||||
├── de.json # German translations (primary UI language)
|
||||
└── en.json # English translations
|
||||
prisma/
|
||||
├── schema.prisma # Database schema (22 models)
|
||||
└── migrations/ # Migration files (0001–0018)
|
||||
├── schema.prisma # Database schema (26 models)
|
||||
└── migrations/ # Migration files (0001–0025; latest: refresh_tokens + user_locale_drift_fix)
|
||||
prisma.config.ts # Prisma config (DB URL lives here, NOT in schema)
|
||||
public/
|
||||
├── sw.js # Service worker (Web Push + offline caching)
|
||||
@@ -128,10 +152,10 @@ docs.healthlog.dev # External documentation site
|
||||
|
||||
## Key Conventions
|
||||
|
||||
- **UI language is German**. All user-facing text in German. Code, comments, docs in English.
|
||||
- **Default locale is English**. All code-level strings in English. UI text via i18n (`t()` calls) with German + English translations.
|
||||
- **i18n**: Use `useTranslations()` hook → `t("section.key")`. Supports parameter interpolation: `t("key", { count: 5 })`. Messages in `messages/de.json` + `messages/en.json`.
|
||||
- **API response format**: Always `{ data, error, meta }` via `apiSuccess(data)` / `apiError(message, status)` from `src/lib/api-response.ts`.
|
||||
- **Auth check pattern**: `const session = await getSession(); if (!session) return apiError("Nicht angemeldet", 401);`
|
||||
- **Auth check pattern**: `const session = await getSession(); if (!session) return apiError("Not authenticated", 401);`
|
||||
- **Timezone**: `Europe/Berlin` for display, UTC in database.
|
||||
- **Encryption**: Sensitive data (Withings tokens, API keys, VAPID private keys) encrypted with AES-256-GCM via `src/lib/crypto.ts`.
|
||||
- **Dracula colors**: Use CSS variables `var(--dracula-purple)`, `var(--dracula-cyan)`, `var(--dracula-green)`, `var(--dracula-orange)`, `var(--dracula-pink)`, `var(--dracula-red)`, `var(--dracula-yellow)`, `var(--dracula-fg)`, `var(--dracula-comment)` for chart/graph elements.
|
||||
@@ -164,17 +188,17 @@ These are hard-won lessons. Ignoring them will cause errors:
|
||||
- **Zod v4**: Import from `"zod/v4"`, not `"zod"`.
|
||||
- **jsPDF**: Client-side only. Import dynamically in browser context. Used with `jspdf-autotable` plugin.
|
||||
|
||||
### Settings Page
|
||||
### Settings & Admin (per-route layout, v1.4)
|
||||
|
||||
- One large file (~2100 lines), 13 sections. Sidebar switches to "settings mode" showing section shortcuts.
|
||||
- Sections scroll-to with highlight animation (`section-highlight` CSS class).
|
||||
- Section IDs: `profil`, `sprache`, `sicherheit`, `telegram`, `ntfy`, `web-push`, `insights`, `withings`, `api`, `export`, `protokoll`, `daten`.
|
||||
- **Settings**: 8 routes under `/settings/[section]` — `account`, `integrations`, `notifications`, `dashboard`, `ai`, `api`, `advanced`, `about`. The legacy `/settings` 308-redirects to `/settings/account`. Sidebar deep-links and the `<a href="/settings#anchor">` patterns from 1.3 still resolve via the redirect.
|
||||
- **Admin**: `src/app/admin/page.tsx` is now a 77-LOC shell that mounts the per-section components in `src/components/admin/`. Status-card grid lives in `status-card-grid.tsx`. The aggregator endpoint `/api/admin/status-overview` returns the six-card summaries in one batched query.
|
||||
- ESLint `react-hooks/set-state-in-effect` is **strict** now (was non-blocking when the settings monolith carried inline-effect state-setters). Use lazy `useState(() => …)` for localStorage reads, TanStack Query for data fetches.
|
||||
|
||||
### Insights Page
|
||||
|
||||
- Large file (~1800+ lines), 7 content sections with AI summaries.
|
||||
- Large file (~1750 lines), 7 content sections with AI summaries.
|
||||
- Sticky horizontal chip navigation (`InsightsSectionNav` component) with IntersectionObserver for active state tracking.
|
||||
- Section IDs: `general`, `bp`, `weight`, `pulse`, `mood`, `medications`, `bmi`.
|
||||
- Section IDs: `general`, `bp`, `weight`, `pulse`, `mood`, `meds`, `bmi`.
|
||||
- Each section has `scroll-mt-28` for header offset on scroll.
|
||||
|
||||
### Sidebar
|
||||
@@ -191,8 +215,8 @@ These are hard-won lessons. Ignoring them will cause errors:
|
||||
|
||||
### Notification System
|
||||
|
||||
- **Channels**: `TELEGRAM`, `NTFY`, `WEB_PUSH` (DB: `NotificationChannel`).
|
||||
- **Event types**: `MEDICATION_REMINDER`, `MEASUREMENT_ANOMALY`, `COMPLIANCE_LOW`, `WITHINGS_SYNC_FAILED`, `SYSTEM_ALERT`.
|
||||
- **Channels**: `telegram`, `ntfy`, `web_push` — stored as plain `String` on `NotificationChannel.type` (no Prisma enum; the canonical list lives in `src/lib/notifications/types.ts`).
|
||||
- **Event types**: `medication_reminder`, `measurement_anomaly`, `compliance_low`, `withings_sync_failed`, `system_alert` — also `String` on `NotificationPreference.eventType`. Source of truth is the discriminated union in `src/lib/notifications/types.ts`.
|
||||
- **Opt-out model**: Preferences default to ON (enabled) when no `NotificationPreference` row exists.
|
||||
- **Dispatcher**: `src/lib/notifications/dispatcher.ts` checks channel enabled + preference per event type.
|
||||
- **Telegram**: Inline buttons (Take/Skip/Snooze 1h/Snooze 3h), pre-end window reminders (30 min before), message auto-cleanup after 24h.
|
||||
@@ -206,7 +230,7 @@ These are hard-won lessons. Ignoring them will cause errors:
|
||||
|
||||
## Database Models (Prisma)
|
||||
|
||||
22 models: `User`, `Passkey`, `Session`, `AuthChallenge`, `Measurement`, `Medication`, `MedicationSchedule`, `MedicationIntakeEvent`, `ReminderPhaseConfig`, `TelegramReminderMessage`, `TelegramScheduledDeletion`, `ApiToken`, `WithingsConnection`, `MoodEntry`, `AppSettings`, `AuditLog`, `NotificationChannel`, `NotificationPreference`, `PushSubscription`, `DataBackup`, `UserAchievement`, `RateLimit`.
|
||||
26 models: `User`, `Passkey`, `Session`, `AuthChallenge`, `Measurement`, `Medication`, `MedicationSchedule`, `MedicationIntakeEvent`, `ReminderPhaseConfig`, `TelegramReminderMessage`, `TelegramScheduledDeletion`, `ApiToken`, `RefreshToken`, `WithingsConnection`, `MoodEntry`, `AppSettings`, `Feedback`, `AuditLog`, `NotificationChannel`, `NotificationPreference`, `PushSubscription`, `DataBackup`, `UserAchievement`, `RateLimit`, `Device`, `IdempotencyKey`. (`RefreshToken` added in v1.4.0 alongside the native-client 24h access-token / refresh-token rotation flow.)
|
||||
|
||||
## When Making Changes
|
||||
|
||||
|
||||
+769
@@ -0,0 +1,769 @@
|
||||
# Changelog
|
||||
|
||||
## [1.4.2] — 2026-05-08
|
||||
|
||||
### Fixed — Production deploy hotfixes
|
||||
|
||||
- **Dashboard and Insights pages no longer crash for users without
|
||||
weight data.** `data?.summaries.WEIGHT` only protected the outer
|
||||
object — the optional chain stopped one level too early, so
|
||||
brand-new users (where `summaries` is undefined) hit
|
||||
`TypeError: undefined is not an object (evaluating 'E?.summaries.WEIGHT')`
|
||||
on first load. Now `data?.summaries?.WEIGHT`.
|
||||
- **Container healthcheck uses `127.0.0.1` instead of `localhost`.**
|
||||
busybox-`wget` in Alpine resolves `localhost` to IPv6 `::1` first,
|
||||
but Next.js standalone listens on IPv4 `0.0.0.0:3000` only — so the
|
||||
healthcheck always returned ECONNREFUSED, Docker marked the
|
||||
container unhealthy, and Traefik returned 503 from the public URL
|
||||
even though the app was actually running. The Dockerfile-level
|
||||
`HEALTHCHECK` already used 127.0.0.1; the `docker-compose.yml`
|
||||
override was the one that drifted to `localhost`. Fixed.
|
||||
|
||||
### Notes
|
||||
|
||||
- The 1.4.1 GHCR image never published cleanly (the docker-publish
|
||||
workflow reported success in GH Actions but the
|
||||
`ghcr.io/mbombeck/healthlog:1.4.1` tag returned `manifest unknown`
|
||||
when Coolify tried to pull). The 1.4.2 release supersedes 1.4.1
|
||||
and includes everything 1.4.1 was supposed to ship — the v1.4.1
|
||||
source on `main` was always healthy; only the Coolify deploy
|
||||
surface was broken.
|
||||
|
||||
## [1.4.1] — 2026-05-08
|
||||
|
||||
### Security
|
||||
|
||||
- **moodLog integration no longer accepts internal-network URLs.** A
|
||||
user could previously save `http://169.254.169.254/` (cloud-metadata)
|
||||
or any RFC1918 address as their moodLog instance; the daily sync
|
||||
worker would then fetch from that target with the user's API key in
|
||||
the Authorization header. The credentials write path now refuses
|
||||
non-public hosts, the sync worker re-checks the URL at the actual
|
||||
fetch site (so legacy rows stored before the guard are also
|
||||
refused), and the fetch is now `redirect: "manual"` so a public
|
||||
host cannot 302 to an internal target with the bearer on the
|
||||
redirect hop.
|
||||
- **Error reports never echo bearer tokens, Telegram bot tokens, or
|
||||
query-string secrets.** `WideEventBuilder.setError()` and the
|
||||
Glitchtip incident path now run every error message and stack
|
||||
trace through a central `redactSecrets()` filter that scrubs
|
||||
`Bearer …`, Telegram `bot<digits>:<token>` URLs, and `?secret=`,
|
||||
`?code=`, `?token=`, `?api_key=` query strings. The substitution
|
||||
is generic `[REDACTED]` so partial entropy is never revealed.
|
||||
|
||||
### Fixed — Citation accuracy
|
||||
|
||||
- **Blood-pressure classification now cites ESH 2023.** The dashboard
|
||||
tile, the doctor-report PDF, and the inline analytics comments
|
||||
used to label the band as "ESC/ESH 2018". The numbers haven't
|
||||
changed (the 2023 ESH update kept the 2018 thresholds), but the
|
||||
joint authoring did — ESC withdrew from the 2023 document, so the
|
||||
correct citation is "ESH 2023" alone.
|
||||
- **Steps target source label is `Saint-Maurice JAMA 2020`** instead
|
||||
of `WHO`. Every other surface in the app (AI prompts, inline
|
||||
comments, drift tests) already enforced this attribution; the
|
||||
insights/targets surface was the last "WHO" label in the tree.
|
||||
WHO publishes physical-activity _time_, not a step quota.
|
||||
- **Saint-Maurice "mortality plateau 8000–12000" attribution
|
||||
softened.** The original JAMA 2020 paper reports continued
|
||||
dose-response benefit (HR 0.49 at 8k, HR 0.35 at 12k) — not a
|
||||
plateau. The plateau-shaped finding belongs to Paluch 2022
|
||||
_Lancet Public Health_ (PMID 35247352), not Saint-Maurice. The
|
||||
inline comments and AI prompts now say "continued dose-response
|
||||
benefit through ~12,000 steps/day" instead.
|
||||
|
||||
### Added — CI safety nets
|
||||
|
||||
- **Postgres-backed integration test suite is now executable.** The
|
||||
testcontainers infrastructure shipped in 1.4.0; this release wires
|
||||
the per-test boilerplate through vitest's `globalSetup` so all
|
||||
four files share one container. `pnpm test:integration` runs ten
|
||||
tests (rate-limit race, idempotency replay-attack contract, GDPR
|
||||
Article-17 cascade delete, session create / read / expire) against
|
||||
a real Postgres in under four seconds. CI runs the suite on every
|
||||
PR.
|
||||
- **Playwright + axe-core E2E foundation.** A new `pnpm e2e` runs
|
||||
five public-surface specs (version endpoint, proxy auth-redirect,
|
||||
login form autofill hints, DE/EN locale switch, axe-core
|
||||
accessibility gate) against the production build in CI. Authenticated
|
||||
flow specs (quick-entry, doctor-report, settings round-trip,
|
||||
test-buttons, onboarding) ride a follow-up release because they
|
||||
need a seeded test user; the foundation makes adding them a
|
||||
one-PR step.
|
||||
|
||||
### Changed — Admin internals
|
||||
|
||||
- **Admin page is now per-section components.** The status-card grid
|
||||
shipped in 1.4.0 sat on top of a 2,700-line monolith; that monolith
|
||||
is now 14 focused files in `src/components/admin/` with a 77-line
|
||||
`src/app/admin/page.tsx` shell that mounts them. Every section
|
||||
keeps the same DOM, ids, query keys, and i18n keys — no
|
||||
user-visible change.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Final ESLint error is gone.** The medications page's "API
|
||||
endpoint" dialog ran its initial-load fetch through a `useCallback`
|
||||
paired with `useEffect` and triggered the strict
|
||||
`react-hooks/set-state-in-effect` rule. Refactored to TanStack
|
||||
Query — same network calls, no effect, lint count is now zero on
|
||||
`main`.
|
||||
|
||||
### Documentation
|
||||
|
||||
- **Repo-internal docs synced for v1.4.** README adds the
|
||||
Multi-tenant ready and Test connection buttons feature blocks, the
|
||||
API reference table includes the eleven new v1.4 endpoints, and
|
||||
the model count is corrected to 26 (RefreshToken). AGENTS.md and
|
||||
CLAUDE.md reflect the per-route `/settings/[section]` layout and
|
||||
the per-section admin layout. `docs/api/openapi.yaml` documents
|
||||
the new endpoints (version, refresh, refresh/revoke,
|
||||
status-overview, backup/test, the five test-connection probes).
|
||||
`docs/migration/v1.3-to-v1.4.md` corrects the now-wrong "no
|
||||
migrations" claim and adds full env-var sections for the
|
||||
worker/web split, encryption-key versioning, and off-host backup
|
||||
target.
|
||||
|
||||
### Notes
|
||||
|
||||
- No database migration in 1.4.1.
|
||||
- No environment-variable change required to upgrade.
|
||||
- No API contract change — every route added in 1.4.0 is still
|
||||
there; no shapes or status codes flipped.
|
||||
- The audit pass that drove this release identified five medium
|
||||
security items and three P0 performance items that warrant
|
||||
deeper architectural work; those are tracked in
|
||||
`docs/ops/v141-followup-issues.md` and ride a future release.
|
||||
|
||||
## [1.4.0] — 2026-05-08
|
||||
|
||||
### Added — Foundation, safer ranges, and a faster dashboard
|
||||
|
||||
- **UI guidelines, design tokens, and shared primitives.** A new
|
||||
`docs/ui-guidelines.md` is the single source of truth for spacing,
|
||||
typography, button hierarchy, dialog-vs-sheet decisions, accessibility
|
||||
baseline (WCAG 2.1 AA), and the autofill / honeypot pattern for
|
||||
health-data forms. Two new shadcn primitives — `<Skeleton>` and
|
||||
`<EmptyState>` — replace the previous mix of spinners and "No data"
|
||||
placeholder strings. Future v1.4.x components reference the doc; the
|
||||
primitives ship with screen-reader-aware semantics and respect
|
||||
`prefers-reduced-motion`.
|
||||
- **`/api/version` public endpoint** exposing the build's version,
|
||||
optional Git SHA / build timestamp, license, and canonical links.
|
||||
Wires the future Settings → About surface and a thin "Check for
|
||||
updates" UX. Static-cached so the route adds zero DB load.
|
||||
- **`src/lib/medical-citations.ts`** — single source of truth for
|
||||
cited medical guidelines (id, name, year, URL, caveat). Future
|
||||
medical surfaces import these constants instead of duplicating
|
||||
strings in code, prompts, and `messages/*.json`. A new drift-test
|
||||
asserts every entry has a non-empty URL + caveat and that the
|
||||
recurring "WHO ≥ N steps" hallucination cannot reappear as a constant.
|
||||
|
||||
### Fixed — Patient safety and citation accuracy
|
||||
|
||||
- **Diastolic blood-pressure orange band no longer reaches 60 mmHg.**
|
||||
With the default age-based targets (DBP 70–79), the lower orange
|
||||
wing was computed as `diaLow − 10 = 60`. A reading of 60 mmHg landed
|
||||
in "mildly low" yellow instead of red even though that level is the
|
||||
general-adult hypotension threshold and the J-curve risk floor in
|
||||
ESH 2023 for treated hypertensives. Orange floor is now clamped at
|
||||
65 mmHg, so 60 mmHg lands in red. The user-override path stays
|
||||
intact and remains audit-logged.
|
||||
- **BP guideline citations consolidated on ESH 2023.** The codebase
|
||||
had a mix of "ESC/ESH 2018" (analytics) and "ESC/ESH 2023" (AI
|
||||
prompts). The 2023 hypertension document is ESH-only — ESC withdrew
|
||||
from the joint authoring — so neither label was correct. Every site
|
||||
now cites "ESH 2023" with the published source URL. Numbers
|
||||
unchanged.
|
||||
- **"WHO ≥ 8 000 steps/day" hallucination fully removed.** WHO
|
||||
publishes activity _time_ (150–300 min/wk moderate), not a step
|
||||
quota. The v1.3.3 fix only landed in `effective-range.ts`; four AI
|
||||
prompt strings and the `getStepsRange()` helper carried the old
|
||||
wording forward. Saint-Maurice et al., JAMA 2020 (mortality plateau
|
||||
8 000–12 000) is now cited everywhere and the two surfaces agree on
|
||||
the band. Sleep target moves from "ESC" (no adult sleep guideline)
|
||||
to AASM 2015.
|
||||
- **Body-fat ACE bands corrected and three-way drift resolved.** The
|
||||
classifier used `essential = 6 (M) / 14 (F)` as the floor — but
|
||||
that's actually ACE's _Athletes_ lower bound. Readings below were
|
||||
mislabelled "Essential" instead of "Below essential" (a danger
|
||||
band). Six-band classifier now mirrors the ACE table, and the three
|
||||
sites that had three different green-band numbers
|
||||
(`value-bands.ts`, `targets/route.ts`, `classifications.ts`) all
|
||||
derive from `getBodyFatTargetRange` (ACE fitness + acceptable bands).
|
||||
- **Bedtime-glucose citation softened.** ADA Standards 2024 §6
|
||||
publishes pre-prandial 80–130 and post-prandial <180 — no published
|
||||
adult bedtime target. The 90–150 mg/dL band stays (reasonable adult
|
||||
overnight band) but the inline citation now states the absence
|
||||
explicitly and references ISPAD 2022 (pediatric) as the closest
|
||||
comparator.
|
||||
|
||||
### Fixed — Localisation reaches the notification path
|
||||
|
||||
- **Medication reminders now follow the user's locale.** Telegram,
|
||||
ntfy, and Web Push reminders previously read "Erinnerung", "Bald
|
||||
fällig", etc. regardless of the user's stored language. Templates
|
||||
for every phase (`green`/`yellow`/`orange`/`red`) and every keyboard
|
||||
button now resolve from `messages/{de,en}.json` per
|
||||
`med.user.locale`. Telegram callback IDs stay stable English
|
||||
identifiers so the dispatcher keeps matching across locale changes.
|
||||
- **Dashboard greeting and streak label** are localised server-side.
|
||||
Previously hard-coded `"Hi, ${name}"` and `"Tage in Folge"` — both
|
||||
now i18n-key-resolved.
|
||||
- **Mixed-locale Zod validation messages unified to English.** Two
|
||||
measurement-form messages and four admin-validation messages
|
||||
flipped between German and English depending on which schema fired.
|
||||
All consolidated on English (the app is English-first; the German
|
||||
UI maps field labels client-side).
|
||||
|
||||
### Fixed — Chart math edge cases
|
||||
|
||||
- **`summarize` and `trendSlope` use the same time anchor.** Averages
|
||||
snapped to `Date.now()`; slopes snapped to the latest point in the
|
||||
series. A stale series reported a trend even though the dashboard
|
||||
tile correctly hid the average. Both now anchor on `Date.now()`, so
|
||||
a stale series returns `null` consistently from every windowed stat.
|
||||
- **`summarize([])` returns `null` for `min`/`max`/`mean`** instead
|
||||
of zeros that leaked into chart axes and AI feature bundles as
|
||||
fake readings.
|
||||
- **`weeklyAverages` is Berlin-timezone aware.** A Sunday-evening
|
||||
Berlin reading bucketed into the next week on the UTC production
|
||||
container because `Date.getDay()` was system-local. ISO-Monday key
|
||||
now resolves via `Intl.DateTimeFormat({ timeZone: "Europe/Berlin" })`.
|
||||
- **`pairByTimestamp` JSDoc** documents the greedy nearest-match
|
||||
heuristic and when a Hungarian-style match would matter (sparse
|
||||
health data is well below that bar).
|
||||
|
||||
### Fixed — Hidden friction
|
||||
|
||||
- **AI provider connection-test honours the unsaved selection.**
|
||||
Changing the AI provider in `/settings`, then clicking "Verbindung
|
||||
testen" without saving first, used to silently run the test against
|
||||
the stored provider — surfacing as a confusing OK / failure unrelated
|
||||
to what the user had on screen. Plaintext keys never persist; the
|
||||
existing SSRF guard, rate limit, and V3 error-leak shielding stay in
|
||||
place.
|
||||
- **Health-data inputs no longer autofill the user's account
|
||||
password.** The base `<Input>` primitive defaults to
|
||||
`autoComplete="off"` plus the LastPass / 1Password ignore attributes
|
||||
whenever the caller doesn't pass a semantic value. Auth and profile
|
||||
forms continue to autofill normally because they pass an explicit
|
||||
`autoComplete` (`"username"`, `"email"`, `"current-password"`,
|
||||
`"new-password"`).
|
||||
- **Step-range target aligned across two callsites.**
|
||||
`getStepsRange()` returned `{7000, 10000}` while
|
||||
`effective-range.ts` returned `{8000, 15000}`; two surfaces showed
|
||||
different "green" bands to the same user. Both now use
|
||||
`{8000, 15000}`, anchored on Saint-Maurice 2020.
|
||||
|
||||
### Performance
|
||||
|
||||
- **Two more N+1 queries closed.** `extractFeatures` (used by every
|
||||
AI-insight route) issued one `prisma.medicationIntakeEvent.findMany`
|
||||
per active medication; replaced with a single batched query and an
|
||||
in-memory group. `/api/insights/targets` issued one `findFirst` per
|
||||
measurement type; replaced with a single `distinct: ["type"]` query.
|
||||
Same shape as the v1.3.0 fix to `/api/insights/comprehensive`.
|
||||
|
||||
### Changed — Dashboard
|
||||
|
||||
- **Tile strip is always one row.** Replaces the wrapping
|
||||
`grid-cols-2 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5` layout
|
||||
with a `flex snap-x snap-mandatory overflow-x-auto` strip. When the
|
||||
user enables more tiles than fit the viewport, the strip
|
||||
horizontal-scrolls instead of wrapping; the user trims the set in
|
||||
Settings → Dashboard Layout.
|
||||
|
||||
### Added — Settings, integrations, and operations
|
||||
|
||||
- **Settings page now lives at `/settings/[section]`.** Eight focused
|
||||
routes (`account`, `integrations`, `notifications`, `dashboard`,
|
||||
`ai`, `api`, `advanced`, `about`) replace the single 3,000-line page.
|
||||
Existing `/settings#anchor` links 308-redirect; the side-bar, in-app
|
||||
deep links, and the AI / Withings / Codex callbacks all follow the
|
||||
new structure.
|
||||
- **About page** lists the running version, build SHA, license,
|
||||
repository link, CHANGELOG link, docs link, and a "Check for
|
||||
updates" button that pings the public GitHub releases API. Backed by
|
||||
the `/api/version` endpoint shipped earlier in 1.4.0.
|
||||
- **Admin console** is built around a status-first card grid (Users,
|
||||
Integrations, Monitoring, Backups, Maintenance, Audit Log) with each
|
||||
area in a focused panel beneath. Per-section extraction of the old
|
||||
inline panels is tracked for v1.4.1 — the v1.4.0 admin page already
|
||||
routes through the new aggregator endpoint and the status-card
|
||||
grid.
|
||||
- **Five new "Test connection" buttons in Settings.** Withings,
|
||||
moodLog, Web Push, Glitchtip, and Umami now ship with one-click
|
||||
connection probes — same pattern as the existing AI / Telegram /
|
||||
ntfy tests, with per-button rate limit, sanitised error reporting,
|
||||
redirect-follow SSRF guard, and an `errorCode` in the response
|
||||
envelope so the UI can localise the message.
|
||||
- **AI insights can reference any of your charts inline.** When a
|
||||
finding centres on a single metric (e.g. systolic blood pressure),
|
||||
the corresponding chart renders directly under the explanation.
|
||||
Server-side allow-list — only the allowed metric tokens render; any
|
||||
other model emission drops silently.
|
||||
- **Off-host backup target.** Daily encrypted JSON dumps to any
|
||||
S3-compatible bucket. Worker-side IAM grant is intentionally
|
||||
PutObject + GetObject only — retention is the bucket's
|
||||
lifecycle-rule job, so a compromised worker cannot wipe the backup
|
||||
history. Restore script + step-by-step doc shipped under
|
||||
`docs/ops/backup-restore.md`, and an admin "Backup target" test
|
||||
button validates the configuration.
|
||||
- **Encryption-key versioning.** Rotate the at-rest encryption key
|
||||
without downtime via `pnpm tsx scripts/rotate-encryption-key.ts <id>`.
|
||||
Existing data keeps decrypting under its original key while the new
|
||||
one is rolled out. Walk-through + rollback notes in
|
||||
`docs/ops/encryption-key-rotation.md`.
|
||||
- **Worker / web split.** Optional
|
||||
`HEALTHLOG_PROCESS_TYPE=web|worker|all` (default `all` for the
|
||||
single-container setup) lets you scale background jobs and HTTP
|
||||
traffic independently. The proxy refuses HTTP traffic with a 503 +
|
||||
`X-HealthLog-Process-Type: worker` header in worker mode so a
|
||||
misrouted request fails loudly instead of a silent half-served
|
||||
response.
|
||||
- **Native API clients now get short-lived 24-hour access tokens with
|
||||
refresh-token rotation.** The browser keeps the existing 90-day
|
||||
Bearer. Reuse-detection (presenting a refresh token a second time)
|
||||
revokes every refresh token for the user — the small cost of a
|
||||
forced re-login on the legitimate device buys defense-in-depth
|
||||
against an undetected stolen-token replay.
|
||||
- **Critical-path coverage on Telegram / Withings / moodLog /
|
||||
Glitchtip webhook handlers + the four admin routes lifted to ≥80%
|
||||
line coverage,** plus `src/lib/auth/audit.ts`. ~+100 new tests.
|
||||
|
||||
### Fixed — Operational hardening from the v1.4 review pass
|
||||
|
||||
- **Container time zone is correct.** Alpine images ship without
|
||||
`tzdata`; the daily backup cron `30 2 * * *` Europe/Berlin was
|
||||
silently falling back to UTC. The runner stage now installs
|
||||
`tzdata` and exports `TZ=Europe/Berlin` so schedules fire at the
|
||||
documented local time.
|
||||
- **Compose healthcheck uses `wget --spider /api/version`** — `/api/version`
|
||||
is now in the proxy's public-paths allowlist, so the healthcheck no
|
||||
longer 302-redirects through the auth gate (which was accepting the
|
||||
login page as a 200 success).
|
||||
- **Idempotency replay-cache no longer caches refresh tokens.** The
|
||||
guard already blocked the `hlk_` access-token prefix; the new
|
||||
`hlr_` refresh tokens are blocked too.
|
||||
- **Logout-on-device revokes the paired access token.** Calling
|
||||
`/api/auth/refresh` with `revoke: true` now flips both the refresh
|
||||
row and the matching `ApiToken` row to revoked, so a leaked access
|
||||
token cannot outlive its refresh-token sibling.
|
||||
- **`users.locale` migration drift backfilled.** The column had been
|
||||
on `schema.prisma` since the v1.3 locale-aware reminder work but
|
||||
never landed in the migration history (it must have been applied
|
||||
via `prisma db push` to dev/prod). Any environment built strictly
|
||||
from `prisma/migrations/` (CI testcontainers, brand-new self-host
|
||||
installs) is now consistent. Migration is `ADD COLUMN IF NOT
|
||||
EXISTS`, so it's a clean add on a fresh database and a safe no-op
|
||||
against any environment that was already kept in sync.
|
||||
|
||||
### Notes
|
||||
|
||||
- Largely additive release. Existing API contracts (response
|
||||
envelopes, OpenAPI 3.1 spec) are unchanged. New endpoints surface
|
||||
optional fields; no breaking changes.
|
||||
- New migration `0025_refresh_tokens` adds the rotating refresh-token
|
||||
table; new migration `0025_user_locale_drift_fix` backfills the
|
||||
schema-vs-migrations drift on `users.locale`. Both are
|
||||
forward-compatible — `IF NOT EXISTS` guards make them idempotent on
|
||||
any environment already pushed-to.
|
||||
- Operators of the off-host backup feature must configure a bucket
|
||||
lifecycle policy for retention. The worker has no DeleteObject
|
||||
grant by design.
|
||||
- Native API clients (iOS, n8n, Health Connect) need to update their
|
||||
login flow: native logins now return both a 24-hour access token
|
||||
and a refresh token. The browser flow is unchanged.
|
||||
- **Tracked for v1.4.1:** per-section admin panel extraction (the
|
||||
status-card grid + aggregator already ship in 1.4.0; the inner
|
||||
per-section file split is structural cleanup), the Postgres-backed
|
||||
integration test suite (testcontainers infrastructure ships in this
|
||||
release; the four integration tests themselves need a follow-up
|
||||
pass against the merged schema), and Playwright E2E + axe-core CI
|
||||
gates.
|
||||
|
||||
## [1.3.3] — 2026-05-08
|
||||
|
||||
### Added
|
||||
|
||||
- **Pulse oximetry as a first-class measurement type (`OXYGEN_SATURATION`).**
|
||||
Closes the SpO2 part of #109. Migration `0024_oxygen_saturation` extends
|
||||
the `MeasurementType` enum. Plausibility range 50–100% (below 50% is
|
||||
incompatible with sustained life and almost certainly a faulty sensor;
|
||||
upper bound 100% is physical). Default severity bands follow BTS Guideline
|
||||
2017 + ATS clinical practice: green 95–100%, orange 92–94%, red <92% —
|
||||
lower-only concern (the upper orange wing collapses onto greenMax since
|
||||
saturation cannot physically exceed 100%). COPD / chronic-respiratory
|
||||
users with a doctor-set baseline of 88–92% can personalize via the
|
||||
threshold-override UI. Wired through Withings (ScanWatch type 54),
|
||||
measurement form, list, charts, doctor PDF, OpenAPI spec, and i18n (DE +
|
||||
EN). iOS DTO already declared `OXYGEN_SATURATION` from a prior commit;
|
||||
the server enum addition closes the long-standing drift.
|
||||
- **Body composition surfaces (TOTAL_BODY_WATER, BONE_MASS, BLOOD_GLUCOSE)
|
||||
in the measurements list filter, badge, mobile icon, edit dialog, and
|
||||
server-rendered doctor-report PDF** — closes the UI side of #109. Root
|
||||
cause was three local maps in `measurement-list.tsx` that drifted from
|
||||
the v1.3 server enum; extracted to `measurement-list-meta.ts` with
|
||||
fail-fast coverage tests so future enum additions are caught at build
|
||||
time. Server-side PDF used a separately-drifted type map vs. the
|
||||
browser-side renderer; both are now in sync.
|
||||
- **Effective-range thresholds for `TOTAL_BODY_WATER` and `BONE_MASS`** —
|
||||
severity logic was returning `nominal` for any value because no defaults
|
||||
existed.
|
||||
|
||||
### Changed
|
||||
|
||||
- **OpenAPI `MeasurementType` enum extended + spec version bumped 1.3.0 →
|
||||
1.3.3** to match the actual app. Spec was lagging by two minor releases.
|
||||
- **Withings webhook secret now reads from `X-Withings-Webhook-Secret`
|
||||
header** in preference to the legacy `?secret=…` URL query parameter.
|
||||
Closes the URL-leak-via-access-logs vector flagged in audit C-3. Legacy
|
||||
query-param path is retained for backwards compatibility and emits a
|
||||
Wide Event warning so operators can spot still-using-the-old-flow
|
||||
integrators. Plan: remove the query fallback in 1.4.x once warnings drain.
|
||||
- **Idempotency `defaultUserIdResolver` now supports Bearer tokens.**
|
||||
Cookie sessions tried first, then Bearer-token via `hashToken` lookup.
|
||||
Without the Bearer fallback, every iOS / external-ingest retry was
|
||||
hitting the handler again and creating duplicate measurements (audit
|
||||
C-4 — the exact use case `withIdempotency` was built for).
|
||||
- **GlitchTip URL stripping** — `reportToGlitchtip` now strips the URL
|
||||
query string before forwarding so Withings legacy `?secret=…` and OAuth
|
||||
`?code=…` callbacks cannot leak via the error tracker (audit H-B7).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Migration `0022_body_composition_metrics` unit comment lied** —
|
||||
claimed `TOTAL_BODY_WATER: percent of body weight (%)` while every other
|
||||
surface (validators, Withings client, doctor PDF) treated it as `kg`.
|
||||
Comment corrected to match reality.
|
||||
|
||||
### Security
|
||||
|
||||
- **Bearer-scope wildcard handling (CRITICAL — V3-1).** `requireAuth()`
|
||||
previously accepted any non-admin token regardless of declared
|
||||
permission scope, so a token with `permissions:["medication:ingest"]`
|
||||
could DELETE the user account. Spec now requires `permissions:["*"]`
|
||||
or the explicit required permission.
|
||||
- **Account-deletion completeness (CRITICAL — V3-2 / GDPR Art. 17).**
|
||||
Cascades through `Feedback` + `AuditLog` rows so user-erasure is
|
||||
actually total. Daily retention job sweeps orphaned audit rows after
|
||||
90 days as a defence-in-depth.
|
||||
- **Withings webhook secret header migration (audit C-3)**, idempotency
|
||||
Bearer-resolver (audit C-4), GlitchTip URL strip (audit H-B7).
|
||||
- **Truthfulness pass on medical citations** — SpO2 normal-range source
|
||||
is now consumer-pulse-oximeter consensus + NICE NG115 + FDA labelling
|
||||
(BTS-2017 was for clinical hypoxaemia thresholds, not consumer
|
||||
monitoring); body-composition metrics are explicitly labelled
|
||||
"bioimpedance-estimated, not DEXA-comparable" in the doctor PDF;
|
||||
TBW citation now references the Watson formula / ICRP Reference Man
|
||||
(was misattributed to ESPEN 2017); steps target now references
|
||||
Saint-Maurice JAMA 2020 (WHO publishes minutes/week, not steps).
|
||||
- **SpO2 user-override clamp** — overrides could emit physical
|
||||
impossibilities (e.g. `orangeMax = 100.75`); clamped to METRIC_BOUNDS
|
||||
for SpO2 + BODY_FAT.
|
||||
- **moodLog webhook secret encrypted at rest with AES-256-GCM** (V3
|
||||
STILL-V2-C-2). Read path tolerates legacy plaintext rows during the
|
||||
transition window; one-shot startup migration in the worker rotates
|
||||
any leftover plaintext rows.
|
||||
- **CSP tightening** — `chatgpt.com` + `api.openai.com` `connect-src`
|
||||
now gated to `/settings/ai/**` (was a global blanket on every page,
|
||||
including `/auth/login` → DOM-XSS exfil channel).
|
||||
- **Web-Push subscription endpoint SSRF guard** — `endpoint` now
|
||||
requires HTTPS + passes `isPublicUrl()` (was `z.url()` only).
|
||||
Side-fix: `isPublicUrl()` no longer falsely classifies DNS labels
|
||||
starting with `fc`/`fd` (e.g. `fcm.googleapis.com`) as IPv6
|
||||
unique-local; the IPv6 check is now gated on a colon being present.
|
||||
- **IP-geolocation lookup is now HTTPS-only.** Default provider is
|
||||
`ipwho.is` (free, HTTPS, no key). Existing `ip-api.com` plaintext
|
||||
HTTP path leaked auth-event IP + timestamp on every login (GDPR Art.
|
||||
32 + Art. 44). Operators can override via `IP_GEO_LOOKUP_URL` (HTTPS
|
||||
only) or disable entirely with `IP_GEO_LOOKUP_DISABLED=1`.
|
||||
- **`/api/ai/test` no longer returns provider error message + body
|
||||
excerpt to the client.** Diagnostics land server-side via Wide Events
|
||||
(annotate); client gets a categorised generic message. Closes provider
|
||||
URL / partial key / internal header leak.
|
||||
- **`/api/import` rate-limit added** — 5 imports/hour/user. Was
|
||||
unlimited (bulk-injection vector).
|
||||
- **Trusted-proxy XFF semantics** — `getClientIp()` now reads
|
||||
`X-Forwarded-For` right-to-left with a configurable
|
||||
`TRUST_PROXY_HOPS` (default 1, matches typical single-proxy
|
||||
self-host). Closes XFF rotation bypass of per-IP rate-limits.
|
||||
- **Audit-log retention job** — `audit_logs` rows older than
|
||||
`AUDIT_LOG_RETENTION_DAYS` (default 365) are purged daily. Closes
|
||||
GDPR Art. 5(1)(e) "storage limitation" gap.
|
||||
- **Idempotency cachable-status filter** is now an exported, unit-tested
|
||||
function — pins the do-not-cache contract for 401/403/408/429/5xx.
|
||||
- **Bearer mock tightening** in `require-auth-bearer.test.ts` +
|
||||
`idempotency.test.ts`: `apiToken.findUnique` calls are now asserted
|
||||
to use `where: { tokenHash: <hashed> }`, so a regression to raw-token
|
||||
comparison would break the suite immediately.
|
||||
|
||||
### Internal
|
||||
|
||||
- **Server-side enum drift cousins closed.** Five module-level
|
||||
hardcoded type-arrays in `/api/insights/comprehensive`,
|
||||
`/api/dashboard/summary`, `/api/analytics`, `/lib/insights/general-status`,
|
||||
`/api/import` are now derived from `measurementTypeEnum.options`.
|
||||
External-contract enums extended additively:
|
||||
`/api/measurements/series` (`oxygen`, `totalBodyWater`, `boneMass`),
|
||||
`/api/dashboard/widgets` (`oxygenSaturation`), `DashboardWidgetId` +
|
||||
`DEFAULT_DASHBOARD_LAYOUT`. New coverage test asserts the canonical
|
||||
enum stays the source of truth.
|
||||
- **Doctor-PDF text-content tests** — replaced bytes-only "renders body
|
||||
composition rows" theatre with `pdf-parse`-driven assertions on the
|
||||
actual rendered DE + EN labels and values. Adds dev dep `pdf-parse`.
|
||||
|
||||
## [1.3.2] — 2026-04-28
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Glucose tiles on the dashboard rendered the raw i18n key
|
||||
`targets.glucoseFasting` instead of the translated label** (closes
|
||||
#108). Both `messages/en.json` and `messages/de.json` had two
|
||||
top-level `targets` blocks; `JSON.parse` silently keeps the last
|
||||
occurrence, and that block was missing the four glucose labels. The
|
||||
duplicate is now collapsed into a single block. A duplicate
|
||||
`bugreport.bugTitlePlaceholder` shadowed inside `bugreport` was
|
||||
cleaned up too. Two further keys (`dashboard.sleep`,
|
||||
`dashboard.steps`) were missing from both locales and were falling
|
||||
back to hard-coded English; both are now translated.
|
||||
- **New i18n locale-integrity test**
|
||||
(`src/lib/__tests__/i18n-locale-integrity.test.ts`) fails the build
|
||||
on duplicate keys at any nesting depth and on key drift between
|
||||
`en` and `de` — closes the structural gap that let the duplicate
|
||||
`targets` block ship in the first place.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Screenshot upload removed from the bug-report form** (also part
|
||||
of #108). The form previously accepted an image attachment that
|
||||
was stored in the local DB but never reached the published GitHub
|
||||
issue — GitHub does not accept inline base64 data URIs in issue
|
||||
bodies and offers no public API to attach images to an issue
|
||||
programmatically. Rather than ship misleading
|
||||
"a screenshot was attached" placeholder text in the resulting
|
||||
issue, the upload UI is now gone and the placeholder note is no
|
||||
longer added when promoting feedback. The `screenshotBase64`
|
||||
column and the admin-side preview of previously-submitted
|
||||
screenshots are unchanged — existing reports keep their
|
||||
attachments locally. We plan to revisit a real screenshot pipeline
|
||||
in a future release.
|
||||
|
||||
## [1.3.1] — 2026-04-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Compose env-var validation no longer breaks Coolify-style deploys.**
|
||||
`docker-compose.yml` previously used `${VAR:?required}` shell-parameter
|
||||
syntax for the four secrets and `POSTGRES_PASSWORD`. Some hosting
|
||||
platforms (Coolify in particular) parse compose files eagerly and
|
||||
store the _fallback error string_ (`"POSTGRES_PASSWORD is required"`)
|
||||
as the literal env-var value when `POSTGRES_PASSWORD` was unset,
|
||||
which then collided with `DATABASE_URL` and broke the running app
|
||||
with `P1000: Authentication failed`. Compose now uses plain `${VAR}`
|
||||
interpolation; validation moved into `docker-entrypoint.sh`, which
|
||||
fails fast with a clear stderr message listing the unset variables.
|
||||
|
||||
### Notes
|
||||
|
||||
If you upgraded an existing Compose stack from 1.2.x → 1.3.0 and hit
|
||||
the `POSTGRES_PASSWORD is required` literal-as-value bug, set
|
||||
`POSTGRES_PASSWORD` in your environment to whatever your existing
|
||||
Postgres data volume was originally initialised with (likely
|
||||
`healthlog` if you started from a pre-1.2.1 release), then redeploy.
|
||||
Postgres only honours `POSTGRES_PASSWORD` on first volume init — the
|
||||
existing user keeps the original password regardless of env changes.
|
||||
|
||||
## [1.3.0] — 2026-04-27
|
||||
|
||||
### Added — Body composition + targeted hardening
|
||||
|
||||
- **Total body water and bone mass as measurement types** (closes #89). New
|
||||
enum values `TOTAL_BODY_WATER` and `BONE_MASS`, both stored canonically
|
||||
in kilograms (matches Withings hydration/bone-mass measures and Health
|
||||
Connect's `TotalBodyWaterRecord` / `BoneMassRecord`). Migration is
|
||||
purely additive (`ALTER TYPE ... ADD VALUE`) — safe to apply against
|
||||
any 1.2.x database without downtime.
|
||||
- **Withings sync picks both up automatically.** The Withings client now
|
||||
maps measure type `77` (hydration / water mass) and `88` (bone mass).
|
||||
Anyone with a Withings Body+ scale and an active connection will see
|
||||
the new metrics flowing in on the next sync without any extra config.
|
||||
- **Doctor-report PDF includes both new types** in the vital-signs table
|
||||
when data exists, with locale-aware labels in English and German.
|
||||
- **Dashboard widgets registered for both** (default-invisible — opt in
|
||||
via Settings → Dashboard layout).
|
||||
|
||||
### Security
|
||||
|
||||
- **SSRF guard hardened** (`isPublicUrl`). The previous implementation
|
||||
used `parseInt` with permissive prefix checks like `h.startsWith("10.")`
|
||||
which let `010.0.0.1` slip through — and worse, the WHATWG URL parser
|
||||
silently normalises `010.0.0.1` to `8.0.0.1` (octal interpretation), a
|
||||
real bypass on naive checks. The new guard adds a pre-URL leading-zero
|
||||
check on the raw input, a strict IPv4 parser, and proper IPv6
|
||||
bracket / loopback / link-local handling. Now blocks `127.0.0.0/8`,
|
||||
`0.0.0.0/8`, `10.0.0.0/8`, `169.254.0.0/16`, `172.16.0.0/12`,
|
||||
`192.168.0.0/16`, `100.64.0.0/10` (CGNAT), `::1`, `fe80::/10`,
|
||||
`fc00::/7`. Comprehensive regression tests included.
|
||||
- **GitHub PAT redacted from logged error bodies.** When a feedback
|
||||
escalation to GitHub fails, the response body was being passed
|
||||
verbatim into `getEvent()?.addWarning(...)` — flowing to Loki. The
|
||||
body is now stripped of the configured token before logging.
|
||||
- **Per-user threshold writes are rate-limited** (30 writes / 5 min) to
|
||||
make audit-log enumeration unattractive. Audit-logging itself was
|
||||
already there.
|
||||
|
||||
### Performance
|
||||
|
||||
- **N+1 in `/api/insights/comprehensive` fixed.** The medication-compliance
|
||||
loop hit Postgres once per active medication. It now batches into a
|
||||
single `medicationId IN (...)` query and groups in memory. Latency
|
||||
improvement scales linearly with the user's active medication count.
|
||||
|
||||
### Reliability
|
||||
|
||||
- **pg-boss draining on SIGTERM/SIGINT.** `docker stop`, Coolify redeploys,
|
||||
and Kubernetes pod terminations now trigger `boss.stop({ graceful: true,
|
||||
timeout: 30s })` so in-flight handlers finish instead of being killed.
|
||||
Previously, pending handlers could be lost or replayed on restart.
|
||||
- **CI now blocks on TypeScript strict checks.** `continue-on-error: true`
|
||||
removed for typecheck. Tests already were blocking. ESLint stays
|
||||
non-blocking for now — the existing `settings/page.tsx` monolith has
|
||||
long-standing `react-hooks/set-state-in-effect` violations whose proper
|
||||
fix lives in the 1.4.0 settings-split refactor; a blocking lint here
|
||||
would just gate every PR until that refactor lands. Required cleaning
|
||||
up two `any` types in `api-handler.ts` (justified inline — Next.js
|
||||
variadic handler signature constrained by `Promise<Response>` return).
|
||||
|
||||
### Polish
|
||||
|
||||
- **Bottom-nav touch targets** sized to WCAG 2.5.5 minimum (44×44 CSS px).
|
||||
Visual icon stays at 20 px so the design doesn't shift.
|
||||
- **Phase-config dialog** marks the decorative coloured dot `aria-hidden`
|
||||
because the redundant text label already conveys the phase to screen
|
||||
readers. No more meaningless `image` node announcements.
|
||||
- **Admin-status labels** for "Web Push" and "Bug Report" now go through
|
||||
`t()` (new `admin.integrationWebPush` / `admin.integrationBugReport`
|
||||
keys in en + de). They were the last hard-coded English strings on
|
||||
the admin page.
|
||||
|
||||
### What's _not_ in this release (tracked for later)
|
||||
|
||||
- **Onboarding redesign** (dashboard-first empty-state flow + persistent
|
||||
Getting Started checklist) and the **typed `apiClient` wrapper** that
|
||||
underpins it are tracked for a focused 1.4.0 cycle. The 1.2.1 patch
|
||||
already closed the acute symptoms of #87 (silent-failure toast +
|
||||
default schedule), so the redesign is now a proper UI investment
|
||||
rather than a bug-fix.
|
||||
- **Withings sync is mapping both new measures**, but **a dedicated
|
||||
Bearer-auth ingest endpoint for external pipelines** (n8n + Health
|
||||
Connect, requested in #89) ships in 1.4.0 alongside the API-token
|
||||
flow.
|
||||
|
||||
## [1.2.1] — 2026-04-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Onboarding**: Medications added during onboarding are now actually persisted (closes #87). The wizard previously sent an empty `schedules: []` array, the server-side validation rejected it with a 422, the client never checked `response.ok`, and the user was redirected to the dashboard as if everything had worked. Onboarding now wraps each step in `try/catch`, surfaces failures via toast, and attaches a default reminder window (`08:00–09:00 daily`) so the medication actually persists. A hint under the medication list explains the default.
|
||||
- **Docker setup** (closes #88):
|
||||
- `docker-compose.yml` now uses `ports: "3000:3000"` (was `expose: "3000"`, which made the app unreachable from the host).
|
||||
- `POSTGRES_PASSWORD` is a single env var that both the Postgres service and `DATABASE_URL` interpolate, so they cannot drift apart.
|
||||
- `.env.example` now points at the in-container hostname `db:5432` (was `localhost:5432`, which never resolves inside the app container).
|
||||
- **Documentation**:
|
||||
- `package.json` synced to 1.2.0 (was lagging on 1.1.0).
|
||||
- `CLAUDE.md` and `AGENTS.md` corrected to 23 models (the `Feedback` model added in v1.2 was missing from the count).
|
||||
- `README.md` Quick Start gives a realistic time estimate, generates the four secrets in one block straight into `.env`, and points reverse-proxy users at the docs.
|
||||
|
||||
### Added — Tooling & Supply Chain
|
||||
|
||||
- **Pre-built multi-arch images on GHCR**: `.github/workflows/docker-publish.yml` now builds `linux/amd64` + `linux/arm64` images on every push to `main` and on every `v*` tag, publishing to `ghcr.io/mbombeck/healthlog`. Self-hosters no longer need a build toolchain — `docker compose pull && docker compose up -d` is enough. The bundled `docker-compose.yml` references the published image with a `build:` block as fallback for contributors.
|
||||
- **Supply-chain attestations**: each published image carries a SLSA build provenance statement and a Software Bill of Materials. `SECURITY.md` documents how to verify them and how to pin a specific version.
|
||||
- **Documentation single source of truth**: `getting-started/installation.mdx` is now the canonical setup guide (mirrors the bundled `docker-compose.yml`); `self-hosting/docker.mdx` slimmed to image internals + ops notes only. The landing page's Quick Start terminal block now includes the secrets-generation step (was missing).
|
||||
|
||||
### Notes
|
||||
|
||||
This is a patch release that closes the install/onboarding friction reported in #87 and #88. The bigger user-facing changes (additional measurement types like total body water and bone mass per #89, full onboarding redesign, typed API client) are tracked for `1.3.0`.
|
||||
|
||||
## [1.2.0] — 2026-04-18
|
||||
|
||||
### Added — Personalization, Glucose & Multi-Provider AI
|
||||
|
||||
- **Per-user custom thresholds**: Override the computed default ranges (BP, BMI, glucose, pulse) with values from your clinician. Audit-logged with previous/new values and timestamps. Doctor Report PDF flags custom ranges and prints both your target and the standard guideline value.
|
||||
- **Blood glucose tracking**: New metric with `fasting`, `postprandial`, `random`, and `bedtime` contexts. Display unit switch between mg/dL and mmol/L (lossless conversion). Context-aware classification per ADA 2024 / DGIM. Per-context charting on dashboard and Doctor Report PDF.
|
||||
- **Dashboard customization**: Show/hide and drag-to-reorder every dashboard widget. Per-user preference, reset-to-defaults button. Layout persists across the same user on the same device.
|
||||
- **Built-in feedback system**: New in-app Send Feedback flow (Bug / Feature / Question / Other) with anonymized system info attachment. Stored in HealthLog's own database — no GitHub config required. Optional `Escalate to GitHub` button for admins who configure a PAT.
|
||||
- **Multi-provider AI insights**: Provider abstraction extended with **Anthropic Claude** and **local OpenAI-compatible endpoints** (Ollama, LM Studio, vLLM, LiteLLM) alongside OpenAI. Per-user provider selection. Local endpoints keep all health data on your network.
|
||||
- **Locale-aware UI polish (English-first)**: Numbers, dates, glucose units, BP, weight, and BMI all formatted via `useFormatters()` from the active locale. Doctor Report PDF and AI insight prompts now respect locale end-to-end (no hand-rolled `Intl.*` with fixed locales).
|
||||
|
||||
### Changed
|
||||
|
||||
- Reference range computation extracted into a dedicated `src/lib/health/thresholds.ts` module with computed defaults and override resolution.
|
||||
- AI provider routing reworked to dispatch by `provider` field on the user record; OpenAI remains the default for legacy users.
|
||||
- Dashboard route renders widgets from `UserDashboardLayout` model when present, otherwise falls back to the default order.
|
||||
- Doctor Report PDF: locale-aware headers, glucose section, custom-range badges.
|
||||
|
||||
### Security
|
||||
|
||||
- GitHub PAT for feedback escalation stored AES-256-GCM encrypted in the database (never as env var).
|
||||
- Local AI endpoint URLs validated against SSRF (no localhost/RFC1918 unless explicitly allowed by admin).
|
||||
- Custom threshold writes rate-limited and audit-logged with IP.
|
||||
|
||||
## [1.1.0] — 2026-04-06
|
||||
|
||||
### Added — AI Insights Overhaul
|
||||
|
||||
- **ChatGPT Proxy Integration**: Insights now run through a local openai-oauth proxy using your ChatGPT subscription — no separate API billing required
|
||||
- **Admin AI Fallback**: Admins can configure a global API key (OpenAI/OpenRouter) as fallback for users without their own connection
|
||||
- **Provider Abstraction**: New `src/lib/ai/` module with pluggable providers (Codex OAuth, Admin Key, None) and automatic failover
|
||||
- **Medical Insight Prompts**: 7 specialized prompts based on ESC/ESH 2023, WHO, DGE, and DEGAM guidelines
|
||||
- Blood Pressure: ESC/ESH classification, morning risk ladder (J-HOP), pulse pressure, seasonal variation
|
||||
- Weight: 5%/10% milestone recognition, plateau detection, body composition divergence
|
||||
- Pulse: Fitness interpretation ladder, 80-100 bpm elevated-risk band, rate-pressure product
|
||||
- BMI: Age-adjusted DEGAM classification for 65+
|
||||
- Medication Compliance: Chronotherapy hints, mood-adherence risk prediction, 90-day tracking
|
||||
- General Status: Cross-domain synthesis with cardiovascular risk stratification
|
||||
- Mood: Bidirectional correlations with vitals and adherence
|
||||
- **Enriched Feature Extraction**:
|
||||
- Sleep duration and activity steps (previously ignored)
|
||||
- Rate-Pressure Product (pulse × systolic BP, myocardial demand indicator)
|
||||
- Body composition divergence flag (weight stable + body fat rising)
|
||||
- Mood-adherence risk predictor
|
||||
- Seasonal BP variation (winter vs summer, requires >180 days data)
|
||||
- BP standard deviation (sdSys30/sdDia30) as variability risk marker
|
||||
- Pulse pressure (arterial stiffness marker)
|
||||
- 5 cross-metric Pearson correlations (weight↔BP, pulse↔BP, mood↔pulse, mood↔BP, mood↔weight)
|
||||
- 90-day averages and all-time statistics for all metrics
|
||||
- Historical comparison (current 7d vs previous 30d baseline)
|
||||
- **New UI Components**:
|
||||
- `InsightStatusCard`: Compact per-metric status card with classification indicator and fade-in animation
|
||||
- `InsightAdvisorCard`: Premium structured card with findings, correlations, recommendations (ready for integration)
|
||||
- **OAuth Routes**: `/api/auth/codex/authorize`, `/callback`, `/disconnect` for ChatGPT connection
|
||||
- **Admin AI Settings**: `/api/admin/ai-settings` for global API key management
|
||||
|
||||
### Changed
|
||||
|
||||
- Insight prompts now use personal advisor tone ("dein Blutdruck") with positive-first pattern
|
||||
- Reasoning scaffold in system prompt (What changed? → Why? → What to do?)
|
||||
- Conditional correlation instructions (only mention when |r| > 0.4)
|
||||
- InsightResult schema enriched with `insightType`, `primaryRecommendation`, `classificationLabel`
|
||||
- BP target calculation now uses paired readings (both sys AND dia must be in range simultaneously)
|
||||
- Medication streak tracking extended from 7-day to 30-day window
|
||||
- CSP updated to allow `chatgpt.com` for OAuth flow
|
||||
|
||||
### Security
|
||||
|
||||
- Rate limiting on all OAuth and admin endpoints
|
||||
- PKCE (S256) + state parameter for OAuth CSRF protection
|
||||
- Encrypted token storage at rest (AES-256-GCM)
|
||||
- Error messages truncated to prevent upstream response body leaks
|
||||
- Admin key preview shows last 4 chars of decrypted key (not ciphertext prefix)
|
||||
- `prefers-reduced-motion` support for insight card animations
|
||||
|
||||
### Removed
|
||||
|
||||
- `openaiKeyEncrypted` field from User model (replaced by provider abstraction)
|
||||
- Direct OpenAI API calls from insight generators (now routed through provider)
|
||||
- Legacy API key input in settings UI (replaced by ChatGPT connect button)
|
||||
|
||||
## [1.0.1] — Previous release
|
||||
@@ -0,0 +1,124 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project
|
||||
|
||||
HealthLog — a personal health-tracking PWA (weight, blood pressure, pulse, mood, medication compliance) with Withings integration, moodLog.app sync, Dracula-themed UI, mobile-first design, offline caching, and doctor report PDF export.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
pnpm dev # Start dev server (http://localhost:3000)
|
||||
pnpm build # Production build
|
||||
pnpm lint # ESLint
|
||||
pnpm format # Prettier format
|
||||
pnpm format:check # Prettier check
|
||||
pnpm typecheck # TypeScript strict check
|
||||
|
||||
# Tests
|
||||
pnpm test # Run all tests (vitest)
|
||||
pnpm test:watch # Watch mode
|
||||
|
||||
# Database
|
||||
pnpm db:generate # Generate Prisma client
|
||||
pnpm db:migrate # Create & apply migration (dev)
|
||||
pnpm db:migrate:deploy # Apply migrations (production)
|
||||
pnpm db:push # Push schema without migration (prototyping)
|
||||
pnpm db:studio # Prisma Studio GUI
|
||||
|
||||
# Docker
|
||||
docker compose up -d # Start app + postgres
|
||||
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 (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`.
|
||||
- **Zod v4** (`zod/v4`) for all input validation (API routes and forms).
|
||||
- **API response envelope**: `{ data, error, meta }` via helpers in `src/lib/api-response.ts`.
|
||||
- **apiHandler** wraps ALL API route handlers (`src/lib/api-handler.ts`) — error handling, Wide Event logging, x-request-id propagation.
|
||||
- **Structured Logging** (Wide Events) in `src/lib/logging/` — one JSON event per request/operation via `WideEventBuilder` + `AsyncLocalStorage`. Tail sampling, stdout JSON + optional Loki Push.
|
||||
- **Vitest** for unit testing. Config in `vitest.config.mts`.
|
||||
- **pg-boss** (PostgreSQL-native) for job queue: medication reminders, insight caching, weekly data backups.
|
||||
- **jsPDF** + jspdf-autotable for client-side doctor report PDF generation (`src/lib/doctor-report-pdf.ts`).
|
||||
- **PWA offline** via service worker caching strategies in `public/sw.js` (cache-first for static, network-first for pages, network-only for API).
|
||||
- **Proxy route protection** — `src/proxy.ts` checks session cookie, enforces auth on all non-public paths. Next.js 16 uses proxy.ts (not middleware.ts).
|
||||
|
||||
## Key Conventions
|
||||
|
||||
- Default locale is **English**. All code-level strings in English. UI text via i18n `t()` with German + English translations.
|
||||
- Timezone: `Europe/Berlin` for display, UTC in database.
|
||||
- Sensitive data (Withings tokens, API keys, VAPID private keys) encrypted with AES-256-GCM (`src/lib/crypto.ts`) before DB storage.
|
||||
- Passkeys are primary auth (SimpleWebAuthn v13). Sessions stored server-side in PostgreSQL.
|
||||
- All API mutations require authentication. External ingest uses Bearer token (hashed with HMAC-SHA256 keyed by `API_TOKEN_HMAC_KEY`).
|
||||
- Rate limiting (in-memory sliding window) on auth and external-facing endpoints.
|
||||
|
||||
## File Layout
|
||||
|
||||
- `src/app/` — pages and API routes (App Router)
|
||||
- `src/components/ui/` — shadcn/ui primitives
|
||||
- `src/components/layout/` — shell (sidebar-nav, topbar, mobile-nav, bottom-nav)
|
||||
- `src/components/medications/` — medication form, card, timeline
|
||||
- `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)
|
||||
- `src/lib/export.ts` — CSV/JSON export formatting (measurements, medications, mood)
|
||||
- `src/lib/doctor-report-pdf.ts` — client-side PDF generation (jsPDF, European medical format)
|
||||
- `src/lib/withings/` — Withings OAuth client and sync service
|
||||
- `src/lib/notifications/` — dispatcher, types, senders (telegram, ntfy, web-push)
|
||||
- `src/lib/jobs/` — pg-boss reminder worker (reminders, insights, data backups)
|
||||
- `src/lib/analytics/` — trend calculations, compliance, correlations
|
||||
- `src/lib/i18n/` — i18n context, config, locale detection
|
||||
- `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 (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
|
||||
- `AGENTS.md` — AI agent instructions (Codex, Cursor, etc.)
|
||||
- `CHANGELOG.md` — release notes per semver tag
|
||||
|
||||
## Important Patterns
|
||||
|
||||
- Import Prisma client from `@/generated/prisma/client` (not `@/generated/prisma`)
|
||||
- Prisma 7 does NOT support `url = env("...")` in schema.prisma — use `prisma.config.ts`
|
||||
- Next.js 16 uses `proxy.ts` (not `middleware.ts`) — having both causes build error
|
||||
- SimpleWebAuthn v13 has no `@simplewebauthn/server/script/deps` — define Transport type inline
|
||||
- `zxcvbn-typescript` uses default export only (no named `zxcvbnAsync`/`zxcvbnOptions`)
|
||||
- pg-boss v12: `{ PgBoss }` named import, `localConcurrency` instead of `teamSize`, handler receives `Job<T>[]` array
|
||||
- **i18n**: All UI text uses `useTranslations()` hook with `t("section.key")`. Messages in `messages/en.json` (English, default) + `messages/de.json` (German, user-selectable). Keys missing in the active locale fall back to English; missing from both surfaces the raw key — the `src/lib/__tests__/i18n*.test.ts` suite guards against that. Numbers/dates go through `useFormatters()` — never hand-roll `Intl.*` with a fixed locale.
|
||||
- **Notifications**: 3 channels (Telegram, ntfy, Web Push). Dispatcher at `src/lib/notifications/dispatcher.ts`. Opt-out model (all events enabled by default)
|
||||
- **Gravatar**: Server-side SHA-256 hash via `src/lib/gravatar.ts`. URL returned in `/api/auth/me` response as `gravatarUrl` field
|
||||
- **Sidebar**: Collapsible (localStorage-persisted). Settings mode with section shortcuts. User section at bottom with three-dot dropdown menu
|
||||
- **ESLint**: Strict `react-hooks/set-state-in-effect` rule — avoid `setState` inside `useEffect`. Use lazy `useState()` initializer for localStorage reads
|
||||
- **Proxy route protection**: `src/proxy.ts` checks `healthlog_session` cookie; PUBLIC_PATHS array bypasses auth. Security headers (CSP, HSTS, X-Frame-Options) applied to all responses
|
||||
- **PWA safe area**: Root layout has `viewportFit: "cover"`. Bottom-nav uses `pb-[env(safe-area-inset-bottom)]` for iOS safe area
|
||||
- **Doctor report PDF**: Client-side via `src/lib/doctor-report-pdf.ts` (jsPDF). Data from `POST /api/doctor-report`. European medical format, German terminology
|
||||
- **Achievements**: Persistent in `UserAchievement` table. Computed on API call, new unlocks written to DB with stable `unlockedAt` timestamps
|
||||
- **Data backup**: pg-boss `data-backup` queue runs weekly (Sundays 03:00), stores JSON in `DataBackup` model
|
||||
- **Wide Events / Structured Logging**: `apiHandler()` wraps all API routes. Use `annotate()` from `@/lib/logging/context` for business-action annotations. Use `requireAuth()` / `requireAdmin()` from `@/lib/api-handler` (auto-annotates auth). Background jobs use `withBackgroundEvent()`. External calls tracked via `getEvent()?.addExternalCall()`. No `console.log` in production code — use event annotations instead. Env vars: `LOG_LEVEL`, `LOG_SAMPLE_RATE`, `LOG_SLOW_THRESHOLD_MS`, `LOG_INCLUDE_STACK`, `LOKI_ENDPOINT`, `LOKI_USERNAME`, `LOKI_PASSWORD`
|
||||
- **Multi-tenant prep (v1.4)**: `HEALTHLOG_PROCESS_TYPE=web|worker|all` (default `all`) splits HTTP and pg-boss workloads; the proxy refuses HTTP traffic with 503 + `X-HealthLog-Process-Type: worker` in worker mode. `ENCRYPTION_KEYS` is a JSON map of versioned keys (`{"v1": "...", "v2": "..."}`) plus `ENCRYPTION_ACTIVE_KEY_ID` for new writes; rotation via `pnpm tsx scripts/rotate-encryption-key.ts <id>`. `BACKUP_S3_*` env block configures off-host weekly encrypted backups (PutObject + GetObject only — retention is the bucket's lifecycle policy, never the worker).
|
||||
- **Native API clients (v1.4)**: `POST /api/auth/login` and `/api/auth/passkey/login-verify` issue a 24h access token (`hlk_<64hex>`) AND a refresh token (`hlr_<64hex>`) when `X-Client-Type: native` or the User-Agent starts with `HealthLog-iOS/`. The browser flow is unchanged. Refresh-token reuse-detection revokes every refresh token for the user. The idempotency replay-cache rejects bodies containing `hlk_` OR `hlr_` so cached responses can never echo a token back.
|
||||
|
||||
## Headless-Client API Patterns
|
||||
|
||||
The iOS app talks to bearer-protected routes while the web PWA uses cookie sessions; both share the same handlers (`requireAuth()` accepts either credential).
|
||||
|
||||
- **Bearer-token issuance**: `POST /api/auth/login` and `POST /api/auth/passkey/login-verify` return a `token` field for native clients. `isNativeClientRequest()` (`src/lib/auth/issue-token.ts`) opts in via `X-Client-Type: native` header **or** a `User-Agent` starting with `HealthLog-iOS/`. The raw `hlk_<64-hex>` value is returned exactly once; default lifetime 90 days, permissions `["*"]`.
|
||||
- **Token storage**: API tokens are stored as **HMAC-SHA-256** hashes (`hashToken()` in `src/lib/auth/hmac.ts`, keyed by env `API_TOKEN_HMAC_KEY`) — not plain SHA-256. The keyed HMAC defends against precomputed rainbow tables if the DB leaks. Same mechanism is reused for external-ingest token verification.
|
||||
- **Idempotency-Key**: `withIdempotency()` (`src/lib/idempotency.ts`) wraps POST/PUT/PATCH/DELETE handlers so retries with the same `Idempotency-Key` (regex `^[A-Za-z0-9_\-:.]{8,128}$`) for the same `(userId, method, path)` replay the original response with `X-Idempotent-Replay: true` for 24h. **NOT cached**: 401, 403, 408, 429, any 5xx, and any body containing `hlk_` (defence against caching a token-issuing response). Validation 4xx (422 etc.) **is** cached so a broken retry doesn't re-hit the DB. `userIdResolver` defaults to the cookie session — pass a 2nd argument for Bearer-only routes.
|
||||
- **Device-registration cross-user-hijack guard**: `POST /api/devices` rejects re-registration of an existing APNs token under a different user with **409** (`Device token already registered to another account`) instead of silently transferring ownership. APNs tokens aren't a secret, so trusting wire input would let anyone who learns a token redirect another user's pushes. Audit trail: `device.register.denied` with reason `token_owned_by_other_user`.
|
||||
+29
-16
@@ -4,7 +4,7 @@ Contributions are welcome. This guide covers how to set up the project and submi
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 22+
|
||||
- Node.js 20+
|
||||
- pnpm
|
||||
- PostgreSQL 16+ (or Docker)
|
||||
- Git
|
||||
@@ -20,13 +20,21 @@ cp .env.example .env
|
||||
Edit `.env` and configure at minimum:
|
||||
|
||||
```
|
||||
DATABASE_URL="postgresql://healthlog:healthlog@localhost:5432/healthlog"
|
||||
POSTGRES_PASSWORD=<base64, 24 bytes>
|
||||
DATABASE_URL="postgresql://healthlog:${POSTGRES_PASSWORD}@db:5432/healthlog"
|
||||
SESSION_SECRET=<64-char hex>
|
||||
ENCRYPTION_KEY=<64-char hex>
|
||||
API_TOKEN_HMAC_KEY=<64-char hex>
|
||||
```
|
||||
|
||||
Generate secrets with `openssl rand -hex 32`.
|
||||
Generate the four secrets:
|
||||
|
||||
```bash
|
||||
echo "POSTGRES_PASSWORD=$(openssl rand -base64 24)" >> .env
|
||||
echo "SESSION_SECRET=$(openssl rand -hex 32)" >> .env
|
||||
echo "ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .env
|
||||
echo "API_TOKEN_HMAC_KEY=$(openssl rand -hex 32)" >> .env
|
||||
```
|
||||
|
||||
### With Docker (database only)
|
||||
|
||||
@@ -47,23 +55,28 @@ docker compose up -d
|
||||
## Development Workflow
|
||||
|
||||
```bash
|
||||
pnpm dev # Start dev server (http://localhost:3000)
|
||||
pnpm lint # ESLint
|
||||
pnpm format # Prettier
|
||||
pnpm typecheck # TypeScript strict mode
|
||||
pnpm test # Vitest
|
||||
pnpm dev # Start dev server (http://localhost:3000)
|
||||
pnpm lint # ESLint
|
||||
pnpm format:check # Prettier (check)
|
||||
pnpm typecheck # TypeScript strict mode
|
||||
pnpm test # Vitest
|
||||
pnpm build # Next.js production build
|
||||
```
|
||||
|
||||
All four checks must pass before submitting a PR.
|
||||
All five checks must pass before submitting a PR. The full one-liner that mirrors CI:
|
||||
|
||||
```bash
|
||||
pnpm typecheck && pnpm lint && pnpm test && pnpm format:check && pnpm build
|
||||
```
|
||||
|
||||
## Code Conventions
|
||||
|
||||
- **TypeScript strict mode** -- no `any` types, no type assertions unless absolutely necessary
|
||||
- **UI text in German**, code/comments/docs in English
|
||||
- **API responses** follow `{ data, error, meta }` envelope pattern
|
||||
- **Zod v4** for input validation (API routes and forms)
|
||||
- **shadcn/ui** components (new-york style) for UI primitives
|
||||
- **Dracula theme** -- dark mode only, use `--dracula-*` CSS tokens
|
||||
- **TypeScript strict mode** -- avoid `any`. Where it's truly unavoidable (e.g. variadic Next.js handler signatures in `src/lib/api-handler.ts`), document the reason inline.
|
||||
- **English-default UI** -- the default locale is English. German is user-selectable. Code, comments, commit messages, and docs are English. All user-facing strings go through `t("key")` with translations in both `messages/en.json` and `messages/de.json`. The `i18n-locale-integrity.test.ts` suite blocks merges if keys drift between locales or duplicate at any nesting depth.
|
||||
- **API responses** follow the `{ data, error }` envelope via `apiSuccess()` / `apiError()` in `src/lib/api-response.ts`. Wrap every route handler in `apiHandler()` (`src/lib/api-handler.ts`) for error handling, structured Wide-Event logging, and `x-request-id` propagation.
|
||||
- **Zod v4** (`import { ... } from "zod/v4"`) for input validation in API routes and forms.
|
||||
- **shadcn/ui** components (new-york style) for UI primitives.
|
||||
- **Dracula theme** -- dark mode is the default; a `light`/`system` toggle is exposed in the topbar. Chart and accent colors use the `--dracula-*` CSS tokens so both themes stay coherent.
|
||||
|
||||
See `CLAUDE.md` for detailed architecture and patterns.
|
||||
|
||||
@@ -82,7 +95,7 @@ Always include the migration file in your PR.
|
||||
1. Fork the repository
|
||||
2. Create a feature branch (`git checkout -b feature/your-feature`)
|
||||
3. Make your changes
|
||||
4. Ensure all checks pass (`pnpm lint && pnpm typecheck && pnpm test`)
|
||||
4. Ensure all checks pass (`pnpm typecheck && pnpm lint && pnpm test && pnpm format:check && pnpm build`)
|
||||
5. Submit a PR with a clear description of the changes
|
||||
|
||||
## Security
|
||||
|
||||
+6
-1
@@ -23,12 +23,17 @@ RUN pnpm build
|
||||
|
||||
# ── Stage 3: Production runner ─────────────────────────────
|
||||
FROM node:22-alpine AS runner
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
# `tzdata` is required so Europe/Berlin schedules (pg-boss cron, locale-aware
|
||||
# timestamp formatting) resolve to the actual offset instead of silently
|
||||
# falling back to UTC on Alpine images that ship without it.
|
||||
RUN apk add --no-cache tzdata && \
|
||||
corepack enable && corepack prepare pnpm@latest --activate
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV NODE_PATH="/opt/pg-boss/node_modules"
|
||||
ENV TZ=Europe/Berlin
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nextjs
|
||||
|
||||
@@ -37,7 +37,11 @@ Most health apps lock your data behind proprietary clouds, push subscriptions, a
|
||||
|
||||
## Key Features
|
||||
|
||||
**Health Metrics** -- Track weight, blood pressure, pulse, body fat, sleep, and steps with interactive trend charts, moving averages, and traffic-light ranges based on ESC/ESH 2018 guidelines.
|
||||
**Health Metrics** -- Track weight, blood pressure, pulse, body fat, sleep, steps, blood glucose (fasting/postprandial/random/bedtime, mg/dL ↔ mmol/L), total body water, bone mass, and pulse oximetry (SpO₂) with interactive trend charts, moving averages, and traffic-light ranges based on ESC/ESH 2018, ADA 2024, and consensus pulse-oximeter guidance (NICE NG115). Body-composition + SpO₂ metrics sync automatically from Withings Body+ scales and ScanWatch devices.
|
||||
|
||||
**Custom Thresholds** -- Override the computed default ranges per metric with the targets your clinician set. Audit-logged. Doctor Report PDF prints both your target and the standard reference.
|
||||
|
||||
**Customizable Dashboard** -- Show, hide, and drag-to-reorder every widget. Per-user layout with reset-to-defaults.
|
||||
|
||||
**Mood Logging** -- 5-point scale with tags, notes, and trend analytics. Syncs automatically from moodLog.app via webhook.
|
||||
|
||||
@@ -45,37 +49,46 @@ Most health apps lock your data behind proprietary clouds, push subscriptions, a
|
||||
|
||||
**Withings Integration** -- OAuth2 device sync for scales, blood pressure monitors, and activity trackers with automatic deduplication.
|
||||
|
||||
**AI-Powered Insights** -- Bring your own OpenAI API key. Personalized analysis across blood pressure, weight, pulse, mood, BMI, and medication adherence. Cached daily to minimize API usage.
|
||||
**Multi-Provider AI Insights** -- Pick OpenAI, Anthropic Claude, or any local OpenAI-compatible endpoint (Ollama, LM Studio, vLLM). BYOK or admin-shared key. Cached daily. Local endpoints keep all data on your network.
|
||||
|
||||
**Doctor Report PDF Export** -- Generate professional medical reports client-side. European format with vital sign summaries, BP/BMI classification, compliance rates, and optional AI analysis.
|
||||
**Doctor Report PDF Export** -- Generate professional medical reports client-side. Locale-aware (English/German), with vital sign summaries, BP/BMI/glucose classification, compliance rates, custom-threshold badges, and optional AI analysis.
|
||||
|
||||
**Built-in Feedback** -- Send bug reports and feature requests from inside the app. Stored in your HealthLog database — no GitHub config required. Optional GitHub escalation for admins.
|
||||
|
||||
**PWA with Offline Support** -- Installable on iOS and Android. Service worker with intelligent caching strategies for reliable offline access.
|
||||
|
||||
**Multi-Channel Notifications** -- Telegram (with inline action buttons), ntfy (self-hostable), and Web Push. Medication reminders with late/missed escalation.
|
||||
|
||||
**Gamification** -- 30+ persistent achievements across intake streaks, compliance milestones, and healthy metric streaks.
|
||||
**Gamification** -- 38+ persistent achievements across intake streaks, compliance milestones, and healthy metric streaks.
|
||||
|
||||
**Internationalization** -- Full German and English UI with 1000+ translation keys. Browser-based locale detection with per-user override.
|
||||
**Internationalization** -- English (default) and German UI with 1500+ translation keys, guarded by a CI integrity test that fails the build on duplicate keys or drift between locales. Numbers, dates, units, and AI prompts all locale-aware via `useFormatters()`. Browser-based detection with per-user override.
|
||||
|
||||
**Multi-tenant ready** _(v1.4)_ — Off-host AES-GCM-encrypted weekly backups to any S3-compatible bucket (R2, B2, MinIO, AWS), encryption-key versioning + zero-downtime rotation CLI, optional `HEALTHLOG_PROCESS_TYPE=web|worker|all` so HTTP and pg-boss can scale independently, and short-lived 24h access tokens with refresh-token rotation for native API clients. The browser cookie session is unchanged.
|
||||
|
||||
**Test connection buttons** _(v1.4)_ — One-click probes for Withings, moodLog.app, Web Push, Glitchtip, and Umami in addition to the existing AI / Telegram / ntfy tests. Each one rate-limited, sanitised against SSRF redirects, and surfaces a localisable `errorCode` so the UI can render the failure in the user's language.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
Plan ~5 minutes for a working install. The bundled `docker-compose.yml` pulls a pre-built multi-arch image (`linux/amd64` + `linux/arm64`) from [GitHub Container Registry](https://github.com/MBombeck/HealthLog/pkgs/container/healthlog); no build step required for self-hosters. Contributors who want to test local changes can `docker compose up --build`.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/MBombeck/HealthLog.git
|
||||
cd HealthLog
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Generate the required secrets:
|
||||
Generate the four required secrets and paste them into `.env`:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32 # SESSION_SECRET
|
||||
openssl rand -hex 32 # ENCRYPTION_KEY
|
||||
openssl rand -hex 32 # API_TOKEN_HMAC_KEY
|
||||
echo "POSTGRES_PASSWORD=$(openssl rand -base64 24)" >> .env
|
||||
echo "SESSION_SECRET=$(openssl rand -hex 32)" >> .env
|
||||
echo "ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .env
|
||||
echo "API_TOKEN_HMAC_KEY=$(openssl rand -hex 32)" >> .env
|
||||
```
|
||||
|
||||
Add them to `.env`, then:
|
||||
Then bring the stack up:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
@@ -83,25 +96,27 @@ docker compose up -d
|
||||
|
||||
Open **http://localhost:3000**. The first registered user becomes admin.
|
||||
|
||||
> Behind a reverse proxy (Caddy / Traefik / Nginx) for TLS, set `NEXT_PUBLIC_APP_URL` and `APP_URL` to your public URL in `.env` before starting. See [Self-Hosting → Reverse Proxy](https://docs.healthlog.dev/self-hosting/reverse-proxy/) for examples.
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|-----------|
|
||||
| Framework | Next.js 16 (App Router, React Server Components) |
|
||||
| Language | TypeScript (strict mode) |
|
||||
| Database | PostgreSQL 16 + Prisma 7 (22 models) |
|
||||
| Job Queue | pg-boss 12 (reminders, insights, backups) |
|
||||
| UI | shadcn/ui, Tailwind CSS 4, Radix UI, Lucide Icons |
|
||||
| Charts | Recharts 3 |
|
||||
| Data Fetching | TanStack Query 5 |
|
||||
| Forms | React Hook Form 7 + Zod 4 |
|
||||
| Auth | SimpleWebAuthn 13, Argon2id |
|
||||
| Notifications | Telegram Bot API, ntfy, Web Push (VAPID) |
|
||||
| PDF | jsPDF (client-side generation) |
|
||||
| Testing | Vitest 4 |
|
||||
| Deployment | Docker (multi-stage Alpine) |
|
||||
| Layer | Technology |
|
||||
| ------------- | ------------------------------------------------- |
|
||||
| Framework | Next.js 16 (App Router, React Server Components) |
|
||||
| Language | TypeScript (strict mode) |
|
||||
| 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 |
|
||||
| Data Fetching | TanStack Query 5 |
|
||||
| Forms | React Hook Form 7 + Zod 4 |
|
||||
| Auth | SimpleWebAuthn 13, Argon2id |
|
||||
| Notifications | Telegram Bot API, ntfy, Web Push (VAPID) |
|
||||
| PDF | jsPDF (client-side generation) |
|
||||
| Testing | Vitest 4 |
|
||||
| Deployment | Docker (multi-stage Alpine) |
|
||||
|
||||
---
|
||||
|
||||
@@ -124,23 +139,24 @@ HealthLog is designed for people who take data ownership seriously.
|
||||
|
||||
### Required
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `DATABASE_URL` | PostgreSQL connection string |
|
||||
| `SESSION_SECRET` | 64-char hex string for session signing |
|
||||
| `ENCRYPTION_KEY` | 64-char hex string for AES-256-GCM |
|
||||
| `API_TOKEN_HMAC_KEY` | 64-char hex string for API token hashing |
|
||||
| Variable | Description |
|
||||
| -------------------- | ------------------------------------------------------------- |
|
||||
| `POSTGRES_PASSWORD` | Password for the bundled Postgres service (Docker Compose) |
|
||||
| `DATABASE_URL` | PostgreSQL connection string (uses `POSTGRES_PASSWORD` above) |
|
||||
| `SESSION_SECRET` | 64-char hex string for session signing |
|
||||
| `ENCRYPTION_KEY` | 64-char hex string for AES-256-GCM |
|
||||
| `API_TOKEN_HMAC_KEY` | 64-char hex string for API token hashing |
|
||||
|
||||
### Optional
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `NEXT_PUBLIC_APP_URL` | Public-facing URL (default: `http://localhost:3000`) |
|
||||
| `WITHINGS_CLIENT_ID` | Withings OAuth2 client ID |
|
||||
| `WITHINGS_CLIENT_SECRET` | Withings OAuth2 client secret |
|
||||
| `WITHINGS_REDIRECT_URI` | OAuth callback URL |
|
||||
| `WITHINGS_WEBHOOK_SECRET` | Webhook URL hardening secret |
|
||||
| `TELEGRAM_WEBHOOK_SECRET` | Telegram bot webhook secret |
|
||||
| Variable | Description |
|
||||
| ------------------------- | ---------------------------------------------------- |
|
||||
| `NEXT_PUBLIC_APP_URL` | Public-facing URL (default: `http://localhost:3000`) |
|
||||
| `WITHINGS_CLIENT_ID` | Withings OAuth2 client ID |
|
||||
| `WITHINGS_CLIENT_SECRET` | Withings OAuth2 client secret |
|
||||
| `WITHINGS_REDIRECT_URI` | OAuth callback URL |
|
||||
| `WITHINGS_WEBHOOK_SECRET` | Webhook URL hardening secret |
|
||||
| `TELEGRAM_WEBHOOK_SECRET` | Telegram bot webhook secret |
|
||||
|
||||
Telegram bot token, ntfy settings, Web Push VAPID keys, Umami, and GlitchTip URLs are configured in the **Admin Panel** and stored encrypted in the database.
|
||||
|
||||
@@ -151,30 +167,50 @@ Telegram bot token, ntfy settings, Web Push VAPID keys, Umami, and GlitchTip URL
|
||||
```
|
||||
src/
|
||||
├── app/ # Next.js App Router pages & API routes
|
||||
│ ├── api/ # REST API endpoints (~60 route files)
|
||||
│ ├── api/ # REST API endpoints (100+ route files)
|
||||
│ ├── admin/ # Admin panel
|
||||
│ ├── auth/ # Login, register, passkey enrolment
|
||||
│ ├── medications/ # Medication management
|
||||
│ ├── measurements/ # Health metric entry
|
||||
│ ├── mood/ # Mood log
|
||||
│ ├── insights/ # AI-powered analytics
|
||||
│ ├── charts/ # Long-form charts
|
||||
│ ├── achievements/ # Gamification page
|
||||
│ ├── targets/ # Custom thresholds dashboard
|
||||
│ ├── notifications/ # Notification preferences matrix
|
||||
│ ├── bugreport/ # Built-in feedback / bug report
|
||||
│ ├── onboarding/ # 4-step guided setup
|
||||
│ └── settings/ # User preferences
|
||||
│ └── settings/ # User preferences (8 top-level sections)
|
||||
├── components/
|
||||
│ ├── ui/ # shadcn/ui primitives
|
||||
│ ├── layout/ # Shell (sidebar, topbar, bottom nav)
|
||||
│ ├── medications/ # Medication cards, forms, timeline
|
||||
│ ├── measurements/ # Measurement form, list
|
||||
│ ├── mood/ # Mood form, mood list
|
||||
│ └── charts/ # Recharts wrappers
|
||||
│ ├── charts/ # Recharts wrappers
|
||||
│ ├── insights/ # AI insight status / advisor cards
|
||||
│ ├── gamification/ # Achievement cards, progress
|
||||
│ ├── monitoring/ # Umami / GlitchTip bootstrap
|
||||
│ └── settings/ # Settings-page section components
|
||||
├── lib/
|
||||
│ ├── auth/ # Session, audit, passkey logic
|
||||
│ ├── notifications/ # Dispatcher + channel senders
|
||||
│ ├── jobs/ # pg-boss worker
|
||||
│ ├── analytics/ # Trend calculations, compliance
|
||||
│ ├── jobs/ # pg-boss worker (reminders, insights, backups)
|
||||
│ ├── analytics/ # Trend calculations, compliance, correlations
|
||||
│ ├── ai/ # Multi-provider client (OpenAI, Anthropic, local)
|
||||
│ ├── insights/ # Insight pipeline + medical prompts
|
||||
│ ├── gamification/ # Achievement definitions
|
||||
│ ├── feedback/ # Built-in feedback + GitHub escalation
|
||||
│ ├── moodlog/ # moodLog.app webhook + sync
|
||||
│ ├── monitoring/ # Umami / GlitchTip server-side hooks
|
||||
│ ├── withings/ # OAuth client, sync service
|
||||
│ ├── logging/ # Wide Events: builder, context, transports
|
||||
│ ├── i18n/ # Translations context & config
|
||||
│ ├── validations/ # Shared Zod schemas
|
||||
│ ├── api-handler.ts # apiHandler wrapper, requireAuth/requireAdmin
|
||||
│ ├── api-response.ts # { data, error } envelope helpers
|
||||
│ ├── crypto.ts # AES-256-GCM encrypt/decrypt
|
||||
│ ├── rate-limit.ts # Sliding-window rate limiter
|
||||
│ ├── db.ts # Prisma singleton
|
||||
│ └── doctor-report-pdf.ts # Client-side PDF generation
|
||||
├── hooks/ # React hooks
|
||||
@@ -184,10 +220,11 @@ src/
|
||||
### Key Patterns
|
||||
|
||||
- **RSC by default** -- `"use client"` only for interactive components
|
||||
- **API envelope** -- All responses follow `{ data, error, meta }` shape
|
||||
- **API envelope** -- All responses follow `{ data, error }` shape via `apiSuccess()` / `apiError()` in `src/lib/api-response.ts`
|
||||
- **apiHandler wrapper** -- Every API route wraps its handler in `apiHandler()` (`src/lib/api-handler.ts`) for consistent error handling, Wide-Event structured logging, and `x-request-id` propagation
|
||||
- **Encrypted secrets** -- Withings tokens, API keys, VAPID keys, notification credentials
|
||||
- **Timezone-aware** -- `Europe/Berlin` for display, UTC in database
|
||||
- **Route protection** -- `proxy.ts` checks session cookie, redirects unauthenticated requests
|
||||
- **Route protection** -- `proxy.ts` (Next.js 16's renamed middleware) checks session cookie, redirects unauthenticated requests
|
||||
- **Client-side PDF** -- Doctor reports generated in browser via jsPDF
|
||||
|
||||
---
|
||||
@@ -199,60 +236,144 @@ All mutations require authentication via session cookie. External ingest uses Be
|
||||
<details>
|
||||
<summary><strong>Health Data</strong></summary>
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/measurements` | List measurements (paginated, filterable) |
|
||||
| `POST` | `/api/measurements` | Create measurement |
|
||||
| `DELETE` | `/api/measurements/:id` | Delete measurement |
|
||||
| `GET` | `/api/analytics` | Trend summaries (7d/30d) |
|
||||
| `GET` | `/api/export` | Export as CSV or JSON |
|
||||
| `POST` | `/api/import` | Import from JSON |
|
||||
| `POST` | `/api/doctor-report` | Aggregated data for PDF |
|
||||
| Method | Endpoint | Description |
|
||||
| -------- | ----------------------- | ----------------------------------------- |
|
||||
| `GET` | `/api/measurements` | List measurements (paginated, filterable) |
|
||||
| `POST` | `/api/measurements` | Create measurement |
|
||||
| `DELETE` | `/api/measurements/:id` | Delete measurement |
|
||||
| `GET` | `/api/analytics` | Trend summaries (7d/30d) |
|
||||
| `GET` | `/api/export` | Export as CSV or JSON |
|
||||
| `POST` | `/api/import` | Import from JSON |
|
||||
| `POST` | `/api/doctor-report` | Aggregated data for PDF |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Mood</strong></summary>
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/mood-entries` | List mood entries |
|
||||
| `POST` | `/api/mood-entries` | Create mood entry |
|
||||
| `DELETE` | `/api/mood-entries/:id` | Delete mood entry |
|
||||
| `GET` | `/api/mood/analytics` | Mood trend analytics |
|
||||
| `POST` | `/api/integrations/moodlog/webhook` | moodLog.app webhook |
|
||||
| Method | Endpoint | Description |
|
||||
| -------- | ----------------------------------- | -------------------- |
|
||||
| `GET` | `/api/mood-entries` | List mood entries |
|
||||
| `POST` | `/api/mood-entries` | Create mood entry |
|
||||
| `DELETE` | `/api/mood-entries/:id` | Delete mood entry |
|
||||
| `GET` | `/api/mood/analytics` | Mood trend analytics |
|
||||
| `POST` | `/api/integrations/moodlog/webhook` | moodLog.app webhook |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Medications</strong></summary>
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/medications` | List all medications |
|
||||
| `POST` | `/api/medications` | Create medication |
|
||||
| `PUT` | `/api/medications/:id` | Update medication |
|
||||
| `DELETE` | `/api/medications/:id` | Delete medication |
|
||||
| `POST` | `/api/medications/:id/intake` | Log intake event |
|
||||
| `GET` | `/api/medications/:id/compliance` | Compliance stats |
|
||||
| `POST` | `/api/ingest/medication` | External intake (Bearer) |
|
||||
| Method | Endpoint | Description |
|
||||
| -------- | --------------------------------- | ------------------------ |
|
||||
| `GET` | `/api/medications` | List all medications |
|
||||
| `POST` | `/api/medications` | Create medication |
|
||||
| `PUT` | `/api/medications/:id` | Update medication |
|
||||
| `DELETE` | `/api/medications/:id` | Delete medication |
|
||||
| `POST` | `/api/medications/:id/intake` | Log intake event |
|
||||
| `GET` | `/api/medications/:id/compliance` | Compliance stats |
|
||||
| `POST` | `/api/ingest/medication` | External intake (Bearer) |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Auth and Integrations</strong></summary>
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `POST` | `/api/auth/register` | Create account |
|
||||
| `POST` | `/api/auth/login` | Password login |
|
||||
| `POST` | `/api/auth/logout` | Destroy session |
|
||||
| `GET` | `/api/auth/me` | Current user profile |
|
||||
| `POST` | `/api/auth/passkey/*` | WebAuthn flows |
|
||||
| `GET` | `/api/withings/connect` | Initiate Withings OAuth |
|
||||
| `POST` | `/api/insights/generate` | Regenerate AI insights |
|
||||
| `GET` | `/api/gamification/achievements` | Achievement progress |
|
||||
| `GET` | `/api/health` | Docker health check |
|
||||
| Method | Endpoint | Description |
|
||||
| ------- | -------------------------------- | ----------------------------------- |
|
||||
| `POST` | `/api/auth/register` | Create account |
|
||||
| `POST` | `/api/auth/login` | Password login |
|
||||
| `POST` | `/api/auth/logout` | Destroy session |
|
||||
| `GET` | `/api/auth/me` | Current user profile + Gravatar URL |
|
||||
| `POST` | `/api/auth/password` | Change password |
|
||||
| `PATCH` | `/api/auth/profile` | Update profile fields |
|
||||
| `POST` | `/api/auth/passkey/*` | WebAuthn flows (4 sub-routes) |
|
||||
| `GET` | `/api/auth/passkeys` | List enrolled passkeys |
|
||||
| `GET` | `/api/auth/codex/authorize` | ChatGPT (codex) OAuth start |
|
||||
| `GET` | `/api/withings/connect` | Initiate Withings OAuth |
|
||||
| `POST` | `/api/withings/sync` | Trigger manual Withings sync |
|
||||
| `POST` | `/api/withings/webhook` | Withings notification webhook |
|
||||
| `POST` | `/api/insights/generate` | Regenerate AI insights |
|
||||
| `GET` | `/api/insights/comprehensive` | Aggregated insight payload |
|
||||
| `GET` | `/api/gamification/achievements` | Achievement progress |
|
||||
| `GET` | `/api/health` | Docker health check |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Personalization (Thresholds + Dashboard)</strong></summary>
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| ------ | -------------------------- | ---------------------------------------------- |
|
||||
| `GET` | `/api/user/thresholds` | Read per-user threshold overrides |
|
||||
| `PUT` | `/api/user/thresholds` | Upsert thresholds (rate-limited, audit-logged) |
|
||||
| `GET` | `/api/insights/targets` | Effective ranges (defaults + overrides merged) |
|
||||
| `GET` | `/api/dashboard/widgets` | Read dashboard layout |
|
||||
| `PUT` | `/api/dashboard/widgets` | Persist dashboard layout (show/hide/reorder) |
|
||||
| `POST` | `/api/onboarding/complete` | Mark onboarding finished |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Feedback + API Tokens</strong></summary>
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| -------- | ----------------------- | ----------------------------------- |
|
||||
| `POST` | `/api/feedback` | Submit in-app feedback |
|
||||
| `GET` | `/api/bugreport/status` | Check published GitHub issue state |
|
||||
| `GET` | `/api/tokens` | List own API tokens |
|
||||
| `POST` | `/api/tokens` | Mint new API token (Bearer, hashed) |
|
||||
| `DELETE` | `/api/tokens/:id` | Revoke API token |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Notifications</strong></summary>
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| ------ | -------------------------------- | ----------------------------------- |
|
||||
| `GET` | `/api/notifications/preferences` | Read per-channel × per-event matrix |
|
||||
| `PUT` | `/api/notifications/preferences` | Update preferences |
|
||||
| `GET` | `/api/notifications/vapid` | VAPID public key for Web Push |
|
||||
| `POST` | `/api/notifications/web-push` | Register Web Push subscription |
|
||||
| `POST` | `/api/telegram/webhook` | Telegram bot inline-button callback |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Admin (admin role required)</strong></summary>
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| ------ | ------------------------------------- | ------------------------------------- |
|
||||
| `GET` | `/api/admin/status` | System + integration status |
|
||||
| `GET` | `/api/admin/users` | List users |
|
||||
| `POST` | `/api/admin/users/:id/reset-password` | Force password reset |
|
||||
| `GET` | `/api/admin/feedback` | All feedback / bug reports |
|
||||
| `POST` | `/api/admin/feedback/:id/github` | Escalate feedback to GitHub issue |
|
||||
| `GET` | `/api/admin/audit-log` | Audit-log viewer |
|
||||
| `GET` | `/api/admin/ai-settings` | Read shared AI provider config |
|
||||
| `PUT` | `/api/admin/ai-settings` | Update shared AI provider config |
|
||||
| `GET` | `/api/admin/tokens` | All issued API tokens |
|
||||
| `POST` | `/api/admin/notifications/test` | Send test notification |
|
||||
| `GET` | `/api/admin/data` | Data backups + counts |
|
||||
| `GET` | `/api/admin/status-overview` | Aggregated status for the 6-card grid |
|
||||
| `POST` | `/api/admin/backup/test` | Probe S3-compatible backup target |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Public + v1.4 additions</strong></summary>
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| ------ | ---------------------------------- | ----------------------------------------------- |
|
||||
| `GET` | `/api/version` | Public — version + build SHA + license, no auth |
|
||||
| `POST` | `/api/integrations/withings/test` | Probe a saved Withings connection |
|
||||
| `POST` | `/api/integrations/moodlog/test` | Probe moodLog.app webhook reachability |
|
||||
| `POST` | `/api/notifications/web-push/test` | Send a test Web Push to the current user |
|
||||
| `POST` | `/api/monitoring/glitchtip/test` | Trigger a Glitchtip ingest probe |
|
||||
| `POST` | `/api/monitoring/umami/test` | Verify Umami script + website ID resolve |
|
||||
| `POST` | `/api/auth/refresh` | Native client refresh-token rotation |
|
||||
| `POST` | `/api/auth/refresh/revoke` | Revoke an issued refresh token |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -260,16 +381,16 @@ All mutations require authentication via session cookie. External ingest uses Be
|
||||
|
||||
## Integrations
|
||||
|
||||
| Integration | Setup | Purpose |
|
||||
|-------------|-------|---------|
|
||||
| **Withings** | Env vars | Auto-sync weight, BP, and activity |
|
||||
| **Telegram** | Admin Panel | Medication reminders with inline buttons |
|
||||
| **ntfy** | User Settings | Self-hosted push notifications |
|
||||
| **Web Push** | Admin Panel | Browser-native VAPID notifications |
|
||||
| **OpenAI** | User Settings | AI health insights (BYOK) |
|
||||
| **moodLog.app** | User Settings | Mood tracking sync |
|
||||
| **Umami** | Admin Panel | Privacy-friendly analytics |
|
||||
| **GlitchTip** | Admin Panel | Sentry-compatible error tracking |
|
||||
| Integration | Setup | Purpose |
|
||||
| --------------- | ------------- | ---------------------------------------- |
|
||||
| **Withings** | Env vars | Auto-sync weight, BP, and activity |
|
||||
| **Telegram** | Admin Panel | Medication reminders with inline buttons |
|
||||
| **ntfy** | User Settings | Self-hosted push notifications |
|
||||
| **Web Push** | Admin Panel | Browser-native VAPID notifications |
|
||||
| **OpenAI** | User Settings | AI health insights (BYOK) |
|
||||
| **moodLog.app** | User Settings | Mood tracking sync |
|
||||
| **Umami** | Admin Panel | Privacy-friendly analytics |
|
||||
| **GlitchTip** | Admin Panel | Sentry-compatible error tracking |
|
||||
|
||||
---
|
||||
|
||||
@@ -318,7 +439,7 @@ Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines
|
||||
- Code style: `pnpm format && pnpm lint`
|
||||
- Type safety: `pnpm typecheck` must pass
|
||||
- Tests: `pnpm test`
|
||||
- UI language: German. Code, comments, and commits: English.
|
||||
- UI language: English by default, German selectable per user. Code, comments, and commits: English.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+15
-1
@@ -5,7 +5,7 @@
|
||||
Only the latest release on the `main` branch is actively supported with security updates.
|
||||
|
||||
| Version | Supported |
|
||||
|---------|-----------|
|
||||
| ------- | --------- |
|
||||
| Latest | Yes |
|
||||
| Older | No |
|
||||
|
||||
@@ -59,3 +59,17 @@ HealthLog is designed with security as a core principle:
|
||||
- **Proxy-level route protection** via `proxy.ts` (session cookie validation on all non-public paths)
|
||||
|
||||
For more details, see the [security documentation](https://docs.healthlog.dev/security/overview/).
|
||||
|
||||
## Supply Chain
|
||||
|
||||
HealthLog Docker images are built and published from this repository's CI:
|
||||
|
||||
- **Source**: [`.github/workflows/docker-publish.yml`](.github/workflows/docker-publish.yml) — runs on every push to `main` and every `v*` tag
|
||||
- **Registry**: [`ghcr.io/mbombeck/healthlog`](https://github.com/MBombeck/HealthLog/pkgs/container/healthlog)
|
||||
- **Architectures**: `linux/amd64` and `linux/arm64`
|
||||
- **Provenance attestation**: each image carries a [SLSA build provenance](https://slsa.dev/spec/v1.0/provenance) statement linking it back to the GitHub Actions run, the commit SHA, and the workflow definition
|
||||
- **SBOM**: each image includes a [Software Bill of Materials](https://docs.docker.com/build/metadata/attestations/sbom/) you can inspect with `docker buildx imagetools inspect ghcr.io/mbombeck/healthlog:latest --format '{{ json .SBOM }}'`
|
||||
|
||||
To pin to a specific version in production, replace `:latest` with the released tag in your `docker-compose.yml`, e.g. `ghcr.io/mbombeck/healthlog:1.3.2`. Pinning is recommended for self-hosters who want explicit control over upgrades.
|
||||
|
||||
If you discover a tampered or unexpected image, please report it as a vulnerability via the email above.
|
||||
+70
-10
@@ -1,30 +1,90 @@
|
||||
services:
|
||||
# Single-container default (HEALTHLOG_PROCESS_TYPE=all). Existing
|
||||
# self-host deployments stay working with `docker compose up`.
|
||||
# For horizontal scaling switch to the web/worker split below
|
||||
# (HEALTHLOG_PROCESS_TYPE=web on this service + uncomment app-worker).
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
# Pinned to GHCR :1.3.3 multi-arch image (gestern released, audit-tested).
|
||||
# Production on apps-01 has been 503-ing since today's deploys; rolling back
|
||||
# to the last release that's verifiably running on self-host installs to
|
||||
# rule out runtime / Docker-network issues. No `build:` block on purpose:
|
||||
# we want Coolify to PULL the prebuilt image, not build from source.
|
||||
image: ghcr.io/mbombeck/healthlog:1.4.0
|
||||
container_name: healthlog-app
|
||||
expose:
|
||||
- "3000"
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
DATABASE_URL: "postgresql://healthlog:healthlog@db:5432/healthlog?schema=public"
|
||||
DATABASE_URL: "postgresql://healthlog:${POSTGRES_PASSWORD}@db:5432/healthlog?schema=public"
|
||||
NODE_ENV: production
|
||||
SESSION_SECRET: "${SESSION_SECRET:?SESSION_SECRET is required}"
|
||||
ENCRYPTION_KEY: "${ENCRYPTION_KEY:?ENCRYPTION_KEY is required}"
|
||||
API_TOKEN_HMAC_KEY: "${API_TOKEN_HMAC_KEY:?API_TOKEN_HMAC_KEY is required}"
|
||||
SESSION_SECRET: "${SESSION_SECRET}"
|
||||
ENCRYPTION_KEY: "${ENCRYPTION_KEY}"
|
||||
ENCRYPTION_KEYS: "${ENCRYPTION_KEYS:-}"
|
||||
ENCRYPTION_ACTIVE_KEY_ID: "${ENCRYPTION_ACTIVE_KEY_ID:-}"
|
||||
API_TOKEN_HMAC_KEY: "${API_TOKEN_HMAC_KEY}"
|
||||
NEXT_PUBLIC_APP_URL: "${NEXT_PUBLIC_APP_URL:-http://localhost:3000}"
|
||||
APP_URL: "${APP_URL:-http://localhost:3000}"
|
||||
# all = bundle web + worker in this container (default, single-host)
|
||||
# web = serve HTTP only; pair with a separate app-worker service
|
||||
HEALTHLOG_PROCESS_TYPE: "${HEALTHLOG_PROCESS_TYPE:-all}"
|
||||
BACKUP_ENCRYPTION_KEY: "${BACKUP_ENCRYPTION_KEY:-}"
|
||||
BACKUP_S3_ENDPOINT: "${BACKUP_S3_ENDPOINT:-}"
|
||||
BACKUP_S3_BUCKET: "${BACKUP_S3_BUCKET:-}"
|
||||
BACKUP_S3_ACCESS_KEY: "${BACKUP_S3_ACCESS_KEY:-}"
|
||||
BACKUP_S3_SECRET_KEY: "${BACKUP_S3_SECRET_KEY:-}"
|
||||
BACKUP_S3_REGION: "${BACKUP_S3_REGION:-auto}"
|
||||
BACKUP_RETENTION_DAYS: "${BACKUP_RETENTION_DAYS:-30}"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/api/version || exit 1",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
# Optional dedicated worker container — uncomment + set
|
||||
# HEALTHLOG_PROCESS_TYPE=web on `app` to split the deployment.
|
||||
# See docs/self-hosting/scaling.md for the full guide.
|
||||
#
|
||||
# app-worker:
|
||||
# image: ghcr.io/mbombeck/healthlog:latest
|
||||
# build:
|
||||
# context: .
|
||||
# dockerfile: Dockerfile
|
||||
# container_name: healthlog-worker
|
||||
# environment:
|
||||
# DATABASE_URL: "postgresql://healthlog:${POSTGRES_PASSWORD}@db:5432/healthlog?schema=public"
|
||||
# NODE_ENV: production
|
||||
# SESSION_SECRET: "${SESSION_SECRET}"
|
||||
# ENCRYPTION_KEY: "${ENCRYPTION_KEY}"
|
||||
# ENCRYPTION_KEYS: "${ENCRYPTION_KEYS:-}"
|
||||
# ENCRYPTION_ACTIVE_KEY_ID: "${ENCRYPTION_ACTIVE_KEY_ID:-}"
|
||||
# API_TOKEN_HMAC_KEY: "${API_TOKEN_HMAC_KEY}"
|
||||
# APP_URL: "${APP_URL:-http://localhost:3000}"
|
||||
# HEALTHLOG_PROCESS_TYPE: "worker"
|
||||
# BACKUP_ENCRYPTION_KEY: "${BACKUP_ENCRYPTION_KEY:-}"
|
||||
# BACKUP_S3_ENDPOINT: "${BACKUP_S3_ENDPOINT:-}"
|
||||
# BACKUP_S3_BUCKET: "${BACKUP_S3_BUCKET:-}"
|
||||
# BACKUP_S3_ACCESS_KEY: "${BACKUP_S3_ACCESS_KEY:-}"
|
||||
# BACKUP_S3_SECRET_KEY: "${BACKUP_S3_SECRET_KEY:-}"
|
||||
# BACKUP_S3_REGION: "${BACKUP_S3_REGION:-auto}"
|
||||
# depends_on:
|
||||
# db:
|
||||
# condition: service_healthy
|
||||
# restart: unless-stopped
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: healthlog-db
|
||||
environment:
|
||||
POSTGRES_USER: healthlog
|
||||
POSTGRES_PASSWORD: healthlog
|
||||
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"
|
||||
POSTGRES_DB: healthlog
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
|
||||
+21
-1
@@ -1,6 +1,26 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# ── Validate required env vars ───────────────────────────────
|
||||
# We do this in the entrypoint instead of in docker-compose.yml's
|
||||
# environment block (which used to use the ${VAR:?error} syntax)
|
||||
# because some hosting platforms parse compose files eagerly and
|
||||
# end up storing the fallback error string as the literal value.
|
||||
# Validating here guarantees a clear, deploy-platform-agnostic
|
||||
# failure message before we touch the database.
|
||||
missing=""
|
||||
for var in DATABASE_URL SESSION_SECRET ENCRYPTION_KEY API_TOKEN_HMAC_KEY; do
|
||||
eval "value=\$$var"
|
||||
if [ -z "$value" ]; then
|
||||
missing="$missing $var"
|
||||
fi
|
||||
done
|
||||
if [ -n "$missing" ]; then
|
||||
echo "HealthLog: ERROR — required env vars not set:$missing" >&2
|
||||
echo "HealthLog: see .env.example for the full list and how to generate each value." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Wait for PostgreSQL ──────────────────────────────────────
|
||||
MAX_RETRIES=30
|
||||
RETRY=0
|
||||
@@ -15,7 +35,7 @@ until node -e "
|
||||
" 2>/dev/null; do
|
||||
RETRY=$((RETRY + 1))
|
||||
if [ "$RETRY" -ge "$MAX_RETRIES" ]; then
|
||||
echo "HealthLog: ERROR — Database not reachable after ${MAX_RETRIES}s, aborting."
|
||||
echo "HealthLog: ERROR — Database not reachable after ${MAX_RETRIES}s, aborting." >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# HealthLog API Spec
|
||||
|
||||
`openapi.yaml` is the canonical OpenAPI 3.1 description for the HealthLog
|
||||
Next.js API. It is the source of truth for the native iOS client's
|
||||
DTO codegen.
|
||||
|
||||
## Preview locally
|
||||
|
||||
Open an interactive Redoc preview in your browser:
|
||||
|
||||
```bash
|
||||
npx @redocly/cli preview docs/api/openapi.yaml
|
||||
```
|
||||
|
||||
Generate static HTML for sharing:
|
||||
|
||||
```bash
|
||||
npx @redocly/cli build-docs docs/api/openapi.yaml --output docs/api/index.html
|
||||
```
|
||||
|
||||
## Validate
|
||||
|
||||
```bash
|
||||
npx @redocly/cli lint docs/api/openapi.yaml
|
||||
```
|
||||
|
||||
The spec is expected to be lint-clean (errors = 0). Some warnings are
|
||||
intentional:
|
||||
|
||||
- `no-server-example.com` — `http://localhost:3000` is the dev server.
|
||||
- `operation-2xx-response` / `operation-4xx-response` — webhook + redirect
|
||||
endpoints intentionally omit one or the other.
|
||||
- `no-unused-components` — `ReminderPhase` is exposed for client codegen
|
||||
even though no path response references it directly.
|
||||
|
||||
## Layout
|
||||
|
||||
- **`info`** — title, version (mirrored from `package.json`), description.
|
||||
- **`servers`** — production `https://healthlog.bombeck.io` plus
|
||||
`http://localhost:3000` for local development.
|
||||
- **`tags`** — domain groupings (`Auth`, `Measurements`, `Medications`,
|
||||
`Mood`, `Dashboard`, `Insights`, `Achievements`, `DoctorReport`,
|
||||
`Integrations`, `User`, `Tokens`, `Notifications`, `Onboarding`,
|
||||
`Health`, `Settings`, `Analytics`, `Export`, `AuditLog`, `Feedback`,
|
||||
`BugReport`, `Monitoring`, `Webhooks`, `Ingest`, `Admin`).
|
||||
- **`securitySchemes`**:
|
||||
- `bearerAuth` — long-lived `hlk_*` API tokens issued via `POST /api/tokens`.
|
||||
Required on `/api/ingest/medication`.
|
||||
- `sessionCookie` — HttpOnly `healthlog_session` cookie set by the
|
||||
password and passkey login flows. Used by the web app and any iOS
|
||||
client running inside a web view.
|
||||
- **`components.schemas`** — DTOs for all routes plus the underlying
|
||||
Prisma models (e.g. `Measurement`, `Medication`, `MedicationIntakeEvent`,
|
||||
`MoodEntry`, `User`, `DashboardLayout`, `ApiEnvelope`, `ApiError`).
|
||||
- **`paths`** — all routes from `src/app/api/**/route.ts`, including
|
||||
the `/api/admin/*` family which is tagged `Admin` and marked internal.
|
||||
|
||||
## Conventions
|
||||
|
||||
- Successful responses use the envelope `{ data, error: null }`.
|
||||
- Error responses use the envelope `{ data: null, error: '<message>' }`.
|
||||
- Pagination uses `limit` + `offset` query parameters and a
|
||||
`meta: { total, limit, offset }` block on the response.
|
||||
- All timestamps are ISO 8601 / RFC 3339 (`format: date-time`) in UTC.
|
||||
- IDs are CUIDs (opaque strings).
|
||||
|
||||
## Updating the spec
|
||||
|
||||
When adding or changing routes:
|
||||
|
||||
1. Update the relevant route handler under `src/app/api/**/route.ts`.
|
||||
2. Mirror the change in `openapi.yaml`. Keep `operationId` camelCase and
|
||||
stable — the iOS codegen keys off it.
|
||||
3. Run `npx @redocly/cli lint docs/api/openapi.yaml` and resolve all
|
||||
errors. Warnings are tolerated when documented above.
|
||||
4. Bump `info.version` if the change is breaking for clients.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,235 @@
|
||||
# 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
|
||||
|
||||
## Strategische Decisions
|
||||
|
||||
1. **Onboarding-Redesign:** 4-Step-Wizard → 2-Step + Empty-State-Dashboard + Getting-Started-Card.
|
||||
2. **Custom-Measurement-Types:** Pragmatic Hardcoding (jetzt #89), Cronometer-Pattern als v1.5+-Roadmap.
|
||||
3. **`apiClient`-Refactor vor Onboarding-Redesign**, damit neues Onboarding nicht denselben Silent-Failure-Bug reproduziert.
|
||||
|
||||
---
|
||||
|
||||
## 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 | — |
|
||||
|
||||
**Reihenfolge:** 1 → 2 → 3 → **4 (apiClient)** → **3.5 (Onboarding nutzt apiClient)** → 4.5 → 4.6 → 5 → 6 → 7 → 8.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Setup-Notfall (≤4 h)
|
||||
|
||||
**Goal:** Self-Hoster kann mit `git clone && docker compose up -d` tatsächlich starten + Issue #87 stoppt zu blocken.
|
||||
|
||||
- `docker-compose.yml`: `expose:3000` → `ports: "3000:3000"` (DX-002)
|
||||
- `docker-compose.yml`: `POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:?required}"` (SEC-003)
|
||||
- `.env.example`: `localhost:5432` → `db:5432` (DX-003)
|
||||
- `package.json`: `"version": "1.2.0"` (DOC-007)
|
||||
- `CLAUDE.md` + `AGENTS.md`: 23 Models statt 21/22 (DOC-001)
|
||||
- `src/app/onboarding/page.tsx`: `await fetch(...)` mit `.ok`-Check + `toast.error()` (DX-005 Pflaster)
|
||||
- README „minutes"-Claim entschärfen (DX-006)
|
||||
|
||||
**Verification:** Frischer Clone + neue VM → `cp .env.example .env && set secrets && docker compose up -d` → App auf `http://localhost:3000` erreichbar in <5 min.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Docker-Image-Pipeline (~1 d)
|
||||
|
||||
**Goal:** Issue #88 vollständig schließen. Pre-built Image auf GHCR. Docs konsolidiert.
|
||||
|
||||
- `.github/workflows/docker-publish.yml`: Multi-Arch (amd64+arm64), Trigger auf Tag-Push + main, GHCR-Push mit GITHUB_TOKEN
|
||||
- `healthlog-docs/getting-started/installation.mdx` → Verweis auf `self-hosting/docker.mdx` statt Duplikat (DX-004)
|
||||
- Repo-`docker-compose.yml` vs Docs-`docker-compose.yml`: eine Variante als Single-Source-of-Truth (Compose verwendet `image:` aus GHCR; Doc-Snippet wird daraus generiert)
|
||||
- `SECURITY.md` + Image-Pull-Instructions
|
||||
- `healthlog-landing` prüfen ob „Quick Start" aktualisiert werden muss
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Issue #89: TBW + Bone Mass (~1 d)
|
||||
|
||||
**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`
|
||||
4. `src/lib/format.ts` + i18n-Keys (4-6 neue Keys)
|
||||
5. `src/lib/doctor-report-pdf.ts`: `TYPE_LABEL_KEYS`, `TYPE_UNIT_KEYS`, `vitalTypes`
|
||||
6. `src/components/measurements/measurement-form.tsx`: `MEASUREMENT_TYPES` Array
|
||||
7. `src/lib/withings/client.ts` + `sync.ts`: Mapping aus Withings `body_composition`
|
||||
8. `src/app/api/dashboard/widgets/route.ts` + `DEFAULT_DASHBOARD_LAYOUT`: 2 neue Widget-IDs
|
||||
9. healthlog-docs `health-metrics.mdx` aktualisieren
|
||||
10. Optional: `/api/ingest/measurements` Bearer-Endpoint für n8n (analog `/api/ingest/medication`)
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — `apiClient`-Wrapper (2-3 d)
|
||||
|
||||
**Goal:** Silent-Failure-Pattern strukturell eliminieren. Voraussetzung für 3.5.
|
||||
|
||||
- `src/lib/api-client.ts`: `apiClient.{get,post,put,delete}<T>(path, body?, schema?) → Result<T, ApiError>`
|
||||
- Auto-`.ok`-Check + Toast bei Fehler (mit i18n-Mapping)
|
||||
- Geteilte Zod-Schemas: Validations-Module in `src/lib/validations/` werden client-importable
|
||||
- Migration: alle ~50 `fetch(...)`-Aufrufe in `src/app/` und `src/components/` → `apiClient.*`
|
||||
- Tests für `apiClient` (Mock-Fetch, Error-Mapping, Schema-Validation)
|
||||
|
||||
---
|
||||
|
||||
## Phase 3.5 — Onboarding-Redesign (~2 d)
|
||||
|
||||
**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).
|
||||
|
||||
**Entfällt ersatzlos:** Targets-Step, Notifications-Teaser-Step, Front-loaded-Med-Wizard.
|
||||
|
||||
**Permissions deferred:** Notifications nur bei „Add medication → Reminder ein", PWA-Install nur bei Day-2-Return-Visit oder nach 3. Eintrag.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4.5 — Empty-State-Dashboard-Tiles (~1 d)
|
||||
|
||||
**Goal:** Mealie/Tandoor/Linear-Pattern. Jede Metric-Tile hat Empty-State mit „Add your first X"-CTA.
|
||||
|
||||
- `src/components/dashboard/EmptyStateTile.tsx` als wiederverwendbare Komponente
|
||||
- Pro `MeasurementType`: eigene Empty-State-Card (Icon + Headline + CTA-Button → opens MeasurementForm prefilled)
|
||||
- `Medications` analog: Empty-State „Add your first medication" → opens MedicationForm mit Default-Schedule (08:00–09:00 daily)
|
||||
- Dashboard-Routing: User mit `dashboardWidgetsJson` ohne Daten sieht Empty-States, nicht leeres Grid
|
||||
|
||||
---
|
||||
|
||||
## Phase 4.6 — „Getting Started"-Checklist (~4 h)
|
||||
|
||||
**Goal:** Linear-Pattern. Persistent dismissible Card auf Dashboard.
|
||||
|
||||
- `User.gettingStartedDismissedAt: DateTime?` (nullable, NULL = noch sichtbar)
|
||||
- 4 Items mit Live-Status:
|
||||
- ☐ Log your first measurement (✓ wenn ≥1 Measurement existiert)
|
||||
- ☐ Add a medication (✓ wenn ≥1 Medication existiert)
|
||||
- ☐ Set a target value (✓ wenn `thresholdsJson` non-empty)
|
||||
- ☐ Install HealthLog as App (✓ wenn `beforeinstallprompt` accepted, sonst nur sichtbar bei PWA-fähigen Browsern)
|
||||
- „Dismiss"-Button → setzt Timestamp, Card verschwindet
|
||||
- Vollständig durchgeklickt → Card verschwindet automatisch
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Security + Reliability Hardening (1-2 d)
|
||||
|
||||
- SEC-002: SSRF-IPv4-Parser-Härtung (führende Nullen)
|
||||
- SEC-004: GitHub-PAT-Logging-Sanitization (Sensitive-Field-Filter)
|
||||
- SEC-005: AES-256-GCM Key-Rotation-Doc + optional `keyVersion`
|
||||
- SEC-006: Custom-Thresholds-Rate-Limit
|
||||
- PERF-001: N+1 in `insights/comprehensive/route.ts:219` per `groupBy` auflösen
|
||||
- REL-002: Onboarding-Error-Handling über Pflaster hinaus (via apiClient aus Phase 4)
|
||||
- REL-003: pg-boss `process.on('SIGTERM', ...)` für graceful shutdown
|
||||
- LOG-001: 6× `console.log()` in `src/lib/jobs/reminder-worker.ts` → Wide-Event-Annotations
|
||||
- CI: ESLint+Typecheck `continue-on-error: true` → blocking
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Test-Coverage-Boost (1-2 d)
|
||||
|
||||
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)
|
||||
4. pg-boss Reminder-Worker (Queue-Init, Job-Execution, Cron, Retry)
|
||||
5. Withings-OAuth + Sync (`exchangeCode` → `refresh` → `fetchMeasurements`)
|
||||
|
||||
**Plus:** TEST-006 Custom-Thresholds-Resolution, TEST-007 AI-Provider-SSRF.
|
||||
|
||||
---
|
||||
|
||||
## 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)
|
||||
- A11Y-002: Admin StatusItem `aria-label` verifizieren
|
||||
- UX-002: Admin-Save-Error-UX (Field-Highlight bei Fehler)
|
||||
- UX-003: Bottom-Nav-Touch-Targets `p-2.5` → `p-3` (≥44×44 px)
|
||||
|
||||
---
|
||||
|
||||
## Phase 8 — Settings-Page-Splitting (2-3 d)
|
||||
|
||||
**Goal:** 3143 Zeilen → Sub-Components mit klarer Boundary.
|
||||
|
||||
- Research-Phase: Sub-Routes (`/settings/profile`, `/settings/notifications`, …) vs Sub-Components in einer Page?
|
||||
- Plan-Phase: Migrationsstrategie ohne URL-Bruch
|
||||
- Insights-Page (1750 Zeilen) folgt analogem Pattern
|
||||
|
||||
---
|
||||
|
||||
## 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 |
|
||||
|
||||
### 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 | — |
|
||||
|
||||
### Issue #74 (raw locale keys in UI)
|
||||
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)
|
||||
|
||||
## Anti-Patterns explizit vermieden
|
||||
|
||||
- Permissions-Cold-Prompt (Notifications/PWA) auf Day 1
|
||||
- Front-loaded Targets vor erster Messung
|
||||
- Multi-Step-Wizard mit identischem Skip-Button
|
||||
- `fetch()` ohne `.ok`-Check (apiClient löst strukturell)
|
||||
- Mega-Page-Pattern (Phase 8 löst Settings 3143 Z.)
|
||||
- Custom-Measurement-Types vorzeitig (40-60h Wartung/Jahr für 50% Feature-Loss)
|
||||
@@ -0,0 +1,57 @@
|
||||
# Doctor Report
|
||||
|
||||
The doctor report is a printable PDF summary of a user's tracked vitals,
|
||||
medication compliance, glucose context, and mood over a configurable window
|
||||
(default 90 days, max 365). It is intended as discussion material for a
|
||||
doctor's appointment, **not** a medical diagnosis.
|
||||
|
||||
## Two endpoints
|
||||
|
||||
The same aggregation logic backs both endpoints — `collectDoctorReportData`
|
||||
in `src/lib/doctor-report-data.ts` is the single source of truth — so the
|
||||
JSON payload and the server-rendered PDF are guaranteed to be in sync.
|
||||
|
||||
### `POST /api/doctor-report` — JSON payload
|
||||
|
||||
Returns the aggregated `DoctorReportData` envelope. Used by the PWA's
|
||||
client-side renderer (`src/lib/doctor-report-pdf.ts`, jsPDF + autotable).
|
||||
Best on desktop browsers and on Android, where local generation keeps the
|
||||
PDF off the wire and works offline (cached page).
|
||||
|
||||
- Auth: session required.
|
||||
- Rate-limit: 10/h per user.
|
||||
- Body: `{ days?: number }` (default 90).
|
||||
- Response: `200 application/json` with envelope `{ data, error: null }`.
|
||||
- Audit action: `doctor-report.generate`.
|
||||
|
||||
### `POST /api/doctor-report/pdf` — server-rendered PDF
|
||||
|
||||
Returns the ready-to-download PDF bytes. Used primarily on iOS/Safari, where
|
||||
the client-side jsPDF download UX is unreliable (Safari sometimes opens the
|
||||
PDF in a new tab without preserving the filename or fails the blob save).
|
||||
|
||||
- Auth: session required (`401` otherwise).
|
||||
- Rate-limit: 10/h per user, shared with the JSON endpoint (`429` on excess).
|
||||
- Body: `{ days?: number; locale?: "de" | "en" }`. Body is optional — empty
|
||||
bodies are accepted.
|
||||
- Locale resolution: explicit `body.locale` wins, then the
|
||||
`Accept-Language` header (`de*` → `de`, anything else → `en`), then the
|
||||
hard fallback `de`.
|
||||
- Response: `200 application/pdf` with
|
||||
`Content-Disposition: attachment; filename="healthlog-report-YYYY-MM-DD.pdf"`
|
||||
and `Cache-Control: no-store`. Body is the raw PDF bytes.
|
||||
- Errors: JSON envelope `{ data: null, error: "..." }` for `401` and `429`;
|
||||
`500` if rendering fails.
|
||||
- Audit action: `doctor-report.pdf.generate`.
|
||||
|
||||
## Implementation notes
|
||||
|
||||
- `src/lib/doctor-report-pdf-core.ts` contains the isomorphic renderer.
|
||||
jsPDF runs unchanged in Node — `doc.output("arraybuffer")` returns a valid
|
||||
`%PDF-` byte stream in both environments, so we deliberately avoid headless
|
||||
Chromium (Puppeteer/Playwright) to keep the runtime dependency surface low.
|
||||
- `src/lib/doctor-report-pdf.ts` is a thin client façade that returns the
|
||||
raw `jsPDF` instance so the existing settings-page download flow (which
|
||||
calls `doc.save(...)`) keeps working unchanged.
|
||||
- Server-side translations come from `messages/{de,en}.json` via
|
||||
`getServerTranslator()` (`src/lib/i18n/server-translator.ts`).
|
||||
@@ -0,0 +1,232 @@
|
||||
# Migrating from HealthLog 1.3 to 1.4
|
||||
|
||||
v1.4.0 is **drop-in for self-hosters running the GHCR image** — the
|
||||
two new migrations (`0025_refresh_tokens`, `0025_user_locale_drift_fix`)
|
||||
both apply on container start via `prisma migrate deploy`, and every
|
||||
new env var in §Environment variables below has a safe default. This
|
||||
guide documents what changes for operators and integrators, in case
|
||||
you're extending the API or scaling the deployment.
|
||||
|
||||
## TL;DR
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
That's it. The migrations are forward-compatible (`IF NOT EXISTS`
|
||||
guards), no env vars are _required_ to be added, and no API contract
|
||||
changes shape or status code.
|
||||
|
||||
## Database
|
||||
|
||||
- **`0025_refresh_tokens`** _(new)_ — adds the `refresh_tokens` table
|
||||
for the native-client 24h access-token / refresh-token rotation
|
||||
flow. Browser cookie sessions don't use it.
|
||||
- **`0025_user_locale_drift_fix`** _(new)_ — `ADD COLUMN IF NOT
|
||||
EXISTS users.locale TEXT` to backfill an environment that was kept
|
||||
in sync via `prisma db push` rather than `migrate deploy`. No-op
|
||||
on installations that were already current.
|
||||
- Both migrations are idempotent. A rollback to 1.3.3 keeps the
|
||||
schema usable; the dropped tables/columns are simply unused.
|
||||
|
||||
## Environment variables
|
||||
|
||||
### About surface
|
||||
|
||||
- **`NEXT_PUBLIC_APP_BUILD_SHA`** _(optional, new)_ — short Git SHA
|
||||
exposed through `/api/version`, rendered on Settings → About.
|
||||
- **`NEXT_PUBLIC_APP_BUILT_AT`** _(optional, new)_ — ISO-8601 build
|
||||
timestamp.
|
||||
|
||||
Both are optional; without them, the About surface renders
|
||||
"development" wording.
|
||||
|
||||
### Worker / web split
|
||||
|
||||
- **`HEALTHLOG_PROCESS_TYPE`** _(optional, default `all`)_ — set to
|
||||
`web` to disable the pg-boss worker (jobs queue but don't run), or
|
||||
`worker` to refuse all HTTP traffic with `503 + X-HealthLog-Process-Type:
|
||||
worker`. Default `all` keeps the single-container behaviour.
|
||||
Only relevant if you're scaling HTTP and the worker independently.
|
||||
|
||||
### Encryption-key versioning
|
||||
|
||||
- **`ENCRYPTION_KEYS`** _(optional, new)_ — JSON object mapping
|
||||
versioned key IDs to 64-char hex keys, e.g. `{"v1": "...", "v2":
|
||||
"..."}`. When present, ciphertexts are written with the
|
||||
`<keyId>.<base64>` format and decrypted by looking up the key ID.
|
||||
Existing 1.3-format ciphertexts (no key prefix) keep decrypting
|
||||
with `ENCRYPTION_KEY` indefinitely.
|
||||
- **`ENCRYPTION_ACTIVE_KEY_ID`** _(optional, new)_ — the key ID used
|
||||
for new writes. Must be a key in `ENCRYPTION_KEYS`. Defaults to
|
||||
the legacy single-key behaviour if either is unset.
|
||||
- **`ENCRYPTION_KEY`** _(unchanged)_ — still honoured as a fallback
|
||||
when `ENCRYPTION_KEYS` is empty. No action needed for existing
|
||||
installs.
|
||||
- Rotation: `pnpm tsx scripts/rotate-encryption-key.ts <newKeyId>`
|
||||
re-encrypts every ciphertext under the new key without downtime.
|
||||
Walk-through in `docs/ops/encryption-key-rotation.md`.
|
||||
|
||||
### Off-host backup target _(optional)_
|
||||
|
||||
If unset, the existing local `DataBackup` storage continues to work.
|
||||
|
||||
- **`BACKUP_S3_ENDPOINT`** — S3-compatible endpoint URL. Leave blank
|
||||
to disable off-host backup.
|
||||
- **`BACKUP_S3_BUCKET`** — destination bucket name.
|
||||
- **`BACKUP_S3_ACCESS_KEY`**, **`BACKUP_S3_SECRET_KEY`** — IAM
|
||||
credentials. Worker only requests `PutObject` + `GetObject` —
|
||||
never `DeleteObject`. Retention is the bucket's lifecycle policy.
|
||||
- **`BACKUP_S3_REGION`** _(default `auto`)_.
|
||||
- **`BACKUP_RETENTION_DAYS`** _(default `30`)_ — surfaced in the
|
||||
admin UI; the lifecycle rule on your bucket is the actual enforcer.
|
||||
- **`BACKUP_ENCRYPTION_KEY`** — separate 64-char hex key for the
|
||||
AES-GCM envelope of each upload. **Keep offline / in a vault.**
|
||||
Lose this and the off-host backups are unreadable.
|
||||
|
||||
### API token hashing _(unchanged but worth re-confirming)_
|
||||
|
||||
- **`API_TOKEN_HMAC_KEY`** — 64-char hex string. Used to HMAC
|
||||
Bearer tokens before storage so a DB leak can't be reversed via
|
||||
rainbow table. Same key now also keys the new `RefreshToken` row
|
||||
hashes. **Do not rotate without invalidating every native client.**
|
||||
|
||||
## API
|
||||
|
||||
### New
|
||||
|
||||
- **`GET /api/version`** — public, static, returns
|
||||
`{ version, buildSha, builtAt, license, repository, changelog, docs }`.
|
||||
No authentication.
|
||||
- **`POST /api/integrations/withings/test`** — admin-only probe
|
||||
against the saved Withings OAuth connection. Returns `{ ok, latency_ms }`
|
||||
on success.
|
||||
- **`POST /api/integrations/moodlog/test`** — admin-only HEAD
|
||||
request against `https://moodlog.app/api/health`. SSRF-guarded.
|
||||
- **`POST /api/notifications/web-push/test`** — sends a smoke push
|
||||
notification to the current user's subscribed devices.
|
||||
- **`POST /api/monitoring/glitchtip/test`** — fires a benign event
|
||||
at the configured DSN.
|
||||
- **`POST /api/monitoring/umami/test`** — verifies the script URL +
|
||||
website ID resolve and respond with the expected payload.
|
||||
- **`POST /api/admin/backup/test`** — admin-only round-trip against
|
||||
the configured `BACKUP_S3_*` credentials.
|
||||
- **`GET /api/admin/status-overview`** — admin-only aggregator
|
||||
returning the six-card summary the new admin top strip renders in
|
||||
one batched query.
|
||||
- **`POST /api/auth/refresh`** — native-client refresh-token
|
||||
rotation. Cookie-session browsers don't use this.
|
||||
- **`POST /api/auth/refresh/revoke`** — paired logout-on-device that
|
||||
flips both the refresh row and the matching `ApiToken` row to
|
||||
revoked.
|
||||
|
||||
All new admin endpoints are hidden from anonymous traffic by the
|
||||
proxy and rate-limited.
|
||||
|
||||
### Behaviour-only changes
|
||||
|
||||
- **`POST /api/ai/test`** now accepts an optional JSON body that
|
||||
mirrors the AI dropdown in `/settings`. Empty body keeps the
|
||||
pre-1.4 behaviour (test the saved provider). Non-empty body tests
|
||||
the unsaved selection without persisting plaintext keys. The
|
||||
existing rate limit (5/min) and error-leak shielding stay in place.
|
||||
- **`GET /api/dashboard/summary`** localises the `streak.label` and
|
||||
`greeting.salutation` fields against the user's stored locale.
|
||||
- **`POST /api/insights/targets`** uses a single batched query for
|
||||
per-type latest readings; returns identical payloads.
|
||||
|
||||
No `/api/...` route changed shape, status codes, or auth rules. iOS,
|
||||
n8n, and Health Connect integrations need no change.
|
||||
|
||||
## UI / UX
|
||||
|
||||
### Dashboard
|
||||
|
||||
- **Tile strip is always one row.** Previously the four-to-five-tile
|
||||
grid wrapped to a second row when many widgets were enabled. v1.4
|
||||
uses a horizontal-scroll strip — total width caps at the chart
|
||||
width below, snap points keep the touch experience deliberate. To
|
||||
reduce the visible tile count, open Settings → Dashboard Layout
|
||||
and toggle widgets off.
|
||||
|
||||
### Forms
|
||||
|
||||
- **Password managers no longer autofill health-data inputs.**
|
||||
Browsers and LastPass / 1Password were happily filling the user's
|
||||
saved account password into measurement notes, AI tokens, and
|
||||
similar free-text fields. The `<Input>` primitive now defaults to
|
||||
`autoComplete="off"` plus the LastPass / 1Password ignore
|
||||
attributes whenever the caller doesn't pass a semantic value. Auth
|
||||
and profile forms (login, register, change-email) keep autofilling
|
||||
normally because they pass an explicit `autoComplete` (e.g.
|
||||
`"username"`, `"email"`, `"new-password"`).
|
||||
|
||||
### Settings
|
||||
|
||||
- **AI provider connection-test honours the unsaved selection.** Pick
|
||||
a provider in the dropdown and click "Verbindung testen" without
|
||||
saving first — the test now runs against your in-form selection
|
||||
instead of the saved provider.
|
||||
|
||||
### Notifications
|
||||
|
||||
- **Telegram, ntfy, and Web Push reminders follow your locale.**
|
||||
Switching the user's locale to English now produces English
|
||||
reminders; the previous build always sent German strings regardless.
|
||||
Telegram callback IDs stay stable (English) so the bot dispatcher
|
||||
keeps matching them.
|
||||
|
||||
## Medical defaults
|
||||
|
||||
- **Diastolic blood-pressure orange band no longer reaches 60 mmHg.**
|
||||
A reading of 60 mmHg now lands in red instead of yellow. The
|
||||
user-override path is intact — if you set a personal threshold via
|
||||
`/settings#thresholds`, it still applies and is audit-logged.
|
||||
- **Body-fat classifier mirrors the ACE table** — readings below the
|
||||
ACE essential floor (M < 2 / F < 10) now classify as
|
||||
"Below essential" (danger), the actual essential band (M 2–5 /
|
||||
F 10–13) as warning, and the realistic healthy range
|
||||
(M 14–24 / F 21–31) as the green band on the dashboard target tile.
|
||||
- **Step target unified at 8 000–15 000.** The two surfaces that
|
||||
previously disagreed (7 000–10 000 in `getStepsRange()` versus
|
||||
8 000–15 000 in the effective-range resolver) now use the same
|
||||
band, anchored on Saint-Maurice et al., JAMA 2020.
|
||||
|
||||
## URL changes
|
||||
|
||||
- **Settings has moved to per-section routes.** `/settings#integrations`
|
||||
→ `/settings/integrations`, etc. The legacy `/settings#anchor` URLs
|
||||
still 308-redirect to the new routes; sidebar deep-links and the
|
||||
`<a href="/settings#thresholds">` patterns from 1.3 keep working.
|
||||
|
||||
## What landed in v1.4 vs v1.4.1
|
||||
|
||||
The v1.3.3 ecosystem audit identified architectural rewrites tracked
|
||||
through v1.4. Status as of the v1.4.1 cycle:
|
||||
|
||||
| Bucket | Status |
|
||||
| -------------------------------------------------------------------------------------- | ------------------- |
|
||||
| Settings page split into `/settings/[section]` | ✓ shipped in v1.4.0 |
|
||||
| Admin status-first card grid + aggregator endpoint | ✓ shipped in v1.4.0 |
|
||||
| AI insights rework — severity hero + allowlisted inline charts | ✓ shipped in v1.4.0 |
|
||||
| Multi-tenant prep (off-host backups, key versioning, worker/web split, refresh tokens) | ✓ shipped in v1.4.0 |
|
||||
| Per-section admin component extraction (inner monolith → one file per panel) | ✓ shipped in v1.4.1 |
|
||||
| Postgres-backed integration test suite (testcontainers) | ✓ shipped in v1.4.1 |
|
||||
| Playwright + axe-core E2E foundation | ✓ shipped in v1.4.1 |
|
||||
|
||||
## Rollback
|
||||
|
||||
If 1.4.0 surfaces a regression on your install, redeploy the previous
|
||||
1.3.3 image:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
docker pull ghcr.io/mbombeck/healthlog:1.3.3
|
||||
sed -i 's/healthlog:1.4.0/healthlog:1.3.3/' docker-compose.yml
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The schema is unchanged so a rollback does not require a database
|
||||
restore. File a GitHub issue with the symptom — the v1.4 cycle ran
|
||||
multi-agent QA per phase, but no migration is risk-free.
|
||||
@@ -0,0 +1,87 @@
|
||||
# Off-host backup & restore (v1.4 G1)
|
||||
|
||||
HealthLog ships with an optional daily off-host backup that ships every
|
||||
user's JSON dump, encrypted with AES-256-GCM under a SEPARATE key
|
||||
(`BACKUP_ENCRYPTION_KEY`), to any S3-compatible bucket — Cloudflare R2,
|
||||
AWS S3, Backblaze B2, MinIO, etc.
|
||||
|
||||
The backup runs at **02:30 Europe/Berlin** every day from the worker
|
||||
container (queue `data-backup-offhost`). Object key layout:
|
||||
|
||||
```
|
||||
<bucket>/YYYY-MM-DD/user-<userId>.json.enc
|
||||
```
|
||||
|
||||
## Wire format (binary)
|
||||
|
||||
```
|
||||
magic = "HLBK" (4 bytes, ASCII)
|
||||
version = 0x01 (1 byte)
|
||||
iv = 12 random bytes (AES-GCM nonce)
|
||||
authTag = 16 bytes (AES-GCM tag)
|
||||
ciphertext = N bytes (AES-256-GCM, key = BACKUP_ENCRYPTION_KEY)
|
||||
plaintext = JSON dump (UTF-8)
|
||||
```
|
||||
|
||||
## Required env vars
|
||||
|
||||
| Var | Required | Notes |
|
||||
| ----------------------- | -------- | -------------------------------------------------------------------- |
|
||||
| `BACKUP_ENCRYPTION_KEY` | yes | 64 hex chars or 32-byte base64. **Different from `ENCRYPTION_KEY`.** |
|
||||
| `BACKUP_S3_ENDPOINT` | yes | e.g. `https://<account>.r2.cloudflarestorage.com` |
|
||||
| `BACKUP_S3_BUCKET` | yes | |
|
||||
| `BACKUP_S3_ACCESS_KEY` | yes | |
|
||||
| `BACKUP_S3_SECRET_KEY` | yes | |
|
||||
| `BACKUP_S3_REGION` | no | defaults to `auto` (Cloudflare R2) |
|
||||
| `BACKUP_RETENTION_DAYS` | no | defaults to `30` |
|
||||
|
||||
## Bucket lifecycle (recommended)
|
||||
|
||||
The worker prunes objects older than `BACKUP_RETENTION_DAYS`, but the
|
||||
storage provider's lifecycle rule is the canonical safety net:
|
||||
|
||||
```
|
||||
Filter: "" (all objects)
|
||||
Action: Expire after 30 days
|
||||
```
|
||||
|
||||
For Cloudflare R2 add this from the bucket's **Settings → Lifecycle**.
|
||||
|
||||
## Smoke test
|
||||
|
||||
After deploying, hit `POST /api/admin/backup/test` (admin-only). It
|
||||
performs a 1-byte PUT + GET round-trip and returns:
|
||||
|
||||
```json
|
||||
{
|
||||
"endpoint": "https://...r2.cloudflarestorage.com",
|
||||
"bucket": "healthlog-backups",
|
||||
"region": "auto",
|
||||
"putLatencyMs": 142,
|
||||
"getLatencyMs": 38,
|
||||
"ok": true
|
||||
}
|
||||
```
|
||||
|
||||
The credentials are never returned.
|
||||
|
||||
## Restore
|
||||
|
||||
Pick a key (e.g. `2026-05-08/user-clx123.json.enc`) from the bucket
|
||||
and run:
|
||||
|
||||
```bash
|
||||
BACKUP_S3_ENDPOINT=https://...r2.cloudflarestorage.com \
|
||||
BACKUP_S3_BUCKET=healthlog-backups \
|
||||
BACKUP_S3_ACCESS_KEY=... \
|
||||
BACKUP_S3_SECRET_KEY=... \
|
||||
BACKUP_S3_REGION=auto \
|
||||
BACKUP_ENCRYPTION_KEY=$(openssl rand -hex 32) \
|
||||
pnpm tsx scripts/restore-backup.ts \
|
||||
2026-05-08/user-clx123.json.enc \
|
||||
/tmp/restored.json
|
||||
```
|
||||
|
||||
The script downloads the object, decrypts it, and writes the JSON dump
|
||||
to disk. Importing the JSON back into a HealthLog instance is left to
|
||||
the operator (use `prisma db seed` or a custom script).
|
||||
@@ -0,0 +1,107 @@
|
||||
# Encryption-key rotation
|
||||
|
||||
HealthLog encrypts sensitive at-rest data (Withings tokens, AI provider keys,
|
||||
notification channel configs, web-push subscription secrets, VAPID private
|
||||
keys, etc.) with AES-256-GCM under a per-deployment key. v1.4 introduces a
|
||||
versioned key format so the key can be rotated without downtime and without
|
||||
re-encrypting every row by hand.
|
||||
|
||||
## Format
|
||||
|
||||
| Layout | Marker | Where |
|
||||
| --------------- | ----------------------------------------------- | --------------------------- |
|
||||
| Versioned (new) | `<keyId>.<base64(iv \|\| tag \|\| ciphertext)>` | All new writes |
|
||||
| Legacy (v1.3.x) | `<base64(iv \|\| tag \|\| ciphertext)>` | Existing rows until rotated |
|
||||
|
||||
`<keyId>` matches `[A-Za-z0-9_-]{1,32}` and indexes into the `ENCRYPTION_KEYS`
|
||||
JSON map. Decryption tries the versioned format first; if no `.` is present
|
||||
or the prefix isn't a known id, the row is treated as legacy and decrypted
|
||||
under `v1` (the synthetic id assigned to the existing `ENCRYPTION_KEY`).
|
||||
|
||||
> **Why a separate `v1` is required.** Legacy ciphertexts have no key id, so
|
||||
> the only way to identify them is "no `.` in the value". If you remove the
|
||||
> `v1` entry from `ENCRYPTION_KEYS` _before_ every legacy row has been
|
||||
> rotated, those rows can't be decrypted any more — the active key won't
|
||||
> match the original ciphertext. The decrypt path now refuses to silently
|
||||
> fall back to the active key in that scenario; it throws a clear error.
|
||||
|
||||
## Rotating from v1.3.x to a new key
|
||||
|
||||
1. **Generate the new key** on a machine with `openssl`:
|
||||
```
|
||||
openssl rand -hex 32
|
||||
```
|
||||
2. **Update environment variables.** Keep the existing `ENCRYPTION_KEY` in
|
||||
place — it's still needed to decrypt legacy rows during the transition:
|
||||
```
|
||||
ENCRYPTION_KEY="<old key, unchanged>"
|
||||
ENCRYPTION_KEYS='{"v1":"<old key>","v2":"<new key>"}'
|
||||
ENCRYPTION_ACTIVE_KEY_ID="v2"
|
||||
```
|
||||
Restart the app. New writes are now encrypted under `v2`; existing
|
||||
`v1`-keyed and legacy bare rows still decrypt because the `v1` entry is
|
||||
retained.
|
||||
3. **Run the rotation script** to re-encrypt every existing encrypted column
|
||||
under the new active key:
|
||||
```
|
||||
pnpm tsx scripts/rotate-encryption-key.ts v2
|
||||
```
|
||||
The script is idempotent — running it again is a no-op for rows already
|
||||
prefixed with `v2.`. It rotates User, WithingsConnection, AppSettings,
|
||||
NotificationChannel, and PushSubscription rows. The summary line at the
|
||||
end shows `scanned`, `rotated`, and `errors` per table+field; treat
|
||||
`errors > 0` as a hard failure and re-run after fixing the cause.
|
||||
4. **Drop the old key (optional, recommended).** Once the script has run
|
||||
cleanly:
|
||||
```
|
||||
ENCRYPTION_KEYS='{"v2":"<new key>"}'
|
||||
ENCRYPTION_ACTIVE_KEY_ID="v2"
|
||||
# ENCRYPTION_KEY can now be removed
|
||||
```
|
||||
Restart. The legacy single-key fallback is now disconnected; only `v2`
|
||||
exists.
|
||||
|
||||
## Adding a third key (v2 → v3)
|
||||
|
||||
Same procedure, just shift the labels — keep `v2` in the map until the
|
||||
script reports zero `v2.`-prefixed rows remaining.
|
||||
|
||||
```
|
||||
ENCRYPTION_KEYS='{"v2":"<old>","v3":"<new>"}'
|
||||
ENCRYPTION_ACTIVE_KEY_ID="v3"
|
||||
pnpm tsx scripts/rotate-encryption-key.ts v3
|
||||
ENCRYPTION_KEYS='{"v3":"<new>"}'
|
||||
```
|
||||
|
||||
## Rollback
|
||||
|
||||
> **Important.** Once the rotation script has run, ciphertexts in the
|
||||
> database start with `v2.` (or whatever the active id is). The pre-PR
|
||||
> v1.3.x image cannot read that prefix — it expects bare base64 — and
|
||||
> calling `decrypt()` on those rows will throw.
|
||||
|
||||
If you need to revert to the pre-rotation image:
|
||||
|
||||
- Either keep the new image. The new code reads both formats, so most
|
||||
rollback scenarios don't need to undo rotation.
|
||||
- Or, if you must run the old code, restore a database backup taken
|
||||
_before_ the rotation script ran. There is no script to convert
|
||||
`v2.`-prefixed rows back to legacy format — by design, rotation is a
|
||||
forward-only operation.
|
||||
|
||||
This is why we recommend running the rotation in a window where you have a
|
||||
fresh DB snapshot and the new image has been smoke-tested in production for
|
||||
at least 24 hours.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- `Encryption key id 'v1' is not configured` — the database still contains
|
||||
`v1.`-prefixed rows but `v1` was removed from `ENCRYPTION_KEYS`. Re-add
|
||||
the key, run the rotation script, then remove again.
|
||||
- `Found a legacy-format ciphertext but no v1 key is configured` — same
|
||||
cause for legacy bare-base64 rows. Restore `ENCRYPTION_KEY` (or add a
|
||||
`v1` entry to `ENCRYPTION_KEYS`) and run the rotation script before
|
||||
removing it.
|
||||
- `Refusing to rotate: argv key id ... does not match the currently active
|
||||
id ...` — pass the same id you set in `ENCRYPTION_ACTIVE_KEY_ID`. The
|
||||
guard prevents accidental re-encryption to a non-current key.
|
||||
@@ -0,0 +1,6 @@
|
||||
-- 0025_refresh_tokens — down migration (v1.4 G4 rollback).
|
||||
-- Forward-compatible: the up migration only ADDS a table, so this is a
|
||||
-- safe DROP. Any in-flight refresh tokens are invalidated; native clients
|
||||
-- need to re-authenticate.
|
||||
|
||||
DROP TABLE IF EXISTS "refresh_tokens";
|
||||
@@ -0,0 +1,260 @@
|
||||
# v1.4.x follow-up issues
|
||||
|
||||
Items identified by the v1.4.1 audit pass that warrant deeper work
|
||||
than a hardening release should land. Each one carries enough context
|
||||
that a future contributor can pick it up without re-running the audit.
|
||||
|
||||
## Security — deferred
|
||||
|
||||
### S-FOLLOW-1 — Idempotency-Key concurrent duplicate side-effects (HIGH)
|
||||
|
||||
Audit reference: `~/infra/reports/v14-security-audit.md` H1.
|
||||
|
||||
The `withIdempotency()` wrapper currently does
|
||||
`findCached → handler → persistCached`. Two requests with the same
|
||||
`Idempotency-Key` arriving in parallel both miss the SELECT, both
|
||||
execute the handler (duplicate measurements / intake events), and
|
||||
one INSERT loses on the unique constraint silently. The CLAUDE.md
|
||||
contract ("retries with the same Idempotency-Key … replay the
|
||||
original response — no second side-effect") is therefore violated
|
||||
whenever a flaky mobile client double-sends.
|
||||
|
||||
**Fix shape**: acquire the slot before the handler runs.
|
||||
`INSERT … ON CONFLICT DO NOTHING RETURNING id` reserving the
|
||||
`(userId, key, method, path)` row with a sentinel
|
||||
`responseStatus = 0`, then UPDATE on completion; concurrent losers
|
||||
SELECT-loop with backoff for the row to be filled. Or wrap the
|
||||
whole request in a Postgres advisory lock keyed by hash(userId, key)
|
||||
via `pg_advisory_xact_lock`.
|
||||
|
||||
Why deferred: needs a careful integration test against the new
|
||||
testcontainers suite (two concurrent inserts → exactly one handler
|
||||
invocation) and a public-API contract check that the
|
||||
sentinel-status row never bleeds into a 2xx replay. Fits a v1.4.2
|
||||
PR scope on its own.
|
||||
|
||||
---
|
||||
|
||||
### S-FOLLOW-2 — Encryption key fallback NODE_ENV gate (MEDIUM)
|
||||
|
||||
Audit reference: `~/infra/reports/v14-security-audit.md` M1.
|
||||
|
||||
`src/lib/crypto.ts:61-74` only requires the 64-hex production key
|
||||
when `process.env.NODE_ENV === "production"`. A fresh Docker run
|
||||
that forgets to set `NODE_ENV=production` (raw `node …` invocations,
|
||||
some operator setups) silently accepts a 32-character key and pads
|
||||
it deterministically with a SHA-256 of itself, halving the entropy
|
||||
and making the resulting key recoverable from any leaked partial.
|
||||
|
||||
**Fix shape**: invert the gate to `NODE_ENV !== "development" &&
|
||||
NODE_ENV !== "test"` — fail-closed by default — or require an
|
||||
explicit `ALLOW_WEAK_DEV_KEYS=1` opt-in.
|
||||
|
||||
Why deferred: needs a migration note for self-hosters whose `.env`
|
||||
files lack `NODE_ENV` (default Coolify deploys do set it; bespoke
|
||||
setups may not). Worth a release note + .env.example call-out.
|
||||
|
||||
---
|
||||
|
||||
### S-FOLLOW-3 — Refresh-token reuse-detection serialisation (MEDIUM)
|
||||
|
||||
Audit reference: `~/infra/reports/v14-security-audit.md` M2.
|
||||
|
||||
`src/lib/auth/refresh-token.ts` reuse-detection is a `findUnique`
|
||||
followed by a non-transactional `findMany + updateMany`. Concurrent
|
||||
first-time refreshes can briefly have both clients holding
|
||||
unrevoked access tokens before the loser's are revoked, and a
|
||||
parallel reuse attempt doubles the audit-log noise.
|
||||
|
||||
**Fix shape**: wrap the rotation in a single `prisma.$transaction`
|
||||
with `Serializable` isolation, OR reorder to "claim first, mint
|
||||
second": `prisma.refreshToken.update({ where: { id, usedAt: null } })`
|
||||
fails atomically if the row was already consumed; only after the
|
||||
update succeeds, call `issueAccessAndRefresh`.
|
||||
|
||||
Why deferred: needs an integration test that fires N parallel
|
||||
refreshes for the same token and asserts exactly one new pair is
|
||||
issued. The testcontainers suite is the right home; v1.4.2 PR.
|
||||
|
||||
---
|
||||
|
||||
### S-FOLLOW-4 — moodLog webhook HMAC lookup column (MEDIUM)
|
||||
|
||||
Audit reference: `~/infra/reports/v14-security-audit.md` M3.
|
||||
|
||||
`src/app/api/integrations/moodlog/webhook/route.ts` performs an
|
||||
O(n) AES-GCM decrypt sweep across all enabled users for every
|
||||
webhook hit. Side-channels: timing oracle for ordering, DoS
|
||||
amplification (30/min IP rate-limit × n decrypts).
|
||||
|
||||
**Fix shape**: add a `moodLogWebhookSecretLookupHash` column on
|
||||
`User` (HMAC-SHA-256 keyed by `API_TOKEN_HMAC_KEY`, populated
|
||||
alongside the encrypted secret on every write). Webhook does a
|
||||
single indexed `findUnique` on the lookup hash, bypassing the
|
||||
candidate iteration. The encryption stays as defence-in-depth.
|
||||
|
||||
Why deferred: schema migration required. Backfill needs a one-off
|
||||
script that decrypts every existing row and writes the lookup
|
||||
hash. Owners need to approve the migration window. v1.4.2 or
|
||||
v1.5.
|
||||
|
||||
---
|
||||
|
||||
### S-FOLLOW-5 — moodLog `readMoodLogSecret` legacy fallback (MEDIUM)
|
||||
|
||||
Audit reference: `~/infra/reports/v14-security-audit.md` M4.
|
||||
|
||||
If `decrypt()` throws inside `src/lib/moodlog-secret.ts:27-37`, the
|
||||
function returns the stored value as-is. Intent: legacy plaintext
|
||||
rows keep working. Side effect: an attacker with read access to
|
||||
the DB obtains the ciphertext blob and can submit it as the literal
|
||||
webhook secret.
|
||||
|
||||
**Fix shape**: detect "looks like a v1.4 envelope"
|
||||
(`/^[A-Za-z0-9_-]{1,32}\..+$/`) and refuse the legacy fallback for
|
||||
envelope-shaped values. Log a metric so the rotation-on-write
|
||||
contract can be verified to drain.
|
||||
|
||||
Why deferred: needs a small data-audit run to confirm no production
|
||||
row has a non-envelope value before the strict check goes live.
|
||||
|
||||
---
|
||||
|
||||
### S-FOLLOW-6 — Restore script writes decrypted JSON with default permissions (MEDIUM)
|
||||
|
||||
Audit reference: `~/infra/reports/v14-security-audit.md` M5.
|
||||
|
||||
`scripts/restore-backup.ts:73-122` writes the decrypted JSON via
|
||||
`writeFileSync(out, plaintext, "utf8")` with default mode (0644
|
||||
typical). The file contains decrypted PHI for the restored user.
|
||||
|
||||
**Fix shape**:
|
||||
`writeFileSync(out, plaintext, { encoding: "utf8", mode: 0o600 })`.
|
||||
Optional: warn if `out` is in a world-readable directory.
|
||||
|
||||
Why deferred: tiny one-line fix; bundling with the next ops-script
|
||||
update keeps the PR coherent.
|
||||
|
||||
---
|
||||
|
||||
## Performance — deferred
|
||||
|
||||
### P-FOLLOW-1 — Recharts top-level import on `/insights` (P0)
|
||||
|
||||
Audit reference: `~/infra/reports/v14-performance-audit.md` P0.
|
||||
|
||||
`src/app/insights/page.tsx:42-50` static-imports
|
||||
`{ ScatterChart, Scatter, XAxis, … } from "recharts"` even though
|
||||
`HealthChart` and `MoodChart` 8 lines above are already wrapped in
|
||||
`dynamic(…, { ssr: false })`. The static import pulls the whole
|
||||
recharts bundle into the route's first JS payload, defeating the
|
||||
dynamic split.
|
||||
|
||||
**Fix shape**: extract the BP-mood scatter into
|
||||
`src/components/charts/bp-mood-scatter.tsx` and import it via
|
||||
`dynamic(…, { ssr: false })` like the others.
|
||||
|
||||
Why deferred: trivial mechanical change but needs a Lighthouse
|
||||
before/after to quantify the bundle-size win for the v1.4.2
|
||||
release notes.
|
||||
|
||||
---
|
||||
|
||||
### P-FOLLOW-2 — `/api/insights/targets` unbounded glucose history scan (P0)
|
||||
|
||||
Audit reference: `~/infra/reports/v14-performance-audit.md` P0.
|
||||
|
||||
`src/app/api/insights/targets/route.ts:600-604` pulls every glucose
|
||||
measurement the user ever logged (no `gte: thirtyDaysAgo`) and
|
||||
filters in JS. Diabetic users with multi-year history will
|
||||
materialise tens of thousands of rows on every targets fetch.
|
||||
|
||||
**Fix shape**: split into "latest per context"
|
||||
(`distinct: ["glucoseContext"], orderBy: { measuredAt: "desc" }`)
|
||||
and a `measuredAt: { gte: thirtyDaysAgo }` window query, mirroring
|
||||
the pattern at lines 102-121 for `latestEverByType`.
|
||||
|
||||
Why deferred: needs a per-context test asserting the new query
|
||||
returns the same payload shape. Pairs nicely with the MoodEntry
|
||||
index migration (P-FOLLOW-3).
|
||||
|
||||
---
|
||||
|
||||
### P-FOLLOW-3 — `MoodEntry @@index` is on `(userId, date)` not `(userId, moodLoggedAt)` (P0)
|
||||
|
||||
Audit reference: `~/infra/reports/v14-performance-audit.md` P0.
|
||||
|
||||
`prisma/schema.prisma:438` declares `@@index([userId, date])`, but
|
||||
active queries sort/filter by `moodLoggedAt`:
|
||||
|
||||
- `src/app/api/insights/targets/route.ts:495`
|
||||
- `src/app/api/insights/comprehensive/route.ts:52`
|
||||
- `src/app/api/export/route.ts:87`
|
||||
|
||||
The unique constraint `@@unique([userId, date, moodLoggedAt])` is
|
||||
not selectable by Postgres for windowed `moodLoggedAt`-range reads.
|
||||
|
||||
**Fix shape**: add `@@index([userId, moodLoggedAt])` (do NOT drop
|
||||
the existing index — the unique still serves the moodlog webhook
|
||||
upsert path). New migration `0026_moodentry_moodloggedat_index`.
|
||||
|
||||
Why deferred: schema migration. Owner approval for the migration
|
||||
window.
|
||||
|
||||
---
|
||||
|
||||
### P-FOLLOW-4 to -7 — P1 / P2 perf items
|
||||
|
||||
- N+1 count() per medication on admin reminder-check
|
||||
(`src/app/api/admin/notifications/reminder-check/route.ts:84`).
|
||||
Replace with `groupBy` on `medicationIntakeEvent`.
|
||||
- `/api/analytics` 8-parallel full-history scan
|
||||
(`src/app/api/analytics/route.ts:20-40`). Single `findMany` with
|
||||
`type: { in: types }` and JS-side group.
|
||||
- `/api/import` sequential `prisma.measurement.create` per row
|
||||
(`src/app/api/import/route.ts:77-96`). Replace with
|
||||
`createMany({ skipDuplicates: true })`.
|
||||
- `pairByTimestamp` not used in `/api/analytics` BP correlation
|
||||
(`src/app/api/analytics/route.ts:80-86`). Replace the quadratic
|
||||
loop with the helper from `@/lib/analytics/correlations`.
|
||||
|
||||
---
|
||||
|
||||
## Test-quality polish (5 minor items)
|
||||
|
||||
Audit reference: `~/infra/reports/v14-test-theatre-audit.md`.
|
||||
|
||||
- `src/app/api/gamification/__tests__/ios-format.test.ts` L111-112:
|
||||
upgrade `toBeDefined()` to shape predicates.
|
||||
- `src/lib/analytics/__tests__/classifications.test.ts` L169-170:
|
||||
add `toMatch(/60/)` body-content assertion to compliance-alert tests.
|
||||
- `src/lib/__tests__/idempotency.test.ts` L145: upgrade bare
|
||||
`toHaveBeenCalled()` to `toHaveBeenCalledWith({ where: …key… })`.
|
||||
- `src/app/api/telegram/webhook/__tests__/route.test.ts` L457: same
|
||||
shape — upgrade or drop.
|
||||
|
||||
---
|
||||
|
||||
## i18n hardcoded strings
|
||||
|
||||
Audit reference: `~/infra/reports/v14-i18n-drift-audit.md`.
|
||||
|
||||
35+ hardcoded strings in `src/components/settings/ai-section.tsx`
|
||||
need to be moved into `messages/{en,de}.json` keys. The settings
|
||||
section was extracted in #143 with strings inline as a known
|
||||
follow-up; this is the cleanup PR.
|
||||
|
||||
Why deferred: low blast-radius (only the AI provider settings page
|
||||
shows English to a German user; the dashboard, dialogs, and
|
||||
critical UX paths all flow through `t(…)`). v1.4.x or v1.5.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
These are the items the v1.4.1 audit identified that the hardening
|
||||
release deliberately did NOT take. The audit also found 0 hallucinated
|
||||
medical claims, 0 i18n key-parity gaps, and the test theatre rate
|
||||
across the entire codebase was low (5 minor patterns). The codebase
|
||||
is in healthy shape; this list is the punch-list for the next
|
||||
proactive iteration, not a backlog of bugs we know about and ignore.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Scaling: web/worker container split (v1.4 G3)
|
||||
|
||||
The default `docker compose up` launches a single container that runs
|
||||
both the Next.js HTTP server AND the pg-boss worker. That works fine
|
||||
for a personal deployment but is hard to scale horizontally because
|
||||
every replica also runs the full reminder/insight cron schedule.
|
||||
|
||||
v1.4 introduces an environment-variable gate so the same image can be
|
||||
deployed in three modes:
|
||||
|
||||
| `HEALTHLOG_PROCESS_TYPE` | Web | Worker | Use when |
|
||||
| ------------------------ | --- | ------ | ------------------------- |
|
||||
| `all` (default) | ✅ | ✅ | single-host self-hosting |
|
||||
| `web` | ✅ | ❌ | scaling HTTP horizontally |
|
||||
| `worker` | ❌ | ✅ | dedicated job worker |
|
||||
|
||||
## How to split
|
||||
|
||||
1. Edit `docker-compose.yml`: set `HEALTHLOG_PROCESS_TYPE=web` on the
|
||||
`app` service and uncomment the `app-worker` service block at the
|
||||
bottom (it ships with `HEALTHLOG_PROCESS_TYPE=worker` baked in).
|
||||
2. Make sure both containers see the same `DATABASE_URL`,
|
||||
`ENCRYPTION_KEY` (or `ENCRYPTION_KEYS`), `API_TOKEN_HMAC_KEY`,
|
||||
and `SESSION_SECRET`.
|
||||
3. `docker compose up -d --build`.
|
||||
|
||||
Both containers connect to the same Postgres. pg-boss claims jobs
|
||||
atomically via row-level locking, so it is safe to run multiple worker
|
||||
replicas — they will share the load.
|
||||
|
||||
## Healthchecks
|
||||
|
||||
- `app` (web) healthcheck: `wget /api/version` every 30s.
|
||||
- `app-worker` does not expose a port; its liveness is reported into
|
||||
the `worker_status` table on every reminder pass and surfaced through
|
||||
`/api/admin/worker-status`.
|
||||
|
||||
The web container does NOT depend on the worker, and vice versa, so
|
||||
neither container's startup can deadlock the other. The shared
|
||||
dependency is Postgres; both wait on `db: condition: service_healthy`.
|
||||
|
||||
## Caveats
|
||||
|
||||
- Background tasks that touch user-scoped Wide Events (e.g. reminder
|
||||
notifications) emit telemetry from the worker; configure `LOKI_*`
|
||||
env vars on the worker if you want them.
|
||||
- The off-host backup job runs in the worker only. The admin
|
||||
`POST /api/admin/backup/test` endpoint runs in the web container and
|
||||
exercises the same S3 credentials — set `BACKUP_*` env vars on BOTH.
|
||||
- The startup gate (`assertSubsystemEnabled`) refuses to boot the
|
||||
reminder worker when `HEALTHLOG_PROCESS_TYPE=web`, so an accidental
|
||||
cross-mode invocation aborts immediately instead of silently doubling
|
||||
cron load.
|
||||
@@ -0,0 +1,464 @@
|
||||
# HealthLog UI Guidelines
|
||||
|
||||
> Single source of truth for visual & interaction patterns in the HealthLog
|
||||
> PWA. Consult this document before adding a new component, page, or
|
||||
> setting. If a pattern below doesn't fit your case, propose an addition
|
||||
> here in the same PR.
|
||||
|
||||
Last updated: 2026-05-08 (v1.4 cycle)
|
||||
|
||||
---
|
||||
|
||||
## 1. Foundations
|
||||
|
||||
### 1.1 Theme
|
||||
|
||||
HealthLog is a Dracula-themed app with a light counterpart (Alucard). Both
|
||||
themes share the same six-color signal palette (purple, pink, green, cyan,
|
||||
orange, red, yellow, lavender), exposed as CSS variables and as Tailwind
|
||||
`dracula-*` color tokens. Use the semantic tokens (`--primary`,
|
||||
`--destructive`, `--card`, …) for chrome and the `--dracula-*` tokens for
|
||||
data viz only.
|
||||
|
||||
| Surface | Token |
|
||||
| ---------------------------- | ------------------ |
|
||||
| App background | `--background` |
|
||||
| Card / popover surface | `--card` |
|
||||
| Primary action | `--primary` |
|
||||
| Destructive action | `--destructive` |
|
||||
| Secondary / muted background | `--secondary` |
|
||||
| Border | `--border` |
|
||||
| Focus ring | `--ring` |
|
||||
| Chart series 1–5 | `--chart-1`…`-5` |
|
||||
| Brand signal: purple | `--dracula-purple` |
|
||||
| Brand signal: green (good) | `--dracula-green` |
|
||||
| Brand signal: orange (warn) | `--dracula-orange` |
|
||||
| Brand signal: red (alert) | `--dracula-red` |
|
||||
| Brand signal: cyan (info) | `--dracula-cyan` |
|
||||
|
||||
Dark mode is the default. The theme switcher in the sidebar three-dot menu
|
||||
toggles `.dark` / `.light` on `:root`. Always test both modes before
|
||||
shipping a component.
|
||||
|
||||
### 1.2 Spacing
|
||||
|
||||
Tailwind's default 4-pixel scale. Reach for these stops first:
|
||||
|
||||
| Use | Token | Pixels |
|
||||
| ------------------------------------ | ------------- | ------ |
|
||||
| Inline gap between icon and label | `gap-1` | 4 |
|
||||
| Tight stack inside a card | `gap-2` | 8 |
|
||||
| Default form-field stack | `space-y-3` | 12 |
|
||||
| Section block (heading + body) | `space-y-4` | 16 |
|
||||
| Page section separator | `space-y-6` | 24 |
|
||||
| Dashboard tile-row gap, hero spacing | `gap-4` | 16 |
|
||||
| Page-edge padding (mobile / desktop) | `px-4 / px-6` | |
|
||||
|
||||
Avoid arbitrary `space-y-5`, `gap-3.5`, etc. unless aligning to a fixed
|
||||
external constraint.
|
||||
|
||||
### 1.3 Typography
|
||||
|
||||
The app uses the Tailwind defaults inherited via shadcn/ui. Reach for
|
||||
these classes first:
|
||||
|
||||
| Use | Class |
|
||||
| ---------------------------------- | --------------------------------------- |
|
||||
| Page title (h1) | `text-2xl font-semibold tracking-tight` |
|
||||
| Section heading (h2) | `text-lg font-semibold` |
|
||||
| Card title | `text-base font-medium` |
|
||||
| Body text | `text-sm` |
|
||||
| Muted helper / metadata | `text-sm text-muted-foreground` |
|
||||
| Microcopy / footer | `text-xs text-muted-foreground` |
|
||||
| Tabular data (latency, large nums) | `font-mono tabular-nums` |
|
||||
|
||||
Never use `font-bold` for emphasis — bump to `font-semibold` instead and
|
||||
let the design system breathe.
|
||||
|
||||
### 1.4 Radius and elevation
|
||||
|
||||
`--radius: 0.625rem` (10px) is the default. The Tailwind tokens
|
||||
`rounded-md` (`--radius-md` ≈ 8px) and `rounded-lg` (`--radius` ≈ 10px)
|
||||
cover 95% of cases. Use `rounded-2xl` only for hero cards or large
|
||||
modals.
|
||||
|
||||
Elevation is achieved via `shadow-xs`, `shadow-sm`, or
|
||||
`shadow-[0_1px_0_rgba(255,255,255,0.04)]` for very subtle separators on
|
||||
dark backgrounds. Avoid Tailwind's heavy shadows (`shadow-lg`+) in chrome.
|
||||
|
||||
---
|
||||
|
||||
## 2. Components
|
||||
|
||||
This section is **prescriptive, not exhaustive**. Each entry pairs a
|
||||
Radix/shadcn primitive with the cases it's the right answer for and the
|
||||
cases where another primitive should win.
|
||||
|
||||
### 2.1 Buttons
|
||||
|
||||
Every button comes from `@/components/ui/button`. Pick by purpose:
|
||||
|
||||
| Purpose | Variant | Size |
|
||||
| ----------------------------------------------------- | ------------- | ----------------- |
|
||||
| Primary action on a page or dialog | `default` | `default` |
|
||||
| Cancel, dismiss, secondary action next to primary | `outline` | `default` |
|
||||
| Tertiary in-row action (edit, copy, snooze) | `ghost` | `sm` |
|
||||
| Destructive (delete, revoke, disconnect) | `destructive` | `default` or `sm` |
|
||||
| Icon-only action (toolbar, chart toggles, table rows) | `ghost` | `icon-sm` |
|
||||
| Inline link to external help docs | `link` | `sm` |
|
||||
| Toolbar / table row action when space is tight | `outline` | `xs` |
|
||||
|
||||
**Position rules.**
|
||||
|
||||
- Forms (full-width or modal): primary on the **right**, cancel/back on
|
||||
the **left**. Match shadcn dialog defaults.
|
||||
- Settings sections: a single sticky `Save` button at the **right end**
|
||||
of the section header _or_ per-row inline `Save`. Never both in the
|
||||
same section.
|
||||
- Test buttons (Telegram test, AI ping, etc.): immediately to the right
|
||||
of the related Save / Apply control. Never below — keeps the eye
|
||||
motion horizontal.
|
||||
- Destructive actions live in their own confirm dialog with a
|
||||
type-to-confirm input when the action is irreversible (delete account,
|
||||
revoke API token, drop user data).
|
||||
|
||||
**Disabled state.** A disabled button must always come with a tooltip or
|
||||
helper text explaining why. Silent disabled buttons frustrate users.
|
||||
|
||||
### 2.2 Inputs and forms
|
||||
|
||||
We use `react-hook-form` + Zod schemas from `src/lib/validations/`.
|
||||
Wrap inputs in `<label>` (visible) plus `aria-describedby` for helper text.
|
||||
|
||||
- **Text / number / email**: `<Input>`. Always set `autocomplete`. See §4.4.
|
||||
- **Multi-line**: shadcn `<Textarea>` (add via `pnpm dlx shadcn@latest add textarea` if missing).
|
||||
- **Single choice ≤4 options**: prefer `<RadioGroup>` or a SegmentedControl
|
||||
pattern using `<Tabs>`. Never use `<Select>` for tiny enums — extra clicks.
|
||||
- **Single choice 5–15 options**: `<Select>` (shadcn).
|
||||
- **Single choice >15 options**: `<Combobox>` (shadcn `<Command>` + `<Popover>`)
|
||||
with type-ahead.
|
||||
- **Multi choice**: `<Checkbox>` group, or shadcn `<MultiSelect>` when 7+ choices.
|
||||
- **Date / datetime**: shadcn `<Calendar>` inside a `<Popover>`. Always
|
||||
label the timezone (Europe/Berlin).
|
||||
- **Numeric range**: `<Slider>` only when the value space is continuous
|
||||
_and_ the user routinely sweeps. For most thresholds and counts, an
|
||||
`<Input type="number">` plus stepper buttons is faster and less
|
||||
fiddly. See §6 (Slider audit) for the migration path.
|
||||
- **Boolean toggle**: `<Switch>` for "applies immediately" settings (e.g.
|
||||
enable Telegram). `<Checkbox>` for "applies on Save" lists (e.g. select
|
||||
measurements to export).
|
||||
|
||||
**Validation.** Inline messages below the field. Field gets
|
||||
`aria-invalid="true"` and a destructive border. Form-level errors land
|
||||
in a `<Alert variant="destructive">` at the top. Never show a toast for
|
||||
a field-level error.
|
||||
|
||||
### 2.3 Dialog vs Sheet vs Drawer
|
||||
|
||||
The wrong choice here is the most visible UX mistake we make. Pick by
|
||||
intent, not by hand-feel.
|
||||
|
||||
| Use | Primitive | Rationale |
|
||||
| --------------------------------------------------------------- | ------------------------------ | ---------------------------------------------------------------- |
|
||||
| Confirm a destructive or irreversible action | `Dialog` (`AlertDialog` shape) | Modal blocks accidental dismiss; user must read. |
|
||||
| Inspect a single item's details with no edit | `Dialog` | Centered, focused, dismissible. |
|
||||
| Edit a single item with ≤6 fields | `Dialog` | Tight focus, quick save. |
|
||||
| Edit a single item with 7+ fields, or with deep sub-sections | `Sheet` (right) | Wide working surface, scrollable, doesn't crop on tall content. |
|
||||
| Mobile bottom-up sub-action (filter, sort, multi-select picker) | `Sheet` (bottom) | Thumb-friendly, dismiss by swipe. |
|
||||
| Multi-step wizard | Full route | Page transitions communicate progress better than a stuck modal. |
|
||||
| Side-by-side compare or trend deep-dive | Full route | Charts need the viewport. |
|
||||
|
||||
Never nest Dialogs. If a Dialog needs another modal action, escalate the
|
||||
inner step to a separate route or use shadcn's `<AlertDialog>` chained on
|
||||
top of a `<Sheet>`.
|
||||
|
||||
### 2.4 Cards and lists
|
||||
|
||||
- **Card**: a self-contained unit of content. Use `<Card>` from
|
||||
shadcn. Header, body, footer follow Pretendard-style hierarchy.
|
||||
- **Tile**: a _small_ card showing a single metric on the dashboard.
|
||||
See §3.2 for the one-row constraint. Tiles must include current value,
|
||||
unit, optional sparkline, and a single tap-target area.
|
||||
- **List row**: `<div className="flex items-center justify-between gap-3 px-3 py-2 hover:bg-accent/40 rounded-md">` — left side has icon + primary
|
||||
- meta, right side has trailing controls. Never put primary actions in
|
||||
the right-side ellipsis menu — only secondary or destructive.
|
||||
- **Empty state**: every list and tile has an explicit empty-state
|
||||
component with an icon, a one-sentence explanation, and a single
|
||||
primary action ("Add measurement", "Connect Withings"). See §5.
|
||||
|
||||
### 2.5 Tables
|
||||
|
||||
`<Table>` (shadcn) for tabular data only — measurements list, audit log,
|
||||
admin views. Default to `<Card>` + list rows for everything else;
|
||||
tables on mobile collapse poorly. When you must use a table on mobile,
|
||||
swap to a vertical list at `sm:` breakpoint.
|
||||
|
||||
### 2.6 Status indicators
|
||||
|
||||
Use the same color taxonomy across the app:
|
||||
|
||||
| State | Color token | Examples |
|
||||
| --------- | -------------------- | ---------------------------------------------- |
|
||||
| Good | `--dracula-green` | Connected, in target, healthy |
|
||||
| Info | `--dracula-cyan` | Up-to-date, neutral signal |
|
||||
| Caution | `--dracula-orange` | Mildly out of range, expiring token, slow sync |
|
||||
| Alert | `--dracula-red` | Out of range, sync failed, critical |
|
||||
| Pending | `--muted-foreground` | Loading, indeterminate |
|
||||
| Highlight | `--dracula-purple` | New / featured / promoted |
|
||||
|
||||
Show a colored dot **plus** a label. Never rely on color alone.
|
||||
|
||||
---
|
||||
|
||||
## 3. Layouts
|
||||
|
||||
### 3.1 Page shell
|
||||
|
||||
Every authenticated page is wrapped by `<TopBar>` + `<SidebarNav>` (left)
|
||||
|
||||
- `<BottomNav>` (mobile). Don't reinvent these on a per-page basis.
|
||||
|
||||
* Mobile breakpoint: `<sm` shows bottom-nav, hides sidebar.
|
||||
* `sm`–`md`: sidebar in icons-only mode.
|
||||
* `md+`: sidebar full mode unless user has collapsed it
|
||||
(`localStorage:healthlog-sidebar-collapsed`).
|
||||
|
||||
Page content goes in `<main className="mx-auto max-w-5xl px-4 sm:px-6 py-6 space-y-6">` unless the page is a full-bleed dashboard or settings shell.
|
||||
|
||||
### 3.2 Dashboard tiles — one-row rule
|
||||
|
||||
Tiles at the top of the dashboard live in a **single row**. Width caps
|
||||
at the chart's width directly below. **Never** wrap to a second row.
|
||||
|
||||
If the user enables more tiles than fit, the dashboard surfaces a banner
|
||||
inviting them to deactivate or reorder via Settings → Dashboard. The
|
||||
layout editor in Settings shows a live preview of the constraint and
|
||||
flags overflow before save.
|
||||
|
||||
CSS: `grid-cols-N` where N = active tile count, capped by
|
||||
`Math.min(activeTiles, maxTilesForViewport(width))`. Drag-reorder via
|
||||
`@dnd-kit/sortable`. Toggle-show via the layout editor only — not in the
|
||||
tile itself, to avoid accidental hide.
|
||||
|
||||
### 3.3 Settings — `/settings/[section]`
|
||||
|
||||
The 1.4 release splits the legacy 3150-LOC settings monolith into one
|
||||
route per top-level section:
|
||||
|
||||
- `/settings/account` — profile, password, passkeys, account deletion.
|
||||
- `/settings/integrations` — Withings, moodLog, Telegram, ntfy, Web Push.
|
||||
- `/settings/notifications` — channel matrix + quiet hours + reminders.
|
||||
- `/settings/dashboard` — tile layout editor, default ranges, threshold
|
||||
overrides.
|
||||
- `/settings/ai` — AI provider selection + connection + test.
|
||||
- `/settings/api` — API tokens for headless ingest + Bearer issuance.
|
||||
- `/settings/advanced` — exports, imports, danger zone.
|
||||
- `/settings/about` — version, build, license, links to docs/changelog.
|
||||
|
||||
Each section's component lives at `src/components/settings/<section>.tsx`
|
||||
and stays under 400 LOC. Cross-section search lives in the section
|
||||
sidebar header.
|
||||
|
||||
The legacy `/settings` route 301-redirects to `/settings/account`.
|
||||
|
||||
### 3.4 Admin — `/admin/[section]`
|
||||
|
||||
Same shape as Settings, with the right-hand sidebar listing:
|
||||
|
||||
- Users
|
||||
- Integrations status
|
||||
- Monitoring (Wide Events, GlitchTip, Umami)
|
||||
- Backups
|
||||
- Maintenance
|
||||
- Audit log
|
||||
|
||||
Status badges use the §2.6 color taxonomy. Each section opens to a card
|
||||
grid, never a long fluid list.
|
||||
|
||||
---
|
||||
|
||||
## 4. Patterns
|
||||
|
||||
### 4.1 Loading states
|
||||
|
||||
- **Page-level**: a `<Skeleton>` matching the final layout. Never show a
|
||||
full-screen spinner. Mount the skeleton inside the same `<main>` shell
|
||||
the loaded page renders into.
|
||||
- **Inline**: button loading state shows a spinner + label change
|
||||
("Saving…", "Testing…"). Disable other form controls while pending.
|
||||
- **Long-running**: progress bar with a remaining-step count; for
|
||||
background tasks (export, backup), surface in a toast that links to
|
||||
the operation log instead of blocking the UI.
|
||||
|
||||
Skeletons must respect `prefers-reduced-motion: reduce`.
|
||||
|
||||
### 4.2 Empty states
|
||||
|
||||
A reusable `<EmptyState>` shape:
|
||||
|
||||
```tsx
|
||||
<EmptyState
|
||||
icon={<Plus className="size-6" />}
|
||||
title={t("empty.measurements.title")}
|
||||
description={t("empty.measurements.description")}
|
||||
action={<Button>{t("empty.measurements.add")}</Button>}
|
||||
/>
|
||||
```
|
||||
|
||||
Every list, every chart, every dashboard tile that can be empty has an
|
||||
explicit, translated empty-state. No "No data" placeholder strings.
|
||||
|
||||
### 4.3 Error states
|
||||
|
||||
- **Form errors**: inline below the offending field; aggregate at the
|
||||
top of the form for global errors (e.g. "Server unreachable").
|
||||
- **Page errors**: Next.js `error.tsx` boundaries with a friendly
|
||||
recovery action ("Retry", "Go home"). Send the error to GlitchTip
|
||||
through the existing client.
|
||||
- **Network errors during save**: `<Alert variant="destructive">` at
|
||||
the top of the form _plus_ a retry button. Never silently fail.
|
||||
|
||||
### 4.4 Forms and password managers
|
||||
|
||||
- Measurement, medication, and mood form fields use
|
||||
`autocomplete="off"` on every input that browsers keep guessing wrong.
|
||||
- API-token inputs and AI-provider key inputs use
|
||||
`autocomplete="new-password"` so the browser doesn't autofill the
|
||||
user's account password.
|
||||
- Honey-pot fields (offscreen `<input>` with `autocomplete="email"` and
|
||||
`tabIndex={-1}`) sit at the top of measurement forms to catch
|
||||
aggressive autofill.
|
||||
- Forms always include a hidden `<input type="text" name="username"
|
||||
autocomplete="username" value={user.email} readOnly tabIndex={-1}>`
|
||||
inside password change and passkey screens so password managers
|
||||
attribute saved credentials correctly.
|
||||
|
||||
### 4.5 Test buttons (every integration)
|
||||
|
||||
Each configurable integration ships a Test button next to its Save
|
||||
control. Behavior:
|
||||
|
||||
- **Trigger**: ad-hoc API call to the integration with a small,
|
||||
identifiable payload ("HealthLog test ping").
|
||||
- **State**: button shows a spinner with localized "Testing…" label.
|
||||
Result lands inline in a `<StatusCallout>` directly below — green for
|
||||
success with the relevant detail (last sync, message ID, response
|
||||
status) or red for the error (sanitized message — never leak the
|
||||
upstream URL or API key).
|
||||
- **History**: each test writes a row to a tiny per-integration log
|
||||
visible in the section. Last 5 tests, timestamp + result.
|
||||
- **Coverage**: required for Telegram, ntfy, Web Push, AI provider,
|
||||
Withings, moodLog, GlitchTip, Umami, email (when wired), backup
|
||||
target. Headless E2E asserts presence + working call for each.
|
||||
|
||||
### 4.6 Server actions and mutations
|
||||
|
||||
We use `react-hook-form` + TanStack Query mutations, never raw fetch
|
||||
calls in components. Every mutation:
|
||||
|
||||
1. Optimistically updates the cache with a rollback handler.
|
||||
2. Toast on success ("Saved" — translated) and on error (with retry).
|
||||
3. Re-validates queries it touches.
|
||||
|
||||
For multi-step flows, write a typed `apiClient` helper in
|
||||
`src/lib/api/clients/` rather than re-doing fetch in three places.
|
||||
|
||||
### 4.7 Inline help
|
||||
|
||||
A small `?` `<Tooltip>` next to the label is the default for non-trivial
|
||||
fields (e.g. "Why does this default to ESH 2023?"). Tooltips must be
|
||||
keyboard-accessible (Radix handles this) and translated.
|
||||
|
||||
For long explanations, link out to `https://docs.healthlog.dev/...` —
|
||||
never embed multi-paragraph help inline.
|
||||
|
||||
---
|
||||
|
||||
## 5. Accessibility
|
||||
|
||||
We aim for **WCAG 2.1 AA**, with the following non-negotiables:
|
||||
|
||||
- Color contrast ≥ 4.5:1 for normal text, 3:1 for large text and UI
|
||||
components. Verified by `@axe-core/playwright` in CI on every page.
|
||||
- Touch targets ≥ 44×44 CSS px on bottom-nav and primary action buttons
|
||||
(WCAG 2.5.5).
|
||||
- Focus is always visible: shadcn's `focus-visible:ring` is preserved on
|
||||
every customized component.
|
||||
- Every interactive element has an accessible name. Icon-only buttons
|
||||
get `<span className="sr-only">` labels and `aria-label`.
|
||||
- `prefers-reduced-motion: reduce` disables animations beyond a 200 ms
|
||||
fade.
|
||||
- Charts include text-equivalent summaries (`aria-describedby` linked to
|
||||
a `<p className="sr-only">` summary) and exposed data via the visible
|
||||
legend / table view toggle.
|
||||
|
||||
CI fails on any `serious` or `critical` axe-core violation.
|
||||
|
||||
---
|
||||
|
||||
## 6. Slider audit and migration
|
||||
|
||||
Sliders look pretty but are a poor fit for most numeric inputs because
|
||||
they sacrifice precision. The 1.4 audit catalogues every `<Slider>` in
|
||||
the codebase and migrates the inappropriate ones:
|
||||
|
||||
| Setting | Current control | Better control |
|
||||
| ----------------------------------- | --------------- | ------------------------------- |
|
||||
| Mood intensity (0–10) | Slider | SegmentedControl |
|
||||
| Notification quiet-hours range | Two sliders | Two `<Input type="time">` |
|
||||
| Threshold-override numeric values | Slider | `<Input type="number">` + steps |
|
||||
| AI insight depth (1–5 levels) | Slider | `<RadioGroup>` cards |
|
||||
| Export retention days (7/30/90/365) | Slider | `<Select>` with preset options |
|
||||
|
||||
A slider stays appropriate for "scrub through history" or "set chart
|
||||
zoom range" — operations where the value is continuous and live preview
|
||||
is the point.
|
||||
|
||||
---
|
||||
|
||||
## 7. Internationalization
|
||||
|
||||
All user-facing strings come from `messages/en.json` (default) and
|
||||
`messages/de.json`. Use `useTranslations()` on the client and
|
||||
`getServerTranslator()` (`src/lib/i18n/server-translator.ts`) on the
|
||||
server. Numbers, dates, currencies, and units always go through
|
||||
`useFormatters()` — never hand-roll `Intl.NumberFormat(...)` with a
|
||||
fixed locale.
|
||||
|
||||
Server-rendered content (PDFs, notifications, server-issued category
|
||||
labels) must use the locale of the user the content is for, not the
|
||||
locale of the request that triggered the job.
|
||||
|
||||
---
|
||||
|
||||
## 8. Motion
|
||||
|
||||
Default transitions: `transition-all duration-150 ease-out`. Heavier
|
||||
animations (insight cards, dialog enter/exit) use the shadcn defaults.
|
||||
Reduce-motion always wins.
|
||||
|
||||
Loading skeletons pulse via Tailwind `animate-pulse`. Don't introduce
|
||||
custom keyframes without adding a `prefers-reduced-motion` fallback.
|
||||
|
||||
---
|
||||
|
||||
## 9. Component checklist (for new features)
|
||||
|
||||
Before opening a PR for a new component or page, walk through:
|
||||
|
||||
- [ ] Uses tokens from §1, not hard-coded hex/rgba.
|
||||
- [ ] Buttons follow §2.1 variant + position rules.
|
||||
- [ ] Inputs follow §2.2; correct `autocomplete`; honey-pots where needed.
|
||||
- [ ] Dialog vs Sheet vs Drawer per §2.3.
|
||||
- [ ] Loading, empty, and error states from §4.1–4.3 implemented.
|
||||
- [ ] All strings via `t()`; numbers via formatters.
|
||||
- [ ] Mobile viewport tested (Chrome DevTools 375×667 and 414×896).
|
||||
- [ ] Light + Dark mode tested.
|
||||
- [ ] Keyboard navigation: Tab order, Enter to submit, Esc to dismiss.
|
||||
- [ ] axe-core clean (no serious/critical).
|
||||
- [ ] Headless screenshot test added if UI is non-trivial.
|
||||
|
||||
---
|
||||
|
||||
_Maintained as part of the v1.4 release cycle. Propose changes alongside
|
||||
the code that motivates them._
|
||||
@@ -0,0 +1,44 @@
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Accessibility regression gate — fails the build if any WCAG 2.1 AA
|
||||
* violation lands in the `serious` or `critical` bucket on a public
|
||||
* surface. Authenticated pages are out of scope here (need seeded
|
||||
* data); they're covered by the per-flow specs that already touch
|
||||
* those routes.
|
||||
*/
|
||||
test.describe("axe-core public surfaces", () => {
|
||||
for (const path of ["/auth/login"]) {
|
||||
test(`${path} has no serious or critical a11y violations`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(path);
|
||||
// Wait briefly for any reduced-motion fades to settle.
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const results = await new AxeBuilder({ page })
|
||||
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
|
||||
.analyze();
|
||||
|
||||
const blocking = results.violations.filter(
|
||||
(v) => v.impact === "serious" || v.impact === "critical",
|
||||
);
|
||||
|
||||
if (blocking.length > 0) {
|
||||
// Pretty-print so failures are actionable in CI logs.
|
||||
console.log(
|
||||
"axe violations:\n" +
|
||||
blocking
|
||||
.map(
|
||||
(v) =>
|
||||
` - [${v.impact}] ${v.id}: ${v.help}\n ${v.nodes.length} node(s)\n ${v.helpUrl}`,
|
||||
)
|
||||
.join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
expect(blocking).toHaveLength(0);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* The proxy at src/proxy.ts is the single gate enforcing auth on every
|
||||
* non-public path. A regression that accidentally adds a route to
|
||||
* PUBLIC_PATHS, or breaks the redirect, would expose unauthenticated
|
||||
* surfaces. Verify a few representative routes round-trip to /auth/login.
|
||||
*/
|
||||
test.describe("proxy auth gate", () => {
|
||||
for (const path of [
|
||||
"/",
|
||||
"/dashboard",
|
||||
"/medications",
|
||||
"/admin",
|
||||
"/insights",
|
||||
]) {
|
||||
test(`${path} redirects to /auth/login when no session`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const response = await page.goto(path, { waitUntil: "domcontentloaded" });
|
||||
expect(response).not.toBeNull();
|
||||
// Either the proxy hands back a 307/308, or the navigation lands
|
||||
// at /auth/login after redirect; both are acceptable proof.
|
||||
expect(page.url()).toMatch(/\/auth\/login(\?|$)/);
|
||||
});
|
||||
}
|
||||
|
||||
test("public paths are reachable without a session", async ({ page }) => {
|
||||
// /auth/login is in PUBLIC_PATHS — must be served, not bounced anywhere.
|
||||
await page.goto("/auth/login");
|
||||
expect(page.url()).toMatch(/\/auth\/login(\?|$)/);
|
||||
await expect(page.locator("body")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Locale-key drift smoke check — switch the cookie between EN and DE
|
||||
* and assert the login page (the only public surface that's translated)
|
||||
* never shows raw i18n keys like `auth.welcomeBack`.
|
||||
*
|
||||
* Drift catches: a typo'd t("foo.bar") with no key in either locale
|
||||
* file would make the raw key surface in the DOM. messages/de.json and
|
||||
* messages/en.json key parity is enforced by unit tests; this is the
|
||||
* runtime cousin.
|
||||
*/
|
||||
test.describe("locale switch", () => {
|
||||
for (const locale of ["en", "de"] as const) {
|
||||
test(`renders /auth/login in ${locale} without raw i18n keys`, async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await context.addCookies([
|
||||
{
|
||||
name: "healthlog_locale",
|
||||
value: locale,
|
||||
url: page.url() || "http://localhost:3000",
|
||||
},
|
||||
]);
|
||||
await page.goto("/auth/login");
|
||||
|
||||
const bodyText = await page.locator("body").innerText();
|
||||
|
||||
// i18n keys look like `section.subkey` — letters, numbers,
|
||||
// underscores, dots — and are never wrapped in normal sentences,
|
||||
// so a regex match anywhere in body text is a sign the lookup
|
||||
// fell through.
|
||||
const rawKeyPattern = /\b[a-z]+(?:[A-Z][a-z]+)?\.[a-z][A-Za-z0-9_.]+\b/;
|
||||
const match = bodyText.match(rawKeyPattern);
|
||||
|
||||
// Whitelist: filenames or version strings can look like keys,
|
||||
// skip those by requiring at least one camelCase segment.
|
||||
if (match) {
|
||||
const candidate = match[0];
|
||||
const looksLikeKey = /\.[a-z][A-Z]/.test(candidate);
|
||||
expect(
|
||||
looksLikeKey,
|
||||
`Possible raw i18n key in ${locale}: ${candidate}`,
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Smoke checks on the login surface — must render, must wire the
|
||||
* password-manager autofill hints (autoComplete="username" /
|
||||
* "current-password"), must let the user submit.
|
||||
*
|
||||
* These do NOT log in (no DB seed required); the password-flow
|
||||
* roundtrip is exercised by the integration test in
|
||||
* tests/integration/auth-flow.test.ts.
|
||||
*/
|
||||
test.describe("login page", () => {
|
||||
test("renders username + password inputs with the right autoComplete", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/auth/login");
|
||||
|
||||
const username = page.getByLabel(/username|benutzername/i).first();
|
||||
await expect(username).toBeVisible();
|
||||
await expect(username).toHaveAttribute("autoComplete", /username|email/);
|
||||
|
||||
const password = page.locator('input[type="password"]').first();
|
||||
await expect(password).toBeVisible();
|
||||
await expect(password).toHaveAttribute("autoComplete", "current-password");
|
||||
});
|
||||
|
||||
test("rejects an obviously-wrong credential pair", async ({ page }) => {
|
||||
await page.goto("/auth/login");
|
||||
|
||||
await page
|
||||
.getByLabel(/username|benutzername/i)
|
||||
.first()
|
||||
.fill("nobody-here");
|
||||
await page.locator('input[type="password"]').first().fill("not-the-pw");
|
||||
|
||||
// Intercept the API call so the test does not depend on a real
|
||||
// backend rejecting the credentials. The login form's error
|
||||
// surfacing is what we actually want to prove here.
|
||||
await page.route("**/api/auth/login", (route) =>
|
||||
route.fulfill({
|
||||
status: 401,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ error: "Invalid credentials" }),
|
||||
}),
|
||||
);
|
||||
|
||||
await page.getByRole("button", { name: /login|anmelden|sign in/i }).click();
|
||||
|
||||
await expect(
|
||||
page.getByText(/invalid credentials|ungültig|falsch/i),
|
||||
).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* /api/version is the public, unauthenticated endpoint that drives
|
||||
* the Settings → About surface and acts as the container's healthcheck
|
||||
* target. If this breaks, every deployed image's healthcheck fails and
|
||||
* Coolify pulls the container out of rotation.
|
||||
*/
|
||||
test.describe("public version endpoint", () => {
|
||||
test("returns the running version + license", async ({ request }) => {
|
||||
const res = await request.get("/api/version");
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const json = await res.json();
|
||||
expect(json.data).toBeDefined();
|
||||
expect(typeof json.data.version).toBe("string");
|
||||
expect(json.data.version).toMatch(/^\d+\.\d+\.\d+/);
|
||||
expect(json.data.license).toBeDefined();
|
||||
});
|
||||
|
||||
test("does not require authentication", async ({ request }) => {
|
||||
// No cookie, no Authorization header — must still respond 200 so
|
||||
// the docker healthcheck and the in-app "check for updates" button
|
||||
// both work without a session.
|
||||
const res = await request.get("/api/version", {
|
||||
headers: { "X-Client-Type": "anonymous" },
|
||||
});
|
||||
expect(res.status()).toBe(200);
|
||||
});
|
||||
});
|
||||
+641
-57
@@ -36,7 +36,93 @@
|
||||
"replace": "Ersetzen",
|
||||
"add": "Hinzufügen",
|
||||
"import": "Importieren",
|
||||
"test": "Test"
|
||||
"test": "Test",
|
||||
"reset": "Zurücksetzen",
|
||||
"copy": "Kopieren",
|
||||
"copyDetails": "Details kopieren",
|
||||
"retry": "Erneut versuchen",
|
||||
"reportIssue": "Fehler melden"
|
||||
},
|
||||
"doctorReport": {
|
||||
"title": "Gesundheitsbericht",
|
||||
"subtitle": "HealthLog — Persönlicher Gesundheitsbericht",
|
||||
"patient": "Patient",
|
||||
"dateOfBirth": "Geburtsdatum",
|
||||
"gender": "Geschlecht",
|
||||
"genderMale": "Männlich",
|
||||
"genderFemale": "Weiblich",
|
||||
"genderOther": "Divers",
|
||||
"height": "Körpergröße",
|
||||
"period": "Berichtszeitraum",
|
||||
"createdOn": "Erstellt am",
|
||||
"vitalsTitle": "Vitalparameter — Übersicht",
|
||||
"colParameter": "Parameter",
|
||||
"colCurrent": "Aktuell",
|
||||
"colAvgPeriod": "Ø Zeitraum",
|
||||
"colMin": "Min",
|
||||
"colMax": "Max",
|
||||
"colN": "n",
|
||||
"bpClassificationTitle": "Blutdruck — ESH-Klassifikation (2023)",
|
||||
"avgBp": "Durchschnittlicher Blutdruck",
|
||||
"classification": "Klassifikation",
|
||||
"bmiTitle": "Body-Mass-Index (BMI)",
|
||||
"bmiRow": "BMI: {bmi} kg/m² — Klassifikation: {class} (WHO)",
|
||||
"complianceTitle": "Medikamenten-Compliance",
|
||||
"colMedication": "Medikament",
|
||||
"colTaken": "Eingenommen",
|
||||
"colSkipped": "Übersprungen",
|
||||
"colMissed": "Verpasst",
|
||||
"colTotal": "Gesamt",
|
||||
"colComplianceRate": "Compliance-Rate",
|
||||
"moodTitle": "Stimmungsverlauf",
|
||||
"moodSummary": "Durchschnitt: {avg} / 5,0 · Einträge: {count} · Bereich: {min}–{max}",
|
||||
"colMood": "Stimmung",
|
||||
"colCount": "Anzahl",
|
||||
"colShare": "Anteil",
|
||||
"footerDisclaimer1": "Dieser Bericht wurde automatisch aus selbst erfassten Daten generiert und dient ausschließlich zur Unterstützung",
|
||||
"footerDisclaimer2": "des Arzt-Patienten-Gesprächs. Er ersetzt keine ärztliche Diagnose. Korrelationen implizieren keine Kausalität.",
|
||||
"footerSource": "Quelle: HealthLog | Zeitzone: Europe/Berlin | Erstellt: {timestamp}",
|
||||
"typeWeight": "Körpergewicht",
|
||||
"typeBpSys": "Systolischer Blutdruck",
|
||||
"typeBpDia": "Diastolischer Blutdruck",
|
||||
"typePulse": "Ruhepuls",
|
||||
"typeBodyFat": "Körperfettanteil",
|
||||
"typeSleep": "Schlafdauer",
|
||||
"typeSteps": "Aktivität (Schritte)",
|
||||
"typeTotalBodyWater": "Gesamtkörperwasser",
|
||||
"typeBoneMass": "Knochenmasse",
|
||||
"typeOxygenSaturation": "Sauerstoffsättigung",
|
||||
"unitSteps": "Schritte",
|
||||
"bmiUnderweight": "Untergewicht",
|
||||
"bmiNormal": "Normalgewicht",
|
||||
"bmiOverweight": "Übergewicht (Präadipositas)",
|
||||
"bmiObeseGrade1": "Adipositas Grad I",
|
||||
"bmiObeseGrade2": "Adipositas Grad II",
|
||||
"bmiObeseGrade3": "Adipositas Grad III",
|
||||
"bpOptimal": "Optimal",
|
||||
"bpNormal": "Normal",
|
||||
"bpHighNormal": "Hochnormal",
|
||||
"bpHypertensionGrade1": "Hypertonie Grad 1",
|
||||
"bpHypertensionGrade2": "Hypertonie Grad 2",
|
||||
"bpHypertensionGrade3": "Hypertonie Grad 3",
|
||||
"moodAwful": "Sehr schlecht",
|
||||
"moodBad": "Schlecht",
|
||||
"moodNeutral": "Neutral",
|
||||
"moodGood": "Gut",
|
||||
"moodGreat": "Sehr gut",
|
||||
"typeGlucoseFasting": "Glukose (nüchtern)",
|
||||
"typeGlucosePostprandial": "Glukose (postprandial)",
|
||||
"typeGlucoseRandom": "Glukose (beliebig)",
|
||||
"typeGlucoseBedtime": "Glukose (vor dem Schlafen)",
|
||||
"glucoseClassificationTitle": "Blutzucker — Referenzbereiche (ADA 2024 / DDG)",
|
||||
"glucoseRow": "{label}: Ø {avg} {unit} (Ziel {rangeMin}–{rangeMax}) — {class}",
|
||||
"glucoseInTarget": "im Ziel",
|
||||
"glucoseBelowTarget": "unter Ziel",
|
||||
"glucoseAboveTarget": "über Ziel"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Da ist etwas schiefgelaufen",
|
||||
"description": "Ein unerwarteter Fehler ist aufgetreten. Du kannst es erneut versuchen, die technischen Details kopieren oder einen Bug-Report öffnen."
|
||||
},
|
||||
"nav": {
|
||||
"home": "Home",
|
||||
@@ -90,6 +176,7 @@
|
||||
"backToPasskey": "Zurück zu Passkey",
|
||||
"passkeySetupLater": "Passkeys kannst du jederzeit in den Einstellungen einrichten.",
|
||||
"passwordMinLength": "Min. 12 Zeichen",
|
||||
"passwordTooShort": "Passwort muss mindestens {minLength} Zeichen lang sein.",
|
||||
"passwordStrength": "Passwortstärke",
|
||||
"strengthWeak": "Sehr schwach",
|
||||
"strengthFair": "Schwach",
|
||||
@@ -107,12 +194,16 @@
|
||||
},
|
||||
"welcomeBack": "{greeting}, willkommen zurück.",
|
||||
"welcomeBackWithName": "{greeting} {name}, willkommen zurück.",
|
||||
"greetingSalutation": "Hi, {name}",
|
||||
"streakLabel": "Tage in Folge",
|
||||
"weight": "Gewicht",
|
||||
"bloodPressure": "Blutdruck",
|
||||
"bloodPressureSys": "Blutdruck (Sys)",
|
||||
"bloodPressureDia": "Blutdruck (Dia)",
|
||||
"pulse": "Puls",
|
||||
"bodyFat": "Körperfett",
|
||||
"sleep": "Schlaf",
|
||||
"steps": "Schritte",
|
||||
"bpInTarget": "BD im Zielbereich",
|
||||
"days30Short": "30T",
|
||||
"medications": "Medikamente",
|
||||
@@ -127,7 +218,17 @@
|
||||
"compliance7d": "Einnahmetreue (7 Tage)",
|
||||
"complianceGood": "Gut",
|
||||
"complianceLow": "Niedrig",
|
||||
"mood": "Stimmung"
|
||||
"mood": "Stimmung",
|
||||
"customizeTitle": "Dashboard-Layout",
|
||||
"customizeSubtitle": "Wähle, welche Karten auf dem Dashboard angezeigt werden und in welcher Reihenfolge. Defaults funktionieren sofort.",
|
||||
"layoutReset": "Auf Defaults zurücksetzen",
|
||||
"layoutSaveSuccess": "Dashboard-Layout gespeichert",
|
||||
"layoutSaveError": "Layout konnte nicht gespeichert werden",
|
||||
"layoutResetSuccess": "Auf Standard-Layout zurückgesetzt",
|
||||
"layoutUsingDefaults": "Standard-Layout aktiv",
|
||||
"layoutCustomized": "Eigenes Layout aktiv",
|
||||
"moveUp": "Nach oben",
|
||||
"moveDown": "Nach unten"
|
||||
},
|
||||
"measurements": {
|
||||
"title": "Messungen",
|
||||
@@ -148,6 +249,17 @@
|
||||
"typeBodyFat": "Körperfett",
|
||||
"typeSleep": "Schlaf",
|
||||
"typeSteps": "Schritte",
|
||||
"typeBloodGlucose": "Blutzucker",
|
||||
"typeTotalBodyWater": "Gesamtkörperwasser",
|
||||
"typeBoneMass": "Knochenmasse",
|
||||
"typeOxygenSaturation": "Sauerstoffsättigung",
|
||||
"glucoseContext": "Kontext",
|
||||
"glucoseContextFasting": "Nüchtern (≥8 h ohne Nahrung)",
|
||||
"glucoseContextPostprandial": "Postprandial (2 h nach dem Essen)",
|
||||
"glucoseContextRandom": "Beliebig / ohne Kontext",
|
||||
"glucoseContextBedtime": "Vor dem Schlafen",
|
||||
"glucoseUnit": "Blutzucker-Einheit",
|
||||
"glucoseUnitHint": "Gespeichert in mg/dL; Anzeige-Einheit ist deine Präferenz.",
|
||||
"unitHours": "Stunden",
|
||||
"unitSteps": "Schritte",
|
||||
"sourceManual": "Manuell",
|
||||
@@ -385,7 +497,21 @@
|
||||
"phaseAfterEnd": "nach Ende",
|
||||
"phaseResetDefaults": "Auf Defaults zurücksetzen",
|
||||
"phaseSaved": "Erinnerungsphasen gespeichert",
|
||||
"phaseReset": "Auf Defaults zurückgesetzt"
|
||||
"phaseReset": "Auf Defaults zurückgesetzt",
|
||||
"sourceWeb": "Webseite",
|
||||
"sourceApi": "Externe API",
|
||||
"sourceReminder": "Telegram / Erinnerung",
|
||||
"sourceImport": "CSV-Import",
|
||||
"unitDrops": "Tropfen",
|
||||
"unitTablets": "Tablette(n)",
|
||||
"unitCapsules": "Kapsel(n)",
|
||||
"unitPieces": "Stück",
|
||||
"namePlaceholder": "z.B. Ibuprofen",
|
||||
"dosePlaceholder": "z.B. 400",
|
||||
"labelPlaceholder": "z.B. Morgens",
|
||||
"defaultDose": "Standard",
|
||||
"dayDeactivate": "{day} deaktivieren",
|
||||
"dayActivate": "{day} aktivieren"
|
||||
},
|
||||
"charts": {
|
||||
"title": "Verlauf",
|
||||
@@ -406,17 +532,22 @@
|
||||
"noData": "Keine Daten im gewählten Zeitraum",
|
||||
"movingAverage": "Gleitender Durchschnitt",
|
||||
"target": "Zielbereich",
|
||||
"points7Label": "7M",
|
||||
"points7Title": "Die sieben letzten Messpunkte",
|
||||
"points30Label": "30M",
|
||||
"points30Title": "Die dreißig letzten Messpunkte",
|
||||
"points90Label": "90M",
|
||||
"points90Title": "Die neunzig letzten Messpunkte",
|
||||
"points7Label": "7 Pkt",
|
||||
"points7Title": "Letzte 7 Messpunkte",
|
||||
"points30Label": "30 Pkt",
|
||||
"points30Title": "Letzte 30 Messpunkte",
|
||||
"points90Label": "90 Pkt",
|
||||
"points90Title": "Letzte 90 Messpunkte",
|
||||
"pointsAllLabel": "Alle",
|
||||
"pointsAllTitle": "Alle verfügbaren Messpunkte",
|
||||
"systolic": "Systolisch",
|
||||
"diastolic": "Diastolisch",
|
||||
"bodyWater": "Körperwasser",
|
||||
"boneMass": "Knochenmasse",
|
||||
"spo2": "SpO₂",
|
||||
"movingAverage7d": "7T-Schnitt",
|
||||
"avg7dShort": "7T",
|
||||
"avg30dShort": "30T",
|
||||
"trend": "Trend",
|
||||
"targetRanges": "Zielbereiche",
|
||||
"perWeek": "/Woche",
|
||||
@@ -429,9 +560,62 @@
|
||||
"colorRed": "Rot:",
|
||||
"mood": "Stimmung",
|
||||
"moodScore": "Stimmungswert",
|
||||
"moodMA": "7-Tage-Schnitt"
|
||||
"moodMA": "7-Tage-Schnitt",
|
||||
"moodLabel1": "Lausig",
|
||||
"moodLabel2": "Schlecht",
|
||||
"moodLabel3": "Okay",
|
||||
"moodLabel4": "Gut",
|
||||
"moodLabel5": "Super",
|
||||
"weekdays": {
|
||||
"mon": "Mo",
|
||||
"wed": "Mi",
|
||||
"fri": "Fr",
|
||||
"sun": "So"
|
||||
},
|
||||
"months": {
|
||||
"jan": "Jan",
|
||||
"feb": "Feb",
|
||||
"mar": "Mär",
|
||||
"apr": "Apr",
|
||||
"may": "Mai",
|
||||
"jun": "Jun",
|
||||
"jul": "Jul",
|
||||
"aug": "Aug",
|
||||
"sep": "Sep",
|
||||
"oct": "Okt",
|
||||
"nov": "Nov",
|
||||
"dec": "Dez"
|
||||
},
|
||||
"heatmapOnTime": "pünktlich",
|
||||
"heatmapLate": "spät",
|
||||
"heatmapVeryLate": "sehr spät",
|
||||
"legendOnTime": "Pünktlich",
|
||||
"legendLate": "Spät",
|
||||
"legendVeryLate": "Sehr spät",
|
||||
"legendMissed": "Verpasst"
|
||||
},
|
||||
"insights": {
|
||||
"aiAnalysisTitle": "KI-Gesundheitsanalyse",
|
||||
"noAnalysisYet": "Noch keine Analyse vorhanden.",
|
||||
"noProviderConfigured": "KI-Provider nicht konfiguriert.",
|
||||
"startAnalysis": "Analyse starten",
|
||||
"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",
|
||||
"dataGaps": "Datenlücken:",
|
||||
"lastUpdated": "Zuletzt aktualisiert",
|
||||
"confidenceHoch": "Hoch",
|
||||
"confidenceMittel": "Mittel",
|
||||
"confidenceGering": "Gering",
|
||||
"loadError": "Auswertungen konnten nicht geladen werden",
|
||||
"generationFailed": "Die Analyse konnte nicht erstellt werden. Bitte erneut versuchen.",
|
||||
"title": "Insights",
|
||||
"subtitle": "KI-basierte Analyse deiner Gesundheitsdaten",
|
||||
"overviewSubtitle": "Trends, Risiken, Fortschritt auf einen Blick",
|
||||
@@ -450,7 +634,7 @@
|
||||
"inTargetRange": "im Zielbereich",
|
||||
"bpTarget": "Ziel: {range}",
|
||||
"noDobSet": "Geburtsdatum nicht hinterlegt.",
|
||||
"avg30dEsc": "30T-Durchschnitt · ESC/ESH 2018",
|
||||
"avg30dEsc": "30T-Durchschnitt · ESH 2023",
|
||||
"whoClassification": "WHO-Klassifikation",
|
||||
"correlations": "Korrelationen",
|
||||
"weightVsBp": "Gewicht vs. Blutdruck (Systolisch)",
|
||||
@@ -543,47 +727,55 @@
|
||||
"moodWeightNone": "Kein erkennbarer Zusammenhang zwischen Stimmung und Gewicht.",
|
||||
"moodWeightStrong": "Es besteht ein starker Zusammenhang zwischen Stimmung und Gewicht.",
|
||||
"moodWeightWeak": "Es besteht ein schwacher Zusammenhang zwischen Stimmung und Gewicht.",
|
||||
"notEnoughMoodCorrelationData": "Zu wenig Stimmungsdaten für Korrelationsanalyse."
|
||||
"notEnoughMoodCorrelationData": "Zu wenig Stimmungsdaten für Korrelationsanalyse.",
|
||||
"refreshButton": "Aktualisieren",
|
||||
"generateButton": "Generieren",
|
||||
"analyzing": "Analysiere Gesundheitsdaten...",
|
||||
"confidence": "Konfidenz",
|
||||
"confidence_niedrig": "niedrig",
|
||||
"confidence_mittel": "mittel",
|
||||
"confidence_hoch": "hoch",
|
||||
"cached": "Gecacht",
|
||||
"sectionChanged": "Was hat sich verändert",
|
||||
"sectionStable": "Was ist stabil",
|
||||
"sectionDrivers": "Mögliche Zusammenhänge",
|
||||
"sectionNextSteps": "Nächste Schritte",
|
||||
"generatePrompt": "Klicke auf \"Generieren\" für KI-basierte Insights deiner Gesundheitsdaten."
|
||||
},
|
||||
"targets": {
|
||||
"title": "Zielwerte",
|
||||
"subtitle": "Persönliche Referenzbereiche basierend auf deinem Profil",
|
||||
"introText": "Diese Zielwerte sind persönliche Referenzbereiche, die aus deinem Profil und anerkannten medizinischen Richtwerten abgeleitet werden. Sie helfen dir, deine wichtigsten Gesundheitswerte schnell einzuordnen und Entwicklungen früh zu erkennen. Die Bereiche werden für dich individuell berechnet, zum Beispiel anhand von Alter, Geschlecht und Körpergröße. Zu jedem Wert siehst du direkt, ob du im Zielbereich liegst oder darüber beziehungsweise darunter. So bekommst du eine kompakte Übersicht darüber, welche Zielwerte du aktuell gut einhältst und wo noch Potenzial besteht.",
|
||||
"current": "Aktuell",
|
||||
"average30d": "30-Tage-Durchschnitt:",
|
||||
"targetRange": "Zielbereich",
|
||||
"classification": "Bewertung",
|
||||
"source": "Quelle",
|
||||
"sourceLabel": "Quelle: {source}",
|
||||
"trend": "Trend",
|
||||
"weight": "Gewicht",
|
||||
"bloodPressure": "Blutdruck",
|
||||
"pulse": "Ruhepuls",
|
||||
"sleep": "Schlafdauer",
|
||||
"bmi": "BMI",
|
||||
"bodyFat": "Körperfett",
|
||||
"steps": "Schritte/Tag",
|
||||
"compliance": "Einnahmetreue",
|
||||
"compliancePerMedication": "Pro Medikament",
|
||||
"diastolic": "Diastolisch",
|
||||
"noProfile": "Bitte fülle dein Profil aus, um personalisierte Zielwerte zu erhalten.",
|
||||
"profileIncomplete": "Profil unvollständig",
|
||||
"profileIncompleteHeightAge": "Hinterlege deine Größe und dein Geburtsdatum im Profil, um personalisierte Zielbereiche zu erhalten.",
|
||||
"profileIncompleteHeight": "Hinterlege deine Größe im Profil, um den Gewichts-Zielbereich zu berechnen.",
|
||||
"profileIncompleteAge": "Hinterlege dein Geburtsdatum im Profil, um altersbasierte Zielbereiche zu erhalten.",
|
||||
"noMeasurementData": "Noch keine Messdaten vorhanden.",
|
||||
"noMeasurementYet": "Noch keine Messung vorhanden",
|
||||
"currentValue": "Aktuell: {value} {unit}",
|
||||
"targetRangeValue": "Zielbereich: {min}–{max} {unit}",
|
||||
"inTarget": "Im Zielbereich",
|
||||
"belowTarget": "{delta} {unit} unter Ziel",
|
||||
"aboveTarget": "{delta} {unit} über Ziel",
|
||||
"loadError": "Fehler beim Laden",
|
||||
"classificationOptimal": "Optimal",
|
||||
"classificationNormal": "Normal",
|
||||
"classificationElevated": "Erhöht",
|
||||
"classificationHigh": "Hoch",
|
||||
"classificationLow": "Niedrig"
|
||||
"thresholds": {
|
||||
"title": "Persönliche Zielwerte",
|
||||
"subtitle": "Überschreibe den berechneten Zielbereich für jede Metrik. Ohne Eintrag gilt der Default.",
|
||||
"defaultLabel": "Default",
|
||||
"overrideLabel": "Dein Wert",
|
||||
"autoModeLabel": "Auto",
|
||||
"overrideModeLabel": "Überschrieben",
|
||||
"resetAction": "Auf Default zurücksetzen",
|
||||
"resetAllAction": "Alle auf Default zurücksetzen",
|
||||
"minLabel": "Min",
|
||||
"maxLabel": "Max",
|
||||
"unitSuffix": "({unit})",
|
||||
"outOfBoundsHint": "Erlaubter Bereich: {min}–{max} {unit}",
|
||||
"overrideWarning": "Werte außerhalb der Leitlinien können zu ungesunden Zielen führen. Nur nach ärztlicher Rücksprache überschreiben.",
|
||||
"saveSuccess": "Zielwerte aktualisiert",
|
||||
"saveError": "Zielwerte konnten nicht gespeichert werden",
|
||||
"resetSuccess": "Auf Default zurückgesetzt",
|
||||
"sourceOverride": "Eigener Wert",
|
||||
"metricWeight": "Gewicht",
|
||||
"metricBpSys": "Blutdruck (systolisch)",
|
||||
"metricBpDia": "Blutdruck (diastolisch)",
|
||||
"metricPulse": "Ruhepuls",
|
||||
"metricBodyFat": "Körperfett",
|
||||
"metricSleep": "Schlafdauer",
|
||||
"metricSteps": "Schritte/Tag",
|
||||
"metricGlucoseFasting": "Glukose — nüchtern",
|
||||
"metricGlucosePostprandial": "Glukose — postprandial",
|
||||
"metricGlucoseRandom": "Glukose — beliebig",
|
||||
"metricGlucoseBedtime": "Glukose — vor dem Schlafen",
|
||||
"metricBodyWater": "Körperwasser",
|
||||
"metricBoneMass": "Knochenmasse",
|
||||
"metricOxygenSaturation": "Sauerstoffsättigung (SpO₂)",
|
||||
"unsetExplanation": "Kein eigener Wert — berechneter Default aktiv.",
|
||||
"loadError": "Zielwerte konnten nicht geladen werden"
|
||||
},
|
||||
"onboarding": {
|
||||
"welcome": "Willkommen bei HealthLog!",
|
||||
@@ -603,15 +795,129 @@
|
||||
"targetsDescription": "Setze optionale Zielwerte für Blutdruck und Gewicht.",
|
||||
"targetSys": "Systolisch (mmHg)",
|
||||
"targetDia": "Diastolisch (mmHg)",
|
||||
"targetWeight": "Zielgewicht (kg)"
|
||||
"targetWeight": "Zielgewicht (kg)",
|
||||
"errorProfile": "Dein Profil konnte nicht gespeichert werden. Bitte erneut versuchen.",
|
||||
"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.",
|
||||
"v2": {
|
||||
"title": "Willkommen bei HealthLog",
|
||||
"subtitle": "Drei schnelle Schritte. Alles weitere kannst du später in den Einstellungen erledigen.",
|
||||
"stepOf": "Schritt {current} von {total}",
|
||||
"back": "Zurück",
|
||||
"continue": "Weiter",
|
||||
"finish": "Setup abschließen",
|
||||
"skipStep": "Schritt überspringen — du kannst das Setup später in den Einstellungen abschließen.",
|
||||
"step1": {
|
||||
"title": "Über dich",
|
||||
"description": "Wird für Blutdruck-Zielwerte, BMI und passende Erinnerungen genutzt.",
|
||||
"displayNameLabel": "Anzeigename",
|
||||
"displayNameHint": "Erscheint in der Dashboard-Begrüßung und in Berichten.",
|
||||
"languageLabel": "Sprache",
|
||||
"languageHint": "Wird für App, PDFs und Benachrichtigungen verwendet."
|
||||
},
|
||||
"step2": {
|
||||
"title": "Lege deine erste Messung an",
|
||||
"description": "Gewicht, Blutdruck oder Puls — was du gerade zur Hand hast. Schon eine Messung lässt das Dashboard lebendig wirken.",
|
||||
"added": "Top — weitere Messungen kannst du jederzeit über das Dashboard hinzufügen.",
|
||||
"skipHint": "Keine Messung zur Hand? Überspringe und logge eine, wenn du soweit bist."
|
||||
},
|
||||
"step3": {
|
||||
"title": "Wähle einen Benachrichtigungs-Kanal",
|
||||
"description": "Wir senden nur, was du auch willst. Du kannst weitere Kanäle und Events später feinjustieren.",
|
||||
"channelTelegramTitle": "Telegram-Bot",
|
||||
"channelTelegramHint": "Erinnerungen mit Snooze-Buttons direkt in Telegram.",
|
||||
"channelWebPushTitle": "Web Push",
|
||||
"channelWebPushHint": "Native Browser-Benachrichtigungen auf diesem Gerät.",
|
||||
"channelNtfyTitle": "ntfy.sh",
|
||||
"channelNtfyHint": "Open-Source-Push an Handy oder Desktop, ohne Account.",
|
||||
"channelNoneTitle": "Vorerst überspringen",
|
||||
"channelNoneHint": "Ich richte Benachrichtigungen später in den Einstellungen ein.",
|
||||
"openSettings": "In den Einstellungen fortsetzen"
|
||||
},
|
||||
"doneToast": "Alles eingerichtet — willkommen an Bord."
|
||||
}
|
||||
},
|
||||
"gettingStarted": {
|
||||
"title": "Erste Schritte",
|
||||
"subtitle": "Quick Wins, damit du das Beste aus HealthLog herausholst.",
|
||||
"progress": "{done} von {total} erledigt",
|
||||
"dismiss": "Ausblenden",
|
||||
"dismissAll": "Checkliste ausblenden",
|
||||
"dismissTooltip": "Diesen Punkt ausblenden",
|
||||
"restoreHint": "Du kannst die Checkliste in den Einstellungen wiederherstellen.",
|
||||
"items": {
|
||||
"profileTitle": "Profil einrichten",
|
||||
"profileDescription": "Größe, Geburtsdatum und Geschlecht schalten personalisierte Zielwerte frei.",
|
||||
"profileCta": "Profil öffnen",
|
||||
"measurementTitle": "Erste Messung anlegen",
|
||||
"measurementDescription": "Eine Messung reicht, um deine erste Trend-Kachel zu zeichnen.",
|
||||
"measurementCta": "Messung hinzufügen",
|
||||
"medicationTitle": "Medikament hinzufügen",
|
||||
"medicationDescription": "Verfolge Compliance und erhalte Erinnerungen genau dann, wenn du sie brauchst.",
|
||||
"medicationCta": "Medikament hinzufügen",
|
||||
"withingsTitle": "Withings verbinden (optional)",
|
||||
"withingsDescription": "Synchronisiere Gewicht, Blutdruck und Puls automatisch aus deinen Withings-Geräten.",
|
||||
"withingsCta": "Verbinden",
|
||||
"notificationsTitle": "Benachrichtigungen einrichten",
|
||||
"notificationsDescription": "Wähle, wie HealthLog dich erinnert — Telegram, ntfy oder Web Push.",
|
||||
"notificationsCta": "Benachrichtigungen öffnen"
|
||||
}
|
||||
},
|
||||
"trendHints": {
|
||||
"title": "Erster Trend nach 5 Messungen",
|
||||
"remainingOne": "Noch 1 Messung bis zum Trend.",
|
||||
"remainingMany": "Noch {count} Messungen bis zum Trend."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Einstellungen",
|
||||
"subtitle": "Profil, Sicherheit & Verbindungen",
|
||||
"shell": {
|
||||
"sectionsNav": "Einstellungs-Bereiche"
|
||||
},
|
||||
"sections": {
|
||||
"account": {
|
||||
"title": "Konto",
|
||||
"description": "Profil, Anmeldemethoden und Konto-Löschung."
|
||||
},
|
||||
"integrations": {
|
||||
"title": "Integrationen",
|
||||
"description": "Withings, moodLog und weitere verbundene Dienste."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Benachrichtigungen",
|
||||
"description": "Telegram, ntfy, Web Push."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Übersicht",
|
||||
"description": "Kachel-Layout, Schwellwerte."
|
||||
},
|
||||
"ai": {
|
||||
"title": "KI-Auswertungen",
|
||||
"description": "Provider, Modell, API-Key."
|
||||
},
|
||||
"api": {
|
||||
"title": "API & Tokens",
|
||||
"description": "Bearer-Tokens für Drittanbieter-Clients."
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Erweitert",
|
||||
"description": "Export, Import, Daten löschen."
|
||||
},
|
||||
"about": {
|
||||
"title": "Über",
|
||||
"description": "Version, Lizenz, Links."
|
||||
},
|
||||
"placeholder": {
|
||||
"coming_soon": "Wird mit dem nächsten Release-Update hierher umziehen."
|
||||
}
|
||||
},
|
||||
"sectionsTitle": "Settings",
|
||||
"categoryGeneral": "Allgemein",
|
||||
"categoryNotifications": "Benachrichtigungen",
|
||||
"categoryAi": "KI",
|
||||
"categoryPersonalization": "Personalisierung",
|
||||
"categoryIntegration": "Integration",
|
||||
"categoryApi": "API",
|
||||
"profile": "Profil",
|
||||
@@ -651,6 +957,11 @@
|
||||
"passkeyOptionsError": "Konnte Passkey-Optionen nicht laden",
|
||||
"passkeyRegistrationFailed": "Fehler bei Passkey-Registrierung",
|
||||
"passkeyRegistrationCancelled": "Passkey-Registrierung abgebrochen",
|
||||
"passkeyNotSupported": "Dein Browser oder Gerät unterstützt Passkeys noch nicht. Bitte einen anderen Browser oder ein anderes Gerät verwenden.",
|
||||
"passkeyAlreadyRegistered": "Dieses Gerät ist bereits als Passkey für dein Konto registriert.",
|
||||
"passkeySecurityBlocked": "Dein Browser hat die Passkey-Anfrage aus Sicherheitsgründen blockiert. Bitte auf einer vertrauenswürdigen HTTPS-URL erneut versuchen.",
|
||||
"passkeyTimeout": "Die Passkey-Aufforderung ist abgelaufen. Bitte erneut versuchen.",
|
||||
"passkeyUnknownError": "Passkey-Registrierung fehlgeschlagen: {message}",
|
||||
"deletePasskey": "Passkey löschen?",
|
||||
"deletePasskeyDescription": "Der Passkey wird unwiderruflich gelöscht.",
|
||||
"singleDevice": "Einzelgerät",
|
||||
@@ -697,6 +1008,9 @@
|
||||
"apiKeyStored": "Key hinterlegt — neuen eingeben zum Ersetzen",
|
||||
"apiKeySaved": "API-Key gespeichert",
|
||||
"apiKeyRemoved": "API-Key entfernt",
|
||||
"codexConnected": "ChatGPT erfolgreich verbunden! KI-Insights sind jetzt aktiv.",
|
||||
"codexDisconnected": "ChatGPT-Verbindung getrennt.",
|
||||
"codexConnectionFailed": "ChatGPT-Verbindung fehlgeschlagen. Bitte versuche es erneut.",
|
||||
"rawData": "Rohdaten mitsenden",
|
||||
"rawDataOnDescription": "Es werden aggregierte Kennzahlen plus anonymisierte Rohdaten der letzten 30 Tage gesendet. Dazu zählen einzelne Messpunkte je Bereich (z. B. Gewicht, Blutdruck, Puls) mit Zeitbezug, damit die KI zeitliche Muster, Ausreißer und Zusammenhänge zuverlässiger erkennen kann. Nicht übertragen werden Name, E-Mail oder direkte Kontodaten.",
|
||||
"rawDataOffDescription": "Es werden nur zusammengefasste Werte gesendet (z. B. Durchschnitt, Trend, Minimum/Maximum). Keine einzelnen Messpunkte und keine exakten Zeitbezüge. Das ist datensparsamer, die KI kann dafür aber weniger fein auf Muster und kurzfristige Schwankungen eingehen.",
|
||||
@@ -715,6 +1029,7 @@
|
||||
"withingsCredentialsSavedPlaceholderSecret": "Gespeichert — neues eingeben zum Ersetzen",
|
||||
"withingsSaveCredentials": "Zugangsdaten speichern",
|
||||
"withingsConnected": "Verbunden",
|
||||
"configured": "Konfiguriert",
|
||||
"withingsTokenExpired": "Token abgelaufen",
|
||||
"withingsLastSync": "Letzte Sync",
|
||||
"withingsSync": "Jetzt synchronisieren",
|
||||
@@ -838,11 +1153,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",
|
||||
"checkUpdates": "Auf Updates prüfen",
|
||||
"upToDate": "Du nutzt die aktuelle Version",
|
||||
"newerAvailable": "Neue Version: {tag}"
|
||||
},
|
||||
"testConnection": {
|
||||
"test": "Verbindung testen",
|
||||
"testing": "Teste…",
|
||||
"ok": "Verbunden (Latenz {latency} ms)",
|
||||
"errors": {
|
||||
"credentials_rejected": "Zugangsdaten abgelehnt — Token prüfen",
|
||||
"rate_limited": "Vom Upstream rate-limitiert",
|
||||
"timeout": "Request hat das Zeitlimit überschritten",
|
||||
"upstream_error": "Upstream hat einen Fehler gemeldet",
|
||||
"connection_failed": "Verbindung fehlgeschlagen",
|
||||
"not_configured": "Nicht konfiguriert",
|
||||
"url_not_public": "URL ist kein öffentlicher Endpunkt",
|
||||
"url_invalid": "URL ist ungültig",
|
||||
"redirected": "Endpunkt leitet weiter — URL prüfen",
|
||||
"endpoint_not_found": "Endpunkt unter der URL nicht gefunden",
|
||||
"credentials_unreadable": "Zugangsdaten nicht entschlüsselbar",
|
||||
"upstream_invalid_json": "Upstream-Antwort ist kein JSON",
|
||||
"vapid_not_configured": "Web Push nicht konfiguriert (VAPID-Keys fehlen)",
|
||||
"rate_limited_self": "Zu viele Test-Anfragen",
|
||||
"generic": "Test fehlgeschlagen"
|
||||
}
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
"subtitle": "Systemstatus, Benutzer und Einstellungen",
|
||||
"settingsSaveError": "Einstellungen konnten nicht gespeichert werden",
|
||||
"settingsSaveValidationError": "Bitte markierte Felder prüfen",
|
||||
"sectionsTitle": "Admin",
|
||||
"systemStatus": "Systemstatus",
|
||||
"version": "Version",
|
||||
@@ -870,6 +1221,8 @@
|
||||
"telegramGlobal": "Telegram-Benachrichtigungen systemweit erlauben.",
|
||||
"ntfyGlobal": "ntfy-Benachrichtigungen systemweit erlauben.",
|
||||
"webPushGlobal": "Browser-Push-Benachrichtigungen systemweit erlauben.",
|
||||
"integrationWebPush": "Web Push",
|
||||
"integrationBugReport": "Bug-Reports",
|
||||
"apiGlobal": "API-Endpunkte und API-Tokens systemweit erlauben.",
|
||||
"moodLogGlobal": "moodLog-Integration systemweit erlauben.",
|
||||
"sectionGeneral": "Allgemein",
|
||||
@@ -991,7 +1344,55 @@
|
||||
"reminderCheckNotifOn": "Benachrichtigungen an",
|
||||
"reminderCheckNotifOff": "Benachrichtigungen aus",
|
||||
"reminderCheckEventsToday": "Heutige Events",
|
||||
"reminderCheckNotifSent": "Gesendet"
|
||||
"reminderCheckNotifSent": "Gesendet",
|
||||
"feedback": {
|
||||
"title": "Feedback-Postfach",
|
||||
"description": "Eingegangene Rückmeldungen, Fehlerberichte und Feature-Wünsche.",
|
||||
"tabOpen": "Offen",
|
||||
"tabAcknowledged": "Bestätigt",
|
||||
"tabResolved": "Erledigt",
|
||||
"tabArchived": "Archiviert",
|
||||
"subject": "Betreff",
|
||||
"category": "Kategorie",
|
||||
"user": "Benutzer",
|
||||
"createdAt": "Eingegangen",
|
||||
"actions": "Aktionen",
|
||||
"details": "Details",
|
||||
"noEntries": "Keine Feedback-Einträge in dieser Kategorie.",
|
||||
"loading": "Lade Feedback...",
|
||||
"anonymous": "anonym",
|
||||
"categoryBug": "Bug",
|
||||
"categoryFeature": "Feature",
|
||||
"categoryQuestion": "Frage",
|
||||
"categoryOther": "Sonstiges",
|
||||
"statusOpen": "Offen",
|
||||
"statusAcknowledged": "Bestätigt",
|
||||
"statusResolved": "Erledigt",
|
||||
"statusArchived": "Archiviert",
|
||||
"dialogTitle": "Feedback-Details",
|
||||
"metadataHeading": "Metadaten",
|
||||
"metaUrl": "URL",
|
||||
"metaLocale": "Sprache",
|
||||
"metaUserAgent": "User-Agent",
|
||||
"metaAppVersion": "App-Version",
|
||||
"screenshotHeading": "Screenshot",
|
||||
"adminNote": "Admin-Notiz",
|
||||
"adminNotePlaceholder": "Interne Notiz (nicht für Benutzer sichtbar)",
|
||||
"saveNote": "Notiz speichern",
|
||||
"noteSaved": "Notiz gespeichert",
|
||||
"actionAcknowledge": "Bestätigen",
|
||||
"actionResolve": "Erledigen",
|
||||
"actionArchive": "Archivieren",
|
||||
"actionPublishGithub": "Auf GitHub veröffentlichen",
|
||||
"publishedToGithub": "Auf GitHub veröffentlicht",
|
||||
"viewIssue": "Issue ansehen",
|
||||
"publishSuccess": "Issue erstellt",
|
||||
"publishFailed": "GitHub-Veröffentlichung fehlgeschlagen",
|
||||
"updateFailed": "Aktualisierung fehlgeschlagen",
|
||||
"archiveConfirm": "Diesen Feedback-Eintrag archivieren?",
|
||||
"submittedBy": "Eingereicht von",
|
||||
"sectionTitle": "Feedback"
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
"title": "Erfolge",
|
||||
@@ -1171,10 +1572,9 @@
|
||||
},
|
||||
"bugreport": {
|
||||
"title": "Bugreport",
|
||||
"subtitle": "Melde einen Fehler oder ein Problem. Es wird automatisch ein GitHub Issue erstellt.",
|
||||
"subtitle": "Sende eine Fehlermeldung, einen Feature-Wunsch oder eine Frage. Der Server-Administrator sieht deine Einreichung.",
|
||||
"loginRequired": "Bitte anmelden, um einen Bug zu melden.",
|
||||
"bugTitle": "Titel",
|
||||
"bugTitlePlaceholder": "Kurze Beschreibung des Problems",
|
||||
"description": "Beschreibung",
|
||||
"descriptionPlaceholder": "Beschreibe den Fehler möglichst genau. Was hast du getan? Was ist passiert? Was hättest du erwartet?",
|
||||
"screenshot": "Screenshot (optional)",
|
||||
@@ -1185,7 +1585,19 @@
|
||||
"submit": "Senden",
|
||||
"success": "Vielen Dank! Dein Bug Report wurde erfolgreich übermittelt.",
|
||||
"viewOnGithub": "Auf GitHub ansehen",
|
||||
"errorCreating": "Fehler beim Erstellen"
|
||||
"errorCreating": "Fehler beim Erstellen",
|
||||
"notConfiguredTitle": "Bug-Reports noch nicht aktiviert",
|
||||
"notConfiguredUser": "Bug-Reports sind auf diesem Server nicht aktiv. Bitte wende dich an den Administrator.",
|
||||
"notConfiguredAdmin": "Für Bug-Reports wird eine GitHub-Integration benötigt (Issue-Token + Repository). Im Admin-Panel unter Einstellungen → Bug-Reports einrichten.",
|
||||
"openAdminSettings": "Admin-Einstellungen öffnen",
|
||||
"category": "Art",
|
||||
"categoryBug": "Fehlermeldung",
|
||||
"categoryFeature": "Feature-Wunsch",
|
||||
"categoryQuestion": "Frage",
|
||||
"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."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Benachrichtigungen",
|
||||
@@ -1208,5 +1620,177 @@
|
||||
"dateShort": "{day}.{month}.{year}",
|
||||
"timeShort": "{hours}:{minutes}",
|
||||
"dateTime": "{day}.{month}.{year}, {hours}:{minutes}"
|
||||
},
|
||||
"classifications": {
|
||||
"bmi": {
|
||||
"Underweight": "Untergewicht",
|
||||
"Normal": "Normalgewicht",
|
||||
"Overweight": "Übergewicht",
|
||||
"ObesityGradeI": "Adipositas Grad I",
|
||||
"ObesityGradeII": "Adipositas Grad II",
|
||||
"ObesityGradeIII": "Adipositas Grad III"
|
||||
},
|
||||
"bp": {
|
||||
"Optimal": "Optimal",
|
||||
"Normal": "Normal",
|
||||
"HighNormal": "Hoch-normal",
|
||||
"HypertensionGrade1": "Hypertonie Grad 1",
|
||||
"HypertensionGrade2": "Hypertonie Grad 2",
|
||||
"HypertensionGrade3": "Hypertonie Grad 3"
|
||||
},
|
||||
"pulse": {
|
||||
"Bradycardia": "Bradykardie",
|
||||
"Normal": "Normal",
|
||||
"Elevated": "Erhöht",
|
||||
"Tachycardia": "Tachykardie"
|
||||
},
|
||||
"pulseTarget": {
|
||||
"SignificantlyLow": "Deutlich niedrig",
|
||||
"SlightlyLow": "Leicht niedrig",
|
||||
"OnTarget": "Zielbereich",
|
||||
"SlightlyElevated": "Leicht erhöht",
|
||||
"SignificantlyElevated": "Deutlich erhöht"
|
||||
},
|
||||
"sleep": {
|
||||
"FarTooShort": "Deutlich zu kurz",
|
||||
"TooShort": "Zu kurz",
|
||||
"OnTarget": "Zielbereich",
|
||||
"SlightlyLong": "Etwas lang",
|
||||
"FarTooLong": "Deutlich zu lang"
|
||||
},
|
||||
"bodyFat": {
|
||||
"Essential": "Essenziell",
|
||||
"Athletic": "Athletisch",
|
||||
"Fitness": "Fitness",
|
||||
"Average": "Durchschnitt",
|
||||
"Obese": "Adipös"
|
||||
},
|
||||
"steps": {
|
||||
"VeryLow": "Sehr wenig",
|
||||
"LowActive": "Wenig aktiv",
|
||||
"ModeratelyActive": "Moderat aktiv",
|
||||
"Active": "Aktiv",
|
||||
"VeryActive": "Sehr aktiv"
|
||||
},
|
||||
"general": {
|
||||
"Good": "Gut",
|
||||
"Moderate": "Moderat",
|
||||
"Low": "Niedrig",
|
||||
"VeryGood": "Sehr gut",
|
||||
"VeryStable": "Sehr stabil",
|
||||
"Stable": "Stabil",
|
||||
"Fluctuating": "Schwankend"
|
||||
},
|
||||
"alerts": {
|
||||
"bmiBelowNormalTitle": "BMI unter Normalbereich",
|
||||
"bmiBelowNormalMessage": "Ihr BMI von {bmi} liegt unter dem Normalbereich (18,5–24,9).",
|
||||
"bmiOverweightTitle": "BMI im Bereich Übergewicht",
|
||||
"bmiOverweightMessage": "Ihr BMI von {bmi} liegt im Bereich Übergewicht. Regelmäßige Bewegung und ausgewogene Ernährung können helfen.",
|
||||
"bmiObesityTitle": "BMI im Bereich Adipositas",
|
||||
"bmiObesityMessage": "Ihr BMI von {bmi} liegt im Bereich Adipositas.",
|
||||
"bmiNormalTitle": "BMI im Normalbereich",
|
||||
"bmiNormalMessage": "Ihr BMI von {bmi} liegt im gesunden Bereich.",
|
||||
"bpDangerTitle": "Blutdruck deutlich erhöht",
|
||||
"bpDangerMessage": "Ihr durchschnittlicher Blutdruck ({sys}/{dia}) wird als \"{category}\" eingestuft.",
|
||||
"bpWarningTitle": "Blutdruck leicht erhöht",
|
||||
"bpWarningMessage": "Ihr durchschnittlicher Blutdruck ({sys}/{dia}) liegt im Bereich \"{category}\".",
|
||||
"bpTargetGoodTitle": "Gute Blutdruck-Zielbereichstreue",
|
||||
"bpTargetGoodMessage": "{pct}% Ihrer Messungen liegen im Zielbereich.",
|
||||
"bpTargetBadTitle": "Blutdruck oft außerhalb des Zielbereichs",
|
||||
"bpTargetBadMessage": "Nur {pct}% Ihrer Messungen liegen im Zielbereich.",
|
||||
"weightIncreasingTitle": "Gewicht steigend",
|
||||
"weightIncreasingMessage": "Ihr Gewicht steigt um ca. {change} kg pro Woche in den letzten 30 Tagen.",
|
||||
"weightDecreasingTitle": "Gewicht fallend",
|
||||
"weightDecreasingMessage": "Ihr Gewicht sinkt um ca. {change} kg pro Woche in den letzten 30 Tagen.",
|
||||
"pulseOutliersTitle": "Puls-Ausreißer erkannt",
|
||||
"pulseOutliersMessage": "{count} ungewöhnliche Pulsmessungen in den letzten 30 Tagen.",
|
||||
"complianceLowTitle": "Niedrige Einnahmetreue: {name}",
|
||||
"complianceLowMessage": "Ihre 7-Tage-Compliance für {name} liegt bei nur {pct}%. Regelmäßige Einnahme ist wichtig.",
|
||||
"complianceGoodTitle": "Sehr gute Einnahmetreue: {name}",
|
||||
"complianceGoodMessage": "{pct}% Compliance in den letzten 7 Tagen. Weiter so!"
|
||||
}
|
||||
},
|
||||
"targets": {
|
||||
"title": "Zielwerte",
|
||||
"introText": "Deine aktuellen Werte im Vergleich zu empfohlenen Zielbereichen. Quellen der Leitlinien sind auf jeder Karte verlinkt.",
|
||||
"weight": "Gewicht",
|
||||
"bloodPressure": "Blutdruck",
|
||||
"bloodPressureOnTarget": "Blutdruck im Zielbereich",
|
||||
"restingPulse": "Ruhepuls",
|
||||
"sleepDuration": "Schlafdauer",
|
||||
"bmi": "BMI",
|
||||
"bodyFat": "Körperfett",
|
||||
"stepsPerDay": "Schritte/Tag",
|
||||
"medicationCompliance": "Einnahmetreue",
|
||||
"mood": "Stimmung",
|
||||
"moodStability": "Stimmungsstabilität",
|
||||
"steps": "Schritte",
|
||||
"average30d": "30-Tage-Schnitt:",
|
||||
"currentValue": "Aktuell: {value} {unit}",
|
||||
"targetRangeValue": "Zielbereich: {min}–{max} {unit}",
|
||||
"sourceLabel": "Quelle: {source}",
|
||||
"inTarget": "Im Zielbereich",
|
||||
"aboveTarget": "{delta} {unit} über Zielbereich",
|
||||
"belowTarget": "{delta} {unit} unter Zielbereich",
|
||||
"diastolic": "Diastolisch",
|
||||
"noMeasurementYet": "Noch keine Messungen",
|
||||
"noMeasurementData": "Keine Messdaten verfügbar. Füge Messwerte hinzu, um deine Ziele zu sehen.",
|
||||
"profileIncomplete": "Profil unvollständig",
|
||||
"profileIncompleteHeight": "Trage deine Körpergröße im Profil ein, um BMI-Ziele zu aktivieren.",
|
||||
"profileIncompleteAge": "Trage dein Geburtsdatum im Profil ein, um altersspezifische Ziele zu aktivieren.",
|
||||
"profileIncompleteHeightAge": "Trage Körpergröße und Geburtsdatum im Profil ein, um personalisierte Ziele zu sehen.",
|
||||
"compliancePerMedication": "Einnahmetreue pro Medikament",
|
||||
"glucoseFasting": "Glukose — nüchtern",
|
||||
"glucosePostprandial": "Glukose — postprandial",
|
||||
"glucoseRandom": "Glukose — beliebig",
|
||||
"glucoseBedtime": "Glukose — vor dem Schlafen",
|
||||
"loadError": "Zielwerte konnten nicht geladen werden"
|
||||
},
|
||||
"passwordStrength": {
|
||||
"veryWeak": "Sehr schwach",
|
||||
"weak": "Schwach",
|
||||
"acceptable": "Akzeptabel",
|
||||
"strong": "Stark",
|
||||
"veryStrong": "Sehr stark",
|
||||
"tooShort": "Zu kurz",
|
||||
"characters": "Zeichen",
|
||||
"minLength": "Mindestens {count} Zeichen erforderlich."
|
||||
},
|
||||
"telegram": {
|
||||
"errorInvalidAction": "Ungültige Aktion.",
|
||||
"errorMedicationNotFound": "Medikament nicht gefunden.",
|
||||
"errorMedicationInactive": "Medikament nicht gefunden oder inaktiv.",
|
||||
"errorUnknownAction": "Unbekannte Aktion.",
|
||||
"snoozeOneHour": "1 Stunde",
|
||||
"snoozeThreeHours": "3 Stunden",
|
||||
"snoozedFor": "{name} für {duration} zurückgestellt.",
|
||||
"skipped": "{name} übersprungen.",
|
||||
"alreadyRecorded": "{name} war bereits erfasst.",
|
||||
"recordedAsTaken": "{name} als eingenommen erfasst.",
|
||||
"confirmed": "{name} bestätigt.",
|
||||
"genericConfirmed": "Bestätigt.",
|
||||
"cancelled": "Abgebrochen.",
|
||||
"noActiveMedications": "Keine aktiven Medikamente gefunden.",
|
||||
"whichMedication": "<b>Welches Medikament eingenommen?</b>",
|
||||
"cancelButton": "Abbrechen",
|
||||
"askMedicationName": "Bitte Medikament angeben, z.B. <b>genommen Ramipril</b>.",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
+641
-57
@@ -36,7 +36,93 @@
|
||||
"replace": "Replace",
|
||||
"add": "Add",
|
||||
"import": "Import",
|
||||
"test": "Test"
|
||||
"test": "Test",
|
||||
"reset": "Reset",
|
||||
"copy": "Copy",
|
||||
"copyDetails": "Copy details",
|
||||
"retry": "Retry",
|
||||
"reportIssue": "Report issue"
|
||||
},
|
||||
"doctorReport": {
|
||||
"title": "Health Report",
|
||||
"subtitle": "HealthLog — Personal Health Report",
|
||||
"patient": "Patient",
|
||||
"dateOfBirth": "Date of birth",
|
||||
"gender": "Gender",
|
||||
"genderMale": "Male",
|
||||
"genderFemale": "Female",
|
||||
"genderOther": "Non-binary",
|
||||
"height": "Height",
|
||||
"period": "Reporting period",
|
||||
"createdOn": "Created on",
|
||||
"vitalsTitle": "Vital signs — Summary",
|
||||
"colParameter": "Parameter",
|
||||
"colCurrent": "Current",
|
||||
"colAvgPeriod": "Avg. period",
|
||||
"colMin": "Min",
|
||||
"colMax": "Max",
|
||||
"colN": "n",
|
||||
"bpClassificationTitle": "Blood pressure — ESH classification (2023)",
|
||||
"avgBp": "Average blood pressure",
|
||||
"classification": "Classification",
|
||||
"bmiTitle": "Body Mass Index (BMI)",
|
||||
"bmiRow": "BMI: {bmi} kg/m² — Classification: {class} (WHO)",
|
||||
"complianceTitle": "Medication compliance",
|
||||
"colMedication": "Medication",
|
||||
"colTaken": "Taken",
|
||||
"colSkipped": "Skipped",
|
||||
"colMissed": "Missed",
|
||||
"colTotal": "Total",
|
||||
"colComplianceRate": "Compliance rate",
|
||||
"moodTitle": "Mood trajectory",
|
||||
"moodSummary": "Average: {avg} / 5.0 · Entries: {count} · Range: {min}–{max}",
|
||||
"colMood": "Mood",
|
||||
"colCount": "Count",
|
||||
"colShare": "Share",
|
||||
"footerDisclaimer1": "This report was generated automatically from self-tracked data and is intended to support",
|
||||
"footerDisclaimer2": "the doctor–patient conversation. It does not replace a medical diagnosis. Correlations do not imply causation.",
|
||||
"footerSource": "Source: HealthLog | Timezone: Europe/Berlin | Created: {timestamp}",
|
||||
"typeWeight": "Body weight",
|
||||
"typeBpSys": "Systolic blood pressure",
|
||||
"typeBpDia": "Diastolic blood pressure",
|
||||
"typePulse": "Resting pulse",
|
||||
"typeBodyFat": "Body fat",
|
||||
"typeSleep": "Sleep duration",
|
||||
"typeSteps": "Activity (steps)",
|
||||
"typeTotalBodyWater": "Total body water",
|
||||
"typeBoneMass": "Bone mass",
|
||||
"typeOxygenSaturation": "Oxygen saturation",
|
||||
"unitSteps": "steps",
|
||||
"bmiUnderweight": "Underweight",
|
||||
"bmiNormal": "Normal weight",
|
||||
"bmiOverweight": "Overweight (pre-obese)",
|
||||
"bmiObeseGrade1": "Obesity grade I",
|
||||
"bmiObeseGrade2": "Obesity grade II",
|
||||
"bmiObeseGrade3": "Obesity grade III",
|
||||
"bpOptimal": "Optimal",
|
||||
"bpNormal": "Normal",
|
||||
"bpHighNormal": "High-normal",
|
||||
"bpHypertensionGrade1": "Hypertension grade 1",
|
||||
"bpHypertensionGrade2": "Hypertension grade 2",
|
||||
"bpHypertensionGrade3": "Hypertension grade 3",
|
||||
"moodAwful": "Very bad",
|
||||
"moodBad": "Bad",
|
||||
"moodNeutral": "Neutral",
|
||||
"moodGood": "Good",
|
||||
"moodGreat": "Very good",
|
||||
"typeGlucoseFasting": "Glucose (fasting)",
|
||||
"typeGlucosePostprandial": "Glucose (postprandial)",
|
||||
"typeGlucoseRandom": "Glucose (random)",
|
||||
"typeGlucoseBedtime": "Glucose (bedtime)",
|
||||
"glucoseClassificationTitle": "Blood glucose — reference ranges (ADA 2024 / DDG)",
|
||||
"glucoseRow": "{label}: avg {avg} {unit} (target {rangeMin}–{rangeMax}) — {class}",
|
||||
"glucoseInTarget": "in target",
|
||||
"glucoseBelowTarget": "below target",
|
||||
"glucoseAboveTarget": "above target"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Something went wrong",
|
||||
"description": "An unexpected error occurred. You can retry, copy the technical details, or open a bug report."
|
||||
},
|
||||
"nav": {
|
||||
"home": "Home",
|
||||
@@ -90,6 +176,7 @@
|
||||
"backToPasskey": "Back to Passkey",
|
||||
"passkeySetupLater": "You can set up passkeys at any time in Settings.",
|
||||
"passwordMinLength": "Min. 12 characters",
|
||||
"passwordTooShort": "Password must be at least {minLength} characters long.",
|
||||
"passwordStrength": "Password strength",
|
||||
"strengthWeak": "Very weak",
|
||||
"strengthFair": "Weak",
|
||||
@@ -107,12 +194,16 @@
|
||||
},
|
||||
"welcomeBack": "{greeting}, welcome back.",
|
||||
"welcomeBackWithName": "{greeting} {name}, welcome back.",
|
||||
"greetingSalutation": "Hi, {name}",
|
||||
"streakLabel": "days in a row",
|
||||
"weight": "Weight",
|
||||
"bloodPressure": "Blood Pressure",
|
||||
"bloodPressureSys": "Blood Pressure (Sys)",
|
||||
"bloodPressureDia": "Blood Pressure (Dia)",
|
||||
"pulse": "Pulse",
|
||||
"bodyFat": "Body Fat",
|
||||
"sleep": "Sleep",
|
||||
"steps": "Steps",
|
||||
"bpInTarget": "BP in Target",
|
||||
"days30Short": "30d",
|
||||
"medications": "Medications",
|
||||
@@ -127,7 +218,17 @@
|
||||
"compliance7d": "Compliance (7 days)",
|
||||
"complianceGood": "Good",
|
||||
"complianceLow": "Low",
|
||||
"mood": "Mood"
|
||||
"mood": "Mood",
|
||||
"customizeTitle": "Dashboard layout",
|
||||
"customizeSubtitle": "Choose which cards appear on your dashboard and in what order. Defaults work out of the box.",
|
||||
"layoutReset": "Reset to defaults",
|
||||
"layoutSaveSuccess": "Dashboard layout saved",
|
||||
"layoutSaveError": "Could not save dashboard layout",
|
||||
"layoutResetSuccess": "Reset to default layout",
|
||||
"layoutUsingDefaults": "Using default layout",
|
||||
"layoutCustomized": "Custom layout active",
|
||||
"moveUp": "Move up",
|
||||
"moveDown": "Move down"
|
||||
},
|
||||
"measurements": {
|
||||
"title": "Measurements",
|
||||
@@ -148,6 +249,17 @@
|
||||
"typeBodyFat": "Body Fat",
|
||||
"typeSleep": "Sleep",
|
||||
"typeSteps": "Steps",
|
||||
"typeBloodGlucose": "Blood glucose",
|
||||
"typeTotalBodyWater": "Total body water",
|
||||
"typeBoneMass": "Bone mass",
|
||||
"typeOxygenSaturation": "Oxygen saturation",
|
||||
"glucoseContext": "Context",
|
||||
"glucoseContextFasting": "Fasting (≥8h without food)",
|
||||
"glucoseContextPostprandial": "Post-meal (2h after eating)",
|
||||
"glucoseContextRandom": "Random / casual",
|
||||
"glucoseContextBedtime": "Before bed",
|
||||
"glucoseUnit": "Glucose display unit",
|
||||
"glucoseUnitHint": "Stored canonically in mg/dL; display unit is your preference.",
|
||||
"unitHours": "hours",
|
||||
"unitSteps": "steps",
|
||||
"sourceManual": "Manual",
|
||||
@@ -385,7 +497,21 @@
|
||||
"phaseAfterEnd": "after end",
|
||||
"phaseResetDefaults": "Reset to defaults",
|
||||
"phaseSaved": "Reminder phases saved",
|
||||
"phaseReset": "Reset to defaults"
|
||||
"phaseReset": "Reset to defaults",
|
||||
"sourceWeb": "Website",
|
||||
"sourceApi": "External API",
|
||||
"sourceReminder": "Telegram / Reminder",
|
||||
"sourceImport": "CSV Import",
|
||||
"unitDrops": "Drops",
|
||||
"unitTablets": "Tablet(s)",
|
||||
"unitCapsules": "Capsule(s)",
|
||||
"unitPieces": "Piece(s)",
|
||||
"namePlaceholder": "e.g. Ibuprofen",
|
||||
"dosePlaceholder": "e.g. 400",
|
||||
"labelPlaceholder": "e.g. Morning",
|
||||
"defaultDose": "Default",
|
||||
"dayDeactivate": "Deactivate {day}",
|
||||
"dayActivate": "Activate {day}"
|
||||
},
|
||||
"charts": {
|
||||
"title": "History",
|
||||
@@ -406,17 +532,22 @@
|
||||
"noData": "No data in selected time range",
|
||||
"movingAverage": "Moving average",
|
||||
"target": "Target range",
|
||||
"points7Label": "7P",
|
||||
"points7Title": "Last seven data points",
|
||||
"points30Label": "30P",
|
||||
"points30Title": "Last thirty data points",
|
||||
"points90Label": "90P",
|
||||
"points90Title": "Last ninety data points",
|
||||
"points7Label": "7 pts",
|
||||
"points7Title": "Last 7 data points",
|
||||
"points30Label": "30 pts",
|
||||
"points30Title": "Last 30 data points",
|
||||
"points90Label": "90 pts",
|
||||
"points90Title": "Last 90 data points",
|
||||
"pointsAllLabel": "All",
|
||||
"pointsAllTitle": "All available data points",
|
||||
"systolic": "Systolic",
|
||||
"diastolic": "Diastolic",
|
||||
"bodyWater": "Body water",
|
||||
"boneMass": "Bone mass",
|
||||
"spo2": "SpO₂",
|
||||
"movingAverage7d": "7d avg",
|
||||
"avg7dShort": "7d",
|
||||
"avg30dShort": "30d",
|
||||
"trend": "Trend",
|
||||
"targetRanges": "Target ranges",
|
||||
"perWeek": "/week",
|
||||
@@ -429,9 +560,62 @@
|
||||
"colorRed": "Red:",
|
||||
"mood": "Mood",
|
||||
"moodScore": "Mood Score",
|
||||
"moodMA": "7-Day Average"
|
||||
"moodMA": "7-Day Average",
|
||||
"moodLabel1": "Awful",
|
||||
"moodLabel2": "Bad",
|
||||
"moodLabel3": "Okay",
|
||||
"moodLabel4": "Good",
|
||||
"moodLabel5": "Great",
|
||||
"weekdays": {
|
||||
"mon": "Mon",
|
||||
"wed": "Wed",
|
||||
"fri": "Fri",
|
||||
"sun": "Sun"
|
||||
},
|
||||
"months": {
|
||||
"jan": "Jan",
|
||||
"feb": "Feb",
|
||||
"mar": "Mar",
|
||||
"apr": "Apr",
|
||||
"may": "May",
|
||||
"jun": "Jun",
|
||||
"jul": "Jul",
|
||||
"aug": "Aug",
|
||||
"sep": "Sep",
|
||||
"oct": "Oct",
|
||||
"nov": "Nov",
|
||||
"dec": "Dec"
|
||||
},
|
||||
"heatmapOnTime": "on time",
|
||||
"heatmapLate": "late",
|
||||
"heatmapVeryLate": "very late",
|
||||
"legendOnTime": "On time",
|
||||
"legendLate": "Late",
|
||||
"legendVeryLate": "Very late",
|
||||
"legendMissed": "Missed"
|
||||
},
|
||||
"insights": {
|
||||
"aiAnalysisTitle": "AI Health Analysis",
|
||||
"noAnalysisYet": "No analysis yet.",
|
||||
"noProviderConfigured": "AI provider not configured.",
|
||||
"startAnalysis": "Start analysis",
|
||||
"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",
|
||||
"dataGaps": "Data gaps:",
|
||||
"lastUpdated": "Last updated",
|
||||
"confidenceHoch": "High",
|
||||
"confidenceMittel": "Medium",
|
||||
"confidenceGering": "Low",
|
||||
"loadError": "Failed to load insights",
|
||||
"generationFailed": "The analysis couldn't be generated. Please try again.",
|
||||
"title": "Insights",
|
||||
"subtitle": "AI-powered analysis of your health data",
|
||||
"overviewSubtitle": "Trends, risks, progress at a glance",
|
||||
@@ -450,7 +634,7 @@
|
||||
"inTargetRange": "in target range",
|
||||
"bpTarget": "Target: {range}",
|
||||
"noDobSet": "Date of birth not set.",
|
||||
"avg30dEsc": "30d average · ESC/ESH 2018",
|
||||
"avg30dEsc": "30d average · ESH 2023",
|
||||
"whoClassification": "WHO Classification",
|
||||
"correlations": "Correlations",
|
||||
"weightVsBp": "Weight vs. Blood Pressure (Systolic)",
|
||||
@@ -543,47 +727,55 @@
|
||||
"moodWeightNone": "No detectable correlation between mood and weight.",
|
||||
"moodWeightStrong": "There is a strong correlation between mood and weight.",
|
||||
"moodWeightWeak": "There is a weak correlation between mood and weight.",
|
||||
"notEnoughMoodCorrelationData": "Not enough mood data for correlation analysis."
|
||||
"notEnoughMoodCorrelationData": "Not enough mood data for correlation analysis.",
|
||||
"refreshButton": "Refresh",
|
||||
"generateButton": "Generate",
|
||||
"analyzing": "Analyzing health data...",
|
||||
"confidence": "Confidence",
|
||||
"confidence_niedrig": "low",
|
||||
"confidence_mittel": "medium",
|
||||
"confidence_hoch": "high",
|
||||
"cached": "Cached",
|
||||
"sectionChanged": "What has changed",
|
||||
"sectionStable": "What is stable",
|
||||
"sectionDrivers": "Possible correlations",
|
||||
"sectionNextSteps": "Next steps",
|
||||
"generatePrompt": "Click \"Generate\" for AI-powered insights about your health data."
|
||||
},
|
||||
"targets": {
|
||||
"title": "Targets",
|
||||
"subtitle": "Personal reference ranges based on your profile",
|
||||
"introText": "These targets are personal reference ranges derived from your profile and established medical guidelines. They help you quickly classify your key health values and detect changes early. The ranges are calculated individually for you, for example based on age, sex, and height. For each value, you can immediately see whether you are within the target range or above or below it. This gives you a compact overview of which targets you are currently meeting well and where improvement is still possible.",
|
||||
"current": "Current",
|
||||
"average30d": "30-day average:",
|
||||
"targetRange": "Target range",
|
||||
"classification": "Rating",
|
||||
"source": "Source",
|
||||
"sourceLabel": "Source: {source}",
|
||||
"trend": "Trend",
|
||||
"weight": "Weight",
|
||||
"bloodPressure": "Blood Pressure",
|
||||
"pulse": "Resting Pulse",
|
||||
"sleep": "Sleep Duration",
|
||||
"bmi": "BMI",
|
||||
"bodyFat": "Body Fat",
|
||||
"steps": "Steps/Day",
|
||||
"compliance": "Compliance",
|
||||
"compliancePerMedication": "Per medication",
|
||||
"diastolic": "Diastolic",
|
||||
"noProfile": "Please complete your profile to get personalized targets.",
|
||||
"profileIncomplete": "Profile incomplete",
|
||||
"profileIncompleteHeightAge": "Add your height and date of birth in your profile to get personalized target ranges.",
|
||||
"profileIncompleteHeight": "Add your height in your profile to calculate weight target range.",
|
||||
"profileIncompleteAge": "Add your date of birth in your profile to get age-based target ranges.",
|
||||
"noMeasurementData": "No measurement data available yet.",
|
||||
"noMeasurementYet": "No measurement available yet",
|
||||
"currentValue": "Current: {value} {unit}",
|
||||
"targetRangeValue": "Target range: {min}–{max} {unit}",
|
||||
"inTarget": "In target range",
|
||||
"belowTarget": "{delta} {unit} below target",
|
||||
"aboveTarget": "{delta} {unit} above target",
|
||||
"loadError": "Error loading data",
|
||||
"classificationOptimal": "Optimal",
|
||||
"classificationNormal": "Normal",
|
||||
"classificationElevated": "Elevated",
|
||||
"classificationHigh": "High",
|
||||
"classificationLow": "Low"
|
||||
"thresholds": {
|
||||
"title": "Personal thresholds",
|
||||
"subtitle": "Override the computed target range for any metric. Defaults stay in effect until you set a value here.",
|
||||
"defaultLabel": "Default",
|
||||
"overrideLabel": "Your override",
|
||||
"autoModeLabel": "Auto",
|
||||
"overrideModeLabel": "Override",
|
||||
"resetAction": "Reset to default",
|
||||
"resetAllAction": "Reset all to default",
|
||||
"minLabel": "Min",
|
||||
"maxLabel": "Max",
|
||||
"unitSuffix": "({unit})",
|
||||
"outOfBoundsHint": "Allowed range: {min}–{max} {unit}",
|
||||
"overrideWarning": "Overriding with values outside guideline ranges may produce unhealthy targets. Only do this on doctor's advice.",
|
||||
"saveSuccess": "Thresholds updated",
|
||||
"saveError": "Could not save thresholds",
|
||||
"resetSuccess": "Reset to default",
|
||||
"sourceOverride": "User override",
|
||||
"metricWeight": "Weight",
|
||||
"metricBpSys": "Blood pressure (systolic)",
|
||||
"metricBpDia": "Blood pressure (diastolic)",
|
||||
"metricPulse": "Resting pulse",
|
||||
"metricBodyFat": "Body fat",
|
||||
"metricSleep": "Sleep duration",
|
||||
"metricSteps": "Daily steps",
|
||||
"metricGlucoseFasting": "Glucose — fasting",
|
||||
"metricGlucosePostprandial": "Glucose — post-meal",
|
||||
"metricGlucoseRandom": "Glucose — random",
|
||||
"metricGlucoseBedtime": "Glucose — bedtime",
|
||||
"metricBodyWater": "Body water",
|
||||
"metricBoneMass": "Bone mass",
|
||||
"metricOxygenSaturation": "Oxygen saturation (SpO₂)",
|
||||
"unsetExplanation": "No override — using computed default.",
|
||||
"loadError": "Could not load thresholds"
|
||||
},
|
||||
"onboarding": {
|
||||
"welcome": "Welcome to HealthLog!",
|
||||
@@ -603,15 +795,129 @@
|
||||
"targetsDescription": "Set optional target values for blood pressure and weight.",
|
||||
"targetSys": "Systolic (mmHg)",
|
||||
"targetDia": "Diastolic (mmHg)",
|
||||
"targetWeight": "Target weight (kg)"
|
||||
"targetWeight": "Target weight (kg)",
|
||||
"errorProfile": "Could not save your profile. Please try again.",
|
||||
"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.",
|
||||
"v2": {
|
||||
"title": "Welcome to HealthLog",
|
||||
"subtitle": "Three quick steps. You can finish anything later from Settings.",
|
||||
"stepOf": "Step {current} of {total}",
|
||||
"back": "Back",
|
||||
"continue": "Continue",
|
||||
"finish": "Finish setup",
|
||||
"skipStep": "Skip this step — you can finish setup later from Settings.",
|
||||
"step1": {
|
||||
"title": "About you",
|
||||
"description": "Used for blood-pressure targets, BMI, and the right reminders.",
|
||||
"displayNameLabel": "Display name",
|
||||
"displayNameHint": "Shown on the dashboard greeting and in reports.",
|
||||
"languageLabel": "Language",
|
||||
"languageHint": "We'll use this for the app, PDFs, and notifications."
|
||||
},
|
||||
"step2": {
|
||||
"title": "Log your first measurement",
|
||||
"description": "Weight, blood pressure, or pulse — whatever you have right now. The dashboard comes alive with one entry.",
|
||||
"added": "Got it — you can add more anytime from the dashboard.",
|
||||
"skipHint": "No measurement at hand? Skip and log one when you're ready."
|
||||
},
|
||||
"step3": {
|
||||
"title": "Pick one notification channel",
|
||||
"description": "We'll only send what you ask for. You can add more channels or fine-tune events later.",
|
||||
"channelTelegramTitle": "Telegram bot",
|
||||
"channelTelegramHint": "Reminders with snooze buttons inside Telegram.",
|
||||
"channelWebPushTitle": "Web Push",
|
||||
"channelWebPushHint": "Native browser notifications on this device.",
|
||||
"channelNtfyTitle": "ntfy.sh",
|
||||
"channelNtfyHint": "Open-source push to phone or desktop, no account.",
|
||||
"channelNoneTitle": "Skip for now",
|
||||
"channelNoneHint": "I'll set up notifications later from Settings.",
|
||||
"openSettings": "Continue in Settings"
|
||||
},
|
||||
"doneToast": "You're all set — welcome aboard."
|
||||
}
|
||||
},
|
||||
"gettingStarted": {
|
||||
"title": "Getting started",
|
||||
"subtitle": "Quick wins to get the most out of HealthLog.",
|
||||
"progress": "{done} of {total} done",
|
||||
"dismiss": "Dismiss",
|
||||
"dismissAll": "Hide checklist",
|
||||
"dismissTooltip": "Hide this item",
|
||||
"restoreHint": "You can restore the checklist from Settings.",
|
||||
"items": {
|
||||
"profileTitle": "Set your profile",
|
||||
"profileDescription": "Height, date of birth and gender unlock personalised targets.",
|
||||
"profileCta": "Open profile",
|
||||
"measurementTitle": "Log your first measurement",
|
||||
"measurementDescription": "One reading is enough to draw your first trend tile.",
|
||||
"measurementCta": "Add measurement",
|
||||
"medicationTitle": "Add a medication",
|
||||
"medicationDescription": "Track compliance and get reminders right when you need them.",
|
||||
"medicationCta": "Add medication",
|
||||
"withingsTitle": "Connect Withings (optional)",
|
||||
"withingsDescription": "Auto-sync weight, BP and pulse from your Withings devices.",
|
||||
"withingsCta": "Connect",
|
||||
"notificationsTitle": "Configure notifications",
|
||||
"notificationsDescription": "Choose how HealthLog should remind you — Telegram, ntfy or Web Push.",
|
||||
"notificationsCta": "Open notifications"
|
||||
}
|
||||
},
|
||||
"trendHints": {
|
||||
"title": "First trend after 5 readings",
|
||||
"remainingOne": "1 more reading to go.",
|
||||
"remainingMany": "{count} more readings to go."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"subtitle": "Profile, Security & Connections",
|
||||
"shell": {
|
||||
"sectionsNav": "Settings sections"
|
||||
},
|
||||
"sections": {
|
||||
"account": {
|
||||
"title": "Account",
|
||||
"description": "Profile, sign-in methods, and account deletion."
|
||||
},
|
||||
"integrations": {
|
||||
"title": "Integrations",
|
||||
"description": "Withings, moodLog, and other connected services."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"description": "Telegram, ntfy, Web Push."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"description": "Tile layout, thresholds."
|
||||
},
|
||||
"ai": {
|
||||
"title": "AI Insights",
|
||||
"description": "Provider, model, key."
|
||||
},
|
||||
"api": {
|
||||
"title": "API & Tokens",
|
||||
"description": "Bearer tokens for headless clients."
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Advanced",
|
||||
"description": "Export, import, danger zone."
|
||||
},
|
||||
"about": {
|
||||
"title": "About",
|
||||
"description": "Version, license, links."
|
||||
},
|
||||
"placeholder": {
|
||||
"coming_soon": "Moving here with the next release update."
|
||||
}
|
||||
},
|
||||
"sectionsTitle": "Settings",
|
||||
"categoryGeneral": "General",
|
||||
"categoryNotifications": "Notifications",
|
||||
"categoryAi": "AI",
|
||||
"categoryPersonalization": "Personalization",
|
||||
"categoryIntegration": "Integration",
|
||||
"categoryApi": "API",
|
||||
"profile": "Profile",
|
||||
@@ -651,6 +957,11 @@
|
||||
"passkeyOptionsError": "Could not load passkey options",
|
||||
"passkeyRegistrationFailed": "Passkey registration failed",
|
||||
"passkeyRegistrationCancelled": "Passkey registration cancelled",
|
||||
"passkeyNotSupported": "Your browser or device doesn't support passkeys yet. Try a different browser or device.",
|
||||
"passkeyAlreadyRegistered": "This device is already registered as a passkey for your account.",
|
||||
"passkeySecurityBlocked": "Your browser blocked the passkey request for security reasons. Make sure you're on a trusted HTTPS origin and try again.",
|
||||
"passkeyTimeout": "The passkey prompt timed out. Please try again.",
|
||||
"passkeyUnknownError": "Passkey registration failed: {message}",
|
||||
"deletePasskey": "Delete passkey?",
|
||||
"deletePasskeyDescription": "The passkey will be permanently deleted.",
|
||||
"singleDevice": "Single device",
|
||||
@@ -697,6 +1008,9 @@
|
||||
"apiKeyStored": "Key stored — enter new one to replace",
|
||||
"apiKeySaved": "API key saved",
|
||||
"apiKeyRemoved": "API key removed",
|
||||
"codexConnected": "ChatGPT connected successfully! AI insights are now active.",
|
||||
"codexDisconnected": "ChatGPT connection removed.",
|
||||
"codexConnectionFailed": "ChatGPT connection failed. Please try again.",
|
||||
"rawData": "Send raw data",
|
||||
"rawDataOnDescription": "Aggregated metrics plus anonymized raw points from the last 30 days are sent. This includes per-metric measurements (for example weight, blood pressure, pulse) with time context so AI can detect patterns, outliers, and correlations more reliably. Name, email, and direct account identifiers are not transmitted.",
|
||||
"rawDataOffDescription": "Only summarized metrics are sent (for example averages, trends, minimum/maximum). No individual points and no exact time context. This is more privacy-preserving, but AI can be less precise for short-term patterns and fluctuations.",
|
||||
@@ -715,6 +1029,7 @@
|
||||
"withingsCredentialsSavedPlaceholderSecret": "Saved — enter new to replace",
|
||||
"withingsSaveCredentials": "Save credentials",
|
||||
"withingsConnected": "Connected",
|
||||
"configured": "Configured",
|
||||
"withingsTokenExpired": "Token expired",
|
||||
"withingsLastSync": "Last sync",
|
||||
"withingsSync": "Sync now",
|
||||
@@ -838,11 +1153,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",
|
||||
"checkUpdates": "Check for updates",
|
||||
"upToDate": "You are up to date",
|
||||
"newerAvailable": "Newer version: {tag}"
|
||||
},
|
||||
"testConnection": {
|
||||
"test": "Test connection",
|
||||
"testing": "Testing…",
|
||||
"ok": "Connected (latency {latency} ms)",
|
||||
"errors": {
|
||||
"credentials_rejected": "Credentials rejected — check your token",
|
||||
"rate_limited": "Rate-limited by the upstream",
|
||||
"timeout": "Request timed out",
|
||||
"upstream_error": "Upstream returned an error",
|
||||
"connection_failed": "Connection failed",
|
||||
"not_configured": "Not configured",
|
||||
"url_not_public": "URL is not a public endpoint",
|
||||
"url_invalid": "URL is invalid",
|
||||
"redirected": "Endpoint redirects — check the URL",
|
||||
"endpoint_not_found": "Endpoint not found at the URL",
|
||||
"credentials_unreadable": "Credentials cannot be decrypted",
|
||||
"upstream_invalid_json": "Upstream sent invalid JSON",
|
||||
"vapid_not_configured": "Web Push not configured (VAPID keys missing)",
|
||||
"rate_limited_self": "Too many test requests",
|
||||
"generic": "Test failed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
"subtitle": "System status, users, and settings",
|
||||
"settingsSaveError": "Failed to save settings",
|
||||
"settingsSaveValidationError": "Please check the highlighted fields",
|
||||
"sectionsTitle": "Admin",
|
||||
"systemStatus": "System Status",
|
||||
"version": "Version",
|
||||
@@ -870,6 +1221,8 @@
|
||||
"telegramGlobal": "Allow Telegram notifications system-wide.",
|
||||
"ntfyGlobal": "Allow ntfy notifications system-wide.",
|
||||
"webPushGlobal": "Allow browser push notifications system-wide.",
|
||||
"integrationWebPush": "Web Push",
|
||||
"integrationBugReport": "Bug Report",
|
||||
"apiGlobal": "Allow API endpoints and API tokens system-wide.",
|
||||
"moodLogGlobal": "Allow moodLog integration system-wide.",
|
||||
"sectionGeneral": "General",
|
||||
@@ -991,7 +1344,55 @@
|
||||
"reminderCheckNotifOn": "Notifications on",
|
||||
"reminderCheckNotifOff": "Notifications off",
|
||||
"reminderCheckEventsToday": "Events today",
|
||||
"reminderCheckNotifSent": "Sent"
|
||||
"reminderCheckNotifSent": "Sent",
|
||||
"feedback": {
|
||||
"title": "Feedback Inbox",
|
||||
"description": "User-submitted feedback, bug reports, and feature requests.",
|
||||
"tabOpen": "Open",
|
||||
"tabAcknowledged": "Acknowledged",
|
||||
"tabResolved": "Resolved",
|
||||
"tabArchived": "Archived",
|
||||
"subject": "Subject",
|
||||
"category": "Category",
|
||||
"user": "User",
|
||||
"createdAt": "Submitted",
|
||||
"actions": "Actions",
|
||||
"details": "Details",
|
||||
"noEntries": "No feedback in this category.",
|
||||
"loading": "Loading feedback...",
|
||||
"anonymous": "anonymous",
|
||||
"categoryBug": "Bug",
|
||||
"categoryFeature": "Feature",
|
||||
"categoryQuestion": "Question",
|
||||
"categoryOther": "Other",
|
||||
"statusOpen": "Open",
|
||||
"statusAcknowledged": "Acknowledged",
|
||||
"statusResolved": "Resolved",
|
||||
"statusArchived": "Archived",
|
||||
"dialogTitle": "Feedback details",
|
||||
"metadataHeading": "Metadata",
|
||||
"metaUrl": "URL",
|
||||
"metaLocale": "Locale",
|
||||
"metaUserAgent": "User agent",
|
||||
"metaAppVersion": "App version",
|
||||
"screenshotHeading": "Screenshot",
|
||||
"adminNote": "Admin note",
|
||||
"adminNotePlaceholder": "Internal note (not visible to user)",
|
||||
"saveNote": "Save note",
|
||||
"noteSaved": "Note saved",
|
||||
"actionAcknowledge": "Acknowledge",
|
||||
"actionResolve": "Resolve",
|
||||
"actionArchive": "Archive",
|
||||
"actionPublishGithub": "Publish to GitHub",
|
||||
"publishedToGithub": "Published to GitHub",
|
||||
"viewIssue": "View issue",
|
||||
"publishSuccess": "Issue created",
|
||||
"publishFailed": "GitHub publish failed",
|
||||
"updateFailed": "Update failed",
|
||||
"archiveConfirm": "Archive this feedback entry?",
|
||||
"submittedBy": "Submitted by",
|
||||
"sectionTitle": "Feedback"
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
"title": "Achievements",
|
||||
@@ -1171,10 +1572,9 @@
|
||||
},
|
||||
"bugreport": {
|
||||
"title": "Bug Report",
|
||||
"subtitle": "Report a bug or issue. A GitHub issue will be created automatically.",
|
||||
"subtitle": "Send a bug report, feature request, or question. The server administrator will see your submission.",
|
||||
"loginRequired": "Please sign in to report a bug.",
|
||||
"bugTitle": "Title",
|
||||
"bugTitlePlaceholder": "Brief description of the problem",
|
||||
"description": "Description",
|
||||
"descriptionPlaceholder": "Describe the bug in detail. What did you do? What happened? What did you expect?",
|
||||
"screenshot": "Screenshot (optional)",
|
||||
@@ -1185,7 +1585,19 @@
|
||||
"submit": "Send",
|
||||
"success": "Thank you! Your bug report has been submitted successfully.",
|
||||
"viewOnGithub": "View on GitHub",
|
||||
"errorCreating": "Error creating report"
|
||||
"errorCreating": "Error creating report",
|
||||
"notConfiguredTitle": "Bug reports not available yet",
|
||||
"notConfiguredUser": "Bug reporting is disabled on this server. Please contact your administrator.",
|
||||
"notConfiguredAdmin": "Bug reporting requires a GitHub integration (issue token + repository). Configure it in the admin panel under Settings → Bug reports.",
|
||||
"openAdminSettings": "Open admin settings",
|
||||
"category": "Type",
|
||||
"categoryBug": "Bug report",
|
||||
"categoryFeature": "Feature request",
|
||||
"categoryQuestion": "Question",
|
||||
"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."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
@@ -1208,5 +1620,177 @@
|
||||
"dateShort": "{month}/{day}/{year}",
|
||||
"timeShort": "{hours}:{minutes}",
|
||||
"dateTime": "{month}/{day}/{year}, {hours}:{minutes}"
|
||||
},
|
||||
"classifications": {
|
||||
"bmi": {
|
||||
"Underweight": "Underweight",
|
||||
"Normal": "Normal",
|
||||
"Overweight": "Overweight",
|
||||
"ObesityGradeI": "Obesity Grade I",
|
||||
"ObesityGradeII": "Obesity Grade II",
|
||||
"ObesityGradeIII": "Obesity Grade III"
|
||||
},
|
||||
"bp": {
|
||||
"Optimal": "Optimal",
|
||||
"Normal": "Normal",
|
||||
"HighNormal": "High-normal",
|
||||
"HypertensionGrade1": "Hypertension Grade 1",
|
||||
"HypertensionGrade2": "Hypertension Grade 2",
|
||||
"HypertensionGrade3": "Hypertension Grade 3"
|
||||
},
|
||||
"pulse": {
|
||||
"Bradycardia": "Bradycardia",
|
||||
"Normal": "Normal",
|
||||
"Elevated": "Elevated",
|
||||
"Tachycardia": "Tachycardia"
|
||||
},
|
||||
"pulseTarget": {
|
||||
"SignificantlyLow": "Significantly low",
|
||||
"SlightlyLow": "Slightly low",
|
||||
"OnTarget": "On target",
|
||||
"SlightlyElevated": "Slightly elevated",
|
||||
"SignificantlyElevated": "Significantly elevated"
|
||||
},
|
||||
"sleep": {
|
||||
"FarTooShort": "Far too short",
|
||||
"TooShort": "Too short",
|
||||
"OnTarget": "On target",
|
||||
"SlightlyLong": "Slightly long",
|
||||
"FarTooLong": "Far too long"
|
||||
},
|
||||
"bodyFat": {
|
||||
"Essential": "Essential",
|
||||
"Athletic": "Athletic",
|
||||
"Fitness": "Fitness",
|
||||
"Average": "Average",
|
||||
"Obese": "Obese"
|
||||
},
|
||||
"steps": {
|
||||
"VeryLow": "Very low",
|
||||
"LowActive": "Low active",
|
||||
"ModeratelyActive": "Moderately active",
|
||||
"Active": "Active",
|
||||
"VeryActive": "Very active"
|
||||
},
|
||||
"general": {
|
||||
"Good": "Good",
|
||||
"Moderate": "Moderate",
|
||||
"Low": "Low",
|
||||
"VeryGood": "Very good",
|
||||
"VeryStable": "Very stable",
|
||||
"Stable": "Stable",
|
||||
"Fluctuating": "Fluctuating"
|
||||
},
|
||||
"alerts": {
|
||||
"bmiBelowNormalTitle": "BMI below normal range",
|
||||
"bmiBelowNormalMessage": "Your BMI of {bmi} is below the normal range (18.5–24.9).",
|
||||
"bmiOverweightTitle": "BMI in overweight range",
|
||||
"bmiOverweightMessage": "Your BMI of {bmi} is in the overweight range. Regular exercise and a balanced diet can help.",
|
||||
"bmiObesityTitle": "BMI in obesity range",
|
||||
"bmiObesityMessage": "Your BMI of {bmi} is in the obesity range.",
|
||||
"bmiNormalTitle": "BMI in normal range",
|
||||
"bmiNormalMessage": "Your BMI of {bmi} is in the healthy range.",
|
||||
"bpDangerTitle": "Blood pressure significantly elevated",
|
||||
"bpDangerMessage": "Your average blood pressure ({sys}/{dia}) is classified as \"{category}\".",
|
||||
"bpWarningTitle": "Blood pressure slightly elevated",
|
||||
"bpWarningMessage": "Your average blood pressure ({sys}/{dia}) is in the \"{category}\" range.",
|
||||
"bpTargetGoodTitle": "Good blood pressure target adherence",
|
||||
"bpTargetGoodMessage": "{pct}% of your readings are within the target range.",
|
||||
"bpTargetBadTitle": "Blood pressure often outside target range",
|
||||
"bpTargetBadMessage": "Only {pct}% of your readings are within the target range.",
|
||||
"weightIncreasingTitle": "Weight increasing",
|
||||
"weightIncreasingMessage": "Your weight is increasing by approx. {change} kg per week over the last 30 days.",
|
||||
"weightDecreasingTitle": "Weight decreasing",
|
||||
"weightDecreasingMessage": "Your weight is decreasing by approx. {change} kg per week over the last 30 days.",
|
||||
"pulseOutliersTitle": "Pulse outliers detected",
|
||||
"pulseOutliersMessage": "{count} unusual pulse readings in the last 30 days.",
|
||||
"complianceLowTitle": "Low compliance: {name}",
|
||||
"complianceLowMessage": "Your 7-day compliance for {name} is only {pct}%. Regular intake is important.",
|
||||
"complianceGoodTitle": "Excellent compliance: {name}",
|
||||
"complianceGoodMessage": "{pct}% compliance over the last 7 days. Keep it up!"
|
||||
}
|
||||
},
|
||||
"targets": {
|
||||
"title": "Targets",
|
||||
"introText": "Your current values compared to recommended target ranges. Guideline sources are linked on each card.",
|
||||
"weight": "Weight",
|
||||
"bloodPressure": "Blood pressure",
|
||||
"bloodPressureOnTarget": "Blood pressure on target",
|
||||
"restingPulse": "Resting pulse",
|
||||
"sleepDuration": "Sleep duration",
|
||||
"bmi": "BMI",
|
||||
"bodyFat": "Body fat",
|
||||
"stepsPerDay": "Steps/day",
|
||||
"medicationCompliance": "Medication compliance",
|
||||
"mood": "Mood",
|
||||
"moodStability": "Mood stability",
|
||||
"steps": "steps",
|
||||
"average30d": "30-day average:",
|
||||
"currentValue": "Current: {value} {unit}",
|
||||
"targetRangeValue": "Target: {min}–{max} {unit}",
|
||||
"sourceLabel": "Source: {source}",
|
||||
"inTarget": "Within target range",
|
||||
"aboveTarget": "{delta} {unit} above target",
|
||||
"belowTarget": "{delta} {unit} below target",
|
||||
"diastolic": "Diastolic",
|
||||
"noMeasurementYet": "No measurements yet",
|
||||
"noMeasurementData": "No measurement data available. Add some measurements to see your targets.",
|
||||
"profileIncomplete": "Profile incomplete",
|
||||
"profileIncompleteHeight": "Add your height in profile settings to enable BMI targets.",
|
||||
"profileIncompleteAge": "Add your date of birth in profile settings for age-specific targets.",
|
||||
"profileIncompleteHeightAge": "Add your height and date of birth in profile settings for personalized targets.",
|
||||
"compliancePerMedication": "Compliance per medication",
|
||||
"glucoseFasting": "Glucose — fasting",
|
||||
"glucosePostprandial": "Glucose — post-meal",
|
||||
"glucoseRandom": "Glucose — random",
|
||||
"glucoseBedtime": "Glucose — bedtime",
|
||||
"loadError": "Failed to load targets"
|
||||
},
|
||||
"passwordStrength": {
|
||||
"veryWeak": "Very weak",
|
||||
"weak": "Weak",
|
||||
"acceptable": "Acceptable",
|
||||
"strong": "Strong",
|
||||
"veryStrong": "Very strong",
|
||||
"tooShort": "Too short",
|
||||
"characters": "characters",
|
||||
"minLength": "At least {count} characters required."
|
||||
},
|
||||
"telegram": {
|
||||
"errorInvalidAction": "Invalid action.",
|
||||
"errorMedicationNotFound": "Medication not found.",
|
||||
"errorMedicationInactive": "Medication not found or inactive.",
|
||||
"errorUnknownAction": "Unknown action.",
|
||||
"snoozeOneHour": "1 hour",
|
||||
"snoozeThreeHours": "3 hours",
|
||||
"snoozedFor": "{name} snoozed for {duration}.",
|
||||
"skipped": "{name} skipped.",
|
||||
"alreadyRecorded": "{name} was already recorded.",
|
||||
"recordedAsTaken": "{name} recorded as taken.",
|
||||
"confirmed": "{name} confirmed.",
|
||||
"genericConfirmed": "Confirmed.",
|
||||
"cancelled": "Cancelled.",
|
||||
"noActiveMedications": "No active medications found.",
|
||||
"whichMedication": "<b>Which medication did you take?</b>",
|
||||
"cancelButton": "Cancel",
|
||||
"askMedicationName": "Please specify a medication, e.g. <b>taken Ramipril</b>.",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
+35
-25
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "0.1.0",
|
||||
"version": "1.4.2",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.31.0",
|
||||
"scripts": {
|
||||
@@ -17,53 +17,63 @@
|
||||
"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.4.2",
|
||||
"@prisma/client": "^7.4.2",
|
||||
"@simplewebauthn/browser": "^13.2.2",
|
||||
"@simplewebauthn/server": "^13.2.3",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@prisma/adapter-pg": "^7.8.0",
|
||||
"@prisma/client": "^7.8.0",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@simplewebauthn/server": "^13.3.0",
|
||||
"@tanstack/react-query": "^5.100.9",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^17.3.1",
|
||||
"jspdf": "^4.2.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"jspdf": "^4.2.1",
|
||||
"jspdf-autotable": "^5.0.7",
|
||||
"lucide-react": "^0.575.0",
|
||||
"next": "16.1.6",
|
||||
"lucide-react": "^1.14.0",
|
||||
"next": "16.2.4",
|
||||
"next-themes": "^0.4.6",
|
||||
"pg-boss": "^12.13.0",
|
||||
"prisma": "^7.4.2",
|
||||
"pg-boss": "^12.18.2",
|
||||
"prisma": "^7.8.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.3",
|
||||
"react-hook-form": "^7.71.1",
|
||||
"recharts": "^3.7.0",
|
||||
"react": "19.2.5",
|
||||
"react-dom": "19.2.5",
|
||||
"react-hook-form": "^7.75.0",
|
||||
"recharts": "^3.8.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"web-push": "^3.6.7",
|
||||
"zod": "^4.3.6",
|
||||
"zod": "^4.4.2",
|
||||
"zxcvbn-typescript": "^5.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.11.3",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@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.1.6",
|
||||
"prettier": "^3.5.0",
|
||||
"prettier-plugin-tailwindcss": "^0.6.0",
|
||||
"shadcn": "^3.8.5",
|
||||
"eslint-config-next": "16.2.4",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"prettier": "^3.8.3",
|
||||
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||
"shadcn": "^4.6.0",
|
||||
"tailwindcss": "^4",
|
||||
"testcontainers": "^11.14.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.0.18"
|
||||
"typescript": "^6",
|
||||
"vitest": "^4.1.5"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Playwright configuration for the HealthLog E2E suite.
|
||||
*
|
||||
* The suite covers the smoke-level user paths (auth redirect, login
|
||||
* form, public version endpoint, locale switch, axe-core) without
|
||||
* needing seeded data — every spec either runs against an unauthed
|
||||
* surface or uses route interception to stub out the API. Specs that
|
||||
* need a logged-in user are kept narrow and flagged in their describe
|
||||
* block; CI runs them against a worker that seeds a deterministic test
|
||||
* user on startup.
|
||||
*
|
||||
* To run locally: `pnpm dlx playwright install --with-deps chromium`
|
||||
* once, then `pnpm e2e`.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
timeout: 30_000,
|
||||
expect: { timeout: 5_000 },
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
workers: process.env.CI ? 2 : undefined,
|
||||
reporter: process.env.CI
|
||||
? [["github"], ["html", { open: "never" }]]
|
||||
: [["list"], ["html", { open: "never" }]],
|
||||
|
||||
use: {
|
||||
baseURL: process.env.E2E_BASE_URL ?? "http://localhost:3000",
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
video: "retain-on-failure",
|
||||
},
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: "chromium-desktop",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
viewport: { width: 1280, height: 720 },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "chromium-mobile",
|
||||
use: {
|
||||
...devices["iPhone 13"],
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
// Spin up the production build for E2E. `pnpm build` is run
|
||||
// separately by CI before the suite — locally, set E2E_SKIP_WEB_SERVER=1
|
||||
// to point at an already-running dev server.
|
||||
webServer: process.env.E2E_SKIP_WEB_SERVER
|
||||
? undefined
|
||||
: {
|
||||
command: "pnpm exec next start --port 3000",
|
||||
url: "http://localhost:3000/api/version",
|
||||
timeout: 60_000,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
stdout: "ignore",
|
||||
stderr: "pipe",
|
||||
},
|
||||
});
|
||||
Generated
+4309
-1881
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,13 @@
|
||||
-- AlterTable: Remove openai_key_encrypted, add Codex OAuth fields to users
|
||||
ALTER TABLE "users" DROP COLUMN "openai_key_encrypted";
|
||||
|
||||
ALTER TABLE "users" ADD COLUMN "codex_access_token_encrypted" TEXT;
|
||||
ALTER TABLE "users" ADD COLUMN "codex_refresh_token_encrypted" TEXT;
|
||||
ALTER TABLE "users" ADD COLUMN "codex_token_expires_at" TIMESTAMP(3);
|
||||
ALTER TABLE "users" ADD COLUMN "codex_connected_at" TIMESTAMP(3);
|
||||
ALTER TABLE "users" ADD COLUMN "codex_connection_status" TEXT NOT NULL DEFAULT 'disconnected';
|
||||
|
||||
-- AlterTable: Add admin AI provider fields to app_settings
|
||||
ALTER TABLE "app_settings" ADD COLUMN "admin_ai_key_encrypted" TEXT;
|
||||
ALTER TABLE "app_settings" ADD COLUMN "admin_ai_model" TEXT NOT NULL DEFAULT 'gpt-4o-mini';
|
||||
ALTER TABLE "app_settings" ADD COLUMN "admin_ai_base_url" TEXT NOT NULL DEFAULT 'https://api.openai.com/v1';
|
||||
@@ -0,0 +1,73 @@
|
||||
-- v1.2: Personalization + Blood Glucose + Built-in Feedback
|
||||
--
|
||||
-- This migration is purely additive.
|
||||
-- - New enums: measurement_type.BLOOD_GLUCOSE, glucose_context, feedback_category, feedback_status.
|
||||
-- - New columns on users: thresholds_json, dashboard_widgets_json, glucose_unit,
|
||||
-- ai_provider, ai_model, ai_base_url, ai_anthropic_key_encrypted, ai_local_key_encrypted.
|
||||
-- - New column on measurements: glucose_context (only set for BLOOD_GLUCOSE rows).
|
||||
-- - New table: feedback.
|
||||
|
||||
-- ── Enums ────────────────────────────────────────────────────────────────
|
||||
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'BLOOD_GLUCOSE';
|
||||
|
||||
CREATE TYPE "glucose_context" AS ENUM ('FASTING', 'POSTPRANDIAL', 'RANDOM', 'BEDTIME');
|
||||
|
||||
CREATE TYPE "feedback_category" AS ENUM ('BUG', 'FEATURE_REQUEST', 'QUESTION', 'OTHER');
|
||||
|
||||
CREATE TYPE "feedback_status" AS ENUM ('OPEN', 'ACKNOWLEDGED', 'RESOLVED', 'ARCHIVED');
|
||||
|
||||
-- ── users personalization columns ───────────────────────────────────────
|
||||
|
||||
ALTER TABLE "users"
|
||||
ADD COLUMN "thresholds_json" JSONB,
|
||||
ADD COLUMN "dashboard_widgets_json" JSONB,
|
||||
ADD COLUMN "glucose_unit" TEXT,
|
||||
ADD COLUMN "ai_provider" TEXT,
|
||||
ADD COLUMN "ai_model" TEXT,
|
||||
ADD COLUMN "ai_base_url" TEXT,
|
||||
ADD COLUMN "ai_anthropic_key_encrypted" TEXT,
|
||||
ADD COLUMN "ai_local_key_encrypted" TEXT;
|
||||
|
||||
-- ── measurements.glucose_context ────────────────────────────────────────
|
||||
|
||||
ALTER TABLE "measurements"
|
||||
ADD COLUMN "glucose_context" "glucose_context";
|
||||
|
||||
-- Enforce: glucose_context is only set for BLOOD_GLUCOSE rows.
|
||||
ALTER TABLE "measurements"
|
||||
ADD CONSTRAINT "measurements_glucose_context_requires_type"
|
||||
CHECK (
|
||||
(type = 'BLOOD_GLUCOSE' AND glucose_context IS NOT NULL)
|
||||
OR
|
||||
(type <> 'BLOOD_GLUCOSE' AND glucose_context IS NULL)
|
||||
);
|
||||
|
||||
-- ── feedback table ──────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE "feedback" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT,
|
||||
"email" TEXT,
|
||||
"category" "feedback_category" NOT NULL DEFAULT 'BUG',
|
||||
"subject" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL,
|
||||
"screenshot_base64" TEXT,
|
||||
"metadata" JSONB,
|
||||
"status" "feedback_status" NOT NULL DEFAULT 'OPEN',
|
||||
"admin_note" TEXT,
|
||||
"github_issue_url" TEXT,
|
||||
"notified_email" BOOLEAN NOT NULL DEFAULT false,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "feedback_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "feedback_status_created_at_idx" ON "feedback" ("status", "created_at");
|
||||
CREATE INDEX "feedback_user_id_idx" ON "feedback" ("user_id");
|
||||
|
||||
ALTER TABLE "feedback"
|
||||
ADD CONSTRAINT "feedback_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id")
|
||||
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,17 @@
|
||||
-- v1.3: Add body-composition measurement types
|
||||
--
|
||||
-- Closes #89: total body water and bone mass as first-class measurement types.
|
||||
-- Both are reported by Withings body-composition scales and can also be ingested
|
||||
-- via the API by external pipelines (e.g. n8n + Health Connect).
|
||||
--
|
||||
-- Storage units (canonical, both kg — matches VALUE_RANGES in
|
||||
-- src/lib/validations/measurement.ts and Withings client type-77/type-88
|
||||
-- mapping). Adult plausibility ranges: water 30–55 kg, bone 2.5–4.5 kg.
|
||||
-- TOTAL_BODY_WATER: kilograms (kg) — total body water content
|
||||
-- BONE_MASS: kilograms (kg) — DEXA-equivalent bone mineral content
|
||||
--
|
||||
-- Both are unconditional value types — no companion enum (unlike BLOOD_GLUCOSE,
|
||||
-- which carries a glucose_context). The migration is purely additive.
|
||||
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'TOTAL_BODY_WATER';
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'BONE_MASS';
|
||||
@@ -0,0 +1,55 @@
|
||||
-- v1.3: iOS adapter endpoints + idempotency cache + device registration
|
||||
--
|
||||
-- Purely additive:
|
||||
-- • users.display_name, users.healthkit_config_json, users.healthkit_last_synced_at
|
||||
-- • new tables: idempotency_keys, devices
|
||||
|
||||
-- ── users new columns ─────────────────────────────────────────────────
|
||||
|
||||
ALTER TABLE "users"
|
||||
ADD COLUMN "display_name" TEXT,
|
||||
ADD COLUMN "healthkit_config_json" JSONB,
|
||||
ADD COLUMN "healthkit_last_synced_at" TIMESTAMP(3);
|
||||
|
||||
-- ── idempotency_keys ──────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE "idempotency_keys" (
|
||||
"id" TEXT PRIMARY KEY,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
"method" TEXT NOT NULL,
|
||||
"path" TEXT NOT NULL,
|
||||
"response_status" INTEGER NOT NULL,
|
||||
"response_body" TEXT NOT NULL,
|
||||
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "idempotency_keys_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "idempotency_keys_user_id_key_method_path_key"
|
||||
ON "idempotency_keys" ("user_id", "key", "method", "path");
|
||||
|
||||
CREATE INDEX "idempotency_keys_expires_at_idx"
|
||||
ON "idempotency_keys" ("expires_at");
|
||||
|
||||
-- ── devices ───────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE "devices" (
|
||||
"id" TEXT PRIMARY KEY,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"platform" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"bundle_id" TEXT NOT NULL,
|
||||
"locale" TEXT,
|
||||
"app_version" TEXT,
|
||||
"model" TEXT,
|
||||
"last_seen" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "devices_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "devices_token_key" ON "devices" ("token");
|
||||
CREATE INDEX "devices_user_id_idx" ON "devices" ("user_id");
|
||||
@@ -0,0 +1,12 @@
|
||||
-- v1.3.3: Add OXYGEN_SATURATION (SpO2) measurement type
|
||||
--
|
||||
-- Closes #109 feature request. Single-value type (no per-context split like
|
||||
-- BLOOD_GLUCOSE — context goes into the free-text `notes` field).
|
||||
--
|
||||
-- Storage unit: percent (0..100). Plausibility range 50..100 in
|
||||
-- src/lib/validations/measurement.ts (anything below 50% is incompatible with
|
||||
-- sustained life and almost certainly a faulty sensor).
|
||||
--
|
||||
-- Withings devices that support SpO2 (e.g. ScanWatch) report measure type 54.
|
||||
-- See src/lib/withings/client.ts MEASURE_TYPE_MAP.
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'OXYGEN_SATURATION';
|
||||
@@ -0,0 +1,47 @@
|
||||
-- 0025_refresh_tokens
|
||||
-- Native-client short-lived access + rotating refresh tokens (v1.4 G4).
|
||||
-- Forward-compatible: this migration only ADDS a table. No DROP COLUMN, no
|
||||
-- type changes on existing tables. To roll back, the down migration in
|
||||
-- docs/ops/migrations/0025-down.sql DROPs the table.
|
||||
--
|
||||
-- All statements use IF NOT EXISTS so a partial-deploy retry is a no-op
|
||||
-- instead of "relation already exists". Wrapped in a single transaction
|
||||
-- so a mid-statement failure leaves no half-built table behind.
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "refresh_tokens" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"token_hash" TEXT NOT NULL,
|
||||
"device_id" TEXT,
|
||||
"access_token_hash" TEXT,
|
||||
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"revoked_at" TIMESTAMP(3),
|
||||
"used_at" TIMESTAMP(3),
|
||||
"replaced_by_id" TEXT,
|
||||
"user_agent" TEXT,
|
||||
"ip_address" TEXT,
|
||||
CONSTRAINT "refresh_tokens_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "refresh_tokens_token_hash_key" ON "refresh_tokens"("token_hash");
|
||||
CREATE INDEX IF NOT EXISTS "refresh_tokens_user_id_idx" ON "refresh_tokens"("user_id");
|
||||
CREATE INDEX IF NOT EXISTS "refresh_tokens_user_id_device_id_idx" ON "refresh_tokens"("user_id", "device_id");
|
||||
CREATE INDEX IF NOT EXISTS "refresh_tokens_expires_at_idx" ON "refresh_tokens"("expires_at");
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE constraint_name = 'refresh_tokens_user_id_fkey'
|
||||
) THEN
|
||||
ALTER TABLE "refresh_tokens"
|
||||
ADD CONSTRAINT "refresh_tokens_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,14 @@
|
||||
-- 0025_user_locale_drift_fix
|
||||
-- The `users.locale` column has been on `prisma/schema.prisma` since the
|
||||
-- locale-aware reminder work in v1.3 but never landed in the migration
|
||||
-- history (it must have been applied via `prisma db push` to dev / prod
|
||||
-- but not committed as a migration). The schema drift is invisible until
|
||||
-- a fresh database is built from migrations alone — at which point the
|
||||
-- Prisma client tries to read `users.locale` and Postgres reports
|
||||
-- `column users.locale does not exist`.
|
||||
--
|
||||
-- This migration backfills the column with `IF NOT EXISTS` so it is a
|
||||
-- safe no-op against any database that was kept in sync via `db push`,
|
||||
-- and a clean add against any environment built strictly from this
|
||||
-- migrations directory (CI test containers, fresh deploys, etc.).
|
||||
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "locale" TEXT;
|
||||
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "postgresql"
|
||||
+192
-3
@@ -24,8 +24,12 @@ model User {
|
||||
gender String? @map("gender") // "MALE" | "FEMALE" | null = keine Angabe
|
||||
timezone String @default("Europe/Berlin")
|
||||
|
||||
// OpenAI Insights (optional)
|
||||
openaiKeyEncrypted String? @map("openai_key_encrypted") // AES-256-GCM encrypted
|
||||
// Codex OAuth (ChatGPT subscription)
|
||||
codexAccessTokenEncrypted String? @map("codex_access_token_encrypted")
|
||||
codexRefreshTokenEncrypted String? @map("codex_refresh_token_encrypted")
|
||||
codexTokenExpiresAt DateTime? @map("codex_token_expires_at")
|
||||
codexConnectedAt DateTime? @map("codex_connected_at")
|
||||
codexConnectionStatus String @default("disconnected") @map("codex_connection_status")
|
||||
insightsPrivacyMode String @default("aggregated") @map("insights_privacy_mode") // "aggregated" | "raw"
|
||||
insightsCachedAt DateTime? @map("insights_cached_at")
|
||||
insightsCachedText String? @map("insights_cached_text") // cached JSON output
|
||||
@@ -49,9 +53,35 @@ model User {
|
||||
// Locale (null = use system/admin default)
|
||||
locale String? @map("locale") // "de" | "en" | null
|
||||
|
||||
// Personalization
|
||||
// Per-user overrides for computed default thresholds. Shape:
|
||||
// { "WEIGHT": { "min": 70, "max": 85 },
|
||||
// "BLOOD_PRESSURE_SYS": { "min": 110, "max": 130 }, ... }
|
||||
// Null or missing metric → use computed default.
|
||||
thresholdsJson Json? @map("thresholds_json")
|
||||
// Per-user dashboard layout — widget visibility + ordering.
|
||||
// Shape: { "widgets": [{ "id": "weight", "visible": true, "order": 0 }, ...], "version": 1 }
|
||||
dashboardWidgetsJson Json? @map("dashboard_widgets_json")
|
||||
// Blood-glucose display unit preference. Canonical storage is mg/dL.
|
||||
glucoseUnit String? @map("glucose_unit") // "mg/dL" | "mmol/L" | null = default "mg/dL"
|
||||
// AI provider selection for insights. Null = OPENAI fallback.
|
||||
aiProvider String? @map("ai_provider") // "OPENAI" | "ANTHROPIC" | "LOCAL" | "CHATGPT_OAUTH"
|
||||
aiModel String? @map("ai_model")
|
||||
aiBaseUrl String? @map("ai_base_url")
|
||||
aiAnthropicKeyEncrypted String? @map("ai_anthropic_key_encrypted")
|
||||
aiLocalKeyEncrypted String? @map("ai_local_key_encrypted")
|
||||
|
||||
// Onboarding
|
||||
onboardingCompletedAt DateTime? @map("onboarding_completed_at")
|
||||
|
||||
// HealthKit (iOS) integration config — list of per-metric direction toggles.
|
||||
// Shape: { entries: [{ id, kind, direction, enabled }], lastSyncedAt }
|
||||
healthKitConfigJson Json? @map("healthkit_config_json")
|
||||
healthKitLastSyncedAt DateTime? @map("healthkit_last_synced_at")
|
||||
|
||||
// Display name (optional override of username for greetings).
|
||||
displayName String? @map("display_name")
|
||||
|
||||
// Relations
|
||||
passkeys Passkey[]
|
||||
sessions Session[]
|
||||
@@ -60,6 +90,7 @@ model User {
|
||||
medications Medication[]
|
||||
intakeEvents MedicationIntakeEvent[]
|
||||
apiTokens ApiToken[]
|
||||
refreshTokens RefreshToken[]
|
||||
auditLogs AuditLog[]
|
||||
withingsConnection WithingsConnection?
|
||||
moodEntries MoodEntry[]
|
||||
@@ -67,6 +98,9 @@ model User {
|
||||
pushSubscriptions PushSubscription[]
|
||||
dataBackups DataBackup[]
|
||||
achievements UserAchievement[]
|
||||
feedback Feedback[]
|
||||
idempotencyKeys IdempotencyKey[]
|
||||
devices Device[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@@ -126,10 +160,25 @@ enum MeasurementType {
|
||||
BODY_FAT
|
||||
SLEEP_DURATION
|
||||
ACTIVITY_STEPS
|
||||
BLOOD_GLUCOSE // Canonical storage: mg/dL. Display unit (mg/dL or mmol/L) is user preference.
|
||||
TOTAL_BODY_WATER // Canonical storage: kilograms (kg of water). Matches Withings' hydration measure (type 77) and Health Connect's TotalBodyWaterRecord. Display can compute % from same-day weight if desired.
|
||||
BONE_MASS // Canonical storage: kilograms (kg). Matches Withings' bone mass measure (type 88).
|
||||
OXYGEN_SATURATION // Canonical storage: percent (0..100). Withings ScanWatch reports type 54. Health Connect's OxygenSaturationRecord uses Percentage (already 0..100). HealthKit uses HKQuantityTypeIdentifierOxygenSaturation as a fraction (0..1) — iOS DTO must multiply by 100 before submitting.
|
||||
|
||||
@@map("measurement_type")
|
||||
}
|
||||
|
||||
/// Context for glucose measurements — affects which target range applies.
|
||||
/// NULL for non-glucose measurements.
|
||||
enum GlucoseContext {
|
||||
FASTING // ≥8h without food, typically morning
|
||||
POSTPRANDIAL // 2h after meal
|
||||
RANDOM // any time, non-fasting, non-post-meal
|
||||
BEDTIME // before sleep
|
||||
|
||||
@@map("glucose_context")
|
||||
}
|
||||
|
||||
enum MeasurementSource {
|
||||
MANUAL
|
||||
WITHINGS
|
||||
@@ -143,11 +192,13 @@ model Measurement {
|
||||
userId String @map("user_id")
|
||||
type MeasurementType
|
||||
value Float
|
||||
unit String // kg, mmHg, bpm, %, hours, steps
|
||||
unit String // kg, mmHg, bpm, %, hours, steps, mg/dL
|
||||
source MeasurementSource @default(MANUAL)
|
||||
measuredAt DateTime @map("measured_at")
|
||||
notes String? // encrypted at app level if sensitive
|
||||
externalId String? @map("external_id") // Withings grpid for dedup
|
||||
// Only set for BLOOD_GLUCOSE measurements. NULL for all others.
|
||||
glucoseContext GlucoseContext? @map("glucose_context")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@ -307,6 +358,47 @@ model ApiToken {
|
||||
@@map("api_tokens")
|
||||
}
|
||||
|
||||
// ─── Refresh Tokens (native client short-lived access + rotating refresh) ─
|
||||
|
||||
model RefreshToken {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
tokenHash String @unique @map("token_hash")
|
||||
/// Optional device fingerprint — links the refresh token to a specific
|
||||
/// `Device` row when the caller advertised one (X-Device-Id header). Used
|
||||
/// to revoke all tokens issued for a stolen/lost device.
|
||||
deviceId String? @map("device_id")
|
||||
/// HMAC-SHA-256 (matching `ApiToken.tokenHash` format, keyed by
|
||||
/// `API_TOKEN_HMAC_KEY`) of the access token issued in the same response.
|
||||
/// When the refresh token is rotated we revoke the matching access token
|
||||
/// via `ApiToken.tokenHash`. Null if the access token was issued
|
||||
/// out-of-band.
|
||||
accessTokenHash String? @map("access_token_hash")
|
||||
expiresAt DateTime @map("expires_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
revokedAt DateTime? @map("revoked_at")
|
||||
/// When this token was used (one-time-use). Set during rotation; a second
|
||||
/// use of the same token is a hard fail and triggers reuse-detection.
|
||||
usedAt DateTime? @map("used_at")
|
||||
/// Pointer to the row that replaced this one during rotation. Helps audit
|
||||
/// trail and reuse-detection ("this token was already exchanged into X").
|
||||
replacedById String? @map("replaced_by_id")
|
||||
userAgent String? @map("user_agent")
|
||||
ipAddress String? @map("ip_address")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
/// Composite index — every reuse-detection / device-scoped lookup filters
|
||||
/// on (userId, deviceId), so a single-column device_id index would never
|
||||
/// be used while costing writes. Multiple live rows per (userId, deviceId)
|
||||
/// are intentional: rotated rows linger in `revokedAt != null` state for
|
||||
/// audit and reuse-detection.
|
||||
@@index([userId, deviceId])
|
||||
@@index([expiresAt])
|
||||
@@map("refresh_tokens")
|
||||
}
|
||||
|
||||
// ─── Withings ──────────────────────────────────────────────
|
||||
|
||||
model WithingsConnection {
|
||||
@@ -374,9 +466,60 @@ model AppSettings {
|
||||
reminderLateMinutes Int @default(120) @map("reminder_late_minutes")
|
||||
reminderMissedMinutes Int @default(240) @map("reminder_missed_minutes")
|
||||
|
||||
// Admin AI Provider (fallback for users without Codex OAuth)
|
||||
adminAiKeyEncrypted String? @map("admin_ai_key_encrypted")
|
||||
adminAiModel String @default("gpt-4o-mini") @map("admin_ai_model")
|
||||
adminAiBaseUrl String @default("https://api.openai.com/v1") @map("admin_ai_base_url")
|
||||
|
||||
@@map("app_settings")
|
||||
}
|
||||
|
||||
// ─── Built-in Feedback ───────────────────────────────────────
|
||||
|
||||
enum FeedbackCategory {
|
||||
BUG
|
||||
FEATURE_REQUEST
|
||||
QUESTION
|
||||
OTHER
|
||||
|
||||
@@map("feedback_category")
|
||||
}
|
||||
|
||||
enum FeedbackStatus {
|
||||
OPEN
|
||||
ACKNOWLEDGED
|
||||
RESOLVED
|
||||
ARCHIVED
|
||||
|
||||
@@map("feedback_status")
|
||||
}
|
||||
|
||||
/// Stores user-submitted feedback without requiring external GitHub config.
|
||||
/// Admin inbox lives at /admin#feedback; optional escalation publishes to a
|
||||
/// GitHub issue reusing the existing integration when configured.
|
||||
model Feedback {
|
||||
id String @id @default(cuid())
|
||||
userId String? @map("user_id") // Null after user deletion; email snapshot remains.
|
||||
email String? // Snapshot at submission time for triage continuity.
|
||||
category FeedbackCategory @default(BUG)
|
||||
subject String
|
||||
description String
|
||||
screenshotBase64 String? @map("screenshot_base64") // Optional, <5MB, PNG/JPG.
|
||||
metadata Json? // URL, userAgent, locale, appVersion, …
|
||||
status FeedbackStatus @default(OPEN)
|
||||
adminNote String? @map("admin_note")
|
||||
gitHubIssueUrl String? @map("github_issue_url")
|
||||
notifiedEmail Boolean @default(false) @map("notified_email")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([status, createdAt])
|
||||
@@index([userId])
|
||||
@@map("feedback")
|
||||
}
|
||||
|
||||
// ─── Audit Log ─────────────────────────────────────────────
|
||||
|
||||
model AuditLog {
|
||||
@@ -469,6 +612,52 @@ model RateLimit {
|
||||
@@map("rate_limits")
|
||||
}
|
||||
|
||||
// ─── Idempotency Keys (mobile-client replay protection) ──
|
||||
|
||||
/// Caches the response of a POST/PUT/PATCH/DELETE request keyed by
|
||||
/// (userId, key, method, path) so retries within the TTL replay the
|
||||
/// original outcome instead of producing a duplicate side-effect.
|
||||
model IdempotencyKey {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
key String
|
||||
method String
|
||||
path String
|
||||
responseStatus Int @map("response_status")
|
||||
responseBody String @map("response_body") // serialized JSON envelope
|
||||
expiresAt DateTime @map("expires_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, key, method, path])
|
||||
@@index([expiresAt])
|
||||
@@map("idempotency_keys")
|
||||
}
|
||||
|
||||
// ─── Devices (APNs / mobile push targets) ────────────────
|
||||
|
||||
/// Registered native-client device for push delivery. Token is unique
|
||||
/// across users; re-registering on a different account moves ownership.
|
||||
model Device {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
platform String // "ios"
|
||||
token String @unique
|
||||
bundleId String @map("bundle_id")
|
||||
locale String?
|
||||
appVersion String? @map("app_version")
|
||||
model String?
|
||||
lastSeen DateTime @default(now()) @map("last_seen")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@map("devices")
|
||||
}
|
||||
|
||||
// ─── User Achievements ───────────────────────────────────────
|
||||
|
||||
model UserAchievement {
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* scripts/restore-backup.ts <s3-key> [output-file]
|
||||
*
|
||||
* Downloads a single off-host backup object, decrypts it with
|
||||
* `BACKUP_ENCRYPTION_KEY`, and writes the JSON dump to disk.
|
||||
*
|
||||
* Wire format (binary):
|
||||
* magic(4)="HLBK" || version(1)=0x01 || iv(12) || tag(16) || ciphertext
|
||||
*
|
||||
* Usage:
|
||||
* BACKUP_S3_ENDPOINT=... \
|
||||
* BACKUP_S3_BUCKET=... \
|
||||
* BACKUP_S3_ACCESS_KEY=... \
|
||||
* BACKUP_S3_SECRET_KEY=... \
|
||||
* BACKUP_S3_REGION=auto \
|
||||
* BACKUP_ENCRYPTION_KEY=<hex64> \
|
||||
* pnpm tsx scripts/restore-backup.ts 2026-05-08/user-clx123.json.enc /tmp/restored.json
|
||||
*/
|
||||
import "dotenv/config";
|
||||
import { writeFileSync } from "node:fs";
|
||||
import {
|
||||
loadOffhostConfig,
|
||||
getS3Client,
|
||||
decryptBackup,
|
||||
OffhostBackupNotConfiguredError,
|
||||
} from "@/lib/jobs/offhost-backup";
|
||||
|
||||
/**
|
||||
* Schema-validate the decrypted JSON before writing it to disk. The wire
|
||||
* format from `runOffhostBackup` always carries `userId` and the four data
|
||||
* arrays — anything else means the operator may be looking at a tampered or
|
||||
* mis-targeted object. We reject rather than silently writing a body that a
|
||||
* later import script would trust.
|
||||
*/
|
||||
function validateBackupShape(payload: unknown): {
|
||||
exportedAt: string;
|
||||
userId: string;
|
||||
measurements: unknown[];
|
||||
medications: unknown[];
|
||||
intakeEvents: unknown[];
|
||||
moodEntries: unknown[];
|
||||
} {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
throw new Error("backup payload is not a JSON object");
|
||||
}
|
||||
const p = payload as Record<string, unknown>;
|
||||
const required = [
|
||||
"exportedAt",
|
||||
"userId",
|
||||
"measurements",
|
||||
"medications",
|
||||
"intakeEvents",
|
||||
"moodEntries",
|
||||
] as const;
|
||||
for (const k of required) {
|
||||
if (!(k in p)) throw new Error(`backup is missing required field: ${k}`);
|
||||
}
|
||||
if (typeof p.exportedAt !== "string")
|
||||
throw new Error("backup.exportedAt must be string");
|
||||
if (typeof p.userId !== "string" || p.userId.length === 0)
|
||||
throw new Error("backup.userId must be a non-empty string");
|
||||
for (const k of [
|
||||
"measurements",
|
||||
"medications",
|
||||
"intakeEvents",
|
||||
"moodEntries",
|
||||
] as const) {
|
||||
if (!Array.isArray(p[k])) throw new Error(`backup.${k} must be an array`);
|
||||
}
|
||||
return p as ReturnType<typeof validateBackupShape>;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const key = process.argv[2];
|
||||
const out = process.argv[3] ?? "./restored.json";
|
||||
// Optional positional arg: --user-id=<id> requires the dump's `userId`
|
||||
// field to match. Defends against an operator pointing the script at the
|
||||
// wrong S3 key (e.g. another tenant's dump under a similar path) and
|
||||
// silently importing the wrong user's data downstream.
|
||||
const expectedUserId = process.argv
|
||||
.find((a) => a.startsWith("--user-id="))
|
||||
?.slice("--user-id=".length);
|
||||
if (!key) {
|
||||
console.error(
|
||||
"Usage: pnpm tsx scripts/restore-backup.ts <s3-key> [output-file] [--user-id=<id>]",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const cfg = loadOffhostConfig();
|
||||
if (!cfg) {
|
||||
throw new OffhostBackupNotConfiguredError(
|
||||
"Off-host backup is not configured. Set BACKUP_* env vars.",
|
||||
);
|
||||
}
|
||||
|
||||
const s3 = await getS3Client(cfg);
|
||||
console.log(
|
||||
`Downloading s3://${cfg.bucket}/${key} from ${cfg.endpoint} (region=${cfg.region})`,
|
||||
);
|
||||
const ciphertext = await s3.getObject(key);
|
||||
const plaintext = decryptBackup(ciphertext, cfg.encryptionKey);
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(plaintext);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Decrypted payload is not valid JSON: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
const body = validateBackupShape(parsed);
|
||||
if (expectedUserId && body.userId !== expectedUserId) {
|
||||
throw new Error(
|
||||
`Refusing to write: backup.userId='${body.userId}' does not match --user-id='${expectedUserId}'`,
|
||||
);
|
||||
}
|
||||
|
||||
writeFileSync(out, plaintext, "utf8");
|
||||
console.log(
|
||||
`Restored userId=${body.userId} (${plaintext.length} bytes) -> ${out}`,
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Restore failed:", err);
|
||||
process.exit(2);
|
||||
});
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* scripts/rotate-encryption-key.ts <new-key-id>
|
||||
*
|
||||
* Re-encrypts every encrypted column in the database with the currently
|
||||
* active key (`ENCRYPTION_ACTIVE_KEY_ID`). Idempotent: rows whose ciphertext
|
||||
* already carries the active key id prefix are skipped.
|
||||
*
|
||||
* Usage:
|
||||
* ENCRYPTION_KEYS='{"v1":"<old>","v2":"<new>"}' \
|
||||
* ENCRYPTION_ACTIVE_KEY_ID=v2 \
|
||||
* pnpm tsx scripts/rotate-encryption-key.ts v2
|
||||
*/
|
||||
import "dotenv/config";
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import { decrypt, encrypt, extractKeyId, getActiveKeyId } from "@/lib/crypto";
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
console.error("DATABASE_URL must be set");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const targetKeyId = process.argv[2] ?? getActiveKeyId();
|
||||
if (targetKeyId !== getActiveKeyId()) {
|
||||
console.error(
|
||||
`Refusing to rotate: argv key id '${targetKeyId}' does not match the ` +
|
||||
`currently active id '${getActiveKeyId()}'. Set ENCRYPTION_ACTIVE_KEY_ID first.`,
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const prisma = new PrismaClient({
|
||||
adapter: new PrismaPg({ connectionString: DATABASE_URL }),
|
||||
});
|
||||
|
||||
interface RotationResult {
|
||||
table: string;
|
||||
field: string;
|
||||
scanned: number;
|
||||
rotated: number;
|
||||
errors: number;
|
||||
}
|
||||
|
||||
function shouldRotate(value: string | null): boolean {
|
||||
if (!value) return false;
|
||||
const id = extractKeyId(value);
|
||||
// null = legacy/unversioned (rotate); else rotate if not already on active.
|
||||
return id !== getActiveKeyId();
|
||||
}
|
||||
|
||||
async function rotateField<T extends { id: string }>(
|
||||
table: string,
|
||||
field: string,
|
||||
rows: T[],
|
||||
fieldGetter: (row: T) => string | null,
|
||||
updater: (id: string, ciphertext: string) => Promise<void>,
|
||||
): Promise<RotationResult> {
|
||||
const result: RotationResult = {
|
||||
table,
|
||||
field,
|
||||
scanned: rows.length,
|
||||
rotated: 0,
|
||||
errors: 0,
|
||||
};
|
||||
for (const row of rows) {
|
||||
const v = fieldGetter(row);
|
||||
if (!shouldRotate(v)) continue;
|
||||
try {
|
||||
const re = encrypt(decrypt(v as string));
|
||||
await updater(row.id, re);
|
||||
result.rotated++;
|
||||
} catch (err) {
|
||||
result.errors++;
|
||||
console.error(
|
||||
`[${table}.${field}] row ${row.id}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`Rotating encrypted rows to active key id: ${targetKeyId}`);
|
||||
const results: RotationResult[] = [];
|
||||
|
||||
// ───── User table ─────
|
||||
const users = await prisma.user.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
codexAccessTokenEncrypted: true,
|
||||
codexRefreshTokenEncrypted: true,
|
||||
aiAnthropicKeyEncrypted: true,
|
||||
aiLocalKeyEncrypted: true,
|
||||
moodLogWebhookSecret: true,
|
||||
telegramBotToken: true,
|
||||
moodLogUrlEncrypted: true,
|
||||
moodLogApiKeyEncrypted: true,
|
||||
withingsClientIdEncrypted: true,
|
||||
withingsClientSecretEncrypted: true,
|
||||
},
|
||||
});
|
||||
|
||||
const userFields: Array<keyof (typeof users)[number]> = [
|
||||
"codexAccessTokenEncrypted",
|
||||
"codexRefreshTokenEncrypted",
|
||||
"aiAnthropicKeyEncrypted",
|
||||
"aiLocalKeyEncrypted",
|
||||
"moodLogWebhookSecret",
|
||||
"telegramBotToken",
|
||||
"moodLogUrlEncrypted",
|
||||
"moodLogApiKeyEncrypted",
|
||||
"withingsClientIdEncrypted",
|
||||
"withingsClientSecretEncrypted",
|
||||
];
|
||||
for (const field of userFields) {
|
||||
const r = await rotateField(
|
||||
"User",
|
||||
field as string,
|
||||
users,
|
||||
(u) => (u as Record<string, unknown>)[field as string] as string | null,
|
||||
async (id, ciphertext) => {
|
||||
await prisma.user.update({
|
||||
where: { id },
|
||||
data: { [field]: ciphertext } as Record<string, string>,
|
||||
});
|
||||
},
|
||||
);
|
||||
results.push(r);
|
||||
}
|
||||
|
||||
// ───── WithingsConnection table (accessToken / refreshToken) ─────
|
||||
const withings = await prisma.withingsConnection.findMany({
|
||||
select: { id: true, accessToken: true, refreshToken: true },
|
||||
});
|
||||
for (const field of ["accessToken", "refreshToken"] as const) {
|
||||
const r = await rotateField(
|
||||
"WithingsConnection",
|
||||
field,
|
||||
withings,
|
||||
(w) => w[field],
|
||||
async (id, ciphertext) => {
|
||||
await prisma.withingsConnection.update({
|
||||
where: { id },
|
||||
data: { [field]: ciphertext } as Record<string, string>,
|
||||
});
|
||||
},
|
||||
);
|
||||
results.push(r);
|
||||
}
|
||||
|
||||
// ───── AppSettings table (singleton typically) ─────
|
||||
const settings = await prisma.appSettings.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
adminAiKeyEncrypted: true,
|
||||
webPushVapidPrivateKeyEncrypted: true,
|
||||
githubIssueTokenEncrypted: true,
|
||||
},
|
||||
});
|
||||
const appFields = [
|
||||
"adminAiKeyEncrypted",
|
||||
"webPushVapidPrivateKeyEncrypted",
|
||||
"githubIssueTokenEncrypted",
|
||||
] as const;
|
||||
for (const field of appFields) {
|
||||
const r = await rotateField(
|
||||
"AppSettings",
|
||||
field,
|
||||
settings,
|
||||
(s) => s[field],
|
||||
async (id, ciphertext) => {
|
||||
await prisma.appSettings.update({
|
||||
where: { id },
|
||||
data: { [field]: ciphertext } as Record<string, string>,
|
||||
});
|
||||
},
|
||||
);
|
||||
results.push(r);
|
||||
}
|
||||
|
||||
// ───── NotificationChannel.config ─────
|
||||
// Channel config (Telegram chat id, ntfy topic, etc.) is encrypted JSON.
|
||||
// Skipping these on rotation would leave channels permanently undecryptable
|
||||
// once the operator drops `v1` from the key map.
|
||||
const channels = await prisma.notificationChannel.findMany({
|
||||
select: { id: true, config: true },
|
||||
});
|
||||
results.push(
|
||||
await rotateField(
|
||||
"NotificationChannel",
|
||||
"config",
|
||||
channels,
|
||||
(c) => c.config,
|
||||
async (id, ciphertext) => {
|
||||
await prisma.notificationChannel.update({
|
||||
where: { id },
|
||||
data: { config: ciphertext },
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// ───── PushSubscription.{p256dh, auth} ─────
|
||||
// Web-push routing secrets — without these, the push endpoint is reachable
|
||||
// but the browser ignores the message (auth tag mismatch).
|
||||
const subs = await prisma.pushSubscription.findMany({
|
||||
select: { id: true, p256dh: true, auth: true },
|
||||
});
|
||||
for (const field of ["p256dh", "auth"] as const) {
|
||||
results.push(
|
||||
await rotateField(
|
||||
"PushSubscription",
|
||||
field,
|
||||
subs,
|
||||
(s) => s[field],
|
||||
async (id, ciphertext) => {
|
||||
await prisma.pushSubscription.update({
|
||||
where: { id },
|
||||
data: { [field]: ciphertext } as Record<string, string>,
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
console.log("\n=== Rotation summary ===");
|
||||
let totalRotated = 0;
|
||||
let totalErrors = 0;
|
||||
for (const r of results) {
|
||||
console.log(
|
||||
`${r.table}.${r.field}: scanned=${r.scanned} rotated=${r.rotated} errors=${r.errors}`,
|
||||
);
|
||||
totalRotated += r.rotated;
|
||||
totalErrors += r.errors;
|
||||
}
|
||||
console.log(`\nTOTAL rotated=${totalRotated} errors=${totalErrors}`);
|
||||
await prisma.$disconnect();
|
||||
process.exit(totalErrors > 0 ? 3 : 0);
|
||||
}
|
||||
|
||||
main().catch(async (err) => {
|
||||
console.error("Rotation failed:", err);
|
||||
await prisma.$disconnect();
|
||||
process.exit(1);
|
||||
});
|
||||
+21
-2095
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,138 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAdmin, HttpError } from "@/lib/api-handler";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, getClientIp, safeJson } from "@/lib/api-response";
|
||||
import { encrypt, decrypt } from "@/lib/crypto";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
await requireAdmin();
|
||||
annotate({ action: { name: "admin.ai-settings.get" } });
|
||||
|
||||
const settings = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
});
|
||||
|
||||
const hasKey = Boolean(settings?.adminAiKeyEncrypted);
|
||||
const model = settings?.adminAiModel ?? "gpt-4o-mini";
|
||||
const baseUrl = settings?.adminAiBaseUrl ?? "https://api.openai.com/v1";
|
||||
|
||||
return apiSuccess({
|
||||
hasKey,
|
||||
keyPreview: settings?.adminAiKeyEncrypted
|
||||
? `...${decrypt(settings.adminAiKeyEncrypted).slice(-4)}`
|
||||
: null,
|
||||
model,
|
||||
baseUrl,
|
||||
});
|
||||
});
|
||||
|
||||
export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAdmin();
|
||||
annotate({ action: { name: "admin.ai-settings.update" } });
|
||||
|
||||
const rl = await checkRateLimit("admin-ai-settings", 10, 60_000);
|
||||
if (!rl.allowed) throw new HttpError(429, "Too many requests");
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const { apiKey, model, baseUrl } = body as {
|
||||
apiKey?: string;
|
||||
model?: string;
|
||||
baseUrl?: string;
|
||||
};
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
const auditDetails: Record<string, unknown> = {};
|
||||
|
||||
if (apiKey !== undefined) {
|
||||
const trimmed = apiKey.trim();
|
||||
if (trimmed) {
|
||||
updates.adminAiKeyEncrypted = encrypt(trimmed);
|
||||
auditDetails.apiKeyUpdated = true;
|
||||
} else {
|
||||
updates.adminAiKeyEncrypted = null;
|
||||
auditDetails.apiKeyUpdated = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (model !== undefined) {
|
||||
const trimmed = model.trim();
|
||||
if (!trimmed) throw new HttpError(422, "Model cannot be empty");
|
||||
updates.adminAiModel = trimmed;
|
||||
auditDetails.model = trimmed;
|
||||
}
|
||||
|
||||
if (baseUrl !== undefined) {
|
||||
const trimmed = baseUrl.trim();
|
||||
if (!trimmed) throw new HttpError(422, "Base URL cannot be empty");
|
||||
|
||||
// The admin AI base URL has full egress of the bearer key on every
|
||||
// insight call. Lock it to HTTPS + a hostname allowlist so a
|
||||
// compromised admin (or stolen cookie) cannot point it at
|
||||
// attacker.example and exfiltrate the key on the next AI call.
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(trimmed);
|
||||
} catch {
|
||||
throw new HttpError(422, "Base URL is not a valid URL");
|
||||
}
|
||||
if (parsed.protocol !== "https:") {
|
||||
throw new HttpError(422, "Base URL must use https://");
|
||||
}
|
||||
const allowedHosts = new Set([
|
||||
"api.openai.com",
|
||||
"api.anthropic.com",
|
||||
"generativelanguage.googleapis.com",
|
||||
"api.mistral.ai",
|
||||
"api.groq.com",
|
||||
"openrouter.ai",
|
||||
]);
|
||||
for (const h of (process.env.ADMIN_AI_BASE_URL_ALLOWLIST ?? "")
|
||||
.split(",")
|
||||
.map((h) => h.trim())
|
||||
.filter(Boolean)) {
|
||||
allowedHosts.add(h);
|
||||
}
|
||||
if (!allowedHosts.has(parsed.hostname)) {
|
||||
throw new HttpError(
|
||||
422,
|
||||
`Base URL host '${parsed.hostname}' not in allowlist. Set ADMIN_AI_BASE_URL_ALLOWLIST if you need a custom provider.`,
|
||||
);
|
||||
}
|
||||
updates.adminAiBaseUrl = trimmed;
|
||||
auditDetails.baseUrl = trimmed;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
throw new HttpError(422, "No valid fields");
|
||||
}
|
||||
|
||||
const settings = await prisma.appSettings.upsert({
|
||||
where: { id: "singleton" },
|
||||
update: updates,
|
||||
create: { id: "singleton", ...updates },
|
||||
});
|
||||
|
||||
await auditLog("admin.ai-settings.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: auditDetails,
|
||||
});
|
||||
|
||||
const hasKey = Boolean(settings.adminAiKeyEncrypted);
|
||||
|
||||
return apiSuccess({
|
||||
hasKey,
|
||||
keyPreview: settings.adminAiKeyEncrypted
|
||||
? `...${decrypt(settings.adminAiKeyEncrypted).slice(-4)}`
|
||||
: null,
|
||||
model: settings.adminAiModel,
|
||||
baseUrl: settings.adminAiBaseUrl,
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -30,11 +30,11 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
const body = await request.json();
|
||||
confirm = typeof body?.confirm === "string" ? body.confirm : "";
|
||||
} catch {
|
||||
return apiError("Ungültige Anfrage", 422);
|
||||
return apiError("Invalid request", 422);
|
||||
}
|
||||
|
||||
if (confirm !== "DELETE ALL") {
|
||||
return apiError("Bestätigung fehlt", 422);
|
||||
return apiError("Confirmation missing", 422);
|
||||
}
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
@@ -51,7 +51,6 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
heightCm: null,
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
openaiKeyEncrypted: null,
|
||||
insightsPrivacyMode: "aggregated",
|
||||
insightsCachedAt: null,
|
||||
insightsCachedText: null,
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* POST /api/admin/feedback/[id]/github — promote feedback to a GitHub Issue.
|
||||
*
|
||||
* Reuses publishFeedbackToGithub() so the issue body matches the legacy
|
||||
* /api/bugreport flow. Idempotent: if gitHubIssueUrl is already set we return
|
||||
* the existing URL instead of creating a duplicate.
|
||||
*/
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
publishFeedbackToGithub,
|
||||
GithubPublishError,
|
||||
} from "@/lib/feedback/publish-github";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
export const POST = apiHandler(
|
||||
async (request: NextRequest, ctx: { params: Promise<{ id: string }> }) => {
|
||||
const { user: admin } = await requireAdmin();
|
||||
const { id } = await ctx.params;
|
||||
|
||||
const feedback = await prisma.feedback.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
category: true,
|
||||
subject: true,
|
||||
description: true,
|
||||
gitHubIssueUrl: true,
|
||||
screenshotBase64: true,
|
||||
metadata: true,
|
||||
user: { select: { username: true } },
|
||||
},
|
||||
});
|
||||
if (!feedback) return apiError("Feedback not found", 404);
|
||||
|
||||
if (feedback.gitHubIssueUrl) {
|
||||
return apiError(
|
||||
`Already published: ${feedback.gitHubIssueUrl}`,
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
// Atomic claim to prevent concurrent admin clicks from creating two
|
||||
// duplicate GitHub issues. Only the first caller whose updateMany
|
||||
// matches (gitHubIssueUrl still null) proceeds. We write a PENDING
|
||||
// sentinel now and replace it with the real URL after the API call.
|
||||
const PENDING = "pending://creating";
|
||||
const claim = await prisma.feedback.updateMany({
|
||||
where: { id, gitHubIssueUrl: null },
|
||||
data: { gitHubIssueUrl: PENDING },
|
||||
});
|
||||
if (claim.count !== 1) {
|
||||
return apiError("Already being published by another request", 409);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await publishFeedbackToGithub({
|
||||
category: feedback.category,
|
||||
subject: feedback.subject,
|
||||
description: feedback.description,
|
||||
username: feedback.user?.username ?? "anonymous",
|
||||
metadata: feedback.metadata,
|
||||
});
|
||||
|
||||
await prisma.feedback.update({
|
||||
where: { id },
|
||||
data: { gitHubIssueUrl: result.issueUrl },
|
||||
});
|
||||
|
||||
await auditLog("admin.feedback.github_publish", {
|
||||
userId: admin.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { feedbackId: id, issueNumber: result.issueNumber },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "admin.feedback.github_publish" },
|
||||
meta: { feedback_id: id, issue_number: result.issueNumber },
|
||||
});
|
||||
|
||||
return apiSuccess({ issueUrl: result.issueUrl });
|
||||
} catch (err) {
|
||||
// Release the PENDING claim so a retry can succeed.
|
||||
await prisma.feedback
|
||||
.updateMany({
|
||||
where: { id, gitHubIssueUrl: PENDING },
|
||||
data: { gitHubIssueUrl: null },
|
||||
})
|
||||
.catch(() => undefined);
|
||||
if (err instanceof GithubPublishError) {
|
||||
return apiError(err.message, err.status);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* PATCH /api/admin/feedback/[id] — change status or admin note.
|
||||
* 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 { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { updateFeedbackSchema } from "@/lib/validations/feedback";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
export const PATCH = apiHandler(
|
||||
async (request: NextRequest, ctx: { params: Promise<{ id: string }> }) => {
|
||||
const { user } = await requireAdmin();
|
||||
const { id } = await ctx.params;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = updateFeedbackSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
}
|
||||
|
||||
const before = await prisma.feedback.findUnique({ where: { id } });
|
||||
if (!before) return apiError("Feedback not found", 404);
|
||||
|
||||
const updated = await prisma.feedback.update({
|
||||
where: { id },
|
||||
data: parsed.data,
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
adminNote: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog("admin.feedback.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
feedbackId: id,
|
||||
before: { status: before.status, adminNote: before.adminNote },
|
||||
after: parsed.data,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "admin.feedback.update" },
|
||||
meta: { feedback_id: id },
|
||||
});
|
||||
|
||||
return apiSuccess(updated);
|
||||
},
|
||||
);
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (request: NextRequest, ctx: { params: Promise<{ id: string }> }) => {
|
||||
const { user } = await requireAdmin();
|
||||
const { id } = await ctx.params;
|
||||
|
||||
const updated = await prisma.feedback.update({
|
||||
where: { id },
|
||||
data: { status: "ARCHIVED" },
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
|
||||
await auditLog("admin.feedback.archive", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { feedbackId: id },
|
||||
});
|
||||
|
||||
return apiSuccess(updated);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Admin feedback inbox. Lists all feedback with status filter.
|
||||
*/
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { NextRequest } from "next/server";
|
||||
import type { FeedbackStatus } from "@/generated/prisma/client";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
await requireAdmin();
|
||||
|
||||
const url = new URL(request.url);
|
||||
const status = url.searchParams.get("status") as FeedbackStatus | null;
|
||||
const limit = Math.min(
|
||||
100,
|
||||
Math.max(1, Number(url.searchParams.get("limit") ?? 50)),
|
||||
);
|
||||
const offset = Math.max(0, Number(url.searchParams.get("offset") ?? 0));
|
||||
|
||||
const where = status ? { status } : {};
|
||||
|
||||
const [items, total, counts] = await Promise.all([
|
||||
prisma.feedback.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
email: true,
|
||||
category: true,
|
||||
subject: true,
|
||||
description: true,
|
||||
status: true,
|
||||
adminNote: true,
|
||||
gitHubIssueUrl: true,
|
||||
metadata: true,
|
||||
screenshotBase64: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
user: { select: { username: true } },
|
||||
},
|
||||
}),
|
||||
prisma.feedback.count({ where }),
|
||||
prisma.feedback.groupBy({
|
||||
by: ["status"],
|
||||
_count: { _all: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
annotate({
|
||||
action: { name: "admin.feedback.list" },
|
||||
meta: { total, filtered_status: status },
|
||||
});
|
||||
|
||||
const countsByStatus = Object.fromEntries(
|
||||
counts.map((c) => [c.status, c._count._all]),
|
||||
) as Record<FeedbackStatus, number>;
|
||||
|
||||
return apiSuccess({
|
||||
items,
|
||||
meta: { total, limit, offset, countsByStatus },
|
||||
});
|
||||
});
|
||||
@@ -12,10 +12,10 @@ export const POST = apiHandler(async () => {
|
||||
|
||||
const settings = await getGlitchtipSettings();
|
||||
if (!settings.glitchtipEnabled) {
|
||||
return apiError("Glitchtip ist deaktiviert", 422);
|
||||
return apiError("Glitchtip is disabled", 422);
|
||||
}
|
||||
if (!settings.glitchtipDsn) {
|
||||
return apiError("Glitchtip DSN fehlt", 422);
|
||||
return apiError("Glitchtip DSN is missing", 422);
|
||||
}
|
||||
|
||||
const delivery = await sendGlitchtipEvent({
|
||||
@@ -32,13 +32,13 @@ export const POST = apiHandler(async () => {
|
||||
if (!delivery.ok) {
|
||||
getEvent()?.addWarning("Glitchtip test event rejected: " + delivery.method + " " + delivery.status + " " + delivery.details);
|
||||
return apiError(
|
||||
`Glitchtip-Testevent wurde abgelehnt (HTTP ${delivery.status ?? 502})`,
|
||||
`Glitchtip test event rejected (HTTP ${delivery.status ?? 502})`,
|
||||
502,
|
||||
);
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
sent: true,
|
||||
message: "Glitchtip-Testevent wurde gesendet",
|
||||
message: "Glitchtip test event sent",
|
||||
});
|
||||
});
|
||||
@@ -51,18 +51,18 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const settings = await getPublicMonitoringSettings();
|
||||
if (!settings.umamiEnabled) {
|
||||
return apiError("Umami ist deaktiviert", 422);
|
||||
return apiError("Umami is disabled", 422);
|
||||
}
|
||||
if (!settings.umamiScriptUrl || !settings.umamiWebsiteId) {
|
||||
return apiError(
|
||||
"Umami Script-URL und Website-ID müssen konfiguriert sein",
|
||||
"Umami script URL and website ID must be configured",
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
const targetUrls = resolveUmamiSendUrls(settings.umamiScriptUrl);
|
||||
if (targetUrls.length === 0) {
|
||||
return apiError("Umami Script-URL ist ungültig", 422);
|
||||
return apiError("Umami script URL is invalid", 422);
|
||||
}
|
||||
|
||||
const appUrl = resolveAppUrl(request);
|
||||
@@ -72,7 +72,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
website: settings.umamiWebsiteId,
|
||||
hostname: appUrl.hostname,
|
||||
screen: "1920x1080",
|
||||
language: "de-DE",
|
||||
language: "en-US",
|
||||
title: "HealthLog Monitoring Test",
|
||||
url: "/admin",
|
||||
referrer: "",
|
||||
@@ -100,7 +100,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
if (upstream.ok) {
|
||||
return apiSuccess({
|
||||
sent: true,
|
||||
message: "Umami-Testevent wurde gesendet",
|
||||
message: "Umami test event sent",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
getEvent()?.addWarning("Umami test event rejected: " + lastStatus);
|
||||
return apiError(
|
||||
`Umami-Testevent wurde abgelehnt (HTTP ${lastStatus})`,
|
||||
`Umami test event rejected (HTTP ${lastStatus})`,
|
||||
502,
|
||||
);
|
||||
});
|
||||
@@ -11,7 +11,9 @@ export const dynamic = "force-dynamic";
|
||||
function parseTimeToMinutes(value: string): number {
|
||||
const [h, m] = value.split(":").map(Number);
|
||||
if (!Number.isFinite(h) || !Number.isFinite(m)) return 0;
|
||||
return h * 60 + m;
|
||||
// Normalize "24:00" → "00:00" (some ICU builds emit that for midnight).
|
||||
const hours = h === 24 ? 0 : h;
|
||||
return hours * 60 + m;
|
||||
}
|
||||
|
||||
const dayLabels = ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"];
|
||||
@@ -71,7 +73,7 @@ export const POST = apiHandler(async () => {
|
||||
now,
|
||||
userTz,
|
||||
);
|
||||
const currentTime = now.toLocaleTimeString("de-DE", {
|
||||
const currentTime = now.toLocaleTimeString("en-GB", {
|
||||
timeZone: userTz,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
|
||||
@@ -79,7 +79,7 @@ export const POST = apiHandler(async () => {
|
||||
if (channels.length === 0) {
|
||||
return apiSuccess({
|
||||
sent: false,
|
||||
message: "Keine aktivierten Benachrichtigungskanäle gefunden",
|
||||
message: "No enabled notification channels found",
|
||||
results: [],
|
||||
});
|
||||
}
|
||||
@@ -89,7 +89,7 @@ export const POST = apiHandler(async () => {
|
||||
userId: user.id,
|
||||
title: "Test-Notification",
|
||||
message:
|
||||
"<b>HealthLog Test:</b> Wenn du diese Nachricht siehst, funktionieren deine Benachrichtigungen!",
|
||||
"<b>HealthLog Test:</b> If you see this message, your notifications are working!",
|
||||
};
|
||||
|
||||
const results: Array<{
|
||||
@@ -104,7 +104,7 @@ export const POST = apiHandler(async () => {
|
||||
results.push({
|
||||
channel: channel.type,
|
||||
success: false,
|
||||
error: "SYSTEM_ALERT deaktiviert in Einstellungen",
|
||||
error: "SYSTEM_ALERT disabled in settings",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -124,7 +124,7 @@ export const POST = apiHandler(async () => {
|
||||
results.push({
|
||||
channel: "TELEGRAM",
|
||||
success: false,
|
||||
error: `Telegram API hat Fehler gemeldet (chatId: ${config.chatId})`,
|
||||
error: `Telegram API reported error (chatId: ${config.chatId})`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -139,7 +139,7 @@ export const POST = apiHandler(async () => {
|
||||
results.push({
|
||||
channel: "NTFY",
|
||||
success: false,
|
||||
error: `Senden fehlgeschlagen (topic: ${config.topic})`,
|
||||
error: `Send failed (topic: ${config.topic})`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -153,7 +153,7 @@ export const POST = apiHandler(async () => {
|
||||
results.push({
|
||||
channel: type,
|
||||
success: false,
|
||||
error: "Unbekannter Kanaltyp",
|
||||
error: "Unknown channel type",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -163,7 +163,7 @@ export const POST = apiHandler(async () => {
|
||||
results.push({
|
||||
channel: channel.type,
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : "Unbekannter Fehler",
|
||||
error: err instanceof Error ? err.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -173,11 +173,11 @@ export const POST = apiHandler(async () => {
|
||||
|
||||
let message: string;
|
||||
if (failCount === 0) {
|
||||
message = `Alle ${successCount} Kanäle erfolgreich`;
|
||||
message = `All ${successCount} channels successful`;
|
||||
} else if (successCount === 0) {
|
||||
message = `Alle ${failCount} Kanäle fehlgeschlagen`;
|
||||
message = `All ${failCount} channels failed`;
|
||||
} else {
|
||||
message = `${successCount} erfolgreich, ${failCount} fehlgeschlagen`;
|
||||
message = `${successCount} successful, ${failCount} failed`;
|
||||
}
|
||||
|
||||
return apiSuccess({ sent: successCount > 0, message, results });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -180,7 +180,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return apiError("Keine gültigen Felder", 422);
|
||||
return apiError("No valid fields", 422);
|
||||
}
|
||||
|
||||
const settings = await prisma.appSettings.upsert({
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { count: vi.fn() },
|
||||
withingsConnection: { count: vi.fn() },
|
||||
notificationChannel: { count: vi.fn() },
|
||||
pushSubscription: { count: vi.fn() },
|
||||
appSettings: { findUnique: vi.fn() },
|
||||
auditLog: { findFirst: vi.fn(), count: vi.fn() },
|
||||
dataBackup: { findFirst: vi.fn(), findMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/lib/api-handler")>(
|
||||
"@/lib/api-handler",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
|
||||
h: T,
|
||||
): T => h,
|
||||
requireAdmin: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/jobs/worker-status", () => ({
|
||||
getWorkerStatus: vi.fn(() => ({
|
||||
running: true,
|
||||
startedAt: new Date(Date.now() - 3_600_000).toISOString(),
|
||||
lastHeartbeat: null,
|
||||
lastReminderCheck: null,
|
||||
lastWithingsSync: null,
|
||||
lastInsightsRun: null,
|
||||
jobsProcessed: 0,
|
||||
errors: 0,
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
const mocks = prisma as unknown as {
|
||||
user: { count: ReturnType<typeof vi.fn> };
|
||||
withingsConnection: { count: ReturnType<typeof vi.fn> };
|
||||
notificationChannel: { count: ReturnType<typeof vi.fn> };
|
||||
pushSubscription: { count: ReturnType<typeof vi.fn> };
|
||||
appSettings: { findUnique: ReturnType<typeof vi.fn> };
|
||||
auditLog: {
|
||||
findFirst: ReturnType<typeof vi.fn>;
|
||||
count: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
dataBackup: {
|
||||
findFirst: ReturnType<typeof vi.fn>;
|
||||
findMany: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
};
|
||||
|
||||
describe("GET /api/admin/status-overview", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// default returns
|
||||
mocks.user.count.mockResolvedValue(0);
|
||||
mocks.withingsConnection.count.mockResolvedValue(0);
|
||||
mocks.notificationChannel.count.mockResolvedValue(0);
|
||||
mocks.pushSubscription.count.mockResolvedValue(0);
|
||||
mocks.appSettings.findUnique.mockResolvedValue(null);
|
||||
mocks.auditLog.findFirst.mockResolvedValue(null);
|
||||
mocks.auditLog.count.mockResolvedValue(0);
|
||||
mocks.dataBackup.findFirst.mockResolvedValue(null);
|
||||
mocks.dataBackup.findMany.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("returns the 6-section overview shape", async () => {
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as { data: Record<string, unknown> };
|
||||
expect(body.data).toHaveProperty("users");
|
||||
expect(body.data).toHaveProperty("integrations");
|
||||
expect(body.data).toHaveProperty("monitoring");
|
||||
expect(body.data).toHaveProperty("backups");
|
||||
expect(body.data).toHaveProperty("maintenance");
|
||||
expect(body.data).toHaveProperty("auditLog");
|
||||
});
|
||||
|
||||
it("batches DB calls in a single Promise.all (no N+1)", async () => {
|
||||
await GET();
|
||||
// 3 user.count + 1 withings + 1 ntfy notifications + 1 webPush + 3
|
||||
// moodLog/telegram done via user.count → expect user.count called 4 times
|
||||
// (total, admins, newThisWeek, moodLog, telegram)
|
||||
expect(mocks.user.count).toHaveBeenCalledTimes(5);
|
||||
expect(mocks.withingsConnection.count).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.notificationChannel.count).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.pushSubscription.count).toHaveBeenCalledTimes(1);
|
||||
// The route does not loop — every aggregate is a single query.
|
||||
expect(mocks.dataBackup.findMany).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("flags backups as alert when none exist", async () => {
|
||||
mocks.dataBackup.findFirst.mockResolvedValue(null);
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as {
|
||||
data: { backups: { severity: string } };
|
||||
};
|
||||
expect(body.data.backups.severity).toBe("alert");
|
||||
});
|
||||
|
||||
it("flags backups as good when fresh (<8d)", async () => {
|
||||
mocks.dataBackup.findFirst.mockResolvedValue({
|
||||
createdAt: new Date(Date.now() - 24 * 60 * 60 * 1000),
|
||||
});
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as {
|
||||
data: { backups: { severity: string } };
|
||||
};
|
||||
expect(body.data.backups.severity).toBe("good");
|
||||
});
|
||||
|
||||
it("flags monitoring as alert on a recent error (<24h)", async () => {
|
||||
mocks.auditLog.findFirst.mockImplementation(
|
||||
({ where }: { where: { action: unknown } }) => {
|
||||
const action = where.action as { startsWith?: string } | string;
|
||||
if (
|
||||
typeof action === "object" &&
|
||||
action.startsWith === "system.error"
|
||||
) {
|
||||
return Promise.resolve({ createdAt: new Date(Date.now() - 60_000) });
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
},
|
||||
);
|
||||
mocks.appSettings.findUnique.mockResolvedValue({
|
||||
glitchtipEnabled: true,
|
||||
umamiEnabled: true,
|
||||
});
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as {
|
||||
data: { monitoring: { severity: string } };
|
||||
};
|
||||
expect(body.data.monitoring.severity).toBe("alert");
|
||||
});
|
||||
|
||||
it("flags maintenance as alert when worker is stopped", async () => {
|
||||
const wsModule = await import("@/lib/jobs/worker-status");
|
||||
vi.mocked(wsModule.getWorkerStatus).mockReturnValue({
|
||||
running: false,
|
||||
startedAt: null,
|
||||
lastHeartbeat: null,
|
||||
lastReminderCheck: null,
|
||||
lastWithingsSync: null,
|
||||
lastInsightsRun: null,
|
||||
jobsProcessed: 0,
|
||||
errors: 0,
|
||||
});
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as {
|
||||
data: { maintenance: { severity: string; workerRunning: boolean } };
|
||||
};
|
||||
expect(body.data.maintenance.severity).toBe("alert");
|
||||
expect(body.data.maintenance.workerRunning).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { getWorkerStatus } from "@/lib/jobs/worker-status";
|
||||
|
||||
/**
|
||||
* Severity tokens map onto §2.6 status taxonomy of `docs/ui-guidelines.md`.
|
||||
* Returned as plain strings so the client renders dots + labels — not RAW
|
||||
* colours — and never leans on color alone for state.
|
||||
*/
|
||||
export type StatusSeverity = "good" | "info" | "caution" | "alert" | "pending";
|
||||
|
||||
export interface StatusOverview {
|
||||
users: {
|
||||
severity: StatusSeverity;
|
||||
total: number;
|
||||
admins: number;
|
||||
newThisWeek: number;
|
||||
};
|
||||
integrations: {
|
||||
severity: StatusSeverity;
|
||||
withings: number;
|
||||
moodLog: number;
|
||||
telegram: number;
|
||||
ntfy: number;
|
||||
webPush: number;
|
||||
};
|
||||
monitoring: {
|
||||
severity: StatusSeverity;
|
||||
glitchtipEnabled: boolean;
|
||||
umamiEnabled: boolean;
|
||||
wideEventsEnabled: boolean;
|
||||
lastErrorAt: string | null;
|
||||
};
|
||||
backups: {
|
||||
severity: StatusSeverity;
|
||||
lastBackupAt: string | null;
|
||||
backedUpUsers: number;
|
||||
retentionDays: number;
|
||||
};
|
||||
maintenance: {
|
||||
severity: StatusSeverity;
|
||||
workerRunning: boolean;
|
||||
workerUptimeSeconds: number | null;
|
||||
lastIdempotencyCleanup: string | null;
|
||||
lastAuditLogCleanup: string | null;
|
||||
};
|
||||
auditLog: {
|
||||
severity: StatusSeverity;
|
||||
eventsLast30d: number;
|
||||
lastLoginAt: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function severityForBackup(lastBackupAt: Date | null): StatusSeverity {
|
||||
if (!lastBackupAt) return "alert";
|
||||
const ageMs = Date.now() - lastBackupAt.getTime();
|
||||
if (ageMs > 14 * ONE_DAY_MS) return "alert";
|
||||
if (ageMs > 8 * ONE_DAY_MS) return "caution";
|
||||
return "good";
|
||||
}
|
||||
|
||||
function severityForMaintenance(workerRunning: boolean): StatusSeverity {
|
||||
return workerRunning ? "good" : "alert";
|
||||
}
|
||||
|
||||
function severityForMonitoring(
|
||||
glitchtipEnabled: boolean,
|
||||
umamiEnabled: boolean,
|
||||
recentErrorAt: Date | null,
|
||||
): StatusSeverity {
|
||||
if (recentErrorAt && Date.now() - recentErrorAt.getTime() < ONE_DAY_MS) {
|
||||
return "alert";
|
||||
}
|
||||
if (glitchtipEnabled && umamiEnabled) return "good";
|
||||
if (glitchtipEnabled || umamiEnabled) return "caution";
|
||||
return "info";
|
||||
}
|
||||
|
||||
function severityForIntegrations(totalConfigured: number): StatusSeverity {
|
||||
if (totalConfigured === 0) return "info";
|
||||
return "good";
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
await requireAdmin();
|
||||
annotate({ action: { name: "admin.status-overview" } });
|
||||
|
||||
const now = new Date();
|
||||
const sevenDaysAgo = new Date(now.getTime() - 7 * ONE_DAY_MS);
|
||||
const thirtyDaysAgo = new Date(now.getTime() - 30 * ONE_DAY_MS);
|
||||
|
||||
// Single batched roundtrip — no N+1.
|
||||
const [
|
||||
userTotal,
|
||||
userAdmins,
|
||||
userNewThisWeek,
|
||||
withingsCount,
|
||||
moodLogCount,
|
||||
telegramCount,
|
||||
ntfyCount,
|
||||
webPushCount,
|
||||
appSettings,
|
||||
lastErrorEntry,
|
||||
latestBackup,
|
||||
backedUpUsers,
|
||||
last30dEvents,
|
||||
lastLoginEntry,
|
||||
lastIdempotencyCleanup,
|
||||
lastAuditLogCleanup,
|
||||
] = await Promise.all([
|
||||
prisma.user.count(),
|
||||
prisma.user.count({ where: { role: "ADMIN" } }),
|
||||
prisma.user.count({ where: { createdAt: { gte: sevenDaysAgo } } }),
|
||||
prisma.withingsConnection.count(),
|
||||
prisma.user.count({ where: { moodLogEnabled: true } }),
|
||||
prisma.user.count({ where: { telegramEnabled: true } }),
|
||||
prisma.notificationChannel.count({
|
||||
where: { type: "NTFY", enabled: true },
|
||||
}),
|
||||
prisma.pushSubscription.count(),
|
||||
prisma.appSettings.findUnique({ where: { id: "singleton" } }),
|
||||
prisma.auditLog.findFirst({
|
||||
where: { action: { startsWith: "system.error" } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true },
|
||||
}),
|
||||
prisma.dataBackup.findFirst({
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true },
|
||||
}),
|
||||
prisma.dataBackup
|
||||
.findMany({ select: { userId: true }, distinct: ["userId"] })
|
||||
.then((rows) => rows.length),
|
||||
prisma.auditLog.count({ where: { createdAt: { gte: thirtyDaysAgo } } }),
|
||||
prisma.auditLog.findFirst({
|
||||
where: { action: "auth.login" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true },
|
||||
}),
|
||||
prisma.auditLog.findFirst({
|
||||
where: { action: "system.cleanup.idempotency" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true },
|
||||
}),
|
||||
prisma.auditLog.findFirst({
|
||||
where: { action: "system.cleanup.audit-log" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const worker = getWorkerStatus();
|
||||
const workerUptimeSeconds = worker.startedAt
|
||||
? Math.max(
|
||||
0,
|
||||
Math.floor(
|
||||
(now.getTime() - new Date(worker.startedAt).getTime()) / 1000,
|
||||
),
|
||||
)
|
||||
: null;
|
||||
|
||||
const integrationsTotal =
|
||||
withingsCount + moodLogCount + telegramCount + ntfyCount + webPushCount;
|
||||
|
||||
const overview: StatusOverview = {
|
||||
users: {
|
||||
severity: "good",
|
||||
total: userTotal,
|
||||
admins: userAdmins,
|
||||
newThisWeek: userNewThisWeek,
|
||||
},
|
||||
integrations: {
|
||||
severity: severityForIntegrations(integrationsTotal),
|
||||
withings: withingsCount,
|
||||
moodLog: moodLogCount,
|
||||
telegram: telegramCount,
|
||||
ntfy: ntfyCount,
|
||||
webPush: webPushCount,
|
||||
},
|
||||
monitoring: {
|
||||
severity: severityForMonitoring(
|
||||
Boolean(appSettings?.glitchtipEnabled),
|
||||
Boolean(appSettings?.umamiEnabled),
|
||||
lastErrorEntry?.createdAt ?? null,
|
||||
),
|
||||
glitchtipEnabled: Boolean(appSettings?.glitchtipEnabled),
|
||||
umamiEnabled: Boolean(appSettings?.umamiEnabled),
|
||||
wideEventsEnabled: true,
|
||||
lastErrorAt: lastErrorEntry?.createdAt.toISOString() ?? null,
|
||||
},
|
||||
backups: {
|
||||
severity: severityForBackup(latestBackup?.createdAt ?? null),
|
||||
lastBackupAt: latestBackup?.createdAt.toISOString() ?? null,
|
||||
backedUpUsers,
|
||||
retentionDays: 90,
|
||||
},
|
||||
maintenance: {
|
||||
severity: severityForMaintenance(worker.running),
|
||||
workerRunning: worker.running,
|
||||
workerUptimeSeconds,
|
||||
lastIdempotencyCleanup:
|
||||
lastIdempotencyCleanup?.createdAt.toISOString() ?? null,
|
||||
lastAuditLogCleanup: lastAuditLogCleanup?.createdAt.toISOString() ?? null,
|
||||
},
|
||||
auditLog: {
|
||||
severity: "info",
|
||||
eventsLast30d: last30dEvents,
|
||||
lastLoginAt: lastLoginEntry?.createdAt.toISOString() ?? null,
|
||||
},
|
||||
};
|
||||
|
||||
return apiSuccess(overview);
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { count: vi.fn() },
|
||||
measurement: { count: vi.fn() },
|
||||
medication: { count: vi.fn() },
|
||||
medicationIntakeEvent: { count: vi.fn() },
|
||||
apiToken: { count: vi.fn() },
|
||||
session: { count: vi.fn() },
|
||||
appSettings: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/lib/api-handler")>(
|
||||
"@/lib/api-handler",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
|
||||
h: T,
|
||||
): T => h,
|
||||
requireAdmin: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/jobs/worker-status", () => ({
|
||||
getWorkerStatus: vi.fn(() => ({
|
||||
running: true,
|
||||
startedAt: new Date(Date.now() - 60_000).toISOString(),
|
||||
lastHeartbeat: null,
|
||||
lastReminderCheck: null,
|
||||
lastWithingsSync: null,
|
||||
lastInsightsRun: null,
|
||||
jobsProcessed: 0,
|
||||
errors: 0,
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAdmin, HttpError } from "@/lib/api-handler";
|
||||
|
||||
const ADMIN_CTX = {
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "admin-1",
|
||||
username: "admin",
|
||||
role: "ADMIN",
|
||||
} as never,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
|
||||
vi.mocked(prisma.user.count).mockResolvedValue(0);
|
||||
vi.mocked(prisma.measurement.count).mockResolvedValue(0);
|
||||
vi.mocked(prisma.medication.count).mockResolvedValue(0);
|
||||
vi.mocked(prisma.medicationIntakeEvent.count).mockResolvedValue(0);
|
||||
vi.mocked(prisma.apiToken.count).mockResolvedValue(0);
|
||||
vi.mocked(prisma.session.count).mockResolvedValue(0);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null);
|
||||
});
|
||||
|
||||
describe("GET /api/admin/status (integration health summary)", () => {
|
||||
it("rejects non-admin callers with 403", async () => {
|
||||
vi.mocked(requireAdmin).mockRejectedValue(
|
||||
new HttpError(403, "Admin access required"),
|
||||
);
|
||||
await expect(GET()).rejects.toThrow("Admin access required");
|
||||
});
|
||||
|
||||
it("returns null integration entries when no app settings exist", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null);
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { integrations: Record<string, unknown> };
|
||||
};
|
||||
expect(body.data.integrations).toEqual({
|
||||
umami: null,
|
||||
glitchtip: null,
|
||||
webPush: null,
|
||||
bugReport: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("aggregates configured + enabled flags for umami / glitchtip / web-push / bugReport", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
umamiScriptUrl: "https://x/script.js",
|
||||
umamiWebsiteId: "site-1",
|
||||
umamiEnabled: true,
|
||||
glitchtipDsn: "https://abc@x/123",
|
||||
glitchtipEnabled: false,
|
||||
webPushVapidPublicKey: "pub",
|
||||
webPushVapidPrivateKeyEncrypted: "secret-blob",
|
||||
webPushVapidSubject: "mailto:a@b.com",
|
||||
githubIssueRepo: "owner/repo",
|
||||
githubIssueTokenEncrypted: "token-blob",
|
||||
} as never);
|
||||
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as {
|
||||
data: { integrations: Record<string, Record<string, unknown> | null> };
|
||||
};
|
||||
expect(body.data.integrations.umami).toEqual({
|
||||
configured: true,
|
||||
enabled: true,
|
||||
});
|
||||
// glitchtip is configured but the global toggle is off
|
||||
expect(body.data.integrations.glitchtip).toEqual({
|
||||
configured: true,
|
||||
enabled: false,
|
||||
});
|
||||
expect(body.data.integrations.webPush).toEqual({ configured: true });
|
||||
expect(body.data.integrations.bugReport).toEqual({ configured: true });
|
||||
|
||||
// Regression: encrypted blobs MUST never appear in the response envelope.
|
||||
const serialized = JSON.stringify(body);
|
||||
expect(serialized).not.toContain("secret-blob");
|
||||
expect(serialized).not.toContain("token-blob");
|
||||
});
|
||||
|
||||
it("does not mark webPush configured when only the public key is present", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
webPushVapidPublicKey: "pub",
|
||||
webPushVapidPrivateKeyEncrypted: null,
|
||||
webPushVapidSubject: null,
|
||||
} as never);
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as {
|
||||
data: { integrations: { webPush: unknown } };
|
||||
};
|
||||
expect(body.data.integrations.webPush).toBeNull();
|
||||
});
|
||||
|
||||
it("returns aggregate counts and the worker status block", async () => {
|
||||
vi.mocked(prisma.user.count).mockResolvedValue(7);
|
||||
vi.mocked(prisma.measurement.count).mockResolvedValue(123);
|
||||
vi.mocked(prisma.apiToken.count).mockResolvedValue(2);
|
||||
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
counts: Record<string, number>;
|
||||
worker: { running: boolean };
|
||||
database: string;
|
||||
};
|
||||
};
|
||||
expect(body.data.counts).toEqual({
|
||||
users: 7,
|
||||
measurements: 123,
|
||||
medications: 0,
|
||||
intakeEvents: 0,
|
||||
activeTokens: 2,
|
||||
activeSessions: 0,
|
||||
});
|
||||
expect(body.data.worker.running).toBe(true);
|
||||
expect(body.data.database).toBe("connected");
|
||||
|
||||
// Regression: only NON-revoked tokens are counted as "active".
|
||||
const tokenCallArgs = vi.mocked(prisma.apiToken.count).mock.calls[0]?.[0];
|
||||
expect(tokenCallArgs).toEqual({ where: { revoked: false } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
count: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/lib/api-handler")>(
|
||||
"@/lib/api-handler",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
|
||||
h: T,
|
||||
): T => h,
|
||||
requireAdmin: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
import { PUT } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAdmin, HttpError } from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
const ADMIN_CTX = {
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "admin-1",
|
||||
username: "admin",
|
||||
role: "ADMIN",
|
||||
} as never,
|
||||
};
|
||||
|
||||
function jsonReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/admin/users/u1", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function params(id: string) {
|
||||
return { params: Promise.resolve({ id }) };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
|
||||
});
|
||||
|
||||
describe("PUT /api/admin/users/[id]", () => {
|
||||
it("rejects with 401 when unauthenticated", async () => {
|
||||
vi.mocked(requireAdmin).mockRejectedValue(
|
||||
new HttpError(401, "Not authenticated"),
|
||||
);
|
||||
await expect(
|
||||
PUT(jsonReq({ username: "marc" }), params("u1")),
|
||||
).rejects.toThrow("Not authenticated");
|
||||
});
|
||||
|
||||
it("rejects with 403 when caller is not admin", async () => {
|
||||
vi.mocked(requireAdmin).mockRejectedValue(
|
||||
new HttpError(403, "Admin access required"),
|
||||
);
|
||||
await expect(
|
||||
PUT(jsonReq({ username: "marc" }), params("u1")),
|
||||
).rejects.toThrow("Admin access required");
|
||||
});
|
||||
|
||||
it("returns 422 when payload fails Zod validation (short username)", async () => {
|
||||
const res = await PUT(jsonReq({ username: "ab" }), params("u1"));
|
||||
expect(res.status).toBe(422);
|
||||
expect(vi.mocked(prisma.user.findUnique)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 422 when role is unknown", async () => {
|
||||
const res = await PUT(jsonReq({ role: "SUPERUSER" }), params("u1"));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("returns 404 when target user does not exist", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
|
||||
const res = await PUT(jsonReq({ username: "marc" }), params("u-missing"));
|
||||
expect(res.status).toBe(404);
|
||||
expect(vi.mocked(prisma.user.update)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forbids demoting the last remaining admin", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
id: "u1",
|
||||
role: "ADMIN",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.count).mockResolvedValue(1);
|
||||
const res = await PUT(jsonReq({ role: "USER" }), params("u1"));
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toMatch(/last admin/i);
|
||||
expect(vi.mocked(prisma.user.update)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows demoting an admin while another admin exists", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
id: "u1",
|
||||
role: "ADMIN",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.count).mockResolvedValue(2);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({
|
||||
id: "u1",
|
||||
username: "alice",
|
||||
email: "a@b.com",
|
||||
role: "USER",
|
||||
createdAt: new Date("2024-01-01T00:00:00Z"),
|
||||
} as never);
|
||||
const res = await PUT(jsonReq({ role: "USER" }), params("u1"));
|
||||
expect(res.status).toBe(200);
|
||||
const updateArgs = vi.mocked(prisma.user.update).mock.calls[0]?.[0];
|
||||
expect(updateArgs?.where).toEqual({ id: "u1" });
|
||||
expect(updateArgs?.data).toEqual({ role: "USER" });
|
||||
});
|
||||
|
||||
it("persists username + email + role and writes an audit-log entry", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
id: "u1",
|
||||
role: "USER",
|
||||
} as never);
|
||||
const updated = {
|
||||
id: "u1",
|
||||
username: "renamed",
|
||||
email: "new@example.com",
|
||||
role: "ADMIN" as const,
|
||||
createdAt: new Date("2024-01-01T00:00:00Z"),
|
||||
};
|
||||
vi.mocked(prisma.user.update).mockResolvedValue(updated as never);
|
||||
|
||||
const res = await PUT(
|
||||
jsonReq({
|
||||
username: "renamed",
|
||||
email: "new@example.com",
|
||||
role: "ADMIN",
|
||||
}),
|
||||
params("u1"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: Record<string, unknown> };
|
||||
expect(body.data.username).toBe("renamed");
|
||||
expect(body.data.role).toBe("ADMIN");
|
||||
expect(body.data).not.toHaveProperty("passwordHash");
|
||||
|
||||
const updateArgs = vi.mocked(prisma.user.update).mock.calls[0]?.[0];
|
||||
expect(updateArgs?.data).toEqual({
|
||||
username: "renamed",
|
||||
email: "new@example.com",
|
||||
role: "ADMIN",
|
||||
});
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"admin.user.update",
|
||||
expect.objectContaining({
|
||||
userId: "admin-1",
|
||||
details: expect.objectContaining({ targetUserId: "u1" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("supports clearing email by passing null", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
id: "u1",
|
||||
role: "USER",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({
|
||||
id: "u1",
|
||||
username: "alice",
|
||||
email: null,
|
||||
role: "USER",
|
||||
createdAt: new Date(),
|
||||
} as never);
|
||||
const res = await PUT(jsonReq({ email: null }), params("u1"));
|
||||
expect(res.status).toBe(200);
|
||||
const updateArgs = vi.mocked(prisma.user.update).mock.calls[0]?.[0];
|
||||
expect(updateArgs?.data).toEqual({ email: null });
|
||||
});
|
||||
|
||||
it("returns 415 when content-type is not JSON", async () => {
|
||||
const r = new NextRequest("http://localhost/api/admin/users/u1", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "text/plain" },
|
||||
body: "{}",
|
||||
});
|
||||
const res = await PUT(r, params("u1"));
|
||||
expect(res.status).toBe(415);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ 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,
|
||||
@@ -32,16 +33,20 @@ export const POST = apiHandler(async (
|
||||
const { password } = body as { password?: string };
|
||||
|
||||
if (!password || typeof password !== "string") {
|
||||
return apiError("Passwort erforderlich", 422);
|
||||
return apiError("Password required", 422);
|
||||
}
|
||||
|
||||
const target = await prisma.user.findUnique({ where: { id } });
|
||||
if (!target) return apiError("Benutzer nicht gefunden", 404);
|
||||
if (!target) return apiError("User not found", 404);
|
||||
|
||||
const strength = checkPasswordStrength(password, [target.username]);
|
||||
const locale = await resolveServerLocale({
|
||||
request,
|
||||
userLocale: target.locale ?? null,
|
||||
});
|
||||
const strength = checkPasswordStrength(password, [target.username], locale);
|
||||
if (!strength.isAcceptable) {
|
||||
return apiError(
|
||||
strength.feedback[0] || "Passwort zu schwach (Score < 3)",
|
||||
strength.feedback[0] || "Password too weak (score < 3)",
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,13 +30,13 @@ export const PUT = apiHandler(async (
|
||||
}
|
||||
|
||||
const target = await prisma.user.findUnique({ where: { id } });
|
||||
if (!target) return apiError("Benutzer nicht gefunden", 404);
|
||||
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("Der letzte Admin kann nicht degradiert werden", 400);
|
||||
return apiError("The last admin cannot be demoted", 400);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/lib/api-handler")>(
|
||||
"@/lib/api-handler",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
|
||||
h: T,
|
||||
): T => h,
|
||||
requireAdmin: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAdmin, HttpError } from "@/lib/api-handler";
|
||||
|
||||
const mockUserFindMany = vi.mocked(prisma.user.findMany);
|
||||
const mockRequireAdmin = vi.mocked(requireAdmin);
|
||||
|
||||
describe("GET /api/admin/users", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockRequireAdmin.mockResolvedValue({
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "admin-1",
|
||||
username: "admin",
|
||||
role: "ADMIN",
|
||||
} as never,
|
||||
});
|
||||
mockUserFindMany.mockResolvedValue([] as never);
|
||||
});
|
||||
|
||||
it("rejects with 401 when no session", async () => {
|
||||
mockRequireAdmin.mockRejectedValue(new HttpError(401, "Not authenticated"));
|
||||
await expect(GET()).rejects.toThrow("Not authenticated");
|
||||
});
|
||||
|
||||
it("rejects with 403 when caller is not admin", async () => {
|
||||
mockRequireAdmin.mockRejectedValue(
|
||||
new HttpError(403, "Admin access required"),
|
||||
);
|
||||
await expect(GET()).rejects.toThrow("Admin access required");
|
||||
});
|
||||
|
||||
it("returns users sorted by createdAt ascending with passkey counts", async () => {
|
||||
const created = new Date("2024-01-01T00:00:00Z");
|
||||
mockUserFindMany.mockResolvedValue([
|
||||
{
|
||||
id: "u1",
|
||||
username: "alice",
|
||||
email: "alice@example.com",
|
||||
role: "ADMIN",
|
||||
createdAt: created,
|
||||
_count: { passkeys: 2 },
|
||||
},
|
||||
{
|
||||
id: "u2",
|
||||
username: "bob",
|
||||
email: null,
|
||||
role: "USER",
|
||||
createdAt: created,
|
||||
_count: { passkeys: 0 },
|
||||
},
|
||||
] as never);
|
||||
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: Array<Record<string, unknown>>;
|
||||
};
|
||||
expect(body.data).toHaveLength(2);
|
||||
expect(body.data[0]).toEqual({
|
||||
id: "u1",
|
||||
username: "alice",
|
||||
email: "alice@example.com",
|
||||
role: "ADMIN",
|
||||
createdAt: created.toISOString(),
|
||||
passkeyCount: 2,
|
||||
});
|
||||
expect(body.data[1].passkeyCount).toBe(0);
|
||||
expect(body.data[1].email).toBeNull();
|
||||
});
|
||||
|
||||
it("queries Prisma with the safe select shape (no password / sensitive cols)", async () => {
|
||||
await GET();
|
||||
expect(mockUserFindMany).toHaveBeenCalledTimes(1);
|
||||
const args = mockUserFindMany.mock.calls[0]?.[0] as {
|
||||
select: Record<string, unknown>;
|
||||
orderBy: { createdAt: "asc" | "desc" };
|
||||
};
|
||||
// Regression: the route must NOT pull password hash / token columns.
|
||||
expect(args.select).toEqual({
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
role: true,
|
||||
createdAt: true,
|
||||
_count: { select: { passkeys: true } },
|
||||
});
|
||||
expect(args.select).not.toHaveProperty("passwordHash");
|
||||
expect(args.orderBy).toEqual({ createdAt: "asc" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// Mocks must be hoisted before importing the route.
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({
|
||||
user: { id: "u-1" },
|
||||
session: { id: "s-1" },
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/ai/provider", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/lib/ai/provider")>(
|
||||
"@/lib/ai/provider",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
resolveProviderForTest: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { resolveProviderForTest } from "@/lib/ai/provider";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(resolveProviderForTest).mockReset();
|
||||
});
|
||||
|
||||
interface ApiErrorEnvelope {
|
||||
data: null;
|
||||
error: string;
|
||||
}
|
||||
|
||||
interface ApiSuccessEnvelope<T> {
|
||||
data: T;
|
||||
error: null;
|
||||
}
|
||||
|
||||
function emptyRequest(): Request {
|
||||
return new Request("http://localhost/api/ai/test", {
|
||||
method: "POST",
|
||||
headers: { "content-length": "0" },
|
||||
});
|
||||
}
|
||||
|
||||
function jsonRequest(body: unknown): Request {
|
||||
const text = JSON.stringify(body);
|
||||
return new Request("http://localhost/api/ai/test", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"content-length": String(text.length),
|
||||
},
|
||||
body: text,
|
||||
});
|
||||
}
|
||||
|
||||
// V3 audit: /api/ai/test was returning provider err.message + bodyExcerpt
|
||||
// directly to the client, leaking provider URLs / partial keys / internal
|
||||
// headers. Server now logs full details via annotate() and responds with
|
||||
// a categorised, generic message.
|
||||
describe("POST /api/ai/test — provider error leak guard", () => {
|
||||
function makeProviderThatThrows(
|
||||
err: Error & { httpStatus?: number; bodyExcerpt?: string },
|
||||
) {
|
||||
vi.mocked(resolveProviderForTest).mockResolvedValue({
|
||||
type: "openai",
|
||||
generateCompletion: vi.fn(async () => {
|
||||
throw err;
|
||||
}),
|
||||
} as never);
|
||||
}
|
||||
|
||||
it("does not echo provider err.message back to the client (HIGH coverage gap)", async () => {
|
||||
const err = Object.assign(
|
||||
new Error("OpenAI 401 from https://api.openai.com/v1 sk-leaked-key"),
|
||||
{
|
||||
httpStatus: 401 as const,
|
||||
bodyExcerpt: '{"error":"invalid api key sk-secret"}',
|
||||
},
|
||||
);
|
||||
makeProviderThatThrows(err);
|
||||
|
||||
const response = await POST(emptyRequest() as never);
|
||||
const body = (await response.json()) as ApiErrorEnvelope;
|
||||
|
||||
expect(response.status).toBe(502);
|
||||
expect(body.error ?? "").not.toMatch(/sk-/);
|
||||
expect(body.error ?? "").not.toMatch(/api\.openai\.com/);
|
||||
expect(body.error ?? "").not.toMatch(/invalid api key/i);
|
||||
expect(body.error).toBe("Provider rejected the credentials");
|
||||
});
|
||||
|
||||
it("returns the 429-categorised message when the provider rate-limits", async () => {
|
||||
makeProviderThatThrows(
|
||||
Object.assign(new Error("429 from openai"), { httpStatus: 429 as const }),
|
||||
);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(502);
|
||||
expect(((await response.json()) as ApiErrorEnvelope).error).toBe(
|
||||
"Provider rate-limited the request",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the 5xx-categorised message when the provider has a server error", async () => {
|
||||
makeProviderThatThrows(
|
||||
Object.assign(new Error("503 upstream"), { httpStatus: 503 as const }),
|
||||
);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(((await response.json()) as ApiErrorEnvelope).error).toBe(
|
||||
"Provider returned a server error",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the unknown-error fallback otherwise", async () => {
|
||||
makeProviderThatThrows(new Error("ECONNRESET"));
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(((await response.json()) as ApiErrorEnvelope).error).toBe(
|
||||
"Provider connection failed",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// v1.4 fix: the dropdown in /settings was not honoured — the test always
|
||||
// ran against the SAVED provider (Marc reported "wirft was Komisches raus").
|
||||
// The route now accepts a JSON override body and forwards the user's
|
||||
// unsaved selection to resolveProviderForTest().
|
||||
describe("POST /api/ai/test — dropdown-aware override", () => {
|
||||
function makeProviderThatSucceeds(model: string) {
|
||||
vi.mocked(resolveProviderForTest).mockResolvedValue({
|
||||
type: "anthropic",
|
||||
generateCompletion: vi.fn(async () => ({
|
||||
content: '{"ok":true}',
|
||||
providerType: "anthropic",
|
||||
model,
|
||||
tokensUsed: 5,
|
||||
})),
|
||||
} as never);
|
||||
}
|
||||
|
||||
it("forwards a dropdown override to resolveProviderForTest", async () => {
|
||||
makeProviderThatSucceeds("claude-3-5-sonnet-latest");
|
||||
const response = await POST(
|
||||
jsonRequest({
|
||||
provider: "ANTHROPIC",
|
||||
model: "claude-3-5-sonnet-latest",
|
||||
}) as never,
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
expect(resolveProviderForTest).toHaveBeenCalledWith("u-1", {
|
||||
provider: "ANTHROPIC",
|
||||
model: "claude-3-5-sonnet-latest",
|
||||
});
|
||||
const body = (await response.json()) as ApiSuccessEnvelope<{
|
||||
providerType: string;
|
||||
model: string;
|
||||
}>;
|
||||
expect(body.data.providerType).toBe("anthropic");
|
||||
expect(body.data.model).toBe("claude-3-5-sonnet-latest");
|
||||
});
|
||||
|
||||
it("rejects unknown provider values with 422", async () => {
|
||||
const response = await POST(
|
||||
jsonRequest({ provider: "MALICIOUS" }) as never,
|
||||
);
|
||||
expect(response.status).toBe(422);
|
||||
expect(resolveProviderForTest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the persisted config when the body is empty", async () => {
|
||||
makeProviderThatSucceeds("claude-3-5-sonnet-latest");
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(200);
|
||||
expect(resolveProviderForTest).toHaveBeenCalledWith("u-1", {});
|
||||
});
|
||||
|
||||
it("surfaces config errors as the same status as the resolver throws", async () => {
|
||||
const { AITestConfigError } = await import("@/lib/ai/provider");
|
||||
vi.mocked(resolveProviderForTest).mockRejectedValueOnce(
|
||||
new AITestConfigError(422, "Anthropic API key not configured"),
|
||||
);
|
||||
const response = await POST(
|
||||
jsonRequest({ provider: "ANTHROPIC" }) as never,
|
||||
);
|
||||
expect(response.status).toBe(422);
|
||||
const body = (await response.json()) as ApiErrorEnvelope;
|
||||
expect(body.error).toBe("Anthropic API key not configured");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
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";
|
||||
|
||||
const overrideSchema = z
|
||||
.object({
|
||||
provider: z
|
||||
.enum(["OPENAI", "ANTHROPIC", "LOCAL", "CHATGPT_OAUTH"])
|
||||
.optional()
|
||||
.nullable(),
|
||||
model: z.string().min(1).max(120).optional().nullable(),
|
||||
baseUrl: z.string().url().max(2048).optional().nullable(),
|
||||
anthropicKey: z.string().min(1).max(500).optional().nullable(),
|
||||
localKey: z.string().min(1).max(500).optional().nullable(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "ai.test" } });
|
||||
|
||||
const rl = await checkRateLimit(`ai-test:${user.id}`, 5, 60_000);
|
||||
if (!rl.allowed) return apiError("Too many test requests", 429);
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await provider.generateCompletion({
|
||||
systemPrompt: "You are a connection-test responder.",
|
||||
userPrompt: 'Reply with the JSON object {"ok": true} and nothing else.',
|
||||
temperature: 0,
|
||||
maxTokens: 32,
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
ok: true,
|
||||
providerType: result.providerType,
|
||||
model: result.model,
|
||||
tokensUsed: result.tokensUsed,
|
||||
sample: result.content.slice(0, 200),
|
||||
});
|
||||
} catch (e) {
|
||||
// V3 audit: do not return provider error message + bodyExcerpt to the
|
||||
// client (leaks provider URL / partial keys / internal headers).
|
||||
// Log full details server-side for the operator and respond with a
|
||||
// categorised, generic message.
|
||||
const err = e as Error & { httpStatus?: number; bodyExcerpt?: string };
|
||||
annotate({
|
||||
meta: {
|
||||
ai_test_error: err.message.slice(0, 500),
|
||||
ai_test_status: err.httpStatus ?? null,
|
||||
ai_test_body_excerpt: err.bodyExcerpt?.slice(0, 500) ?? null,
|
||||
ai_test_provider: provider.type,
|
||||
},
|
||||
});
|
||||
const status = err.httpStatus ?? 0;
|
||||
const safeMessage =
|
||||
status === 401 || status === 403
|
||||
? "Provider rejected the credentials"
|
||||
: status === 429
|
||||
? "Provider rate-limited the request"
|
||||
: status >= 500
|
||||
? "Provider returned a server error"
|
||||
: "Provider connection failed";
|
||||
return apiError(safeMessage, 502);
|
||||
}
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { apiSuccess } from "@/lib/api-response";
|
||||
import { summarize, type DataPoint } from "@/lib/analytics/trends";
|
||||
import { getBpTargets } from "@/lib/analytics/bp-targets";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -12,15 +13,9 @@ export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "analytics.get" } });
|
||||
|
||||
const types: MeasurementType[] = [
|
||||
"WEIGHT",
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
"PULSE",
|
||||
"BODY_FAT",
|
||||
"SLEEP_DURATION",
|
||||
"ACTIVITY_STEPS",
|
||||
];
|
||||
// Derived from canonical enum so a new measurement type is auto-summarised
|
||||
// by /api/analytics (V3 audit: enum drift cousins).
|
||||
const types = [...measurementTypeEnum.options] as MeasurementType[];
|
||||
|
||||
const measurementsByType = await Promise.all(
|
||||
types.map((type) =>
|
||||
@@ -110,9 +105,28 @@ export const GET = apiHandler(async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Per-context glucose summaries (canonical mg/dL).
|
||||
const glucoseRows = await prisma.measurement.findMany({
|
||||
where: { userId: user.id, type: "BLOOD_GLUCOSE" },
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { value: true, measuredAt: true, glucoseContext: true },
|
||||
});
|
||||
const glucoseByContext: Record<string, ReturnType<typeof summarize>> = {};
|
||||
if (glucoseRows.length > 0) {
|
||||
const contexts = ["FASTING", "POSTPRANDIAL", "RANDOM", "BEDTIME"] as const;
|
||||
for (const ctx of contexts) {
|
||||
const ctxRows = glucoseRows.filter((r) => r.glucoseContext === ctx);
|
||||
if (ctxRows.length === 0) continue;
|
||||
glucoseByContext[ctx] = summarize(
|
||||
ctxRows.map((r): DataPoint => ({ date: r.measuredAt, value: r.value })),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
summaries: results,
|
||||
bmi,
|
||||
bpInTargetPct,
|
||||
glucoseByContext,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
generatePKCE,
|
||||
generateState,
|
||||
buildAuthorizationUrl,
|
||||
} from "@/lib/ai/codex-oauth";
|
||||
import { cookies } from "next/headers";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export const GET = apiHandler(async (_request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rl = await checkRateLimit(`codex-authorize:${user.id}`, 5, 60_000);
|
||||
if (!rl.allowed) throw new HttpError(429, "Too many requests");
|
||||
|
||||
const { verifier, challenge } = generatePKCE();
|
||||
const state = generateState();
|
||||
|
||||
const appUrl = process.env.APP_URL || process.env.NEXT_PUBLIC_APP_URL;
|
||||
if (!appUrl) throw new Error("APP_URL not configured");
|
||||
|
||||
const redirectUri = `${appUrl}/api/auth/codex/callback`;
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const cookieOptions = {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax" as const,
|
||||
path: "/",
|
||||
maxAge: 300,
|
||||
};
|
||||
|
||||
cookieStore.set("codex_verifier", verifier, cookieOptions);
|
||||
cookieStore.set("codex_state", state, cookieOptions);
|
||||
|
||||
const authUrl = buildAuthorizationUrl({
|
||||
codeChallenge: challenge,
|
||||
state,
|
||||
redirectUri,
|
||||
});
|
||||
annotate({ action: { name: "codex.oauth.authorize" } });
|
||||
|
||||
return NextResponse.redirect(authUrl);
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
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 { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { getClientIp } from "@/lib/api-response";
|
||||
import { cookies } from "next/headers";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rl = await checkRateLimit(`codex-callback:${user.id}`, 10, 60_000);
|
||||
if (!rl.allowed) {
|
||||
const appUrl = process.env.APP_URL || process.env.NEXT_PUBLIC_APP_URL;
|
||||
return NextResponse.redirect(`${appUrl}/settings/integrations?codex_error=rate_limited`);
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
|
||||
const code = url.searchParams.get("code");
|
||||
const state = url.searchParams.get("state");
|
||||
const error = url.searchParams.get("error");
|
||||
|
||||
const appUrl = process.env.APP_URL || process.env.NEXT_PUBLIC_APP_URL;
|
||||
if (!appUrl) throw new Error("APP_URL not configured");
|
||||
|
||||
if (error) {
|
||||
annotate({ meta: { oauth_error: error } });
|
||||
return NextResponse.redirect(
|
||||
`${appUrl}/settings/integrations?codex_error=${encodeURIComponent(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
return NextResponse.redirect(
|
||||
`${appUrl}/settings/integrations?codex_error=missing_params`,
|
||||
);
|
||||
}
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const storedState = cookieStore.get("codex_state")?.value;
|
||||
const storedVerifier = cookieStore.get("codex_verifier")?.value;
|
||||
|
||||
cookieStore.delete("codex_state");
|
||||
cookieStore.delete("codex_verifier");
|
||||
|
||||
if (!storedState || !storedVerifier || state !== storedState) {
|
||||
return NextResponse.redirect(
|
||||
`${appUrl}/settings/integrations?codex_error=invalid_state`,
|
||||
);
|
||||
}
|
||||
|
||||
const redirectUri = `${appUrl}/api/auth/codex/callback`;
|
||||
|
||||
try {
|
||||
const tokens = await exchangeCodeForTokens({
|
||||
code,
|
||||
codeVerifier: storedVerifier,
|
||||
redirectUri,
|
||||
});
|
||||
const encrypted = encryptTokens({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
});
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
codexAccessTokenEncrypted: encrypted.accessEncrypted,
|
||||
codexRefreshTokenEncrypted: encrypted.refreshEncrypted,
|
||||
codexTokenExpiresAt: new Date(Date.now() + tokens.expires_in * 1000),
|
||||
codexConnectedAt: new Date(),
|
||||
codexConnectionStatus: "connected",
|
||||
insightsCachedAt: null,
|
||||
insightsCachedText: null,
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog("codex.oauth.connected", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
});
|
||||
annotate({ action: { name: "codex.oauth.callback.success" } });
|
||||
|
||||
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/integrations?codex_error=token_exchange_failed`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rl = await checkRateLimit(`codex-disconnect:${user.id}`, 5, 60_000);
|
||||
if (!rl.allowed) throw new HttpError(429, "Too many requests");
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
codexAccessTokenEncrypted: null,
|
||||
codexRefreshTokenEncrypted: null,
|
||||
codexTokenExpiresAt: null,
|
||||
codexConnectedAt: null,
|
||||
codexConnectionStatus: "disconnected",
|
||||
insightsCachedAt: null,
|
||||
insightsCachedText: null,
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog("codex.oauth.disconnected", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
});
|
||||
annotate({ action: { name: "codex.oauth.disconnect" } });
|
||||
|
||||
return apiSuccess({ disconnected: true });
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
// --- Mocks must come before importing the route. ---
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findFirst: vi.fn() },
|
||||
apiToken: { create: vi.fn() },
|
||||
refreshToken: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/password", () => ({
|
||||
verifyPassword: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({
|
||||
createSession: vi.fn().mockResolvedValue("session-id"),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ allowed: true, remaining: 5, reset: 0 }),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/hmac", () => ({
|
||||
hashToken: vi.fn((raw: string) => `hashed:${raw}`),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({
|
||||
emitIfSampled: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { verifyPassword } from "@/lib/auth/password";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { ensureDbCompatibility } from "@/lib/db-compat";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { createSession } from "@/lib/auth/session";
|
||||
|
||||
const FAKE_USER = {
|
||||
id: "user-1",
|
||||
username: "marc",
|
||||
email: "marc@example.com",
|
||||
passwordHash: "$argon2id$...",
|
||||
};
|
||||
|
||||
function makeRequest(headers: Record<string, string> = {}): NextRequest {
|
||||
return new NextRequest("http://localhost/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...headers,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: "marc@example.com",
|
||||
password: "supersecret",
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValue(FAKE_USER as never);
|
||||
vi.mocked(verifyPassword).mockResolvedValue(true);
|
||||
vi.mocked(prisma.apiToken.create).mockResolvedValue({ id: "tok-1" } as never);
|
||||
vi.mocked(prisma.refreshToken.create).mockResolvedValue({
|
||||
id: "rt-1",
|
||||
} as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 5,
|
||||
reset: 0,
|
||||
} as never);
|
||||
vi.mocked(rateLimitHeaders).mockReturnValue({} as never);
|
||||
vi.mocked(ensureDbCompatibility).mockResolvedValue(undefined);
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined as never);
|
||||
vi.mocked(createSession).mockResolvedValue("session-id");
|
||||
});
|
||||
|
||||
describe("POST /api/auth/login — native token issuance", () => {
|
||||
it("issues a Bearer token when X-Client-Type: native is set", async () => {
|
||||
const original = process.env.API_TOKEN_HMAC_KEY;
|
||||
process.env.API_TOKEN_HMAC_KEY = "test-key";
|
||||
try {
|
||||
const res = await POST(makeRequest({ "x-client-type": "native" }));
|
||||
const body = (await res.json()) as {
|
||||
data: { user: { id: string }; token?: string; tokenExpiresAt?: string };
|
||||
};
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.data.user.id).toBe("user-1");
|
||||
expect(body.data.token).toMatch(/^hlk_[a-f0-9]{64}$/);
|
||||
expect(body.data.tokenExpiresAt).toEqual(expect.any(String));
|
||||
expect(prisma.apiToken.create).toHaveBeenCalledTimes(1);
|
||||
const createArgs = vi.mocked(prisma.apiToken.create).mock.calls[0][0];
|
||||
expect(createArgs.data.tokenHash).toBe(`hashed:${body.data.token}`);
|
||||
} finally {
|
||||
process.env.API_TOKEN_HMAC_KEY = original;
|
||||
}
|
||||
});
|
||||
|
||||
it("does NOT issue a token without the native header", async () => {
|
||||
const res = await POST(makeRequest());
|
||||
const body = (await res.json()) as {
|
||||
data: { user: { id: string }; token?: string };
|
||||
};
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.data.user.id).toBe("user-1");
|
||||
expect(body.data.token).toBeUndefined();
|
||||
expect(prisma.apiToken.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("issues a token when User-Agent starts with HealthLog-iOS", async () => {
|
||||
const original = process.env.API_TOKEN_HMAC_KEY;
|
||||
process.env.API_TOKEN_HMAC_KEY = "test-key";
|
||||
try {
|
||||
const res = await POST(
|
||||
makeRequest({ "user-agent": "HealthLog-iOS/1.0 (iPhone)" }),
|
||||
);
|
||||
const body = (await res.json()) as { data: { token?: string } };
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.data.token).toMatch(/^hlk_/);
|
||||
const createArgs = vi.mocked(prisma.apiToken.create).mock.calls[0][0];
|
||||
expect(createArgs.data.tokenHash).toBe(`hashed:${body.data.token}`);
|
||||
} finally {
|
||||
process.env.API_TOKEN_HMAC_KEY = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -3,12 +3,23 @@ import { loginPasswordSchema } from "@/lib/validations/auth";
|
||||
import { verifyPassword } from "@/lib/auth/password";
|
||||
import { createSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { ensureDbCompatibility } from "@/lib/db-compat";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { issueApiToken, isNativeClientRequest } from "@/lib/auth/issue-token";
|
||||
import {
|
||||
resolveTokenPolicy,
|
||||
shouldIssueBearerToken,
|
||||
} from "@/lib/auth/native-client";
|
||||
import { issueAccessAndRefresh } from "@/lib/auth/refresh-token";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const ip = getClientIp(request) ?? "unknown";
|
||||
@@ -17,7 +28,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
return NextResponse.json(
|
||||
{
|
||||
data: null,
|
||||
error: "Zu viele Anmeldeversuche. Bitte später erneut versuchen.",
|
||||
error: "Too many login attempts. Please try again later.",
|
||||
},
|
||||
{ status: 429, headers: rateLimitHeaders(rl) },
|
||||
);
|
||||
@@ -31,7 +42,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const parsed = loginPasswordSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return apiError("Ungültige Anmeldedaten", 422);
|
||||
return apiError("Invalid credentials", 422);
|
||||
}
|
||||
|
||||
const { email, password } = parsed.data;
|
||||
@@ -51,7 +62,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
ipAddress: ip,
|
||||
details: { identifier, reason: "user_not_found_or_no_password" },
|
||||
});
|
||||
return apiError("Ungültige Anmeldedaten", 401);
|
||||
return apiError("Invalid credentials", 401);
|
||||
}
|
||||
|
||||
const valid = await verifyPassword(user.passwordHash, password);
|
||||
@@ -61,7 +72,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
ipAddress: ip,
|
||||
details: { reason: "invalid_password" },
|
||||
});
|
||||
return apiError("Ungültige Anmeldedaten", 401);
|
||||
return apiError("Invalid credentials", 401);
|
||||
}
|
||||
|
||||
const ua = request.headers.get("user-agent");
|
||||
@@ -74,6 +85,71 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
annotate({ action: { name: "auth.login.password" } });
|
||||
|
||||
// v1.4 G4: Native callers (iOS, n8n, Health-Connect, unrecognised UAs)
|
||||
// get a 24h access token + 60d rotating refresh token. Web UAs keep the
|
||||
// legacy 90d Bearer (issued only when X-Client-Type: native is set —
|
||||
// browser flows continue to use the session cookie).
|
||||
if (
|
||||
shouldIssueBearerToken(request.headers) ||
|
||||
isNativeClientRequest(request.headers)
|
||||
) {
|
||||
const policy = resolveTokenPolicy(request.headers);
|
||||
const deviceId = request.headers.get("x-device-id");
|
||||
|
||||
if (policy.refreshTokenDays !== null) {
|
||||
const bundle = await issueAccessAndRefresh({
|
||||
userId: user.id,
|
||||
policy,
|
||||
deviceId,
|
||||
userAgent: ua,
|
||||
ipAddress: ip,
|
||||
source: "login.password",
|
||||
});
|
||||
await auditLog("auth.token.autoissue.native", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: { source: "login.password", policy: "native" },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.token.autoissue.native" },
|
||||
meta: { token_policy: "native" },
|
||||
});
|
||||
return apiSuccess({
|
||||
user: { id: user.id, username: user.username },
|
||||
token: bundle.accessToken,
|
||||
tokenExpiresAt: bundle.accessTokenExpiresAt.toISOString(),
|
||||
refreshToken: bundle.refreshToken,
|
||||
refreshTokenExpiresAt: bundle.refreshTokenExpiresAt.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Web policy with explicit X-Client-Type:native (legacy iOS auto-login)
|
||||
const issued = await issueApiToken({
|
||||
userId: user.id,
|
||||
name: `web auto-login ${new Date().toISOString()}`,
|
||||
permissions: ["*"],
|
||||
expiresInDays: policy.accessTokenDays,
|
||||
});
|
||||
await auditLog("auth.token.autoissue.native", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: {
|
||||
tokenId: issued.tokenId,
|
||||
source: "login.password",
|
||||
policy: "web",
|
||||
},
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.token.autoissue.native" },
|
||||
meta: { token_policy: "web" },
|
||||
});
|
||||
return apiSuccess({
|
||||
user: { id: user.id, username: user.username },
|
||||
token: issued.token,
|
||||
tokenExpiresAt: issued.expiresAt.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
user: { id: user.id, username: user.username },
|
||||
});
|
||||
|
||||
@@ -21,5 +21,6 @@ export const GET = apiHandler(async () => {
|
||||
timezone: user.timezone,
|
||||
onboardingCompletedAt: user.onboardingCompletedAt,
|
||||
gravatarUrl: user.email ? getGravatarUrl(user.email) : null,
|
||||
glucoseUnit: user.glucoseUnit ?? null,
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,7 @@ export const POST = apiHandler(async (request: Request) => {
|
||||
return NextResponse.json(
|
||||
{
|
||||
data: null,
|
||||
error: "Zu viele Passkey-Anfragen. Bitte später erneut versuchen.",
|
||||
error: "Too many passkey requests. Please try again later.",
|
||||
},
|
||||
{ status: 429, headers: rateLimitHeaders(rl) },
|
||||
);
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn() },
|
||||
apiToken: { create: vi.fn() },
|
||||
refreshToken: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/passkey", () => ({
|
||||
verifyAuthentication: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({
|
||||
createSession: vi.fn().mockResolvedValue("session-id"),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/hmac", () => ({
|
||||
hashToken: vi.fn((raw: string) => `hashed:${raw}`),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({
|
||||
emitIfSampled: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { verifyAuthentication } from "@/lib/auth/passkey";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const FAKE_USER = {
|
||||
id: "user-1",
|
||||
username: "marc",
|
||||
email: "marc@example.com",
|
||||
};
|
||||
|
||||
function makeRequest(headers: Record<string, string> = {}): NextRequest {
|
||||
return new NextRequest("http://localhost/api/auth/passkey/login-verify", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", ...headers },
|
||||
body: JSON.stringify({
|
||||
challengeId: "ch-1",
|
||||
credential: { id: "cred-1" },
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(FAKE_USER as never);
|
||||
vi.mocked(prisma.apiToken.create).mockResolvedValue({ id: "tok-1" } as never);
|
||||
vi.mocked(prisma.refreshToken.create).mockResolvedValue({
|
||||
id: "rt-1",
|
||||
} as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 9,
|
||||
reset: 0,
|
||||
} as never);
|
||||
vi.mocked(verifyAuthentication).mockResolvedValue({
|
||||
verification: { verified: true },
|
||||
passkey: { userId: "user-1" },
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("POST /api/auth/passkey/login-verify — native token issuance", () => {
|
||||
it("issues a Bearer token when X-Client-Type: native is set", async () => {
|
||||
const original = process.env.API_TOKEN_HMAC_KEY;
|
||||
process.env.API_TOKEN_HMAC_KEY = "test-key";
|
||||
try {
|
||||
const res = await POST(makeRequest({ "x-client-type": "native" }));
|
||||
const body = (await res.json()) as {
|
||||
data: { token?: string; tokenExpiresAt?: string };
|
||||
};
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.data.token).toMatch(/^hlk_[a-f0-9]{64}$/);
|
||||
expect(body.data.tokenExpiresAt).toEqual(expect.any(String));
|
||||
expect(prisma.apiToken.create).toHaveBeenCalledTimes(1);
|
||||
const createArgs = vi.mocked(prisma.apiToken.create).mock.calls[0][0];
|
||||
expect(createArgs.data.tokenHash).toBe(`hashed:${body.data.token}`);
|
||||
} finally {
|
||||
process.env.API_TOKEN_HMAC_KEY = original;
|
||||
}
|
||||
});
|
||||
|
||||
it("does NOT issue a token without the native header", async () => {
|
||||
const res = await POST(makeRequest());
|
||||
const body = (await res.json()) as { data: { token?: string } };
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.data.token).toBeUndefined();
|
||||
expect(prisma.apiToken.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 401 when verification fails", async () => {
|
||||
vi.mocked(verifyAuthentication).mockResolvedValue({
|
||||
verification: { verified: false },
|
||||
passkey: { userId: "user-1" },
|
||||
} as never);
|
||||
const res = await POST(makeRequest({ "x-client-type": "native" }));
|
||||
expect(res.status).toBe(401);
|
||||
expect(prisma.apiToken.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,31 +1,47 @@
|
||||
import { verifyAuthentication } from "@/lib/auth/passkey";
|
||||
import { createSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { ensureDbCompatibility } from "@/lib/db-compat";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { issueApiToken, isNativeClientRequest } from "@/lib/auth/issue-token";
|
||||
import {
|
||||
resolveTokenPolicy,
|
||||
shouldIssueBearerToken,
|
||||
} from "@/lib/auth/native-client";
|
||||
import { issueAccessAndRefresh } from "@/lib/auth/refresh-token";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
await ensureDbCompatibility();
|
||||
|
||||
const ip = getClientIp(request);
|
||||
const rl = await checkRateLimit(`auth:passkey-verify:${ip}`, 10, 15 * 60 * 1000);
|
||||
const rl = await checkRateLimit(
|
||||
`auth:passkey-verify:${ip}`,
|
||||
10,
|
||||
15 * 60 * 1000,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Zu viele Versuche. Bitte 15 Minuten warten.", 429);
|
||||
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;
|
||||
const credential = body.credential;
|
||||
|
||||
if (!challengeId || !credential) {
|
||||
return apiError("challengeId und credential erforderlich", 422);
|
||||
return apiError("challengeId and credential required", 422);
|
||||
}
|
||||
|
||||
const { verification, passkey } = await verifyAuthentication(
|
||||
@@ -38,7 +54,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
ipAddress: ip,
|
||||
details: { reason: "passkey_verification_failed" },
|
||||
});
|
||||
return apiError("Passkey-Verifizierung fehlgeschlagen", 401);
|
||||
return apiError("Passkey verification failed", 401);
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
@@ -46,7 +62,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return apiError("Benutzer nicht gefunden", 404);
|
||||
return apiError("User not found", 404);
|
||||
}
|
||||
|
||||
const ua = request.headers.get("user-agent");
|
||||
@@ -59,6 +75,66 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
annotate({ action: { name: "auth.login.passkey" } });
|
||||
|
||||
if (
|
||||
shouldIssueBearerToken(request.headers) ||
|
||||
isNativeClientRequest(request.headers)
|
||||
) {
|
||||
const policy = resolveTokenPolicy(request.headers);
|
||||
const deviceId = request.headers.get("x-device-id");
|
||||
|
||||
if (policy.refreshTokenDays !== null) {
|
||||
const bundle = await issueAccessAndRefresh({
|
||||
userId: user.id,
|
||||
policy,
|
||||
deviceId,
|
||||
userAgent: ua,
|
||||
ipAddress: ip ?? null,
|
||||
source: "login.passkey",
|
||||
});
|
||||
await auditLog("auth.token.autoissue.native", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: { source: "login.passkey", policy: "native" },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.token.autoissue.native" },
|
||||
meta: { token_policy: "native" },
|
||||
});
|
||||
return apiSuccess({
|
||||
user: { id: user.id, username: user.username },
|
||||
token: bundle.accessToken,
|
||||
tokenExpiresAt: bundle.accessTokenExpiresAt.toISOString(),
|
||||
refreshToken: bundle.refreshToken,
|
||||
refreshTokenExpiresAt: bundle.refreshTokenExpiresAt.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
const issued = await issueApiToken({
|
||||
userId: user.id,
|
||||
name: `web auto-login ${new Date().toISOString()}`,
|
||||
permissions: ["*"],
|
||||
expiresInDays: policy.accessTokenDays,
|
||||
});
|
||||
await auditLog("auth.token.autoissue.native", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: {
|
||||
tokenId: issued.tokenId,
|
||||
source: "login.passkey",
|
||||
policy: "web",
|
||||
},
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.token.autoissue.native" },
|
||||
meta: { token_policy: "web" },
|
||||
});
|
||||
return apiSuccess({
|
||||
user: { id: user.id, username: user.username },
|
||||
token: issued.token,
|
||||
tokenExpiresAt: issued.expiresAt.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
user: { id: user.id, username: user.username },
|
||||
});
|
||||
|
||||
@@ -16,13 +16,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const credential = body.credential as Record<string, unknown> | undefined;
|
||||
|
||||
if (!challengeId || !credential) {
|
||||
return apiError("challengeId und credential erforderlich", 422);
|
||||
return apiError("challengeId and credential required", 422);
|
||||
}
|
||||
|
||||
const verification = await verifyRegistration(challengeId, credential);
|
||||
|
||||
if (!verification.verified || !verification.registrationInfo) {
|
||||
return apiError("Passkey-Verifizierung fehlgeschlagen", 400);
|
||||
return apiError("Passkey verification failed", 400);
|
||||
}
|
||||
|
||||
const { registrationInfo } = verification;
|
||||
|
||||
@@ -18,7 +18,7 @@ export const DELETE = apiHandler(async (
|
||||
});
|
||||
|
||||
if (!passkey || passkey.userId !== user.id) {
|
||||
return apiError("Passkey nicht gefunden", 404);
|
||||
return apiError("Passkey not found", 404);
|
||||
}
|
||||
|
||||
// Check: at least 1 auth method must remain
|
||||
@@ -29,7 +29,7 @@ export const DELETE = apiHandler(async (
|
||||
|
||||
if (passkeyCount <= 1 && !hasPassword) {
|
||||
return apiError(
|
||||
"Kann nicht gelöscht werden — es muss mindestens eine Anmeldemethode bestehen bleiben",
|
||||
"Cannot delete — at least one authentication method must remain",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { destroyAllSessions, createSession } from "@/lib/auth/session";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
@@ -23,7 +24,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return apiError(
|
||||
"Zu viele Versuche. Bitte 15 Minuten warten.",
|
||||
"Too many attempts. Please wait 15 minutes.",
|
||||
429,
|
||||
);
|
||||
}
|
||||
@@ -38,7 +39,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const { currentPassword, newPassword } = parsed.data;
|
||||
if (!user.passwordHash) {
|
||||
return apiError("Für dieses Konto ist kein Passwort gesetzt", 400);
|
||||
return apiError("No password set for this account", 400);
|
||||
}
|
||||
|
||||
const currentValid = await verifyPassword(
|
||||
@@ -46,23 +47,28 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
currentPassword,
|
||||
);
|
||||
if (!currentValid) {
|
||||
return apiError("Aktuelles Passwort ist falsch", 401);
|
||||
return apiError("Current password is incorrect", 401);
|
||||
}
|
||||
|
||||
if (currentPassword === newPassword) {
|
||||
return apiError(
|
||||
"Neues Passwort muss sich vom aktuellen unterscheiden",
|
||||
"New password must differ from current password",
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
const strength = checkPasswordStrength(newPassword, [
|
||||
user.username,
|
||||
user.email ?? "",
|
||||
]);
|
||||
const locale = await resolveServerLocale({
|
||||
request,
|
||||
userLocale: user.locale ?? null,
|
||||
});
|
||||
const strength = checkPasswordStrength(
|
||||
newPassword,
|
||||
[user.username, user.email ?? ""],
|
||||
locale,
|
||||
);
|
||||
if (!strength.isAcceptable) {
|
||||
return apiError(
|
||||
strength.feedback[0] || "Passwort zu schwach (Score < 3)",
|
||||
strength.feedback[0] || "Password too weak (score < 3)",
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,62 +1,30 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { profileSchema } from "@/lib/validations/auth";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { applyProfileUpdate } from "@/lib/auth/profile-update";
|
||||
|
||||
export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = profileSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
const result = await applyProfileUpdate(user.id, body, getClientIp(request));
|
||||
if (!result.ok) {
|
||||
return apiError(result.message, result.status);
|
||||
}
|
||||
|
||||
const data = parsed.data;
|
||||
const normalizedEmail = data.email ? data.email.trim().toLowerCase() : null;
|
||||
|
||||
if (data.email !== undefined && normalizedEmail) {
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { email: normalizedEmail },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (existingUser && existingUser.id !== user.id) {
|
||||
return apiError("E-Mail-Adresse wird bereits verwendet", 409);
|
||||
}
|
||||
}
|
||||
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
...(data.email !== undefined ? { email: normalizedEmail } : {}),
|
||||
heightCm: data.heightCm ?? null,
|
||||
dateOfBirth: data.dateOfBirth ? new Date(data.dateOfBirth) : null,
|
||||
...(data.gender !== undefined ? { gender: data.gender } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog("profile.update", {
|
||||
userId: updatedUser.id,
|
||||
ipAddress: getClientIp(request),
|
||||
});
|
||||
|
||||
annotate({ action: { name: "auth.profile.update" } });
|
||||
|
||||
return apiSuccess({
|
||||
id: updatedUser.id,
|
||||
username: updatedUser.username,
|
||||
email: updatedUser.email,
|
||||
role: updatedUser.role,
|
||||
heightCm: updatedUser.heightCm,
|
||||
dateOfBirth: updatedUser.dateOfBirth,
|
||||
gender: updatedUser.gender,
|
||||
timezone: updatedUser.timezone,
|
||||
id: result.user.id,
|
||||
username: result.user.username,
|
||||
email: result.user.email,
|
||||
role: result.user.role,
|
||||
heightCm: result.user.heightCm,
|
||||
dateOfBirth: result.user.dateOfBirth,
|
||||
gender: result.user.gender,
|
||||
timezone: result.user.timezone,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* POST /api/auth/refresh — exchange a one-time-use refresh token for a new
|
||||
* access + refresh pair. Audited on every call (success + failure).
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { resolveTokenPolicy } from "@/lib/auth/native-client";
|
||||
import {
|
||||
rotateRefreshToken,
|
||||
revokeRefreshToken,
|
||||
} from "@/lib/auth/refresh-token";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const ip = getClientIp(request) ?? "unknown";
|
||||
const rl = await checkRateLimit(`auth:refresh:${ip}`, 60, 15 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many refresh attempts. Please retry later.", 429);
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson<{
|
||||
refreshToken?: string;
|
||||
revoke?: boolean;
|
||||
}>(request);
|
||||
if (jsonError) return jsonError;
|
||||
if (!body.refreshToken || typeof body.refreshToken !== "string") {
|
||||
return apiError("refreshToken required", 422);
|
||||
}
|
||||
|
||||
// logout-on-device path: client wants to invalidate the refresh token.
|
||||
if (body.revoke) {
|
||||
const ok = await revokeRefreshToken(body.refreshToken);
|
||||
annotate({
|
||||
action: { name: "auth.token.refresh.revoke" },
|
||||
meta: { refresh_revoked: ok },
|
||||
});
|
||||
await auditLog("auth.token.refresh.revoke", {
|
||||
ipAddress: ip,
|
||||
details: { ok },
|
||||
});
|
||||
return apiSuccess({ revoked: ok });
|
||||
}
|
||||
|
||||
const policy = resolveTokenPolicy(request.headers);
|
||||
// Refresh always uses the native policy: a refresh token only exists for
|
||||
// native callers, and we never want to upgrade it back to a 90d token.
|
||||
if (policy.refreshTokenDays === null) {
|
||||
policy.refreshTokenDays = 60;
|
||||
policy.accessTokenDays = 1;
|
||||
}
|
||||
|
||||
const result = await rotateRefreshToken({
|
||||
refreshToken: body.refreshToken,
|
||||
policy,
|
||||
deviceId: request.headers.get("x-device-id"),
|
||||
userAgent: request.headers.get("user-agent"),
|
||||
ipAddress: ip,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
await auditLog("auth.token.refresh.failed", {
|
||||
ipAddress: ip,
|
||||
details: { reason: result.reason },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.token.refresh.failed" },
|
||||
meta: { refresh_failure: result.reason },
|
||||
});
|
||||
if (result.reason === "already_used") {
|
||||
return apiError(
|
||||
"Refresh token reuse detected — please log in again.",
|
||||
401,
|
||||
);
|
||||
}
|
||||
return apiError("Invalid refresh token", 401);
|
||||
}
|
||||
|
||||
await auditLog("auth.token.refresh", {
|
||||
ipAddress: ip,
|
||||
details: { policy: policy.policy },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.token.refresh" },
|
||||
meta: { token_policy: policy.policy },
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
token: result.bundle.accessToken,
|
||||
tokenExpiresAt: result.bundle.accessTokenExpiresAt.toISOString(),
|
||||
refreshToken: result.bundle.refreshToken,
|
||||
refreshTokenExpiresAt: result.bundle.refreshTokenExpiresAt.toISOString(),
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import { ensureDbCompatibility } from "@/lib/db-compat";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const ip = getClientIp(request) ?? "unknown";
|
||||
@@ -18,7 +19,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
{
|
||||
data: null,
|
||||
error:
|
||||
"Zu viele Registrierungsversuche. Bitte später erneut versuchen.",
|
||||
"Too many registration attempts. Please try again later.",
|
||||
},
|
||||
{ status: 429, headers: rateLimitHeaders(rl) },
|
||||
);
|
||||
@@ -42,7 +43,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// Table may not exist yet; allow registration
|
||||
}
|
||||
if (!registrationEnabled) {
|
||||
return apiError("Registrierung ist deaktiviert", 403);
|
||||
return apiError("Registration is disabled", 403);
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
@@ -62,14 +63,15 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
prisma.user.findUnique({ where: { username } }),
|
||||
]);
|
||||
if (existingEmail || existingUsername) {
|
||||
return apiError("Benutzername oder E-Mail-Adresse bereits vergeben", 409);
|
||||
return apiError("Username or email already taken", 409);
|
||||
}
|
||||
|
||||
// Validate password strength
|
||||
const strength = checkPasswordStrength(password, [username, email]);
|
||||
// Validate password strength (locale-aware feedback)
|
||||
const locale = await resolveServerLocale({ request });
|
||||
const strength = checkPasswordStrength(password, [username, email], locale);
|
||||
if (!strength.isAcceptable) {
|
||||
return apiError(
|
||||
strength.feedback[0] || "Passwort zu schwach (Score < 3)",
|
||||
strength.feedback[0] || "Password too weak (score < 3)",
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,11 +8,16 @@ import { decrypt } from "@/lib/crypto";
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
// Strict image/* data URL (no SVG — it can carry inline JS).
|
||||
const SCREENSHOT_DATA_URL =
|
||||
/^data:image\/(png|jpeg|webp|gif);base64,[A-Za-z0-9+/=]+$/;
|
||||
|
||||
const bugReportSchema = z.object({
|
||||
description: z.string().min(10, "Beschreibung zu kurz").max(5000),
|
||||
description: z.string().min(10, "Description too short").max(5000),
|
||||
screenshot: z
|
||||
.string()
|
||||
.max(7_000_000, "Screenshot zu groß (max. 5 MB)")
|
||||
.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")
|
||||
.optional(),
|
||||
});
|
||||
|
||||
@@ -21,7 +26,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const rl = await checkRateLimit(`bugreport:${user.id}`, 3, 60 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Maximal 3 Bugreports pro Stunde", 429);
|
||||
return apiError("Maximum 3 bug reports per hour", 429);
|
||||
}
|
||||
|
||||
annotate({ action: { name: "bugreport.submit" } });
|
||||
@@ -47,9 +52,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const ghRepo = appSettings?.githubIssueRepo || process.env.GITHUB_ISSUE_REPO; // e.g. "owner/repo"
|
||||
|
||||
if (!ghToken || !ghRepo) {
|
||||
// 503 instead of 500: the client keys off status to render the "not
|
||||
// configured" screen. The user also sees a clear message rather than
|
||||
// "Internal server error".
|
||||
return apiError(
|
||||
"Bugreport ist nicht konfiguriert (GitHub-Issue-Token/Repository in Admin-Einstellungen fehlt)",
|
||||
500,
|
||||
"Bug report not configured (GitHub issue token/repository missing in admin settings)",
|
||||
503,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -63,27 +71,36 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const { description, screenshot } = parsed.data;
|
||||
|
||||
const dateStr = new Date().toLocaleString("de-DE", {
|
||||
timeZone: "Europe/Berlin",
|
||||
});
|
||||
// GitHub issues are maintainer-facing and the repo's primary language is English.
|
||||
// Use an ISO-8601 timestamp so titles are unambiguous and sortable.
|
||||
const now = new Date();
|
||||
const dateStr = now.toISOString().replace("T", " ").slice(0, 16) + " UTC";
|
||||
const title = `Bug Report – ${dateStr}`;
|
||||
|
||||
// Sanitize user-provided content for GitHub markdown
|
||||
// Sanitize user-provided content for GitHub markdown. Strip HTML tags
|
||||
// entirely (prevents `<img onerror=>` etc.) and neutralise markdown link
|
||||
// targets that start with dangerous schemes like javascript: or data:.
|
||||
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(/```/g, "\\`\\`\\`")
|
||||
.slice(0, 5000);
|
||||
|
||||
// Build issue body
|
||||
let issueBody = `**Gemeldet von:** ${safeUsername}\n`;
|
||||
issueBody += `**Datum:** ${dateStr}\n\n`;
|
||||
issueBody += `## Beschreibung\n\n${safeDescription}\n`;
|
||||
issueBody += `\n---\n*Erstellt über die HealthLog Bugreport-Funktion*`;
|
||||
// Wrap the user's description in a fenced code block so any residual
|
||||
// markdown renders literally rather than as executable markup.
|
||||
let issueBody = `**Reported by:** ${safeUsername}\n`;
|
||||
issueBody += `**Date:** ${dateStr}\n\n`;
|
||||
issueBody += `## Description\n\n\`\`\`\n${safeDescription}\n\`\`\`\n`;
|
||||
issueBody += `\n---\n*Created via HealthLog bug report*`;
|
||||
|
||||
// If screenshot provided, upload to GitHub as a gist or include as base64
|
||||
// For simplicity, we'll note there's a screenshot but GitHub issues don't support inline base64
|
||||
if (screenshot) {
|
||||
issueBody += `\n\n**Screenshot:** Ein Screenshot wurde angehängt (siehe Kommentar).`;
|
||||
issueBody += `\n\n**Screenshot:** A screenshot was attached (see comment).`;
|
||||
}
|
||||
|
||||
// Create the issue
|
||||
@@ -107,7 +124,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
if (!issueRes.ok) {
|
||||
const errBody = await issueRes.text();
|
||||
getEvent()?.addWarning("GitHub issue creation failed: " + errBody);
|
||||
return apiError("Issue konnte nicht erstellt werden", 500);
|
||||
return apiError("Failed to create issue", 500);
|
||||
}
|
||||
|
||||
const issue = (await issueRes.json()) as {
|
||||
@@ -123,7 +140,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
// Upload screenshot as issue comment with embedded image
|
||||
// GitHub doesn't support base64 in comments, so we note the limitation
|
||||
const commentBody = `### Screenshot\n\n}...)\n\n*Hinweis: Der vollständige Screenshot wurde aus Größengründen gekürzt. Bitte den Nutzer direkt kontaktieren.*`;
|
||||
const commentBody = `### Screenshot\n\n}...)\n\n*Note: The full screenshot was truncated due to size limits. Please contact the user directly.*`;
|
||||
|
||||
await fetch(
|
||||
`https://api.github.com/repos/${ghRepo}/issues/${issue.number}/comments`,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Returns whether bug report submission is configured (GitHub token + repo).
|
||||
*
|
||||
* The bug report page uses this to gate its UI so users don't hit a silent
|
||||
* 500 when the admin hasn't set up the GitHub integration. Only two booleans
|
||||
* escape: a single `configured` flag and `isAdmin` so the page can render
|
||||
* the admin-specific copy + admin link. Token/repo granularity is kept
|
||||
* server-side (log annotation only) so we don't hand regular users a probe.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// 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,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Rate limit exceeded", 429);
|
||||
}
|
||||
|
||||
const appSettings = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: { githubIssueTokenEncrypted: true, githubIssueRepo: true },
|
||||
});
|
||||
|
||||
const hasToken = Boolean(
|
||||
appSettings?.githubIssueTokenEncrypted || process.env.GITHUB_ISSUE_TOKEN,
|
||||
);
|
||||
const hasRepo = Boolean(
|
||||
appSettings?.githubIssueRepo || process.env.GITHUB_ISSUE_REPO,
|
||||
);
|
||||
const configured = hasToken && hasRepo;
|
||||
|
||||
annotate({
|
||||
action: { name: "bugreport.status" },
|
||||
meta: { configured, has_token: hasToken, has_repo: hasRepo },
|
||||
});
|
||||
|
||||
return apiSuccess({ configured, isAdmin: user.role === "ADMIN" });
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: { findMany: vi.fn() },
|
||||
medicationIntakeEvent: { findMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({
|
||||
getSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({
|
||||
emitIfSampled: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({ get: () => undefined, set: () => {}, delete: () => {} })),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "user-1",
|
||||
username: "marc",
|
||||
role: "USER" as const,
|
||||
displayName: null,
|
||||
},
|
||||
};
|
||||
|
||||
// `apiHandler` always reads `request.url` — even when the inner handler
|
||||
// ignores it — so we hand it a NextRequest and bypass the inner-handler
|
||||
// arity check via a cast.
|
||||
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
|
||||
function makeReq(): NextRequest {
|
||||
return new NextRequest("http://localhost/api/dashboard/summary");
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([] as never);
|
||||
});
|
||||
|
||||
describe("GET /api/dashboard/summary", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns the aggregated payload with empty data", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
greeting: { salutation: string; date: string };
|
||||
streak: { currentDays: number; longest: number; label: string };
|
||||
compliance: { scheduledToday: number; takenToday: number };
|
||||
metrics: Array<{ id: string; kind: string; sparkline: number[] }>;
|
||||
};
|
||||
};
|
||||
expect(body.data.greeting.salutation).toBe("Hi, marc");
|
||||
expect(body.data.streak.currentDays).toBe(0);
|
||||
expect(body.data.compliance.scheduledToday).toBe(0);
|
||||
expect(body.data.metrics).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: "weight" }),
|
||||
expect.objectContaining({ id: "bp" }),
|
||||
expect.objectContaining({ id: "pulse" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("computes intake compliance for today", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockImplementation(((
|
||||
args: unknown,
|
||||
) => {
|
||||
const a = args as { where: { OR?: unknown } };
|
||||
if (!a.where.OR) {
|
||||
return Promise.resolve([
|
||||
{ id: "e1", takenAt: new Date(), skipped: false },
|
||||
{ id: "e2", takenAt: null, skipped: false },
|
||||
]) as never;
|
||||
}
|
||||
return Promise.resolve([]) as never;
|
||||
}) as never);
|
||||
const res = await callGet(makeReq());
|
||||
const body = (await res.json()) as {
|
||||
data: { compliance: { scheduledToday: number; takenToday: number } };
|
||||
};
|
||||
expect(body.data.compliance.scheduledToday).toBe(2);
|
||||
expect(body.data.compliance.takenToday).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* GET /api/dashboard/summary
|
||||
*
|
||||
* Aggregator endpoint for the iOS DashboardSummary view. Combines
|
||||
* greeting, intake-day streaks, today's medication compliance, the
|
||||
* highlighted insight, and per-metric latest+sparkline+trend.
|
||||
*
|
||||
* The shape is fixed for the iOS client and intentionally normalised —
|
||||
* `kind` is iOS-friendly (camelCase), unlike the canonical Prisma enum
|
||||
* (BLOOD_PRESSURE_SYS etc.).
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { defaultLocale, type Locale } from "@/lib/i18n/config";
|
||||
|
||||
const SPARK_DAYS = 7;
|
||||
const STREAK_WINDOW_DAYS = 365;
|
||||
|
||||
type MetricKind =
|
||||
| "weight"
|
||||
| "bloodPressure"
|
||||
| "pulse"
|
||||
| "bodyFat"
|
||||
| "glucose"
|
||||
| "sleep"
|
||||
| "steps"
|
||||
| "totalBodyWater"
|
||||
| "boneMass"
|
||||
| "oxygenSaturation";
|
||||
|
||||
interface MetricCard {
|
||||
id: string;
|
||||
kind: MetricKind;
|
||||
title: string;
|
||||
latestValue: number | null;
|
||||
secondaryValue: number | null;
|
||||
unit: string;
|
||||
trend: "up" | "down" | "flat" | "unknown";
|
||||
sparkline: number[];
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
const METRIC_TITLES: Record<MetricKind, string> = {
|
||||
weight: "Gewicht",
|
||||
bloodPressure: "Blutdruck",
|
||||
pulse: "Puls",
|
||||
bodyFat: "Körperfett",
|
||||
glucose: "Blutzucker",
|
||||
sleep: "Schlaf",
|
||||
steps: "Schritte",
|
||||
totalBodyWater: "Gesamtkörperwasser",
|
||||
boneMass: "Knochenmasse",
|
||||
oxygenSaturation: "Sauerstoffsättigung",
|
||||
};
|
||||
|
||||
const METRIC_UNITS: Record<MetricKind, string> = {
|
||||
weight: "kg",
|
||||
bloodPressure: "mmHg",
|
||||
pulse: "bpm",
|
||||
bodyFat: "%",
|
||||
glucose: "mg/dL",
|
||||
sleep: "h",
|
||||
steps: "Schritte",
|
||||
totalBodyWater: "kg",
|
||||
boneMass: "kg",
|
||||
oxygenSaturation: "%",
|
||||
};
|
||||
|
||||
function trendOf(values: number[]): MetricCard["trend"] {
|
||||
if (values.length < 2) return "unknown";
|
||||
const first = values[0];
|
||||
const last = values[values.length - 1];
|
||||
const delta = last - first;
|
||||
const epsilon = Math.max(1, Math.abs(first) * 0.01);
|
||||
if (Math.abs(delta) < epsilon) return "flat";
|
||||
return delta > 0 ? "up" : "down";
|
||||
}
|
||||
|
||||
function startOfDayBerlin(date: Date): Date {
|
||||
// Compute midnight in Europe/Berlin → UTC ms.
|
||||
const berlin = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: "Europe/Berlin",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).formatToParts(date);
|
||||
const y = berlin.find((p) => p.type === "year")?.value ?? "1970";
|
||||
const m = berlin.find((p) => p.type === "month")?.value ?? "01";
|
||||
const d = berlin.find((p) => p.type === "day")?.value ?? "01";
|
||||
return new Date(`${y}-${m}-${d}T00:00:00.000Z`);
|
||||
}
|
||||
|
||||
function berlinDayKey(date: Date): string {
|
||||
return new Intl.DateTimeFormat("sv-SE", {
|
||||
timeZone: "Europe/Berlin",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
interface StreakInfo {
|
||||
currentDays: number;
|
||||
longest: number;
|
||||
}
|
||||
|
||||
/** Compute the current logging-day streak (days where any measurement or
|
||||
* intake event was recorded, in Berlin time) plus the longest streak in
|
||||
* the last `STREAK_WINDOW_DAYS` days. */
|
||||
function computeStreak(activityDays: Set<string>): StreakInfo {
|
||||
if (activityDays.size === 0) return { currentDays: 0, longest: 0 };
|
||||
|
||||
const sorted = [...activityDays].sort();
|
||||
let longest = 1;
|
||||
let run = 1;
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
const prev = new Date(`${sorted[i - 1]}T00:00:00.000Z`).getTime();
|
||||
const cur = new Date(`${sorted[i]}T00:00:00.000Z`).getTime();
|
||||
if (cur - prev === 86_400_000) {
|
||||
run += 1;
|
||||
longest = Math.max(longest, run);
|
||||
} else {
|
||||
run = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Current streak: walk back from today (Berlin).
|
||||
const todayKey = berlinDayKey(new Date());
|
||||
let currentDays = 0;
|
||||
let cursor = new Date(`${todayKey}T00:00:00.000Z`);
|
||||
// Allow yesterday's last day to count if today not yet logged.
|
||||
if (!activityDays.has(berlinDayKey(cursor))) {
|
||||
cursor = new Date(cursor.getTime() - 86_400_000);
|
||||
}
|
||||
while (activityDays.has(berlinDayKey(cursor))) {
|
||||
currentDays += 1;
|
||||
cursor = new Date(cursor.getTime() - 86_400_000);
|
||||
}
|
||||
|
||||
return { currentDays, longest };
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "dashboard.summary" } });
|
||||
|
||||
const now = new Date();
|
||||
const sevenDaysAgo = new Date(now.getTime() - SPARK_DAYS * 86_400_000);
|
||||
const streakWindowStart = new Date(
|
||||
now.getTime() - STREAK_WINDOW_DAYS * 86_400_000,
|
||||
);
|
||||
const todayStart = startOfDayBerlin(now);
|
||||
const todayEnd = new Date(todayStart.getTime() + 86_400_000);
|
||||
|
||||
// Derived from canonical enum so a new measurement type is auto-included
|
||||
// (V3 audit: enum drift cousins). Per-kind display blocks below decide
|
||||
// which types render as MetricCards.
|
||||
const measurementTypes = [
|
||||
...measurementTypeEnum.options,
|
||||
] as MeasurementType[];
|
||||
|
||||
const [recentMeasurements, todaysIntakes, streakActivity] = await Promise.all(
|
||||
[
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
type: { in: measurementTypes },
|
||||
measuredAt: { gte: sevenDaysAgo },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { type: true, value: true, measuredAt: true },
|
||||
}),
|
||||
prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
scheduledFor: { gte: todayStart, lt: todayEnd },
|
||||
},
|
||||
select: { id: true, takenAt: true, skipped: true },
|
||||
}),
|
||||
prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
scheduledFor: { gte: streakWindowStart },
|
||||
OR: [{ takenAt: { not: null } }, { skipped: true }],
|
||||
},
|
||||
select: { takenAt: true, scheduledFor: true },
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
const activityDays = new Set<string>();
|
||||
for (const m of recentMeasurements)
|
||||
activityDays.add(berlinDayKey(m.measuredAt));
|
||||
// Pull a wider measurement window for the streak so it isn't capped by
|
||||
// SPARK_DAYS — but only if the user has any data at all.
|
||||
if (recentMeasurements.length > 0) {
|
||||
const wider = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
measuredAt: { gte: streakWindowStart },
|
||||
},
|
||||
select: { measuredAt: true },
|
||||
});
|
||||
for (const m of wider) activityDays.add(berlinDayKey(m.measuredAt));
|
||||
}
|
||||
for (const e of streakActivity) {
|
||||
activityDays.add(berlinDayKey(e.takenAt ?? e.scheduledFor));
|
||||
}
|
||||
|
||||
const streak = computeStreak(activityDays);
|
||||
|
||||
// Per-type latest + sparkline.
|
||||
const byType = new Map<MeasurementType, { value: number; at: Date }[]>();
|
||||
for (const m of recentMeasurements) {
|
||||
const list = byType.get(m.type) ?? [];
|
||||
list.push({ value: m.value, at: m.measuredAt });
|
||||
byType.set(m.type, list);
|
||||
}
|
||||
|
||||
function latestOf(type: MeasurementType): { value: number; at: Date } | null {
|
||||
const list = byType.get(type);
|
||||
if (!list || list.length === 0) return null;
|
||||
return list[list.length - 1];
|
||||
}
|
||||
|
||||
function sparkOf(type: MeasurementType): number[] {
|
||||
const list = byType.get(type);
|
||||
if (!list) return [];
|
||||
return list.map((p) => p.value);
|
||||
}
|
||||
|
||||
const metrics: MetricCard[] = [];
|
||||
|
||||
// Weight
|
||||
{
|
||||
const latest = latestOf("WEIGHT");
|
||||
const spark = sparkOf("WEIGHT");
|
||||
metrics.push({
|
||||
id: "weight",
|
||||
kind: "weight",
|
||||
title: METRIC_TITLES.weight,
|
||||
latestValue: latest?.value ?? null,
|
||||
secondaryValue: null,
|
||||
unit: METRIC_UNITS.weight,
|
||||
trend: trendOf(spark),
|
||||
sparkline: spark,
|
||||
updatedAt: latest?.at.toISOString() ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
// Blood pressure (paired sys/dia)
|
||||
{
|
||||
const sysList = byType.get("BLOOD_PRESSURE_SYS") ?? [];
|
||||
const diaList = byType.get("BLOOD_PRESSURE_DIA") ?? [];
|
||||
const latestSys = sysList[sysList.length - 1] ?? null;
|
||||
const latestDia = diaList[diaList.length - 1] ?? null;
|
||||
metrics.push({
|
||||
id: "bp",
|
||||
kind: "bloodPressure",
|
||||
title: METRIC_TITLES.bloodPressure,
|
||||
latestValue: latestSys?.value ?? null,
|
||||
secondaryValue: latestDia?.value ?? null,
|
||||
unit: METRIC_UNITS.bloodPressure,
|
||||
trend: trendOf(sysList.map((p) => p.value)),
|
||||
sparkline: sysList.map((p) => p.value),
|
||||
updatedAt:
|
||||
latestSys?.at.toISOString() ?? latestDia?.at.toISOString() ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
// Pulse
|
||||
{
|
||||
const latest = latestOf("PULSE");
|
||||
const spark = sparkOf("PULSE");
|
||||
metrics.push({
|
||||
id: "pulse",
|
||||
kind: "pulse",
|
||||
title: METRIC_TITLES.pulse,
|
||||
latestValue: latest?.value ?? null,
|
||||
secondaryValue: null,
|
||||
unit: METRIC_UNITS.pulse,
|
||||
trend: trendOf(spark),
|
||||
sparkline: spark,
|
||||
updatedAt: latest?.at.toISOString() ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
// Body fat
|
||||
{
|
||||
const latest = latestOf("BODY_FAT");
|
||||
const spark = sparkOf("BODY_FAT");
|
||||
if (latest) {
|
||||
metrics.push({
|
||||
id: "bodyFat",
|
||||
kind: "bodyFat",
|
||||
title: METRIC_TITLES.bodyFat,
|
||||
latestValue: latest.value,
|
||||
secondaryValue: null,
|
||||
unit: METRIC_UNITS.bodyFat,
|
||||
trend: trendOf(spark),
|
||||
sparkline: spark,
|
||||
updatedAt: latest.at.toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Optional cards — only emitted if the user has data for that type.
|
||||
for (const [type, kind] of [
|
||||
["BLOOD_GLUCOSE", "glucose"],
|
||||
["SLEEP_DURATION", "sleep"],
|
||||
["ACTIVITY_STEPS", "steps"],
|
||||
["TOTAL_BODY_WATER", "totalBodyWater"],
|
||||
["BONE_MASS", "boneMass"],
|
||||
["OXYGEN_SATURATION", "oxygenSaturation"],
|
||||
] as const) {
|
||||
const latest = latestOf(type);
|
||||
if (!latest) continue;
|
||||
const spark = sparkOf(type);
|
||||
metrics.push({
|
||||
id: kind,
|
||||
kind,
|
||||
title: METRIC_TITLES[kind],
|
||||
latestValue: latest.value,
|
||||
secondaryValue: null,
|
||||
unit: METRIC_UNITS[kind],
|
||||
trend: trendOf(spark),
|
||||
sparkline: spark,
|
||||
updatedAt: latest.at.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
const scheduledToday = todaysIntakes.length;
|
||||
const takenToday = todaysIntakes.filter(
|
||||
(e) => e.takenAt !== null && !e.skipped,
|
||||
).length;
|
||||
|
||||
const greetingName = user.displayName ?? user.username;
|
||||
const locale: Locale =
|
||||
user.locale === "de" || user.locale === "en" ? user.locale : defaultLocale;
|
||||
const t = getServerTranslator(locale).t;
|
||||
|
||||
return apiSuccess({
|
||||
greeting: {
|
||||
salutation: t("dashboard.greetingSalutation", { name: greetingName }),
|
||||
date: now.toISOString(),
|
||||
},
|
||||
streak: {
|
||||
currentDays: streak.currentDays,
|
||||
longest: streak.longest,
|
||||
label: t("dashboard.streakLabel"),
|
||||
},
|
||||
compliance: {
|
||||
scheduledToday,
|
||||
takenToday,
|
||||
},
|
||||
highlightInsight: null,
|
||||
metrics,
|
||||
lastUpdated: now.toISOString(),
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* GET / PUT / DELETE dashboard widget layout.
|
||||
*
|
||||
* GET returns the resolved effective layout (defaults merged in if the user
|
||||
* hasn't customized yet). PUT replaces the layout atomically. DELETE resets
|
||||
* to default.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
resolveDashboardLayout,
|
||||
serializeDashboardLayout,
|
||||
DEFAULT_DASHBOARD_LAYOUT,
|
||||
type DashboardLayout,
|
||||
} from "@/lib/dashboard-layout";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { z } from "zod/v4";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
const widgetIdEnum = z.enum([
|
||||
"weight",
|
||||
"bp",
|
||||
"pulse",
|
||||
"bodyFat",
|
||||
"mood",
|
||||
"medications",
|
||||
"sleep",
|
||||
"steps",
|
||||
"glucose",
|
||||
"totalBodyWater",
|
||||
"boneMass",
|
||||
"bpInTarget",
|
||||
"oxygenSaturation",
|
||||
]);
|
||||
|
||||
const layoutSchema = z.object({
|
||||
version: z.literal(1),
|
||||
widgets: z
|
||||
.array(
|
||||
z.object({
|
||||
id: widgetIdEnum,
|
||||
visible: z.boolean(),
|
||||
order: z.number().int().min(0).max(99),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(20),
|
||||
});
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { dashboardWidgetsJson: true },
|
||||
});
|
||||
|
||||
const layout = resolveDashboardLayout(row?.dashboardWidgetsJson);
|
||||
return apiSuccess(layout);
|
||||
});
|
||||
|
||||
export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = layoutSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
}
|
||||
|
||||
const normalized = serializeDashboardLayout(parsed.data as DashboardLayout);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
dashboardWidgetsJson: normalized as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "dashboard.widgets.update" },
|
||||
meta: { visible_count: normalized.widgets.filter((w) => w.visible).length },
|
||||
});
|
||||
|
||||
return apiSuccess(normalized);
|
||||
});
|
||||
|
||||
export const DELETE = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { dashboardWidgetsJson: Prisma.JsonNull },
|
||||
});
|
||||
|
||||
annotate({ action: { name: "dashboard.widgets.reset" } });
|
||||
|
||||
return apiSuccess(DEFAULT_DASHBOARD_LAYOUT);
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
device: {
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({ get: () => undefined, set: () => {}, delete: () => {} })),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
};
|
||||
|
||||
function req(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/devices", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.device.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(prisma.device.create).mockResolvedValue({ id: "dev-1" } as never);
|
||||
vi.mocked(prisma.device.update).mockResolvedValue({ id: "dev-1" } as never);
|
||||
});
|
||||
|
||||
describe("POST /api/devices", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await POST(
|
||||
req({
|
||||
token: "abcd1234efgh5678",
|
||||
bundleId: "io.healthlog.app",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 422 for missing token", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await POST(req({ bundleId: "io.healthlog.app" }));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("creates a new device row when token is unknown", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await POST(
|
||||
req({
|
||||
token: "abcd1234efgh5678",
|
||||
bundleId: "io.healthlog.app",
|
||||
locale: "de-DE",
|
||||
appVersion: "1.0.0",
|
||||
model: "iPhone15,2",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
expect(prisma.device.create).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.device.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects cross-user re-registration with 409", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.device.findUnique).mockResolvedValue({
|
||||
id: "dev-1",
|
||||
userId: "other-user",
|
||||
token: "abcd1234efgh5678",
|
||||
} as never);
|
||||
const res = await POST(
|
||||
req({ token: "abcd1234efgh5678", bundleId: "io.healthlog.app" }),
|
||||
);
|
||||
expect(res.status).toBe(409);
|
||||
expect(prisma.device.update).not.toHaveBeenCalled();
|
||||
expect(prisma.device.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("updates in place when same user re-registers their own token", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.device.findUnique).mockResolvedValue({
|
||||
id: "dev-1",
|
||||
userId: "user-1",
|
||||
token: "abcd1234efgh5678",
|
||||
} as never);
|
||||
const res = await POST(
|
||||
req({ token: "abcd1234efgh5678", bundleId: "io.healthlog.app" }),
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
expect(prisma.device.update).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.device.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* POST /api/devices
|
||||
*
|
||||
* APNs device registration. Native clients call this on login and on
|
||||
* APNs token rotation. We upsert by `token`; sending the same token on a
|
||||
* different account simply transfers ownership.
|
||||
*
|
||||
* No APNs send-side logic lives here — that ships in Phase 8.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, safeJson } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
const deviceSchema = z.object({
|
||||
token: z
|
||||
.string()
|
||||
.min(8)
|
||||
.max(512)
|
||||
.regex(/^[A-Za-z0-9+/=._:-]+$/, "Invalid token format"),
|
||||
bundleId: z.string().min(1).max(128),
|
||||
locale: z.string().min(2).max(16).optional(),
|
||||
appVersion: z.string().min(1).max(32).optional(),
|
||||
model: z.string().min(1).max(64).optional(),
|
||||
});
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { data: body, error } = await safeJson(request);
|
||||
if (error) return error;
|
||||
|
||||
const parsed = deviceSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
}
|
||||
|
||||
const { token, bundleId, locale, appVersion, model } = parsed.data;
|
||||
|
||||
const existing = await prisma.device.findUnique({ where: { token } });
|
||||
let id: string;
|
||||
if (existing) {
|
||||
// Cross-user-hijack guard: a device-token belongs to exactly one user.
|
||||
// Re-registering the same token under a different account is rejected
|
||||
// — APNs tokens aren't a secret, so trusting the wire input would let
|
||||
// anyone who learns/guesses a token redirect another user's pushes.
|
||||
if (existing.userId !== user.id) {
|
||||
await auditLog("device.register.denied", {
|
||||
userId: user.id,
|
||||
details: { reason: "token_owned_by_other_user", deviceId: existing.id },
|
||||
});
|
||||
return apiError("Device token already registered to another account", 409);
|
||||
}
|
||||
const updated = await prisma.device.update({
|
||||
where: { token },
|
||||
data: {
|
||||
platform: "ios",
|
||||
bundleId,
|
||||
locale: locale ?? null,
|
||||
appVersion: appVersion ?? null,
|
||||
model: model ?? null,
|
||||
lastSeen: new Date(),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
id = updated.id;
|
||||
} else {
|
||||
const created = await prisma.device.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
platform: "ios",
|
||||
token,
|
||||
bundleId,
|
||||
locale: locale ?? null,
|
||||
appVersion: appVersion ?? null,
|
||||
model: model ?? null,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
id = created.id;
|
||||
}
|
||||
|
||||
await auditLog("devices.register", {
|
||||
userId: user.id,
|
||||
details: { deviceId: id, bundleId, model: model ?? null },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "devices.register", entity_type: "device", entity_id: id },
|
||||
});
|
||||
|
||||
return apiSuccess({ id }, 201);
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
// --- Mocks must come before importing the route. ---
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: { findMany: vi.fn() },
|
||||
medication: { findMany: vi.fn() },
|
||||
medicationIntakeEvent: { findMany: vi.fn() },
|
||||
moodEntry: { findMany: vi.fn() },
|
||||
user: { findUnique: vi.fn() },
|
||||
auditLog: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({
|
||||
getSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/geo", () => ({
|
||||
lookupIpLocation: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/analytics/effective-range", () => ({
|
||||
getEffectiveRange: vi.fn(() => ({ range: null })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({
|
||||
emitIfSampled: vi.fn(),
|
||||
}));
|
||||
|
||||
import { POST } from "../pdf/route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
function makeRequest(
|
||||
body?: Record<string, unknown>,
|
||||
headers: Record<string, string> = {},
|
||||
): NextRequest {
|
||||
const baseHeaders: Record<string, string> = {
|
||||
"content-type": "application/json",
|
||||
...headers,
|
||||
};
|
||||
return new NextRequest("http://localhost/api/doctor-report/pdf", {
|
||||
method: "POST",
|
||||
headers: baseHeaders,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3600_000) },
|
||||
user: { id: "user-1", role: "USER" as const },
|
||||
};
|
||||
|
||||
function setEmptyDataMocks() {
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
username: "marc",
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
heightCm: null,
|
||||
glucoseUnit: null,
|
||||
thresholdsJson: null,
|
||||
} as never);
|
||||
}
|
||||
|
||||
describe("POST /api/doctor-report/pdf", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it("returns 401 when not authenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
|
||||
const res = await POST(makeRequest({ days: 90 }));
|
||||
expect(res.status).toBe(401);
|
||||
const json = (await res.json()) as { error: string };
|
||||
expect(json.error).toBe("Not authenticated");
|
||||
});
|
||||
|
||||
it("returns 429 when rate limit exceeded", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 3600_000,
|
||||
});
|
||||
|
||||
const res = await POST(makeRequest({ days: 90 }));
|
||||
expect(res.status).toBe(429);
|
||||
const json = (await res.json()) as { error: string };
|
||||
expect(json.error).toMatch(/Maximum/);
|
||||
});
|
||||
|
||||
it("returns 200 application/pdf with PDF bytes on happy path", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 9,
|
||||
resetAt: Date.now() + 3600_000,
|
||||
});
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined as never);
|
||||
setEmptyDataMocks();
|
||||
|
||||
const res = await POST(
|
||||
makeRequest({ days: 30, locale: "de" }, { "accept-language": "de-DE" }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("content-type")).toBe("application/pdf");
|
||||
expect(res.headers.get("cache-control")).toBe("no-store");
|
||||
const disposition = res.headers.get("content-disposition") ?? "";
|
||||
expect(disposition).toMatch(/attachment;\s*filename="healthlog-report-/);
|
||||
expect(disposition).toMatch(/\.pdf"/);
|
||||
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
expect(buf.byteLength).toBeGreaterThan(1024);
|
||||
expect(buf.slice(0, 5).toString("utf8")).toBe("%PDF-");
|
||||
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"doctor-report.pdf.generate",
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
details: expect.objectContaining({ days: 30, locale: "de" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to default days=90 when body omits it", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 9,
|
||||
resetAt: Date.now() + 3600_000,
|
||||
});
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined as never);
|
||||
setEmptyDataMocks();
|
||||
|
||||
const res = await POST(makeRequest({}));
|
||||
expect(res.status).toBe(200);
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"doctor-report.pdf.generate",
|
||||
expect.objectContaining({
|
||||
details: expect.objectContaining({ days: 90 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses Accept-Language to pick the locale when body omits it", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 9,
|
||||
resetAt: Date.now() + 3600_000,
|
||||
});
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined as never);
|
||||
setEmptyDataMocks();
|
||||
|
||||
const res = await POST(
|
||||
makeRequest({ days: 7 }, { "accept-language": "en-US,en;q=0.9" }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"doctor-report.pdf.generate",
|
||||
expect.objectContaining({
|
||||
details: expect.objectContaining({ locale: "en" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiError, getClientIp } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
collectDoctorReportData,
|
||||
normaliseDays,
|
||||
} from "@/lib/doctor-report-data";
|
||||
import { renderDoctorReportPdfBytes } from "@/lib/doctor-report-pdf-core";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { parseLocaleFromAcceptLanguage } from "@/lib/format-locale";
|
||||
import { locales, type Locale } from "@/lib/i18n/config";
|
||||
|
||||
/**
|
||||
* Server-rendered PDF doctor report.
|
||||
*
|
||||
* Mirrors `/api/doctor-report` (which returns JSON for client-side rendering)
|
||||
* but produces the finished PDF bytes server-side. Useful on iOS / Safari where
|
||||
* the client-side jsPDF download UX is unreliable.
|
||||
*
|
||||
* Auth, rate-limit (10/h), and audit-log policy match the JSON endpoint.
|
||||
*/
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "doctor-report.pdf.generate" } });
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`doctor-report:${user.id}`,
|
||||
10,
|
||||
60 * 60 * 1000,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Maximum 10 reports per hour", 429);
|
||||
}
|
||||
|
||||
// Body is optional — JSON is allowed but not required.
|
||||
const body = await readOptionalJsonBody(request);
|
||||
const days = normaliseDays(body?.days);
|
||||
const locale = resolveLocale(
|
||||
body?.locale,
|
||||
request.headers.get("accept-language"),
|
||||
);
|
||||
|
||||
const data = await collectDoctorReportData(user.id, days);
|
||||
|
||||
const { t } = getServerTranslator(locale);
|
||||
const pdfBytes = renderDoctorReportPdfBytes(data, { t, locale });
|
||||
|
||||
await auditLog("doctor-report.pdf.generate", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { days, locale },
|
||||
});
|
||||
|
||||
annotate({ meta: { report_days: days, locale, bytes: pdfBytes.byteLength } });
|
||||
|
||||
const filename = `healthlog-report-${new Date().toISOString().slice(0, 10)}.pdf`;
|
||||
// NextResponse accepts a BodyInit; copy to a fresh ArrayBuffer to avoid
|
||||
// SharedArrayBuffer typing surprises with Uint8Array.
|
||||
const buffer = pdfBytes.slice().buffer;
|
||||
return new NextResponse(buffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
"Content-Length": String(pdfBytes.byteLength),
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
interface PdfRequestBody {
|
||||
days?: unknown;
|
||||
locale?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort JSON body parse. Returns `null` if the request had no body or
|
||||
* a non-JSON content type — both are acceptable for this endpoint.
|
||||
*/
|
||||
async function readOptionalJsonBody(
|
||||
request: NextRequest,
|
||||
): Promise<PdfRequestBody | null> {
|
||||
const contentType = request.headers.get("content-type") ?? "";
|
||||
if (!contentType.includes("application/json")) return null;
|
||||
try {
|
||||
return (await request.json()) as PdfRequestBody;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the active locale: explicit body override wins, then a parsed
|
||||
* `Accept-Language`, then a hard fallback to German (the primary HealthLog
|
||||
* locale for the medical context).
|
||||
*/
|
||||
function resolveLocale(
|
||||
bodyLocale: unknown,
|
||||
acceptLanguage: string | null,
|
||||
): Locale {
|
||||
if (
|
||||
typeof bodyLocale === "string" &&
|
||||
(locales as readonly string[]).includes(bodyLocale)
|
||||
) {
|
||||
return bodyLocale as Locale;
|
||||
}
|
||||
if (!acceptLanguage) return "de";
|
||||
// parseLocaleFromAcceptLanguage returns "de" for de-* headers, otherwise
|
||||
// "en" — matching the broader app's i18n behaviour for present headers.
|
||||
return parseLocaleFromAcceptLanguage(acceptLanguage);
|
||||
}
|
||||
@@ -1,10 +1,18 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { NextRequest } from "next/server";
|
||||
import {
|
||||
collectDoctorReportData,
|
||||
normaliseDays,
|
||||
} from "@/lib/doctor-report-data";
|
||||
|
||||
/**
|
||||
* Collect data for doctor report PDF generation (client-side).
|
||||
@@ -14,158 +22,28 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "doctor-report.generate" } });
|
||||
|
||||
const rl = await checkRateLimit(`doctor-report:${user.id}`, 10, 60 * 60 * 1000);
|
||||
const rl = await checkRateLimit(
|
||||
`doctor-report:${user.id}`,
|
||||
10,
|
||||
60 * 60 * 1000,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Maximal 10 Reports pro Stunde", 429);
|
||||
return apiError("Maximum 10 reports per hour", 429);
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
|
||||
if (jsonError) return jsonError;
|
||||
const rawDays = (body as Record<string, unknown>)?.days;
|
||||
const days =
|
||||
typeof rawDays === "number" && Number.isInteger(rawDays) && rawDays >= 1 && rawDays <= 365
|
||||
? rawDays
|
||||
: 90;
|
||||
const since = new Date();
|
||||
since.setDate(since.getDate() - days);
|
||||
|
||||
const userId = user.id;
|
||||
|
||||
const [measurements, medications, intakeEvents, moodEntries, userProfile] =
|
||||
await Promise.all([
|
||||
prisma.measurement.findMany({
|
||||
where: { userId, measuredAt: { gte: since } },
|
||||
orderBy: { measuredAt: "asc" },
|
||||
}),
|
||||
prisma.medication.findMany({
|
||||
where: { userId, active: true },
|
||||
include: { schedules: true },
|
||||
}),
|
||||
prisma.medicationIntakeEvent.findMany({
|
||||
where: { userId, scheduledFor: { gte: since } },
|
||||
include: { medication: { select: { name: true } } },
|
||||
orderBy: { scheduledFor: "asc" },
|
||||
}),
|
||||
prisma.moodEntry.findMany({
|
||||
where: { userId, moodLoggedAt: { gte: since } },
|
||||
orderBy: { moodLoggedAt: "asc" },
|
||||
}),
|
||||
prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
username: true,
|
||||
dateOfBirth: true,
|
||||
gender: true,
|
||||
heightCm: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
// Group measurements by type
|
||||
const byType: Record<
|
||||
string,
|
||||
Array<{ value: number; measuredAt: string }>
|
||||
> = {};
|
||||
for (const m of measurements) {
|
||||
if (!byType[m.type]) byType[m.type] = [];
|
||||
byType[m.type].push({
|
||||
value: m.value,
|
||||
measuredAt: m.measuredAt.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate stats per type
|
||||
const stats: Record<
|
||||
string,
|
||||
{ avg: number; min: number; max: number; count: number; latest: number }
|
||||
> = {};
|
||||
for (const [type, entries] of Object.entries(byType)) {
|
||||
const values = entries.map((e) => e.value);
|
||||
stats[type] = {
|
||||
avg: values.reduce((a, b) => a + b, 0) / values.length,
|
||||
min: Math.min(...values),
|
||||
max: Math.max(...values),
|
||||
count: values.length,
|
||||
latest: values[values.length - 1],
|
||||
};
|
||||
}
|
||||
|
||||
// Medication compliance
|
||||
const complianceByMed: Record<
|
||||
string,
|
||||
{ total: number; taken: number; skipped: number; missed: number }
|
||||
> = {};
|
||||
for (const event of intakeEvents) {
|
||||
const name = event.medication.name;
|
||||
if (!complianceByMed[name]) {
|
||||
complianceByMed[name] = { total: 0, taken: 0, skipped: 0, missed: 0 };
|
||||
}
|
||||
complianceByMed[name].total++;
|
||||
if (event.takenAt) {
|
||||
complianceByMed[name].taken++;
|
||||
} else if (event.skipped) {
|
||||
complianceByMed[name].skipped++;
|
||||
} else {
|
||||
complianceByMed[name].missed++;
|
||||
}
|
||||
}
|
||||
|
||||
// Mood summary
|
||||
const moodScores = moodEntries.map((e) => e.score);
|
||||
const moodSummary =
|
||||
moodScores.length > 0
|
||||
? {
|
||||
avg: moodScores.reduce((a, b) => a + b, 0) / moodScores.length,
|
||||
min: Math.min(...moodScores),
|
||||
max: Math.max(...moodScores),
|
||||
count: moodScores.length,
|
||||
distribution: {
|
||||
1: moodScores.filter((s) => s === 1).length,
|
||||
2: moodScores.filter((s) => s === 2).length,
|
||||
3: moodScores.filter((s) => s === 3).length,
|
||||
4: moodScores.filter((s) => s === 4).length,
|
||||
5: moodScores.filter((s) => s === 5).length,
|
||||
},
|
||||
}
|
||||
: null;
|
||||
|
||||
// BMI
|
||||
const weightStats = stats.WEIGHT;
|
||||
const bmi =
|
||||
weightStats && userProfile?.heightCm
|
||||
? weightStats.latest / (userProfile.heightCm / 100) ** 2
|
||||
: null;
|
||||
const days = normaliseDays((body as Record<string, unknown>)?.days);
|
||||
const data = await collectDoctorReportData(user.id, days);
|
||||
|
||||
await auditLog("doctor-report.generate", {
|
||||
userId,
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { days },
|
||||
});
|
||||
|
||||
annotate({ meta: { report_days: days } });
|
||||
|
||||
return apiSuccess({
|
||||
period: { days, since: since.toISOString() },
|
||||
patient: {
|
||||
username: userProfile?.username ?? null,
|
||||
dateOfBirth: userProfile?.dateOfBirth ?? null,
|
||||
gender: userProfile?.gender ?? null,
|
||||
heightCm: userProfile?.heightCm ?? null,
|
||||
},
|
||||
measurements: byType,
|
||||
stats,
|
||||
bmi: bmi ? Math.round(bmi * 10) / 10 : null,
|
||||
compliance: complianceByMed,
|
||||
medications: medications.map((m) => ({
|
||||
name: m.name,
|
||||
dose: m.dose,
|
||||
schedules: m.schedules.map((s) => ({
|
||||
windowStart: s.windowStart,
|
||||
windowEnd: s.windowEnd,
|
||||
label: s.label,
|
||||
})),
|
||||
})),
|
||||
mood: moodSummary,
|
||||
});
|
||||
return apiSuccess(data);
|
||||
});
|
||||
@@ -26,7 +26,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const rl = await checkRateLimit(`export:${user.id}`, 10, 60 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Maximal 10 Exports pro Stunde", 429);
|
||||
return apiError("Maximum 10 exports per hour", 429);
|
||||
}
|
||||
|
||||
annotate({ action: { name: "export.download" } });
|
||||
@@ -36,10 +36,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const type = searchParams.get("type") ?? "all";
|
||||
|
||||
if (!["csv", "json"].includes(format)) {
|
||||
return apiError("Format muss csv oder json sein", 422);
|
||||
return apiError("Format must be csv or json", 422);
|
||||
}
|
||||
if (!["measurements", "medications", "intake", "mood", "all"].includes(type)) {
|
||||
return apiError("Typ muss measurements, medications, intake, mood oder all sein", 422);
|
||||
return apiError("Type must be measurements, medications, intake, mood, or all", 422);
|
||||
}
|
||||
|
||||
const userId = user.id;
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* POST /api/feedback — user submits feedback. Always works, no GitHub needed.
|
||||
* GET /api/feedback — user's own feedback history (paginated).
|
||||
*
|
||||
* Rate-limited at 5 per hour per user. Screenshots are stored as base64 in
|
||||
* the database (≤5 MB each).
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
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";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { createFeedbackSchema } from "@/lib/validations/feedback";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rl = await checkRateLimit(`feedback:${user.id}`, 5, 60 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many submissions — please try again later", 429);
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = createFeedbackSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
}
|
||||
|
||||
const feedback = await prisma.feedback.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
email: user.email ?? null,
|
||||
category: parsed.data.category,
|
||||
subject: parsed.data.subject,
|
||||
description: parsed.data.description,
|
||||
screenshotBase64: parsed.data.screenshot ?? null,
|
||||
metadata:
|
||||
parsed.data.metadata !== undefined
|
||||
? (parsed.data.metadata as Prisma.InputJsonValue)
|
||||
: Prisma.JsonNull,
|
||||
},
|
||||
select: { id: true, createdAt: true, category: true, status: true },
|
||||
});
|
||||
|
||||
await auditLog("feedback.submit", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
feedbackId: feedback.id,
|
||||
category: feedback.category,
|
||||
hasScreenshot: Boolean(parsed.data.screenshot),
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "feedback.submit" },
|
||||
meta: {
|
||||
feedback_id: feedback.id,
|
||||
category: feedback.category,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(feedback, 201);
|
||||
});
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const url = new URL(request.url);
|
||||
const limit = Math.min(
|
||||
50,
|
||||
Math.max(1, Number(url.searchParams.get("limit") ?? 25)),
|
||||
);
|
||||
const offset = Math.max(0, Number(url.searchParams.get("offset") ?? 0));
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
prisma.feedback.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
select: {
|
||||
id: true,
|
||||
category: true,
|
||||
subject: true,
|
||||
status: true,
|
||||
adminNote: true,
|
||||
gitHubIssueUrl: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
}),
|
||||
prisma.feedback.count({ where: { userId: user.id } }),
|
||||
]);
|
||||
|
||||
return apiSuccess({ items, meta: { total, limit, offset } });
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: { findMany: vi.fn() },
|
||||
medicationIntakeEvent: { findMany: vi.fn() },
|
||||
medication: { findMany: vi.fn() },
|
||||
passkey: { findMany: vi.fn() },
|
||||
auditLog: { findMany: vi.fn() },
|
||||
userAchievement: {
|
||||
findMany: vi.fn(),
|
||||
createMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({ get: () => undefined, set: () => {}, delete: () => {} })),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "user-1",
|
||||
username: "marc",
|
||||
role: "USER" as const,
|
||||
createdAt: new Date("2026-03-01T00:00:00.000Z"),
|
||||
heightCm: null,
|
||||
locale: "en",
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.passkey.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.auditLog.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.userAchievement.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.userAchievement.createMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("GET /api/gamification/achievements?format=ios", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await GET(
|
||||
new NextRequest(
|
||||
"http://localhost/api/gamification/achievements?format=ios",
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns a flat iOS-shaped achievement array", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await GET(
|
||||
new NextRequest(
|
||||
"http://localhost/api/gamification/achievements?format=ios",
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: Array<{
|
||||
id: string;
|
||||
key: string;
|
||||
title: string;
|
||||
description: string;
|
||||
iconName: string;
|
||||
unlocked: boolean;
|
||||
unlockedAt: string | null;
|
||||
progress: number;
|
||||
}>;
|
||||
};
|
||||
expect(Array.isArray(body.data)).toBe(true);
|
||||
expect(body.data.length).toBeGreaterThan(0);
|
||||
const first = body.data[0];
|
||||
expect(first.id).toEqual(expect.any(String));
|
||||
expect(first.key).toEqual(expect.any(String));
|
||||
expect(first.title).toEqual(expect.any(String));
|
||||
expect(first.iconName).toEqual(expect.any(String));
|
||||
expect(first.progress).toBeGreaterThanOrEqual(0);
|
||||
expect(first.progress).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("falls back to the legacy wrapped shape when no format is given", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/gamification/achievements"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { achievements: unknown[]; summary: unknown };
|
||||
};
|
||||
expect(body.data.achievements).toBeDefined();
|
||||
expect(body.data.summary).toBeDefined();
|
||||
});
|
||||
});
|
||||
Loaded 100 of 400 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user