mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 12:22:03 +00:00
Compare commits
No files matched your search
@@ -104,3 +104,41 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
|
||||
# LOKI_ENDPOINT="http://loki:3100"
|
||||
# LOKI_USERNAME=""
|
||||
# LOKI_PASSWORD=""
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Process split (optional, single-container deploys ignore this)
|
||||
# -----------------------------------------------------------------------------
|
||||
# `all` — single container runs HTTP + reminder worker (default, legacy).
|
||||
# `web` — HTTP only; pair with a separate `app-worker` service.
|
||||
# `worker`— pg-boss only; rejects HTTP traffic with a 503.
|
||||
# See docs/self-hosting/scaling.md for the split-stack guide.
|
||||
# HEALTHLOG_PROCESS_TYPE="all"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Encryption-key versioning (optional — for at-rest key rotation)
|
||||
# -----------------------------------------------------------------------------
|
||||
# JSON map of key id → 64-hex-char or base64 32-byte key. When set, it
|
||||
# supersedes ENCRYPTION_KEY for new writes; old rows continue to decrypt under
|
||||
# their original id. ENCRYPTION_ACTIVE_KEY_ID picks which entry encrypt() uses.
|
||||
# Run `pnpm tsx scripts/rotate-encryption-key.ts <new-id>` to re-encrypt every
|
||||
# existing row under the new key. See docs/ops/encryption-key-rotation.md.
|
||||
# ENCRYPTION_KEYS='{"v1":"<old-key>","v2":"<new-key>"}'
|
||||
# ENCRYPTION_ACTIVE_KEY_ID="v2"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Off-host backup (optional — daily encrypted dumps to an S3-compatible bucket)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Required ALL-OR-NONE: setting any of these without setting the rest disables
|
||||
# the backup silently. The backup runs at 02:30 Europe/Berlin via pg-boss cron.
|
||||
# Restore with `pnpm tsx scripts/restore-backup.ts <userId> <YYYY-MM-DD>`.
|
||||
# See docs/ops/backup-restore.md for the format spec + step-by-step.
|
||||
# BACKUP_S3_ENDPOINT="https://<account>.r2.cloudflarestorage.com"
|
||||
# BACKUP_S3_BUCKET="healthlog-backups"
|
||||
# BACKUP_S3_ACCESS_KEY=""
|
||||
# BACKUP_S3_SECRET_KEY=""
|
||||
# BACKUP_S3_REGION="auto" # "auto" for Cloudflare R2; real region for AWS S3
|
||||
# BACKUP_RETENTION_DAYS="30" # objects older than this are pruned (best-effort; prefer a bucket lifecycle rule)
|
||||
# BACKUP_ENCRYPTION_KEY="" # 64-hex-char or base64 32-byte; SEPARATE from ENCRYPTION_KEY
|
||||
@@ -0,0 +1,83 @@
|
||||
name: e2e
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
# Cancel in-progress runs when a new commit lands on the same PR.
|
||||
concurrency:
|
||||
group: e2e-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_USER: healthlog
|
||||
POSTGRES_PASSWORD: healthlog
|
||||
POSTGRES_DB: healthlog
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U healthlog"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
|
||||
env:
|
||||
DATABASE_URL: postgresql://healthlog:healthlog@localhost:5432/healthlog?schema=public
|
||||
ENCRYPTION_KEYS: '{"v1":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}'
|
||||
ENCRYPTION_ACTIVE_KEY_ID: v1
|
||||
API_TOKEN_HMAC_KEY: ci-hmac-key-32-bytes-min-padding-x
|
||||
AUTH_RP_NAME: HealthLog
|
||||
AUTH_RP_ID: localhost
|
||||
AUTH_RP_ORIGIN: http://localhost:3000
|
||||
NODE_ENV: production
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- run: pnpm db:generate
|
||||
|
||||
- run: pnpm db:migrate:deploy
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
playwright-${{ runner.os }}-
|
||||
|
||||
- run: pnpm exec playwright install --with-deps chromium
|
||||
|
||||
- run: pnpm exec next build
|
||||
|
||||
- run: pnpm exec playwright test
|
||||
env:
|
||||
E2E_BASE_URL: http://localhost:3000
|
||||
|
||||
- name: Upload Playwright HTML report on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 7
|
||||
@@ -0,0 +1,34 @@
|
||||
name: Integration tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
integration:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- run: pnpm db:generate
|
||||
|
||||
- run: pnpm test:integration
|
||||
env:
|
||||
# The integration suite boots a Postgres testcontainer and points
|
||||
# Prisma at it via DATABASE_URL set inside `tests/integration/setup.ts`.
|
||||
# These env vars satisfy code paths that read them at module load.
|
||||
API_TOKEN_HMAC_KEY: integration-tests-do-not-use-in-prod
|
||||
ENCRYPTION_KEY: 0000000000000000000000000000000000000000000000000000000000000000
|
||||
SESSION_SECRET: integration-tests
|
||||
@@ -11,6 +11,11 @@
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# playwright e2e
|
||||
/test-results
|
||||
/playwright-report
|
||||
/playwright/.cache
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"shadcn": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "shadcn@latest", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,19 @@ Instructions for AI coding agents (OpenAI Codex, Claude Code, Cursor, etc.) work
|
||||
|
||||
**HealthLog** — a personal health-tracking web app (weight, blood pressure, pulse, mood, medication compliance) with Withings integration, moodLog.app sync, Dracula-themed UI, mobile-first PWA design.
|
||||
|
||||
**Status**: v1.3.3 — Pulse oximetry (SpO₂) as a first-class measurement type, layered on top of v1.3.2 body composition (TBW + Bone Mass). SSRF-hardened outbound fetches (now also covers Web-Push endpoint + Bearer-scope wildcard handling + IP-geolocation HTTPS-only), GHCR multi-arch images (`linux/amd64` + `linux/arm64`) with SLSA provenance + SBOM, pg-boss graceful SIGTERM drain + audit-log retention purge (GDPR Art. 5(1)(e)), blocking TypeScript CI, locale-integrity test guard. moodLog webhook secret now AES-GCM encrypted at rest. See GitHub Releases + CHANGELOG.md for the full feature timeline (v1.0 → v1.3).
|
||||
**Status**: v1.4.0 (released) → v1.4.1 in progress. v1.4.0 shipped
|
||||
the UI guidelines + Skeleton/EmptyState primitives, medical-citations
|
||||
consolidation, the per-route `/settings/[section]` split, the
|
||||
status-first admin grid + aggregator endpoint, five new "Test
|
||||
connection" buttons, AI insights with inline charts via allowlisted
|
||||
metric tokens, off-host encrypted S3 backup, encryption-key
|
||||
versioning + rotation CLI, optional `HEALTHLOG_PROCESS_TYPE` worker /
|
||||
web split, and short-lived 24h access tokens with refresh-token
|
||||
rotation for native API clients. v1.4.1 follows up with per-section
|
||||
admin component extraction (the inner monolith into one file per
|
||||
panel), a Postgres testcontainers integration test suite, and a
|
||||
Playwright + axe-core E2E foundation. See GitHub Releases +
|
||||
CHANGELOG.md for the full feature timeline.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
@@ -66,7 +78,7 @@ src/
|
||||
│ ├── layout.tsx # Root layout (viewport-fit: cover for PWA)
|
||||
│ ├── page.tsx # Dashboard (/) with quick entry dropdown
|
||||
│ ├── globals.css # Dracula theme CSS variables
|
||||
│ ├── admin/page.tsx # Admin panel
|
||||
│ ├── admin/page.tsx # Admin shell — 77 LOC, mounts the per-section components from src/components/admin/
|
||||
│ ├── auth/login/page.tsx # Login
|
||||
│ ├── auth/register/page.tsx # Registration
|
||||
│ ├── achievements/page.tsx # Gamification achievements
|
||||
@@ -76,7 +88,8 @@ src/
|
||||
│ ├── medications/page.tsx # Medications management
|
||||
│ ├── notifications/page.tsx # Notification preferences matrix
|
||||
│ ├── onboarding/page.tsx # 4-step guided onboarding
|
||||
│ ├── settings/page.tsx # All settings (8 top-level sections, ~3150 lines — split tracked for 1.4.0)
|
||||
│ ├── settings/page.tsx # 308-redirects /settings → /settings/account
|
||||
│ ├── settings/[section]/page.tsx # Per-section route shell (account, integrations, notifications, dashboard, ai, api, advanced, about)
|
||||
│ ├── mood/page.tsx # Mood tracking
|
||||
│ ├── targets/page.tsx # Target values dashboard
|
||||
│ └── api/ # 100+ API route files (admin, auth, measurements, medications, mood, insights, integrations, ingest, dashboard, feedback, tokens, notifications, monitoring, …)
|
||||
@@ -95,7 +108,8 @@ src/
|
||||
│ ├── charts/ # Recharts wrappers, compliance charts
|
||||
│ ├── insights/ # AI insights cards (status, advisor)
|
||||
│ ├── gamification/ # Achievement cards, progress
|
||||
│ ├── settings/ # Settings-page section components
|
||||
│ ├── settings/ # Per-route Settings section components (one per /settings/[section])
|
||||
│ ├── admin/ # Per-route Admin section components (system-status, integrations, monitoring, reminders, users, audit, danger-zone, feedback)
|
||||
│ └── monitoring/ # Umami, GlitchTip bootstrap
|
||||
├── lib/
|
||||
│ ├── db.ts # Prisma client singleton
|
||||
@@ -126,8 +140,8 @@ messages/
|
||||
├── de.json # German translations (primary UI language)
|
||||
└── en.json # English translations
|
||||
prisma/
|
||||
├── schema.prisma # Database schema (25 models)
|
||||
└── migrations/ # Migration files (0001–0024; latest: oxygen_saturation)
|
||||
├── schema.prisma # Database schema (26 models)
|
||||
└── migrations/ # Migration files (0001–0025; latest: refresh_tokens + user_locale_drift_fix)
|
||||
prisma.config.ts # Prisma config (DB URL lives here, NOT in schema)
|
||||
public/
|
||||
├── sw.js # Service worker (Web Push + offline caching)
|
||||
@@ -174,11 +188,11 @@ These are hard-won lessons. Ignoring them will cause errors:
|
||||
- **Zod v4**: Import from `"zod/v4"`, not `"zod"`.
|
||||
- **jsPDF**: Client-side only. Import dynamically in browser context. Used with `jspdf-autotable` plugin.
|
||||
|
||||
### Settings Page
|
||||
### Settings & Admin (per-route layout, v1.4)
|
||||
|
||||
- One large file (~3150 lines), 8 top-level sections. Sidebar switches to "settings mode" showing section shortcuts. Splitting into per-section files is tracked for 1.4.0 — until then, ESLint `react-hooks/set-state-in-effect` stays non-blocking because of the long-standing violations in this file.
|
||||
- Sections scroll-to with highlight animation (`section-highlight` CSS class).
|
||||
- Top-level section IDs: `section-allgemein`, `section-sicherheit`, `section-benachrichtigungen`, `section-personalization`, `section-integration`, `section-api`, `section-export`, `section-danger-zone`. Sub-anchors inside those sections include `profil`, `passwort`, `passkeys`, `telegram`, `ntfy`, `web-push`, `insights`, `dashboard-layout`, `thresholds`, `withings`, `moodlog`, `api-tokens`, `api-endpoints`.
|
||||
- **Settings**: 8 routes under `/settings/[section]` — `account`, `integrations`, `notifications`, `dashboard`, `ai`, `api`, `advanced`, `about`. The legacy `/settings` 308-redirects to `/settings/account`. Sidebar deep-links and the `<a href="/settings#anchor">` patterns from 1.3 still resolve via the redirect.
|
||||
- **Admin**: `src/app/admin/page.tsx` is now a 77-LOC shell that mounts the per-section components in `src/components/admin/`. Status-card grid lives in `status-card-grid.tsx`. The aggregator endpoint `/api/admin/status-overview` returns the six-card summaries in one batched query.
|
||||
- ESLint `react-hooks/set-state-in-effect` is **strict** now (was non-blocking when the settings monolith carried inline-effect state-setters). Use lazy `useState(() => …)` for localStorage reads, TanStack Query for data fetches.
|
||||
|
||||
### Insights Page
|
||||
|
||||
@@ -216,7 +230,7 @@ These are hard-won lessons. Ignoring them will cause errors:
|
||||
|
||||
## Database Models (Prisma)
|
||||
|
||||
25 models: `User`, `Passkey`, `Session`, `AuthChallenge`, `Measurement`, `Medication`, `MedicationSchedule`, `MedicationIntakeEvent`, `ReminderPhaseConfig`, `TelegramReminderMessage`, `TelegramScheduledDeletion`, `ApiToken`, `WithingsConnection`, `MoodEntry`, `AppSettings`, `Feedback`, `AuditLog`, `NotificationChannel`, `NotificationPreference`, `PushSubscription`, `DataBackup`, `UserAchievement`, `RateLimit`, `Device`, `IdempotencyKey`.
|
||||
26 models: `User`, `Passkey`, `Session`, `AuthChallenge`, `Measurement`, `Medication`, `MedicationSchedule`, `MedicationIntakeEvent`, `ReminderPhaseConfig`, `TelegramReminderMessage`, `TelegramScheduledDeletion`, `ApiToken`, `RefreshToken`, `WithingsConnection`, `MoodEntry`, `AppSettings`, `Feedback`, `AuditLog`, `NotificationChannel`, `NotificationPreference`, `PushSubscription`, `DataBackup`, `UserAchievement`, `RateLimit`, `Device`, `IdempotencyKey`. (`RefreshToken` added in v1.4.0 alongside the native-client 24h access-token / refresh-token rotation flow.)
|
||||
|
||||
## When Making Changes
|
||||
|
||||
|
||||
+383
@@ -1,5 +1,388 @@
|
||||
# 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
|
||||
|
||||
@@ -36,7 +36,7 @@ docker compose logs -f app # Tail app logs
|
||||
## Architecture
|
||||
|
||||
- **Next.js 16** App Router with TypeScript strict. Pages are RSC by default; `"use client"` only for interactivity.
|
||||
- **Prisma 7** ORM with PostgreSQL (25 models). Uses `PrismaPg` adapter from `@prisma/adapter-pg`. Client singleton at `src/lib/db.ts`. Generated client at `src/generated/prisma/client` (note the `/client` suffix). Prisma config in `prisma.config.ts` (not in schema.prisma).
|
||||
- **Prisma 7** ORM with PostgreSQL (26 models). Uses `PrismaPg` adapter from `@prisma/adapter-pg`. Client singleton at `src/lib/db.ts`. Generated client at `src/generated/prisma/client` (note the `/client` suffix). Prisma config in `prisma.config.ts` (not in schema.prisma).
|
||||
- **shadcn/ui** components (new-york style) in `src/components/ui/`. Add new ones via `pnpm dlx shadcn@latest add <component>`.
|
||||
- **Dracula theme** via CSS variables in `globals.css`. Dark mode is default. Use `--dracula-*` tokens for chart colors.
|
||||
- **TanStack Query** for client-side data fetching. Provider in `src/components/providers.tsx`.
|
||||
@@ -68,6 +68,8 @@ docker compose logs -f app # Tail app logs
|
||||
- `src/components/measurements/` — measurement form, list
|
||||
- `src/components/mood/` — mood form, mood list
|
||||
- `src/components/charts/` — Recharts wrappers
|
||||
- `src/components/settings/` — per-route Settings section components (one per `/settings/[section]`)
|
||||
- `src/components/admin/` — per-route Admin section components (system-status, integrations, monitoring, users, audit, danger-zone, feedback)
|
||||
- `src/lib/logging/` — Wide Events structured logging (types, config, event-builder, context, sampler, transports, background)
|
||||
- `src/lib/api-handler.ts` — apiHandler wrapper, requireAuth(), requireAdmin(), HttpError
|
||||
- `src/lib/` — server utilities (db, crypto, auth, analytics, export, rate-limit, gravatar)
|
||||
@@ -81,7 +83,9 @@ docker compose logs -f app # Tail app logs
|
||||
- `src/lib/validations/` — Zod schemas shared between API + client
|
||||
- `src/hooks/` — React hooks (`use-auth`)
|
||||
- `messages/de.json` + `messages/en.json` — i18n translations
|
||||
- `prisma/schema.prisma` — database schema (25 models)
|
||||
- `prisma/schema.prisma` — database schema (26 models)
|
||||
- `tests/integration/` — Postgres testcontainers integration suite (rate-limit race, idempotency replay, GDPR cascade delete, session lifecycle); `pnpm test:integration`
|
||||
- `e2e/` — Playwright + axe-core suite for public smoke checks (version, auth-redirect, login, locale-switch, a11y); `pnpm e2e`
|
||||
- `prisma.config.ts` — Prisma config (DB URL here, not in schema)
|
||||
- `public/sw.js` — Service worker for Web Push notifications + offline caching
|
||||
- `docs/` — long-form audit notes (`docs/audit/`); end-user docs live in the separate site at https://docs.healthlog.dev
|
||||
@@ -107,6 +111,8 @@ docker compose logs -f app # Tail app logs
|
||||
- **Achievements**: Persistent in `UserAchievement` table. Computed on API call, new unlocks written to DB with stable `unlockedAt` timestamps
|
||||
- **Data backup**: pg-boss `data-backup` queue runs weekly (Sundays 03:00), stores JSON in `DataBackup` model
|
||||
- **Wide Events / Structured Logging**: `apiHandler()` wraps all API routes. Use `annotate()` from `@/lib/logging/context` for business-action annotations. Use `requireAuth()` / `requireAdmin()` from `@/lib/api-handler` (auto-annotates auth). Background jobs use `withBackgroundEvent()`. External calls tracked via `getEvent()?.addExternalCall()`. No `console.log` in production code — use event annotations instead. Env vars: `LOG_LEVEL`, `LOG_SAMPLE_RATE`, `LOG_SLOW_THRESHOLD_MS`, `LOG_INCLUDE_STACK`, `LOKI_ENDPOINT`, `LOKI_USERNAME`, `LOKI_PASSWORD`
|
||||
- **Multi-tenant prep (v1.4)**: `HEALTHLOG_PROCESS_TYPE=web|worker|all` (default `all`) splits HTTP and pg-boss workloads; the proxy refuses HTTP traffic with 503 + `X-HealthLog-Process-Type: worker` in worker mode. `ENCRYPTION_KEYS` is a JSON map of versioned keys (`{"v1": "...", "v2": "..."}`) plus `ENCRYPTION_ACTIVE_KEY_ID` for new writes; rotation via `pnpm tsx scripts/rotate-encryption-key.ts <id>`. `BACKUP_S3_*` env block configures off-host weekly encrypted backups (PutObject + GetObject only — retention is the bucket's lifecycle policy, never the worker).
|
||||
- **Native API clients (v1.4)**: `POST /api/auth/login` and `/api/auth/passkey/login-verify` issue a 24h access token (`hlk_<64hex>`) AND a refresh token (`hlr_<64hex>`) when `X-Client-Type: native` or the User-Agent starts with `HealthLog-iOS/`. The browser flow is unchanged. Refresh-token reuse-detection revokes every refresh token for the user. The idempotency replay-cache rejects bodies containing `hlk_` OR `hlr_` so cached responses can never echo a token back.
|
||||
|
||||
## Headless-Client API Patterns
|
||||
|
||||
|
||||
+6
-1
@@ -23,12 +23,17 @@ RUN pnpm build
|
||||
|
||||
# ── Stage 3: Production runner ─────────────────────────────
|
||||
FROM node:22-alpine AS runner
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
# `tzdata` is required so Europe/Berlin schedules (pg-boss cron, locale-aware
|
||||
# timestamp formatting) resolve to the actual offset instead of silently
|
||||
# falling back to UTC on Alpine images that ship without it.
|
||||
RUN apk add --no-cache tzdata && \
|
||||
corepack enable && corepack prepare pnpm@latest --activate
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV NODE_PATH="/opt/pg-boss/node_modules"
|
||||
ENV TZ=Europe/Berlin
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nextjs
|
||||
|
||||
@@ -63,6 +63,10 @@ Most health apps lock your data behind proprietary clouds, push subscriptions, a
|
||||
|
||||
**Internationalization** -- English (default) and German UI with 1500+ translation keys, guarded by a CI integrity test that fails the build on duplicate keys or drift between locales. Numbers, dates, units, and AI prompts all locale-aware via `useFormatters()`. Browser-based detection with per-user override.
|
||||
|
||||
**Multi-tenant ready** _(v1.4)_ — Off-host AES-GCM-encrypted weekly backups to any S3-compatible bucket (R2, B2, MinIO, AWS), encryption-key versioning + zero-downtime rotation CLI, optional `HEALTHLOG_PROCESS_TYPE=web|worker|all` so HTTP and pg-boss can scale independently, and short-lived 24h access tokens with refresh-token rotation for native API clients. The browser cookie session is unchanged.
|
||||
|
||||
**Test connection buttons** _(v1.4)_ — One-click probes for Withings, moodLog.app, Web Push, Glitchtip, and Umami in addition to the existing AI / Telegram / ntfy tests. Each one rate-limited, sanitised against SSRF redirects, and surfaces a localisable `errorCode` so the UI can render the failure in the user's language.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
@@ -102,7 +106,7 @@ Open **http://localhost:3000**. The first registered user becomes admin.
|
||||
| ------------- | ------------------------------------------------- |
|
||||
| Framework | Next.js 16 (App Router, React Server Components) |
|
||||
| Language | TypeScript (strict mode) |
|
||||
| Database | PostgreSQL 16 + Prisma 7 (25 models) |
|
||||
| Database | PostgreSQL 16 + Prisma 7 (26 models) |
|
||||
| Job Queue | pg-boss 12 (reminders, insights, backups) |
|
||||
| UI | shadcn/ui, Tailwind CSS 4, Radix UI, Lucide Icons |
|
||||
| Charts | Recharts 3 |
|
||||
@@ -275,83 +279,101 @@ All mutations require authentication via session cookie. External ingest uses Be
|
||||
<details>
|
||||
<summary><strong>Auth and Integrations</strong></summary>
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| ------ | -------------------------------- | ------------------------------------ |
|
||||
| `POST` | `/api/auth/register` | Create account |
|
||||
| `POST` | `/api/auth/login` | Password login |
|
||||
| `POST` | `/api/auth/logout` | Destroy session |
|
||||
| `GET` | `/api/auth/me` | Current user profile + Gravatar URL |
|
||||
| `POST` | `/api/auth/password` | Change password |
|
||||
| `PATCH`| `/api/auth/profile` | Update profile fields |
|
||||
| `POST` | `/api/auth/passkey/*` | WebAuthn flows (4 sub-routes) |
|
||||
| `GET` | `/api/auth/passkeys` | List enrolled passkeys |
|
||||
| `GET` | `/api/auth/codex/authorize` | ChatGPT (codex) OAuth start |
|
||||
| `GET` | `/api/withings/connect` | Initiate Withings OAuth |
|
||||
| `POST` | `/api/withings/sync` | Trigger manual Withings sync |
|
||||
| `POST` | `/api/withings/webhook` | Withings notification webhook |
|
||||
| `POST` | `/api/insights/generate` | Regenerate AI insights |
|
||||
| `GET` | `/api/insights/comprehensive` | Aggregated insight payload |
|
||||
| `GET` | `/api/gamification/achievements` | Achievement progress |
|
||||
| `GET` | `/api/health` | Docker health check |
|
||||
| Method | Endpoint | Description |
|
||||
| ------- | -------------------------------- | ----------------------------------- |
|
||||
| `POST` | `/api/auth/register` | Create account |
|
||||
| `POST` | `/api/auth/login` | Password login |
|
||||
| `POST` | `/api/auth/logout` | Destroy session |
|
||||
| `GET` | `/api/auth/me` | Current user profile + Gravatar URL |
|
||||
| `POST` | `/api/auth/password` | Change password |
|
||||
| `PATCH` | `/api/auth/profile` | Update profile fields |
|
||||
| `POST` | `/api/auth/passkey/*` | WebAuthn flows (4 sub-routes) |
|
||||
| `GET` | `/api/auth/passkeys` | List enrolled passkeys |
|
||||
| `GET` | `/api/auth/codex/authorize` | ChatGPT (codex) OAuth start |
|
||||
| `GET` | `/api/withings/connect` | Initiate Withings OAuth |
|
||||
| `POST` | `/api/withings/sync` | Trigger manual Withings sync |
|
||||
| `POST` | `/api/withings/webhook` | Withings notification webhook |
|
||||
| `POST` | `/api/insights/generate` | Regenerate AI insights |
|
||||
| `GET` | `/api/insights/comprehensive` | Aggregated insight payload |
|
||||
| `GET` | `/api/gamification/achievements` | Achievement progress |
|
||||
| `GET` | `/api/health` | Docker health check |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Personalization (Thresholds + Dashboard)</strong></summary>
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| -------- | ------------------------- | --------------------------------------------- |
|
||||
| `GET` | `/api/user/thresholds` | Read per-user threshold overrides |
|
||||
| `PUT` | `/api/user/thresholds` | Upsert thresholds (rate-limited, audit-logged)|
|
||||
| `GET` | `/api/insights/targets` | Effective ranges (defaults + overrides merged)|
|
||||
| `GET` | `/api/dashboard/widgets` | Read dashboard layout |
|
||||
| `PUT` | `/api/dashboard/widgets` | Persist dashboard layout (show/hide/reorder) |
|
||||
| `POST` | `/api/onboarding/complete`| Mark onboarding finished |
|
||||
| Method | Endpoint | Description |
|
||||
| ------ | -------------------------- | ---------------------------------------------- |
|
||||
| `GET` | `/api/user/thresholds` | Read per-user threshold overrides |
|
||||
| `PUT` | `/api/user/thresholds` | Upsert thresholds (rate-limited, audit-logged) |
|
||||
| `GET` | `/api/insights/targets` | Effective ranges (defaults + overrides merged) |
|
||||
| `GET` | `/api/dashboard/widgets` | Read dashboard layout |
|
||||
| `PUT` | `/api/dashboard/widgets` | Persist dashboard layout (show/hide/reorder) |
|
||||
| `POST` | `/api/onboarding/complete` | Mark onboarding finished |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Feedback + API Tokens</strong></summary>
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| -------- | --------------------------------------- | ------------------------------------ |
|
||||
| `POST` | `/api/feedback` | Submit in-app feedback |
|
||||
| `GET` | `/api/bugreport/status` | Check published GitHub issue state |
|
||||
| `GET` | `/api/tokens` | List own API tokens |
|
||||
| `POST` | `/api/tokens` | Mint new API token (Bearer, hashed) |
|
||||
| `DELETE` | `/api/tokens/:id` | Revoke API token |
|
||||
| Method | Endpoint | Description |
|
||||
| -------- | ----------------------- | ----------------------------------- |
|
||||
| `POST` | `/api/feedback` | Submit in-app feedback |
|
||||
| `GET` | `/api/bugreport/status` | Check published GitHub issue state |
|
||||
| `GET` | `/api/tokens` | List own API tokens |
|
||||
| `POST` | `/api/tokens` | Mint new API token (Bearer, hashed) |
|
||||
| `DELETE` | `/api/tokens/:id` | Revoke API token |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Notifications</strong></summary>
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| -------- | --------------------------------- | -------------------------------------- |
|
||||
| `GET` | `/api/notifications/preferences` | Read per-channel × per-event matrix |
|
||||
| `PUT` | `/api/notifications/preferences` | Update preferences |
|
||||
| `GET` | `/api/notifications/vapid` | VAPID public key for Web Push |
|
||||
| `POST` | `/api/notifications/web-push` | Register Web Push subscription |
|
||||
| `POST` | `/api/telegram/webhook` | Telegram bot inline-button callback |
|
||||
| Method | Endpoint | Description |
|
||||
| ------ | -------------------------------- | ----------------------------------- |
|
||||
| `GET` | `/api/notifications/preferences` | Read per-channel × per-event matrix |
|
||||
| `PUT` | `/api/notifications/preferences` | Update preferences |
|
||||
| `GET` | `/api/notifications/vapid` | VAPID public key for Web Push |
|
||||
| `POST` | `/api/notifications/web-push` | Register Web Push subscription |
|
||||
| `POST` | `/api/telegram/webhook` | Telegram bot inline-button callback |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Admin (admin role required)</strong></summary>
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| -------- | --------------------------------- | -------------------------------------- |
|
||||
| `GET` | `/api/admin/status` | System + integration status |
|
||||
| `GET` | `/api/admin/users` | List users |
|
||||
| `POST` | `/api/admin/users/:id/reset-password` | Force password reset |
|
||||
| `GET` | `/api/admin/feedback` | All feedback / bug reports |
|
||||
| `POST` | `/api/admin/feedback/:id/github` | Escalate feedback to GitHub issue |
|
||||
| `GET` | `/api/admin/audit-log` | Audit-log viewer |
|
||||
| `GET` | `/api/admin/ai-settings` | Read shared AI provider config |
|
||||
| `PUT` | `/api/admin/ai-settings` | Update shared AI provider config |
|
||||
| `GET` | `/api/admin/tokens` | All issued API tokens |
|
||||
| `POST` | `/api/admin/notifications/test` | Send test notification |
|
||||
| `GET` | `/api/admin/data` | Data backups + counts |
|
||||
| Method | Endpoint | Description |
|
||||
| ------ | ------------------------------------- | ------------------------------------- |
|
||||
| `GET` | `/api/admin/status` | System + integration status |
|
||||
| `GET` | `/api/admin/users` | List users |
|
||||
| `POST` | `/api/admin/users/:id/reset-password` | Force password reset |
|
||||
| `GET` | `/api/admin/feedback` | All feedback / bug reports |
|
||||
| `POST` | `/api/admin/feedback/:id/github` | Escalate feedback to GitHub issue |
|
||||
| `GET` | `/api/admin/audit-log` | Audit-log viewer |
|
||||
| `GET` | `/api/admin/ai-settings` | Read shared AI provider config |
|
||||
| `PUT` | `/api/admin/ai-settings` | Update shared AI provider config |
|
||||
| `GET` | `/api/admin/tokens` | All issued API tokens |
|
||||
| `POST` | `/api/admin/notifications/test` | Send test notification |
|
||||
| `GET` | `/api/admin/data` | Data backups + counts |
|
||||
| `GET` | `/api/admin/status-overview` | Aggregated status for the 6-card grid |
|
||||
| `POST` | `/api/admin/backup/test` | Probe S3-compatible backup target |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Public + v1.4 additions</strong></summary>
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| ------ | ---------------------------------- | ----------------------------------------------- |
|
||||
| `GET` | `/api/version` | Public — version + build SHA + license, no auth |
|
||||
| `POST` | `/api/integrations/withings/test` | Probe a saved Withings connection |
|
||||
| `POST` | `/api/integrations/moodlog/test` | Probe moodLog.app webhook reachability |
|
||||
| `POST` | `/api/notifications/web-push/test` | Send a test Web Push to the current user |
|
||||
| `POST` | `/api/monitoring/glitchtip/test` | Trigger a Glitchtip ingest probe |
|
||||
| `POST` | `/api/monitoring/umami/test` | Verify Umami script + website ID resolve |
|
||||
| `POST` | `/api/auth/refresh` | Native client refresh-token rotation |
|
||||
| `POST` | `/api/auth/refresh/revoke` | Revoke an issued refresh token |
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+63
-15
@@ -1,35 +1,83 @@
|
||||
services:
|
||||
# Single-container default (HEALTHLOG_PROCESS_TYPE=all). Existing
|
||||
# self-host deployments stay working with `docker compose up`.
|
||||
# For horizontal scaling switch to the web/worker split below
|
||||
# (HEALTHLOG_PROCESS_TYPE=web on this service + uncomment app-worker).
|
||||
app:
|
||||
# Pre-built multi-arch images are published to GHCR by
|
||||
# .github/workflows/docker-publish.yml on every push to main and on
|
||||
# tagged releases. Pin to a specific version in production:
|
||||
# e.g. ghcr.io/mbombeck/healthlog:1.2.0
|
||||
image: ghcr.io/mbombeck/healthlog:latest
|
||||
# Fallback build context: if the image is not yet in the registry,
|
||||
# or you want to test local changes, run `docker compose up --build`.
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
# Pinned to GHCR :1.3.3 multi-arch image (gestern released, audit-tested).
|
||||
# Production on apps-01 has been 503-ing since today's deploys; rolling back
|
||||
# to the last release that's verifiably running on self-host installs to
|
||||
# rule out runtime / Docker-network issues. No `build:` block on purpose:
|
||||
# we want Coolify to PULL the prebuilt image, not build from source.
|
||||
image: ghcr.io/mbombeck/healthlog:1.4.0
|
||||
container_name: healthlog-app
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
# Plain ${VAR} interpolation only — no ${VAR:?error} validation
|
||||
# syntax. Some hosting platforms (e.g. Coolify) parse compose files
|
||||
# eagerly and store the *fallback error string* as the literal value
|
||||
# when the env var is unset, which then breaks the deployment.
|
||||
# Validation is enforced by docker-entrypoint.sh before the app starts.
|
||||
DATABASE_URL: "postgresql://healthlog:${POSTGRES_PASSWORD}@db:5432/healthlog?schema=public"
|
||||
NODE_ENV: production
|
||||
SESSION_SECRET: "${SESSION_SECRET}"
|
||||
ENCRYPTION_KEY: "${ENCRYPTION_KEY}"
|
||||
ENCRYPTION_KEYS: "${ENCRYPTION_KEYS:-}"
|
||||
ENCRYPTION_ACTIVE_KEY_ID: "${ENCRYPTION_ACTIVE_KEY_ID:-}"
|
||||
API_TOKEN_HMAC_KEY: "${API_TOKEN_HMAC_KEY}"
|
||||
NEXT_PUBLIC_APP_URL: "${NEXT_PUBLIC_APP_URL:-http://localhost:3000}"
|
||||
APP_URL: "${APP_URL:-http://localhost:3000}"
|
||||
# all = bundle web + worker in this container (default, single-host)
|
||||
# web = serve HTTP only; pair with a separate app-worker service
|
||||
HEALTHLOG_PROCESS_TYPE: "${HEALTHLOG_PROCESS_TYPE:-all}"
|
||||
BACKUP_ENCRYPTION_KEY: "${BACKUP_ENCRYPTION_KEY:-}"
|
||||
BACKUP_S3_ENDPOINT: "${BACKUP_S3_ENDPOINT:-}"
|
||||
BACKUP_S3_BUCKET: "${BACKUP_S3_BUCKET:-}"
|
||||
BACKUP_S3_ACCESS_KEY: "${BACKUP_S3_ACCESS_KEY:-}"
|
||||
BACKUP_S3_SECRET_KEY: "${BACKUP_S3_SECRET_KEY:-}"
|
||||
BACKUP_S3_REGION: "${BACKUP_S3_REGION:-auto}"
|
||||
BACKUP_RETENTION_DAYS: "${BACKUP_RETENTION_DAYS:-30}"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/api/version || exit 1",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
# Optional dedicated worker container — uncomment + set
|
||||
# HEALTHLOG_PROCESS_TYPE=web on `app` to split the deployment.
|
||||
# See docs/self-hosting/scaling.md for the full guide.
|
||||
#
|
||||
# app-worker:
|
||||
# image: ghcr.io/mbombeck/healthlog:latest
|
||||
# build:
|
||||
# context: .
|
||||
# dockerfile: Dockerfile
|
||||
# container_name: healthlog-worker
|
||||
# environment:
|
||||
# DATABASE_URL: "postgresql://healthlog:${POSTGRES_PASSWORD}@db:5432/healthlog?schema=public"
|
||||
# NODE_ENV: production
|
||||
# SESSION_SECRET: "${SESSION_SECRET}"
|
||||
# ENCRYPTION_KEY: "${ENCRYPTION_KEY}"
|
||||
# ENCRYPTION_KEYS: "${ENCRYPTION_KEYS:-}"
|
||||
# ENCRYPTION_ACTIVE_KEY_ID: "${ENCRYPTION_ACTIVE_KEY_ID:-}"
|
||||
# API_TOKEN_HMAC_KEY: "${API_TOKEN_HMAC_KEY}"
|
||||
# APP_URL: "${APP_URL:-http://localhost:3000}"
|
||||
# HEALTHLOG_PROCESS_TYPE: "worker"
|
||||
# BACKUP_ENCRYPTION_KEY: "${BACKUP_ENCRYPTION_KEY:-}"
|
||||
# BACKUP_S3_ENDPOINT: "${BACKUP_S3_ENDPOINT:-}"
|
||||
# BACKUP_S3_BUCKET: "${BACKUP_S3_BUCKET:-}"
|
||||
# BACKUP_S3_ACCESS_KEY: "${BACKUP_S3_ACCESS_KEY:-}"
|
||||
# BACKUP_S3_SECRET_KEY: "${BACKUP_S3_SECRET_KEY:-}"
|
||||
# BACKUP_S3_REGION: "${BACKUP_S3_REGION:-auto}"
|
||||
# depends_on:
|
||||
# db:
|
||||
# condition: service_healthy
|
||||
# restart: unless-stopped
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
|
||||
+1493
-1000
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
});
|
||||
});
|
||||
+167
-4
@@ -62,7 +62,7 @@
|
||||
"colMin": "Min",
|
||||
"colMax": "Max",
|
||||
"colN": "n",
|
||||
"bpClassificationTitle": "Blutdruck — ESC/ESH-Klassifikation (2018)",
|
||||
"bpClassificationTitle": "Blutdruck — ESH-Klassifikation (2023)",
|
||||
"avgBp": "Durchschnittlicher Blutdruck",
|
||||
"classification": "Klassifikation",
|
||||
"bmiTitle": "Body-Mass-Index (BMI)",
|
||||
@@ -194,6 +194,8 @@
|
||||
},
|
||||
"welcomeBack": "{greeting}, willkommen zurück.",
|
||||
"welcomeBackWithName": "{greeting} {name}, willkommen zurück.",
|
||||
"greetingSalutation": "Hi, {name}",
|
||||
"streakLabel": "Tage in Folge",
|
||||
"weight": "Gewicht",
|
||||
"bloodPressure": "Blutdruck",
|
||||
"bloodPressureSys": "Blutdruck (Sys)",
|
||||
@@ -600,6 +602,10 @@
|
||||
"refreshAnalysis": "Analyse aktualisieren",
|
||||
"keyTakeaway": "Das Wichtigste",
|
||||
"findingsTitle": "Befunde",
|
||||
"heroFindingPositive": "Wichtigster Befund",
|
||||
"heroFindingNeutral": "Wichtigster Befund",
|
||||
"heroFindingAttention": "Beachten",
|
||||
"heroFindingWarning": "Wichtigster Alarm",
|
||||
"correlationsTitle": "Zusammenhänge",
|
||||
"recommendationsTitle": "Empfehlungen",
|
||||
"dataFoundation": "Datengrundlage",
|
||||
@@ -628,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)",
|
||||
@@ -794,11 +800,119 @@
|
||||
"errorMedication": "Das Medikament „{name}\" konnte nicht angelegt werden. Bitte erneut versuchen oder später unter „Medikamente\" hinzufügen.",
|
||||
"errorTargets": "Deine Zielwerte konnten nicht gespeichert werden. Bitte erneut versuchen.",
|
||||
"errorGeneric": "Beim Abschluss des Setups ist etwas schiefgelaufen. Bitte erneut versuchen.",
|
||||
"medScheduleHint": "Wir legen ein Standard-Erinnerungsfenster an (08:00–09:00 täglich). Du kannst es jederzeit unter „Medikamente\" anpassen."
|
||||
"medScheduleHint": "Wir legen ein Standard-Erinnerungsfenster an (08:00–09:00 täglich). Du kannst es jederzeit unter „Medikamente\" anpassen.",
|
||||
"v2": {
|
||||
"title": "Willkommen bei HealthLog",
|
||||
"subtitle": "Drei schnelle Schritte. Alles weitere kannst du später in den Einstellungen erledigen.",
|
||||
"stepOf": "Schritt {current} von {total}",
|
||||
"back": "Zurück",
|
||||
"continue": "Weiter",
|
||||
"finish": "Setup abschließen",
|
||||
"skipStep": "Schritt überspringen — du kannst das Setup später in den Einstellungen abschließen.",
|
||||
"step1": {
|
||||
"title": "Über dich",
|
||||
"description": "Wird für Blutdruck-Zielwerte, BMI und passende Erinnerungen genutzt.",
|
||||
"displayNameLabel": "Anzeigename",
|
||||
"displayNameHint": "Erscheint in der Dashboard-Begrüßung und in Berichten.",
|
||||
"languageLabel": "Sprache",
|
||||
"languageHint": "Wird für App, PDFs und Benachrichtigungen verwendet."
|
||||
},
|
||||
"step2": {
|
||||
"title": "Lege deine erste Messung an",
|
||||
"description": "Gewicht, Blutdruck oder Puls — was du gerade zur Hand hast. Schon eine Messung lässt das Dashboard lebendig wirken.",
|
||||
"added": "Top — weitere Messungen kannst du jederzeit über das Dashboard hinzufügen.",
|
||||
"skipHint": "Keine Messung zur Hand? Überspringe und logge eine, wenn du soweit bist."
|
||||
},
|
||||
"step3": {
|
||||
"title": "Wähle einen Benachrichtigungs-Kanal",
|
||||
"description": "Wir senden nur, was du auch willst. Du kannst weitere Kanäle und Events später feinjustieren.",
|
||||
"channelTelegramTitle": "Telegram-Bot",
|
||||
"channelTelegramHint": "Erinnerungen mit Snooze-Buttons direkt in Telegram.",
|
||||
"channelWebPushTitle": "Web Push",
|
||||
"channelWebPushHint": "Native Browser-Benachrichtigungen auf diesem Gerät.",
|
||||
"channelNtfyTitle": "ntfy.sh",
|
||||
"channelNtfyHint": "Open-Source-Push an Handy oder Desktop, ohne Account.",
|
||||
"channelNoneTitle": "Vorerst überspringen",
|
||||
"channelNoneHint": "Ich richte Benachrichtigungen später in den Einstellungen ein.",
|
||||
"openSettings": "In den Einstellungen fortsetzen"
|
||||
},
|
||||
"doneToast": "Alles eingerichtet — willkommen an Bord."
|
||||
}
|
||||
},
|
||||
"gettingStarted": {
|
||||
"title": "Erste Schritte",
|
||||
"subtitle": "Quick Wins, damit du das Beste aus HealthLog herausholst.",
|
||||
"progress": "{done} von {total} erledigt",
|
||||
"dismiss": "Ausblenden",
|
||||
"dismissAll": "Checkliste ausblenden",
|
||||
"dismissTooltip": "Diesen Punkt ausblenden",
|
||||
"restoreHint": "Du kannst die Checkliste in den Einstellungen wiederherstellen.",
|
||||
"items": {
|
||||
"profileTitle": "Profil einrichten",
|
||||
"profileDescription": "Größe, Geburtsdatum und Geschlecht schalten personalisierte Zielwerte frei.",
|
||||
"profileCta": "Profil öffnen",
|
||||
"measurementTitle": "Erste Messung anlegen",
|
||||
"measurementDescription": "Eine Messung reicht, um deine erste Trend-Kachel zu zeichnen.",
|
||||
"measurementCta": "Messung hinzufügen",
|
||||
"medicationTitle": "Medikament hinzufügen",
|
||||
"medicationDescription": "Verfolge Compliance und erhalte Erinnerungen genau dann, wenn du sie brauchst.",
|
||||
"medicationCta": "Medikament hinzufügen",
|
||||
"withingsTitle": "Withings verbinden (optional)",
|
||||
"withingsDescription": "Synchronisiere Gewicht, Blutdruck und Puls automatisch aus deinen Withings-Geräten.",
|
||||
"withingsCta": "Verbinden",
|
||||
"notificationsTitle": "Benachrichtigungen einrichten",
|
||||
"notificationsDescription": "Wähle, wie HealthLog dich erinnert — Telegram, ntfy oder Web Push.",
|
||||
"notificationsCta": "Benachrichtigungen öffnen"
|
||||
}
|
||||
},
|
||||
"trendHints": {
|
||||
"title": "Erster Trend nach 5 Messungen",
|
||||
"remainingOne": "Noch 1 Messung bis zum Trend.",
|
||||
"remainingMany": "Noch {count} Messungen bis zum Trend."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Einstellungen",
|
||||
"subtitle": "Profil, Sicherheit & Verbindungen",
|
||||
"shell": {
|
||||
"sectionsNav": "Einstellungs-Bereiche"
|
||||
},
|
||||
"sections": {
|
||||
"account": {
|
||||
"title": "Konto",
|
||||
"description": "Profil, Anmeldemethoden und Konto-Löschung."
|
||||
},
|
||||
"integrations": {
|
||||
"title": "Integrationen",
|
||||
"description": "Withings, moodLog und weitere verbundene Dienste."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Benachrichtigungen",
|
||||
"description": "Telegram, ntfy, Web Push."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Übersicht",
|
||||
"description": "Kachel-Layout, Schwellwerte."
|
||||
},
|
||||
"ai": {
|
||||
"title": "KI-Auswertungen",
|
||||
"description": "Provider, Modell, API-Key."
|
||||
},
|
||||
"api": {
|
||||
"title": "API & Tokens",
|
||||
"description": "Bearer-Tokens für Drittanbieter-Clients."
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Erweitert",
|
||||
"description": "Export, Import, Daten löschen."
|
||||
},
|
||||
"about": {
|
||||
"title": "Über",
|
||||
"description": "Version, Lizenz, Links."
|
||||
},
|
||||
"placeholder": {
|
||||
"coming_soon": "Wird mit dem nächsten Release-Update hierher umziehen."
|
||||
}
|
||||
},
|
||||
"sectionsTitle": "Settings",
|
||||
"categoryGeneral": "Allgemein",
|
||||
"categoryNotifications": "Benachrichtigungen",
|
||||
@@ -1039,7 +1153,41 @@
|
||||
"moodLogSyncResult": "{count} Einträge synchronisiert",
|
||||
"moodLogSyncFailed": "Sync fehlgeschlagen",
|
||||
"moodLogSaved": "moodLog-Verbindung gespeichert",
|
||||
"moodLogDisconnected": "moodLog getrennt"
|
||||
"moodLogDisconnected": "moodLog getrennt",
|
||||
"about": {
|
||||
"version": "App-Version",
|
||||
"gitSha": "Build",
|
||||
"builtAt": "Erstellt {time}",
|
||||
"license": "Lizenz",
|
||||
"repository": "Quellcode",
|
||||
"changelog": "Changelog",
|
||||
"docs": "Dokumentation",
|
||||
"checkUpdates": "Auf Updates prüfen",
|
||||
"upToDate": "Du nutzt die aktuelle Version",
|
||||
"newerAvailable": "Neue Version: {tag}"
|
||||
},
|
||||
"testConnection": {
|
||||
"test": "Verbindung testen",
|
||||
"testing": "Teste…",
|
||||
"ok": "Verbunden (Latenz {latency} ms)",
|
||||
"errors": {
|
||||
"credentials_rejected": "Zugangsdaten abgelehnt — Token prüfen",
|
||||
"rate_limited": "Vom Upstream rate-limitiert",
|
||||
"timeout": "Request hat das Zeitlimit überschritten",
|
||||
"upstream_error": "Upstream hat einen Fehler gemeldet",
|
||||
"connection_failed": "Verbindung fehlgeschlagen",
|
||||
"not_configured": "Nicht konfiguriert",
|
||||
"url_not_public": "URL ist kein öffentlicher Endpunkt",
|
||||
"url_invalid": "URL ist ungültig",
|
||||
"redirected": "Endpunkt leitet weiter — URL prüfen",
|
||||
"endpoint_not_found": "Endpunkt unter der URL nicht gefunden",
|
||||
"credentials_unreadable": "Zugangsdaten nicht entschlüsselbar",
|
||||
"upstream_invalid_json": "Upstream-Antwort ist kein JSON",
|
||||
"vapid_not_configured": "Web Push nicht konfiguriert (VAPID-Keys fehlen)",
|
||||
"rate_limited_self": "Zu viele Test-Anfragen",
|
||||
"generic": "Test fehlgeschlagen"
|
||||
}
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
@@ -1629,5 +1777,20 @@
|
||||
"medicationNotFoundExact": "Medikament nicht gefunden. Bitte Namen exakt wie in HealthLog senden.",
|
||||
"helpHeader": "<b>Verfügbare Befehle:</b>",
|
||||
"helpBody": "/help — Diese Hilfe anzeigen\n/add — Einnahme erfassen\n/start — Bot starten\n\n<b>Textbefehle:</b>\ngenommen <Name> — Einnahme bestätigen\n\n<b>Über die Buttons in Erinnerungen:</b>\n• Genommen — Einnahme bestätigen\n• 🕐 1h / 🕐 3h — Erinnerung verschieben\n• ⏭ Überspringen — Einnahme überspringen\n• ✓ Bestätigen — Verpasste Einnahme bestätigen"
|
||||
},
|
||||
"medicationReminders": {
|
||||
"phaseGreenTitle": "🟢 Erinnerung: {medName}",
|
||||
"phaseGreenMessage": "🟢 Erinnerung:\n<b>{medName}</b> ({doseInfo}, {timeWindow})\nZeitfenster endet in {minutes} Min.",
|
||||
"phaseYellowTitle": "🟡 Bald fällig: {medName}",
|
||||
"phaseYellowMessage": "🟡 Bald fällig:\n<b>{medName}</b> ({doseInfo}, {timeWindow})\nNoch {minutes} Min. Zeit.",
|
||||
"phaseOrangeTitle": "🟠 Überfällig: {medName}",
|
||||
"phaseOrangeMessage": "🟠 Überfällig:\n<b>{medName}</b> ({doseInfo}, {timeWindow})\nSeit {minutes} Min. überfällig.",
|
||||
"phaseRedTitle": "🔴 Verpasst: {medName}",
|
||||
"phaseRedMessage": "🔴 Verpasst:\n<b>{medName}</b> ({doseInfo}, {timeWindow})\nAls verpasst markiert.",
|
||||
"buttonTaken": "Genommen",
|
||||
"buttonConfirm": "✓ Bestätigen",
|
||||
"buttonSnoozeOneHour": "🕐 1h",
|
||||
"buttonSnoozeThreeHours": "🕐 3h",
|
||||
"buttonSkip": "⏭ Überspringen"
|
||||
}
|
||||
}
|
||||
+167
-4
@@ -62,7 +62,7 @@
|
||||
"colMin": "Min",
|
||||
"colMax": "Max",
|
||||
"colN": "n",
|
||||
"bpClassificationTitle": "Blood pressure — ESC/ESH classification (2018)",
|
||||
"bpClassificationTitle": "Blood pressure — ESH classification (2023)",
|
||||
"avgBp": "Average blood pressure",
|
||||
"classification": "Classification",
|
||||
"bmiTitle": "Body Mass Index (BMI)",
|
||||
@@ -194,6 +194,8 @@
|
||||
},
|
||||
"welcomeBack": "{greeting}, welcome back.",
|
||||
"welcomeBackWithName": "{greeting} {name}, welcome back.",
|
||||
"greetingSalutation": "Hi, {name}",
|
||||
"streakLabel": "days in a row",
|
||||
"weight": "Weight",
|
||||
"bloodPressure": "Blood Pressure",
|
||||
"bloodPressureSys": "Blood Pressure (Sys)",
|
||||
@@ -600,6 +602,10 @@
|
||||
"refreshAnalysis": "Refresh analysis",
|
||||
"keyTakeaway": "Key Takeaway",
|
||||
"findingsTitle": "Findings",
|
||||
"heroFindingPositive": "Top finding",
|
||||
"heroFindingNeutral": "Top finding",
|
||||
"heroFindingAttention": "Worth attention",
|
||||
"heroFindingWarning": "Top alert",
|
||||
"correlationsTitle": "Correlations",
|
||||
"recommendationsTitle": "Recommendations",
|
||||
"dataFoundation": "Data coverage",
|
||||
@@ -628,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)",
|
||||
@@ -794,11 +800,119 @@
|
||||
"errorMedication": "Could not add the medication \"{name}\". Please try again or add it later in Medications.",
|
||||
"errorTargets": "Could not save your target values. Please try again.",
|
||||
"errorGeneric": "Something went wrong while finishing setup. Please try again.",
|
||||
"medScheduleHint": "We'll add a default reminder window (08:00–09:00 daily). You can adjust it anytime in Medications."
|
||||
"medScheduleHint": "We'll add a default reminder window (08:00–09:00 daily). You can adjust it anytime in Medications.",
|
||||
"v2": {
|
||||
"title": "Welcome to HealthLog",
|
||||
"subtitle": "Three quick steps. You can finish anything later from Settings.",
|
||||
"stepOf": "Step {current} of {total}",
|
||||
"back": "Back",
|
||||
"continue": "Continue",
|
||||
"finish": "Finish setup",
|
||||
"skipStep": "Skip this step — you can finish setup later from Settings.",
|
||||
"step1": {
|
||||
"title": "About you",
|
||||
"description": "Used for blood-pressure targets, BMI, and the right reminders.",
|
||||
"displayNameLabel": "Display name",
|
||||
"displayNameHint": "Shown on the dashboard greeting and in reports.",
|
||||
"languageLabel": "Language",
|
||||
"languageHint": "We'll use this for the app, PDFs, and notifications."
|
||||
},
|
||||
"step2": {
|
||||
"title": "Log your first measurement",
|
||||
"description": "Weight, blood pressure, or pulse — whatever you have right now. The dashboard comes alive with one entry.",
|
||||
"added": "Got it — you can add more anytime from the dashboard.",
|
||||
"skipHint": "No measurement at hand? Skip and log one when you're ready."
|
||||
},
|
||||
"step3": {
|
||||
"title": "Pick one notification channel",
|
||||
"description": "We'll only send what you ask for. You can add more channels or fine-tune events later.",
|
||||
"channelTelegramTitle": "Telegram bot",
|
||||
"channelTelegramHint": "Reminders with snooze buttons inside Telegram.",
|
||||
"channelWebPushTitle": "Web Push",
|
||||
"channelWebPushHint": "Native browser notifications on this device.",
|
||||
"channelNtfyTitle": "ntfy.sh",
|
||||
"channelNtfyHint": "Open-source push to phone or desktop, no account.",
|
||||
"channelNoneTitle": "Skip for now",
|
||||
"channelNoneHint": "I'll set up notifications later from Settings.",
|
||||
"openSettings": "Continue in Settings"
|
||||
},
|
||||
"doneToast": "You're all set — welcome aboard."
|
||||
}
|
||||
},
|
||||
"gettingStarted": {
|
||||
"title": "Getting started",
|
||||
"subtitle": "Quick wins to get the most out of HealthLog.",
|
||||
"progress": "{done} of {total} done",
|
||||
"dismiss": "Dismiss",
|
||||
"dismissAll": "Hide checklist",
|
||||
"dismissTooltip": "Hide this item",
|
||||
"restoreHint": "You can restore the checklist from Settings.",
|
||||
"items": {
|
||||
"profileTitle": "Set your profile",
|
||||
"profileDescription": "Height, date of birth and gender unlock personalised targets.",
|
||||
"profileCta": "Open profile",
|
||||
"measurementTitle": "Log your first measurement",
|
||||
"measurementDescription": "One reading is enough to draw your first trend tile.",
|
||||
"measurementCta": "Add measurement",
|
||||
"medicationTitle": "Add a medication",
|
||||
"medicationDescription": "Track compliance and get reminders right when you need them.",
|
||||
"medicationCta": "Add medication",
|
||||
"withingsTitle": "Connect Withings (optional)",
|
||||
"withingsDescription": "Auto-sync weight, BP and pulse from your Withings devices.",
|
||||
"withingsCta": "Connect",
|
||||
"notificationsTitle": "Configure notifications",
|
||||
"notificationsDescription": "Choose how HealthLog should remind you — Telegram, ntfy or Web Push.",
|
||||
"notificationsCta": "Open notifications"
|
||||
}
|
||||
},
|
||||
"trendHints": {
|
||||
"title": "First trend after 5 readings",
|
||||
"remainingOne": "1 more reading to go.",
|
||||
"remainingMany": "{count} more readings to go."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"subtitle": "Profile, Security & Connections",
|
||||
"shell": {
|
||||
"sectionsNav": "Settings sections"
|
||||
},
|
||||
"sections": {
|
||||
"account": {
|
||||
"title": "Account",
|
||||
"description": "Profile, sign-in methods, and account deletion."
|
||||
},
|
||||
"integrations": {
|
||||
"title": "Integrations",
|
||||
"description": "Withings, moodLog, and other connected services."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"description": "Telegram, ntfy, Web Push."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"description": "Tile layout, thresholds."
|
||||
},
|
||||
"ai": {
|
||||
"title": "AI Insights",
|
||||
"description": "Provider, model, key."
|
||||
},
|
||||
"api": {
|
||||
"title": "API & Tokens",
|
||||
"description": "Bearer tokens for headless clients."
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Advanced",
|
||||
"description": "Export, import, danger zone."
|
||||
},
|
||||
"about": {
|
||||
"title": "About",
|
||||
"description": "Version, license, links."
|
||||
},
|
||||
"placeholder": {
|
||||
"coming_soon": "Moving here with the next release update."
|
||||
}
|
||||
},
|
||||
"sectionsTitle": "Settings",
|
||||
"categoryGeneral": "General",
|
||||
"categoryNotifications": "Notifications",
|
||||
@@ -1039,7 +1153,41 @@
|
||||
"moodLogSyncResult": "{count} entries synchronized",
|
||||
"moodLogSyncFailed": "Sync failed",
|
||||
"moodLogSaved": "moodLog connection saved",
|
||||
"moodLogDisconnected": "moodLog disconnected"
|
||||
"moodLogDisconnected": "moodLog disconnected",
|
||||
"about": {
|
||||
"version": "App version",
|
||||
"gitSha": "Build",
|
||||
"builtAt": "Built {time}",
|
||||
"license": "License",
|
||||
"repository": "Source code",
|
||||
"changelog": "Changelog",
|
||||
"docs": "Documentation",
|
||||
"checkUpdates": "Check for updates",
|
||||
"upToDate": "You are up to date",
|
||||
"newerAvailable": "Newer version: {tag}"
|
||||
},
|
||||
"testConnection": {
|
||||
"test": "Test connection",
|
||||
"testing": "Testing…",
|
||||
"ok": "Connected (latency {latency} ms)",
|
||||
"errors": {
|
||||
"credentials_rejected": "Credentials rejected — check your token",
|
||||
"rate_limited": "Rate-limited by the upstream",
|
||||
"timeout": "Request timed out",
|
||||
"upstream_error": "Upstream returned an error",
|
||||
"connection_failed": "Connection failed",
|
||||
"not_configured": "Not configured",
|
||||
"url_not_public": "URL is not a public endpoint",
|
||||
"url_invalid": "URL is invalid",
|
||||
"redirected": "Endpoint redirects — check the URL",
|
||||
"endpoint_not_found": "Endpoint not found at the URL",
|
||||
"credentials_unreadable": "Credentials cannot be decrypted",
|
||||
"upstream_invalid_json": "Upstream sent invalid JSON",
|
||||
"vapid_not_configured": "Web Push not configured (VAPID keys missing)",
|
||||
"rate_limited_self": "Too many test requests",
|
||||
"generic": "Test failed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
@@ -1629,5 +1777,20 @@
|
||||
"medicationNotFoundExact": "Medication not found. Please send the name exactly as in HealthLog.",
|
||||
"helpHeader": "<b>Available commands:</b>",
|
||||
"helpBody": "/help — show this help\n/add — record an intake\n/start — start the bot\n\n<b>Text commands:</b>\ntaken <name> — confirm an intake\n\n<b>Buttons in reminders:</b>\n• Taken — confirm intake\n• 🕐 1h / 🕐 3h — postpone reminder\n• ⏭ Skip — skip intake\n• ✓ Confirm — confirm a missed intake"
|
||||
},
|
||||
"medicationReminders": {
|
||||
"phaseGreenTitle": "🟢 Reminder: {medName}",
|
||||
"phaseGreenMessage": "🟢 Reminder:\n<b>{medName}</b> ({doseInfo}, {timeWindow})\nWindow closes in {minutes} min.",
|
||||
"phaseYellowTitle": "🟡 Due soon: {medName}",
|
||||
"phaseYellowMessage": "🟡 Due soon:\n<b>{medName}</b> ({doseInfo}, {timeWindow})\n{minutes} min left.",
|
||||
"phaseOrangeTitle": "🟠 Overdue: {medName}",
|
||||
"phaseOrangeMessage": "🟠 Overdue:\n<b>{medName}</b> ({doseInfo}, {timeWindow})\nOverdue by {minutes} min.",
|
||||
"phaseRedTitle": "🔴 Missed: {medName}",
|
||||
"phaseRedMessage": "🔴 Missed:\n<b>{medName}</b> ({doseInfo}, {timeWindow})\nMarked as missed.",
|
||||
"buttonTaken": "Taken",
|
||||
"buttonConfirm": "✓ Confirm",
|
||||
"buttonSnoozeOneHour": "🕐 1h",
|
||||
"buttonSnoozeThreeHours": "🕐 3h",
|
||||
"buttonSkip": "⏭ Skip"
|
||||
}
|
||||
}
|
||||
+11
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.3.3",
|
||||
"version": "1.4.2",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.31.0",
|
||||
"scripts": {
|
||||
@@ -17,9 +17,13 @@
|
||||
"format:check": "prettier --check .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
"test:watch": "vitest",
|
||||
"test:integration": "vitest run --config vitest.integration.config.mts",
|
||||
"e2e": "playwright test",
|
||||
"e2e:ui": "playwright test --ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1045.0",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"@prisma/adapter-pg": "^7.8.0",
|
||||
@@ -49,12 +53,16 @@
|
||||
"zxcvbn-typescript": "^5.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.11.3",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@testcontainers/postgresql": "^11.14.0",
|
||||
"@types/node": "^25",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.4",
|
||||
"pdf-parse": "^2.4.5",
|
||||
@@ -62,6 +70,7 @@
|
||||
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||
"shadcn": "^4.6.0",
|
||||
"tailwindcss": "^4",
|
||||
"testcontainers": "^11.14.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^6",
|
||||
"vitest": "^4.1.5"
|
||||
|
||||
@@ -0,0 +1,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
+2326
-21
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,47 @@
|
||||
-- 0025_refresh_tokens
|
||||
-- Native-client short-lived access + rotating refresh tokens (v1.4 G4).
|
||||
-- Forward-compatible: this migration only ADDS a table. No DROP COLUMN, no
|
||||
-- type changes on existing tables. To roll back, the down migration in
|
||||
-- docs/ops/migrations/0025-down.sql DROPs the table.
|
||||
--
|
||||
-- All statements use IF NOT EXISTS so a partial-deploy retry is a no-op
|
||||
-- instead of "relation already exists". Wrapped in a single transaction
|
||||
-- so a mid-statement failure leaves no half-built table behind.
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "refresh_tokens" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"token_hash" TEXT NOT NULL,
|
||||
"device_id" TEXT,
|
||||
"access_token_hash" TEXT,
|
||||
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"revoked_at" TIMESTAMP(3),
|
||||
"used_at" TIMESTAMP(3),
|
||||
"replaced_by_id" TEXT,
|
||||
"user_agent" TEXT,
|
||||
"ip_address" TEXT,
|
||||
CONSTRAINT "refresh_tokens_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "refresh_tokens_token_hash_key" ON "refresh_tokens"("token_hash");
|
||||
CREATE INDEX IF NOT EXISTS "refresh_tokens_user_id_idx" ON "refresh_tokens"("user_id");
|
||||
CREATE INDEX IF NOT EXISTS "refresh_tokens_user_id_device_id_idx" ON "refresh_tokens"("user_id", "device_id");
|
||||
CREATE INDEX IF NOT EXISTS "refresh_tokens_expires_at_idx" ON "refresh_tokens"("expires_at");
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE constraint_name = 'refresh_tokens_user_id_fkey'
|
||||
) THEN
|
||||
ALTER TABLE "refresh_tokens"
|
||||
ADD CONSTRAINT "refresh_tokens_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,14 @@
|
||||
-- 0025_user_locale_drift_fix
|
||||
-- The `users.locale` column has been on `prisma/schema.prisma` since the
|
||||
-- locale-aware reminder work in v1.3 but never landed in the migration
|
||||
-- history (it must have been applied via `prisma db push` to dev / prod
|
||||
-- but not committed as a migration). The schema drift is invisible until
|
||||
-- a fresh database is built from migrations alone — at which point the
|
||||
-- Prisma client tries to read `users.locale` and Postgres reports
|
||||
-- `column users.locale does not exist`.
|
||||
--
|
||||
-- This migration backfills the column with `IF NOT EXISTS` so it is a
|
||||
-- safe no-op against any database that was kept in sync via `db push`,
|
||||
-- and a clean add against any environment built strictly from this
|
||||
-- migrations directory (CI test containers, fresh deploys, etc.).
|
||||
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "locale" TEXT;
|
||||
@@ -90,6 +90,7 @@ model User {
|
||||
medications Medication[]
|
||||
intakeEvents MedicationIntakeEvent[]
|
||||
apiTokens ApiToken[]
|
||||
refreshTokens RefreshToken[]
|
||||
auditLogs AuditLog[]
|
||||
withingsConnection WithingsConnection?
|
||||
moodEntries MoodEntry[]
|
||||
@@ -357,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 {
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* scripts/restore-backup.ts <s3-key> [output-file]
|
||||
*
|
||||
* Downloads a single off-host backup object, decrypts it with
|
||||
* `BACKUP_ENCRYPTION_KEY`, and writes the JSON dump to disk.
|
||||
*
|
||||
* Wire format (binary):
|
||||
* magic(4)="HLBK" || version(1)=0x01 || iv(12) || tag(16) || ciphertext
|
||||
*
|
||||
* Usage:
|
||||
* BACKUP_S3_ENDPOINT=... \
|
||||
* BACKUP_S3_BUCKET=... \
|
||||
* BACKUP_S3_ACCESS_KEY=... \
|
||||
* BACKUP_S3_SECRET_KEY=... \
|
||||
* BACKUP_S3_REGION=auto \
|
||||
* BACKUP_ENCRYPTION_KEY=<hex64> \
|
||||
* pnpm tsx scripts/restore-backup.ts 2026-05-08/user-clx123.json.enc /tmp/restored.json
|
||||
*/
|
||||
import "dotenv/config";
|
||||
import { writeFileSync } from "node:fs";
|
||||
import {
|
||||
loadOffhostConfig,
|
||||
getS3Client,
|
||||
decryptBackup,
|
||||
OffhostBackupNotConfiguredError,
|
||||
} from "@/lib/jobs/offhost-backup";
|
||||
|
||||
/**
|
||||
* Schema-validate the decrypted JSON before writing it to disk. The wire
|
||||
* format from `runOffhostBackup` always carries `userId` and the four data
|
||||
* arrays — anything else means the operator may be looking at a tampered or
|
||||
* mis-targeted object. We reject rather than silently writing a body that a
|
||||
* later import script would trust.
|
||||
*/
|
||||
function validateBackupShape(payload: unknown): {
|
||||
exportedAt: string;
|
||||
userId: string;
|
||||
measurements: unknown[];
|
||||
medications: unknown[];
|
||||
intakeEvents: unknown[];
|
||||
moodEntries: unknown[];
|
||||
} {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
throw new Error("backup payload is not a JSON object");
|
||||
}
|
||||
const p = payload as Record<string, unknown>;
|
||||
const required = [
|
||||
"exportedAt",
|
||||
"userId",
|
||||
"measurements",
|
||||
"medications",
|
||||
"intakeEvents",
|
||||
"moodEntries",
|
||||
] as const;
|
||||
for (const k of required) {
|
||||
if (!(k in p)) throw new Error(`backup is missing required field: ${k}`);
|
||||
}
|
||||
if (typeof p.exportedAt !== "string")
|
||||
throw new Error("backup.exportedAt must be string");
|
||||
if (typeof p.userId !== "string" || p.userId.length === 0)
|
||||
throw new Error("backup.userId must be a non-empty string");
|
||||
for (const k of [
|
||||
"measurements",
|
||||
"medications",
|
||||
"intakeEvents",
|
||||
"moodEntries",
|
||||
] as const) {
|
||||
if (!Array.isArray(p[k])) throw new Error(`backup.${k} must be an array`);
|
||||
}
|
||||
return p as ReturnType<typeof validateBackupShape>;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const key = process.argv[2];
|
||||
const out = process.argv[3] ?? "./restored.json";
|
||||
// Optional positional arg: --user-id=<id> requires the dump's `userId`
|
||||
// field to match. Defends against an operator pointing the script at the
|
||||
// wrong S3 key (e.g. another tenant's dump under a similar path) and
|
||||
// silently importing the wrong user's data downstream.
|
||||
const expectedUserId = process.argv
|
||||
.find((a) => a.startsWith("--user-id="))
|
||||
?.slice("--user-id=".length);
|
||||
if (!key) {
|
||||
console.error(
|
||||
"Usage: pnpm tsx scripts/restore-backup.ts <s3-key> [output-file] [--user-id=<id>]",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const cfg = loadOffhostConfig();
|
||||
if (!cfg) {
|
||||
throw new OffhostBackupNotConfiguredError(
|
||||
"Off-host backup is not configured. Set BACKUP_* env vars.",
|
||||
);
|
||||
}
|
||||
|
||||
const s3 = await getS3Client(cfg);
|
||||
console.log(
|
||||
`Downloading s3://${cfg.bucket}/${key} from ${cfg.endpoint} (region=${cfg.region})`,
|
||||
);
|
||||
const ciphertext = await s3.getObject(key);
|
||||
const plaintext = decryptBackup(ciphertext, cfg.encryptionKey);
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(plaintext);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Decrypted payload is not valid JSON: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
const body = validateBackupShape(parsed);
|
||||
if (expectedUserId && body.userId !== expectedUserId) {
|
||||
throw new Error(
|
||||
`Refusing to write: backup.userId='${body.userId}' does not match --user-id='${expectedUserId}'`,
|
||||
);
|
||||
}
|
||||
|
||||
writeFileSync(out, plaintext, "utf8");
|
||||
console.log(
|
||||
`Restored userId=${body.userId} (${plaintext.length} bytes) -> ${out}`,
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Restore failed:", err);
|
||||
process.exit(2);
|
||||
});
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* scripts/rotate-encryption-key.ts <new-key-id>
|
||||
*
|
||||
* Re-encrypts every encrypted column in the database with the currently
|
||||
* active key (`ENCRYPTION_ACTIVE_KEY_ID`). Idempotent: rows whose ciphertext
|
||||
* already carries the active key id prefix are skipped.
|
||||
*
|
||||
* Usage:
|
||||
* ENCRYPTION_KEYS='{"v1":"<old>","v2":"<new>"}' \
|
||||
* ENCRYPTION_ACTIVE_KEY_ID=v2 \
|
||||
* pnpm tsx scripts/rotate-encryption-key.ts v2
|
||||
*/
|
||||
import "dotenv/config";
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import { decrypt, encrypt, extractKeyId, getActiveKeyId } from "@/lib/crypto";
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
console.error("DATABASE_URL must be set");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const targetKeyId = process.argv[2] ?? getActiveKeyId();
|
||||
if (targetKeyId !== getActiveKeyId()) {
|
||||
console.error(
|
||||
`Refusing to rotate: argv key id '${targetKeyId}' does not match the ` +
|
||||
`currently active id '${getActiveKeyId()}'. Set ENCRYPTION_ACTIVE_KEY_ID first.`,
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const prisma = new PrismaClient({
|
||||
adapter: new PrismaPg({ connectionString: DATABASE_URL }),
|
||||
});
|
||||
|
||||
interface RotationResult {
|
||||
table: string;
|
||||
field: string;
|
||||
scanned: number;
|
||||
rotated: number;
|
||||
errors: number;
|
||||
}
|
||||
|
||||
function shouldRotate(value: string | null): boolean {
|
||||
if (!value) return false;
|
||||
const id = extractKeyId(value);
|
||||
// null = legacy/unversioned (rotate); else rotate if not already on active.
|
||||
return id !== getActiveKeyId();
|
||||
}
|
||||
|
||||
async function rotateField<T extends { id: string }>(
|
||||
table: string,
|
||||
field: string,
|
||||
rows: T[],
|
||||
fieldGetter: (row: T) => string | null,
|
||||
updater: (id: string, ciphertext: string) => Promise<void>,
|
||||
): Promise<RotationResult> {
|
||||
const result: RotationResult = {
|
||||
table,
|
||||
field,
|
||||
scanned: rows.length,
|
||||
rotated: 0,
|
||||
errors: 0,
|
||||
};
|
||||
for (const row of rows) {
|
||||
const v = fieldGetter(row);
|
||||
if (!shouldRotate(v)) continue;
|
||||
try {
|
||||
const re = encrypt(decrypt(v as string));
|
||||
await updater(row.id, re);
|
||||
result.rotated++;
|
||||
} catch (err) {
|
||||
result.errors++;
|
||||
console.error(
|
||||
`[${table}.${field}] row ${row.id}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`Rotating encrypted rows to active key id: ${targetKeyId}`);
|
||||
const results: RotationResult[] = [];
|
||||
|
||||
// ───── User table ─────
|
||||
const users = await prisma.user.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
codexAccessTokenEncrypted: true,
|
||||
codexRefreshTokenEncrypted: true,
|
||||
aiAnthropicKeyEncrypted: true,
|
||||
aiLocalKeyEncrypted: true,
|
||||
moodLogWebhookSecret: true,
|
||||
telegramBotToken: true,
|
||||
moodLogUrlEncrypted: true,
|
||||
moodLogApiKeyEncrypted: true,
|
||||
withingsClientIdEncrypted: true,
|
||||
withingsClientSecretEncrypted: true,
|
||||
},
|
||||
});
|
||||
|
||||
const userFields: Array<keyof (typeof users)[number]> = [
|
||||
"codexAccessTokenEncrypted",
|
||||
"codexRefreshTokenEncrypted",
|
||||
"aiAnthropicKeyEncrypted",
|
||||
"aiLocalKeyEncrypted",
|
||||
"moodLogWebhookSecret",
|
||||
"telegramBotToken",
|
||||
"moodLogUrlEncrypted",
|
||||
"moodLogApiKeyEncrypted",
|
||||
"withingsClientIdEncrypted",
|
||||
"withingsClientSecretEncrypted",
|
||||
];
|
||||
for (const field of userFields) {
|
||||
const r = await rotateField(
|
||||
"User",
|
||||
field as string,
|
||||
users,
|
||||
(u) => (u as Record<string, unknown>)[field as string] as string | null,
|
||||
async (id, ciphertext) => {
|
||||
await prisma.user.update({
|
||||
where: { id },
|
||||
data: { [field]: ciphertext } as Record<string, string>,
|
||||
});
|
||||
},
|
||||
);
|
||||
results.push(r);
|
||||
}
|
||||
|
||||
// ───── WithingsConnection table (accessToken / refreshToken) ─────
|
||||
const withings = await prisma.withingsConnection.findMany({
|
||||
select: { id: true, accessToken: true, refreshToken: true },
|
||||
});
|
||||
for (const field of ["accessToken", "refreshToken"] as const) {
|
||||
const r = await rotateField(
|
||||
"WithingsConnection",
|
||||
field,
|
||||
withings,
|
||||
(w) => w[field],
|
||||
async (id, ciphertext) => {
|
||||
await prisma.withingsConnection.update({
|
||||
where: { id },
|
||||
data: { [field]: ciphertext } as Record<string, string>,
|
||||
});
|
||||
},
|
||||
);
|
||||
results.push(r);
|
||||
}
|
||||
|
||||
// ───── AppSettings table (singleton typically) ─────
|
||||
const settings = await prisma.appSettings.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
adminAiKeyEncrypted: true,
|
||||
webPushVapidPrivateKeyEncrypted: true,
|
||||
githubIssueTokenEncrypted: true,
|
||||
},
|
||||
});
|
||||
const appFields = [
|
||||
"adminAiKeyEncrypted",
|
||||
"webPushVapidPrivateKeyEncrypted",
|
||||
"githubIssueTokenEncrypted",
|
||||
] as const;
|
||||
for (const field of appFields) {
|
||||
const r = await rotateField(
|
||||
"AppSettings",
|
||||
field,
|
||||
settings,
|
||||
(s) => s[field],
|
||||
async (id, ciphertext) => {
|
||||
await prisma.appSettings.update({
|
||||
where: { id },
|
||||
data: { [field]: ciphertext } as Record<string, string>,
|
||||
});
|
||||
},
|
||||
);
|
||||
results.push(r);
|
||||
}
|
||||
|
||||
// ───── NotificationChannel.config ─────
|
||||
// Channel config (Telegram chat id, ntfy topic, etc.) is encrypted JSON.
|
||||
// Skipping these on rotation would leave channels permanently undecryptable
|
||||
// once the operator drops `v1` from the key map.
|
||||
const channels = await prisma.notificationChannel.findMany({
|
||||
select: { id: true, config: true },
|
||||
});
|
||||
results.push(
|
||||
await rotateField(
|
||||
"NotificationChannel",
|
||||
"config",
|
||||
channels,
|
||||
(c) => c.config,
|
||||
async (id, ciphertext) => {
|
||||
await prisma.notificationChannel.update({
|
||||
where: { id },
|
||||
data: { config: ciphertext },
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// ───── PushSubscription.{p256dh, auth} ─────
|
||||
// Web-push routing secrets — without these, the push endpoint is reachable
|
||||
// but the browser ignores the message (auth tag mismatch).
|
||||
const subs = await prisma.pushSubscription.findMany({
|
||||
select: { id: true, p256dh: true, auth: true },
|
||||
});
|
||||
for (const field of ["p256dh", "auth"] as const) {
|
||||
results.push(
|
||||
await rotateField(
|
||||
"PushSubscription",
|
||||
field,
|
||||
subs,
|
||||
(s) => s[field],
|
||||
async (id, ciphertext) => {
|
||||
await prisma.pushSubscription.update({
|
||||
where: { id },
|
||||
data: { [field]: ciphertext } as Record<string, string>,
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
console.log("\n=== Rotation summary ===");
|
||||
let totalRotated = 0;
|
||||
let totalErrors = 0;
|
||||
for (const r of results) {
|
||||
console.log(
|
||||
`${r.table}.${r.field}: scanned=${r.scanned} rotated=${r.rotated} errors=${r.errors}`,
|
||||
);
|
||||
totalRotated += r.rotated;
|
||||
totalErrors += r.errors;
|
||||
}
|
||||
console.log(`\nTOTAL rotated=${totalRotated} errors=${totalErrors}`);
|
||||
await prisma.$disconnect();
|
||||
process.exit(totalErrors > 0 ? 3 : 0);
|
||||
}
|
||||
|
||||
main().catch(async (err) => {
|
||||
console.error("Rotation failed:", err);
|
||||
await prisma.$disconnect();
|
||||
process.exit(1);
|
||||
});
|
||||
+17
-2639
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* POST /api/admin/backup/test — admin-only smoke test for the off-host
|
||||
* backup target. Issues a 1-byte PUT + GET roundtrip and returns the
|
||||
* endpoint, bucket, region, and per-call latency. Credentials are NEVER
|
||||
* returned; only the endpoint host + bucket name are surfaced.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
runOffhostRoundtripTest,
|
||||
OffhostBackupNotConfiguredError,
|
||||
} from "@/lib/jobs/offhost-backup";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
void request;
|
||||
await requireAdmin();
|
||||
try {
|
||||
const report = await runOffhostRoundtripTest();
|
||||
annotate({
|
||||
action: { name: "admin.backup.test" },
|
||||
meta: {
|
||||
ok: report.ok,
|
||||
put_latency_ms: report.putLatencyMs,
|
||||
get_latency_ms: report.getLatencyMs,
|
||||
},
|
||||
});
|
||||
return apiSuccess(report);
|
||||
} catch (err) {
|
||||
if (err instanceof OffhostBackupNotConfiguredError) {
|
||||
return apiError(err.message, 400);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,437 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
appSettings: {
|
||||
findUnique: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/lib/api-handler")>(
|
||||
"@/lib/api-handler",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
|
||||
h: T,
|
||||
): T => h,
|
||||
requireAdmin: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
encrypt: vi.fn((value: string) => `enc(${value})`),
|
||||
decrypt: vi.fn((value: string) => value.replace(/^enc\(|\)$/g, "")),
|
||||
}));
|
||||
|
||||
import { GET, PUT } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAdmin, HttpError } from "@/lib/api-handler";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
const ADMIN_CTX = {
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "admin-1",
|
||||
username: "admin",
|
||||
role: "ADMIN",
|
||||
} as never,
|
||||
};
|
||||
|
||||
function jsonReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/admin/settings", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
|
||||
});
|
||||
|
||||
describe("GET /api/admin/settings", () => {
|
||||
it("rejects with 401 when no session", async () => {
|
||||
vi.mocked(requireAdmin).mockRejectedValue(
|
||||
new HttpError(401, "Not authenticated"),
|
||||
);
|
||||
await expect(GET()).rejects.toThrow("Not authenticated");
|
||||
});
|
||||
|
||||
it("rejects with 403 for non-admin", async () => {
|
||||
vi.mocked(requireAdmin).mockRejectedValue(
|
||||
new HttpError(403, "Admin access required"),
|
||||
);
|
||||
await expect(GET()).rejects.toThrow("Admin access required");
|
||||
});
|
||||
|
||||
it("reads the singleton row (regression: not the default-typo)", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null);
|
||||
await GET();
|
||||
expect(prisma.appSettings.findUnique).toHaveBeenCalledTimes(1);
|
||||
const args = vi.mocked(prisma.appSettings.findUnique).mock.calls[0]?.[0];
|
||||
// Must be "singleton" — the test-endpoints PR previously typo'd this as "default".
|
||||
expect(args).toEqual({ where: { id: "singleton" } });
|
||||
});
|
||||
|
||||
it("returns sane defaults when no settings row exists", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null);
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: Record<string, unknown> };
|
||||
expect(body.data.registrationEnabled).toBe(true);
|
||||
expect(body.data.defaultLocale).toBe("de");
|
||||
expect(body.data.webPushVapidConfigured).toBe(false);
|
||||
expect(body.data.bugReportConfigured).toBe(false);
|
||||
expect(body.data.glitchtipEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it("never leaks the encrypted private key fields", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
id: "singleton",
|
||||
registrationEnabled: false,
|
||||
defaultLocale: "en",
|
||||
telegramGlobal: true,
|
||||
ntfyGlobal: true,
|
||||
webPushGlobal: true,
|
||||
webPushVapidPublicKey: "pub",
|
||||
webPushVapidSubject: "mailto:a@b.com",
|
||||
webPushVapidPrivateKeyEncrypted: "secret-blob",
|
||||
apiGlobal: true,
|
||||
umamiEnabled: false,
|
||||
umamiScriptUrl: null,
|
||||
umamiWebsiteId: null,
|
||||
glitchtipEnabled: false,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: "production",
|
||||
githubIssueRepo: "owner/repo",
|
||||
githubIssueTokenEncrypted: "token-blob",
|
||||
reminderLateMinutes: 120,
|
||||
reminderMissedMinutes: 240,
|
||||
moodLogGlobal: true,
|
||||
} as never);
|
||||
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as { data: Record<string, unknown> };
|
||||
// The two configured-flags must be true …
|
||||
expect(body.data.webPushVapidConfigured).toBe(true);
|
||||
expect(body.data.bugReportConfigured).toBe(true);
|
||||
// … but raw encrypted blobs must never reach the client.
|
||||
const serialized = JSON.stringify(body.data);
|
||||
expect(serialized).not.toContain("secret-blob");
|
||||
expect(serialized).not.toContain("token-blob");
|
||||
expect(body.data).not.toHaveProperty("webPushVapidPrivateKeyEncrypted");
|
||||
expect(body.data).not.toHaveProperty("githubIssueTokenEncrypted");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/admin/settings", () => {
|
||||
it("rejects with 403 for non-admin", async () => {
|
||||
vi.mocked(requireAdmin).mockRejectedValue(
|
||||
new HttpError(403, "Admin access required"),
|
||||
);
|
||||
await expect(PUT(jsonReq({}))).rejects.toThrow("Admin access required");
|
||||
});
|
||||
|
||||
it("returns 422 for an unknown field (strict schema)", async () => {
|
||||
const res = await PUT(jsonReq({ totallyBogus: true }));
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.appSettings.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 422 for invalid Glitchtip DSN URL", async () => {
|
||||
const res = await PUT(jsonReq({ glitchtipDsn: "not a url" }));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("returns 422 when the body has no actionable fields", async () => {
|
||||
const res = await PUT(jsonReq({}));
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toBe("No valid fields");
|
||||
});
|
||||
|
||||
it("encrypts the VAPID private key before persisting", async () => {
|
||||
vi.mocked(prisma.appSettings.upsert).mockResolvedValue({
|
||||
id: "singleton",
|
||||
registrationEnabled: true,
|
||||
defaultLocale: "en",
|
||||
telegramGlobal: true,
|
||||
ntfyGlobal: true,
|
||||
webPushGlobal: true,
|
||||
webPushVapidPublicKey: null,
|
||||
webPushVapidSubject: null,
|
||||
webPushVapidPrivateKeyEncrypted: "enc(plain-private)",
|
||||
apiGlobal: true,
|
||||
umamiEnabled: false,
|
||||
umamiScriptUrl: null,
|
||||
umamiWebsiteId: null,
|
||||
glitchtipEnabled: false,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: "production",
|
||||
githubIssueRepo: null,
|
||||
githubIssueTokenEncrypted: null,
|
||||
reminderLateMinutes: 120,
|
||||
reminderMissedMinutes: 240,
|
||||
moodLogGlobal: true,
|
||||
} as never);
|
||||
|
||||
const res = await PUT(jsonReq({ webPushVapidPrivateKey: "plain-private" }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(encrypt).toHaveBeenCalledWith("plain-private");
|
||||
|
||||
const upsertArgs = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0];
|
||||
expect(upsertArgs?.where).toEqual({ id: "singleton" });
|
||||
expect(upsertArgs?.update).toEqual({
|
||||
webPushVapidPrivateKeyEncrypted: "enc(plain-private)",
|
||||
});
|
||||
// Audit detail must NOT carry the plaintext private key.
|
||||
expect(auditLog).toHaveBeenCalled();
|
||||
const auditDetails = vi.mocked(auditLog).mock.calls[0]?.[1]
|
||||
?.details as Record<string, unknown>;
|
||||
expect(JSON.stringify(auditDetails)).not.toContain("plain-private");
|
||||
expect(auditDetails.webPushVapidPrivateKeyUpdated).toBe(true);
|
||||
});
|
||||
|
||||
it("clears the encrypted private key when clear flag is set", async () => {
|
||||
vi.mocked(prisma.appSettings.upsert).mockResolvedValue({
|
||||
id: "singleton",
|
||||
registrationEnabled: true,
|
||||
defaultLocale: "en",
|
||||
telegramGlobal: true,
|
||||
ntfyGlobal: true,
|
||||
webPushGlobal: true,
|
||||
webPushVapidPublicKey: null,
|
||||
webPushVapidSubject: null,
|
||||
webPushVapidPrivateKeyEncrypted: null,
|
||||
apiGlobal: true,
|
||||
umamiEnabled: false,
|
||||
umamiScriptUrl: null,
|
||||
umamiWebsiteId: null,
|
||||
glitchtipEnabled: false,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: null,
|
||||
githubIssueRepo: null,
|
||||
githubIssueTokenEncrypted: null,
|
||||
reminderLateMinutes: 120,
|
||||
reminderMissedMinutes: 240,
|
||||
moodLogGlobal: true,
|
||||
} as never);
|
||||
const res = await PUT(jsonReq({ clearWebPushVapidPrivateKey: true }));
|
||||
expect(res.status).toBe(200);
|
||||
const args = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0];
|
||||
expect(args?.update).toEqual({ webPushVapidPrivateKeyEncrypted: null });
|
||||
});
|
||||
|
||||
it("normalises a Umami URL with no path to /script.js", async () => {
|
||||
vi.mocked(prisma.appSettings.upsert).mockResolvedValue({
|
||||
id: "singleton",
|
||||
registrationEnabled: true,
|
||||
defaultLocale: "en",
|
||||
telegramGlobal: true,
|
||||
ntfyGlobal: true,
|
||||
webPushGlobal: true,
|
||||
webPushVapidPublicKey: null,
|
||||
webPushVapidSubject: null,
|
||||
webPushVapidPrivateKeyEncrypted: null,
|
||||
apiGlobal: true,
|
||||
umamiEnabled: false,
|
||||
umamiScriptUrl: "https://analytics.example.com/script.js",
|
||||
umamiWebsiteId: null,
|
||||
glitchtipEnabled: false,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: null,
|
||||
githubIssueRepo: null,
|
||||
githubIssueTokenEncrypted: null,
|
||||
reminderLateMinutes: 120,
|
||||
reminderMissedMinutes: 240,
|
||||
moodLogGlobal: true,
|
||||
} as never);
|
||||
|
||||
const res = await PUT(
|
||||
jsonReq({ umamiScriptUrl: "https://analytics.example.com" }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const args = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0];
|
||||
expect(args?.update).toEqual({
|
||||
umamiScriptUrl: "https://analytics.example.com/script.js",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalises and persists every Zod-allowed field in a single PUT", async () => {
|
||||
vi.mocked(prisma.appSettings.upsert).mockResolvedValue({
|
||||
id: "singleton",
|
||||
registrationEnabled: false,
|
||||
defaultLocale: "en",
|
||||
telegramGlobal: false,
|
||||
ntfyGlobal: false,
|
||||
webPushGlobal: false,
|
||||
webPushVapidPublicKey: "vapid-pub",
|
||||
webPushVapidSubject: "mailto:ops@healthlog.dev",
|
||||
webPushVapidPrivateKeyEncrypted: "enc(secret)",
|
||||
apiGlobal: false,
|
||||
umamiEnabled: true,
|
||||
umamiScriptUrl: "https://analytics.example.com/custom.js",
|
||||
umamiWebsiteId: "site-uuid",
|
||||
glitchtipEnabled: true,
|
||||
glitchtipDsn: "https://abc@glitchtip.example.com/1",
|
||||
glitchtipEnvironment: "staging",
|
||||
githubIssueRepo: "owner/repo",
|
||||
githubIssueTokenEncrypted: "enc(gh-token)",
|
||||
reminderLateMinutes: 30,
|
||||
reminderMissedMinutes: 90,
|
||||
moodLogGlobal: false,
|
||||
} as never);
|
||||
|
||||
const res = await PUT(
|
||||
jsonReq({
|
||||
registrationEnabled: false,
|
||||
defaultLocale: "en",
|
||||
telegramGlobal: false,
|
||||
ntfyGlobal: false,
|
||||
webPushGlobal: false,
|
||||
apiGlobal: false,
|
||||
umamiEnabled: true,
|
||||
moodLogGlobal: false,
|
||||
glitchtipEnabled: true,
|
||||
webPushVapidPublicKey: "vapid-pub",
|
||||
webPushVapidSubject: "mailto:ops@healthlog.dev",
|
||||
webPushVapidPrivateKey: "secret",
|
||||
umamiScriptUrl: "https://analytics.example.com/custom.js",
|
||||
umamiWebsiteId: "site-uuid",
|
||||
glitchtipDsn: "https://abc@glitchtip.example.com/1",
|
||||
glitchtipEnvironment: "staging",
|
||||
bugReportRepo: "owner/repo",
|
||||
bugReportToken: "gh-token",
|
||||
reminderLateMinutes: 30,
|
||||
reminderMissedMinutes: 90,
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const upsert = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0];
|
||||
expect(upsert?.update).toMatchObject({
|
||||
registrationEnabled: false,
|
||||
defaultLocale: "en",
|
||||
telegramGlobal: false,
|
||||
apiGlobal: false,
|
||||
glitchtipEnabled: true,
|
||||
umamiEnabled: true,
|
||||
umamiScriptUrl: "https://analytics.example.com/custom.js",
|
||||
umamiWebsiteId: "site-uuid",
|
||||
glitchtipDsn: "https://abc@glitchtip.example.com/1",
|
||||
glitchtipEnvironment: "staging",
|
||||
githubIssueRepo: "owner/repo",
|
||||
githubIssueTokenEncrypted: "enc(gh-token)",
|
||||
webPushVapidPublicKey: "vapid-pub",
|
||||
webPushVapidSubject: "mailto:ops@healthlog.dev",
|
||||
webPushVapidPrivateKeyEncrypted: "enc(secret)",
|
||||
reminderLateMinutes: 30,
|
||||
reminderMissedMinutes: 90,
|
||||
});
|
||||
expect(encrypt).toHaveBeenCalledWith("secret");
|
||||
expect(encrypt).toHaveBeenCalledWith("gh-token");
|
||||
});
|
||||
|
||||
it("clears nullable string fields when an empty string is submitted", async () => {
|
||||
vi.mocked(prisma.appSettings.upsert).mockResolvedValue({
|
||||
id: "singleton",
|
||||
registrationEnabled: true,
|
||||
defaultLocale: "en",
|
||||
telegramGlobal: true,
|
||||
ntfyGlobal: true,
|
||||
webPushGlobal: true,
|
||||
webPushVapidPublicKey: null,
|
||||
webPushVapidSubject: null,
|
||||
webPushVapidPrivateKeyEncrypted: null,
|
||||
apiGlobal: true,
|
||||
umamiEnabled: false,
|
||||
umamiScriptUrl: null,
|
||||
umamiWebsiteId: null,
|
||||
glitchtipEnabled: false,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: null,
|
||||
githubIssueRepo: null,
|
||||
githubIssueTokenEncrypted: null,
|
||||
reminderLateMinutes: 120,
|
||||
reminderMissedMinutes: 240,
|
||||
moodLogGlobal: true,
|
||||
} as never);
|
||||
|
||||
await PUT(
|
||||
jsonReq({
|
||||
webPushVapidPublicKey: "",
|
||||
webPushVapidSubject: "",
|
||||
umamiScriptUrl: "",
|
||||
umamiWebsiteId: "",
|
||||
glitchtipDsn: "",
|
||||
glitchtipEnvironment: "",
|
||||
bugReportRepo: "",
|
||||
}),
|
||||
);
|
||||
const upsert = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0];
|
||||
expect(upsert?.update).toEqual({
|
||||
webPushVapidPublicKey: null,
|
||||
webPushVapidSubject: null,
|
||||
umamiScriptUrl: null,
|
||||
umamiWebsiteId: null,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: null,
|
||||
githubIssueRepo: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("clears the encrypted bug-report token when clear flag is set", async () => {
|
||||
vi.mocked(prisma.appSettings.upsert).mockResolvedValue({
|
||||
id: "singleton",
|
||||
registrationEnabled: true,
|
||||
defaultLocale: "en",
|
||||
telegramGlobal: true,
|
||||
ntfyGlobal: true,
|
||||
webPushGlobal: true,
|
||||
webPushVapidPublicKey: null,
|
||||
webPushVapidSubject: null,
|
||||
webPushVapidPrivateKeyEncrypted: null,
|
||||
apiGlobal: true,
|
||||
umamiEnabled: false,
|
||||
umamiScriptUrl: null,
|
||||
umamiWebsiteId: null,
|
||||
glitchtipEnabled: false,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: null,
|
||||
githubIssueRepo: null,
|
||||
githubIssueTokenEncrypted: null,
|
||||
reminderLateMinutes: 120,
|
||||
reminderMissedMinutes: 240,
|
||||
moodLogGlobal: true,
|
||||
} as never);
|
||||
await PUT(jsonReq({ clearBugReportToken: true }));
|
||||
const upsert = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0];
|
||||
expect(upsert?.update).toEqual({ githubIssueTokenEncrypted: null });
|
||||
});
|
||||
|
||||
it("returns 415 when content-type is not JSON", async () => {
|
||||
const r = new NextRequest("http://localhost/api/admin/settings", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "text/plain" },
|
||||
body: "{}",
|
||||
});
|
||||
const res = await PUT(r);
|
||||
expect(res.status).toBe(415);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { count: vi.fn() },
|
||||
withingsConnection: { count: vi.fn() },
|
||||
notificationChannel: { count: vi.fn() },
|
||||
pushSubscription: { count: vi.fn() },
|
||||
appSettings: { findUnique: vi.fn() },
|
||||
auditLog: { findFirst: vi.fn(), count: vi.fn() },
|
||||
dataBackup: { findFirst: vi.fn(), findMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/lib/api-handler")>(
|
||||
"@/lib/api-handler",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
|
||||
h: T,
|
||||
): T => h,
|
||||
requireAdmin: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/jobs/worker-status", () => ({
|
||||
getWorkerStatus: vi.fn(() => ({
|
||||
running: true,
|
||||
startedAt: new Date(Date.now() - 3_600_000).toISOString(),
|
||||
lastHeartbeat: null,
|
||||
lastReminderCheck: null,
|
||||
lastWithingsSync: null,
|
||||
lastInsightsRun: null,
|
||||
jobsProcessed: 0,
|
||||
errors: 0,
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
const mocks = prisma as unknown as {
|
||||
user: { count: ReturnType<typeof vi.fn> };
|
||||
withingsConnection: { count: ReturnType<typeof vi.fn> };
|
||||
notificationChannel: { count: ReturnType<typeof vi.fn> };
|
||||
pushSubscription: { count: ReturnType<typeof vi.fn> };
|
||||
appSettings: { findUnique: ReturnType<typeof vi.fn> };
|
||||
auditLog: {
|
||||
findFirst: ReturnType<typeof vi.fn>;
|
||||
count: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
dataBackup: {
|
||||
findFirst: ReturnType<typeof vi.fn>;
|
||||
findMany: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
};
|
||||
|
||||
describe("GET /api/admin/status-overview", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// default returns
|
||||
mocks.user.count.mockResolvedValue(0);
|
||||
mocks.withingsConnection.count.mockResolvedValue(0);
|
||||
mocks.notificationChannel.count.mockResolvedValue(0);
|
||||
mocks.pushSubscription.count.mockResolvedValue(0);
|
||||
mocks.appSettings.findUnique.mockResolvedValue(null);
|
||||
mocks.auditLog.findFirst.mockResolvedValue(null);
|
||||
mocks.auditLog.count.mockResolvedValue(0);
|
||||
mocks.dataBackup.findFirst.mockResolvedValue(null);
|
||||
mocks.dataBackup.findMany.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("returns the 6-section overview shape", async () => {
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as { data: Record<string, unknown> };
|
||||
expect(body.data).toHaveProperty("users");
|
||||
expect(body.data).toHaveProperty("integrations");
|
||||
expect(body.data).toHaveProperty("monitoring");
|
||||
expect(body.data).toHaveProperty("backups");
|
||||
expect(body.data).toHaveProperty("maintenance");
|
||||
expect(body.data).toHaveProperty("auditLog");
|
||||
});
|
||||
|
||||
it("batches DB calls in a single Promise.all (no N+1)", async () => {
|
||||
await GET();
|
||||
// 3 user.count + 1 withings + 1 ntfy notifications + 1 webPush + 3
|
||||
// moodLog/telegram done via user.count → expect user.count called 4 times
|
||||
// (total, admins, newThisWeek, moodLog, telegram)
|
||||
expect(mocks.user.count).toHaveBeenCalledTimes(5);
|
||||
expect(mocks.withingsConnection.count).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.notificationChannel.count).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.pushSubscription.count).toHaveBeenCalledTimes(1);
|
||||
// The route does not loop — every aggregate is a single query.
|
||||
expect(mocks.dataBackup.findMany).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("flags backups as alert when none exist", async () => {
|
||||
mocks.dataBackup.findFirst.mockResolvedValue(null);
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as {
|
||||
data: { backups: { severity: string } };
|
||||
};
|
||||
expect(body.data.backups.severity).toBe("alert");
|
||||
});
|
||||
|
||||
it("flags backups as good when fresh (<8d)", async () => {
|
||||
mocks.dataBackup.findFirst.mockResolvedValue({
|
||||
createdAt: new Date(Date.now() - 24 * 60 * 60 * 1000),
|
||||
});
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as {
|
||||
data: { backups: { severity: string } };
|
||||
};
|
||||
expect(body.data.backups.severity).toBe("good");
|
||||
});
|
||||
|
||||
it("flags monitoring as alert on a recent error (<24h)", async () => {
|
||||
mocks.auditLog.findFirst.mockImplementation(
|
||||
({ where }: { where: { action: unknown } }) => {
|
||||
const action = where.action as { startsWith?: string } | string;
|
||||
if (
|
||||
typeof action === "object" &&
|
||||
action.startsWith === "system.error"
|
||||
) {
|
||||
return Promise.resolve({ createdAt: new Date(Date.now() - 60_000) });
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
},
|
||||
);
|
||||
mocks.appSettings.findUnique.mockResolvedValue({
|
||||
glitchtipEnabled: true,
|
||||
umamiEnabled: true,
|
||||
});
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as {
|
||||
data: { monitoring: { severity: string } };
|
||||
};
|
||||
expect(body.data.monitoring.severity).toBe("alert");
|
||||
});
|
||||
|
||||
it("flags maintenance as alert when worker is stopped", async () => {
|
||||
const wsModule = await import("@/lib/jobs/worker-status");
|
||||
vi.mocked(wsModule.getWorkerStatus).mockReturnValue({
|
||||
running: false,
|
||||
startedAt: null,
|
||||
lastHeartbeat: null,
|
||||
lastReminderCheck: null,
|
||||
lastWithingsSync: null,
|
||||
lastInsightsRun: null,
|
||||
jobsProcessed: 0,
|
||||
errors: 0,
|
||||
});
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as {
|
||||
data: { maintenance: { severity: string; workerRunning: boolean } };
|
||||
};
|
||||
expect(body.data.maintenance.severity).toBe("alert");
|
||||
expect(body.data.maintenance.workerRunning).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { getWorkerStatus } from "@/lib/jobs/worker-status";
|
||||
|
||||
/**
|
||||
* Severity tokens map onto §2.6 status taxonomy of `docs/ui-guidelines.md`.
|
||||
* Returned as plain strings so the client renders dots + labels — not RAW
|
||||
* colours — and never leans on color alone for state.
|
||||
*/
|
||||
export type StatusSeverity = "good" | "info" | "caution" | "alert" | "pending";
|
||||
|
||||
export interface StatusOverview {
|
||||
users: {
|
||||
severity: StatusSeverity;
|
||||
total: number;
|
||||
admins: number;
|
||||
newThisWeek: number;
|
||||
};
|
||||
integrations: {
|
||||
severity: StatusSeverity;
|
||||
withings: number;
|
||||
moodLog: number;
|
||||
telegram: number;
|
||||
ntfy: number;
|
||||
webPush: number;
|
||||
};
|
||||
monitoring: {
|
||||
severity: StatusSeverity;
|
||||
glitchtipEnabled: boolean;
|
||||
umamiEnabled: boolean;
|
||||
wideEventsEnabled: boolean;
|
||||
lastErrorAt: string | null;
|
||||
};
|
||||
backups: {
|
||||
severity: StatusSeverity;
|
||||
lastBackupAt: string | null;
|
||||
backedUpUsers: number;
|
||||
retentionDays: number;
|
||||
};
|
||||
maintenance: {
|
||||
severity: StatusSeverity;
|
||||
workerRunning: boolean;
|
||||
workerUptimeSeconds: number | null;
|
||||
lastIdempotencyCleanup: string | null;
|
||||
lastAuditLogCleanup: string | null;
|
||||
};
|
||||
auditLog: {
|
||||
severity: StatusSeverity;
|
||||
eventsLast30d: number;
|
||||
lastLoginAt: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function severityForBackup(lastBackupAt: Date | null): StatusSeverity {
|
||||
if (!lastBackupAt) return "alert";
|
||||
const ageMs = Date.now() - lastBackupAt.getTime();
|
||||
if (ageMs > 14 * ONE_DAY_MS) return "alert";
|
||||
if (ageMs > 8 * ONE_DAY_MS) return "caution";
|
||||
return "good";
|
||||
}
|
||||
|
||||
function severityForMaintenance(workerRunning: boolean): StatusSeverity {
|
||||
return workerRunning ? "good" : "alert";
|
||||
}
|
||||
|
||||
function severityForMonitoring(
|
||||
glitchtipEnabled: boolean,
|
||||
umamiEnabled: boolean,
|
||||
recentErrorAt: Date | null,
|
||||
): StatusSeverity {
|
||||
if (recentErrorAt && Date.now() - recentErrorAt.getTime() < ONE_DAY_MS) {
|
||||
return "alert";
|
||||
}
|
||||
if (glitchtipEnabled && umamiEnabled) return "good";
|
||||
if (glitchtipEnabled || umamiEnabled) return "caution";
|
||||
return "info";
|
||||
}
|
||||
|
||||
function severityForIntegrations(totalConfigured: number): StatusSeverity {
|
||||
if (totalConfigured === 0) return "info";
|
||||
return "good";
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
await requireAdmin();
|
||||
annotate({ action: { name: "admin.status-overview" } });
|
||||
|
||||
const now = new Date();
|
||||
const sevenDaysAgo = new Date(now.getTime() - 7 * ONE_DAY_MS);
|
||||
const thirtyDaysAgo = new Date(now.getTime() - 30 * ONE_DAY_MS);
|
||||
|
||||
// Single batched roundtrip — no N+1.
|
||||
const [
|
||||
userTotal,
|
||||
userAdmins,
|
||||
userNewThisWeek,
|
||||
withingsCount,
|
||||
moodLogCount,
|
||||
telegramCount,
|
||||
ntfyCount,
|
||||
webPushCount,
|
||||
appSettings,
|
||||
lastErrorEntry,
|
||||
latestBackup,
|
||||
backedUpUsers,
|
||||
last30dEvents,
|
||||
lastLoginEntry,
|
||||
lastIdempotencyCleanup,
|
||||
lastAuditLogCleanup,
|
||||
] = await Promise.all([
|
||||
prisma.user.count(),
|
||||
prisma.user.count({ where: { role: "ADMIN" } }),
|
||||
prisma.user.count({ where: { createdAt: { gte: sevenDaysAgo } } }),
|
||||
prisma.withingsConnection.count(),
|
||||
prisma.user.count({ where: { moodLogEnabled: true } }),
|
||||
prisma.user.count({ where: { telegramEnabled: true } }),
|
||||
prisma.notificationChannel.count({
|
||||
where: { type: "NTFY", enabled: true },
|
||||
}),
|
||||
prisma.pushSubscription.count(),
|
||||
prisma.appSettings.findUnique({ where: { id: "singleton" } }),
|
||||
prisma.auditLog.findFirst({
|
||||
where: { action: { startsWith: "system.error" } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true },
|
||||
}),
|
||||
prisma.dataBackup.findFirst({
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true },
|
||||
}),
|
||||
prisma.dataBackup
|
||||
.findMany({ select: { userId: true }, distinct: ["userId"] })
|
||||
.then((rows) => rows.length),
|
||||
prisma.auditLog.count({ where: { createdAt: { gte: thirtyDaysAgo } } }),
|
||||
prisma.auditLog.findFirst({
|
||||
where: { action: "auth.login" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true },
|
||||
}),
|
||||
prisma.auditLog.findFirst({
|
||||
where: { action: "system.cleanup.idempotency" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true },
|
||||
}),
|
||||
prisma.auditLog.findFirst({
|
||||
where: { action: "system.cleanup.audit-log" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { createdAt: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const worker = getWorkerStatus();
|
||||
const workerUptimeSeconds = worker.startedAt
|
||||
? Math.max(
|
||||
0,
|
||||
Math.floor(
|
||||
(now.getTime() - new Date(worker.startedAt).getTime()) / 1000,
|
||||
),
|
||||
)
|
||||
: null;
|
||||
|
||||
const integrationsTotal =
|
||||
withingsCount + moodLogCount + telegramCount + ntfyCount + webPushCount;
|
||||
|
||||
const overview: StatusOverview = {
|
||||
users: {
|
||||
severity: "good",
|
||||
total: userTotal,
|
||||
admins: userAdmins,
|
||||
newThisWeek: userNewThisWeek,
|
||||
},
|
||||
integrations: {
|
||||
severity: severityForIntegrations(integrationsTotal),
|
||||
withings: withingsCount,
|
||||
moodLog: moodLogCount,
|
||||
telegram: telegramCount,
|
||||
ntfy: ntfyCount,
|
||||
webPush: webPushCount,
|
||||
},
|
||||
monitoring: {
|
||||
severity: severityForMonitoring(
|
||||
Boolean(appSettings?.glitchtipEnabled),
|
||||
Boolean(appSettings?.umamiEnabled),
|
||||
lastErrorEntry?.createdAt ?? null,
|
||||
),
|
||||
glitchtipEnabled: Boolean(appSettings?.glitchtipEnabled),
|
||||
umamiEnabled: Boolean(appSettings?.umamiEnabled),
|
||||
wideEventsEnabled: true,
|
||||
lastErrorAt: lastErrorEntry?.createdAt.toISOString() ?? null,
|
||||
},
|
||||
backups: {
|
||||
severity: severityForBackup(latestBackup?.createdAt ?? null),
|
||||
lastBackupAt: latestBackup?.createdAt.toISOString() ?? null,
|
||||
backedUpUsers,
|
||||
retentionDays: 90,
|
||||
},
|
||||
maintenance: {
|
||||
severity: severityForMaintenance(worker.running),
|
||||
workerRunning: worker.running,
|
||||
workerUptimeSeconds,
|
||||
lastIdempotencyCleanup:
|
||||
lastIdempotencyCleanup?.createdAt.toISOString() ?? null,
|
||||
lastAuditLogCleanup: lastAuditLogCleanup?.createdAt.toISOString() ?? null,
|
||||
},
|
||||
auditLog: {
|
||||
severity: "info",
|
||||
eventsLast30d: last30dEvents,
|
||||
lastLoginAt: lastLoginEntry?.createdAt.toISOString() ?? null,
|
||||
},
|
||||
};
|
||||
|
||||
return apiSuccess(overview);
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { count: vi.fn() },
|
||||
measurement: { count: vi.fn() },
|
||||
medication: { count: vi.fn() },
|
||||
medicationIntakeEvent: { count: vi.fn() },
|
||||
apiToken: { count: vi.fn() },
|
||||
session: { count: vi.fn() },
|
||||
appSettings: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/lib/api-handler")>(
|
||||
"@/lib/api-handler",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
|
||||
h: T,
|
||||
): T => h,
|
||||
requireAdmin: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/jobs/worker-status", () => ({
|
||||
getWorkerStatus: vi.fn(() => ({
|
||||
running: true,
|
||||
startedAt: new Date(Date.now() - 60_000).toISOString(),
|
||||
lastHeartbeat: null,
|
||||
lastReminderCheck: null,
|
||||
lastWithingsSync: null,
|
||||
lastInsightsRun: null,
|
||||
jobsProcessed: 0,
|
||||
errors: 0,
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAdmin, HttpError } from "@/lib/api-handler";
|
||||
|
||||
const ADMIN_CTX = {
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "admin-1",
|
||||
username: "admin",
|
||||
role: "ADMIN",
|
||||
} as never,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
|
||||
vi.mocked(prisma.user.count).mockResolvedValue(0);
|
||||
vi.mocked(prisma.measurement.count).mockResolvedValue(0);
|
||||
vi.mocked(prisma.medication.count).mockResolvedValue(0);
|
||||
vi.mocked(prisma.medicationIntakeEvent.count).mockResolvedValue(0);
|
||||
vi.mocked(prisma.apiToken.count).mockResolvedValue(0);
|
||||
vi.mocked(prisma.session.count).mockResolvedValue(0);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null);
|
||||
});
|
||||
|
||||
describe("GET /api/admin/status (integration health summary)", () => {
|
||||
it("rejects non-admin callers with 403", async () => {
|
||||
vi.mocked(requireAdmin).mockRejectedValue(
|
||||
new HttpError(403, "Admin access required"),
|
||||
);
|
||||
await expect(GET()).rejects.toThrow("Admin access required");
|
||||
});
|
||||
|
||||
it("returns null integration entries when no app settings exist", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null);
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { integrations: Record<string, unknown> };
|
||||
};
|
||||
expect(body.data.integrations).toEqual({
|
||||
umami: null,
|
||||
glitchtip: null,
|
||||
webPush: null,
|
||||
bugReport: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("aggregates configured + enabled flags for umami / glitchtip / web-push / bugReport", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
umamiScriptUrl: "https://x/script.js",
|
||||
umamiWebsiteId: "site-1",
|
||||
umamiEnabled: true,
|
||||
glitchtipDsn: "https://abc@x/123",
|
||||
glitchtipEnabled: false,
|
||||
webPushVapidPublicKey: "pub",
|
||||
webPushVapidPrivateKeyEncrypted: "secret-blob",
|
||||
webPushVapidSubject: "mailto:a@b.com",
|
||||
githubIssueRepo: "owner/repo",
|
||||
githubIssueTokenEncrypted: "token-blob",
|
||||
} as never);
|
||||
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as {
|
||||
data: { integrations: Record<string, Record<string, unknown> | null> };
|
||||
};
|
||||
expect(body.data.integrations.umami).toEqual({
|
||||
configured: true,
|
||||
enabled: true,
|
||||
});
|
||||
// glitchtip is configured but the global toggle is off
|
||||
expect(body.data.integrations.glitchtip).toEqual({
|
||||
configured: true,
|
||||
enabled: false,
|
||||
});
|
||||
expect(body.data.integrations.webPush).toEqual({ configured: true });
|
||||
expect(body.data.integrations.bugReport).toEqual({ configured: true });
|
||||
|
||||
// Regression: encrypted blobs MUST never appear in the response envelope.
|
||||
const serialized = JSON.stringify(body);
|
||||
expect(serialized).not.toContain("secret-blob");
|
||||
expect(serialized).not.toContain("token-blob");
|
||||
});
|
||||
|
||||
it("does not mark webPush configured when only the public key is present", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
webPushVapidPublicKey: "pub",
|
||||
webPushVapidPrivateKeyEncrypted: null,
|
||||
webPushVapidSubject: null,
|
||||
} as never);
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as {
|
||||
data: { integrations: { webPush: unknown } };
|
||||
};
|
||||
expect(body.data.integrations.webPush).toBeNull();
|
||||
});
|
||||
|
||||
it("returns aggregate counts and the worker status block", async () => {
|
||||
vi.mocked(prisma.user.count).mockResolvedValue(7);
|
||||
vi.mocked(prisma.measurement.count).mockResolvedValue(123);
|
||||
vi.mocked(prisma.apiToken.count).mockResolvedValue(2);
|
||||
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
counts: Record<string, number>;
|
||||
worker: { running: boolean };
|
||||
database: string;
|
||||
};
|
||||
};
|
||||
expect(body.data.counts).toEqual({
|
||||
users: 7,
|
||||
measurements: 123,
|
||||
medications: 0,
|
||||
intakeEvents: 0,
|
||||
activeTokens: 2,
|
||||
activeSessions: 0,
|
||||
});
|
||||
expect(body.data.worker.running).toBe(true);
|
||||
expect(body.data.database).toBe("connected");
|
||||
|
||||
// Regression: only NON-revoked tokens are counted as "active".
|
||||
const tokenCallArgs = vi.mocked(prisma.apiToken.count).mock.calls[0]?.[0];
|
||||
expect(tokenCallArgs).toEqual({ where: { revoked: false } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
count: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/lib/api-handler")>(
|
||||
"@/lib/api-handler",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
|
||||
h: T,
|
||||
): T => h,
|
||||
requireAdmin: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
import { PUT } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAdmin, HttpError } from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
const ADMIN_CTX = {
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "admin-1",
|
||||
username: "admin",
|
||||
role: "ADMIN",
|
||||
} as never,
|
||||
};
|
||||
|
||||
function jsonReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/admin/users/u1", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function params(id: string) {
|
||||
return { params: Promise.resolve({ id }) };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
|
||||
});
|
||||
|
||||
describe("PUT /api/admin/users/[id]", () => {
|
||||
it("rejects with 401 when unauthenticated", async () => {
|
||||
vi.mocked(requireAdmin).mockRejectedValue(
|
||||
new HttpError(401, "Not authenticated"),
|
||||
);
|
||||
await expect(
|
||||
PUT(jsonReq({ username: "marc" }), params("u1")),
|
||||
).rejects.toThrow("Not authenticated");
|
||||
});
|
||||
|
||||
it("rejects with 403 when caller is not admin", async () => {
|
||||
vi.mocked(requireAdmin).mockRejectedValue(
|
||||
new HttpError(403, "Admin access required"),
|
||||
);
|
||||
await expect(
|
||||
PUT(jsonReq({ username: "marc" }), params("u1")),
|
||||
).rejects.toThrow("Admin access required");
|
||||
});
|
||||
|
||||
it("returns 422 when payload fails Zod validation (short username)", async () => {
|
||||
const res = await PUT(jsonReq({ username: "ab" }), params("u1"));
|
||||
expect(res.status).toBe(422);
|
||||
expect(vi.mocked(prisma.user.findUnique)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 422 when role is unknown", async () => {
|
||||
const res = await PUT(jsonReq({ role: "SUPERUSER" }), params("u1"));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("returns 404 when target user does not exist", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
|
||||
const res = await PUT(jsonReq({ username: "marc" }), params("u-missing"));
|
||||
expect(res.status).toBe(404);
|
||||
expect(vi.mocked(prisma.user.update)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forbids demoting the last remaining admin", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
id: "u1",
|
||||
role: "ADMIN",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.count).mockResolvedValue(1);
|
||||
const res = await PUT(jsonReq({ role: "USER" }), params("u1"));
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toMatch(/last admin/i);
|
||||
expect(vi.mocked(prisma.user.update)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows demoting an admin while another admin exists", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
id: "u1",
|
||||
role: "ADMIN",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.count).mockResolvedValue(2);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({
|
||||
id: "u1",
|
||||
username: "alice",
|
||||
email: "a@b.com",
|
||||
role: "USER",
|
||||
createdAt: new Date("2024-01-01T00:00:00Z"),
|
||||
} as never);
|
||||
const res = await PUT(jsonReq({ role: "USER" }), params("u1"));
|
||||
expect(res.status).toBe(200);
|
||||
const updateArgs = vi.mocked(prisma.user.update).mock.calls[0]?.[0];
|
||||
expect(updateArgs?.where).toEqual({ id: "u1" });
|
||||
expect(updateArgs?.data).toEqual({ role: "USER" });
|
||||
});
|
||||
|
||||
it("persists username + email + role and writes an audit-log entry", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
id: "u1",
|
||||
role: "USER",
|
||||
} as never);
|
||||
const updated = {
|
||||
id: "u1",
|
||||
username: "renamed",
|
||||
email: "new@example.com",
|
||||
role: "ADMIN" as const,
|
||||
createdAt: new Date("2024-01-01T00:00:00Z"),
|
||||
};
|
||||
vi.mocked(prisma.user.update).mockResolvedValue(updated as never);
|
||||
|
||||
const res = await PUT(
|
||||
jsonReq({
|
||||
username: "renamed",
|
||||
email: "new@example.com",
|
||||
role: "ADMIN",
|
||||
}),
|
||||
params("u1"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: Record<string, unknown> };
|
||||
expect(body.data.username).toBe("renamed");
|
||||
expect(body.data.role).toBe("ADMIN");
|
||||
expect(body.data).not.toHaveProperty("passwordHash");
|
||||
|
||||
const updateArgs = vi.mocked(prisma.user.update).mock.calls[0]?.[0];
|
||||
expect(updateArgs?.data).toEqual({
|
||||
username: "renamed",
|
||||
email: "new@example.com",
|
||||
role: "ADMIN",
|
||||
});
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"admin.user.update",
|
||||
expect.objectContaining({
|
||||
userId: "admin-1",
|
||||
details: expect.objectContaining({ targetUserId: "u1" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("supports clearing email by passing null", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
id: "u1",
|
||||
role: "USER",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({
|
||||
id: "u1",
|
||||
username: "alice",
|
||||
email: null,
|
||||
role: "USER",
|
||||
createdAt: new Date(),
|
||||
} as never);
|
||||
const res = await PUT(jsonReq({ email: null }), params("u1"));
|
||||
expect(res.status).toBe(200);
|
||||
const updateArgs = vi.mocked(prisma.user.update).mock.calls[0]?.[0];
|
||||
expect(updateArgs?.data).toEqual({ email: null });
|
||||
});
|
||||
|
||||
it("returns 415 when content-type is not JSON", async () => {
|
||||
const r = new NextRequest("http://localhost/api/admin/users/u1", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "text/plain" },
|
||||
body: "{}",
|
||||
});
|
||||
const res = await PUT(r, params("u1"));
|
||||
expect(res.status).toBe(415);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/lib/api-handler")>(
|
||||
"@/lib/api-handler",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
|
||||
h: T,
|
||||
): T => h,
|
||||
requireAdmin: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAdmin, HttpError } from "@/lib/api-handler";
|
||||
|
||||
const mockUserFindMany = vi.mocked(prisma.user.findMany);
|
||||
const mockRequireAdmin = vi.mocked(requireAdmin);
|
||||
|
||||
describe("GET /api/admin/users", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockRequireAdmin.mockResolvedValue({
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "admin-1",
|
||||
username: "admin",
|
||||
role: "ADMIN",
|
||||
} as never,
|
||||
});
|
||||
mockUserFindMany.mockResolvedValue([] as never);
|
||||
});
|
||||
|
||||
it("rejects with 401 when no session", async () => {
|
||||
mockRequireAdmin.mockRejectedValue(new HttpError(401, "Not authenticated"));
|
||||
await expect(GET()).rejects.toThrow("Not authenticated");
|
||||
});
|
||||
|
||||
it("rejects with 403 when caller is not admin", async () => {
|
||||
mockRequireAdmin.mockRejectedValue(
|
||||
new HttpError(403, "Admin access required"),
|
||||
);
|
||||
await expect(GET()).rejects.toThrow("Admin access required");
|
||||
});
|
||||
|
||||
it("returns users sorted by createdAt ascending with passkey counts", async () => {
|
||||
const created = new Date("2024-01-01T00:00:00Z");
|
||||
mockUserFindMany.mockResolvedValue([
|
||||
{
|
||||
id: "u1",
|
||||
username: "alice",
|
||||
email: "alice@example.com",
|
||||
role: "ADMIN",
|
||||
createdAt: created,
|
||||
_count: { passkeys: 2 },
|
||||
},
|
||||
{
|
||||
id: "u2",
|
||||
username: "bob",
|
||||
email: null,
|
||||
role: "USER",
|
||||
createdAt: created,
|
||||
_count: { passkeys: 0 },
|
||||
},
|
||||
] as never);
|
||||
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: Array<Record<string, unknown>>;
|
||||
};
|
||||
expect(body.data).toHaveLength(2);
|
||||
expect(body.data[0]).toEqual({
|
||||
id: "u1",
|
||||
username: "alice",
|
||||
email: "alice@example.com",
|
||||
role: "ADMIN",
|
||||
createdAt: created.toISOString(),
|
||||
passkeyCount: 2,
|
||||
});
|
||||
expect(body.data[1].passkeyCount).toBe(0);
|
||||
expect(body.data[1].email).toBeNull();
|
||||
});
|
||||
|
||||
it("queries Prisma with the safe select shape (no password / sensitive cols)", async () => {
|
||||
await GET();
|
||||
expect(mockUserFindMany).toHaveBeenCalledTimes(1);
|
||||
const args = mockUserFindMany.mock.calls[0]?.[0] as {
|
||||
select: Record<string, unknown>;
|
||||
orderBy: { createdAt: "asc" | "desc" };
|
||||
};
|
||||
// Regression: the route must NOT pull password hash / token columns.
|
||||
expect(args.select).toEqual({
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
role: true,
|
||||
createdAt: true,
|
||||
_count: { select: { passkeys: true } },
|
||||
});
|
||||
expect(args.select).not.toHaveProperty("passwordHash");
|
||||
expect(args.orderBy).toEqual({ createdAt: "asc" });
|
||||
});
|
||||
});
|
||||
@@ -3,12 +3,22 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
// Mocks must be hoisted before importing the route.
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({ user: { id: "u-1" }, session: { id: "s-1" } })),
|
||||
requireAuth: vi.fn(async () => ({
|
||||
user: { id: "u-1" },
|
||||
session: { id: "s-1" },
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/ai/provider", () => ({
|
||||
resolveProvider: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/provider", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/lib/ai/provider")>(
|
||||
"@/lib/ai/provider",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
resolveProviderForTest: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
@@ -19,10 +29,11 @@ vi.mock("@/lib/logging/context", () => ({
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { resolveProvider } from "@/lib/ai/provider";
|
||||
import { resolveProviderForTest } from "@/lib/ai/provider";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(resolveProviderForTest).mockReset();
|
||||
});
|
||||
|
||||
interface ApiErrorEnvelope {
|
||||
@@ -30,6 +41,30 @@ interface ApiErrorEnvelope {
|
||||
error: string;
|
||||
}
|
||||
|
||||
interface ApiSuccessEnvelope<T> {
|
||||
data: T;
|
||||
error: null;
|
||||
}
|
||||
|
||||
function emptyRequest(): Request {
|
||||
return new Request("http://localhost/api/ai/test", {
|
||||
method: "POST",
|
||||
headers: { "content-length": "0" },
|
||||
});
|
||||
}
|
||||
|
||||
function jsonRequest(body: unknown): Request {
|
||||
const text = JSON.stringify(body);
|
||||
return new Request("http://localhost/api/ai/test", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"content-length": String(text.length),
|
||||
},
|
||||
body: text,
|
||||
});
|
||||
}
|
||||
|
||||
// V3 audit: /api/ai/test was returning provider err.message + bodyExcerpt
|
||||
// directly to the client, leaking provider URLs / partial keys / internal
|
||||
// headers. Server now logs full details via annotate() and responds with
|
||||
@@ -38,7 +73,7 @@ describe("POST /api/ai/test — provider error leak guard", () => {
|
||||
function makeProviderThatThrows(
|
||||
err: Error & { httpStatus?: number; bodyExcerpt?: string },
|
||||
) {
|
||||
vi.mocked(resolveProvider).mockResolvedValue({
|
||||
vi.mocked(resolveProviderForTest).mockResolvedValue({
|
||||
type: "openai",
|
||||
generateCompletion: vi.fn(async () => {
|
||||
throw err;
|
||||
@@ -49,11 +84,14 @@ describe("POST /api/ai/test — provider error leak guard", () => {
|
||||
it("does not echo provider err.message back to the client (HIGH coverage gap)", async () => {
|
||||
const err = Object.assign(
|
||||
new Error("OpenAI 401 from https://api.openai.com/v1 sk-leaked-key"),
|
||||
{ httpStatus: 401 as const, bodyExcerpt: '{"error":"invalid api key sk-secret"}' },
|
||||
{
|
||||
httpStatus: 401 as const,
|
||||
bodyExcerpt: '{"error":"invalid api key sk-secret"}',
|
||||
},
|
||||
);
|
||||
makeProviderThatThrows(err);
|
||||
|
||||
const response = await POST();
|
||||
const response = await POST(emptyRequest() as never);
|
||||
const body = (await response.json()) as ApiErrorEnvelope;
|
||||
|
||||
expect(response.status).toBe(502);
|
||||
@@ -67,7 +105,7 @@ describe("POST /api/ai/test — provider error leak guard", () => {
|
||||
makeProviderThatThrows(
|
||||
Object.assign(new Error("429 from openai"), { httpStatus: 429 as const }),
|
||||
);
|
||||
const response = await POST();
|
||||
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",
|
||||
@@ -78,7 +116,7 @@ describe("POST /api/ai/test — provider error leak guard", () => {
|
||||
makeProviderThatThrows(
|
||||
Object.assign(new Error("503 upstream"), { httpStatus: 503 as const }),
|
||||
);
|
||||
const response = await POST();
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(((await response.json()) as ApiErrorEnvelope).error).toBe(
|
||||
"Provider returned a server error",
|
||||
);
|
||||
@@ -86,9 +124,76 @@ describe("POST /api/ai/test — provider error leak guard", () => {
|
||||
|
||||
it("returns the unknown-error fallback otherwise", async () => {
|
||||
makeProviderThatThrows(new Error("ECONNRESET"));
|
||||
const response = await POST();
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(((await response.json()) as ApiErrorEnvelope).error).toBe(
|
||||
"Provider connection failed",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// v1.4 fix: the dropdown in /settings was not honoured — the test always
|
||||
// ran against the SAVED provider (Marc reported "wirft was Komisches raus").
|
||||
// The route now accepts a JSON override body and forwards the user's
|
||||
// unsaved selection to resolveProviderForTest().
|
||||
describe("POST /api/ai/test — dropdown-aware override", () => {
|
||||
function makeProviderThatSucceeds(model: string) {
|
||||
vi.mocked(resolveProviderForTest).mockResolvedValue({
|
||||
type: "anthropic",
|
||||
generateCompletion: vi.fn(async () => ({
|
||||
content: '{"ok":true}',
|
||||
providerType: "anthropic",
|
||||
model,
|
||||
tokensUsed: 5,
|
||||
})),
|
||||
} as never);
|
||||
}
|
||||
|
||||
it("forwards a dropdown override to resolveProviderForTest", async () => {
|
||||
makeProviderThatSucceeds("claude-3-5-sonnet-latest");
|
||||
const response = await POST(
|
||||
jsonRequest({
|
||||
provider: "ANTHROPIC",
|
||||
model: "claude-3-5-sonnet-latest",
|
||||
}) as never,
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
expect(resolveProviderForTest).toHaveBeenCalledWith("u-1", {
|
||||
provider: "ANTHROPIC",
|
||||
model: "claude-3-5-sonnet-latest",
|
||||
});
|
||||
const body = (await response.json()) as ApiSuccessEnvelope<{
|
||||
providerType: string;
|
||||
model: string;
|
||||
}>;
|
||||
expect(body.data.providerType).toBe("anthropic");
|
||||
expect(body.data.model).toBe("claude-3-5-sonnet-latest");
|
||||
});
|
||||
|
||||
it("rejects unknown provider values with 422", async () => {
|
||||
const response = await POST(
|
||||
jsonRequest({ provider: "MALICIOUS" }) as never,
|
||||
);
|
||||
expect(response.status).toBe(422);
|
||||
expect(resolveProviderForTest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the persisted config when the body is empty", async () => {
|
||||
makeProviderThatSucceeds("claude-3-5-sonnet-latest");
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(200);
|
||||
expect(resolveProviderForTest).toHaveBeenCalledWith("u-1", {});
|
||||
});
|
||||
|
||||
it("surfaces config errors as the same status as the resolver throws", async () => {
|
||||
const { AITestConfigError } = await import("@/lib/ai/provider");
|
||||
vi.mocked(resolveProviderForTest).mockRejectedValueOnce(
|
||||
new AITestConfigError(422, "Anthropic API key not configured"),
|
||||
);
|
||||
const response = await POST(
|
||||
jsonRequest({ provider: "ANTHROPIC" }) as never,
|
||||
);
|
||||
expect(response.status).toBe(422);
|
||||
const body = (await response.json()) as ApiErrorEnvelope;
|
||||
expect(body.error).toBe("Anthropic API key not configured");
|
||||
});
|
||||
});
|
||||
@@ -1,19 +1,62 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { resolveProvider } from "@/lib/ai/provider";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import {
|
||||
resolveProviderForTest,
|
||||
AITestConfigError,
|
||||
type AITestOverride,
|
||||
} from "@/lib/ai/provider";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const POST = apiHandler(async () => {
|
||||
const overrideSchema = z
|
||||
.object({
|
||||
provider: z
|
||||
.enum(["OPENAI", "ANTHROPIC", "LOCAL", "CHATGPT_OAUTH"])
|
||||
.optional()
|
||||
.nullable(),
|
||||
model: z.string().min(1).max(120).optional().nullable(),
|
||||
baseUrl: z.string().url().max(2048).optional().nullable(),
|
||||
anthropicKey: z.string().min(1).max(500).optional().nullable(),
|
||||
localKey: z.string().min(1).max(500).optional().nullable(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "ai.test" } });
|
||||
|
||||
const rl = await checkRateLimit(`ai-test:${user.id}`, 5, 60_000);
|
||||
if (!rl.allowed) return apiError("Too many test requests", 429);
|
||||
|
||||
const provider = await resolveProvider(user.id);
|
||||
// Body is optional. Empty body → behaves like before (test the saved
|
||||
// config). Non-empty body → tests the unsaved selection without
|
||||
// mutating the user row. Plaintext keys never persist.
|
||||
let override: AITestOverride = {};
|
||||
const contentLength = Number(request.headers.get("content-length") ?? "0");
|
||||
if (contentLength > 0) {
|
||||
const { data, error } = await safeJson<unknown>(request);
|
||||
if (error) return error;
|
||||
const parsed = overrideSchema.safeParse(data);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid override payload", 422);
|
||||
}
|
||||
override = parsed.data;
|
||||
}
|
||||
|
||||
let provider;
|
||||
try {
|
||||
provider = await resolveProviderForTest(user.id, override);
|
||||
} catch (e) {
|
||||
if (e instanceof AITestConfigError) {
|
||||
return apiError(e.message, e.status);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
if (provider.type === "none") {
|
||||
return apiError("No AI provider configured", 422);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const rl = await checkRateLimit(`codex-callback:${user.id}`, 10, 60_000);
|
||||
if (!rl.allowed) {
|
||||
const appUrl = process.env.APP_URL || process.env.NEXT_PUBLIC_APP_URL;
|
||||
return NextResponse.redirect(`${appUrl}/settings?codex_error=rate_limited`);
|
||||
return NextResponse.redirect(`${appUrl}/settings/integrations?codex_error=rate_limited`);
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
@@ -29,13 +29,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
if (error) {
|
||||
annotate({ meta: { oauth_error: error } });
|
||||
return NextResponse.redirect(
|
||||
`${appUrl}/settings?codex_error=${encodeURIComponent(error)}`,
|
||||
`${appUrl}/settings/integrations?codex_error=${encodeURIComponent(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
return NextResponse.redirect(
|
||||
`${appUrl}/settings?codex_error=missing_params`,
|
||||
`${appUrl}/settings/integrations?codex_error=missing_params`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
if (!storedState || !storedVerifier || state !== storedState) {
|
||||
return NextResponse.redirect(
|
||||
`${appUrl}/settings?codex_error=invalid_state`,
|
||||
`${appUrl}/settings/integrations?codex_error=invalid_state`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,9 +70,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
data: {
|
||||
codexAccessTokenEncrypted: encrypted.accessEncrypted,
|
||||
codexRefreshTokenEncrypted: encrypted.refreshEncrypted,
|
||||
codexTokenExpiresAt: new Date(
|
||||
Date.now() + tokens.expires_in * 1000,
|
||||
),
|
||||
codexTokenExpiresAt: new Date(Date.now() + tokens.expires_in * 1000),
|
||||
codexConnectedAt: new Date(),
|
||||
codexConnectionStatus: "connected",
|
||||
insightsCachedAt: null,
|
||||
@@ -86,12 +84,14 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
annotate({ action: { name: "codex.oauth.callback.success" } });
|
||||
|
||||
return NextResponse.redirect(`${appUrl}/settings?codex_connected=true`);
|
||||
return NextResponse.redirect(
|
||||
`${appUrl}/settings/integrations?codex_connected=true`,
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "unknown";
|
||||
annotate({ meta: { codex_token_error: message } });
|
||||
return NextResponse.redirect(
|
||||
`${appUrl}/settings?codex_error=token_exchange_failed`,
|
||||
`${appUrl}/settings/integrations?codex_error=token_exchange_failed`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -7,6 +7,7 @@ vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findFirst: vi.fn() },
|
||||
apiToken: { create: vi.fn() },
|
||||
refreshToken: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -23,7 +24,9 @@ vi.mock("@/lib/auth/audit", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({ allowed: true, remaining: 5, reset: 0 }),
|
||||
checkRateLimit: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ allowed: true, remaining: 5, reset: 0 }),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
@@ -32,7 +35,7 @@ vi.mock("@/lib/db-compat", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/hmac", () => ({
|
||||
hashToken: vi.fn(() => "deadbeef"),
|
||||
hashToken: vi.fn((raw: string) => `hashed:${raw}`),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({
|
||||
@@ -41,7 +44,11 @@ vi.mock("@/lib/logging/transports", () => ({
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({ get: () => undefined, set: () => {}, delete: () => {} })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
@@ -66,7 +73,10 @@ function makeRequest(headers: Record<string, string> = {}): NextRequest {
|
||||
"content-type": "application/json",
|
||||
...headers,
|
||||
},
|
||||
body: JSON.stringify({ email: "marc@example.com", password: "supersecret" }),
|
||||
body: JSON.stringify({
|
||||
email: "marc@example.com",
|
||||
password: "supersecret",
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -75,6 +85,9 @@ beforeEach(() => {
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValue(FAKE_USER as never);
|
||||
vi.mocked(verifyPassword).mockResolvedValue(true);
|
||||
vi.mocked(prisma.apiToken.create).mockResolvedValue({ id: "tok-1" } as never);
|
||||
vi.mocked(prisma.refreshToken.create).mockResolvedValue({
|
||||
id: "rt-1",
|
||||
} as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 5,
|
||||
@@ -100,6 +113,8 @@ describe("POST /api/auth/login — native token issuance", () => {
|
||||
expect(body.data.token).toMatch(/^hlk_[a-f0-9]{64}$/);
|
||||
expect(body.data.tokenExpiresAt).toEqual(expect.any(String));
|
||||
expect(prisma.apiToken.create).toHaveBeenCalledTimes(1);
|
||||
const createArgs = vi.mocked(prisma.apiToken.create).mock.calls[0][0];
|
||||
expect(createArgs.data.tokenHash).toBe(`hashed:${body.data.token}`);
|
||||
} finally {
|
||||
process.env.API_TOKEN_HMAC_KEY = original;
|
||||
}
|
||||
@@ -126,6 +141,8 @@ describe("POST /api/auth/login — native token issuance", () => {
|
||||
const body = (await res.json()) as { data: { token?: string } };
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.data.token).toMatch(/^hlk_/);
|
||||
const createArgs = vi.mocked(prisma.apiToken.create).mock.calls[0][0];
|
||||
expect(createArgs.data.tokenHash).toBe(`hashed:${body.data.token}`);
|
||||
} finally {
|
||||
process.env.API_TOKEN_HMAC_KEY = original;
|
||||
}
|
||||
|
||||
@@ -3,13 +3,23 @@ import { loginPasswordSchema } from "@/lib/validations/auth";
|
||||
import { verifyPassword } from "@/lib/auth/password";
|
||||
import { createSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { ensureDbCompatibility } from "@/lib/db-compat";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { issueApiToken, isNativeClientRequest } from "@/lib/auth/issue-token";
|
||||
import {
|
||||
resolveTokenPolicy,
|
||||
shouldIssueBearerToken,
|
||||
} from "@/lib/auth/native-client";
|
||||
import { issueAccessAndRefresh } from "@/lib/auth/refresh-token";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const ip = getClientIp(request) ?? "unknown";
|
||||
@@ -75,22 +85,64 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
annotate({ action: { name: "auth.login.password" } });
|
||||
|
||||
// Native clients (iOS) additionally receive a long-lived Bearer token in
|
||||
// the response so they don't need to juggle the cookie jar.
|
||||
if (isNativeClientRequest(request.headers)) {
|
||||
// v1.4 G4: Native callers (iOS, n8n, Health-Connect, unrecognised UAs)
|
||||
// get a 24h access token + 60d rotating refresh token. Web UAs keep the
|
||||
// legacy 90d Bearer (issued only when X-Client-Type: native is set —
|
||||
// browser flows continue to use the session cookie).
|
||||
if (
|
||||
shouldIssueBearerToken(request.headers) ||
|
||||
isNativeClientRequest(request.headers)
|
||||
) {
|
||||
const policy = resolveTokenPolicy(request.headers);
|
||||
const deviceId = request.headers.get("x-device-id");
|
||||
|
||||
if (policy.refreshTokenDays !== null) {
|
||||
const bundle = await issueAccessAndRefresh({
|
||||
userId: user.id,
|
||||
policy,
|
||||
deviceId,
|
||||
userAgent: ua,
|
||||
ipAddress: ip,
|
||||
source: "login.password",
|
||||
});
|
||||
await auditLog("auth.token.autoissue.native", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: { source: "login.password", policy: "native" },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.token.autoissue.native" },
|
||||
meta: { token_policy: "native" },
|
||||
});
|
||||
return apiSuccess({
|
||||
user: { id: user.id, username: user.username },
|
||||
token: bundle.accessToken,
|
||||
tokenExpiresAt: bundle.accessTokenExpiresAt.toISOString(),
|
||||
refreshToken: bundle.refreshToken,
|
||||
refreshTokenExpiresAt: bundle.refreshTokenExpiresAt.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Web policy with explicit X-Client-Type:native (legacy iOS auto-login)
|
||||
const issued = await issueApiToken({
|
||||
userId: user.id,
|
||||
name: `iOS auto-login ${new Date().toISOString()}`,
|
||||
name: `web auto-login ${new Date().toISOString()}`,
|
||||
permissions: ["*"],
|
||||
expiresInDays: 90,
|
||||
expiresInDays: policy.accessTokenDays,
|
||||
});
|
||||
await auditLog("auth.token.autoissue.native", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: { tokenId: issued.tokenId, source: "login.password" },
|
||||
details: {
|
||||
tokenId: issued.tokenId,
|
||||
source: "login.password",
|
||||
policy: "web",
|
||||
},
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.token.autoissue.native" },
|
||||
meta: { token_policy: "web" },
|
||||
});
|
||||
annotate({ action: { name: "auth.token.autoissue.native" } });
|
||||
|
||||
return apiSuccess({
|
||||
user: { id: user.id, username: user.username },
|
||||
token: issued.token,
|
||||
|
||||
@@ -5,6 +5,7 @@ vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn() },
|
||||
apiToken: { create: vi.fn() },
|
||||
refreshToken: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -29,7 +30,7 @@ vi.mock("@/lib/db-compat", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/hmac", () => ({
|
||||
hashToken: vi.fn(() => "deadbeef"),
|
||||
hashToken: vi.fn((raw: string) => `hashed:${raw}`),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({
|
||||
@@ -38,7 +39,11 @@ vi.mock("@/lib/logging/transports", () => ({
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({ get: () => undefined, set: () => {}, delete: () => {} })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
@@ -67,6 +72,9 @@ beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(FAKE_USER as never);
|
||||
vi.mocked(prisma.apiToken.create).mockResolvedValue({ id: "tok-1" } as never);
|
||||
vi.mocked(prisma.refreshToken.create).mockResolvedValue({
|
||||
id: "rt-1",
|
||||
} as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 9,
|
||||
@@ -91,6 +99,8 @@ describe("POST /api/auth/passkey/login-verify — native token issuance", () =>
|
||||
expect(body.data.token).toMatch(/^hlk_[a-f0-9]{64}$/);
|
||||
expect(body.data.tokenExpiresAt).toEqual(expect.any(String));
|
||||
expect(prisma.apiToken.create).toHaveBeenCalledTimes(1);
|
||||
const createArgs = vi.mocked(prisma.apiToken.create).mock.calls[0][0];
|
||||
expect(createArgs.data.tokenHash).toBe(`hashed:${body.data.token}`);
|
||||
} finally {
|
||||
process.env.API_TOKEN_HMAC_KEY = original;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { verifyAuthentication } from "@/lib/auth/passkey";
|
||||
import { createSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { ensureDbCompatibility } from "@/lib/db-compat";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
@@ -9,17 +14,27 @@ import { NextRequest } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { issueApiToken, isNativeClientRequest } from "@/lib/auth/issue-token";
|
||||
import {
|
||||
resolveTokenPolicy,
|
||||
shouldIssueBearerToken,
|
||||
} from "@/lib/auth/native-client";
|
||||
import { issueAccessAndRefresh } from "@/lib/auth/refresh-token";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
await ensureDbCompatibility();
|
||||
|
||||
const ip = getClientIp(request);
|
||||
const rl = await checkRateLimit(`auth:passkey-verify:${ip}`, 10, 15 * 60 * 1000);
|
||||
const rl = await checkRateLimit(
|
||||
`auth:passkey-verify:${ip}`,
|
||||
10,
|
||||
15 * 60 * 1000,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many attempts. Please wait 15 minutes.", 429);
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson<Record<string, unknown>>(request);
|
||||
const { data: body, error: jsonError } =
|
||||
await safeJson<Record<string, unknown>>(request);
|
||||
|
||||
if (jsonError) return jsonError;
|
||||
const challengeId = body.challengeId as string | undefined;
|
||||
@@ -60,20 +75,59 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
annotate({ action: { name: "auth.login.passkey" } });
|
||||
|
||||
if (isNativeClientRequest(request.headers)) {
|
||||
if (
|
||||
shouldIssueBearerToken(request.headers) ||
|
||||
isNativeClientRequest(request.headers)
|
||||
) {
|
||||
const policy = resolveTokenPolicy(request.headers);
|
||||
const deviceId = request.headers.get("x-device-id");
|
||||
|
||||
if (policy.refreshTokenDays !== null) {
|
||||
const bundle = await issueAccessAndRefresh({
|
||||
userId: user.id,
|
||||
policy,
|
||||
deviceId,
|
||||
userAgent: ua,
|
||||
ipAddress: ip ?? null,
|
||||
source: "login.passkey",
|
||||
});
|
||||
await auditLog("auth.token.autoissue.native", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: { source: "login.passkey", policy: "native" },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.token.autoissue.native" },
|
||||
meta: { token_policy: "native" },
|
||||
});
|
||||
return apiSuccess({
|
||||
user: { id: user.id, username: user.username },
|
||||
token: bundle.accessToken,
|
||||
tokenExpiresAt: bundle.accessTokenExpiresAt.toISOString(),
|
||||
refreshToken: bundle.refreshToken,
|
||||
refreshTokenExpiresAt: bundle.refreshTokenExpiresAt.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
const issued = await issueApiToken({
|
||||
userId: user.id,
|
||||
name: `iOS auto-login ${new Date().toISOString()}`,
|
||||
name: `web auto-login ${new Date().toISOString()}`,
|
||||
permissions: ["*"],
|
||||
expiresInDays: 90,
|
||||
expiresInDays: policy.accessTokenDays,
|
||||
});
|
||||
await auditLog("auth.token.autoissue.native", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: { tokenId: issued.tokenId, source: "login.passkey" },
|
||||
details: {
|
||||
tokenId: issued.tokenId,
|
||||
source: "login.passkey",
|
||||
policy: "web",
|
||||
},
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.token.autoissue.native" },
|
||||
meta: { token_policy: "web" },
|
||||
});
|
||||
annotate({ action: { name: "auth.token.autoissue.native" } });
|
||||
|
||||
return apiSuccess({
|
||||
user: { id: user.id, username: user.username },
|
||||
token: issued.token,
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,8 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { defaultLocale, type Locale } from "@/lib/i18n/config";
|
||||
|
||||
const SPARK_DAYS = 7;
|
||||
const STREAK_WINDOW_DAYS = 365;
|
||||
@@ -159,8 +161,8 @@ export const GET = apiHandler(async () => {
|
||||
...measurementTypeEnum.options,
|
||||
] as MeasurementType[];
|
||||
|
||||
const [recentMeasurements, todaysIntakes, streakActivity] =
|
||||
await Promise.all([
|
||||
const [recentMeasurements, todaysIntakes, streakActivity] = await Promise.all(
|
||||
[
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
@@ -185,10 +187,12 @@ export const GET = apiHandler(async () => {
|
||||
},
|
||||
select: { takenAt: true, scheduledFor: true },
|
||||
}),
|
||||
]);
|
||||
],
|
||||
);
|
||||
|
||||
const activityDays = new Set<string>();
|
||||
for (const m of recentMeasurements) activityDays.add(berlinDayKey(m.measuredAt));
|
||||
for (const m of recentMeasurements)
|
||||
activityDays.add(berlinDayKey(m.measuredAt));
|
||||
// Pull a wider measurement window for the streak so it isn't capped by
|
||||
// SPARK_DAYS — but only if the user has any data at all.
|
||||
if (recentMeasurements.length > 0) {
|
||||
@@ -333,16 +337,19 @@ export const GET = apiHandler(async () => {
|
||||
).length;
|
||||
|
||||
const greetingName = user.displayName ?? user.username;
|
||||
const locale: Locale =
|
||||
user.locale === "de" || user.locale === "en" ? user.locale : defaultLocale;
|
||||
const t = getServerTranslator(locale).t;
|
||||
|
||||
return apiSuccess({
|
||||
greeting: {
|
||||
salutation: `Hi, ${greetingName}`,
|
||||
salutation: t("dashboard.greetingSalutation", { name: greetingName }),
|
||||
date: now.toISOString(),
|
||||
},
|
||||
streak: {
|
||||
currentDays: streak.currentDays,
|
||||
longest: streak.longest,
|
||||
label: "Tage in Folge",
|
||||
label: t("dashboard.streakLabel"),
|
||||
},
|
||||
compliance: {
|
||||
scheduledToday,
|
||||
|
||||
@@ -17,13 +17,17 @@ import {
|
||||
classifyPulseByTarget,
|
||||
getPersonalizedPulseTarget,
|
||||
} from "@/lib/analytics/pulse-targets";
|
||||
import type { MeasurementType, GlucoseContext } from "@/generated/prisma/client";
|
||||
import type {
|
||||
MeasurementType,
|
||||
GlucoseContext,
|
||||
} from "@/generated/prisma/client";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
getEffectiveRange,
|
||||
type ThresholdOverridesJson,
|
||||
} from "@/lib/analytics/effective-range";
|
||||
import { getBodyFatTargetRange } from "@/lib/analytics/value-bands";
|
||||
import { thresholdMetricForContext, resolveGlucoseUnit } from "@/lib/glucose";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -105,20 +109,24 @@ export const GET = apiHandler(async () => {
|
||||
select: { type: true, value: true, measuredAt: true },
|
||||
});
|
||||
|
||||
// Also get the absolute latest measurement per type (even if older than 30 days)
|
||||
// Also get the absolute latest measurement per type (even if older than 30 days).
|
||||
// Single grouped query replaces the previous N×findFirst loop — DISTINCT ON
|
||||
// (Postgres) emulated via a windowed orderBy + take-1 per type would still be
|
||||
// N round-trips, so we sort once on the wide query and pick per-type below.
|
||||
const latestEverByType = await prisma.measurement.findMany({
|
||||
where: { userId, type: { in: types } },
|
||||
orderBy: { measuredAt: "desc" },
|
||||
distinct: ["type"],
|
||||
select: { type: true, value: true },
|
||||
});
|
||||
|
||||
const latestByType: Record<string, number | null> = {};
|
||||
const avg30ByType: Record<string, number | null> = {};
|
||||
|
||||
for (const t of types) {
|
||||
// Latest value
|
||||
const latest = await prisma.measurement.findFirst({
|
||||
where: { userId, type: t },
|
||||
orderBy: { measuredAt: "desc" },
|
||||
select: { value: true },
|
||||
});
|
||||
const latest = latestEverByType.find((row) => row.type === t);
|
||||
latestByType[t] = latest?.value ?? null;
|
||||
|
||||
// 30-day average
|
||||
const recentOfType = recentMeasurements.filter((m) => m.type === t);
|
||||
if (recentOfType.length > 0) {
|
||||
const sum = recentOfType.reduce((acc, m) => acc + m.value, 0);
|
||||
@@ -200,7 +208,7 @@ export const GET = apiHandler(async () => {
|
||||
unit: "mmHg",
|
||||
range: bpRange ? { min: bpRange.sysLow, max: bpRange.sysHigh } : null,
|
||||
classification: bpClassification,
|
||||
source: "ESC/ESH 2018",
|
||||
source: "ESH 2023",
|
||||
// Extra fields for diastolic
|
||||
} as TargetItem);
|
||||
|
||||
@@ -248,7 +256,7 @@ export const GET = apiHandler(async () => {
|
||||
? { category: "Moderate", color: "#f1fa8c" }
|
||||
: { category: "Low", color: "#ff5555" }
|
||||
: null,
|
||||
source: "ESC/ESH 2018",
|
||||
source: "ESH 2023",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -330,16 +338,12 @@ export const GET = apiHandler(async () => {
|
||||
const cls = classifyBodyFat(latestByType.BODY_FAT, gender, age);
|
||||
bodyFatClassification = { category: cls.category, color: cls.color };
|
||||
}
|
||||
// Body fat ranges depend on gender/age; provide simplified ranges
|
||||
let bodyFatRange: { min: number; max: number } | null = null;
|
||||
if (gender === "MALE") {
|
||||
bodyFatRange = { min: 14, max: 24 };
|
||||
} else if (gender === "FEMALE") {
|
||||
bodyFatRange = { min: 21, max: 31 };
|
||||
} else if (age != null) {
|
||||
// Average of male/female fitness+average ranges
|
||||
bodyFatRange = { min: 17.5, max: 27.5 };
|
||||
}
|
||||
// Single source of truth for the body-fat green band lives in
|
||||
// `value-bands.ts`. v1.3.3 had three different hardcoded ranges across
|
||||
// value-bands.ts / targets/route.ts / classifications.ts; the v1.4
|
||||
// marathon consolidated them onto `getBodyFatTargetRange` (ACE
|
||||
// fitness + acceptable bands).
|
||||
const bodyFatRange = age != null ? getBodyFatTargetRange(gender) : null;
|
||||
targets.push({
|
||||
type: "BODY_FAT",
|
||||
label: "Body fat",
|
||||
@@ -368,7 +372,13 @@ export const GET = apiHandler(async () => {
|
||||
unit: "steps",
|
||||
range: stepsRange,
|
||||
classification: stepsClassification,
|
||||
source: "WHO",
|
||||
// WHO publishes activity *time* (150–300 min/wk moderate),
|
||||
// not a step quota. The closest peer-reviewed dose-response
|
||||
// for the 8 000–15 000 band is Saint-Maurice JAMA 2020. The
|
||||
// AI prompts at src/lib/insights/prompts/{base-system,
|
||||
// general-status}.ts already enforce this attribution; this
|
||||
// surface label was the last "WHO" mislabel in the codebase.
|
||||
source: "Saint-Maurice JAMA 2020",
|
||||
});
|
||||
|
||||
// 8. Medication Compliance (average across active medications)
|
||||
@@ -585,7 +595,8 @@ export const GET = apiHandler(async () => {
|
||||
"BEDTIME",
|
||||
];
|
||||
const glucoseUnit = resolveGlucoseUnit(dbUser?.glucoseUnit ?? null);
|
||||
const overrides = (dbUser?.thresholdsJson ?? null) as ThresholdOverridesJson | null;
|
||||
const overrides = (dbUser?.thresholdsJson ??
|
||||
null) as ThresholdOverridesJson | null;
|
||||
const profileForRange = {
|
||||
heightCm,
|
||||
dateOfBirth: dbUser?.dateOfBirth ?? null,
|
||||
@@ -651,7 +662,10 @@ export const GET = apiHandler(async () => {
|
||||
});
|
||||
}
|
||||
|
||||
annotate({ action: { name: "insights.targets" }, meta: { targetCount: targets.length } });
|
||||
annotate({
|
||||
action: { name: "insights.targets" },
|
||||
meta: { targetCount: targets.length },
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
targets,
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({
|
||||
user: { id: "u-1" },
|
||||
session: { id: "s-1" },
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: vi.fn((x: string) => x),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
interface ApiErrorEnvelope {
|
||||
data: null;
|
||||
error: string;
|
||||
}
|
||||
interface ApiSuccessEnvelope<T> {
|
||||
data: T;
|
||||
error: null;
|
||||
}
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 5,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findUnique).mockReset();
|
||||
global.fetch = vi.fn() as never;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
function emptyRequest(): Request {
|
||||
return new Request("http://localhost/api/integrations/moodlog/test", {
|
||||
method: "POST",
|
||||
headers: { "content-length": "0" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("POST /api/integrations/moodlog/test", () => {
|
||||
it("happy path returns ok with shape", async () => {
|
||||
const lastSyncedAt = new Date("2026-05-01T10:00:00.000Z");
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
moodLogUrlEncrypted: "https://example.com",
|
||||
moodLogApiKeyEncrypted: "ml-key-123",
|
||||
moodLogLastSyncedAt: lastSyncedAt,
|
||||
} as never);
|
||||
(global.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ entries: [] }), { status: 200 }),
|
||||
);
|
||||
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as ApiSuccessEnvelope<{
|
||||
ok: boolean;
|
||||
statusCode: number;
|
||||
latencyMs: number;
|
||||
lastSyncedAt: string;
|
||||
}>;
|
||||
expect(body.data.ok).toBe(true);
|
||||
expect(body.data.statusCode).toBe(200);
|
||||
expect(typeof body.data.latencyMs).toBe("number");
|
||||
expect(body.data.lastSyncedAt).toBe(lastSyncedAt.toISOString());
|
||||
|
||||
// Probe must hit the actual sync endpoint with Bearer auth, not a bare HEAD
|
||||
// against the configured URL.
|
||||
const fetchCall = (global.fetch as unknown as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0];
|
||||
const init = fetchCall[1] as RequestInit;
|
||||
expect(init.method).toBe("GET");
|
||||
const headers = init.headers as Record<string, string>;
|
||||
expect(headers.Authorization).toBe("Bearer ml-key-123");
|
||||
expect(init.redirect).toBe("manual");
|
||||
});
|
||||
|
||||
it("rate-limit denial returns 429 with no upstream call", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(429);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 422 when no moodLog URL configured", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
moodLogUrlEncrypted: null,
|
||||
moodLogApiKeyEncrypted: null,
|
||||
moodLogLastSyncedAt: null,
|
||||
} as never);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(422);
|
||||
const body = (await response.json()) as ApiErrorEnvelope;
|
||||
expect(body.error).toMatch(/not configured/i);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects SSRF (private IP) with 422", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
moodLogUrlEncrypted: "http://127.0.0.1/moodlog",
|
||||
moodLogApiKeyEncrypted: "ml-key-123",
|
||||
moodLogLastSyncedAt: null,
|
||||
} as never);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(422);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not leak upstream URL or Bearer token on upstream rejection", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
moodLogUrlEncrypted: "https://example.com",
|
||||
moodLogApiKeyEncrypted: "sk-secret",
|
||||
moodLogLastSyncedAt: null,
|
||||
} as never);
|
||||
(global.fetch as unknown as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
||||
new Error(
|
||||
"Authorization: Bearer sk-secret echoed from https://example.com/api/integrations/health-log/mood",
|
||||
),
|
||||
);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
const text = await response.text();
|
||||
expect(response.status).toBe(502);
|
||||
expect(text).not.toMatch(/sk-/);
|
||||
expect(text).not.toMatch(/example\.com/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { isPublicUrl } from "@/lib/validations/notifications";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const TIMEOUT_MS = 5_000;
|
||||
|
||||
function categoriseHttpStatus(status: number): {
|
||||
code: string;
|
||||
message: string;
|
||||
} {
|
||||
if (status === 401 || status === 403) {
|
||||
return {
|
||||
code: "credentials_rejected",
|
||||
message: "moodLog rejected the API key",
|
||||
};
|
||||
}
|
||||
if (status === 404) {
|
||||
return {
|
||||
code: "endpoint_not_found",
|
||||
message: "moodLog endpoint not found at the configured URL",
|
||||
};
|
||||
}
|
||||
if (status === 429) {
|
||||
return {
|
||||
code: "rate_limited",
|
||||
message: "moodLog rate-limited the request",
|
||||
};
|
||||
}
|
||||
if (status >= 500) {
|
||||
return {
|
||||
code: "upstream_error",
|
||||
message: "moodLog returned a server error",
|
||||
};
|
||||
}
|
||||
return { code: "connection_failed", message: "moodLog connection failed" };
|
||||
}
|
||||
|
||||
function redact(text: string): string {
|
||||
return text
|
||||
.replace(/Bearer\s+\S+/gi, "Bearer [REDACTED]")
|
||||
.replace(/https?:\/\/\S+/gi, "[url]");
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async (_request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "integrations.moodlog.test" } });
|
||||
|
||||
const rl = await checkRateLimit(`moodlog-test:${user.id}`, 5, 60_000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many test requests", 429, {
|
||||
errorCode: "rate_limited_self",
|
||||
});
|
||||
}
|
||||
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
moodLogUrlEncrypted: true,
|
||||
moodLogApiKeyEncrypted: true,
|
||||
moodLogLastSyncedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!dbUser?.moodLogUrlEncrypted || !dbUser.moodLogApiKeyEncrypted) {
|
||||
return apiError("moodLog not configured", 422, {
|
||||
errorCode: "not_configured",
|
||||
});
|
||||
}
|
||||
|
||||
let baseUrl: string;
|
||||
let apiKey: string;
|
||||
try {
|
||||
baseUrl = decrypt(dbUser.moodLogUrlEncrypted);
|
||||
apiKey = decrypt(dbUser.moodLogApiKeyEncrypted);
|
||||
} catch {
|
||||
return apiError("moodLog credentials unreadable", 422, {
|
||||
errorCode: "credentials_unreadable",
|
||||
});
|
||||
}
|
||||
|
||||
if (!isPublicUrl(baseUrl)) {
|
||||
return apiError("moodLog URL must be a public HTTP(S) endpoint", 422, {
|
||||
errorCode: "url_not_public",
|
||||
});
|
||||
}
|
||||
|
||||
// Probe the actual sync endpoint with the actual auth header — a HEAD on the
|
||||
// bare URL would return 200 from the static landing page even if the API key
|
||||
// is invalid. Use a 1-day window to keep the upstream payload small.
|
||||
let probeUrl: URL;
|
||||
try {
|
||||
probeUrl = new URL("/api/integrations/health-log/mood", baseUrl);
|
||||
} catch {
|
||||
return apiError("moodLog URL is invalid", 422, {
|
||||
errorCode: "url_invalid",
|
||||
});
|
||||
}
|
||||
const yesterday = new Date(Date.now() - 24 * 3600 * 1000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
probeUrl.searchParams.set("from", yesterday);
|
||||
probeUrl.searchParams.set("to", today);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
const start = performance.now();
|
||||
|
||||
try {
|
||||
const res = await fetch(probeUrl.toString(), {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
signal: controller.signal,
|
||||
cache: "no-store",
|
||||
// `manual` neutralises the redirect-follow SSRF where a public host
|
||||
// serves a 302 to 169.254.169.254 (cloud metadata) or RFC1918 ranges.
|
||||
redirect: "manual",
|
||||
});
|
||||
const latencyMs = Math.round(performance.now() - start);
|
||||
|
||||
// 3xx in `manual` redirect mode → we never followed; treat as success
|
||||
// failure rather than chase the Location header.
|
||||
if (res.status >= 300 && res.status < 400) {
|
||||
annotate({ meta: { moodlog_test_status: res.status } });
|
||||
return apiError("moodLog redirected — check the configured URL", 502, {
|
||||
errorCode: "redirected",
|
||||
});
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const cat = categoriseHttpStatus(res.status);
|
||||
annotate({
|
||||
meta: {
|
||||
moodlog_test_status: res.status,
|
||||
moodlog_test_code: cat.code,
|
||||
moodlog_test_latency_ms: latencyMs,
|
||||
},
|
||||
});
|
||||
return apiError(cat.message, 502, { errorCode: cat.code });
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
ok: true,
|
||||
statusCode: res.status,
|
||||
latencyMs,
|
||||
lastSyncedAt: dbUser.moodLogLastSyncedAt,
|
||||
});
|
||||
} catch (e) {
|
||||
const err = e as Error;
|
||||
const isAbort = err.name === "AbortError";
|
||||
const code = isAbort ? "timeout" : "connection_failed";
|
||||
annotate({
|
||||
meta: {
|
||||
moodlog_test_code: code,
|
||||
moodlog_test_error: redact(err.message).slice(0, 300),
|
||||
},
|
||||
});
|
||||
return apiError(
|
||||
isAbort ? "moodLog request timed out" : "moodLog connection failed",
|
||||
502,
|
||||
{ errorCode: code },
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
// --- Module-boundary mocks must come before importing the route. ---
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
appSettings: { findUnique: vi.fn() },
|
||||
user: { findMany: vi.fn() },
|
||||
moodEntry: {
|
||||
upsert: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/moodlog-secret", () => ({
|
||||
readMoodLogSecret: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: vi.fn(() => ({ setAuth: vi.fn(), addWarning: vi.fn() })),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { readMoodLogSecret } from "@/lib/moodlog-secret";
|
||||
|
||||
const VALID_PAYLOAD = {
|
||||
event: "mood.created" as const,
|
||||
timestamp: "2026-05-08T12:00:00.000Z",
|
||||
entry: {
|
||||
date: "2026-05-08",
|
||||
time: "2026-05-08T12:00:00.000Z",
|
||||
mood: "GUT" as const,
|
||||
score: 4,
|
||||
tags: ["walk", "sun"],
|
||||
loggedVia: "WEB" as const,
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 30,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
moodLogGlobal: true,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findMany).mockResolvedValue([
|
||||
{ id: "user-1", moodLogWebhookSecret: "encrypted-blob-1" },
|
||||
] as never);
|
||||
vi.mocked(readMoodLogSecret).mockReturnValue("plaintext-secret");
|
||||
vi.mocked(prisma.moodEntry.upsert).mockResolvedValue({} as never);
|
||||
vi.mocked(prisma.moodEntry.deleteMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
});
|
||||
|
||||
function jsonRequest(
|
||||
body: unknown,
|
||||
headers: Record<string, string> = {},
|
||||
): NextRequest {
|
||||
return new NextRequest("http://localhost/api/integrations/moodlog/webhook", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", ...headers },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
describe("POST /api/integrations/moodlog/webhook", () => {
|
||||
it("returns 401 when X-Webhook-Secret header is missing", async () => {
|
||||
const res = await POST(jsonRequest(VALID_PAYLOAD));
|
||||
expect(res.status).toBe(401);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toMatch(/missing/i);
|
||||
expect(prisma.moodEntry.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 401 when no enabled user matches the supplied secret", async () => {
|
||||
// The single candidate decrypts to a different plaintext.
|
||||
vi.mocked(readMoodLogSecret).mockReturnValue("different-plaintext");
|
||||
|
||||
const res = await POST(
|
||||
jsonRequest(VALID_PAYLOAD, { "x-webhook-secret": "plaintext-secret" }),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
expect(readMoodLogSecret).toHaveBeenCalledWith("encrypted-blob-1");
|
||||
expect(prisma.moodEntry.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("decrypts each candidate via readMoodLogSecret and matches timing-safely", async () => {
|
||||
vi.mocked(prisma.user.findMany).mockResolvedValueOnce([
|
||||
{ id: "user-A", moodLogWebhookSecret: "blob-A" },
|
||||
{ id: "user-B", moodLogWebhookSecret: "blob-B" },
|
||||
] as never);
|
||||
|
||||
// First candidate decrypts to a wrong value, second decrypts to the
|
||||
// submitted secret. The route must walk the list and pick user-B.
|
||||
vi.mocked(readMoodLogSecret)
|
||||
.mockReturnValueOnce("nope")
|
||||
.mockReturnValueOnce("plaintext-secret");
|
||||
|
||||
const res = await POST(
|
||||
jsonRequest(VALID_PAYLOAD, { "x-webhook-secret": "plaintext-secret" }),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(readMoodLogSecret).toHaveBeenNthCalledWith(1, "blob-A");
|
||||
expect(readMoodLogSecret).toHaveBeenNthCalledWith(2, "blob-B");
|
||||
const upsertArgs = vi.mocked(prisma.moodEntry.upsert).mock.calls[0][0];
|
||||
// The route must use the matched user's id, not the first candidate's.
|
||||
expect(
|
||||
(upsertArgs.where as { userId_date_moodLoggedAt: { userId: string } })
|
||||
.userId_date_moodLoggedAt.userId,
|
||||
).toBe("user-B");
|
||||
});
|
||||
|
||||
it("happy path: valid secret + mood.created upserts a mood entry tagged MOODLOG", async () => {
|
||||
const res = await POST(
|
||||
jsonRequest(VALID_PAYLOAD, { "x-webhook-secret": "plaintext-secret" }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(prisma.moodEntry.upsert).toHaveBeenCalledTimes(1);
|
||||
const args = vi.mocked(prisma.moodEntry.upsert).mock.calls[0][0];
|
||||
expect(args).toMatchObject({
|
||||
where: {
|
||||
userId_date_moodLoggedAt: {
|
||||
userId: "user-1",
|
||||
date: "2026-05-08",
|
||||
moodLoggedAt: new Date("2026-05-08T12:00:00.000Z"),
|
||||
},
|
||||
},
|
||||
update: {
|
||||
mood: "GUT",
|
||||
score: 4,
|
||||
tags: JSON.stringify(["walk", "sun"]),
|
||||
source: "MOODLOG",
|
||||
},
|
||||
create: {
|
||||
userId: "user-1",
|
||||
date: "2026-05-08",
|
||||
mood: "GUT",
|
||||
score: 4,
|
||||
tags: JSON.stringify(["walk", "sun"]),
|
||||
source: "MOODLOG",
|
||||
moodLoggedAt: new Date("2026-05-08T12:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("idempotency: a duplicate mood.updated event hits upsert with the same composite key, so the DB no-ops", async () => {
|
||||
const updated = { ...VALID_PAYLOAD, event: "mood.updated" as const };
|
||||
|
||||
const r1 = await POST(
|
||||
jsonRequest(updated, { "x-webhook-secret": "plaintext-secret" }),
|
||||
);
|
||||
const r2 = await POST(
|
||||
jsonRequest(updated, { "x-webhook-secret": "plaintext-secret" }),
|
||||
);
|
||||
|
||||
expect(r1.status).toBe(200);
|
||||
expect(r2.status).toBe(200);
|
||||
expect(prisma.moodEntry.upsert).toHaveBeenCalledTimes(2);
|
||||
const wheres = vi.mocked(prisma.moodEntry.upsert).mock.calls.map(
|
||||
(c) =>
|
||||
(
|
||||
c[0].where as {
|
||||
userId_date_moodLoggedAt: {
|
||||
userId: string;
|
||||
date: string;
|
||||
moodLoggedAt: Date;
|
||||
};
|
||||
}
|
||||
).userId_date_moodLoggedAt,
|
||||
);
|
||||
// Both calls target the same composite key — Postgres ON CONFLICT
|
||||
// makes the second upsert a no-op write.
|
||||
expect(wheres[0]).toEqual(wheres[1]);
|
||||
});
|
||||
|
||||
it("mood.deleted removes any matching entry rather than upserting", async () => {
|
||||
const deletedPayload = {
|
||||
...VALID_PAYLOAD,
|
||||
event: "mood.deleted" as const,
|
||||
};
|
||||
const res = await POST(
|
||||
jsonRequest(deletedPayload, {
|
||||
"x-webhook-secret": "plaintext-secret",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.moodEntry.upsert).not.toHaveBeenCalled();
|
||||
expect(prisma.moodEntry.deleteMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
userId: "user-1",
|
||||
date: "2026-05-08",
|
||||
moodLoggedAt: new Date("2026-05-08T12:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("webhook.test ping short-circuits to 200 with no DB writes", async () => {
|
||||
const res = await POST(
|
||||
jsonRequest(
|
||||
{ event: "webhook.test" },
|
||||
{ "x-webhook-secret": "plaintext-secret" },
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.moodEntry.upsert).not.toHaveBeenCalled();
|
||||
expect(prisma.moodEntry.deleteMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects payloads that don't match the schema with 400", async () => {
|
||||
const bad = {
|
||||
event: "mood.created",
|
||||
timestamp: "2026-05-08T12:00:00.000Z",
|
||||
// entry missing required fields
|
||||
entry: { date: "not-a-date", time: "x", mood: "BAD", score: 0 },
|
||||
};
|
||||
const res = await POST(
|
||||
jsonRequest(bad, { "x-webhook-secret": "plaintext-secret" }),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toMatch(/invalid payload/i);
|
||||
expect(prisma.moodEntry.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 403 when the global moodLog toggle is disabled", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
|
||||
moodLogGlobal: false,
|
||||
} as never);
|
||||
const res = await POST(
|
||||
jsonRequest(VALID_PAYLOAD, { "x-webhook-secret": "plaintext-secret" }),
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
expect(prisma.user.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 429 when rate limit is exceeded — no secret lookup performed", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
const res = await POST(
|
||||
jsonRequest(VALID_PAYLOAD, { "x-webhook-secret": "plaintext-secret" }),
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
expect(prisma.user.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({
|
||||
user: { id: "u-1" },
|
||||
session: { id: "s-1" },
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
withingsConnection: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: vi.fn((x: string) => x),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/withings/sync", () => ({
|
||||
getValidToken: vi.fn(),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { getValidToken } from "@/lib/withings/sync";
|
||||
|
||||
interface ApiErrorEnvelope {
|
||||
data: null;
|
||||
error: string;
|
||||
}
|
||||
interface ApiSuccessEnvelope<T> {
|
||||
data: T;
|
||||
error: null;
|
||||
}
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 5,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
vi.mocked(prisma.withingsConnection.findUnique).mockReset();
|
||||
vi.mocked(getValidToken).mockReset();
|
||||
global.fetch = vi.fn() as never;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
function emptyRequest(): Request {
|
||||
return new Request("http://localhost/api/integrations/withings/test", {
|
||||
method: "POST",
|
||||
headers: { "content-length": "0" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("POST /api/integrations/withings/test", () => {
|
||||
it("happy path returns ok with shape", async () => {
|
||||
const lastSyncedAt = new Date("2026-05-01T10:00:00.000Z");
|
||||
vi.mocked(getValidToken).mockResolvedValueOnce({
|
||||
accessToken: "plain-access",
|
||||
connection: { id: "wc-1", withingsUserId: "wu-1" },
|
||||
});
|
||||
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValueOnce({
|
||||
lastSyncedAt,
|
||||
} as never);
|
||||
(global.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ status: 0, body: {} }), { status: 200 }),
|
||||
);
|
||||
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as ApiSuccessEnvelope<{
|
||||
ok: boolean;
|
||||
lastSyncedAt: string;
|
||||
latencyMs: number;
|
||||
}>;
|
||||
expect(body.data.ok).toBe(true);
|
||||
expect(typeof body.data.latencyMs).toBe("number");
|
||||
expect(body.data.lastSyncedAt).toBe(lastSyncedAt.toISOString());
|
||||
});
|
||||
|
||||
it("rate-limit denial returns 429 with no upstream call", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(429);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 422 when no Withings connection exists", async () => {
|
||||
vi.mocked(getValidToken).mockResolvedValueOnce(null);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(422);
|
||||
const body = (await response.json()) as ApiErrorEnvelope;
|
||||
expect(body.error).toMatch(/not connected/i);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sanitises 401 upstream — does not leak Bearer token or upstream URL", async () => {
|
||||
vi.mocked(getValidToken).mockResolvedValueOnce({
|
||||
accessToken: "sk-secret",
|
||||
connection: { id: "wc-1", withingsUserId: "wu-1" },
|
||||
});
|
||||
vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValueOnce({
|
||||
lastSyncedAt: null,
|
||||
} as never);
|
||||
(global.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
error: "Authorization: Bearer sk-secret echoed",
|
||||
url: "https://wbsapi.withings.net/measure",
|
||||
}),
|
||||
{ status: 401 },
|
||||
),
|
||||
);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
const text = await response.text();
|
||||
expect(response.status).toBe(502);
|
||||
expect(text).not.toMatch(/sk-/);
|
||||
expect(text).not.toMatch(/wbsapi\.withings\.net/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getValidToken } from "@/lib/withings/sync";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const WITHINGS_MEASURE_URL = "https://wbsapi.withings.net/measure";
|
||||
const TIMEOUT_MS = 5_000;
|
||||
|
||||
type CategorisedError = { code: string; message: string };
|
||||
|
||||
function categoriseHttpStatus(status: number): CategorisedError {
|
||||
if (status === 401 || status === 403) {
|
||||
return {
|
||||
code: "credentials_rejected",
|
||||
message: "Withings rejected the credentials",
|
||||
};
|
||||
}
|
||||
if (status === 429) {
|
||||
return {
|
||||
code: "rate_limited",
|
||||
message: "Withings rate-limited the request",
|
||||
};
|
||||
}
|
||||
if (status >= 500) {
|
||||
return {
|
||||
code: "upstream_error",
|
||||
message: "Withings returned a server error",
|
||||
};
|
||||
}
|
||||
return { code: "connection_failed", message: "Withings connection failed" };
|
||||
}
|
||||
|
||||
// Withings returns HTTP 200 with `status` in the JSON body to signal API-level
|
||||
// errors. Reference: https://developer.withings.com/api-reference/#section/Response-status
|
||||
function categoriseApiStatus(apiStatus: number): CategorisedError {
|
||||
switch (apiStatus) {
|
||||
case 100:
|
||||
case 101:
|
||||
case 102:
|
||||
case 401:
|
||||
return {
|
||||
code: "credentials_rejected",
|
||||
message: "Withings rejected the credentials",
|
||||
};
|
||||
case 601:
|
||||
return {
|
||||
code: "rate_limited",
|
||||
message: "Withings rate-limited the request",
|
||||
};
|
||||
case 503:
|
||||
return {
|
||||
code: "upstream_error",
|
||||
message: "Withings reported a temporary server error",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
code: "upstream_error",
|
||||
message: "Withings returned an error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async (_request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "integrations.withings.test" } });
|
||||
|
||||
const rl = await checkRateLimit(`withings-test:${user.id}`, 5, 60_000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many test requests", 429, {
|
||||
errorCode: "rate_limited_self",
|
||||
});
|
||||
}
|
||||
|
||||
const tokenInfo = await getValidToken(user.id);
|
||||
if (!tokenInfo) {
|
||||
return apiError("Withings not connected", 422, {
|
||||
errorCode: "not_configured",
|
||||
});
|
||||
}
|
||||
|
||||
const connection = await prisma.withingsConnection.findUnique({
|
||||
where: { userId: user.id },
|
||||
select: { lastSyncedAt: true },
|
||||
});
|
||||
|
||||
const params = new URLSearchParams({
|
||||
action: "getmeas",
|
||||
meastypes: "1",
|
||||
lastupdate: Math.floor(Date.now() / 1000 - 60).toString(),
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
const start = performance.now();
|
||||
|
||||
try {
|
||||
const res = await fetch(WITHINGS_MEASURE_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Authorization: `Bearer ${tokenInfo.accessToken}`,
|
||||
},
|
||||
body: params.toString(),
|
||||
signal: controller.signal,
|
||||
redirect: "manual",
|
||||
});
|
||||
const latencyMs = Math.round(performance.now() - start);
|
||||
|
||||
if (!res.ok) {
|
||||
const cat = categoriseHttpStatus(res.status);
|
||||
annotate({
|
||||
meta: {
|
||||
withings_test_status: res.status,
|
||||
withings_test_code: cat.code,
|
||||
withings_test_latency_ms: latencyMs,
|
||||
},
|
||||
});
|
||||
return apiError(cat.message, 502, { errorCode: cat.code });
|
||||
}
|
||||
|
||||
let json: { status?: number } = {};
|
||||
try {
|
||||
json = (await res.json()) as { status?: number };
|
||||
} catch {
|
||||
annotate({
|
||||
meta: { withings_test_code: "upstream_invalid_json" },
|
||||
});
|
||||
return apiError("Withings returned an unparseable response", 502, {
|
||||
errorCode: "upstream_invalid_json",
|
||||
});
|
||||
}
|
||||
|
||||
if (json.status !== 0) {
|
||||
const apiStatus = json.status ?? -1;
|
||||
const cat = categoriseApiStatus(apiStatus);
|
||||
annotate({
|
||||
meta: {
|
||||
withings_test_api_status: apiStatus,
|
||||
withings_test_code: cat.code,
|
||||
withings_test_latency_ms: latencyMs,
|
||||
},
|
||||
});
|
||||
return apiError(cat.message, 502, { errorCode: cat.code });
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
ok: true,
|
||||
lastSyncedAt: connection?.lastSyncedAt ?? null,
|
||||
latencyMs,
|
||||
});
|
||||
} catch (e) {
|
||||
const err = e as Error;
|
||||
const isAbort = err.name === "AbortError";
|
||||
const code = isAbort ? "timeout" : "connection_failed";
|
||||
annotate({
|
||||
meta: {
|
||||
withings_test_code: code,
|
||||
withings_test_error: redact(err.message).slice(0, 300),
|
||||
},
|
||||
});
|
||||
return apiError(
|
||||
isAbort ? "Withings request timed out" : "Withings connection failed",
|
||||
502,
|
||||
{ errorCode: code },
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
});
|
||||
|
||||
// Strip any `Bearer <token>` substring before annotating; the catch path can
|
||||
// see DNS / TLS errors that include the request init via err.cause in some
|
||||
// runtimes, and we never want a token in Loki.
|
||||
function redact(text: string): string {
|
||||
return text.replace(/Bearer\s+\S+/gi, "Bearer [REDACTED]");
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({ prisma: {} }));
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(h: T): T =>
|
||||
h,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/monitoring-settings", () => ({
|
||||
getGlitchtipSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/monitoring/glitchtip", () => ({
|
||||
sendGlitchtipEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: vi.fn(() => ({ addWarning: vi.fn() })),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { getGlitchtipSettings } from "@/lib/monitoring-settings";
|
||||
import { sendGlitchtipEvent } from "@/lib/monitoring/glitchtip";
|
||||
|
||||
function jsonReq(body: unknown, contentType = "application/json"): NextRequest {
|
||||
return new NextRequest("http://localhost/api/monitoring/glitchtip", {
|
||||
method: "POST",
|
||||
headers: { "content-type": contentType },
|
||||
body: typeof body === "string" ? body : JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 19,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
vi.mocked(getGlitchtipSettings).mockResolvedValue({
|
||||
glitchtipEnabled: true,
|
||||
glitchtipDsn: "https://pub@glitchtip.example.com/1",
|
||||
glitchtipEnvironment: "production",
|
||||
});
|
||||
vi.mocked(sendGlitchtipEvent).mockResolvedValue({
|
||||
ok: true,
|
||||
method: "envelope",
|
||||
status: 200,
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/monitoring/glitchtip", () => {
|
||||
it("returns 429 when rate limit is exceeded", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
const res = await POST(jsonReq({ message: "x" }));
|
||||
expect(res.status).toBe(429);
|
||||
expect(sendGlitchtipEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("short-circuits with skipped:true when GlitchTip is disabled", async () => {
|
||||
vi.mocked(getGlitchtipSettings).mockResolvedValue({
|
||||
glitchtipEnabled: false,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: null,
|
||||
});
|
||||
const res = await POST(jsonReq({ message: "boom" }));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { skipped: boolean } };
|
||||
expect(body.data.skipped).toBe(true);
|
||||
expect(sendGlitchtipEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("short-circuits when DSN is empty even if globally enabled", async () => {
|
||||
vi.mocked(getGlitchtipSettings).mockResolvedValue({
|
||||
glitchtipEnabled: true,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: null,
|
||||
});
|
||||
const res = await POST(jsonReq({ message: "boom" }));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { skipped: boolean } };
|
||||
expect(body.data.skipped).toBe(true);
|
||||
});
|
||||
|
||||
it("returns 422 when JSON parse fails", async () => {
|
||||
const res = await POST(jsonReq("{not-json", "application/json"));
|
||||
expect(res.status).toBe(422);
|
||||
expect(sendGlitchtipEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 422 when payload fails schema (missing message)", async () => {
|
||||
const res = await POST(jsonReq({ stack: "boom" }));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("returns 422 when message exceeds size cap (2000 chars)", async () => {
|
||||
// The Zod schema caps `message` at 2000 chars — bigger payloads must
|
||||
// be rejected before they ever leave the box.
|
||||
const huge = "x".repeat(2001);
|
||||
const res = await POST(jsonReq({ message: huge }));
|
||||
expect(res.status).toBe(422);
|
||||
expect(sendGlitchtipEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 422 when stack exceeds 20 000 chars", async () => {
|
||||
const res = await POST(
|
||||
jsonReq({ message: "boom", stack: "x".repeat(20001) }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("forwards a well-formed event with default level=error to the configured DSN", async () => {
|
||||
const res = await POST(
|
||||
jsonReq({
|
||||
message: "Boom!",
|
||||
stack: "Error: Boom!\n at foo",
|
||||
type: "ReferenceError",
|
||||
url: "https://app.example.com/page",
|
||||
userAgent: "Mozilla/5.0",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { sent: boolean } };
|
||||
expect(body.data.sent).toBe(true);
|
||||
|
||||
expect(sendGlitchtipEvent).toHaveBeenCalledTimes(1);
|
||||
const args = vi.mocked(sendGlitchtipEvent).mock.calls[0]?.[0];
|
||||
expect(args?.dsn).toBe("https://pub@glitchtip.example.com/1");
|
||||
expect(args?.input).toMatchObject({
|
||||
environment: "production",
|
||||
message: "Boom!",
|
||||
level: "error",
|
||||
type: "ReferenceError",
|
||||
sourceTag: "healthlog-client",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the literal 'production' env when settings.glitchtipEnvironment is null", async () => {
|
||||
vi.mocked(getGlitchtipSettings).mockResolvedValue({
|
||||
glitchtipEnabled: true,
|
||||
glitchtipDsn: "https://pub@glitchtip.example.com/1",
|
||||
glitchtipEnvironment: null,
|
||||
});
|
||||
await POST(jsonReq({ message: "boom" }));
|
||||
const args = vi.mocked(sendGlitchtipEvent).mock.calls[0]?.[0];
|
||||
expect(args?.input.environment).toBe("production");
|
||||
});
|
||||
|
||||
it("returns 502 when the upstream delivery fails", async () => {
|
||||
vi.mocked(sendGlitchtipEvent).mockResolvedValue({
|
||||
ok: false,
|
||||
method: "envelope",
|
||||
status: 500,
|
||||
details: "internal",
|
||||
});
|
||||
const res = await POST(jsonReq({ message: "boom" }));
|
||||
expect(res.status).toBe(502);
|
||||
});
|
||||
|
||||
it("rate-limits per IP — uses x-forwarded-for as the bucket key", async () => {
|
||||
const r = new NextRequest("http://localhost/api/monitoring/glitchtip", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-forwarded-for": "10.0.0.42",
|
||||
},
|
||||
body: JSON.stringify({ message: "boom" }),
|
||||
});
|
||||
await POST(r);
|
||||
const callArgs = vi.mocked(checkRateLimit).mock.calls[0];
|
||||
// First arg is the bucket key — must include the resolved client IP.
|
||||
expect(callArgs?.[0]).toContain("10.0.0.42");
|
||||
// 20 requests / 60 000 ms — guard the documented sliding window.
|
||||
expect(callArgs?.[1]).toBe(20);
|
||||
expect(callArgs?.[2]).toBe(60_000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAdmin: vi.fn(async () => ({
|
||||
user: { id: "u-admin" },
|
||||
session: { id: "s-1" },
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
appSettings: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: vi.fn((x: string) => x),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/monitoring/glitchtip", () => ({
|
||||
sendGlitchtipEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { sendGlitchtipEvent } from "@/lib/monitoring/glitchtip";
|
||||
|
||||
interface ApiErrorEnvelope {
|
||||
data: null;
|
||||
error: string;
|
||||
}
|
||||
interface ApiSuccessEnvelope<T> {
|
||||
data: T;
|
||||
error: null;
|
||||
}
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 5,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockReset();
|
||||
vi.mocked(sendGlitchtipEvent).mockReset();
|
||||
global.fetch = vi.fn() as never;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
function emptyRequest(): Request {
|
||||
return new Request("http://localhost/api/monitoring/glitchtip/test", {
|
||||
method: "POST",
|
||||
headers: { "content-length": "0" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("POST /api/monitoring/glitchtip/test", () => {
|
||||
it("happy path returns ok with statusCode", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
|
||||
glitchtipDsn: "https://abc@glitch.example.com/1",
|
||||
} as never);
|
||||
vi.mocked(sendGlitchtipEvent).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
method: "envelope",
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as ApiSuccessEnvelope<{
|
||||
ok: boolean;
|
||||
statusCode: number;
|
||||
latencyMs: number;
|
||||
}>;
|
||||
expect(body.data.ok).toBe(true);
|
||||
expect(body.data.statusCode).toBe(200);
|
||||
expect(typeof body.data.latencyMs).toBe("number");
|
||||
|
||||
// CRITICAL guard against the `id: "default"` typo regression.
|
||||
expect(prisma.appSettings.findUnique).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { id: "singleton" } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects HTTP DSN with 422", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
|
||||
glitchtipDsn: "http://abc@glitch.example.com/1",
|
||||
} as never);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(422);
|
||||
const body = (await response.json()) as ApiErrorEnvelope & {
|
||||
meta: { errorCode: string };
|
||||
};
|
||||
expect(body.meta.errorCode).toBe("dsn_not_https");
|
||||
expect(sendGlitchtipEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects DSN pointing at a private host (SSRF guard)", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
|
||||
glitchtipDsn: "https://abc@127.0.0.1/1",
|
||||
} as never);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(422);
|
||||
const body = (await response.json()) as ApiErrorEnvelope & {
|
||||
meta: { errorCode: string };
|
||||
};
|
||||
expect(body.meta.errorCode).toBe("dsn_host_not_public");
|
||||
expect(sendGlitchtipEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rate-limit denial returns 429 with no upstream call", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(429);
|
||||
expect(sendGlitchtipEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 422 when DSN is not configured", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
|
||||
glitchtipDsn: null,
|
||||
} as never);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(422);
|
||||
const body = (await response.json()) as ApiErrorEnvelope;
|
||||
expect(body.error).toMatch(/not configured/i);
|
||||
expect(sendGlitchtipEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not leak DSN/Bearer/sk- on upstream 401", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
|
||||
glitchtipDsn: "https://abc@glitch.example.com/1",
|
||||
} as never);
|
||||
vi.mocked(sendGlitchtipEvent).mockRejectedValueOnce(
|
||||
new Error(
|
||||
"401 Unauthorized: Authorization: Bearer sk-secret from https://glitch.example.com/api/1/envelope/",
|
||||
),
|
||||
);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
const text = await response.text();
|
||||
expect(response.status).toBe(502);
|
||||
expect(text).not.toMatch(/sk-/);
|
||||
expect(text).not.toMatch(/glitch\.example\.com/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { sendGlitchtipEvent } from "@/lib/monitoring/glitchtip";
|
||||
import { isPublicUrl } from "@/lib/validations/notifications";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const TIMEOUT_MS = 5_000;
|
||||
|
||||
export const POST = apiHandler(async (_request: NextRequest) => {
|
||||
const { user } = await requireAdmin();
|
||||
annotate({ action: { name: "monitoring.glitchtip.test" } });
|
||||
|
||||
const rl = await checkRateLimit(`glitchtip-test:${user.id}`, 5, 60_000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many test requests", 429, {
|
||||
errorCode: "rate_limited_self",
|
||||
});
|
||||
}
|
||||
|
||||
const settings = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: { glitchtipDsn: true },
|
||||
});
|
||||
|
||||
if (!settings?.glitchtipDsn) {
|
||||
return apiError("Glitchtip DSN not configured", 422, {
|
||||
errorCode: "not_configured",
|
||||
});
|
||||
}
|
||||
|
||||
// DSN is admin-set, but a compromised/social-engineered admin (or a stored
|
||||
// settings record from a hostile import) could aim Glitchtip at internal
|
||||
// hosts. Validate the host is publicly reachable and uses HTTPS before we
|
||||
// hand the URL to the helper.
|
||||
let dsnHost: string;
|
||||
try {
|
||||
const parsed = new URL(settings.glitchtipDsn);
|
||||
if (parsed.protocol !== "https:") {
|
||||
return apiError("Glitchtip DSN must use HTTPS", 422, {
|
||||
errorCode: "dsn_not_https",
|
||||
});
|
||||
}
|
||||
if (!isPublicUrl(`${parsed.protocol}//${parsed.host}`)) {
|
||||
return apiError("Glitchtip DSN host is not publicly reachable", 422, {
|
||||
errorCode: "dsn_host_not_public",
|
||||
});
|
||||
}
|
||||
dsnHost = parsed.host;
|
||||
} catch {
|
||||
return apiError("Glitchtip DSN is malformed", 422, {
|
||||
errorCode: "dsn_invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const start = performance.now();
|
||||
const send = sendGlitchtipEvent({
|
||||
dsn: settings.glitchtipDsn,
|
||||
input: {
|
||||
environment: process.env.NODE_ENV ?? "production",
|
||||
message: "HealthLog Glitchtip self-test",
|
||||
level: "info",
|
||||
// sourceTag rides through into the event tags. Adding "noise: true" lets
|
||||
// operators silence these in their inbound Glitchtip rules so the test
|
||||
// button doesn't pollute the issue inbox.
|
||||
sourceTag: "self-test:noise",
|
||||
},
|
||||
});
|
||||
|
||||
const timeoutErr = new Error("timeout");
|
||||
timeoutErr.name = "AbortError";
|
||||
const timeout = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(timeoutErr), TIMEOUT_MS),
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await Promise.race([send, timeout]);
|
||||
const latencyMs = Math.round(performance.now() - start);
|
||||
|
||||
if (!result.ok) {
|
||||
annotate({
|
||||
meta: {
|
||||
glitchtip_test_status: result.status ?? null,
|
||||
glitchtip_test_method: result.method ?? null,
|
||||
glitchtip_test_latency_ms: latencyMs,
|
||||
glitchtip_test_host: dsnHost,
|
||||
},
|
||||
});
|
||||
return apiError("Glitchtip rejected the event", 502, {
|
||||
errorCode: "upstream_error",
|
||||
});
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
ok: true,
|
||||
statusCode: result.status ?? 200,
|
||||
latencyMs,
|
||||
});
|
||||
} catch (e) {
|
||||
const err = e as Error;
|
||||
const isTimeout = err.name === "AbortError";
|
||||
const code = isTimeout ? "timeout" : "connection_failed";
|
||||
annotate({
|
||||
meta: {
|
||||
glitchtip_test_code: code,
|
||||
glitchtip_test_host: dsnHost,
|
||||
glitchtip_test_error: err.message.slice(0, 200),
|
||||
},
|
||||
});
|
||||
return apiError(
|
||||
isTimeout ? "Glitchtip request timed out" : "Glitchtip connection failed",
|
||||
502,
|
||||
{ errorCode: code },
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAdmin: vi.fn(async () => ({
|
||||
user: { id: "u-admin" },
|
||||
session: { id: "s-1" },
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
appSettings: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: vi.fn((x: string) => x),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
interface ApiErrorEnvelope {
|
||||
data: null;
|
||||
error: string;
|
||||
}
|
||||
interface ApiSuccessEnvelope<T> {
|
||||
data: T;
|
||||
error: null;
|
||||
}
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 5,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockReset();
|
||||
global.fetch = vi.fn() as never;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
function emptyRequest(): Request {
|
||||
return new Request("http://localhost/api/monitoring/umami/test", {
|
||||
method: "POST",
|
||||
headers: { "content-length": "0" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("POST /api/monitoring/umami/test", () => {
|
||||
it("happy path returns ok with hasMarker=true", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
|
||||
umamiScriptUrl: "https://analytics.example.com/script.js",
|
||||
} as never);
|
||||
(global.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
|
||||
new Response("(function(){window.umami=function(){};})();", {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as ApiSuccessEnvelope<{
|
||||
ok: boolean;
|
||||
statusCode: number;
|
||||
hasMarker: boolean;
|
||||
}>;
|
||||
expect(body.data.ok).toBe(true);
|
||||
expect(body.data.hasMarker).toBe(true);
|
||||
expect(body.data.statusCode).toBe(200);
|
||||
|
||||
// CRITICAL guard: AppSettings is keyed on `id: "singleton"` everywhere
|
||||
// else in the codebase. A typo here ("default") would silently make the
|
||||
// route return 422 in prod even with valid settings.
|
||||
expect(prisma.appSettings.findUnique).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { id: "singleton" } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rate-limit denial returns 429 with no upstream call", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(429);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 422 when URL not configured", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
|
||||
umamiScriptUrl: null,
|
||||
} as never);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(422);
|
||||
const body = (await response.json()) as ApiErrorEnvelope;
|
||||
expect(body.error).toMatch(/not configured/i);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not leak Bearer/sk- or upstream URL on upstream 401", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
|
||||
umamiScriptUrl: "https://analytics.example.com/script.js",
|
||||
} as never);
|
||||
(global.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
|
||||
new Response(
|
||||
"Authorization: Bearer sk-secret echoed back from https://analytics.example.com/script.js",
|
||||
{ status: 401 },
|
||||
),
|
||||
);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
const text = await response.text();
|
||||
expect(response.status).toBe(502);
|
||||
expect(text).not.toMatch(/sk-/);
|
||||
expect(text).not.toMatch(/analytics\.example\.com/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { isPublicUrl } from "@/lib/validations/notifications";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const TIMEOUT_MS = 5_000;
|
||||
const MAX_BODY_BYTES = 256 * 1024;
|
||||
|
||||
function redact(text: string): string {
|
||||
return text.replace(/https?:\/\/\S+/gi, "[url]");
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async (_request: NextRequest) => {
|
||||
const { user } = await requireAdmin();
|
||||
annotate({ action: { name: "monitoring.umami.test" } });
|
||||
|
||||
const rl = await checkRateLimit(`umami-test:${user.id}`, 5, 60_000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many test requests", 429, {
|
||||
errorCode: "rate_limited_self",
|
||||
});
|
||||
}
|
||||
|
||||
const settings = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: { umamiScriptUrl: true },
|
||||
});
|
||||
|
||||
if (!settings?.umamiScriptUrl) {
|
||||
return apiError("Umami script URL not configured", 422, {
|
||||
errorCode: "not_configured",
|
||||
});
|
||||
}
|
||||
|
||||
const url = settings.umamiScriptUrl;
|
||||
if (!isPublicUrl(url) || !url.startsWith("https://")) {
|
||||
return apiError("Umami URL must be a public HTTPS endpoint", 422, {
|
||||
errorCode: "url_not_public",
|
||||
});
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
const start = performance.now();
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "GET",
|
||||
signal: controller.signal,
|
||||
cache: "no-store",
|
||||
// `manual` neutralises a redirect-follow SSRF — even though the URL was
|
||||
// checked with isPublicUrl(), a public host can 302 to a private IP.
|
||||
redirect: "manual",
|
||||
});
|
||||
const latencyMs = Math.round(performance.now() - start);
|
||||
|
||||
if (res.status >= 300 && res.status < 400) {
|
||||
annotate({ meta: { umami_test_status: res.status } });
|
||||
return apiError("Umami URL redirects — check configuration", 502, {
|
||||
errorCode: "redirected",
|
||||
});
|
||||
}
|
||||
|
||||
// Cap response read to 256 KB so a hostile public server can't stream
|
||||
// gigabytes through us; combined with the 5 s abort, RAM use stays bounded.
|
||||
let body = "";
|
||||
if (res.body) {
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let total = 0;
|
||||
try {
|
||||
while (total < MAX_BODY_BYTES) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.byteLength;
|
||||
body += decoder.decode(value, { stream: true });
|
||||
}
|
||||
body += decoder.decode();
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
annotate({
|
||||
meta: {
|
||||
umami_test_status: res.status,
|
||||
umami_test_latency_ms: latencyMs,
|
||||
},
|
||||
});
|
||||
return apiError("Umami connection failed", 502, {
|
||||
errorCode: "upstream_error",
|
||||
});
|
||||
}
|
||||
|
||||
// Look for tracker-shape signal rather than a literal "umami" substring —
|
||||
// a 404 page that mentions "Powered by Umami" would otherwise slip through.
|
||||
const hasMarker = /umami\.track|window\.umami|"umami"/i.test(body);
|
||||
|
||||
return apiSuccess({
|
||||
ok: hasMarker,
|
||||
statusCode: res.status,
|
||||
latencyMs,
|
||||
hasMarker,
|
||||
});
|
||||
} catch (e) {
|
||||
const err = e as Error;
|
||||
const isAbort = err.name === "AbortError";
|
||||
const code = isAbort ? "timeout" : "connection_failed";
|
||||
annotate({
|
||||
meta: {
|
||||
umami_test_code: code,
|
||||
umami_test_error: redact(err.message).slice(0, 300),
|
||||
},
|
||||
});
|
||||
return apiError(
|
||||
isAbort ? "Umami request timed out" : "Umami connection failed",
|
||||
502,
|
||||
{ errorCode: code },
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({
|
||||
user: { id: "u-1" },
|
||||
session: { id: "s-1" },
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
pushSubscription: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: vi.fn((x: string) => x),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/notifications/vapid-config", () => ({
|
||||
getVapidConfig: vi.fn(async () => ({
|
||||
subject: "mailto:test@example.com",
|
||||
publicKey:
|
||||
"BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4YfYCA_0QTpQtUbVlUls0VJXg7A8u-Ts1XbjhazAkj7I99e8QcYP7DkM",
|
||||
privateKey: "private-key-stub",
|
||||
})),
|
||||
}));
|
||||
|
||||
const sendNotification = vi.fn();
|
||||
vi.mock("web-push", () => ({
|
||||
default: {
|
||||
setVapidDetails: vi.fn(),
|
||||
sendNotification: (...args: unknown[]) => sendNotification(...args),
|
||||
},
|
||||
setVapidDetails: vi.fn(),
|
||||
sendNotification: (...args: unknown[]) => sendNotification(...args),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
interface ApiErrorEnvelope {
|
||||
data: null;
|
||||
error: string;
|
||||
}
|
||||
interface ApiSuccessEnvelope<T> {
|
||||
data: T;
|
||||
error: null;
|
||||
}
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 5,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
vi.mocked(prisma.pushSubscription.findFirst).mockReset();
|
||||
sendNotification.mockReset();
|
||||
global.fetch = vi.fn() as never;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
function emptyRequest(): Request {
|
||||
return new Request("http://localhost/api/notifications/web-push/test", {
|
||||
method: "POST",
|
||||
headers: { "content-length": "0" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("POST /api/notifications/web-push/test", () => {
|
||||
it("happy path returns ok and sends to one subscription", async () => {
|
||||
vi.mocked(prisma.pushSubscription.findFirst).mockResolvedValueOnce({
|
||||
id: "p-1",
|
||||
userId: "u-1",
|
||||
endpoint: "https://fcm.googleapis.com/fcm/send/abc123token",
|
||||
p256dh: "p256",
|
||||
auth: "auth",
|
||||
} as never);
|
||||
sendNotification.mockResolvedValueOnce({ statusCode: 201 });
|
||||
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as ApiSuccessEnvelope<{
|
||||
ok: boolean;
|
||||
sent: number;
|
||||
latencyMs: number;
|
||||
perEndpoint: Array<{ host: string; status: number | null }>;
|
||||
}>;
|
||||
expect(body.data.ok).toBe(true);
|
||||
expect(body.data.sent).toBe(1);
|
||||
expect(body.data.perEndpoint).toHaveLength(1);
|
||||
expect(body.data.perEndpoint[0].host).toBe("fcm.googleapis.com");
|
||||
// never the full endpoint URL (the routing token is the only thing
|
||||
// protecting that subscription from anyone who learns it).
|
||||
const text = JSON.stringify(body);
|
||||
expect(text).not.toMatch(/abc123token/);
|
||||
|
||||
// Spec: only the most-recent subscription gets the test push, not all.
|
||||
expect(prisma.pushSubscription.findFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { userId: "u-1" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
}),
|
||||
);
|
||||
expect(sendNotification).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rate-limit denial returns 429 with no upstream call", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(429);
|
||||
expect(sendNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 422 when no subscriptions registered", async () => {
|
||||
vi.mocked(prisma.pushSubscription.findFirst).mockResolvedValueOnce(null);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(422);
|
||||
const body = (await response.json()) as ApiErrorEnvelope;
|
||||
expect(body.error).toMatch(/no push subscriptions/i);
|
||||
expect(sendNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not leak Bearer/sk- or full endpoint URL when upstream rejects", async () => {
|
||||
vi.mocked(prisma.pushSubscription.findFirst).mockResolvedValueOnce({
|
||||
id: "p-1",
|
||||
userId: "u-1",
|
||||
endpoint: "https://fcm.googleapis.com/fcm/send/abc123token",
|
||||
p256dh: "p256",
|
||||
auth: "auth",
|
||||
} as never);
|
||||
sendNotification.mockRejectedValueOnce(
|
||||
Object.assign(
|
||||
new Error(
|
||||
"401 Unauthorized: Authorization: Bearer sk-secret from https://fcm.googleapis.com/fcm/send/abc123token",
|
||||
),
|
||||
{ statusCode: 401 },
|
||||
),
|
||||
);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
const text = await response.text();
|
||||
expect(response.status).toBe(200);
|
||||
expect(text).not.toMatch(/sk-/);
|
||||
expect(text).not.toMatch(/abc123token/);
|
||||
const parsed = JSON.parse(text) as ApiSuccessEnvelope<{
|
||||
ok: boolean;
|
||||
sent: number;
|
||||
perEndpoint: Array<{ host: string; status: number | null }>;
|
||||
}>;
|
||||
expect(parsed.data.ok).toBe(false);
|
||||
expect(parsed.data.sent).toBe(0);
|
||||
expect(parsed.data.perEndpoint[0].host).toBe("fcm.googleapis.com");
|
||||
expect(parsed.data.perEndpoint[0].status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { getVapidConfig } from "@/lib/notifications/vapid-config";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function hostFromEndpoint(endpoint: string): string {
|
||||
try {
|
||||
return new URL(endpoint).host;
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
// Strip any URL from a stringified web-push library error. Push providers (FCM,
|
||||
// Mozilla autopush, etc.) embed the full subscription endpoint into 410/404
|
||||
// error messages — that endpoint token is the routing secret, so it must never
|
||||
// land in our Wide Events.
|
||||
function redactPushError(text: string): string {
|
||||
return text.replace(/https?:\/\/\S+/gi, "[endpoint]");
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async (_request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "notifications.web-push.test" } });
|
||||
|
||||
const rl = await checkRateLimit(`web-push-test:${user.id}`, 5, 60_000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many test requests", 429, {
|
||||
errorCode: "rate_limited_self",
|
||||
});
|
||||
}
|
||||
|
||||
// Spec: only the most-recent subscription receives the test push. A user
|
||||
// with phone + tablet + desktop should not get three buzzes per click.
|
||||
const subscription = await prisma.pushSubscription.findFirst({
|
||||
where: { userId: user.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
if (!subscription) {
|
||||
return apiError("No push subscriptions registered", 422, {
|
||||
errorCode: "not_configured",
|
||||
});
|
||||
}
|
||||
|
||||
const config = await getVapidConfig();
|
||||
if (!config) {
|
||||
return apiError("VAPID keys not configured", 422, {
|
||||
errorCode: "vapid_not_configured",
|
||||
});
|
||||
}
|
||||
|
||||
const webpush = await import("web-push");
|
||||
webpush.setVapidDetails(config.subject, config.publicKey, config.privateKey);
|
||||
|
||||
const pushPayload = JSON.stringify({
|
||||
title: "HealthLog",
|
||||
body: "This is a test notification from HealthLog",
|
||||
tag: "self-test",
|
||||
});
|
||||
|
||||
const host = hostFromEndpoint(subscription.endpoint);
|
||||
const start = performance.now();
|
||||
|
||||
try {
|
||||
const p256dh = decrypt(subscription.p256dh);
|
||||
const auth = decrypt(subscription.auth);
|
||||
|
||||
const result = await webpush.sendNotification(
|
||||
{ endpoint: subscription.endpoint, keys: { p256dh, auth } },
|
||||
pushPayload,
|
||||
);
|
||||
const latencyMs = Math.round(performance.now() - start);
|
||||
|
||||
return apiSuccess({
|
||||
ok: true,
|
||||
sent: 1,
|
||||
latencyMs,
|
||||
perEndpoint: [{ host, status: result.statusCode ?? 201 }],
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const status = (err as { statusCode?: number }).statusCode ?? null;
|
||||
const message = redactPushError((err as Error).message ?? "").slice(0, 200);
|
||||
annotate({
|
||||
meta: {
|
||||
web_push_test_status: status,
|
||||
web_push_test_host: host,
|
||||
web_push_test_error: message,
|
||||
},
|
||||
});
|
||||
return apiSuccess({
|
||||
ok: false,
|
||||
sent: 0,
|
||||
perEndpoint: [{ host, status }],
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
const onboardingSchema = z.object({
|
||||
displayName: z.string().trim().min(1).max(50).optional(),
|
||||
heightCm: z.number().min(50).max(300).optional(),
|
||||
dateOfBirth: z.string().optional(),
|
||||
gender: z.enum(["MALE", "FEMALE"]).optional(),
|
||||
@@ -46,6 +47,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
data.gender = result.data.gender;
|
||||
}
|
||||
|
||||
if (result.data.displayName) {
|
||||
data.displayName = result.data.displayName;
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data,
|
||||
|
||||
@@ -0,0 +1,616 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
// --- Module-boundary mocks must come before importing the route. ---
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findFirst: vi.fn() },
|
||||
medication: {
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
medicationIntakeEvent: {
|
||||
create: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
telegramReminderMessage: {
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
telegramScheduledDeletion: {
|
||||
createMany: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: vi.fn((value: string) => `decrypted:${value}`),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/telegram", () => ({
|
||||
answerTelegramCallbackQuery: vi.fn().mockResolvedValue(undefined),
|
||||
deleteMessage: vi.fn().mockResolvedValue(undefined),
|
||||
sendTelegramMessage: vi.fn().mockResolvedValue({ messageId: 999, ok: true }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: vi.fn(() => ({
|
||||
setAuth: vi.fn(),
|
||||
addWarning: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST, GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
answerTelegramCallbackQuery,
|
||||
deleteMessage,
|
||||
sendTelegramMessage,
|
||||
} from "@/lib/telegram";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const ORIGINAL_SECRET = process.env.TELEGRAM_WEBHOOK_SECRET;
|
||||
|
||||
const TG_USER = {
|
||||
id: "user-1",
|
||||
telegramBotToken: "ENC(token-blob)",
|
||||
locale: "en",
|
||||
};
|
||||
|
||||
const MEDICATION = { id: "med-1", name: "Ramipril" };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
process.env.TELEGRAM_WEBHOOK_SECRET = "sekret";
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 120,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValue(TG_USER as never);
|
||||
vi.mocked(prisma.medication.findFirst).mockResolvedValue(MEDICATION as never);
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.medication.update).mockResolvedValue({} as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.medicationIntakeEvent.create).mockResolvedValue({} as never);
|
||||
vi.mocked(prisma.telegramReminderMessage.deleteMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
vi.mocked(prisma.telegramScheduledDeletion.createMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue([] as never);
|
||||
vi.mocked(answerTelegramCallbackQuery).mockResolvedValue(undefined as never);
|
||||
vi.mocked(deleteMessage).mockResolvedValue(undefined as never);
|
||||
vi.mocked(sendTelegramMessage).mockResolvedValue({
|
||||
messageId: 999,
|
||||
ok: true,
|
||||
} as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_SECRET === undefined) delete process.env.TELEGRAM_WEBHOOK_SECRET;
|
||||
else process.env.TELEGRAM_WEBHOOK_SECRET = ORIGINAL_SECRET;
|
||||
});
|
||||
|
||||
function tgRequest(
|
||||
body: unknown,
|
||||
headers: Record<string, string> = {
|
||||
"x-telegram-bot-api-secret-token": "sekret",
|
||||
},
|
||||
): NextRequest {
|
||||
return new NextRequest("http://localhost/api/telegram/webhook", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", ...headers },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function callbackUpdate(data: string, overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
update_id: 100,
|
||||
callback_query: {
|
||||
id: "cb-1",
|
||||
data,
|
||||
from: { id: 7777 },
|
||||
message: {
|
||||
message_id: 555,
|
||||
chat: { id: 7777 },
|
||||
},
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("Telegram webhook — secret verification", () => {
|
||||
it("returns 401 when the X-Telegram-Bot-Api-Secret-Token header is missing", async () => {
|
||||
const res = await POST(tgRequest(callbackUpdate("taken:med-1"), {}));
|
||||
expect(res.status).toBe(401);
|
||||
expect(prisma.user.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 401 when the header is present but does not match the env secret", async () => {
|
||||
const res = await POST(
|
||||
tgRequest(callbackUpdate("taken:med-1"), {
|
||||
"x-telegram-bot-api-secret-token": "WRONG",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
expect(prisma.user.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 401 when TELEGRAM_WEBHOOK_SECRET env var is unset (defence in depth)", async () => {
|
||||
delete process.env.TELEGRAM_WEBHOOK_SECRET;
|
||||
const res = await POST(
|
||||
tgRequest(callbackUpdate("taken:med-1"), {
|
||||
"x-telegram-bot-api-secret-token": "anything",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 200 with a valid header secret (happy path baseline)", async () => {
|
||||
const res = await POST(tgRequest(callbackUpdate("taken:med-1")));
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Telegram webhook — rate limit", () => {
|
||||
it("returns 429 when rate-limited and never reads the secret/user", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
const res = await POST(tgRequest(callbackUpdate("taken:med-1")));
|
||||
expect(res.status).toBe(429);
|
||||
expect(prisma.user.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Telegram webhook — callback dispatch", () => {
|
||||
it("'taken:<medId>' creates a MedicationIntakeEvent + clears snoozedUntil + acks the callback", async () => {
|
||||
const res = await POST(tgRequest(callbackUpdate("taken:med-1")));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// $transaction is invoked with [create-intake, update-medication]. Inspect
|
||||
// its argument list for both writes.
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||
const txOps = vi.mocked(prisma.$transaction).mock.calls[0][0] as unknown;
|
||||
expect(Array.isArray(txOps)).toBe(true);
|
||||
|
||||
// The route calls prisma.medicationIntakeEvent.create + medication.update
|
||||
// synchronously while building the transaction array, so these mocks
|
||||
// record the arguments the way it built them.
|
||||
expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1);
|
||||
const intakeArgs = vi.mocked(prisma.medicationIntakeEvent.create).mock
|
||||
.calls[0][0];
|
||||
expect(intakeArgs.data).toMatchObject({
|
||||
userId: "user-1",
|
||||
medicationId: "med-1",
|
||||
skipped: false,
|
||||
source: "REMINDER",
|
||||
});
|
||||
expect(intakeArgs.data.idempotencyKey).toMatch(
|
||||
/^telegram:cb:7777:555:med-1/,
|
||||
);
|
||||
|
||||
expect(prisma.medication.update).toHaveBeenCalledWith({
|
||||
where: { id: "med-1" },
|
||||
data: { snoozedUntil: null },
|
||||
});
|
||||
|
||||
expect(answerTelegramCallbackQuery).toHaveBeenCalledTimes(1);
|
||||
expect(deleteMessage).toHaveBeenCalledWith(
|
||||
"decrypted:ENC(token-blob)",
|
||||
"7777",
|
||||
555,
|
||||
);
|
||||
});
|
||||
|
||||
it("'taken:<medId>' is idempotent — replaying the same callback with the same message_id does NOT create a second intake event", async () => {
|
||||
// First call: existing-row lookup misses → write happens.
|
||||
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValueOnce(
|
||||
null,
|
||||
);
|
||||
await POST(tgRequest(callbackUpdate("taken:med-1")));
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Second call: existing-row lookup hits → no second write.
|
||||
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValueOnce({
|
||||
id: "intake-existing",
|
||||
} as never);
|
||||
const res2 = await POST(tgRequest(callbackUpdate("taken:med-1")));
|
||||
expect(res2.status).toBe(200);
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1); // unchanged
|
||||
});
|
||||
|
||||
it("'snooze:<medId>:60' updates the medication's snoozedUntil and answers with the locale-translated message", async () => {
|
||||
const res = await POST(tgRequest(callbackUpdate("snooze:med-1:60")));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(prisma.medication.update).toHaveBeenCalledTimes(1);
|
||||
const updateArgs = vi.mocked(prisma.medication.update).mock.calls[0][0];
|
||||
expect(updateArgs.where).toEqual({ id: "med-1" });
|
||||
const snoozedUntil = (updateArgs.data as { snoozedUntil: Date })
|
||||
.snoozedUntil;
|
||||
expect(snoozedUntil).toBeInstanceOf(Date);
|
||||
// Allow some clock drift but verify ~60 minutes ahead.
|
||||
const deltaMs = snoozedUntil.getTime() - Date.now();
|
||||
expect(deltaMs).toBeGreaterThan(55 * 60_000);
|
||||
expect(deltaMs).toBeLessThan(65 * 60_000);
|
||||
|
||||
expect(answerTelegramCallbackQuery).toHaveBeenCalledTimes(1);
|
||||
const ackText = vi.mocked(answerTelegramCallbackQuery).mock.calls[0][2];
|
||||
expect(ackText).toMatch(/Ramipril/);
|
||||
expect(ackText).toMatch(/1 hour/i);
|
||||
});
|
||||
|
||||
it("'skip:<medId>' creates an intake event with skipped=true and snoozes through end-of-day", async () => {
|
||||
const res = await POST(tgRequest(callbackUpdate("skip:med-1")));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1);
|
||||
const intakeArgs = vi.mocked(prisma.medicationIntakeEvent.create).mock
|
||||
.calls[0][0];
|
||||
expect(intakeArgs.data).toMatchObject({
|
||||
userId: "user-1",
|
||||
medicationId: "med-1",
|
||||
skipped: true,
|
||||
takenAt: null,
|
||||
source: "REMINDER",
|
||||
});
|
||||
expect(intakeArgs.data.idempotencyKey).toMatch(
|
||||
/^telegram:skip:7777:555:med-1/,
|
||||
);
|
||||
|
||||
const updateArgs = vi.mocked(prisma.medication.update).mock.calls[0][0];
|
||||
const snoozedUntil = (updateArgs.data as { snoozedUntil: Date })
|
||||
.snoozedUntil;
|
||||
expect(snoozedUntil.getHours()).toBe(23);
|
||||
expect(snoozedUntil.getMinutes()).toBe(59);
|
||||
});
|
||||
|
||||
it("'ack:<medId>' answers the callback without creating an intake event", async () => {
|
||||
const res = await POST(tgRequest(callbackUpdate("ack:med-1")));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
expect(answerTelegramCallbackQuery).toHaveBeenCalledTimes(1);
|
||||
expect(deleteMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("unknown callback action is acked with an 'unknown action' message and writes nothing", async () => {
|
||||
const res = await POST(tgRequest(callbackUpdate("totally:bogus")));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
|
||||
expect(prisma.medication.update).not.toHaveBeenCalled();
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
expect(answerTelegramCallbackQuery).toHaveBeenCalledTimes(1);
|
||||
const ackText = vi.mocked(answerTelegramCallbackQuery).mock.calls[0][2];
|
||||
expect(ackText).toMatch(/unknown action/i);
|
||||
});
|
||||
|
||||
it("ignores callbacks from chats with no enrolled Telegram user (no answer, no DB writes)", async () => {
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValueOnce(null);
|
||||
const res = await POST(tgRequest(callbackUpdate("taken:med-1")));
|
||||
expect(res.status).toBe(200);
|
||||
expect(answerTelegramCallbackQuery).not.toHaveBeenCalled();
|
||||
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects 'taken' for an inactive medication and acks with the localised error", async () => {
|
||||
vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce(null);
|
||||
const res = await POST(tgRequest(callbackUpdate("taken:med-1")));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
|
||||
const ackText = vi.mocked(answerTelegramCallbackQuery).mock.calls[0][2];
|
||||
expect(ackText).toMatch(/medication.*not.*found.*inactive/i);
|
||||
});
|
||||
|
||||
it("'snooze' with an empty medication id acks with errorInvalidAction (no writes)", async () => {
|
||||
const res = await POST(tgRequest(callbackUpdate("snooze::60")));
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.medication.update).not.toHaveBeenCalled();
|
||||
const ackText = vi.mocked(answerTelegramCallbackQuery).mock.calls[0][2];
|
||||
expect(ackText).toMatch(/invalid action/i);
|
||||
});
|
||||
|
||||
it("'snooze' for a missing medication acks with medicationNotFound (no update)", async () => {
|
||||
vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce(null);
|
||||
const res = await POST(tgRequest(callbackUpdate("snooze:med-X:60")));
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.medication.update).not.toHaveBeenCalled();
|
||||
const ackText = vi.mocked(answerTelegramCallbackQuery).mock.calls[0][2];
|
||||
expect(ackText).toMatch(/medication not found/i);
|
||||
});
|
||||
|
||||
it("'add:<medId>:umid:<userMsgId>' creates an intake event and deletes both bot + user messages", async () => {
|
||||
const res = await POST(tgRequest(callbackUpdate("add:med-1:umid:1234")));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1);
|
||||
const intakeArgs = vi.mocked(prisma.medicationIntakeEvent.create).mock
|
||||
.calls[0][0];
|
||||
expect(intakeArgs.data.idempotencyKey).toMatch(
|
||||
/^telegram:add:7777:555:med-1/,
|
||||
);
|
||||
|
||||
// Bot's selection message + user's /add command message both deleted.
|
||||
const deleteCalls = vi.mocked(deleteMessage).mock.calls;
|
||||
const ids = deleteCalls.map((c) => c[2]);
|
||||
expect(ids).toContain(555);
|
||||
expect(ids).toContain(1234);
|
||||
});
|
||||
|
||||
it("'cancel_add:umid:1234' deletes both messages and acks with the cancelled message", async () => {
|
||||
const res = await POST(tgRequest(callbackUpdate("cancel_add:umid:1234")));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
|
||||
const ids = vi.mocked(deleteMessage).mock.calls.map((c) => c[2]);
|
||||
expect(ids).toContain(555);
|
||||
expect(ids).toContain(1234);
|
||||
const ackText = vi.mocked(answerTelegramCallbackQuery).mock.calls[0][2];
|
||||
expect(ackText).toMatch(/cancelled/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Telegram webhook — text-message dispatch", () => {
|
||||
it("returns 200 'ok' for /help and replies with help text", async () => {
|
||||
const update = {
|
||||
update_id: 200,
|
||||
message: {
|
||||
message_id: 11,
|
||||
text: "/help",
|
||||
chat: { id: 7777 },
|
||||
},
|
||||
};
|
||||
const res = await POST(tgRequest(update));
|
||||
expect(res.status).toBe(200);
|
||||
expect(sendTelegramMessage).toHaveBeenCalledTimes(1);
|
||||
const text = vi.mocked(sendTelegramMessage).mock.calls[0][2];
|
||||
expect(text).toMatch(/available commands/i);
|
||||
});
|
||||
|
||||
it("ignores unrelated text — never calls sendTelegramMessage", async () => {
|
||||
const update = {
|
||||
update_id: 201,
|
||||
message: {
|
||||
message_id: 12,
|
||||
text: "the weather is nice today",
|
||||
chat: { id: 7777 },
|
||||
},
|
||||
};
|
||||
const res = await POST(tgRequest(update));
|
||||
expect(res.status).toBe(200);
|
||||
expect(sendTelegramMessage).not.toHaveBeenCalled();
|
||||
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("greeting like 'hi' echoes a friendly reply", async () => {
|
||||
const res = await POST(
|
||||
tgRequest({
|
||||
update_id: 202,
|
||||
message: {
|
||||
message_id: 13,
|
||||
text: "hi",
|
||||
chat: { id: 7777 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(sendTelegramMessage).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(sendTelegramMessage).mock.calls[0][2]).toMatch(/Hi!/);
|
||||
});
|
||||
|
||||
it("/add with no active medications replies with the 'no active' message", async () => {
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValueOnce([] as never);
|
||||
const res = await POST(
|
||||
tgRequest({
|
||||
update_id: 203,
|
||||
message: {
|
||||
message_id: 14,
|
||||
text: "/add",
|
||||
chat: { id: 7777 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(sendTelegramMessage).mock.calls[0][2]).toMatch(
|
||||
/no active medication/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("/add with exactly one active medication marks it taken without prompting", async () => {
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValueOnce([
|
||||
{ id: "med-only", name: "Aspirin", dose: "100mg" },
|
||||
] as never);
|
||||
const res = await POST(
|
||||
tgRequest({
|
||||
update_id: 204,
|
||||
message: {
|
||||
message_id: 15,
|
||||
text: "/add",
|
||||
chat: { id: 7777 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
// markMedicationTaken → findFirst on active medication
|
||||
expect(prisma.medication.findFirst).toHaveBeenCalled();
|
||||
expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("/add with multiple medications sends a keyboard listing them with cancel_add", async () => {
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValueOnce([
|
||||
{ id: "med-a", name: "Aspirin", dose: "100mg" },
|
||||
{ id: "med-b", name: "Bisoprolol", dose: "5mg" },
|
||||
] as never);
|
||||
|
||||
const res = await POST(
|
||||
tgRequest({
|
||||
update_id: 205,
|
||||
message: {
|
||||
message_id: 16,
|
||||
text: "/add",
|
||||
chat: { id: 7777 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(sendTelegramMessage).toHaveBeenCalledTimes(1);
|
||||
const opts = vi.mocked(sendTelegramMessage).mock.calls[0][3] as {
|
||||
replyMarkup: { inline_keyboard: { callback_data: string }[][] };
|
||||
};
|
||||
const flatData = opts.replyMarkup.inline_keyboard
|
||||
.flat()
|
||||
.map((b) => b.callback_data);
|
||||
expect(flatData).toEqual(
|
||||
expect.arrayContaining([
|
||||
"add:med-a:umid:16",
|
||||
"add:med-b:umid:16",
|
||||
"cancel_add:umid:16",
|
||||
]),
|
||||
);
|
||||
// No intake yet — user still has to choose.
|
||||
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("'taken Ramipril' (text command) creates an intake event for the matched medication", async () => {
|
||||
vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce({
|
||||
id: "med-1",
|
||||
} as never);
|
||||
// Second findFirst inside markMedicationTaken
|
||||
vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce({
|
||||
id: "med-1",
|
||||
name: "Ramipril",
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
tgRequest({
|
||||
update_id: 206,
|
||||
message: {
|
||||
message_id: 17,
|
||||
text: "taken Ramipril",
|
||||
chat: { id: 7777 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1);
|
||||
const intakeArgs = vi.mocked(prisma.medicationIntakeEvent.create).mock
|
||||
.calls[0][0];
|
||||
expect(intakeArgs.data.idempotencyKey).toMatch(/^telegram:text:206:med-1/);
|
||||
});
|
||||
|
||||
it("'taken' (no name) with no clearly matching medication asks for a medication name", async () => {
|
||||
// findMany returns 2 → ambiguous → ask
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValueOnce([
|
||||
{ id: "med-a", name: "Aspirin" },
|
||||
{ id: "med-b", name: "Bisoprolol" },
|
||||
] as never);
|
||||
const res = await POST(
|
||||
tgRequest({
|
||||
update_id: 207,
|
||||
message: {
|
||||
message_id: 18,
|
||||
text: "taken",
|
||||
chat: { id: 7777 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(sendTelegramMessage).mock.calls[0][2]).toMatch(
|
||||
/specify a medication/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("'taken UnknownMed' replies with the medication-not-found message and writes nothing", async () => {
|
||||
// First findFirst (lookup by name) returns null
|
||||
vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce(null);
|
||||
|
||||
const res = await POST(
|
||||
tgRequest({
|
||||
update_id: 208,
|
||||
message: {
|
||||
message_id: 19,
|
||||
text: "taken UnknownMed",
|
||||
chat: { id: 7777 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(sendTelegramMessage).mock.calls[0][2]).toMatch(
|
||||
/not found/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns 200 ignored when the body has no message and no callback_query", async () => {
|
||||
const res = await POST(
|
||||
tgRequest({
|
||||
update_id: 999,
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { status: string };
|
||||
expect(body.status).toBe("ignored");
|
||||
});
|
||||
|
||||
it("returns 200 ignored when the body has no update_id", async () => {
|
||||
const res = await POST(tgRequest({ message: { text: "/help" } }));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { status: string };
|
||||
expect(body.status).toBe("ignored");
|
||||
});
|
||||
|
||||
it("returns 200 'invalid json' when the request body is malformed JSON", async () => {
|
||||
const req = new NextRequest("http://localhost/api/telegram/webhook", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-telegram-bot-api-secret-token": "sekret",
|
||||
},
|
||||
body: "{not-valid",
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { status: string };
|
||||
expect(body.status).toBe("invalid json");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Telegram webhook — GET verification", () => {
|
||||
it("returns 200 with a valid header secret", async () => {
|
||||
const req = new NextRequest("http://localhost/api/telegram/webhook", {
|
||||
headers: { "x-telegram-bot-api-secret-token": "sekret" },
|
||||
});
|
||||
const res = await GET(req);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("returns 401 without a header secret", async () => {
|
||||
const req = new NextRequest("http://localhost/api/telegram/webhook");
|
||||
const res = await GET(req);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.NEXT_PUBLIC_APP_BUILD_SHA;
|
||||
delete process.env.NEXT_PUBLIC_APP_BUILT_AT;
|
||||
});
|
||||
|
||||
interface VersionEnvelope {
|
||||
data: {
|
||||
version: string;
|
||||
buildSha: string | null;
|
||||
builtAt: string | null;
|
||||
license: string;
|
||||
repository: string;
|
||||
changelog: string;
|
||||
docs: string;
|
||||
};
|
||||
}
|
||||
|
||||
describe("GET /api/version", () => {
|
||||
it("returns the package.json version", async () => {
|
||||
const response = await (GET as unknown as () => Promise<Response>)();
|
||||
const body = (await response.json()) as VersionEnvelope;
|
||||
expect(body.data.version).toMatch(/^\d+\.\d+\.\d+(-[a-z0-9.-]+)?$/i);
|
||||
expect(body.data.license).toBe("AGPL-3.0");
|
||||
expect(body.data.repository).toContain("github.com");
|
||||
expect(body.data.changelog).toContain("CHANGELOG");
|
||||
expect(body.data.docs).toContain("docs.healthlog.dev");
|
||||
});
|
||||
|
||||
it("returns null buildSha / builtAt for a `pnpm dev` build", async () => {
|
||||
const response = await (GET as unknown as () => Promise<Response>)();
|
||||
const body = (await response.json()) as VersionEnvelope;
|
||||
expect(body.data.buildSha).toBeNull();
|
||||
expect(body.data.builtAt).toBeNull();
|
||||
});
|
||||
|
||||
it("surfaces the build SHA and timestamp when the env vars are set", async () => {
|
||||
process.env.NEXT_PUBLIC_APP_BUILD_SHA = "abc1234";
|
||||
process.env.NEXT_PUBLIC_APP_BUILT_AT = "2026-05-08T12:00:00.000Z";
|
||||
const response = await (GET as unknown as () => Promise<Response>)();
|
||||
const body = (await response.json()) as VersionEnvelope;
|
||||
expect(body.data.buildSha).toBe("abc1234");
|
||||
expect(body.data.builtAt).toBe("2026-05-08T12:00:00.000Z");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import packageJson from "../../../../package.json";
|
||||
|
||||
export const dynamic = "force-static";
|
||||
export const revalidate = false;
|
||||
|
||||
/**
|
||||
* GET /api/version
|
||||
*
|
||||
* Public endpoint exposing the running build's version. Used by the
|
||||
* Settings → About surface, the footer of every page, and the
|
||||
* "Check for updates" button (which compares against the GHCR API).
|
||||
*
|
||||
* The build SHA and built-at timestamp come from environment variables
|
||||
* baked at image build time:
|
||||
* - `NEXT_PUBLIC_APP_BUILD_SHA` — short Git SHA, set by the
|
||||
* `docker-publish` workflow.
|
||||
* - `NEXT_PUBLIC_APP_BUILT_AT` — ISO-8601 build timestamp, same
|
||||
* workflow.
|
||||
*
|
||||
* For local `pnpm dev` neither is set; the route returns `null` and
|
||||
* the UI falls back to "development" wording.
|
||||
*/
|
||||
export const GET = apiHandler(async () => {
|
||||
const version = packageJson.version;
|
||||
const buildSha = process.env.NEXT_PUBLIC_APP_BUILD_SHA?.trim() || null;
|
||||
const builtAt = process.env.NEXT_PUBLIC_APP_BUILT_AT?.trim() || null;
|
||||
|
||||
return apiSuccess({
|
||||
version,
|
||||
buildSha,
|
||||
builtAt,
|
||||
license: "AGPL-3.0",
|
||||
repository: "https://github.com/MBombeck/HealthLog",
|
||||
changelog: "https://github.com/MBombeck/HealthLog/blob/main/CHANGELOG.md",
|
||||
docs: "https://docs.healthlog.dev",
|
||||
});
|
||||
});
|
||||
@@ -30,7 +30,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
) {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
"/settings?withings=error&reason=state",
|
||||
"/settings/integrations?withings=error&reason=state",
|
||||
process.env.NEXT_PUBLIC_APP_URL!,
|
||||
),
|
||||
);
|
||||
@@ -41,7 +41,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
if (stateUserId !== user.id) {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
"/settings?withings=error&reason=user",
|
||||
"/settings/integrations?withings=error&reason=user",
|
||||
process.env.NEXT_PUBLIC_APP_URL!,
|
||||
),
|
||||
);
|
||||
@@ -50,7 +50,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
if (!code) {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
"/settings?withings=error&reason=nocode",
|
||||
"/settings/integrations?withings=error&reason=nocode",
|
||||
process.env.NEXT_PUBLIC_APP_URL!,
|
||||
),
|
||||
);
|
||||
@@ -61,7 +61,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
if (!creds) {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
"/settings?withings=error&reason=nocreds",
|
||||
"/settings/integrations?withings=error&reason=nocreds",
|
||||
process.env.NEXT_PUBLIC_APP_URL!,
|
||||
),
|
||||
);
|
||||
@@ -90,7 +90,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
|
||||
// Subscribe to webhooks in background
|
||||
setupWebhook(user.id).catch((err) => getEvent()?.addWarning("Webhook setup failed: " + err));
|
||||
setupWebhook(user.id).catch((err) =>
|
||||
getEvent()?.addWarning("Webhook setup failed: " + err),
|
||||
);
|
||||
|
||||
await auditLog("withings.connect", {
|
||||
userId: user.id,
|
||||
@@ -98,7 +100,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
|
||||
const response = NextResponse.redirect(
|
||||
new URL("/settings?withings=connected", process.env.NEXT_PUBLIC_APP_URL!),
|
||||
new URL(
|
||||
"/settings/integrations?withings=connected",
|
||||
process.env.NEXT_PUBLIC_APP_URL!,
|
||||
),
|
||||
);
|
||||
response.cookies.delete("withings_state");
|
||||
return response;
|
||||
@@ -106,7 +111,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
getEvent()?.setError(err);
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
"/settings?withings=error&reason=token",
|
||||
"/settings/integrations?withings=error&reason=token",
|
||||
process.env.NEXT_PUBLIC_APP_URL!,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
// --- Module-boundary mocks must come before importing the route. ---
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
withingsConnection: { findFirst: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/withings/sync", () => ({
|
||||
syncUserMeasurements: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: vi.fn(() => ({
|
||||
setAuth: vi.fn(),
|
||||
addWarning: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST, GET, HEAD } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { syncUserMeasurements } from "@/lib/withings/sync";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
|
||||
const ORIGINAL_SECRET = process.env.WITHINGS_WEBHOOK_SECRET;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
process.env.WITHINGS_WEBHOOK_SECRET = "test-secret";
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 30,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
vi.mocked(getEvent).mockReturnValue({
|
||||
setAuth: vi.fn(),
|
||||
addWarning: vi.fn(),
|
||||
} as never);
|
||||
vi.mocked(syncUserMeasurements).mockResolvedValue(undefined as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_SECRET === undefined) delete process.env.WITHINGS_WEBHOOK_SECRET;
|
||||
else process.env.WITHINGS_WEBHOOK_SECRET = ORIGINAL_SECRET;
|
||||
});
|
||||
|
||||
function jsonRequest(
|
||||
body: unknown,
|
||||
headers: Record<string, string> = {},
|
||||
search = "",
|
||||
): NextRequest {
|
||||
return new NextRequest(`http://localhost/api/withings/webhook${search}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", ...headers },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function formRequest(
|
||||
formBody: Record<string, string>,
|
||||
headers: Record<string, string> = {},
|
||||
): NextRequest {
|
||||
const usp = new URLSearchParams(formBody);
|
||||
return new NextRequest("http://localhost/api/withings/webhook", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/x-www-form-urlencoded",
|
||||
...headers,
|
||||
},
|
||||
body: usp.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
describe("POST /api/withings/webhook", () => {
|
||||
it("returns 401 with no secret header and no query secret", async () => {
|
||||
const res = await POST(jsonRequest({ userid: "wu-1" }));
|
||||
expect(res.status).toBe(401);
|
||||
expect(syncUserMeasurements).not.toHaveBeenCalled();
|
||||
expect(prisma.withingsConnection.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 401 when the X-Withings-Webhook-Secret header is wrong", async () => {
|
||||
const res = await POST(
|
||||
jsonRequest({ userid: "wu-1" }, { "x-withings-webhook-secret": "WRONG" }),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
expect(syncUserMeasurements).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 401 when WITHINGS_WEBHOOK_SECRET env var is unset, regardless of incoming header", async () => {
|
||||
delete process.env.WITHINGS_WEBHOOK_SECRET;
|
||||
const addWarning = vi.fn();
|
||||
vi.mocked(getEvent).mockReturnValue({
|
||||
setAuth: vi.fn(),
|
||||
addWarning,
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
jsonRequest(
|
||||
{ userid: "wu-1" },
|
||||
{ "x-withings-webhook-secret": "anything" },
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
expect(addWarning).toHaveBeenCalledWith(
|
||||
"WITHINGS_WEBHOOK_SECRET not configured",
|
||||
);
|
||||
});
|
||||
|
||||
it("happy path: valid header secret + JSON body triggers sync for the resolved user", async () => {
|
||||
vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce({
|
||||
userId: "user-42",
|
||||
withingsUserId: "wu-1",
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
jsonRequest(
|
||||
{ userid: "wu-1" },
|
||||
{ "x-withings-webhook-secret": "test-secret" },
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { status: string };
|
||||
expect(body.status).toBe("ok");
|
||||
|
||||
expect(prisma.withingsConnection.findFirst).toHaveBeenCalledWith({
|
||||
where: { withingsUserId: "wu-1" },
|
||||
});
|
||||
expect(syncUserMeasurements).toHaveBeenCalledTimes(1);
|
||||
expect(syncUserMeasurements).toHaveBeenCalledWith("user-42");
|
||||
});
|
||||
|
||||
it("legacy ?secret=… query path still authorises and emits a Wide Event warning", async () => {
|
||||
const addWarning = vi.fn();
|
||||
vi.mocked(getEvent).mockReturnValue({
|
||||
setAuth: vi.fn(),
|
||||
addWarning,
|
||||
} as never);
|
||||
vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce({
|
||||
userId: "user-99",
|
||||
withingsUserId: "wu-2",
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
jsonRequest({ userid: "wu-2" }, {}, "?secret=test-secret"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(syncUserMeasurements).toHaveBeenCalledWith("user-99");
|
||||
expect(addWarning).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/legacy URL query/i),
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts form-encoded payloads (Withings legacy delivery format)", async () => {
|
||||
vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce({
|
||||
userId: "user-form",
|
||||
withingsUserId: "wu-form",
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
formRequest(
|
||||
{ userid: "wu-form", appli: "1" },
|
||||
{ "x-withings-webhook-secret": "test-secret" },
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(syncUserMeasurements).toHaveBeenCalledWith("user-form");
|
||||
});
|
||||
|
||||
it("returns 200 ignored when the body has no userid", async () => {
|
||||
const res = await POST(
|
||||
jsonRequest({}, { "x-withings-webhook-secret": "test-secret" }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { status: string };
|
||||
expect(body.status).toBe("ignored");
|
||||
expect(prisma.withingsConnection.findFirst).not.toHaveBeenCalled();
|
||||
expect(syncUserMeasurements).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 200 unknown_user when no WithingsConnection matches the wire userid", async () => {
|
||||
vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce(null);
|
||||
const res = await POST(
|
||||
jsonRequest(
|
||||
{ userid: "wu-ghost" },
|
||||
{ "x-withings-webhook-secret": "test-secret" },
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { status: string };
|
||||
expect(body.status).toBe("unknown_user");
|
||||
expect(syncUserMeasurements).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 429 and skips sync when rate-limited", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
const res = await POST(
|
||||
jsonRequest(
|
||||
{ userid: "wu-1" },
|
||||
{ "x-withings-webhook-secret": "test-secret" },
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
expect(syncUserMeasurements).not.toHaveBeenCalled();
|
||||
expect(prisma.withingsConnection.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("HEAD /api/withings/webhook (Withings URL verification)", () => {
|
||||
it("returns 200 with a valid header secret", async () => {
|
||||
const req = new NextRequest("http://localhost/api/withings/webhook", {
|
||||
method: "HEAD",
|
||||
headers: { "x-withings-webhook-secret": "test-secret" },
|
||||
});
|
||||
const res = await HEAD(req);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("returns 401 with no secret", async () => {
|
||||
const req = new NextRequest("http://localhost/api/withings/webhook", {
|
||||
method: "HEAD",
|
||||
});
|
||||
const res = await HEAD(req);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/withings/webhook", () => {
|
||||
it("returns 200 with a valid header secret and 401 without", async () => {
|
||||
const ok = await GET(
|
||||
new NextRequest("http://localhost/api/withings/webhook", {
|
||||
headers: { "x-withings-webhook-secret": "test-secret" },
|
||||
}),
|
||||
);
|
||||
expect(ok.status).toBe(200);
|
||||
|
||||
const fail = await GET(
|
||||
new NextRequest("http://localhost/api/withings/webhook"),
|
||||
);
|
||||
expect(fail.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -632,7 +632,7 @@ export default function InsightsPage() {
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
const w = analytics?.summaries.WEIGHT;
|
||||
const w = analytics?.summaries?.WEIGHT;
|
||||
const sys = analytics?.summaries.BLOOD_PRESSURE_SYS;
|
||||
const dia = analytics?.summaries.BLOOD_PRESSURE_DIA;
|
||||
const p = analytics?.summaries.PULSE;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -130,10 +130,11 @@ export default function MedicationsPage() {
|
||||
const byName = (a: Medication, b: Medication) =>
|
||||
a.name.localeCompare(b.name, "de", { sensitivity: "base" });
|
||||
|
||||
const activeMeds = (medications?.filter((m) => m.active) ?? []).sort(byName);
|
||||
const inactiveMeds = (medications?.filter((m) => !m.active) ?? []).sort(
|
||||
byName,
|
||||
);
|
||||
// Defensive against stale service-worker responses or any future API
|
||||
// shape change: only filter when we actually have an array.
|
||||
const medsArray = Array.isArray(medications) ? medications : [];
|
||||
const activeMeds = medsArray.filter((m) => m.active).sort(byName);
|
||||
const inactiveMeds = medsArray.filter((m) => !m.active).sort(byName);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
@@ -485,20 +486,52 @@ function ApiEndpointDialog({
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
type ExampleType = "curl" | "wget" | "fetch" | "powershell";
|
||||
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [activeTokenCount, setActiveTokenCount] = useState(0);
|
||||
const [loadingStatus, setLoadingStatus] = useState(false);
|
||||
const apiEndpointKey = ["medication-api-endpoint", medication?.id];
|
||||
|
||||
type ApiEndpointStatus = { enabled: boolean; activeTokenCount: number };
|
||||
|
||||
const {
|
||||
data: status,
|
||||
isFetching: loadingStatus,
|
||||
refetch: refetchStatus,
|
||||
error: statusError,
|
||||
} = useQuery<ApiEndpointStatus>({
|
||||
queryKey: apiEndpointKey,
|
||||
queryFn: async () => {
|
||||
const res = await fetch(
|
||||
`/api/medications/${medication!.id}/api-endpoint`,
|
||||
);
|
||||
const json = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(json.error || t("medications.statusLoadFailed"));
|
||||
}
|
||||
return {
|
||||
enabled: json.data.enabled === true,
|
||||
activeTokenCount: json.data.activeTokenCount ?? 0,
|
||||
};
|
||||
},
|
||||
enabled: !!medication,
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
});
|
||||
|
||||
const enabled = status?.enabled ?? false;
|
||||
const activeTokenCount = status?.activeTokenCount ?? 0;
|
||||
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [toggling, setToggling] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
const [exampleType, setExampleType] = useState<ExampleType>("curl");
|
||||
|
||||
const displayMsg =
|
||||
msg ?? (statusError instanceof Error ? statusError.message : null);
|
||||
|
||||
function handleClose() {
|
||||
setEnabled(false);
|
||||
setActiveTokenCount(0);
|
||||
queryClient.removeQueries({ queryKey: apiEndpointKey });
|
||||
setToken(null);
|
||||
setMsg(null);
|
||||
setCopied(null);
|
||||
@@ -506,27 +539,6 @@ function ApiEndpointDialog({
|
||||
onClose();
|
||||
}
|
||||
|
||||
const loadStatus = useCallback(async () => {
|
||||
if (!medication) return;
|
||||
setLoadingStatus(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
const res = await fetch(`/api/medications/${medication.id}/api-endpoint`);
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setEnabled(json.data.enabled === true);
|
||||
setActiveTokenCount(json.data.activeTokenCount ?? 0);
|
||||
} else {
|
||||
const json = await res.json();
|
||||
setMsg(json.error || t("medications.statusLoadFailed"));
|
||||
}
|
||||
} catch {
|
||||
setMsg(t("medications.statusLoadFailed"));
|
||||
} finally {
|
||||
setLoadingStatus(false);
|
||||
}
|
||||
}, [medication, t]);
|
||||
|
||||
async function toggleEndpoint(nextEnabled: boolean) {
|
||||
if (!medication) return;
|
||||
setToggling(true);
|
||||
@@ -547,15 +559,16 @@ function ApiEndpointDialog({
|
||||
return;
|
||||
}
|
||||
|
||||
setEnabled(json.data.enabled === true);
|
||||
if (typeof json.data.activeTokenCount === "number") {
|
||||
setActiveTokenCount(json.data.activeTokenCount);
|
||||
} else if (
|
||||
!json.data.enabled &&
|
||||
typeof json.data.revokedTokenCount === "number"
|
||||
) {
|
||||
setActiveTokenCount(0);
|
||||
}
|
||||
const nextStatus: ApiEndpointStatus = {
|
||||
enabled: json.data.enabled === true,
|
||||
activeTokenCount:
|
||||
typeof json.data.activeTokenCount === "number"
|
||||
? json.data.activeTokenCount
|
||||
: !json.data.enabled
|
||||
? 0
|
||||
: (status?.activeTokenCount ?? 0),
|
||||
};
|
||||
queryClient.setQueryData<ApiEndpointStatus>(apiEndpointKey, nextStatus);
|
||||
|
||||
if (json.data.token) {
|
||||
setToken(json.data.token);
|
||||
@@ -579,11 +592,6 @@ function ApiEndpointDialog({
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!medication) return;
|
||||
void loadStatus();
|
||||
}, [medication, loadStatus]);
|
||||
|
||||
const baseUrl =
|
||||
typeof window !== "undefined" ? window.location.origin : "https://...";
|
||||
const endpoint = `${baseUrl}/api/ingest/medication`;
|
||||
@@ -756,11 +764,11 @@ function ApiEndpointDialog({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{msg && (
|
||||
{displayMsg && (
|
||||
<p
|
||||
className={`text-sm ${msg === t("medications.apiEndpointActivated") || msg === t("medications.apiEndpointDeactivated") ? "text-dracula-green" : "text-destructive"}`}
|
||||
className={`text-sm ${displayMsg === t("medications.apiEndpointActivated") || displayMsg === t("medications.apiEndpointDeactivated") ? "text-dracula-green" : "text-destructive"}`}
|
||||
>
|
||||
{msg}
|
||||
{displayMsg}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -780,7 +788,7 @@ function ApiEndpointDialog({
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
void loadStatus();
|
||||
void refetchStatus();
|
||||
}}
|
||||
disabled={loadingStatus || toggling}
|
||||
>
|
||||
|
||||
@@ -163,7 +163,7 @@ export default function NotificationsPage() {
|
||||
{t("notifications.noChannels")}
|
||||
</p>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/settings">
|
||||
<Link href="/settings/notifications">
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
{t("notifications.goToSettings")}
|
||||
</Link>
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
// Stub Next.js navigation — we never actually navigate in these tests.
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ replace: vi.fn(), push: vi.fn() }),
|
||||
}));
|
||||
|
||||
// Stub TanStack Query — the onboarding page only invalidates after
|
||||
// finish, which we don't trigger here.
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
useQuery: () => ({ data: null, isLoading: false }),
|
||||
}));
|
||||
|
||||
// Stub the toaster — fires post-finish only.
|
||||
vi.mock("sonner", () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
// The Logo pulls in an SVG icon set we don't need to assert on.
|
||||
vi.mock("@/components/ui/logo", () => ({
|
||||
Logo: () => null,
|
||||
}));
|
||||
|
||||
// MeasurementForm is independently tested. Stub it so step 2's render
|
||||
// doesn't drag the entire form's dependencies into this SSR test.
|
||||
vi.mock("@/components/measurements/measurement-form", () => ({
|
||||
MeasurementForm: () => null,
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import OnboardingPage from "../page";
|
||||
|
||||
function render(locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<OnboardingPage />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("<OnboardingPage> v2", () => {
|
||||
it("renders three progress dots, not four", () => {
|
||||
const html = render();
|
||||
// The progress row holds exactly three dots — count `w-10` segments.
|
||||
const dots = html.match(/h-1\.5 w-10 rounded-full/g) ?? [];
|
||||
expect(dots.length).toBe(3);
|
||||
});
|
||||
|
||||
it("shows the v2 welcome copy and step 1 of 3 indicator", () => {
|
||||
const html = render();
|
||||
expect(html).toContain("Welcome to HealthLog");
|
||||
expect(html).toContain("Step 1 of 3");
|
||||
expect(html).toContain("About you");
|
||||
});
|
||||
|
||||
it("renders the German title when locale is de", () => {
|
||||
const html = render("de");
|
||||
expect(html).toContain("Willkommen bei HealthLog");
|
||||
expect(html).toContain("Schritt 1 von 3");
|
||||
});
|
||||
|
||||
it("includes a single skip link with the descriptive label", () => {
|
||||
const html = render();
|
||||
const skipMatches =
|
||||
html.match(
|
||||
/Skip this step — you can finish setup later from Settings./g,
|
||||
) ?? [];
|
||||
// Exactly one skip control on step 1; second skip from the legacy
|
||||
// wizard is gone.
|
||||
expect(skipMatches.length).toBe(1);
|
||||
});
|
||||
|
||||
it("does not show a Back button on step 1 (first step)", () => {
|
||||
const html = render();
|
||||
expect(html).not.toContain(">Back<");
|
||||
});
|
||||
|
||||
it("provides every step-1 form field via accessible labels", () => {
|
||||
const html = render();
|
||||
expect(html).toContain('for="ob-display-name"');
|
||||
expect(html).toContain('for="ob-language"');
|
||||
expect(html).toContain('for="ob-height"');
|
||||
expect(html).toContain('for="ob-gender"');
|
||||
expect(html).toContain('for="ob-dob"');
|
||||
});
|
||||
|
||||
it("step-1 progressbar has correct aria attributes", () => {
|
||||
const html = render();
|
||||
expect(html).toContain('role="progressbar"');
|
||||
expect(html).toContain('aria-valuemin="1"');
|
||||
expect(html).toContain('aria-valuemax="3"');
|
||||
expect(html).toContain('aria-valuenow="1"');
|
||||
});
|
||||
});
|
||||
+333
-248
@@ -4,93 +4,128 @@ import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Bell,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
MessageCircle,
|
||||
Smartphone,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Logo } from "@/components/ui/logo";
|
||||
import { Loader2, ChevronRight } from "lucide-react";
|
||||
import { MeasurementForm } from "@/components/measurements/measurement-form";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { locales, localeLabels, type Locale } from "@/lib/i18n/config";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const TOTAL_STEPS = 4;
|
||||
const TOTAL_STEPS = 3;
|
||||
|
||||
type ChannelChoice = "TELEGRAM" | "WEB_PUSH" | "NTFY" | "NONE";
|
||||
|
||||
const CHANNEL_OPTIONS: ReadonlyArray<{
|
||||
id: ChannelChoice;
|
||||
Icon: typeof MessageCircle;
|
||||
titleKey: string;
|
||||
hintKey: string;
|
||||
/**
|
||||
* Hash anchor on the notifications settings page to scroll to. NONE = skip,
|
||||
* no hash. The wizard pushes the user to `/settings/notifications#<hash>`
|
||||
* after onboarding finishes — see the v1.4 settings split (PR A2-shell)
|
||||
* for the new route shape.
|
||||
*/
|
||||
hash: string | null;
|
||||
}> = [
|
||||
{
|
||||
id: "TELEGRAM",
|
||||
Icon: MessageCircle,
|
||||
titleKey: "onboarding.v2.step3.channelTelegramTitle",
|
||||
hintKey: "onboarding.v2.step3.channelTelegramHint",
|
||||
hash: "telegram",
|
||||
},
|
||||
{
|
||||
id: "WEB_PUSH",
|
||||
Icon: Bell,
|
||||
titleKey: "onboarding.v2.step3.channelWebPushTitle",
|
||||
hintKey: "onboarding.v2.step3.channelWebPushHint",
|
||||
hash: "web-push",
|
||||
},
|
||||
{
|
||||
id: "NTFY",
|
||||
Icon: Smartphone,
|
||||
titleKey: "onboarding.v2.step3.channelNtfyTitle",
|
||||
hintKey: "onboarding.v2.step3.channelNtfyHint",
|
||||
hash: "ntfy",
|
||||
},
|
||||
{
|
||||
id: "NONE",
|
||||
Icon: CheckCircle2,
|
||||
titleKey: "onboarding.v2.step3.channelNoneTitle",
|
||||
hintKey: "onboarding.v2.step3.channelNoneHint",
|
||||
hash: null,
|
||||
},
|
||||
];
|
||||
|
||||
export default function OnboardingPage() {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslations();
|
||||
const { t, locale, setLocale } = useTranslations();
|
||||
|
||||
const [step, setStep] = useState(1);
|
||||
const [step, setStep] = useState<1 | 2 | 3>(1);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Step 1: Profile
|
||||
// Step 1 — about you
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [heightCm, setHeightCm] = useState("");
|
||||
const [dateOfBirth, setDateOfBirth] = useState("");
|
||||
const [gender, setGender] = useState("");
|
||||
|
||||
// Step 2: Medications
|
||||
const [medName, setMedName] = useState("");
|
||||
const [medDose, setMedDose] = useState("");
|
||||
const [meds, setMeds] = useState<Array<{ name: string; dose: string }>>([]);
|
||||
// Step 2 — first measurement is captured by `<MeasurementForm>`
|
||||
// directly; we just track whether the user has already submitted one
|
||||
// so the UI can switch to a "got it" state.
|
||||
const [measurementLogged, setMeasurementLogged] = useState(false);
|
||||
|
||||
// Step 3: Targets
|
||||
const [targetSys, setTargetSys] = useState("");
|
||||
const [targetDia, setTargetDia] = useState("");
|
||||
const [targetWeight, setTargetWeight] = useState("");
|
||||
// Step 3 — preferred notification channel.
|
||||
const [channelChoice, setChannelChoice] = useState<ChannelChoice>("NONE");
|
||||
|
||||
async function finishOnboarding() {
|
||||
function goBack() {
|
||||
setStep((prev) => (prev > 1 ? ((prev - 1) as 1 | 2) : 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the profile + complete onboarding. Always called once at
|
||||
* the end of the wizard — independent of which steps were skipped —
|
||||
* so `onboardingCompletedAt` is set even if the user skips
|
||||
* everything except the wizard click-through.
|
||||
*/
|
||||
async function persistAndExit(opts?: { gotoSettingsHash?: string | null }) {
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
const body: Record<string, unknown> = {};
|
||||
if (displayName.trim()) body.displayName = displayName.trim();
|
||||
if (heightCm) body.heightCm = parseFloat(heightCm);
|
||||
if (dateOfBirth) body.dateOfBirth = dateOfBirth;
|
||||
if (gender) body.gender = gender;
|
||||
|
||||
const profileRes = await fetch("/api/onboarding/complete", {
|
||||
const res = await fetch("/api/onboarding/complete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!profileRes.ok) {
|
||||
throw new Error(t("onboarding.errorProfile"));
|
||||
}
|
||||
|
||||
// Create medications. Server validation requires at least one schedule,
|
||||
// so we attach a sensible default — the user is informed via medScheduleHint
|
||||
// and can refine it in Medications later. Phase 3.5 of the audit replaces
|
||||
// this entire wizard with an empty-state-driven flow.
|
||||
for (const med of meds) {
|
||||
const medRes = await fetch("/api/medications", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: med.name,
|
||||
dose: med.dose,
|
||||
schedules: [{ windowStart: "08:00", windowEnd: "09:00" }],
|
||||
}),
|
||||
});
|
||||
if (!medRes.ok) {
|
||||
throw new Error(t("onboarding.errorMedication", { name: med.name }));
|
||||
}
|
||||
}
|
||||
|
||||
if (targetSys || targetDia || targetWeight) {
|
||||
const targets: Record<string, unknown> = {};
|
||||
if (targetSys) targets.bpSysTarget = parseInt(targetSys);
|
||||
if (targetDia) targets.bpDiaTarget = parseInt(targetDia);
|
||||
if (targetWeight) targets.weightTarget = parseFloat(targetWeight);
|
||||
const targetsRes = await fetch("/api/insights/targets", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(targets),
|
||||
});
|
||||
if (!targetsRes.ok) {
|
||||
throw new Error(t("onboarding.errorTargets"));
|
||||
}
|
||||
}
|
||||
if (!res.ok) throw new Error(t("onboarding.errorProfile"));
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: ["auth"] });
|
||||
router.replace("/");
|
||||
toast.success(t("onboarding.v2.doneToast"));
|
||||
|
||||
if (opts?.gotoSettingsHash) {
|
||||
router.replace(`/settings/notifications#${opts.gotoSettingsHash}`);
|
||||
} else {
|
||||
router.replace("/");
|
||||
}
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : t("onboarding.errorGeneric");
|
||||
@@ -99,79 +134,145 @@ export default function OnboardingPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function addMedication() {
|
||||
if (!medName.trim()) return;
|
||||
setMeds([...meds, { name: medName.trim(), dose: medDose.trim() }]);
|
||||
setMedName("");
|
||||
setMedDose("");
|
||||
}
|
||||
|
||||
function nextStep() {
|
||||
if (step < TOTAL_STEPS) {
|
||||
setStep(step + 1);
|
||||
} else {
|
||||
finishOnboarding();
|
||||
setStep((step + 1) as 1 | 2 | 3);
|
||||
}
|
||||
}
|
||||
|
||||
function finish() {
|
||||
const target =
|
||||
channelChoice !== "NONE"
|
||||
? (CHANNEL_OPTIONS.find((c) => c.id === channelChoice)?.hash ?? null)
|
||||
: null;
|
||||
void persistAndExit({ gotoSettingsHash: target });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-md space-y-6">
|
||||
<div className="mx-auto w-full max-w-md space-y-6 py-6">
|
||||
<div className="text-center">
|
||||
<Logo className="text-primary mx-auto mb-4" size={48} />
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("onboarding.welcome")}
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
{t("onboarding.v2.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
{t("onboarding.setupDescription")}
|
||||
{t("onboarding.v2.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Progress indicator */}
|
||||
<div className="flex justify-center gap-2">
|
||||
{/* Progress indicator — labelled for screen readers */}
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-valuemin={1}
|
||||
aria-valuemax={TOTAL_STEPS}
|
||||
aria-valuenow={step}
|
||||
aria-label={t("onboarding.v2.stepOf", {
|
||||
current: step,
|
||||
total: TOTAL_STEPS,
|
||||
})}
|
||||
className="flex items-center justify-center gap-2"
|
||||
>
|
||||
{Array.from({ length: TOTAL_STEPS }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"h-1.5 w-8 rounded-full transition-colors",
|
||||
"h-1.5 w-10 rounded-full transition-colors duration-150 ease-out motion-reduce:transition-none",
|
||||
i + 1 <= step ? "bg-primary" : "bg-muted",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-muted-foreground -mt-3 text-center text-xs">
|
||||
{t("onboarding.v2.stepOf", { current: step, total: TOTAL_STEPS })}
|
||||
</p>
|
||||
|
||||
{/* Step 1: Profile */}
|
||||
{/* ───── Step 1 — about you ───── */}
|
||||
{step === 1 && (
|
||||
<div className="bg-card border-border space-y-4 rounded-xl border p-6">
|
||||
<h2 className="text-lg font-semibold">{t("settings.profile")}</h2>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ob-height">{t("settings.height")}</Label>
|
||||
<Input
|
||||
id="ob-height"
|
||||
type="number"
|
||||
value={heightCm}
|
||||
onChange={(e) => setHeightCm(e.target.value)}
|
||||
placeholder="175"
|
||||
min={50}
|
||||
max={300}
|
||||
step={0.1}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ob-gender">{t("settings.gender")}</Label>
|
||||
<select
|
||||
id="ob-gender"
|
||||
value={gender}
|
||||
onChange={(e) => setGender(e.target.value)}
|
||||
className="border-input bg-background text-foreground ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-sm focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
<section
|
||||
aria-labelledby="ob-step1-title"
|
||||
className="bg-card border-border space-y-5 rounded-xl border p-6"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
<h2
|
||||
id="ob-step1-title"
|
||||
className="text-lg font-semibold tracking-tight"
|
||||
>
|
||||
<option value="">{t("settings.genderNone")}</option>
|
||||
<option value="MALE">{t("settings.genderMale")}</option>
|
||||
<option value="FEMALE">{t("settings.genderFemale")}</option>
|
||||
</select>
|
||||
{t("onboarding.v2.step1.title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("onboarding.v2.step1.description")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ob-display-name">
|
||||
{t("onboarding.v2.step1.displayNameLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="ob-display-name"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
autoComplete="nickname"
|
||||
placeholder={t("settings.username")}
|
||||
maxLength={50}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.genderHint")}
|
||||
{t("onboarding.v2.step1.displayNameHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ob-language">
|
||||
{t("onboarding.v2.step1.languageLabel")}
|
||||
</Label>
|
||||
<select
|
||||
id="ob-language"
|
||||
value={locale}
|
||||
onChange={(e) => setLocale(e.target.value as Locale)}
|
||||
className="border-input bg-background text-foreground ring-offset-background focus-visible:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-sm focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
{locales.map((loc) => (
|
||||
<option key={loc} value={loc}>
|
||||
{localeLabels[loc as Locale]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("onboarding.v2.step1.languageHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ob-height">{t("settings.height")}</Label>
|
||||
<Input
|
||||
id="ob-height"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
value={heightCm}
|
||||
onChange={(e) => setHeightCm(e.target.value)}
|
||||
placeholder="175"
|
||||
min={50}
|
||||
max={300}
|
||||
step={0.1}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ob-gender">{t("settings.gender")}</Label>
|
||||
<select
|
||||
id="ob-gender"
|
||||
value={gender}
|
||||
onChange={(e) => setGender(e.target.value)}
|
||||
className="border-input bg-background text-foreground ring-offset-background focus-visible:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-sm focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
<option value="">{t("settings.genderNone")}</option>
|
||||
<option value="MALE">{t("settings.genderMale")}</option>
|
||||
<option value="FEMALE">{t("settings.genderFemale")}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ob-dob">{t("settings.dateOfBirth")}</Label>
|
||||
<Input
|
||||
@@ -180,181 +281,165 @@ export default function OnboardingPage() {
|
||||
value={dateOfBirth}
|
||||
onChange={(e) => setDateOfBirth(e.target.value)}
|
||||
max={new Date().toISOString().slice(0, 10)}
|
||||
autoComplete="bday"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.dateOfBirthHint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Step 2: Medications */}
|
||||
{/* ───── Step 2 — first measurement ───── */}
|
||||
{step === 2 && (
|
||||
<div className="bg-card border-border space-y-4 rounded-xl border p-6">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("onboarding.medicationsTitle")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("onboarding.medicationsDescription")}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={medName}
|
||||
onChange={(e) => setMedName(e.target.value)}
|
||||
placeholder={t("onboarding.medNamePlaceholder")}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
value={medDose}
|
||||
onChange={(e) => setMedDose(e.target.value)}
|
||||
placeholder={t("onboarding.medDosePlaceholder")}
|
||||
className="w-24"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addMedication}
|
||||
<section
|
||||
aria-labelledby="ob-step2-title"
|
||||
className="bg-card border-border space-y-5 rounded-xl border p-6"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
<h2
|
||||
id="ob-step2-title"
|
||||
className="text-lg font-semibold tracking-tight"
|
||||
>
|
||||
+
|
||||
</Button>
|
||||
</div>
|
||||
{meds.length > 0 && (
|
||||
<>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{meds.map((m, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="bg-muted/50 flex items-center justify-between rounded px-3 py-1.5"
|
||||
>
|
||||
<span>
|
||||
{m.name} {m.dose && `(${m.dose})`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-destructive text-xs"
|
||||
onClick={() => setMeds(meds.filter((_, j) => j !== i))}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("onboarding.medScheduleHint")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Notifications teaser */}
|
||||
{step === 3 && (
|
||||
<div className="bg-card border-border space-y-4 rounded-xl border p-6">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("onboarding.notificationsTitle")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("onboarding.notificationsDescription")}
|
||||
</p>
|
||||
<div className="bg-muted/50 space-y-2 rounded-lg p-4 text-sm">
|
||||
<p>
|
||||
📱 <strong>Telegram</strong> — {t("onboarding.telegramHint")}
|
||||
{t("onboarding.v2.step2.title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("onboarding.v2.step2.description")}
|
||||
</p>
|
||||
<p>
|
||||
🔔 <strong>Web Push</strong> — {t("onboarding.webPushHint")}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("onboarding.notificationsLater")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Step 4: Targets */}
|
||||
{step === 4 && (
|
||||
<div className="bg-card border-border space-y-4 rounded-xl border p-6">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("onboarding.targetsTitle")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("onboarding.targetsDescription")}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="ob-sys">{t("onboarding.targetSys")}</Label>
|
||||
<Input
|
||||
id="ob-sys"
|
||||
type="number"
|
||||
value={targetSys}
|
||||
onChange={(e) => setTargetSys(e.target.value)}
|
||||
placeholder="130"
|
||||
/>
|
||||
{measurementLogged ? (
|
||||
<div className="border-primary/40 bg-primary/5 text-foreground flex items-start gap-3 rounded-lg border p-4 text-sm">
|
||||
<CheckCircle2 className="text-primary mt-0.5 size-5 shrink-0" />
|
||||
<p>{t("onboarding.v2.step2.added")}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="ob-dia">{t("onboarding.targetDia")}</Label>
|
||||
<Input
|
||||
id="ob-dia"
|
||||
type="number"
|
||||
value={targetDia}
|
||||
onChange={(e) => setTargetDia(e.target.value)}
|
||||
placeholder="85"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="ob-weight">{t("onboarding.targetWeight")}</Label>
|
||||
<Input
|
||||
id="ob-weight"
|
||||
type="number"
|
||||
value={targetWeight}
|
||||
onChange={(e) => setTargetWeight(e.target.value)}
|
||||
placeholder="75"
|
||||
step={0.1}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation buttons */}
|
||||
<div className="flex gap-3">
|
||||
<Button onClick={nextStep} className="flex-1" disabled={saving}>
|
||||
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{step < TOTAL_STEPS ? (
|
||||
<>
|
||||
{t("common.next")}
|
||||
<ChevronRight className="ml-1 h-4 w-4" />
|
||||
</>
|
||||
) : (
|
||||
t("onboarding.completeSetup")
|
||||
<MeasurementForm onSuccess={() => setMeasurementLogged(true)} />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("onboarding.v2.step2.skipHint")}
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ───── Step 3 — pick a channel ───── */}
|
||||
{step === 3 && (
|
||||
<section
|
||||
aria-labelledby="ob-step3-title"
|
||||
className="bg-card border-border space-y-5 rounded-xl border p-6"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
<h2
|
||||
id="ob-step3-title"
|
||||
className="text-lg font-semibold tracking-tight"
|
||||
>
|
||||
{t("onboarding.v2.step3.title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("onboarding.v2.step3.description")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="sr-only">
|
||||
{t("onboarding.v2.step3.title")}
|
||||
</legend>
|
||||
{CHANNEL_OPTIONS.map((option) => {
|
||||
const checked = channelChoice === option.id;
|
||||
const Icon = option.Icon;
|
||||
return (
|
||||
<label
|
||||
key={option.id}
|
||||
className={cn(
|
||||
"border-border hover:border-primary/50 flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition-colors duration-150 ease-out motion-reduce:transition-none",
|
||||
checked && "border-primary bg-primary/5",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="ob-channel"
|
||||
value={option.id}
|
||||
checked={checked}
|
||||
onChange={() => setChannelChoice(option.id)}
|
||||
className="text-primary focus-visible:ring-ring mt-1 size-4 focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
/>
|
||||
<span className="flex min-w-0 flex-1 items-start gap-3">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="bg-muted text-muted-foreground flex size-8 shrink-0 items-center justify-center rounded-md"
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-medium">
|
||||
{t(option.titleKey)}
|
||||
</span>
|
||||
<span className="text-muted-foreground block text-xs">
|
||||
{t(option.hintKey)}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</fieldset>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ───── Navigation row ───── */}
|
||||
<div className="flex items-center gap-3">
|
||||
{step > 1 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={goBack}
|
||||
disabled={saving}
|
||||
>
|
||||
<ArrowLeft className="mr-1 size-4" />
|
||||
{t("onboarding.v2.back")}
|
||||
</Button>
|
||||
) : (
|
||||
<span className="flex-1" />
|
||||
)}
|
||||
|
||||
{step < TOTAL_STEPS ? (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
onClick={nextStep}
|
||||
className="ml-auto"
|
||||
disabled={saving}
|
||||
className="text-muted-foreground hover:text-foreground text-sm underline underline-offset-4 transition-colors"
|
||||
>
|
||||
{t("onboarding.skip")}
|
||||
</button>
|
||||
{t("onboarding.v2.continue")}
|
||||
<ArrowRight className="ml-1 size-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// Skip targets and finish
|
||||
finishOnboarding();
|
||||
}}
|
||||
onClick={finish}
|
||||
className="ml-auto"
|
||||
disabled={saving}
|
||||
className="text-muted-foreground hover:text-foreground text-sm underline underline-offset-4 transition-colors"
|
||||
>
|
||||
{t("onboarding.skip")}
|
||||
</button>
|
||||
{saving && <Loader2 className="mr-2 size-4 animate-spin" />}
|
||||
{channelChoice === "NONE"
|
||||
? t("onboarding.v2.finish")
|
||||
: t("onboarding.v2.step3.openSettings")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Single Skip per step. Step 3's "NONE" choice already covers
|
||||
skipping notifications, so we hide the underline on step 3. */}
|
||||
{step < TOTAL_STEPS ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={nextStep}
|
||||
disabled={saving}
|
||||
className="text-muted-foreground hover:text-foreground mx-auto block text-sm underline-offset-4 transition-colors duration-150 ease-out hover:underline motion-reduce:transition-none"
|
||||
>
|
||||
{t("onboarding.v2.skipStep")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+150
-30
@@ -40,6 +40,8 @@ import {
|
||||
import { MeasurementForm } from "@/components/measurements/measurement-form";
|
||||
import { MoodForm } from "@/components/mood/mood-form";
|
||||
import { TrendCard } from "@/components/charts/trend-card";
|
||||
import { TrendHint } from "@/components/charts/trend-hint";
|
||||
import { GettingStartedChecklist } from "@/components/onboarding/getting-started-checklist";
|
||||
|
||||
const HealthChart = dynamic(
|
||||
() =>
|
||||
@@ -155,7 +157,9 @@ export default function DashboardPage() {
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const [quickEntryDialog, setQuickEntryDialog] = useState<"measurement" | "mood" | null>(null);
|
||||
const [quickEntryDialog, setQuickEntryDialog] = useState<
|
||||
"measurement" | "mood" | null
|
||||
>(null);
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: queryKeys.analytics(),
|
||||
@@ -185,12 +189,15 @@ export default function DashboardPage() {
|
||||
const res = await fetch("/api/mood/analytics");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
const json = await res.json();
|
||||
return json.data as { entries: Array<{ date: string; score: number; samples: number }>; summary: DataSummary };
|
||||
return json.data as {
|
||||
entries: Array<{ date: string; score: number; samples: number }>;
|
||||
summary: DataSummary;
|
||||
};
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
const w = data?.summaries.WEIGHT;
|
||||
const w = data?.summaries?.WEIGHT;
|
||||
const sys = data?.summaries.BLOOD_PRESSURE_SYS;
|
||||
const dia = data?.summaries.BLOOD_PRESSURE_DIA;
|
||||
const p = data?.summaries.PULSE;
|
||||
@@ -230,11 +237,17 @@ export default function DashboardPage() {
|
||||
const glucoseWidgetVisible = isWidgetVisible("glucose");
|
||||
const displayGlucoseUnit = resolveGlucoseUnit(user?.glucoseUnit ?? null);
|
||||
const glucoseByContext = data?.glucoseByContext ?? {};
|
||||
const glucoseContexts = ["FASTING", "POSTPRANDIAL", "RANDOM", "BEDTIME"] as const;
|
||||
const glucoseContexts = [
|
||||
"FASTING",
|
||||
"POSTPRANDIAL",
|
||||
"RANDOM",
|
||||
"BEDTIME",
|
||||
] as const;
|
||||
const glucoseSummariesPresent = glucoseContexts.filter(
|
||||
(ctx) => (glucoseByContext[ctx]?.count ?? 0) > 0,
|
||||
);
|
||||
const showGlucoseCards = glucoseWidgetVisible && glucoseSummariesPresent.length > 0;
|
||||
const showGlucoseCards =
|
||||
glucoseWidgetVisible && glucoseSummariesPresent.length > 0;
|
||||
const glucoseLabelKey: Record<string, string> = {
|
||||
FASTING: "targets.glucoseFasting",
|
||||
POSTPRANDIAL: "targets.glucosePostprandial",
|
||||
@@ -356,7 +369,9 @@ export default function DashboardPage() {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setQuickEntryDialog("measurement")}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setQuickEntryDialog("measurement")}
|
||||
>
|
||||
<Activity className="mr-2 h-4 w-4" />
|
||||
{t("measurements.addMeasurement")}
|
||||
</DropdownMenuItem>
|
||||
@@ -368,6 +383,12 @@ export default function DashboardPage() {
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* v1.4: Getting-started checklist for brand-new users.
|
||||
* Self-gates visibility on (onboardingCompletedAt == null
|
||||
* || measurementCount < 5) and disappears once dismissed
|
||||
* or fully complete. See B2 in the v1.4 discovery summary. */}
|
||||
<GettingStartedChecklist />
|
||||
|
||||
{/* Quick Entry Dialogs */}
|
||||
<Dialog
|
||||
open={quickEntryDialog === "measurement"}
|
||||
@@ -414,10 +435,24 @@ export default function DashboardPage() {
|
||||
unit="kg"
|
||||
avg7={w?.avg7 ?? null}
|
||||
avg30={w?.avg30 ?? null}
|
||||
avg7ColorClass={getRangeColorClass(w?.avg7, { range: weightRange })}
|
||||
avg30ColorClass={getRangeColorClass(w?.avg30, { range: weightRange })}
|
||||
avg7Hint={getRangeHint("kg", { range: weightRange }, t, fmt.number)}
|
||||
avg30Hint={getRangeHint("kg", { range: weightRange }, t, fmt.number)}
|
||||
avg7ColorClass={getRangeColorClass(w?.avg7, {
|
||||
range: weightRange,
|
||||
})}
|
||||
avg30ColorClass={getRangeColorClass(w?.avg30, {
|
||||
range: weightRange,
|
||||
})}
|
||||
avg7Hint={getRangeHint(
|
||||
"kg",
|
||||
{ range: weightRange },
|
||||
t,
|
||||
fmt.number,
|
||||
)}
|
||||
avg30Hint={getRangeHint(
|
||||
"kg",
|
||||
{ range: weightRange },
|
||||
t,
|
||||
fmt.number,
|
||||
)}
|
||||
slope30={w?.slope30 ?? null}
|
||||
icon={Activity}
|
||||
/>
|
||||
@@ -436,10 +471,24 @@ export default function DashboardPage() {
|
||||
unit="mmHg"
|
||||
avg7={sys?.avg7 ?? null}
|
||||
avg30={sys?.avg30 ?? null}
|
||||
avg7ColorClass={getRangeColorClass(sys?.avg7, { range: bpSysRange })}
|
||||
avg30ColorClass={getRangeColorClass(sys?.avg30, { range: bpSysRange })}
|
||||
avg7Hint={getRangeHint("mmHg", { range: bpSysRange }, t, fmt.number)}
|
||||
avg30Hint={getRangeHint("mmHg", { range: bpSysRange }, t, fmt.number)}
|
||||
avg7ColorClass={getRangeColorClass(sys?.avg7, {
|
||||
range: bpSysRange,
|
||||
})}
|
||||
avg30ColorClass={getRangeColorClass(sys?.avg30, {
|
||||
range: bpSysRange,
|
||||
})}
|
||||
avg7Hint={getRangeHint(
|
||||
"mmHg",
|
||||
{ range: bpSysRange },
|
||||
t,
|
||||
fmt.number,
|
||||
)}
|
||||
avg30Hint={getRangeHint(
|
||||
"mmHg",
|
||||
{ range: bpSysRange },
|
||||
t,
|
||||
fmt.number,
|
||||
)}
|
||||
slope30={sys?.slope30 ?? null}
|
||||
icon={Heart}
|
||||
/>
|
||||
@@ -449,10 +498,24 @@ export default function DashboardPage() {
|
||||
unit="mmHg"
|
||||
avg7={dia?.avg7 ?? null}
|
||||
avg30={dia?.avg30 ?? null}
|
||||
avg7ColorClass={getRangeColorClass(dia?.avg7, { range: bpDiaRange })}
|
||||
avg30ColorClass={getRangeColorClass(dia?.avg30, { range: bpDiaRange })}
|
||||
avg7Hint={getRangeHint("mmHg", { range: bpDiaRange }, t, fmt.number)}
|
||||
avg30Hint={getRangeHint("mmHg", { range: bpDiaRange }, t, fmt.number)}
|
||||
avg7ColorClass={getRangeColorClass(dia?.avg7, {
|
||||
range: bpDiaRange,
|
||||
})}
|
||||
avg30ColorClass={getRangeColorClass(dia?.avg30, {
|
||||
range: bpDiaRange,
|
||||
})}
|
||||
avg7Hint={getRangeHint(
|
||||
"mmHg",
|
||||
{ range: bpDiaRange },
|
||||
t,
|
||||
fmt.number,
|
||||
)}
|
||||
avg30Hint={getRangeHint(
|
||||
"mmHg",
|
||||
{ range: bpDiaRange },
|
||||
t,
|
||||
fmt.number,
|
||||
)}
|
||||
slope30={dia?.slope30 ?? null}
|
||||
icon={Heart}
|
||||
/>
|
||||
@@ -472,10 +535,24 @@ export default function DashboardPage() {
|
||||
unit="bpm"
|
||||
avg7={p?.avg7 ?? null}
|
||||
avg30={p?.avg30 ?? null}
|
||||
avg7ColorClass={getRangeColorClass(p?.avg7, { range: pulseDisplayRange })}
|
||||
avg30ColorClass={getRangeColorClass(p?.avg30, { range: pulseDisplayRange })}
|
||||
avg7Hint={getRangeHint("bpm", { range: pulseDisplayRange }, t, fmt.number)}
|
||||
avg30Hint={getRangeHint("bpm", { range: pulseDisplayRange }, t, fmt.number)}
|
||||
avg7ColorClass={getRangeColorClass(p?.avg7, {
|
||||
range: pulseDisplayRange,
|
||||
})}
|
||||
avg30ColorClass={getRangeColorClass(p?.avg30, {
|
||||
range: pulseDisplayRange,
|
||||
})}
|
||||
avg7Hint={getRangeHint(
|
||||
"bpm",
|
||||
{ range: pulseDisplayRange },
|
||||
t,
|
||||
fmt.number,
|
||||
)}
|
||||
avg30Hint={getRangeHint(
|
||||
"bpm",
|
||||
{ range: pulseDisplayRange },
|
||||
t,
|
||||
fmt.number,
|
||||
)}
|
||||
slope30={p?.slope30 ?? null}
|
||||
icon={TrendingUp}
|
||||
/>
|
||||
@@ -610,12 +687,23 @@ export default function DashboardPage() {
|
||||
|
||||
trendCards.sort((a, b) => a.order - b.order);
|
||||
|
||||
type ChartEntry = { id: string; order: number; node: React.ReactNode };
|
||||
type ChartEntry = {
|
||||
id: string;
|
||||
order: number;
|
||||
node: React.ReactNode;
|
||||
/**
|
||||
* Total raw readings for this metric. <5 surfaces a contextual
|
||||
* "First trend after 5 readings" hint underneath the chart;
|
||||
* undefined disables the hint (e.g. medications card).
|
||||
*/
|
||||
count?: number;
|
||||
};
|
||||
const charts: ChartEntry[] = [];
|
||||
if (showWeightCard) {
|
||||
charts.push({
|
||||
id: "weight-chart",
|
||||
order: widgetOrder("weight"),
|
||||
count: w?.count ?? 0,
|
||||
node: (
|
||||
<HealthChart
|
||||
key="weight-chart"
|
||||
@@ -655,6 +743,7 @@ export default function DashboardPage() {
|
||||
charts.push({
|
||||
id: "bp-chart",
|
||||
order: widgetOrder("bp"),
|
||||
count: Math.max(sys?.count ?? 0, dia?.count ?? 0),
|
||||
node: (
|
||||
<HealthChart
|
||||
key="bp-chart"
|
||||
@@ -672,6 +761,7 @@ export default function DashboardPage() {
|
||||
charts.push({
|
||||
id: "pulse-chart",
|
||||
order: widgetOrder("pulse"),
|
||||
count: p?.count ?? 0,
|
||||
node: (
|
||||
<HealthChart
|
||||
key="pulse-chart"
|
||||
@@ -688,6 +778,7 @@ export default function DashboardPage() {
|
||||
charts.push({
|
||||
id: "bodyFat-chart",
|
||||
order: widgetOrder("bodyFat"),
|
||||
count: bf?.count ?? 0,
|
||||
node: (
|
||||
<HealthChart
|
||||
key="bodyFat-chart"
|
||||
@@ -704,6 +795,7 @@ export default function DashboardPage() {
|
||||
charts.push({
|
||||
id: "mood-chart",
|
||||
order: widgetOrder("mood"),
|
||||
count: moodSummary?.count ?? 0,
|
||||
node: <MoodChart key="mood-chart" />,
|
||||
});
|
||||
}
|
||||
@@ -711,6 +803,7 @@ export default function DashboardPage() {
|
||||
charts.push({
|
||||
id: "sleep-chart",
|
||||
order: widgetOrder("sleep"),
|
||||
count: sleepSummary?.count ?? 0,
|
||||
node: (
|
||||
<HealthChart
|
||||
key="sleep-chart"
|
||||
@@ -726,6 +819,7 @@ export default function DashboardPage() {
|
||||
charts.push({
|
||||
id: "steps-chart",
|
||||
order: widgetOrder("steps"),
|
||||
count: stepsSummary?.count ?? 0,
|
||||
node: (
|
||||
<HealthChart
|
||||
key="steps-chart"
|
||||
@@ -741,10 +835,7 @@ export default function DashboardPage() {
|
||||
id: "medications",
|
||||
order: widgetOrder("medications"),
|
||||
node: (
|
||||
<div
|
||||
key="medications"
|
||||
className="bg-card rounded-lg border p-4"
|
||||
>
|
||||
<div key="medications" className="bg-card rounded-lg border p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Pill className="h-4 w-4" />
|
||||
<h3 className="text-sm font-medium">
|
||||
@@ -763,10 +854,39 @@ export default function DashboardPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{trendCards.map((entry) => entry.node)}
|
||||
{/* v1.4: dashboard tiles are *always* a single row.
|
||||
* Marc-explicit (memory feedback_dashboard_one_row.md): a 2-row
|
||||
* tile strip breaks the visual hierarchy and reads like an
|
||||
* Excel grid. Total width caps at the parent container —
|
||||
* exactly the chart-width below. When the active tile count
|
||||
* exceeds what fits the viewport, the strip horizontal-scrolls
|
||||
* instead of wrapping; the user trims the set in
|
||||
* Settings → Dashboard (`/settings/dashboard`).
|
||||
* Each tile keeps a `min-w-[10rem]` so a single tile still
|
||||
* looks substantial on a wide screen, and `snap-x snap-mandatory`
|
||||
* makes the scroll feel deliberate rather than arbitrary on
|
||||
* touch.
|
||||
*/}
|
||||
<div
|
||||
className="-mx-2 flex snap-x snap-mandatory gap-3 overflow-x-auto px-2 pb-2"
|
||||
data-slot="dashboard-tile-strip"
|
||||
data-tile-count={trendCards.length}
|
||||
>
|
||||
{trendCards.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="shrink-0 grow basis-[10rem] snap-start"
|
||||
>
|
||||
{entry.node}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{charts.map((entry) => entry.node)}
|
||||
{charts.map((entry) => (
|
||||
<div key={entry.id} className="space-y-2">
|
||||
{entry.node}
|
||||
{entry.count != null ? <TrendHint count={entry.count} /> : null}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import type { JSX } from "react";
|
||||
|
||||
import { AccountSection } from "@/components/settings/account-section";
|
||||
import { AboutSection } from "@/components/settings/about-section";
|
||||
import { AdvancedSection } from "@/components/settings/advanced-section";
|
||||
import { AiSection } from "@/components/settings/ai-section";
|
||||
import { ApiSection } from "@/components/settings/api-section";
|
||||
import { DashboardSection } from "@/components/settings/dashboard-section";
|
||||
import { IntegrationsSection } from "@/components/settings/integrations-section";
|
||||
import { NotificationsSection } from "@/components/settings/notifications-section";
|
||||
import { SectionPlaceholder } from "@/components/settings/section-placeholder";
|
||||
import {
|
||||
SETTINGS_SECTION_SLUGS,
|
||||
isSettingsSectionSlug,
|
||||
type SettingsSectionSlug,
|
||||
} from "@/components/settings/section-slugs";
|
||||
import { SettingsShell } from "@/components/settings/settings-shell";
|
||||
|
||||
/**
|
||||
* Dynamic settings section route. Each of the eight `SETTINGS_SECTION_SLUGS`
|
||||
* is pre-rendered at build via `generateStaticParams()` so the URLs are
|
||||
* statically known to Next.js, while the `dynamicParams = false` flag below
|
||||
* tells the router to 404 (instead of attempting on-demand rendering) for any
|
||||
* slug not in the list — which is exactly what `notFound()` would do at
|
||||
* request time, just earlier and without rendering.
|
||||
*/
|
||||
|
||||
export const dynamicParams = false;
|
||||
|
||||
export function generateStaticParams() {
|
||||
return SETTINGS_SECTION_SLUGS.map((section) => ({ section }));
|
||||
}
|
||||
|
||||
const SECTION_COMPONENTS: Record<
|
||||
SettingsSectionSlug,
|
||||
() => JSX.Element | null
|
||||
> = {
|
||||
account: AccountSection,
|
||||
about: AboutSection,
|
||||
ai: AiSection,
|
||||
integrations: IntegrationsSection,
|
||||
notifications: NotificationsSection,
|
||||
dashboard: DashboardSection,
|
||||
api: ApiSection,
|
||||
advanced: AdvancedSection,
|
||||
};
|
||||
|
||||
interface PageProps {
|
||||
// Next.js 16 made route `params` an async Promise. We `await` it before use.
|
||||
params: Promise<{ section: string }>;
|
||||
}
|
||||
|
||||
export default async function SettingsSectionPage({ params }: PageProps) {
|
||||
const { section } = await params;
|
||||
|
||||
// Defence-in-depth — `dynamicParams = false` already 404s unknown slugs at
|
||||
// routing time, but we re-check here so a hand-rolled override of the route
|
||||
// config can never silently fall through to a typo'd slug.
|
||||
if (!isSettingsSectionSlug(section)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const SectionComponent = SECTION_COMPONENTS[section];
|
||||
// Defensive fallback: in theory unreachable since `isSettingsSectionSlug`
|
||||
// guards the slug, but a future slug added to `SETTINGS_SECTION_SLUGS`
|
||||
// without a wired component would otherwise crash silently — placeholder
|
||||
// surfaces the gap visually instead.
|
||||
const body = SectionComponent ? (
|
||||
<SectionComponent />
|
||||
) : (
|
||||
<SectionPlaceholder slug={section} />
|
||||
);
|
||||
|
||||
return <SettingsShell active={section}>{body}</SettingsShell>;
|
||||
}
|
||||
+18
-3148
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { StatusBadge, buildCards } from "../status-card-grid";
|
||||
import type { StatusOverview } from "@/app/api/admin/status-overview/route";
|
||||
|
||||
const mockOverview: StatusOverview = {
|
||||
users: { severity: "good", total: 3, admins: 1, newThisWeek: 1 },
|
||||
integrations: {
|
||||
severity: "good",
|
||||
withings: 1,
|
||||
moodLog: 0,
|
||||
telegram: 1,
|
||||
ntfy: 0,
|
||||
webPush: 2,
|
||||
},
|
||||
monitoring: {
|
||||
severity: "caution",
|
||||
glitchtipEnabled: true,
|
||||
umamiEnabled: false,
|
||||
wideEventsEnabled: true,
|
||||
lastErrorAt: null,
|
||||
},
|
||||
backups: {
|
||||
severity: "alert",
|
||||
lastBackupAt: null,
|
||||
backedUpUsers: 0,
|
||||
retentionDays: 90,
|
||||
},
|
||||
maintenance: {
|
||||
severity: "good",
|
||||
workerRunning: true,
|
||||
workerUptimeSeconds: 3600,
|
||||
lastIdempotencyCleanup: new Date(Date.now() - 60_000).toISOString(),
|
||||
lastAuditLogCleanup: null,
|
||||
},
|
||||
auditLog: {
|
||||
severity: "info",
|
||||
eventsLast30d: 42,
|
||||
lastLoginAt: new Date(Date.now() - 5 * 60_000).toISOString(),
|
||||
},
|
||||
};
|
||||
|
||||
describe("<StatusBadge>", () => {
|
||||
it("renders a colored dot plus a text label (never color-only)", () => {
|
||||
const html = renderToStaticMarkup(<StatusBadge severity="good" />);
|
||||
// dot must be aria-hidden so SR users don't hear it
|
||||
expect(html).toContain('aria-hidden="true"');
|
||||
// label must be present so color-blind users can read state
|
||||
expect(html).toContain("Healthy");
|
||||
// wrapper carries the announcement
|
||||
expect(html).toContain('aria-label="Status: Healthy"');
|
||||
});
|
||||
|
||||
it("maps severity to the §2.6 dracula color tokens", () => {
|
||||
const cases: Array<
|
||||
[Parameters<typeof StatusBadge>[0]["severity"], string]
|
||||
> = [
|
||||
["good", "var(--dracula-green)"],
|
||||
["info", "var(--dracula-cyan)"],
|
||||
["caution", "var(--dracula-orange)"],
|
||||
["alert", "var(--dracula-red)"],
|
||||
["pending", "var(--muted-foreground)"],
|
||||
];
|
||||
for (const [severity, token] of cases) {
|
||||
const html = renderToStaticMarkup(<StatusBadge severity={severity} />);
|
||||
expect(html).toContain(token);
|
||||
}
|
||||
});
|
||||
|
||||
it("respects an explicit label override", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<StatusBadge severity="alert" label="Disk full" />,
|
||||
);
|
||||
expect(html).toContain("Disk full");
|
||||
expect(html).toContain('aria-label="Status: Disk full"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildCards()", () => {
|
||||
it("returns exactly 6 cards in the §3.4 admin order", () => {
|
||||
const cards = buildCards(mockOverview);
|
||||
expect(cards).toHaveLength(6);
|
||||
expect(cards.map((c) => c.title)).toEqual([
|
||||
"Users",
|
||||
"Integrations",
|
||||
"Monitoring",
|
||||
"Backups",
|
||||
"Maintenance",
|
||||
"Audit log",
|
||||
]);
|
||||
});
|
||||
|
||||
it("propagates severity from the aggregator response", () => {
|
||||
const cards = buildCards(mockOverview);
|
||||
expect(cards[0].severity).toBe("good"); // users
|
||||
expect(cards[2].severity).toBe("caution"); // monitoring
|
||||
expect(cards[3].severity).toBe("alert"); // backups (no backup yet)
|
||||
expect(cards[5].severity).toBe("info"); // audit log
|
||||
});
|
||||
|
||||
it("links each card to a manageable destination", () => {
|
||||
const cards = buildCards(mockOverview);
|
||||
for (const card of cards) {
|
||||
expect(card.href.startsWith("/admin")).toBe(true);
|
||||
expect(card.cta.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("includes 3 metric tuples per card", () => {
|
||||
const cards = buildCards(mockOverview);
|
||||
for (const card of cards) {
|
||||
expect(card.metrics).toHaveLength(3);
|
||||
}
|
||||
});
|
||||
|
||||
it("renders worker status as plain text, not color alone", () => {
|
||||
const cards = buildCards(mockOverview);
|
||||
const maintenance = cards.find((c) => c.title === "Maintenance");
|
||||
expect(maintenance?.metrics[0].value).toBe("Running");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,276 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
export interface AdminUser {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string | null;
|
||||
role: string;
|
||||
createdAt: string;
|
||||
passkeyCount: number;
|
||||
}
|
||||
|
||||
export interface WorkerStatus {
|
||||
running: boolean;
|
||||
startedAt: string | null;
|
||||
lastHeartbeat: string | null;
|
||||
lastReminderCheck: string | null;
|
||||
lastWithingsSync: string | null;
|
||||
lastInsightsRun: string | null;
|
||||
jobsProcessed: number;
|
||||
errors: number;
|
||||
}
|
||||
|
||||
export interface SystemStatus {
|
||||
version: string;
|
||||
nodeVersion: string;
|
||||
gitCommit: string;
|
||||
buildTime: string;
|
||||
startTime: string;
|
||||
database: string;
|
||||
worker: WorkerStatus;
|
||||
counts: {
|
||||
users: number;
|
||||
measurements: number;
|
||||
medications: number;
|
||||
intakeEvents: number;
|
||||
activeTokens: number;
|
||||
activeSessions: number;
|
||||
};
|
||||
integrations: {
|
||||
umami: { configured: boolean; enabled: boolean } | null;
|
||||
glitchtip: { configured: boolean; enabled: boolean } | null;
|
||||
webPush: { configured: boolean } | null;
|
||||
bugReport: { configured: boolean } | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AdminSettings {
|
||||
registrationEnabled: boolean;
|
||||
defaultLocale: string;
|
||||
telegramGlobal: boolean;
|
||||
ntfyGlobal: boolean;
|
||||
webPushGlobal: boolean;
|
||||
webPushVapidPublicKey: string | null;
|
||||
webPushVapidSubject: string | null;
|
||||
webPushVapidConfigured: boolean;
|
||||
apiGlobal: boolean;
|
||||
umamiEnabled: boolean;
|
||||
umamiScriptUrl: string | null;
|
||||
umamiWebsiteId: string | null;
|
||||
glitchtipEnabled: boolean;
|
||||
glitchtipDsn: string | null;
|
||||
glitchtipEnvironment: string | null;
|
||||
bugReportRepo: string | null;
|
||||
bugReportConfigured: boolean;
|
||||
reminderLateMinutes: number;
|
||||
reminderMissedMinutes: number;
|
||||
}
|
||||
|
||||
export interface AdminAuditEntry {
|
||||
id: string;
|
||||
action: string;
|
||||
ipAddress: string | null;
|
||||
location: string | null;
|
||||
details: string | null;
|
||||
createdAt: string;
|
||||
user: { id: string; username: string } | null;
|
||||
}
|
||||
|
||||
export interface ApiTokenInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
permissions: string[];
|
||||
lastUsedAt: string | null;
|
||||
expiresAt: string | null;
|
||||
createdAt: string;
|
||||
revoked: boolean;
|
||||
user: { id: string; username: string };
|
||||
}
|
||||
|
||||
export type FeedbackStatusType =
|
||||
| "OPEN"
|
||||
| "ACKNOWLEDGED"
|
||||
| "RESOLVED"
|
||||
| "ARCHIVED";
|
||||
export type FeedbackCategoryType =
|
||||
| "BUG"
|
||||
| "FEATURE_REQUEST"
|
||||
| "QUESTION"
|
||||
| "OTHER";
|
||||
|
||||
export interface FeedbackItem {
|
||||
id: string;
|
||||
userId: string | null;
|
||||
email: string | null;
|
||||
category: FeedbackCategoryType;
|
||||
subject: string;
|
||||
description: string;
|
||||
status: FeedbackStatusType;
|
||||
adminNote: string | null;
|
||||
gitHubIssueUrl: string | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
screenshotBase64: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
user: { username: string } | null;
|
||||
}
|
||||
|
||||
export interface FeedbackListResponse {
|
||||
items: FeedbackItem[];
|
||||
meta: {
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
countsByStatus: Partial<Record<FeedbackStatusType, number>>;
|
||||
};
|
||||
}
|
||||
|
||||
export const FEEDBACK_STATUS_TABS: FeedbackStatusType[] = [
|
||||
"OPEN",
|
||||
"ACKNOWLEDGED",
|
||||
"RESOLVED",
|
||||
"ARCHIVED",
|
||||
];
|
||||
|
||||
export function PasswordInput(props: React.ComponentProps<typeof Input>) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input {...props} type={visible ? "text" : "password"} />
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
onClick={() => setVisible((v) => !v)}
|
||||
className="text-muted-foreground hover:text-foreground absolute top-1/2 right-3 -translate-y-1/2"
|
||||
>
|
||||
{visible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusItem({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
className,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
value: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<div className="text-muted-foreground flex items-center gap-1.5 text-xs">
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{label}
|
||||
</div>
|
||||
<p className={`mt-1 text-sm font-semibold ${className ?? ""}`}>{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsToggle({
|
||||
label,
|
||||
description,
|
||||
icon: Icon,
|
||||
checked,
|
||||
onCheckedChange,
|
||||
disabled,
|
||||
}: {
|
||||
label: string;
|
||||
description: string;
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
checked: boolean;
|
||||
onCheckedChange: (checked: boolean) => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{Icon && <Icon className="text-muted-foreground h-4 w-4" />}
|
||||
<div>
|
||||
<p className="text-sm font-medium">{label}</p>
|
||||
<p className="text-muted-foreground text-xs">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={checked}
|
||||
onCheckedChange={onCheckedChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAdminSettings() {
|
||||
return useQuery({
|
||||
queryKey: ["admin", "settings"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/admin/settings");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
return (await res.json()).data as AdminSettings;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslations();
|
||||
return useMutation({
|
||||
mutationFn: async (data: Record<string, unknown>) => {
|
||||
const res = await fetch("/api/admin/settings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(await getApiErrorMessage(res));
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "settings"] });
|
||||
toast.success(t("common.saved"));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t("admin.settingsSaveError"),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getApiErrorMessage(response: Response): Promise<string> {
|
||||
const fallback = `HTTP ${response.status}`;
|
||||
try {
|
||||
const json = (await response.json()) as { error?: string };
|
||||
if (typeof json?.error === "string" && json.error.trim().length > 0) {
|
||||
return json.error;
|
||||
}
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function useSystemStatus() {
|
||||
return useQuery({
|
||||
queryKey: ["admin", "status"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/admin/status");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
return (await res.json()).data as SystemStatus;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronDown, Key, Loader2 } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatDate, formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { type ApiTokenInfo } from "./_shared";
|
||||
|
||||
export function ApiTokenOverviewSection({ id }: { id: string }) {
|
||||
const { t } = useTranslations();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const { data: tokens, isLoading } = useQuery({
|
||||
queryKey: ["admin", "tokens"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/admin/tokens");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
return (await res.json()).data as ApiTokenInfo[];
|
||||
},
|
||||
enabled: expanded,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className="bg-card border-border scroll-mt-28 rounded-xl border p-6"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Key className="text-primary h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">{t("admin.apiTokens")}</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setExpanded((prev) => !prev)}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
{expanded ? t("settings.collapse") : t("settings.expand")}
|
||||
<ChevronDown
|
||||
className={`ml-1 h-4 w-4 transition-transform ${expanded ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="mt-4">
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-4">
|
||||
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin" />
|
||||
</div>
|
||||
) : !tokens?.length ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("admin.noTokens")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground border-b text-xs">
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.tokenUser")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.tokenName")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.tokenPermissions")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
{t("admin.tokenStatus")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
{t("admin.tokenLastUsed")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
{t("admin.tokenCreated")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-border divide-y">
|
||||
{tokens.map((token, i) => {
|
||||
const isExpired =
|
||||
token.expiresAt && new Date(token.expiresAt) < new Date();
|
||||
return (
|
||||
<tr
|
||||
key={token.id}
|
||||
className={i % 2 === 0 ? "bg-muted/30" : ""}
|
||||
>
|
||||
<td className="px-3 py-2 font-medium">
|
||||
{token.user.username}
|
||||
</td>
|
||||
<td className="px-3 py-2">{token.name}</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{token.permissions.map((p) => (
|
||||
<Badge
|
||||
key={p}
|
||||
variant="secondary"
|
||||
className="text-xs"
|
||||
>
|
||||
{p}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
{token.revoked ? (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
{t("settings.tokenRevoked")}
|
||||
</Badge>
|
||||
) : isExpired ? (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
{t("settings.tokenExpired")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="bg-dracula-green/15 text-dracula-green text-xs">
|
||||
{t("common.active")}
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-right text-xs whitespace-nowrap">
|
||||
{token.lastUsedAt
|
||||
? formatDateTime(token.lastUsedAt)
|
||||
: t("admin.tokenNeverUsed")}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-right text-xs whitespace-nowrap">
|
||||
{formatDate(token.createdAt)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Bug, Loader2 } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { PasswordInput, useAdminSettings, useUpdateSettings } from "./_shared";
|
||||
|
||||
export function BugReportSection({ id }: { id: string }) {
|
||||
const { t } = useTranslations();
|
||||
const { data: settings } = useAdminSettings();
|
||||
const updateSettings = useUpdateSettings();
|
||||
const [bugReportRepoDraft, setBugReportRepoDraft] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [bugReportTokenDraft, setBugReportTokenDraft] = useState("");
|
||||
|
||||
const bugReportRepoValue =
|
||||
bugReportRepoDraft ?? settings?.bugReportRepo ?? "";
|
||||
const configured = settings?.bugReportConfigured ?? false;
|
||||
|
||||
function saveBugReportSettings() {
|
||||
const payload: Record<string, unknown> = {
|
||||
bugReportRepo: bugReportRepoValue,
|
||||
};
|
||||
if (bugReportTokenDraft.trim().length > 0) {
|
||||
payload.bugReportToken = bugReportTokenDraft.trim();
|
||||
}
|
||||
|
||||
updateSettings.mutate(payload, {
|
||||
onSuccess: () => {
|
||||
setBugReportRepoDraft(null);
|
||||
setBugReportTokenDraft("");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className="bg-card border-border scroll-mt-28 rounded-xl border p-6"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bug className="text-primary h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("admin.bugReportGithub")}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{configured && (
|
||||
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
|
||||
{t("admin.configured")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("admin.bugReportGithubDescription")}
|
||||
</p>
|
||||
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-bugreport-repo" className="text-xs">
|
||||
{t("admin.bugReportRepo")}
|
||||
</Label>
|
||||
<Input
|
||||
id="admin-bugreport-repo"
|
||||
value={bugReportRepoValue}
|
||||
onChange={(event) => setBugReportRepoDraft(event.target.value)}
|
||||
placeholder={t("admin.bugReportRepoPlaceholder")}
|
||||
autoComplete="off"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
disabled={updateSettings.isPending}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-bugreport-token" className="text-xs">
|
||||
{t("admin.bugReportToken")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="admin-bugreport-token"
|
||||
value={bugReportTokenDraft}
|
||||
onChange={(event) => setBugReportTokenDraft(event.target.value)}
|
||||
placeholder={t("admin.bugReportTokenPlaceholder")}
|
||||
disabled={updateSettings.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={saveBugReportSettings}
|
||||
disabled={updateSettings.isPending}
|
||||
>
|
||||
{updateSettings.isPending && (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
)}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertTriangle, Loader2, Trash2 } from "lucide-react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
export function DangerZoneSection({ id }: { id: string }) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [wipeMsg, setWipeMsg] = useState<string | null>(null);
|
||||
|
||||
const wipeAllData = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch("/api/admin/data", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ confirm: "DELETE ALL" }),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json.error || t("common.error"));
|
||||
return json.data as {
|
||||
measurements: number;
|
||||
intakeEvents: number;
|
||||
medications: number;
|
||||
};
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries();
|
||||
setWipeMsg(
|
||||
t("admin.deletedResult", {
|
||||
measurements: data.measurements,
|
||||
medications: data.medications,
|
||||
intakeEvents: data.intakeEvents,
|
||||
}),
|
||||
);
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setWipeMsg(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className="bg-destructive/5 border-destructive/30 scroll-mt-28 rounded-xl border p-6"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="text-destructive h-5 w-5" />
|
||||
<h2 className="text-destructive text-lg font-semibold">
|
||||
{t("admin.dangerZone")}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<p className="text-sm font-medium">{t("admin.deleteAllData")}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.deleteAllDescription")}
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={wipeAllData.isPending}
|
||||
>
|
||||
{wipeAllData.isPending ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("admin.deleteButton")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("admin.deleteAllConfirm")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("admin.deleteAllConfirmDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => wipeAllData.mutate()}
|
||||
>
|
||||
{t("admin.finalDelete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
{wipeMsg && (
|
||||
<p
|
||||
className={`mt-2 text-sm ${wipeMsg.startsWith(t("admin.deletedResult", { measurements: "", medications: "", intakeEvents: "" }).split(":")[0]) ? "text-dracula-green" : "text-destructive"}`}
|
||||
>
|
||||
{wipeMsg}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
CheckCircle2,
|
||||
ExternalLink,
|
||||
GitPullRequest,
|
||||
Inbox,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
FEEDBACK_STATUS_TABS,
|
||||
type FeedbackCategoryType,
|
||||
type FeedbackItem,
|
||||
type FeedbackListResponse,
|
||||
type FeedbackStatusType,
|
||||
getApiErrorMessage,
|
||||
useSystemStatus,
|
||||
} from "./_shared";
|
||||
|
||||
export function FeedbackInboxSection({ id }: { id: string }) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: status } = useSystemStatus();
|
||||
const githubConfigured = Boolean(status?.integrations.bugReport?.configured);
|
||||
|
||||
const [activeStatus, setActiveStatus] = useState<FeedbackStatusType>("OPEN");
|
||||
const [selected, setSelected] = useState<FeedbackItem | null>(null);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["admin", "feedback", activeStatus],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(
|
||||
`/api/admin/feedback?status=${activeStatus}&limit=100`,
|
||||
);
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
return (await res.json()).data as FeedbackListResponse;
|
||||
},
|
||||
});
|
||||
|
||||
const counts = data?.meta.countsByStatus ?? {};
|
||||
|
||||
function refresh() {
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "feedback"] });
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className="bg-card border-border scroll-mt-28 rounded-xl border p-6"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Inbox className="text-primary h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">{t("admin.feedback.title")}</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("admin.feedback.description")}
|
||||
</p>
|
||||
|
||||
<Tabs
|
||||
value={activeStatus}
|
||||
onValueChange={(v) => setActiveStatus(v as FeedbackStatusType)}
|
||||
className="mt-4"
|
||||
>
|
||||
<TabsList>
|
||||
{FEEDBACK_STATUS_TABS.map((s) => (
|
||||
<TabsTrigger key={s} value={s}>
|
||||
<span>
|
||||
{t(
|
||||
`admin.feedback.tab${s.charAt(0) + s.slice(1).toLowerCase()}`,
|
||||
)}
|
||||
</span>
|
||||
<Badge variant="secondary" className="ml-1.5 text-xs">
|
||||
{counts[s] ?? 0}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{FEEDBACK_STATUS_TABS.map((s) => (
|
||||
<TabsContent key={s} value={s} className="mt-4">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t("admin.feedback.loading")}
|
||||
</span>
|
||||
</div>
|
||||
) : !data?.items.length ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("admin.feedback.noEntries")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground border-b text-xs">
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.feedback.createdAt")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.feedback.category")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.feedback.subject")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.feedback.user")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
{t("admin.feedback.actions")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-border divide-y">
|
||||
{data.items.map((item, i) => (
|
||||
<tr
|
||||
key={item.id}
|
||||
className={`hover:bg-muted/40 cursor-pointer ${i % 2 === 0 ? "bg-muted/30" : ""}`}
|
||||
onClick={() => setSelected(item)}
|
||||
>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
|
||||
{formatDateTime(item.createdAt)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<FeedbackCategoryBadge category={item.category} />
|
||||
</td>
|
||||
<td className="max-w-[28ch] truncate px-3 py-2 font-medium">
|
||||
{item.subject}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs">
|
||||
{item.user?.username ?? t("admin.feedback.anonymous")}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelected(item);
|
||||
}}
|
||||
>
|
||||
{t("admin.feedback.details")}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
{selected && (
|
||||
<FeedbackDetailDialog
|
||||
item={selected}
|
||||
open={!!selected}
|
||||
onOpenChange={(open) => !open && setSelected(null)}
|
||||
githubConfigured={githubConfigured}
|
||||
onMutated={refresh}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FeedbackCategoryBadge({
|
||||
category,
|
||||
}: {
|
||||
category: FeedbackCategoryType;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const map: Record<
|
||||
FeedbackCategoryType,
|
||||
{ label: string; className: string }
|
||||
> = {
|
||||
BUG: {
|
||||
label: t("admin.feedback.categoryBug"),
|
||||
className: "bg-red-500/15 text-red-400 border-red-500/30",
|
||||
},
|
||||
FEATURE_REQUEST: {
|
||||
label: t("admin.feedback.categoryFeature"),
|
||||
className: "bg-purple-500/15 text-purple-400 border-purple-500/30",
|
||||
},
|
||||
QUESTION: {
|
||||
label: t("admin.feedback.categoryQuestion"),
|
||||
className: "bg-blue-500/15 text-blue-400 border-blue-500/30",
|
||||
},
|
||||
OTHER: {
|
||||
label: t("admin.feedback.categoryOther"),
|
||||
className: "bg-muted text-muted-foreground border-border",
|
||||
},
|
||||
};
|
||||
const cfg = map[category];
|
||||
return (
|
||||
<Badge className={`border text-xs ${cfg.className}`}>{cfg.label}</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function FeedbackDetailDialog({
|
||||
item,
|
||||
open,
|
||||
onOpenChange,
|
||||
githubConfigured,
|
||||
onMutated,
|
||||
}: {
|
||||
item: FeedbackItem;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
githubConfigured: boolean;
|
||||
onMutated: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const [note, setNote] = useState(item.adminNote ?? "");
|
||||
const [issueUrl, setIssueUrl] = useState(item.gitHubIssueUrl);
|
||||
|
||||
const update = useMutation({
|
||||
mutationFn: async (payload: {
|
||||
status?: FeedbackStatusType;
|
||||
adminNote?: string | null;
|
||||
}) => {
|
||||
const res = await fetch(`/api/admin/feedback/${item.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) throw new Error(await getApiErrorMessage(res));
|
||||
},
|
||||
onSuccess: (_, vars) => {
|
||||
onMutated();
|
||||
if (vars.adminNote !== undefined) {
|
||||
toast.success(t("admin.feedback.noteSaved"));
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : t("admin.feedback.updateFailed"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const archive = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch(`/api/admin/feedback/${item.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) throw new Error(await getApiErrorMessage(res));
|
||||
},
|
||||
onSuccess: () => {
|
||||
onMutated();
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : t("admin.feedback.updateFailed"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const publish = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch(`/api/admin/feedback/${item.id}/github`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (!res.ok) throw new Error(await getApiErrorMessage(res));
|
||||
return (await res.json()).data as { issueUrl: string };
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setIssueUrl(data.issueUrl);
|
||||
toast.success(t("admin.feedback.publishSuccess"));
|
||||
onMutated();
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : t("admin.feedback.publishFailed"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const meta = item.metadata ?? {};
|
||||
const url = typeof meta.url === "string" ? meta.url : null;
|
||||
const locale = typeof meta.locale === "string" ? meta.locale : null;
|
||||
const userAgent = typeof meta.userAgent === "string" ? meta.userAgent : null;
|
||||
const appVersion =
|
||||
typeof meta.appVersion === "string" ? meta.appVersion : null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5" />
|
||||
{item.subject}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<span className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<FeedbackCategoryBadge category={item.category} />
|
||||
<span>
|
||||
{t("admin.feedback.submittedBy")}:{" "}
|
||||
{item.user?.username ?? t("admin.feedback.anonymous")}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span>{formatDateTime(item.createdAt)}</span>
|
||||
{issueUrl && (
|
||||
<a
|
||||
href={issueUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary inline-flex items-center gap-1"
|
||||
>
|
||||
<GitPullRequest className="h-3 w-3" />
|
||||
{t("admin.feedback.viewIssue")}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 text-sm">
|
||||
<div className="bg-muted/40 rounded-md p-3 whitespace-pre-wrap">
|
||||
{item.description}
|
||||
</div>
|
||||
|
||||
{(url || locale || userAgent || appVersion) && (
|
||||
<div>
|
||||
<h4 className="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
|
||||
{t("admin.feedback.metadataHeading")}
|
||||
</h4>
|
||||
<dl className="mt-2 grid grid-cols-[max-content_1fr] gap-x-3 gap-y-1 text-xs">
|
||||
{url && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("admin.feedback.metaUrl")}
|
||||
</dt>
|
||||
<dd className="font-mono break-all">{url}</dd>
|
||||
</>
|
||||
)}
|
||||
{locale && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("admin.feedback.metaLocale")}
|
||||
</dt>
|
||||
<dd className="font-mono">{locale}</dd>
|
||||
</>
|
||||
)}
|
||||
{appVersion && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("admin.feedback.metaAppVersion")}
|
||||
</dt>
|
||||
<dd className="font-mono">{appVersion}</dd>
|
||||
</>
|
||||
)}
|
||||
{userAgent && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("admin.feedback.metaUserAgent")}
|
||||
</dt>
|
||||
<dd className="font-mono break-all">{userAgent}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.screenshotBase64 && (
|
||||
<div>
|
||||
<h4 className="text-muted-foreground mb-2 text-xs font-semibold tracking-wider uppercase">
|
||||
{t("admin.feedback.screenshotHeading")}
|
||||
</h4>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={item.screenshotBase64}
|
||||
alt="Screenshot"
|
||||
className="border-border max-h-72 rounded-md border"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-feedback-note" className="text-xs">
|
||||
{t("admin.feedback.adminNote")}
|
||||
</Label>
|
||||
<textarea
|
||||
id="admin-feedback-note"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
rows={3}
|
||||
maxLength={5000}
|
||||
placeholder={t("admin.feedback.adminNotePlaceholder")}
|
||||
className="border-input bg-background text-foreground placeholder:text-muted-foreground focus-visible:ring-ring w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={update.isPending || note === (item.adminNote ?? "")}
|
||||
onClick={() => update.mutate({ adminNote: note || null })}
|
||||
>
|
||||
{update.isPending && (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
)}
|
||||
{t("admin.feedback.saveNote")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-border flex flex-wrap items-center gap-2 border-t pt-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={update.isPending || item.status === "ACKNOWLEDGED"}
|
||||
onClick={() => update.mutate({ status: "ACKNOWLEDGED" })}
|
||||
>
|
||||
<CheckCircle2 className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t("admin.feedback.actionAcknowledge")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={update.isPending || item.status === "RESOLVED"}
|
||||
onClick={() => update.mutate({ status: "RESOLVED" })}
|
||||
>
|
||||
<CheckCircle2 className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t("admin.feedback.actionResolve")}
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={archive.isPending || item.status === "ARCHIVED"}
|
||||
>
|
||||
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t("admin.feedback.actionArchive")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("admin.feedback.archiveConfirm")}
|
||||
</AlertDialogTitle>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => archive.mutate()}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{t("admin.feedback.actionArchive")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
{githubConfigured && !issueUrl && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={publish.isPending}
|
||||
onClick={() => publish.mutate()}
|
||||
className="ml-auto"
|
||||
>
|
||||
{publish.isPending ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<GitPullRequest className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("admin.feedback.actionPublishGithub")}
|
||||
</Button>
|
||||
)}
|
||||
{issueUrl && (
|
||||
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green ml-auto">
|
||||
{t("admin.feedback.publishedToGithub")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { Settings } from "lucide-react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { SettingsToggle, useAdminSettings, useUpdateSettings } from "./_shared";
|
||||
|
||||
export function GeneralSettingsSection({ id }: { id: string }) {
|
||||
const { t } = useTranslations();
|
||||
const { data: settings } = useAdminSettings();
|
||||
const updateSettings = useUpdateSettings();
|
||||
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className="bg-card border-border scroll-mt-28 rounded-xl border p-6"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="text-primary h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">{t("admin.appSettings")}</h2>
|
||||
</div>
|
||||
<div className="mt-4 space-y-4">
|
||||
<SettingsToggle
|
||||
label={t("admin.registrationEnabled")}
|
||||
description={t("admin.registrationEnabledDescription")}
|
||||
checked={settings?.registrationEnabled ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
updateSettings.mutate({ registrationEnabled: checked })
|
||||
}
|
||||
disabled={updateSettings.isPending}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{t("admin.defaultLanguage")}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.defaultLanguageDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<select
|
||||
value={settings?.defaultLocale ?? "de"}
|
||||
onChange={(e) =>
|
||||
updateSettings.mutate({ defaultLocale: e.target.value })
|
||||
}
|
||||
disabled={updateSettings.isPending}
|
||||
className="border-input bg-background text-foreground ring-offset-background focus-visible:ring-ring flex h-9 rounded-md border px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { AlertTriangle, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
SettingsToggle,
|
||||
getApiErrorMessage,
|
||||
useAdminSettings,
|
||||
useUpdateSettings,
|
||||
} from "./_shared";
|
||||
|
||||
export function GlitchtipSection({ id }: { id: string }) {
|
||||
const { t } = useTranslations();
|
||||
const { data: settings } = useAdminSettings();
|
||||
const updateSettings = useUpdateSettings();
|
||||
const [glitchtipDsnDraft, setGlitchtipDsnDraft] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [glitchtipEnvironmentDraft, setGlitchtipEnvironmentDraft] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
const glitchtipDsnValue = glitchtipDsnDraft ?? settings?.glitchtipDsn ?? "";
|
||||
const glitchtipEnvironmentValue =
|
||||
glitchtipEnvironmentDraft ?? settings?.glitchtipEnvironment ?? "production";
|
||||
|
||||
const configured = Boolean(settings?.glitchtipDsn);
|
||||
|
||||
const testGlitchtip = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch("/api/admin/monitoring/glitchtip-test", {
|
||||
method: "POST",
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(await getApiErrorMessage(res));
|
||||
}
|
||||
const json = (await res.json()) as { data?: { message?: string } };
|
||||
return json.data?.message ?? t("admin.monitoringTestSuccess");
|
||||
},
|
||||
onSuccess: (message) => {
|
||||
toast.success(message);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("admin.monitoringTestFailed"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
function saveGlitchtipSettings() {
|
||||
updateSettings.mutate(
|
||||
{
|
||||
glitchtipDsn: glitchtipDsnValue,
|
||||
glitchtipEnvironment: glitchtipEnvironmentValue,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setGlitchtipDsnDraft(null);
|
||||
setGlitchtipEnvironmentDraft(null);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className="bg-card border-border scroll-mt-28 rounded-xl border p-6"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="text-primary h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">{t("admin.glitchtipTitle")}</h2>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{configured && (
|
||||
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
|
||||
{t("admin.configured")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("admin.glitchtipDescription")}
|
||||
</p>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<SettingsToggle
|
||||
label="GlitchTip"
|
||||
description={t("admin.glitchtipEnabled")}
|
||||
icon={AlertTriangle}
|
||||
checked={settings?.glitchtipEnabled ?? false}
|
||||
onCheckedChange={(checked) =>
|
||||
updateSettings.mutate({ glitchtipEnabled: checked })
|
||||
}
|
||||
disabled={updateSettings.isPending}
|
||||
/>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<Label htmlFor="admin-glitchtip-dsn" className="text-xs">
|
||||
{t("admin.glitchtipDsn")}
|
||||
</Label>
|
||||
<Input
|
||||
id="admin-glitchtip-dsn"
|
||||
name="admin-glitchtip-dsn"
|
||||
value={glitchtipDsnValue}
|
||||
onChange={(event) => setGlitchtipDsnDraft(event.target.value)}
|
||||
placeholder={t("admin.glitchtipDsnPlaceholder")}
|
||||
autoComplete="new-password"
|
||||
spellCheck={false}
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
disabled={updateSettings.isPending}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-glitchtip-environment" className="text-xs">
|
||||
{t("admin.glitchtipEnvironment")}
|
||||
</Label>
|
||||
<Input
|
||||
id="admin-glitchtip-environment"
|
||||
name="admin-glitchtip-environment"
|
||||
value={glitchtipEnvironmentValue}
|
||||
onChange={(event) =>
|
||||
setGlitchtipEnvironmentDraft(event.target.value)
|
||||
}
|
||||
placeholder={t("admin.glitchtipEnvironmentPlaceholder")}
|
||||
autoComplete="new-password"
|
||||
spellCheck={false}
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
disabled={updateSettings.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => testGlitchtip.mutate()}
|
||||
disabled={testGlitchtip.isPending || updateSettings.isPending}
|
||||
>
|
||||
{testGlitchtip.isPending && (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
)}
|
||||
{t("common.test")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={saveGlitchtipSettings}
|
||||
disabled={updateSettings.isPending}
|
||||
>
|
||||
{updateSettings.isPending && (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
)}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
Loader2,
|
||||
ScrollText,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { type AdminAuditEntry } from "./_shared";
|
||||
|
||||
export function LoginOverviewSection({ id }: { id: string }) {
|
||||
const { t } = useTranslations();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [filter, setFilter] = useState<"all" | "failed">("all");
|
||||
|
||||
const AUTH_ACTION_LABELS: Record<string, string> = {
|
||||
"auth.register": t("admin.authRegister"),
|
||||
"auth.login": t("admin.authLogin"),
|
||||
"auth.login.passkey": t("admin.authLoginPasskey"),
|
||||
"auth.login.password": t("admin.authLoginPassword"),
|
||||
"auth.login.failed": t("admin.authLoginFailed"),
|
||||
"auth.logout": t("admin.authLogout"),
|
||||
"auth.passkey.register": t("admin.authPasskeyRegister"),
|
||||
"auth.passkey.delete": t("admin.authPasskeyDelete"),
|
||||
};
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["admin", "audit-log", filter],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/admin/audit-log?limit=100&filter=auth`);
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
return (await res.json()).data as {
|
||||
entries: AdminAuditEntry[];
|
||||
meta: { total: number };
|
||||
};
|
||||
},
|
||||
enabled: expanded,
|
||||
});
|
||||
|
||||
const entries =
|
||||
filter === "failed"
|
||||
? data?.entries.filter((e) => e.action === "auth.login.failed")
|
||||
: data?.entries;
|
||||
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className="bg-card border-border scroll-mt-28 rounded-xl border p-6"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<ScrollText className="text-primary h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">{t("admin.loginOverview")}</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setExpanded((prev) => !prev)}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
{expanded ? t("settings.collapse") : t("settings.expand")}
|
||||
<ChevronDown
|
||||
className={`ml-1 h-4 w-4 transition-transform ${expanded ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant={filter === "all" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => setFilter("all")}
|
||||
>
|
||||
{t("admin.allAuthEvents")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={filter === "failed" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => setFilter("failed")}
|
||||
>
|
||||
{t("admin.failedOnly")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-4">
|
||||
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin" />
|
||||
</div>
|
||||
) : !entries?.length ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("admin.noEntries")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground border-b text-xs">
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.status")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.users")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.action")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
{t("admin.ip")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
{t("admin.location")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
{t("admin.timestamp")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-border divide-y">
|
||||
{entries.map((entry, i) => {
|
||||
const isFailed = entry.action === "auth.login.failed";
|
||||
return (
|
||||
<tr
|
||||
key={entry.id}
|
||||
className={i % 2 === 0 ? "bg-muted/30" : ""}
|
||||
>
|
||||
<td className="px-3 py-2">
|
||||
{isFailed ? (
|
||||
<XCircle className="text-destructive h-4 w-4" />
|
||||
) : (
|
||||
<CheckCircle2 className="text-dracula-green h-4 w-4" />
|
||||
)}
|
||||
</td>
|
||||
<td
|
||||
className={`px-3 py-2 font-medium ${isFailed ? "text-destructive" : ""}`}
|
||||
>
|
||||
{entry.user?.username ?? t("common.unknown")}
|
||||
</td>
|
||||
<td
|
||||
className={`px-3 py-2 text-xs ${isFailed ? "text-destructive" : "text-muted-foreground"}`}
|
||||
>
|
||||
{AUTH_ACTION_LABELS[entry.action] ?? entry.action}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-right font-mono text-xs">
|
||||
{entry.ipAddress ?? "—"}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-right text-xs">
|
||||
{entry.location ?? "—"}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-right text-xs whitespace-nowrap">
|
||||
{formatDateTime(entry.createdAt)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{data && data.meta.total > entries.length && (
|
||||
<p className="text-muted-foreground mt-3 text-center text-xs">
|
||||
{t("admin.showingEntries", {
|
||||
count: entries.length,
|
||||
total: data.meta.total,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
Activity,
|
||||
Bell,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Loader2,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
getApiErrorMessage,
|
||||
useAdminSettings,
|
||||
useUpdateSettings,
|
||||
} from "./_shared";
|
||||
|
||||
export function RemindersSection({ id }: { id: string }) {
|
||||
const { t } = useTranslations();
|
||||
const { data: settings } = useAdminSettings();
|
||||
const updateSettings = useUpdateSettings();
|
||||
const [reminderLateDraft, setReminderLateDraft] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const [reminderMissedDraft, setReminderMissedDraft] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const testNotification = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch("/api/admin/notifications/test", {
|
||||
method: "POST",
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(await getApiErrorMessage(res));
|
||||
}
|
||||
const json = (await res.json()) as {
|
||||
data?: {
|
||||
message?: string;
|
||||
results?: Array<{
|
||||
channel: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
return json.data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
const hasFailures = data?.results?.some((r) => !r.success);
|
||||
if (hasFailures) {
|
||||
toast.error(data?.message ?? t("admin.notificationTestFailed"));
|
||||
} else {
|
||||
toast.success(data?.message ?? t("admin.notificationTestSuccess"));
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("admin.notificationTestFailed"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const reminderCheck = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch("/api/admin/notifications/reminder-check", {
|
||||
method: "POST",
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(await getApiErrorMessage(res));
|
||||
}
|
||||
const json = (await res.json()) as {
|
||||
data?: {
|
||||
message?: string;
|
||||
medications?: Array<{
|
||||
name: string;
|
||||
dose: string;
|
||||
user: string;
|
||||
localTime: string;
|
||||
dayOfWeek: string;
|
||||
notificationsEnabled: boolean;
|
||||
schedules: Array<{
|
||||
window: string;
|
||||
days: string;
|
||||
status: string;
|
||||
label: string;
|
||||
notificationSent?: boolean;
|
||||
}>;
|
||||
eventsToday: number;
|
||||
}>;
|
||||
notificationsSent?: number;
|
||||
};
|
||||
};
|
||||
return json.data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
toast.success(data?.message ?? t("admin.reminderCheckSuccess"));
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("admin.reminderCheckFailed"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className="bg-card border-border scroll-mt-28 rounded-xl border p-6"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="text-primary h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("admin.medicationReminders")}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("admin.medicationRemindersDescription")}
|
||||
</p>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-reminder-late" className="text-xs">
|
||||
{t("admin.reminderLateMinutes")}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.reminderLateMinutesDescription")}
|
||||
</p>
|
||||
<Input
|
||||
id="admin-reminder-late"
|
||||
type="number"
|
||||
min={15}
|
||||
max={480}
|
||||
value={reminderLateDraft ?? settings?.reminderLateMinutes ?? 120}
|
||||
onChange={(e) => setReminderLateDraft(Number(e.target.value))}
|
||||
autoComplete="off"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
disabled={updateSettings.isPending}
|
||||
className="w-32"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-reminder-missed" className="text-xs">
|
||||
{t("admin.reminderMissedMinutes")}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.reminderMissedMinutesDescription")}
|
||||
</p>
|
||||
<Input
|
||||
id="admin-reminder-missed"
|
||||
type="number"
|
||||
min={30}
|
||||
max={720}
|
||||
value={
|
||||
reminderMissedDraft ?? settings?.reminderMissedMinutes ?? 240
|
||||
}
|
||||
onChange={(e) => setReminderMissedDraft(Number(e.target.value))}
|
||||
autoComplete="off"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
disabled={updateSettings.isPending}
|
||||
className="w-32"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => testNotification.mutate()}
|
||||
disabled={testNotification.isPending}
|
||||
>
|
||||
{testNotification.isPending && (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
)}
|
||||
<Bell className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t("admin.notificationTestSend")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => reminderCheck.mutate()}
|
||||
disabled={reminderCheck.isPending}
|
||||
>
|
||||
{reminderCheck.isPending && (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
)}
|
||||
<Activity className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t("admin.reminderCheckRun")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
updateSettings.mutate(
|
||||
{
|
||||
...(reminderLateDraft != null && {
|
||||
reminderLateMinutes: reminderLateDraft,
|
||||
}),
|
||||
...(reminderMissedDraft != null && {
|
||||
reminderMissedMinutes: reminderMissedDraft,
|
||||
}),
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setReminderLateDraft(null);
|
||||
setReminderMissedDraft(null);
|
||||
},
|
||||
},
|
||||
);
|
||||
}}
|
||||
disabled={
|
||||
updateSettings.isPending ||
|
||||
(reminderLateDraft == null && reminderMissedDraft == null)
|
||||
}
|
||||
>
|
||||
{updateSettings.isPending && (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
)}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{testNotification.data?.results &&
|
||||
testNotification.data.results.length > 0 && (
|
||||
<div className="mt-4 space-y-1">
|
||||
{testNotification.data.results.map((r, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5 text-xs">
|
||||
{r.success ? (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-green-400" />
|
||||
) : (
|
||||
<XCircle className="h-3.5 w-3.5 shrink-0 text-red-400" />
|
||||
)}
|
||||
<span className="font-medium">{r.channel}</span>
|
||||
{r.error && (
|
||||
<span className="text-muted-foreground">— {r.error}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{reminderCheck.data?.medications &&
|
||||
reminderCheck.data.medications.length > 0 && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<h4 className="text-sm font-medium">
|
||||
{t("admin.reminderCheckResults")}
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{reminderCheck.data.medications.map((med, i) => (
|
||||
<div key={i} className="bg-muted/50 space-y-1.5 rounded-lg p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">
|
||||
{med.name} ({med.dose})
|
||||
</span>
|
||||
<Badge
|
||||
variant={
|
||||
med.notificationsEnabled ? "default" : "secondary"
|
||||
}
|
||||
>
|
||||
{med.notificationsEnabled
|
||||
? t("admin.reminderCheckNotifOn")
|
||||
: t("admin.reminderCheckNotifOff")}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{med.user} — {med.dayOfWeek} {med.localTime} —{" "}
|
||||
{t("admin.reminderCheckEventsToday")}: {med.eventsToday}
|
||||
</p>
|
||||
{med.schedules.map((sched, j) => {
|
||||
const statusColor =
|
||||
sched.status === "open"
|
||||
? "text-green-400"
|
||||
: sched.status === "threshold"
|
||||
? "text-yellow-400"
|
||||
: sched.status === "missed"
|
||||
? "text-red-400"
|
||||
: sched.status === "skipped"
|
||||
? "text-muted-foreground"
|
||||
: "";
|
||||
return (
|
||||
<div key={j} className="flex items-start gap-1.5 text-xs">
|
||||
<span className="text-muted-foreground shrink-0">
|
||||
{sched.window}
|
||||
</span>
|
||||
<span className="text-muted-foreground shrink-0">
|
||||
[{sched.days}]
|
||||
</span>
|
||||
<span className={statusColor}>{sched.label}</span>
|
||||
{sched.notificationSent && (
|
||||
<span className="flex shrink-0 items-center gap-0.5 text-green-400">
|
||||
<Bell className="h-3 w-3" />
|
||||
{t("admin.reminderCheckNotifSent")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { Bell, Globe, Key, MessageCircle } from "lucide-react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { SettingsToggle, useAdminSettings, useUpdateSettings } from "./_shared";
|
||||
|
||||
export function ServicesSection({ id }: { id: string }) {
|
||||
const { t } = useTranslations();
|
||||
const { data: settings } = useAdminSettings();
|
||||
const updateSettings = useUpdateSettings();
|
||||
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className="bg-card border-border scroll-mt-28 rounded-xl border p-6"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="text-primary h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">{t("admin.servicesGlobal")}</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("admin.servicesGlobalDescription")}
|
||||
</p>
|
||||
<div className="mt-4 space-y-3">
|
||||
<SettingsToggle
|
||||
label="Telegram"
|
||||
description={t("admin.telegramGlobal")}
|
||||
icon={MessageCircle}
|
||||
checked={settings?.telegramGlobal ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
updateSettings.mutate({ telegramGlobal: checked })
|
||||
}
|
||||
disabled={updateSettings.isPending}
|
||||
/>
|
||||
<SettingsToggle
|
||||
label="ntfy"
|
||||
description={t("admin.ntfyGlobal")}
|
||||
icon={Bell}
|
||||
checked={settings?.ntfyGlobal ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
updateSettings.mutate({ ntfyGlobal: checked })
|
||||
}
|
||||
disabled={updateSettings.isPending}
|
||||
/>
|
||||
<SettingsToggle
|
||||
label={t("admin.integrationWebPush")}
|
||||
description={t("admin.webPushGlobal")}
|
||||
icon={Globe}
|
||||
checked={settings?.webPushGlobal ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
updateSettings.mutate({ webPushGlobal: checked })
|
||||
}
|
||||
disabled={updateSettings.isPending}
|
||||
/>
|
||||
<SettingsToggle
|
||||
label="API"
|
||||
description={t("admin.apiGlobal")}
|
||||
icon={Key}
|
||||
checked={settings?.apiGlobal ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
updateSettings.mutate({ apiGlobal: checked })
|
||||
}
|
||||
disabled={updateSettings.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Users,
|
||||
Plug,
|
||||
Activity,
|
||||
Database,
|
||||
Wrench,
|
||||
ScrollText,
|
||||
ArrowRight,
|
||||
} from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import type {
|
||||
StatusOverview,
|
||||
StatusSeverity,
|
||||
} from "@/app/api/admin/status-overview/route";
|
||||
|
||||
/**
|
||||
* §2.6 status taxonomy. Renders a colored dot AND a label so we never
|
||||
* lean on color alone (WCAG 1.4.1).
|
||||
*/
|
||||
const SEVERITY_LABEL: Record<StatusSeverity, string> = {
|
||||
good: "Healthy",
|
||||
info: "Up to date",
|
||||
caution: "Attention",
|
||||
alert: "Action required",
|
||||
pending: "Loading",
|
||||
};
|
||||
|
||||
const SEVERITY_COLOR: Record<StatusSeverity, string> = {
|
||||
good: "var(--dracula-green)",
|
||||
info: "var(--dracula-cyan)",
|
||||
caution: "var(--dracula-orange)",
|
||||
alert: "var(--dracula-red)",
|
||||
pending: "var(--muted-foreground)",
|
||||
};
|
||||
|
||||
export function StatusBadge({
|
||||
severity,
|
||||
label,
|
||||
}: {
|
||||
severity: StatusSeverity;
|
||||
label?: string;
|
||||
}) {
|
||||
const text = label ?? SEVERITY_LABEL[severity];
|
||||
return (
|
||||
<span
|
||||
className="bg-muted/40 inline-flex items-center gap-1.5 rounded-md px-2 py-0.5 text-xs font-medium"
|
||||
aria-label={`Status: ${text}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-block h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: SEVERITY_COLOR[severity] }}
|
||||
/>
|
||||
<span>{text}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface StatusCardProps {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
severity: StatusSeverity;
|
||||
metrics: Array<{ label: string; value: string | number }>;
|
||||
href: string;
|
||||
cta: string;
|
||||
}
|
||||
|
||||
function StatusCard({
|
||||
title,
|
||||
icon,
|
||||
severity,
|
||||
metrics,
|
||||
href,
|
||||
cta,
|
||||
}: StatusCardProps) {
|
||||
return (
|
||||
<Card className="flex flex-col gap-2">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0 pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base font-medium">
|
||||
<span aria-hidden="true" className="text-muted-foreground">
|
||||
{icon}
|
||||
</span>
|
||||
{title}
|
||||
</CardTitle>
|
||||
<StatusBadge severity={severity} />
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<dl className="grid grid-cols-3 gap-2">
|
||||
{metrics.map((m) => (
|
||||
<div key={m.label} className="flex flex-col gap-0.5">
|
||||
<dt className="text-muted-foreground text-xs">{m.label}</dt>
|
||||
<dd className="font-mono text-sm tabular-nums">{m.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<Link
|
||||
href={href}
|
||||
className="text-primary inline-flex items-center gap-1 self-start text-sm hover:underline"
|
||||
>
|
||||
{cta}
|
||||
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function fmtRelative(iso: string | null): string {
|
||||
if (!iso) return "never";
|
||||
const ms = Date.now() - new Date(iso).getTime();
|
||||
const min = Math.round(ms / 60_000);
|
||||
if (min < 60) return `${min}m ago`;
|
||||
const hr = Math.round(min / 60);
|
||||
if (hr < 48) return `${hr}h ago`;
|
||||
const day = Math.round(hr / 24);
|
||||
return `${day}d ago`;
|
||||
}
|
||||
|
||||
function fmtUptime(seconds: number | null): string {
|
||||
if (seconds === null) return "—";
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
|
||||
if (seconds < 86_400) return `${Math.floor(seconds / 3600)}h`;
|
||||
return `${Math.floor(seconds / 86_400)}d`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the 6 admin status cards from the aggregator response. Pure;
|
||||
* no data fetching here — easy to unit test.
|
||||
*/
|
||||
export function buildCards(data: StatusOverview): StatusCardProps[] {
|
||||
return [
|
||||
{
|
||||
title: "Users",
|
||||
icon: <Users className="h-4 w-4" />,
|
||||
severity: data.users.severity,
|
||||
metrics: [
|
||||
{ label: "Total", value: data.users.total },
|
||||
{ label: "Admins", value: data.users.admins },
|
||||
{ label: "New 7d", value: data.users.newThisWeek },
|
||||
],
|
||||
href: "/admin/users",
|
||||
cta: "Manage users",
|
||||
},
|
||||
{
|
||||
title: "Integrations",
|
||||
icon: <Plug className="h-4 w-4" />,
|
||||
severity: data.integrations.severity,
|
||||
metrics: [
|
||||
{ label: "Withings", value: data.integrations.withings },
|
||||
{ label: "moodLog", value: data.integrations.moodLog },
|
||||
{ label: "Push", value: data.integrations.webPush },
|
||||
],
|
||||
href: "/admin#integrations",
|
||||
cta: "Configure",
|
||||
},
|
||||
{
|
||||
title: "Monitoring",
|
||||
icon: <Activity className="h-4 w-4" />,
|
||||
severity: data.monitoring.severity,
|
||||
metrics: [
|
||||
{
|
||||
label: "GlitchTip",
|
||||
value: data.monitoring.glitchtipEnabled ? "On" : "Off",
|
||||
},
|
||||
{
|
||||
label: "Umami",
|
||||
value: data.monitoring.umamiEnabled ? "On" : "Off",
|
||||
},
|
||||
{ label: "Last err", value: fmtRelative(data.monitoring.lastErrorAt) },
|
||||
],
|
||||
href: "/admin#monitoring",
|
||||
cta: "View monitoring",
|
||||
},
|
||||
{
|
||||
title: "Backups",
|
||||
icon: <Database className="h-4 w-4" />,
|
||||
severity: data.backups.severity,
|
||||
metrics: [
|
||||
{ label: "Last", value: fmtRelative(data.backups.lastBackupAt) },
|
||||
{ label: "Users", value: data.backups.backedUpUsers },
|
||||
{ label: "Retain", value: `${data.backups.retentionDays}d` },
|
||||
],
|
||||
href: "/admin#backups",
|
||||
cta: "View backups",
|
||||
},
|
||||
{
|
||||
title: "Maintenance",
|
||||
icon: <Wrench className="h-4 w-4" />,
|
||||
severity: data.maintenance.severity,
|
||||
metrics: [
|
||||
{
|
||||
label: "Worker",
|
||||
value: data.maintenance.workerRunning ? "Running" : "Stopped",
|
||||
},
|
||||
{
|
||||
label: "Uptime",
|
||||
value: fmtUptime(data.maintenance.workerUptimeSeconds),
|
||||
},
|
||||
{
|
||||
label: "Cleanup",
|
||||
value: fmtRelative(data.maintenance.lastIdempotencyCleanup),
|
||||
},
|
||||
],
|
||||
href: "/admin#maintenance",
|
||||
cta: "View jobs",
|
||||
},
|
||||
{
|
||||
title: "Audit log",
|
||||
icon: <ScrollText className="h-4 w-4" />,
|
||||
severity: data.auditLog.severity,
|
||||
metrics: [
|
||||
{ label: "Events 30d", value: data.auditLog.eventsLast30d },
|
||||
{ label: "Last login", value: fmtRelative(data.auditLog.lastLoginAt) },
|
||||
{ label: "—", value: "" },
|
||||
],
|
||||
href: "/admin/audit-log",
|
||||
cta: "Open viewer",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function StatusCardGrid() {
|
||||
const { data, isLoading } = useQuery<StatusOverview>({
|
||||
queryKey: ["admin", "status-overview"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/admin/status-overview");
|
||||
if (!res.ok) throw new Error("Failed to load admin status overview");
|
||||
const json = (await res.json()) as { data: StatusOverview };
|
||||
return json.data;
|
||||
},
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-36 w-full" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const cards = buildCards(data);
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{cards.map((c) => (
|
||||
<StatusCard key={c.title} {...c} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
Bell,
|
||||
BellRing,
|
||||
Bug,
|
||||
Clock,
|
||||
Cog,
|
||||
Database,
|
||||
Globe,
|
||||
Key,
|
||||
Loader2,
|
||||
Server,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { StatusItem, useSystemStatus } from "./_shared";
|
||||
|
||||
export function SystemStatusSection({ id }: { id: string }) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const { data: status } = useSystemStatus();
|
||||
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className="bg-card border-border scroll-mt-28 rounded-xl border p-6"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="text-primary h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">{t("admin.systemStatus")}</h2>
|
||||
</div>
|
||||
{status ? (
|
||||
<div className="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatusItem
|
||||
icon={Database}
|
||||
label={t("admin.database")}
|
||||
value={
|
||||
status.database === "connected"
|
||||
? t("admin.databaseConnected")
|
||||
: t("admin.databaseError")
|
||||
}
|
||||
className={
|
||||
status.database === "connected"
|
||||
? "text-dracula-green"
|
||||
: "text-destructive"
|
||||
}
|
||||
/>
|
||||
<StatusItem
|
||||
icon={Clock}
|
||||
label={t("admin.startedAt")}
|
||||
value={formatDateTime(status.startTime)}
|
||||
/>
|
||||
<StatusItem
|
||||
icon={Users}
|
||||
label={t("admin.users")}
|
||||
value={String(status.counts.users)}
|
||||
/>
|
||||
<StatusItem
|
||||
icon={Activity}
|
||||
label={t("admin.measurementsCount")}
|
||||
value={fmt.integer(status.counts.measurements)}
|
||||
/>
|
||||
<StatusItem
|
||||
icon={Key}
|
||||
label={t("admin.activeTokens")}
|
||||
value={String(status.counts.activeTokens)}
|
||||
/>
|
||||
<StatusItem
|
||||
icon={Globe}
|
||||
label={t("admin.activeSessions")}
|
||||
value={String(status.counts.activeSessions)}
|
||||
/>
|
||||
<StatusItem
|
||||
icon={Cog}
|
||||
label={t("admin.workerStatus")}
|
||||
value={
|
||||
status.worker.running
|
||||
? t("admin.workerRunning")
|
||||
: t("admin.workerStopped")
|
||||
}
|
||||
className={
|
||||
status.worker.running ? "text-dracula-green" : "text-destructive"
|
||||
}
|
||||
/>
|
||||
{status.worker.lastReminderCheck && (
|
||||
<StatusItem
|
||||
icon={Bell}
|
||||
label={t("admin.lastReminderCheck")}
|
||||
value={formatDateTime(status.worker.lastReminderCheck)}
|
||||
/>
|
||||
)}
|
||||
{status.integrations.umami && (
|
||||
<StatusItem
|
||||
icon={Activity}
|
||||
label="Umami"
|
||||
value={
|
||||
status.integrations.umami.enabled
|
||||
? t("common.active")
|
||||
: t("common.disabled")
|
||||
}
|
||||
className={
|
||||
status.integrations.umami.enabled
|
||||
? "text-dracula-green"
|
||||
: "text-destructive"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{status.integrations.glitchtip && (
|
||||
<StatusItem
|
||||
icon={AlertTriangle}
|
||||
label="GlitchTip"
|
||||
value={
|
||||
status.integrations.glitchtip.enabled
|
||||
? t("common.active")
|
||||
: t("common.disabled")
|
||||
}
|
||||
className={
|
||||
status.integrations.glitchtip.enabled
|
||||
? "text-dracula-green"
|
||||
: "text-destructive"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{status.integrations.webPush && (
|
||||
<StatusItem
|
||||
icon={BellRing}
|
||||
label={t("admin.integrationWebPush")}
|
||||
value={t("admin.configured")}
|
||||
className="text-dracula-green"
|
||||
/>
|
||||
)}
|
||||
{status.integrations.bugReport && (
|
||||
<StatusItem
|
||||
icon={Bug}
|
||||
label={t("admin.integrationBugReport")}
|
||||
value={t("admin.configured")}
|
||||
className="text-dracula-green"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t("admin.loadingStatus")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Activity, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
SettingsToggle,
|
||||
getApiErrorMessage,
|
||||
useAdminSettings,
|
||||
useUpdateSettings,
|
||||
} from "./_shared";
|
||||
|
||||
export function UmamiSection({ id }: { id: string }) {
|
||||
const { t } = useTranslations();
|
||||
const { data: settings } = useAdminSettings();
|
||||
const updateSettings = useUpdateSettings();
|
||||
const [umamiScriptUrlDraft, setUmamiScriptUrlDraft] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [umamiWebsiteIdDraft, setUmamiWebsiteIdDraft] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const umamiScriptUrlValue =
|
||||
umamiScriptUrlDraft ?? settings?.umamiScriptUrl ?? "";
|
||||
const umamiWebsiteIdValue =
|
||||
umamiWebsiteIdDraft ?? settings?.umamiWebsiteId ?? "";
|
||||
|
||||
const configured = Boolean(
|
||||
settings?.umamiScriptUrl && settings?.umamiWebsiteId,
|
||||
);
|
||||
|
||||
const testUmami = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch("/api/admin/monitoring/umami-test", {
|
||||
method: "POST",
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(await getApiErrorMessage(res));
|
||||
}
|
||||
const json = (await res.json()) as { data?: { message?: string } };
|
||||
return json.data?.message ?? t("admin.monitoringTestSuccess");
|
||||
},
|
||||
onSuccess: (message) => {
|
||||
toast.success(message);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("admin.monitoringTestFailed"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
function saveUmamiSettings() {
|
||||
updateSettings.mutate(
|
||||
{
|
||||
umamiScriptUrl: umamiScriptUrlValue,
|
||||
umamiWebsiteId: umamiWebsiteIdValue,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setUmamiScriptUrlDraft(null);
|
||||
setUmamiWebsiteIdDraft(null);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className="bg-card border-border scroll-mt-28 rounded-xl border p-6"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="text-primary h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">{t("admin.umamiTitle")}</h2>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{configured && (
|
||||
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
|
||||
{t("admin.configured")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("admin.umamiDescription")}
|
||||
</p>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<SettingsToggle
|
||||
label="Umami"
|
||||
description={t("admin.umamiEnabled")}
|
||||
icon={Activity}
|
||||
checked={settings?.umamiEnabled ?? false}
|
||||
onCheckedChange={(checked) =>
|
||||
updateSettings.mutate({ umamiEnabled: checked })
|
||||
}
|
||||
disabled={updateSettings.isPending}
|
||||
/>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-umami-script-url" className="text-xs">
|
||||
{t("admin.umamiScriptUrl")}
|
||||
</Label>
|
||||
<Input
|
||||
id="admin-umami-script-url"
|
||||
name="admin-umami-script-url"
|
||||
value={umamiScriptUrlValue}
|
||||
onChange={(event) => setUmamiScriptUrlDraft(event.target.value)}
|
||||
placeholder={t("admin.umamiScriptUrlPlaceholder")}
|
||||
autoComplete="new-password"
|
||||
spellCheck={false}
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
disabled={updateSettings.isPending}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-umami-website-id" className="text-xs">
|
||||
{t("admin.umamiWebsiteId")}
|
||||
</Label>
|
||||
<Input
|
||||
id="admin-umami-website-id"
|
||||
name="admin-umami-website-id"
|
||||
value={umamiWebsiteIdValue}
|
||||
onChange={(event) => setUmamiWebsiteIdDraft(event.target.value)}
|
||||
placeholder={t("admin.umamiWebsiteIdPlaceholder")}
|
||||
autoComplete="new-password"
|
||||
spellCheck={false}
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
disabled={updateSettings.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => testUmami.mutate()}
|
||||
disabled={testUmami.isPending || updateSettings.isPending}
|
||||
>
|
||||
{testUmami.isPending && (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
)}
|
||||
{t("common.test")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={saveUmamiSettings}
|
||||
disabled={updateSettings.isPending}
|
||||
>
|
||||
{updateSettings.isPending && (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
)}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { KeyRound, Loader2, Pencil, Shield, Users } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { PasswordStrength } from "@/components/ui/password-strength";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { type AdminUser, PasswordInput } from "./_shared";
|
||||
|
||||
export function UserManagementSection({
|
||||
id,
|
||||
currentUserId,
|
||||
}: {
|
||||
id: string;
|
||||
currentUserId: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [editingUser, setEditingUser] = useState<AdminUser | null>(null);
|
||||
const [editUsername, setEditUsername] = useState("");
|
||||
const [editEmail, setEditEmail] = useState("");
|
||||
const [resetUser, setResetUser] = useState<AdminUser | null>(null);
|
||||
const [resetPassword, setResetPassword] = useState("");
|
||||
const [resetMsg, setResetMsg] = useState<string | null>(null);
|
||||
|
||||
const { data: users } = useQuery({
|
||||
queryKey: ["admin", "users"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/admin/users");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
return (await res.json()).data as AdminUser[];
|
||||
},
|
||||
});
|
||||
|
||||
const updateUser = useMutation({
|
||||
mutationFn: async ({
|
||||
id,
|
||||
data,
|
||||
}: {
|
||||
id: string;
|
||||
data: Record<string, unknown>;
|
||||
}) => {
|
||||
const res = await fetch(`/api/admin/users/${id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json.error || t("common.error"));
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "users"] });
|
||||
setEditingUser(null);
|
||||
toast.success(t("common.saved"));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t("admin.settingsSaveError"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const resetPw = useMutation({
|
||||
mutationFn: async ({ id, password }: { id: string; password: string }) => {
|
||||
const res = await fetch(`/api/admin/users/${id}/reset-password`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json.error || t("common.error"));
|
||||
},
|
||||
onSuccess: () => {
|
||||
setResetMsg(t("admin.passwordReset"));
|
||||
setResetPassword("");
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setResetMsg(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
function startEdit(u: AdminUser) {
|
||||
setEditingUser(u);
|
||||
setEditUsername(u.username);
|
||||
setEditEmail(u.email ?? "");
|
||||
}
|
||||
|
||||
function startReset(u: AdminUser) {
|
||||
setResetUser(u);
|
||||
setResetPassword("");
|
||||
setResetMsg(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className="bg-card border-border scroll-mt-28 rounded-xl border p-6"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="text-primary h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">{t("admin.userManagement")}</h2>
|
||||
{users && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{users.length}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{users ? (
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground border-b text-xs">
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.users")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.userEmail")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-center font-medium">
|
||||
{t("admin.userRole")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-center font-medium">
|
||||
{t("admin.userPasskeys")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
{t("admin.userCreated")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
{t("admin.userActions")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-border divide-y">
|
||||
{users.map((u, i) => (
|
||||
<tr key={u.id} className={i % 2 === 0 ? "bg-muted/30" : ""}>
|
||||
<td className="px-3 py-2 font-medium">{u.username}</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs">
|
||||
{u.email || "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<Badge
|
||||
variant={u.role === "ADMIN" ? "default" : "secondary"}
|
||||
className="text-xs"
|
||||
>
|
||||
{u.role}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">{u.passkeyCount}</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-right text-xs whitespace-nowrap">
|
||||
{formatDate(u.createdAt)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() =>
|
||||
updateUser.mutate({
|
||||
id: u.id,
|
||||
data: {
|
||||
role: u.role === "ADMIN" ? "USER" : "ADMIN",
|
||||
},
|
||||
})
|
||||
}
|
||||
disabled={u.id === currentUserId}
|
||||
title={
|
||||
u.id === currentUserId
|
||||
? t("admin.ownRoleUnchangeable")
|
||||
: u.role === "ADMIN"
|
||||
? t("admin.demoteToUser")
|
||||
: t("admin.promoteToAdmin")
|
||||
}
|
||||
>
|
||||
<Shield className="mr-1 h-3 w-3" />
|
||||
{u.role === "ADMIN"
|
||||
? t("admin.toUser")
|
||||
: t("admin.toAdmin")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => startEdit(u)}
|
||||
>
|
||||
<Pencil className="mr-1 h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => startReset(u)}
|
||||
>
|
||||
<KeyRound className="mr-1 h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t("admin.loadingUsers")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Dialog */}
|
||||
{editingUser && (
|
||||
<div className="bg-muted/80 mt-4 rounded-lg p-4">
|
||||
<h3 className="mb-3 text-sm font-medium">
|
||||
{t("admin.editUserTitle", { name: editingUser.username })}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="edit-username">{t("auth.username")}</Label>
|
||||
<Input
|
||||
id="edit-username"
|
||||
value={editUsername}
|
||||
onChange={(e) => setEditUsername(e.target.value)}
|
||||
autoComplete="off"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="edit-email">{t("admin.userEmail")}</Label>
|
||||
<Input
|
||||
id="edit-email"
|
||||
type="email"
|
||||
value={editEmail}
|
||||
onChange={(e) => setEditEmail(e.target.value)}
|
||||
placeholder={t("common.optional")}
|
||||
autoComplete="off"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={updateUser.isPending}
|
||||
onClick={() =>
|
||||
updateUser.mutate({
|
||||
id: editingUser.id,
|
||||
data: {
|
||||
username: editUsername,
|
||||
email: editEmail || null,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
{updateUser.isPending && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
)}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setEditingUser(null)}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
{updateUser.isError && (
|
||||
<span className="text-destructive self-center text-sm">
|
||||
{(updateUser.error as Error).message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Password Reset Dialog */}
|
||||
{resetUser && (
|
||||
<div className="bg-muted/80 mt-4 rounded-lg p-4">
|
||||
<h3 className="mb-3 text-sm font-medium">
|
||||
{t("admin.resetPasswordTitle", { name: resetUser.username })}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="reset-pw">{t("admin.newPassword")}</Label>
|
||||
<PasswordInput
|
||||
id="reset-pw"
|
||||
value={resetPassword}
|
||||
onChange={(e) => setResetPassword(e.target.value)}
|
||||
placeholder={t("admin.newPasswordPlaceholder")}
|
||||
/>
|
||||
<PasswordStrength password={resetPassword} />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={resetPw.isPending || !resetPassword}
|
||||
onClick={() =>
|
||||
resetPw.mutate({
|
||||
id: resetUser.id,
|
||||
password: resetPassword,
|
||||
})
|
||||
}
|
||||
>
|
||||
{resetPw.isPending && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
)}
|
||||
{t("admin.reset")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setResetUser(null)}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
{resetMsg && (
|
||||
<p
|
||||
className={`text-sm ${resetMsg === t("admin.passwordReset") ? "text-dracula-green" : "text-destructive"}`}
|
||||
>
|
||||
{resetMsg}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { BellRing, Loader2 } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { PasswordInput, useAdminSettings, useUpdateSettings } from "./_shared";
|
||||
|
||||
export function WebPushVapidSection({ id }: { id: string }) {
|
||||
const { t } = useTranslations();
|
||||
const { data: settings } = useAdminSettings();
|
||||
const updateSettings = useUpdateSettings();
|
||||
const [webPushVapidPublicKeyDraft, setWebPushVapidPublicKeyDraft] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [webPushVapidPrivateKeyDraft, setWebPushVapidPrivateKeyDraft] =
|
||||
useState("");
|
||||
const [webPushVapidSubjectDraft, setWebPushVapidSubjectDraft] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
const webPushVapidPublicKeyValue =
|
||||
webPushVapidPublicKeyDraft ?? settings?.webPushVapidPublicKey ?? "";
|
||||
const webPushVapidSubjectValue =
|
||||
webPushVapidSubjectDraft ?? settings?.webPushVapidSubject ?? "";
|
||||
|
||||
const configured = settings?.webPushVapidConfigured ?? false;
|
||||
|
||||
function saveWebPushVapidSettings() {
|
||||
const payload: Record<string, unknown> = {
|
||||
webPushVapidPublicKey: webPushVapidPublicKeyValue,
|
||||
webPushVapidSubject: webPushVapidSubjectValue,
|
||||
};
|
||||
if (webPushVapidPrivateKeyDraft.trim().length > 0) {
|
||||
payload.webPushVapidPrivateKey = webPushVapidPrivateKeyDraft.trim();
|
||||
}
|
||||
|
||||
updateSettings.mutate(payload, {
|
||||
onSuccess: () => {
|
||||
setWebPushVapidPublicKeyDraft(null);
|
||||
setWebPushVapidPrivateKeyDraft("");
|
||||
setWebPushVapidSubjectDraft(null);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className="bg-card border-border scroll-mt-28 rounded-xl border p-6"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<BellRing className="text-primary h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("admin.webPushVapidTitle")}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{configured && (
|
||||
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
|
||||
{t("admin.configured")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("admin.webPushVapidDescription")}
|
||||
</p>
|
||||
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<Label htmlFor="admin-web-push-public-key" className="text-xs">
|
||||
{t("admin.webPushVapidPublicKey")}
|
||||
</Label>
|
||||
<Input
|
||||
id="admin-web-push-public-key"
|
||||
name="admin-web-push-public-key"
|
||||
value={webPushVapidPublicKeyValue}
|
||||
onChange={(event) =>
|
||||
setWebPushVapidPublicKeyDraft(event.target.value)
|
||||
}
|
||||
placeholder={t("admin.webPushVapidPublicKeyPlaceholder")}
|
||||
autoComplete="new-password"
|
||||
spellCheck={false}
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
disabled={updateSettings.isPending}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<Label htmlFor="admin-web-push-private-key" className="text-xs">
|
||||
{t("admin.webPushVapidPrivateKey")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="admin-web-push-private-key"
|
||||
name="admin-web-push-private-key"
|
||||
value={webPushVapidPrivateKeyDraft}
|
||||
onChange={(event) =>
|
||||
setWebPushVapidPrivateKeyDraft(event.target.value)
|
||||
}
|
||||
placeholder={t("admin.webPushVapidPrivateKeyPlaceholder")}
|
||||
autoComplete="new-password"
|
||||
spellCheck={false}
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
disabled={updateSettings.isPending}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<Label htmlFor="admin-web-push-subject" className="text-xs">
|
||||
{t("admin.webPushVapidSubject")}
|
||||
</Label>
|
||||
<Input
|
||||
id="admin-web-push-subject"
|
||||
name="admin-web-push-subject"
|
||||
value={webPushVapidSubjectValue}
|
||||
onChange={(event) =>
|
||||
setWebPushVapidSubjectDraft(event.target.value)
|
||||
}
|
||||
placeholder={t("admin.webPushVapidSubjectPlaceholder")}
|
||||
autoComplete="new-password"
|
||||
spellCheck={false}
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
disabled={updateSettings.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={saveWebPushVapidSettings}
|
||||
disabled={updateSettings.isPending}
|
||||
>
|
||||
{updateSettings.isPending && (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
)}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { TrendHint } from "../trend-hint";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<TrendHint>", () => {
|
||||
it("renders nothing for 0 readings", () => {
|
||||
expect(render(<TrendHint count={0} />)).toBe("");
|
||||
});
|
||||
|
||||
it("renders nothing once 5+ readings exist", () => {
|
||||
expect(render(<TrendHint count={5} />)).toBe("");
|
||||
expect(render(<TrendHint count={42} />)).toBe("");
|
||||
});
|
||||
|
||||
it("shows a singular wording when only 1 reading missing", () => {
|
||||
const html = render(<TrendHint count={4} />);
|
||||
expect(html).toContain("First trend after 5 readings");
|
||||
expect(html).toContain("1 more reading to go.");
|
||||
});
|
||||
|
||||
it("shows a plural wording for >1 missing readings", () => {
|
||||
const html = render(<TrendHint count={1} />);
|
||||
expect(html).toContain("4 more readings to go.");
|
||||
});
|
||||
|
||||
it("translates to German when locale is de", () => {
|
||||
const html = render(<TrendHint count={3} />, "de");
|
||||
expect(html).toContain("Erster Trend nach 5 Messungen");
|
||||
expect(html).toContain("Noch 2 Messungen bis zum Trend.");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { TrendingUp } from "lucide-react";
|
||||
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { trendHintFor } from "@/lib/onboarding/checklist";
|
||||
|
||||
interface TrendHintProps {
|
||||
/** Total readings logged for this metric (raw count, not days). */
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tiny banner shown below or alongside a chart for users still in the
|
||||
* "first week" of HealthLog: ≥1 but <5 readings of this metric. We
|
||||
* mount the existing `<EmptyState variant="plain">` primitive so the
|
||||
* shape matches every other empty-state in the app and inherits its
|
||||
* polite-live-region semantics. Renders nothing once the user crosses
|
||||
* 5 readings — no flicker.
|
||||
*/
|
||||
export function TrendHint({ count }: TrendHintProps) {
|
||||
const { t } = useTranslations();
|
||||
const result = trendHintFor(count);
|
||||
if (result.kind === "hidden") return null;
|
||||
|
||||
const description =
|
||||
result.remaining === 1
|
||||
? t("trendHints.remainingOne")
|
||||
: t("trendHints.remainingMany", { count: result.remaining });
|
||||
|
||||
return (
|
||||
<EmptyState
|
||||
variant="plain"
|
||||
size="compact"
|
||||
icon={<TrendingUp className="size-4" />}
|
||||
title={t("trendHints.title")}
|
||||
description={description}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
// HealthChart pulls in TanStack Query + recharts. We don't care about its
|
||||
// internal rendering — we only need to know *that it was rendered* and
|
||||
// with which `types[]` (which is the chart's metric handle, since the
|
||||
// real component takes `types: string[]` not `metric: string`).
|
||||
vi.mock("@/components/charts/health-chart", () => ({
|
||||
HealthChart: ({ types, title }: { types: string[]; title: string }) => (
|
||||
<div data-testid="health-chart" data-types={types.join(",")}>
|
||||
chart:{title}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Stub TanStack Query — InsightAdvisorCard itself doesn't query, but
|
||||
// nested clients (and the HealthChart mock above replaces the real one)
|
||||
// pull from this. Safe no-op.
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data: null, isLoading: false }),
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { InsightAdvisorCard } from "../insight-advisor-card";
|
||||
import type { InsightResult } from "@/lib/ai/types";
|
||||
|
||||
function buildInsight(overrides: Partial<InsightResult> = {}): InsightResult {
|
||||
return {
|
||||
insightType: "general",
|
||||
summary: "All good",
|
||||
classification: "gut",
|
||||
classificationLabel: "Good",
|
||||
findings: [
|
||||
{
|
||||
label: "Heart rate stable",
|
||||
value: "72 bpm",
|
||||
assessment: "positive",
|
||||
},
|
||||
],
|
||||
correlations: [],
|
||||
primaryRecommendation: "Keep going",
|
||||
recommendations: [],
|
||||
dataQuality: {
|
||||
coverage: "good",
|
||||
gaps: [],
|
||||
confidence: "hoch",
|
||||
},
|
||||
disclaimer: "Not a substitute for medical advice.",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function render(insight: InsightResult) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<InsightAdvisorCard title="Test card" insight={insight} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("<InsightAdvisorCard> — inline chart tokens", () => {
|
||||
it("renders an inline HealthChart for an allowlisted token in summary", () => {
|
||||
const html = render(
|
||||
buildInsight({
|
||||
summary: "BP looking great metric:BLOOD_PRESSURE_SYS today",
|
||||
}),
|
||||
);
|
||||
|
||||
// Chart is rendered with the token's metric as the type.
|
||||
expect(html).toContain('data-testid="health-chart"');
|
||||
expect(html).toContain('data-types="BLOOD_PRESSURE_SYS"');
|
||||
|
||||
// Token literal is stripped from the visible prose.
|
||||
expect(html).not.toContain("metric:BLOOD_PRESSURE_SYS");
|
||||
|
||||
// Surrounding prose survives unchanged.
|
||||
expect(html).toContain("BP looking great");
|
||||
expect(html).toContain("today");
|
||||
});
|
||||
|
||||
it("drops hallucinated tokens silently and still strips the literal", () => {
|
||||
const html = render(
|
||||
buildInsight({
|
||||
summary: "metric:NUKE try this",
|
||||
}),
|
||||
);
|
||||
|
||||
// No chart rendered.
|
||||
expect(html).not.toContain('data-testid="health-chart"');
|
||||
|
||||
// Token literal is gone from the DOM.
|
||||
expect(html).not.toContain("metric:NUKE");
|
||||
|
||||
// Visible text is the surrounding prose only.
|
||||
expect(html).toContain("try this");
|
||||
});
|
||||
|
||||
it("treats injection attempts as plain text — no script, no markup", () => {
|
||||
const html = render(
|
||||
buildInsight({
|
||||
summary: "metric:WEIGHT' onclick='alert(1)'",
|
||||
}),
|
||||
);
|
||||
|
||||
// The chart still renders for the safe metric prefix.
|
||||
expect(html).toContain('data-testid="health-chart"');
|
||||
expect(html).toContain('data-types="WEIGHT"');
|
||||
|
||||
// The trailing junk is preserved as escaped text — no script tag,
|
||||
// no live `onclick` attribute survives the React text path.
|
||||
expect(html).not.toContain("<script");
|
||||
expect(html).not.toMatch(/<[^>]*\sonclick\s*=/i);
|
||||
|
||||
// The escaped remnant still appears in the prose so the user can see
|
||||
// what the model emitted (alert(1) is now inert HTML-encoded text).
|
||||
expect(html).toContain("alert(1)");
|
||||
});
|
||||
|
||||
it("renders an inline chart attached to a hero finding token", () => {
|
||||
const html = render(
|
||||
buildInsight({
|
||||
findings: [
|
||||
{
|
||||
label: "Pulse trending down metric:PULSE",
|
||||
value: "68 bpm",
|
||||
assessment: "positive",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(html).toContain('data-types="PULSE"');
|
||||
expect(html).not.toContain("metric:PULSE");
|
||||
expect(html).toContain("Pulse trending down");
|
||||
});
|
||||
|
||||
it("renders inline charts for secondary findings without leaking tokens", () => {
|
||||
const html = render(
|
||||
buildInsight({
|
||||
findings: [
|
||||
{
|
||||
label: "Hero finding",
|
||||
value: "120/80",
|
||||
assessment: "neutral",
|
||||
},
|
||||
{
|
||||
label: "Weight stable metric:WEIGHT",
|
||||
value: "82 kg",
|
||||
assessment: "positive",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(html).toContain('data-types="WEIGHT"');
|
||||
expect(html).not.toContain("metric:WEIGHT");
|
||||
expect(html).toContain("Weight stable");
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,13 @@ import {
|
||||
Info,
|
||||
} from "lucide-react";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { HealthChart } from "@/components/charts/health-chart";
|
||||
import {
|
||||
parseChartTokens,
|
||||
stripChartTokens,
|
||||
tokenToMetric,
|
||||
type ChartToken,
|
||||
} from "@/lib/insights/chart-tokens";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────
|
||||
|
||||
@@ -34,36 +41,34 @@ interface InsightAdvisorCardProps {
|
||||
|
||||
// ─── Classification Colors ───────────────────────────────
|
||||
|
||||
const CLASSIFICATION_STYLES: Record<
|
||||
string,
|
||||
{ badge: string; border: string }
|
||||
> = {
|
||||
optimal: {
|
||||
badge:
|
||||
"bg-dracula-green/10 text-dracula-green border border-dracula-green/25 hover:bg-dracula-green/15",
|
||||
border: "border-l-dracula-green",
|
||||
},
|
||||
gut: {
|
||||
badge:
|
||||
"bg-dracula-cyan/10 text-dracula-cyan border border-dracula-cyan/25 hover:bg-dracula-cyan/15",
|
||||
border: "border-l-dracula-cyan",
|
||||
},
|
||||
grenzwertig: {
|
||||
badge:
|
||||
"bg-dracula-yellow/10 text-dracula-yellow border border-dracula-yellow/25 hover:bg-dracula-yellow/15",
|
||||
border: "border-l-dracula-yellow",
|
||||
},
|
||||
erhoht: {
|
||||
badge:
|
||||
"bg-dracula-orange/10 text-dracula-orange border border-dracula-orange/25 hover:bg-dracula-orange/15",
|
||||
border: "border-l-dracula-orange",
|
||||
},
|
||||
kritisch: {
|
||||
badge:
|
||||
"bg-dracula-red/10 text-dracula-red border border-dracula-red/25 hover:bg-dracula-red/15",
|
||||
border: "border-l-dracula-red",
|
||||
},
|
||||
};
|
||||
const CLASSIFICATION_STYLES: Record<string, { badge: string; border: string }> =
|
||||
{
|
||||
optimal: {
|
||||
badge:
|
||||
"bg-dracula-green/10 text-dracula-green border border-dracula-green/25 hover:bg-dracula-green/15",
|
||||
border: "border-l-dracula-green",
|
||||
},
|
||||
gut: {
|
||||
badge:
|
||||
"bg-dracula-cyan/10 text-dracula-cyan border border-dracula-cyan/25 hover:bg-dracula-cyan/15",
|
||||
border: "border-l-dracula-cyan",
|
||||
},
|
||||
grenzwertig: {
|
||||
badge:
|
||||
"bg-dracula-yellow/10 text-dracula-yellow border border-dracula-yellow/25 hover:bg-dracula-yellow/15",
|
||||
border: "border-l-dracula-yellow",
|
||||
},
|
||||
erhoht: {
|
||||
badge:
|
||||
"bg-dracula-orange/10 text-dracula-orange border border-dracula-orange/25 hover:bg-dracula-orange/15",
|
||||
border: "border-l-dracula-orange",
|
||||
},
|
||||
kritisch: {
|
||||
badge:
|
||||
"bg-dracula-red/10 text-dracula-red border border-dracula-red/25 hover:bg-dracula-red/15",
|
||||
border: "border-l-dracula-red",
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Assessment Icons ─────────────────────────────────────
|
||||
|
||||
@@ -74,16 +79,104 @@ function AssessmentIcon({
|
||||
}) {
|
||||
switch (assessment) {
|
||||
case "positive":
|
||||
return <CheckCircle2 className="h-4 w-4 shrink-0 text-dracula-green" />;
|
||||
return <CheckCircle2 className="text-dracula-green h-4 w-4 shrink-0" />;
|
||||
case "neutral":
|
||||
return <Minus className="h-4 w-4 shrink-0 text-muted-foreground" />;
|
||||
return <Minus className="text-muted-foreground h-4 w-4 shrink-0" />;
|
||||
case "attention":
|
||||
return <AlertCircle className="h-4 w-4 shrink-0 text-dracula-orange" />;
|
||||
return <AlertCircle className="text-dracula-orange h-4 w-4 shrink-0" />;
|
||||
case "warning":
|
||||
return <AlertTriangle className="h-4 w-4 shrink-0 text-dracula-red" />;
|
||||
return <AlertTriangle className="text-dracula-red h-4 w-4 shrink-0" />;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Hero Finding ─────────────────────────────────────────
|
||||
|
||||
const HERO_STYLES: Record<
|
||||
InsightFinding["assessment"],
|
||||
{ wrapper: string; icon: string; label: string }
|
||||
> = {
|
||||
positive: {
|
||||
wrapper:
|
||||
"from-dracula-green/15 via-dracula-green/5 to-transparent border-l-dracula-green",
|
||||
icon: "text-dracula-green",
|
||||
label: "text-dracula-green",
|
||||
},
|
||||
neutral: {
|
||||
wrapper:
|
||||
"from-dracula-cyan/12 via-dracula-cyan/4 to-transparent border-l-dracula-cyan",
|
||||
icon: "text-dracula-cyan",
|
||||
label: "text-dracula-cyan",
|
||||
},
|
||||
attention: {
|
||||
wrapper:
|
||||
"from-dracula-orange/15 via-dracula-orange/5 to-transparent border-l-dracula-orange",
|
||||
icon: "text-dracula-orange",
|
||||
label: "text-dracula-orange",
|
||||
},
|
||||
warning: {
|
||||
wrapper:
|
||||
"from-dracula-red/15 via-dracula-red/5 to-transparent border-l-dracula-red",
|
||||
icon: "text-dracula-red",
|
||||
label: "text-dracula-red",
|
||||
},
|
||||
};
|
||||
|
||||
function HeroFinding({ finding }: { finding: InsightFinding }) {
|
||||
const { t } = useTranslations();
|
||||
const style = HERO_STYLES[finding.assessment];
|
||||
const labelKey =
|
||||
finding.assessment === "positive"
|
||||
? "insights.heroFindingPositive"
|
||||
: finding.assessment === "warning"
|
||||
? "insights.heroFindingWarning"
|
||||
: finding.assessment === "attention"
|
||||
? "insights.heroFindingAttention"
|
||||
: "insights.heroFindingNeutral";
|
||||
const labelTokens = parseChartTokens(finding.label);
|
||||
const cleanLabel = stripChartTokens(finding.label);
|
||||
const guidelineTokens = finding.guideline
|
||||
? parseChartTokens(finding.guideline)
|
||||
: [];
|
||||
const cleanGuideline = finding.guideline
|
||||
? stripChartTokens(finding.guideline)
|
||||
: "";
|
||||
return (
|
||||
<div
|
||||
data-slot="insight-hero-finding"
|
||||
className={`rounded-lg border-l-2 bg-gradient-to-br ${style.wrapper} px-4 py-3.5`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-start gap-2.5">
|
||||
<div className={`mt-0.5 shrink-0 ${style.icon}`}>
|
||||
<AssessmentIcon assessment={finding.assessment} />
|
||||
</div>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p
|
||||
className={`text-xs font-semibold tracking-widest uppercase ${style.label}`}
|
||||
>
|
||||
{t(labelKey)}
|
||||
</p>
|
||||
<p className="text-sm leading-snug font-medium">{cleanLabel}</p>
|
||||
{cleanGuideline && (
|
||||
<p className="text-muted-foreground text-xs leading-snug">
|
||||
{cleanGuideline}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="shrink-0 text-base leading-snug font-semibold tabular-nums">
|
||||
{finding.value}
|
||||
</span>
|
||||
</div>
|
||||
{(labelTokens.length > 0 || guidelineTokens.length > 0) && (
|
||||
<div className="mt-3">
|
||||
<InlineCharts tokens={[...labelTokens, ...guidelineTokens]} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Confidence Colors ────────────────────────────────────
|
||||
|
||||
const CONFIDENCE_STYLES: Record<string, string> = {
|
||||
@@ -98,13 +191,76 @@ const CONFIDENCE_LABEL_KEYS: Record<string, string> = {
|
||||
gering: "insights.confidenceGering",
|
||||
};
|
||||
|
||||
// ─── Inline Chart Renderer ────────────────────────────────
|
||||
//
|
||||
// Expand a list of allowlisted ChartTokens into HealthChart instances.
|
||||
// HealthChart already understands the `MeasurementType`-style strings
|
||||
// (WEIGHT, BLOOD_PRESSURE_SYS, …); the synthetic MOOD and COMPLIANCE
|
||||
// tokens map to the dedicated chart components shipped elsewhere — for
|
||||
// now we render them as a HealthChart with the token's metric name as
|
||||
// the type, which the chart will gracefully render empty if there's no
|
||||
// matching data, instead of silently swallowing the user's expectation.
|
||||
//
|
||||
// Tokens are deduped per call so the prose `metric:WEIGHT … metric:WEIGHT`
|
||||
// doesn't render the same chart twice in a row.
|
||||
|
||||
const INLINE_CHART_TITLE_KEYS: Record<string, string> = {
|
||||
WEIGHT: "charts.weight",
|
||||
BLOOD_PRESSURE_SYS: "charts.systolic",
|
||||
BLOOD_PRESSURE_DIA: "charts.diastolic",
|
||||
PULSE: "charts.pulse",
|
||||
BODY_FAT: "charts.bodyFat",
|
||||
SLEEP_DURATION: "charts.sleep",
|
||||
ACTIVITY_STEPS: "charts.steps",
|
||||
BLOOD_GLUCOSE: "measurements.typeBloodGlucose",
|
||||
TOTAL_BODY_WATER: "charts.bodyWater",
|
||||
BONE_MASS: "charts.boneMass",
|
||||
OXYGEN_SATURATION: "charts.spo2",
|
||||
MOOD: "insights.navMood",
|
||||
COMPLIANCE: "insights.navMedication",
|
||||
};
|
||||
|
||||
function InlineCharts({ tokens }: { tokens: ChartToken[] }) {
|
||||
const { t } = useTranslations();
|
||||
if (tokens.length === 0) return null;
|
||||
|
||||
// Dedup while preserving the order the model emitted them.
|
||||
const seen = new Set<ChartToken>();
|
||||
const unique: ChartToken[] = [];
|
||||
for (const token of tokens) {
|
||||
if (!seen.has(token)) {
|
||||
seen.add(token);
|
||||
unique.push(token);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-slot="insight-inline-charts" className="space-y-3">
|
||||
{unique.map((token) => {
|
||||
const metric = tokenToMetric(token);
|
||||
const titleKey = INLINE_CHART_TITLE_KEYS[metric];
|
||||
const title = titleKey ? t(titleKey) : metric;
|
||||
return (
|
||||
<div
|
||||
key={token}
|
||||
data-slot="insight-inline-chart"
|
||||
data-metric={metric}
|
||||
>
|
||||
<HealthChart types={[metric]} title={title} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Section Separator ────────────────────────────────────
|
||||
|
||||
function SectionSeparator({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs uppercase tracking-wider font-medium text-muted-foreground">
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-xs font-medium tracking-wider uppercase">
|
||||
<span>{label}</span>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<div className="bg-border h-px flex-1" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -126,8 +282,8 @@ export function InsightAdvisorCard({
|
||||
const [dataQualityOpen, setDataQualityOpen] = useState(false);
|
||||
|
||||
const classStyle = insight
|
||||
? CLASSIFICATION_STYLES[insight.classification] ??
|
||||
CLASSIFICATION_STYLES.gut
|
||||
? (CLASSIFICATION_STYLES[insight.classification] ??
|
||||
CLASSIFICATION_STYLES.gut)
|
||||
: CLASSIFICATION_STYLES.gut;
|
||||
|
||||
// ── Loading State ─────────────────────────────────────
|
||||
@@ -135,8 +291,8 @@ export function InsightAdvisorCard({
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-dracula-purple" />
|
||||
<span className="ml-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="text-dracula-purple h-6 w-6 animate-spin" />
|
||||
<span className="text-muted-foreground ml-2 text-sm">
|
||||
{t("insights.generating")}
|
||||
</span>
|
||||
</CardContent>
|
||||
@@ -151,26 +307,24 @@ export function InsightAdvisorCard({
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{icon ?? (
|
||||
<Sparkles className="h-5 w-5 text-dracula-purple" />
|
||||
)}
|
||||
{icon ?? <Sparkles className="text-dracula-purple h-5 w-5" />}
|
||||
<CardTitle className="text-lg">
|
||||
{t("insights.aiAnalysisTitle")}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{title}</p>
|
||||
<p className="text-muted-foreground text-sm">{title}</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
|
||||
<div className="bg-destructive/10 text-destructive flex items-center gap-2 rounded-lg p-3 text-sm">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{!error && (
|
||||
<div className="flex flex-col items-center gap-3 py-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("insights.noAnalysisYet")}
|
||||
</p>
|
||||
{onRegenerate && (
|
||||
@@ -202,9 +356,7 @@ export function InsightAdvisorCard({
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{icon ?? (
|
||||
<Sparkles className="h-5 w-5 text-dracula-purple" />
|
||||
)}
|
||||
{icon ?? <Sparkles className="text-dracula-purple h-5 w-5" />}
|
||||
<CardTitle className="text-lg">
|
||||
{t("insights.aiAnalysisTitle")}
|
||||
</CardTitle>
|
||||
@@ -231,13 +383,13 @@ export function InsightAdvisorCard({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{title}</p>
|
||||
<p className="text-muted-foreground text-sm">{title}</p>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-5">
|
||||
{/* Error banner */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
|
||||
<div className="bg-destructive/10 text-destructive flex items-center gap-2 rounded-lg p-3 text-sm">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
@@ -245,43 +397,70 @@ export function InsightAdvisorCard({
|
||||
|
||||
{/* Primary Recommendation */}
|
||||
{insight.primaryRecommendation && (
|
||||
<div className="rounded-md border-l-2 border-dracula-purple bg-dracula-purple/5 px-4 py-3">
|
||||
<p className="mb-1 text-xs font-medium uppercase tracking-widest text-dracula-purple">
|
||||
<div className="border-dracula-purple bg-dracula-purple/5 rounded-md border-l-2 px-4 py-3">
|
||||
<p className="text-dracula-purple mb-1 text-xs font-medium tracking-widest uppercase">
|
||||
{t("insights.keyTakeaway")}
|
||||
</p>
|
||||
<p className="text-sm">{insight.primaryRecommendation}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary */}
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
{insight.summary}
|
||||
</p>
|
||||
{/* Summary — prose first, then any allowlisted charts the model
|
||||
* inlined via `metric:<TYPE>` tokens. Tokens are dropped from the
|
||||
* visible text so the literal substring never reaches the DOM. */}
|
||||
<div className="space-y-3">
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{stripChartTokens(insight.summary)}
|
||||
</p>
|
||||
<InlineCharts tokens={parseChartTokens(insight.summary)} />
|
||||
</div>
|
||||
|
||||
{/* Findings */}
|
||||
{/* Findings — top finding gets a hero treatment, rest are a compact list */}
|
||||
{insight.findings.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<SectionSeparator label={t("insights.findingsTitle")} />
|
||||
<div className="space-y-2">
|
||||
{insight.findings.map((finding, i) => (
|
||||
<div key={i}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<AssessmentIcon assessment={finding.assessment} />
|
||||
<span className="text-sm">{finding.label}</span>
|
||||
{/* v1.4 — pull the first finding into a severity-tinted hero
|
||||
* card. The model is instructed to put the most clinically
|
||||
* relevant finding first; we let the UI honour that by giving
|
||||
* it more weight than the secondary findings.
|
||||
*/}
|
||||
<HeroFinding finding={insight.findings[0]} />
|
||||
{insight.findings.length > 1 && (
|
||||
<div className="space-y-2 pt-1">
|
||||
{insight.findings.slice(1).map((finding, i) => {
|
||||
const labelTokens = parseChartTokens(finding.label);
|
||||
const guidelineTokens = finding.guideline
|
||||
? parseChartTokens(finding.guideline)
|
||||
: [];
|
||||
const tokens = [...labelTokens, ...guidelineTokens];
|
||||
return (
|
||||
<div key={i}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<AssessmentIcon assessment={finding.assessment} />
|
||||
<span className="text-sm">
|
||||
{stripChartTokens(finding.label)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium tabular-nums">
|
||||
{finding.value}
|
||||
</span>
|
||||
</div>
|
||||
{finding.guideline && (
|
||||
<p className="text-muted-foreground mt-0.5 ml-6 text-xs">
|
||||
{stripChartTokens(finding.guideline)}
|
||||
</p>
|
||||
)}
|
||||
{tokens.length > 0 && (
|
||||
<div className="mt-2 ml-6">
|
||||
<InlineCharts tokens={tokens} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm font-medium tabular-nums">
|
||||
{finding.value}
|
||||
</span>
|
||||
</div>
|
||||
{finding.guideline && (
|
||||
<p className="ml-6 mt-0.5 text-xs text-muted-foreground">
|
||||
{finding.guideline}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -293,7 +472,7 @@ export function InsightAdvisorCard({
|
||||
{insight.correlations.map((corr, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-1.5 rounded-md bg-muted/50 px-2.5 py-1.5 text-xs"
|
||||
className="bg-muted/50 flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs"
|
||||
>
|
||||
<span>{corr.factor}</span>
|
||||
<span className="text-muted-foreground">↔</span>
|
||||
@@ -316,7 +495,7 @@ export function InsightAdvisorCard({
|
||||
<ol className="space-y-1.5 pl-0">
|
||||
{insight.recommendations.map((rec, i) => (
|
||||
<li key={i} className="flex gap-2 text-sm">
|
||||
<span className="shrink-0 font-medium text-muted-foreground">
|
||||
<span className="text-muted-foreground shrink-0 font-medium">
|
||||
{i + 1}.
|
||||
</span>
|
||||
<span>{rec}</span>
|
||||
@@ -332,7 +511,7 @@ export function InsightAdvisorCard({
|
||||
<button
|
||||
onClick={() => setDataQualityOpen((v) => !v)}
|
||||
aria-expanded={dataQualityOpen}
|
||||
className="flex w-full items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
className="text-muted-foreground hover:text-foreground flex w-full items-center gap-1.5 text-xs transition-colors"
|
||||
>
|
||||
{dataQualityOpen ? (
|
||||
<ChevronUp className="h-3.5 w-3.5" />
|
||||
@@ -355,7 +534,7 @@ export function InsightAdvisorCard({
|
||||
</button>
|
||||
|
||||
{dataQualityOpen && (
|
||||
<div className="mt-2 space-y-2 rounded-md bg-muted/30 px-3 py-2.5 text-xs text-muted-foreground animate-insight-in">
|
||||
<div className="bg-muted/30 text-muted-foreground animate-insight-in mt-2 space-y-2 rounded-md px-3 py-2.5 text-xs">
|
||||
<p>{insight.dataQuality.coverage}</p>
|
||||
{insight.dataQuality.gaps.length > 0 && (
|
||||
<div>
|
||||
@@ -373,17 +552,15 @@ export function InsightAdvisorCard({
|
||||
)}
|
||||
|
||||
{cachedAt && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("insights.lastUpdated")}: {fmt.dateTime(cachedAt)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Disclaimer */}
|
||||
<div className="flex items-start gap-1.5 pt-1">
|
||||
<Info className="mt-0.5 h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{insight.disclaimer}
|
||||
</p>
|
||||
<Info className="text-muted-foreground mt-0.5 h-3 w-3 shrink-0" />
|
||||
<p className="text-muted-foreground text-xs">{insight.disclaimer}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -158,7 +158,10 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
|
||||
<div className="border-sidebar-border border-t p-3">
|
||||
<div className="flex items-center justify-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger aria-label={t("nav.userMenu")} className="hover:bg-accent shrink-0 rounded-md p-1.5 transition-colors focus-visible:ring-ring/50 focus-visible:ring-[3px] focus:outline-none">
|
||||
<DropdownMenuTrigger
|
||||
aria-label={t("nav.userMenu")}
|
||||
className="hover:bg-accent focus-visible:ring-ring/50 shrink-0 rounded-md p-1.5 transition-colors focus:outline-none focus-visible:ring-[3px]"
|
||||
>
|
||||
<Avatar className="h-8 w-8">
|
||||
{gravatarUrl && (
|
||||
<AvatarImage src={gravatarUrl} alt={user.username} />
|
||||
@@ -193,7 +196,10 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
|
||||
)}
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger aria-label={t("nav.userMenu")} className="text-muted-foreground hover:text-foreground hover:bg-accent shrink-0 rounded-md p-1.5 transition-colors focus-visible:ring-ring/50 focus-visible:ring-[3px] focus:outline-none">
|
||||
<DropdownMenuTrigger
|
||||
aria-label={t("nav.userMenu")}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-accent focus-visible:ring-ring/50 shrink-0 rounded-md p-1.5 transition-colors focus:outline-none focus-visible:ring-[3px]"
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</DropdownMenuTrigger>
|
||||
{dropdownContent}
|
||||
@@ -364,7 +370,9 @@ export function SidebarNav() {
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href="/bugreport"
|
||||
aria-current={pathname === "/bugreport" ? "page" : undefined}
|
||||
aria-current={
|
||||
pathname === "/bugreport" ? "page" : undefined
|
||||
}
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-lg p-2.5 transition-colors",
|
||||
pathname === "/bugreport"
|
||||
@@ -382,11 +390,13 @@ export function SidebarNav() {
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href="/settings"
|
||||
aria-current={pathname === "/settings" ? "page" : undefined}
|
||||
href="/settings/account"
|
||||
aria-current={
|
||||
pathname.startsWith("/settings") ? "page" : undefined
|
||||
}
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-lg p-2.5 transition-colors",
|
||||
pathname === "/settings"
|
||||
pathname.startsWith("/settings")
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-foreground hover:bg-accent",
|
||||
)}
|
||||
@@ -415,11 +425,13 @@ export function SidebarNav() {
|
||||
{t("nav.bugreport")}
|
||||
</Link>
|
||||
<Link
|
||||
href="/settings"
|
||||
aria-current={pathname === "/settings" ? "page" : undefined}
|
||||
href="/settings/account"
|
||||
aria-current={
|
||||
pathname.startsWith("/settings") ? "page" : undefined
|
||||
}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
|
||||
pathname === "/settings"
|
||||
pathname.startsWith("/settings")
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-foreground hover:bg-accent",
|
||||
)}
|
||||
|
||||
@@ -66,7 +66,7 @@ export function TopBar() {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/settings" className="cursor-pointer">
|
||||
<Link href="/settings/account" className="cursor-pointer">
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
{t("nav.settings")}
|
||||
</Link>
|
||||
|
||||
Loaded 100 of 197 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user