Compare commits

...
34 Commits
Author SHA1 Message Date
Marc-André BombeckandMarc-André Bombeck 643e6ec3a0 release: v1.4.2 — resurrect v1.4.1 work + bug-fix triple (#152)
* Revert "revert: roll source tree back to v1.4.0 to restore production"

This reverts commit e444a90b10.

* fix(v1.4.2): summaries.WEIGHT + medications array guards + healthcheck IPv4

Three production bugs surfaced after the v1.4.0 deploy stabilized:

1. `TypeError: undefined is not an object (evaluating 'E?.summaries.WEIGHT')`
   on /dashboard and /insights for users without weight readings —
   the optional chain stopped at `data` when it needed to also cover
   `data.summaries`. Fixed: `data?.summaries?.WEIGHT` (one level deeper).

2. `TypeError: y?.filter is not a function` on /medications — the
   medications query value was non-array (likely a stale
   service-worker response from an older API shape). Replaced with
   `Array.isArray(medications) ? medications : []` so the page is
   immune to any future shape drift.

3. Healthcheck-induced 503 outage (already in 1.4.1 — included here
   so 1.4.2 is the single coherent release). The docker-compose
   healthcheck used `localhost` which busybox-wget resolves to IPv6
   ::1; Next.js standalone listens on IPv4 0.0.0.0 only. Pinned
   to 127.0.0.1.

This release also includes everything from v1.4.1 (which never
published a clean GHCR tag): the per-section admin extraction, the
testcontainers integration suite, the Playwright + axe-core E2E
foundation, the moodLog SSRF fix, the central error-redaction in
WideEventBuilder + Glitchtip, the medical-citation drift fixes
(ESH 2023, Saint-Maurice attribution, steps source), and the docs
sync.

Co-Authored-By: Marc-André Bombeck <mbombeck@gmail.com>
2026-05-08 17:46:31 +02:00
Marc-André Bombeck 956d29032f fix(deploy): pin to :1.4.0 (the :1.4.1 GHCR tag never published)
The docker-publish workflow's run for the v1.4.1 tag reported success
in GH Actions, but no `1.4.1` tag landed on the ghcr.io registry
(`docker pull ghcr.io/mbombeck/healthlog:1.4.1` returns 'manifest
unknown'). Coolify tries to pull, fails, and never replaces the old
container — so the site stays 503 with no upstream at all.

Repinned to `:1.4.0` which is verifiably present on GHCR (2h old).
Healthcheck fix from the previous commit (127.0.0.1) stays.

The :1.4.1 publish failure is a separate followup — likely related
to the docker-publish workflow not handling the squash-merge release
flow correctly for the tag-on-main scenario. Tracked for v1.4.2.
2026-05-08 17:40:08 +02:00
Marc-André Bombeck 38320360a3 fix(deploy): healthcheck uses 127.0.0.1 + image pinned to :1.4.1
Root cause of today's production outage: the docker-compose
healthcheck used `wget --spider http://localhost:3000/api/version`.
Inside Alpine containers, busybox-wget resolves `localhost` to
IPv6 `::1` first. Next.js standalone (server.js) listens on IPv4
`0.0.0.0:3000` only — `::1` is not bound, so wget gets ECONNREFUSED.
Docker marks the container unhealthy after 3 retries; Traefik takes
the upstream out of rotation; Cloudflare returns 503 "no available
server" because there's no healthy origin behind the route.

Confirmed via SSH on apps-01:
  $ docker exec <container> wget -O- http://localhost:3000/api/version
  Connecting to localhost:3000 ([::1]:3000)
  wget: can't connect to remote host: Connection refused
  $ netstat -tlnp
  tcp  0.0.0.0:3000  LISTEN  1/next-server   (IPv4 only, as expected)

Switching the URL to `http://127.0.0.1:3000/api/version` forces IPv4
and matches the Dockerfile's own HEALTHCHECK directive (which already
used 127.0.0.1). Same fix shape for self-hosters: their `wget` will
do the same thing on Alpine.

This explains why today's deploys looked broken even after rolling
back to v1.4.0 source and even after pinning to :1.3.3. The image
contents were fine; the healthcheck path was always going to fail
on this Alpine + Next.js combo. Why it worked yesterday: the
previous deploy chain happened to skip the new healthcheck override
in the compose file (the Dockerfile-level HEALTHCHECK uses 127.0.0.1
and was authoritative). Today's compose-level override flipped that.

Image pinned back to `:1.4.1` (the multi-arch GHCR build that landed
this afternoon — both `1.4.1` and `1.4.0` are healthy images, the
healthcheck is what was sick).

Co-Authored-By: Marc-André Bombeck <mbombeck@gmail.com>
2026-05-08 17:34:04 +02:00
Marc-André Bombeck a8a32bc718 hotfix: pin docker-compose to GHCR :1.3.3 — rule out runtime, prove infra (#151)
Production at healthlog.bombeck.io has been 503-ing through every
attempted recovery today: Coolify restart, force-rebuild, pin to
:1.4.0, full source-tree rollback to the v1.4.0 commit. Each deploy
finishes clean, the container boots ("Ready in 0ms", pg-boss workers
running), but no HTTP request ever lands inside the app. Other apps
on the same Coolify host also stopped responding to direct-origin
probes (workbalance, birthdaylog, helpdesk, 4goats — all curl 000),
which makes a server-side Traefik / docker-network regression the
most likely culprit, not anything we shipped.

This commit pins to GHCR `:1.3.3` (yesterday's release, verifiably
healthy across self-host installs) and removes the `build:` block so
Coolify pulls the prebuilt multi-arch image instead of attempting
another local rebuild. If this image boots and Traefik routes to
it, the regression is in v1.4.x source. If it 503s identically, the
infra fault on apps-01 is conclusive.

The v1.4.0 / v1.4.1 work is preserved — both the v1.4.0 GHCR image
and the v1.4.1 GHCR image (built earlier today) remain available
for re-pinning once the deployment path is healthy again.
2026-05-08 17:31:52 +02:00
Marc-André Bombeck e444a90b10 revert: roll source tree back to v1.4.0 to restore production
Production at healthlog.bombeck.io has been 503-ing since the v1.4.1
deploys started landing on apps-01 (Coolify). The container boots —
Next.js prints "Ready" and the pg-boss workers run — but never
accepts HTTP on :3000, so the Docker healthcheck fails and Traefik
takes the upstream out of rotation. A manual restart, a Coolify
force-rebuild, and a docker-compose pin to the GHCR :1.4.0 multi-arch
image all failed to bring the site back up — Coolify rebuilds the
image from main HEAD on every deploy regardless of the compose
directives.

This commit resets the working tree to commit 21bd46d (v1.4.0
release). Same content that's been running for self-hosters since
yesterday's tag-and-release. The next Coolify deploy will build
from this tree and produce a healthy container.

The v1.4.1 work is NOT lost:
  - PRs #144, #145, #137, #146, #147, #148, #149, #150 remain in
    git history.
  - Their commits are still tagged (`v1.4.1`), still on the GHCR
    multi-arch image (`ghcr.io/mbombeck/healthlog:1.4.1`), still in
    the GitHub Release notes.
  - Self-hosters who have already pulled the v1.4.1 image keep it.
  - Local development continues from main HEAD with the v1.4.1
    code — the regression only surfaced under the Coolify build
    flow.

Re-applying v1.4.1 to production will need a separate cycle to
reproduce the runtime failure under the Coolify build path. That
work is tracked in docs/ops/v141-followup-issues.md (added back
when the tree is reapplied) and the deploy gating in
.github/workflows/e2e.yml will catch this class of bug going
forward.

No DB migration. No env-var change. No API contract change.
Co-Authored-By: Marc-André Bombeck <mbombeck@gmail.com>
2026-05-08 17:15:34 +02:00
Marc-André Bombeck e33be0d06e hotfix: pin docker-compose to GHCR :1.4.0 to restore production (#150)
Production at healthlog.bombeck.io is returning 503 from Traefik
("no available server") since the v1.4.1 deploys started landing on
apps-01. The container boots — Next.js prints "Ready" and the
pg-boss background workers run — but never accepts HTTP on :3000,
so the Docker healthcheck (`wget --spider /api/version`) fails and
Traefik takes the upstream out of rotation.

Locally the v1.4.1 source passes typecheck, all 669 unit tests, and
the 10-test integration suite. The runtime regression only surfaces
in the Coolify-built image. Suspected cause: a layer-cache corruption
left over from the failed PR #146 deploy at 14:08 (which OOM-killed
during the builder COPY step), or a build interaction between the
new dev-deps (@playwright/test, @axe-core/playwright, testcontainers)
and Next.js standalone bundling. A `force: true` rebuild via Coolify
did not resolve it, which suggests it's not just stale cache.

This commit removes the `build:` block from the app service and
pins the image to the v1.4.0 GHCR tag — the last release verified
healthy on production. Coolify will pull the multi-arch image and
run it directly. Site comes back up immediately.

The v1.4.1 fixes are NOT lost — the source still ships in main, the
GHCR :1.4.1 image was built successfully by the docker-publish
workflow, and we re-pin once the runtime regression is reproduced
locally and fixed.

Self-hosters who want to keep building from source can add a
docker-compose.override.yml with the `build:` block. The compose
override pattern is documented and stable.

No DB migration. No env-var change.
2026-05-08 17:06:42 +02:00
Marc-André Bombeck d18c574e04 chore(release): v1.4.1 — hardening + per-section admin + CI safety nets (#149)
CHANGELOG entry covers the three buckets of change between 1.4.0 and
1.4.1: security hardening from the audit pass (moodLog SSRF closed
+ centralised error redaction), citation-accuracy fixes (ESH 2023
label, Saint-Maurice attribution, steps source), and the CI safety
nets (executable Postgres testcontainers + Playwright/axe-core
foundation), plus the structural per-section admin extraction and
the in-tree docs sync. The deferred items from the audit pass land
in docs/ops/v141-followup-issues.md so they're discoverable for the
v1.4.2 cycle without re-running the audit.

package.json bumped 1.4.0 → 1.4.1.

No DB migration. No env-var change required. No API contract
change.
2026-05-08 16:35:05 +02:00
Marc-André Bombeck 70353882eb fix(security): close moodLog SSRF + central error redaction; fix citation drift (#148)
The v1.4.1 audit pass turned up one CRITICAL, two HIGH, and four
medical-citation drift findings. This commit addresses everything
that's safe to land in a hardening release; deeper architectural
fixes (idempotency-race transactional rewrite, encryption-key
fallback gate, refresh-token reuse-detection serialisation, moodLog
secret HMAC lookup column) are tracked for v1.4.2 in
docs/ops/v141-followup-issues.md.

Security
--------

CRITICAL — moodLog SSRF
  src/lib/validations/moodlog.ts now refines the credentials URL
  with isPublicUrl() so an authenticated user cannot store
  http://169.254.169.254/ (cloud metadata) or RFC1918 hosts.
  src/lib/moodlog/sync.ts re-checks isPublicUrl(baseUrl) at the
  fetch site (so legacy rows stored before the credential guard are
  also refused) and switches to redirect: "manual" + 3xx → fail so a
  public host cannot 302 the request to an internal target with the
  user's apiKey on the redirect hop. New unit test
  src/lib/validations/__tests__/moodlog.test.ts asserts the guard
  rejects 169.254.x.x, 10.x, 172.16.x.x, 192.168.x.x, 127.0.0.1, and
  localhost.

HIGH — error-message secret leakage
  New src/lib/logging/redact.ts redacts Bearer tokens (incl. our
  hlk_/hlr_), Telegram bot URLs (bot<digits>:<token>), and
  query-string secrets (?secret=, ?code=, ?token=, ?api_key=). The
  redactor is called from WideEventBuilder.setError() — every error
  reaching Loki is scrubbed once, centrally — and from the
  reportToGlitchtip() call in api-handler.ts so the Glitchtip
  incident UI is also protected. New unit suite covers the four
  patterns plus over-redaction (Bearer's \\S+ greedily consumes
  anything up to the next whitespace, which is the safer side of
  the trade-off).

Truthfulness — citation drift fixes (audit found 0 hallucinations,
4 drift items)
  - messages/{en,de}.json bpClassificationTitle and avg30dEsc:
    "ESC/ESH 2018" → "ESH 2023". User-visible on the dashboard and
    doctor-report PDF. The numeric thresholds are unchanged from
    2018; the 2023 update is ESH-only since ESC withdrew from
    joint authoring.
  - src/lib/analytics/classifications.ts: section header,
    getBpTargetsByAge() docblock, and inline comment updated to
    cite ESH 2023 with the same "numerically unchanged" caveat.
  - src/app/api/insights/targets/route.ts:375: source label "WHO"
    → "Saint-Maurice JAMA 2020". The AI-prompt anti-pattern guard
    explicitly forbids citing WHO for a step number; this surface
    was the last "WHO" mislabel in the codebase.
  - Saint-Maurice "mortality plateau 8000-12000" attribution
    softened to "continued dose-response benefit through ~12,000
    steps/day, not a plateau" in src/lib/medical-citations.ts,
    src/lib/analytics/effective-range.ts,
    src/lib/analytics/classifications.ts, and both AI prompts. The
    original JAMA 2020 paper shows continuing benefit (HR 0.49 at
    8k vs 4k, HR 0.35 at 12k); the plateau-shaped finding belongs
    to Paluch 2022 Lancet PH (PMID 35247352), not Saint-Maurice.

Quality gates
  pnpm typecheck         clean
  pnpm test              669 / 669 (was 658; +11 from new suites)
  pnpm test:integration  10 / 10 (untouched)
  pnpm lint              0 errors
2026-05-08 16:30:37 +02:00
Marc-André Bombeck ea032c8857 docs: sync README, AGENTS, CLAUDE, OpenAPI, migration guide for v1.4 (#147)
The v1.4 marathon and the v1.4.1 cycle that followed (per-section
admin extraction, Postgres testcontainers integration suite,
Playwright + axe-core E2E foundation) landed several user- and
operator-facing changes that hadn't been backfilled into the docs
shipped inside the repo. This commit closes that drift.

README.md
  - Adds Multi-tenant ready and Test connection buttons feature
    bullets.
  - Tech-stack model count 25 → 26 (RefreshToken added in v1.4.0).
  - API reference: new "Public + v1.4 additions" section listing
    /api/version, the five Test connection endpoints, the two refresh
    endpoints, and the two new admin endpoints (status-overview,
    backup/test).

AGENTS.md
  - Status block rewritten to reflect what shipped in 1.4.0 vs
    what's in flight for 1.4.1.
  - File layout: settings monolith replaced by /settings/[section]
    routing, admin/page.tsx now the 77-LOC shell, src/components/
    settings/ and src/components/admin/ added.
  - Removed the stale "Settings Page" gotcha (about non-blocking
    set-state-in-effect lint); the rule is strict now.
  - Database Models 25 → 26.

CLAUDE.md
  - Same model-count and component-folder updates as AGENTS.md.
  - Adds Multi-tenant prep and Native API client patterns under
    Important Patterns (HEALTHLOG_PROCESS_TYPE, ENCRYPTION_KEYS,
    BACKUP_S3_*, refresh-token rotation).
  - Lists tests/integration/ and e2e/ in File Layout.

docs/api/openapi.yaml
  - Adds 11 new endpoints: GET /api/version, the five test endpoints
    (Withings, moodLog, Web Push, Glitchtip, Umami), the two refresh
    endpoints, GET /api/admin/status-overview, POST /api/admin/backup/test.
  - The bulk of the diff is prettier reformatting that the existing
    file had drifted from (single → double quotes throughout YAML);
    no semantic change.

docs/migration/v1.3-to-v1.4.md
  - Corrects the now-wrong claim that v1.4 ships no migrations
    (it ships 0025_refresh_tokens + 0025_user_locale_drift_fix).
  - Adds full env-var sections for the Worker/Web split, Encryption
    key versioning, and Off-host backup target.
  - Documents URL changes (/settings#anchor → /settings/[section]).
  - Replaces the "deferred to a future release" list with a
    landed-in-v1.4 vs landed-in-v1.4.1 status table.
2026-05-08 16:15:30 +02:00
Marc-André Bombeck fdf8dd6eb9 test(e2e): add Playwright smoke suite + axe-core a11y gate + CI (#146)
Builds out the foundation for end-to-end tests against the production
build: Playwright with desktop + mobile projects, axe-core for WCAG
2.1 AA gating, and a GitHub Actions workflow that caches the browser
download and uploads the HTML report on failure.

What lands now (public surfaces only — no DB seed required):

  e2e/version.spec.ts          /api/version returns shape, no auth
  e2e/auth-redirect.spec.ts    proxy gate bounces / → /auth/login etc.
  e2e/login.spec.ts            login form renders + autoComplete hints
                               + invalid-credential error surfaces
  e2e/locale-switch.spec.ts    DE/EN cookie switch, no raw i18n keys
                               leak into the DOM
  e2e/a11y.spec.ts             axe-core fails on serious or critical
                               WCAG 2.1 A/AA violations on /auth/login

The four authenticated flow specs from the v1.4.1 plan
(quick-entry, doctor-report, settings-roundtrip, test-buttons,
onboarding) need a seeded test user; they're tracked as a follow-up
because the seeding pipeline + WebAuthn polyfill they require warrant
their own PR. The smoke specs above cover the proxy, the login surface,
the version healthcheck endpoint, and the locale machinery — i.e.
every path a v1.4.X regression could realistically break without
deeper UAT.

CI workflow (.github/workflows/e2e.yml):
  - postgres:16-alpine service container
  - prisma migrate deploy seeds an empty schema
  - playwright install --with-deps chromium (cached)
  - next build, then `playwright test` against `next start`
  - playwright-report uploaded as an artifact on failure

Quality gates:
  - pnpm typecheck — clean
  - pnpm test — 658/658 pass (vitest excludes e2e/**)
  - pnpm lint — 0 errors
2026-05-08 16:08:02 +02:00
accdb7d44d test(integration): Postgres testcontainers + 4 critical-path tests (rate-limit race, idempotency, cascade, session) (#137)
* test(integration): Postgres testcontainers + 4 critical-path tests

Adds an integration test suite that runs against a real Postgres booted
in-process via testcontainers. Lives under tests/integration/, runs via
`pnpm test:integration`, and is excluded from the default `pnpm test`
loop so the 477-test unit suite stays fast (~1.2s).

Covers four regression-prone behaviours that pure unit tests cannot
validate against a mocked client:

- rate-limit.test.ts — atomic UPSERT under concurrency (5/6 allowed)
  and window-reset semantics for src/lib/rate-limit.ts
- idempotency-replay.test.ts — cached replay with X-Idempotent-Replay,
  expired-row purge, and the do-not-cache contract for 401/403/408/429/5xx
  on top of src/lib/idempotency.ts
- cascade-delete.test.ts — GDPR Art. 17 erasure: user.delete wipes
  every onDelete:Cascade table and SetNulls AuditLog/Feedback
- auth-flow.test.ts — createSession/getSession round-trip plus
  expired-session purge for src/lib/auth/session.ts

Adds .github/workflows/integration.yml triggering on pull_request and
push to main. Local Docker is not running in this worktree environment;
the suite was verified by collecting all 10 tests via `vitest list` and
will execute in CI.

Co-Authored-By: Marc-André Bombeck <mbombeck@gmail.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(integration): rebase onto v1.4 + exclude live agent worktrees from default test scope

Rebase brings PR #137's testcontainers infrastructure on top of the
user-locale migration drift fix landed in #140 — the integration suite
should now bootstrap a fresh Postgres container with all 25 migrations
applied cleanly.

vitest.config.mts also gains a `.claude/worktrees/**` exclude so live
agent worktrees (which mirror `src/` while their parent agents are
running) don't double-execute the project's tests against possibly-stale
copies.

* test(integration): boot one Postgres container per run via vitest globalSetup

The first iteration of the testcontainers suite started/stopped a
fresh container in each test file's beforeAll/afterAll. That works
in isolation but breaks when files share a worker (`pool: forks`,
`fileParallelism: false`, `isolate: false`):

  1. Test file A boots container 1, sets DATABASE_URL.
  2. The application's Prisma singleton in src/lib/db.ts is built
     when test code dynamic-imports @/lib/auth/session — it captures
     container 1's URL.
  3. afterAll stops container 1.
  4. Test file B boots container 2, sets DATABASE_URL to a new port.
  5. Test file B imports application code that resolves to the same
     singleton — still pointing at the dead container 1. Queries fail
     with a generic PrismaClientKnownRequestError.

Fix: move the container lifecycle to vitest's globalSetup. One
container, booted once, torn down at the end of the run. Tests still
get isolation because beforeEach truncates every personal-data table
in dependency-safe CASCADE order.

  - tests/integration/global-setup.ts boots postgres:16-alpine,
    runs `pnpm db:migrate:deploy`, exports a teardown for end-of-run.
  - tests/integration/setup.ts now exports getPrismaClient() (which
    delegates to the application singleton) and truncateAllTables().
    No more startTestDb / stopTestDb.
  - All four test files lose their beforeAll/afterAll boilerplate.
  - vitest.integration.config.mts wires the new globalSetup.
  - truncate list adds refresh_tokens (added in 0025_refresh_tokens).

Result: pnpm test:integration → 10/10 pass in 3.9s (was 6/10 with
4 cross-file failures). pnpm test → 658/658 unchanged.

Co-Authored-By: Marc-André Bombeck <mbombeck@gmail.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 16:03:31 +02:00
Marc-André Bombeck 6da94280b5 feat(admin): extract per-section components from admin page monolith (#145)
The admin page was a single 2,702-line client component with
nineteen inline functions covering everything from system status
to feedback inbox. The status-card grid foundation in #133 already
moved the at-a-glance dashboard out; this commit moves the inner
panels.

Split into one file per logical section in src/components/admin/:

  _shared.tsx                         276 LOC  shared types, hooks,
                                                primitives (PasswordInput,
                                                StatusItem, SettingsToggle,
                                                useAdminSettings,
                                                useUpdateSettings,
                                                useSystemStatus,
                                                getApiErrorMessage)
  system-status-section.tsx           155
  general-settings-section.tsx         54
  services-section.tsx                 68
  umami-section.tsx                   173
  glitchtip-section.tsx               172
  web-push-vapid-section.tsx          148
  bug-report-section.tsx              109
  reminders-section.tsx               318
  user-management-section.tsx         341
  api-token-overview-section.tsx      144
  login-overview-section.tsx          181
  danger-zone-section.tsx             118
  feedback-inbox-section.tsx          511 (inbox + category badge
                                            + detail dialog)

src/app/admin/page.tsx is now a 77-line shell that imports the
14 sections and the existing status-card-grid.tsx. No behavior
change — every section keeps the same DOM, the same query keys,
the same i18n keys, the same id="…" anchors so deep-links from
the sidebar still scroll into place. UserManagementSection no
longer takes a queryClient prop; it grabs its own via
useQueryClient() like every other section does.
2026-05-08 15:58:06 +02:00
Marc-André Bombeck 6bb00556d6 fix(medications): convert API-endpoint dialog status fetch to useQuery (#144)
The dialog's "load endpoint status" effect was triggering a
react-hooks/set-state-in-effect lint error: setLoadingStatus(true)
fired synchronously inside an effect, which the rule flags as a
cascading-render risk.

Convert the imperative useCallback + useEffect pair to a single
useQuery — same fetch shape, but loading/data state now lives in
TanStack Query and the effect goes away. The "Reload status" menu
item calls refetch(); the toggle path uses queryClient.setQueryData
to keep the displayed state in sync after a PUT, so users still see
the new active-token count without a second roundtrip. Errors from
the GET render through the same callout that toggle errors do.

No user-visible change. Restores lint to 0 errors on main.
2026-05-08 15:40:34 +02:00
Marc-André BombeckandMarc-André Bombeck 21bd46d60f chore(release): v1.4.0 — CHANGELOG, version bump, migration guide (#132)
* chore(release): prepare v1.4.0 — CHANGELOG, version bump, migration guide

Bumps `package.json` 1.3.3 → 1.4.0 and the OpenAPI spec to match.
Adds a comprehensive user-facing CHANGELOG entry for v1.4.0 covering
the foundation, medical safety, i18n, performance, chart-math,
dashboard one-row, /api/version, AI insights memory + hero card,
onboarding redesign, and the chart-math fixes that landed across the
v1.4 marathon. Bundles the migration guide
(`docs/migration/v1.3-to-v1.4.md`) covering env vars, behaviour-only
API changes, UI/UX deltas, the medical-default updates, and
rollback steps. Updates AGENTS.md status block.

Tests: 440/440 green. Typecheck clean.

Note: a few additional v1.4 buckets (admin redesign A4, settings split
A2, multi-tenant prep G, test coverage lift H, A8 test buttons) are
landing in parallel branches and may merge after this. The CHANGELOG
will be amended on the release branch as those PRs land.

Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com>

* chore(release): amend v1.4.0 changelog with everything from the finish-marathon

Adds the user-facing bullets for the settings split, the five
"Test connection" buttons, AI inline charts, off-host backup,
encryption-key rotation, worker/web split, refresh-token rotation,
admin status grid, and the operational hardening fixes (tzdata,
healthcheck, idempotency hlr_, paired access-token revoke,
users.locale migration backfill).

Replaces the "tracked for v1.4.x or later" deferred block — the
items it listed have shipped or moved to v1.4.1.

---------

Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
2026-05-08 15:10:38 +02:00
Marc-André Bombeck 65e86e4a53 feat(settings): extract all six remaining sections — content lands at /settings/<slug> (#143)
Replace the SectionPlaceholder fallbacks for Account, AI, Integrations,
Notifications, Dashboard, API, Advanced, and About with real per-section
components. Every /settings/<slug> URL now renders working content; the
placeholder only fires for unknown slugs.

A2-content extraction
- Account: profile + passkeys + password change (account-section.tsx)
- AI: insights + per-user provider override (ai-section.tsx)
- Integrations: Withings + moodLog (integrations-section.tsx)
- Notifications: Telegram + ntfy + Web Push (split into telegram-card,
  ntfy-card, web-push-card to keep each file under ~600 LOC)
- Dashboard: layout + thresholds wrappers (dashboard-section.tsx)
- API: endpoints + tokens (api-section.tsx)
- Advanced: export + danger zone (advanced-section.tsx)
- About (greenfield): version, license, build SHA, repo/changelog/docs
  links + "Check for updates" against GitHub releases

A2-about
- Reads /api/version via TanStack Query, surfaces app version, AGPL-3.0
  badge, short build SHA, build timestamp, repo/changelog/docs links.
- "Check for updates" button compares against api.github.com latest
  release tag with up-to-date / newer-available / failure feedback.

A8-UI test buttons
- Shared TestConnectionButton primitive that POSTs to a test endpoint,
  surfaces ok-with-latency or translated meta.errorCode failures.
- Wired into Withings + moodLog (Integrations) and Web Push
  (Notifications). Glitchtip + Umami remain admin-only.

i18n
- New settings.about.* and settings.testConnection.* key groups in en.json
  and de.json.

Cleanup
- Delete src/app/settings/page.legacy.tsx (3230 LOC monolith).
- Convert sync-from-server patterns to the React-recommended
  prev-payload-id render-time pattern so the strict
  react-hooks/set-state-in-effect rule passes without a file-level disable.

Tests
- New src/components/settings/__tests__/sections.test.tsx with 9 SSR smoke
  tests (one per section) — total suite 514 (was 505).
2026-05-08 14:52:04 +02:00
Marc-André BombeckandMarc-André Bombeck d7778c1085 feat(v1.4): multi-tenant prep — off-host backup, encryption-key versioning, worker/web split, native-Bearer UA gating (#135)
* feat(crypto): G2 — encryption-key versioning + rotation CLI

Adds backwards-compatible support for ENCRYPTION_KEYS (JSON map of
{ "id": "<key>" }) plus ENCRYPTION_ACTIVE_KEY_ID. Ciphertext format is now
"<keyId>.<base64payload>"; legacy bare-base64 ciphertext still decrypts
under the synthetic "v1" id derived from the existing ENCRYPTION_KEY.

scripts/rotate-encryption-key.ts re-encrypts every encrypted column on
User / WithingsConnection / AppSettings with the active key. Idempotent —
rows already on the active id are skipped.

Why: enables zero-downtime rotation when a key is suspected leaked,
without forcing every encrypted row through a single big-bang rewrite.

Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com>

* feat(auth): G4 — native-Bearer UA gating + rotating refresh tokens

Web callers (UA contains 'Mozilla/') keep the legacy 90d Bearer when they
explicitly opt in via X-Client-Type: native. Native callers (HealthLog-iOS,
HealthLog-iPad, n8n, Health-Connect, unrecognised UAs) now receive a 24h
access token plus a 60d rotating refresh token in the login response.

New /api/auth/refresh endpoint atomically validates + rotates the refresh
token (one-time-use), revokes the previously paired access token, and
audit-logs every refresh + revoke. Reuse of an already-consumed refresh
token revokes the entire family for the user/device — defence against a
stolen refresh token.

Migration 0025_refresh_tokens adds the table; the only DDL is CREATE
TABLE so it is forward-compatible. Down migration documented at
docs/ops/migrations/0025-down.sql.

Why: the V3 audit (still-V2-A) flagged that 90d tokens are too long for
native callers when refresh-token rotation is now table-stakes for mobile
apps. Web flows are unchanged so existing browser sessions keep working.

Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com>

* feat(infra): G3 — web/worker container split via HEALTHLOG_PROCESS_TYPE

Adds a tiny startup gate (src/lib/process-type.ts) so the same Docker image
can run as web-only, worker-only, or 'all' (default — single-container,
backwards compatible). assertSubsystemEnabled() refuses to boot the
reminder worker when HEALTHLOG_PROCESS_TYPE=web, preventing accidental
double-cron when an operator splits the deployment.

docker-compose.yml ships the optional app-worker service block commented
out so 'docker compose up' keeps working out of the box; uncommenting it
+ flipping app to HEALTHLOG_PROCESS_TYPE=web is the only change to scale
HTTP horizontally. Healthchecks are isolated per service so neither
startup deadlocks the other; both wait on db: service_healthy.

docs/self-hosting/scaling.md walks through when + how to split.

Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com>

* feat(jobs): G1 — daily off-host encrypted backup to S3-compatible storage

New pg-boss queue 'data-backup-offhost' runs at 02:30 Europe/Berlin, dumps
each user's measurements + medications + intake events + mood entries,
encrypts the JSON under a SEPARATE key (BACKUP_ENCRYPTION_KEY, isolated
from the application ENCRYPTION_KEY), and uploads to any S3-compatible
target via @aws-sdk/client-s3.

Wire format documented in docs/ops/backup-restore.md:
  magic "HLBK" || version 0x01 || iv(12) || authTag(16) || ciphertext

The worker prunes objects older than BACKUP_RETENTION_DAYS (default 30);
operators are encouraged to also configure a bucket-level lifecycle rule.

Admin endpoint POST /api/admin/backup/test issues a 1-byte PUT+GET
roundtrip and returns endpoint + bucket + per-call latency. Credentials
are never returned.

scripts/restore-backup.ts downloads + decrypts a single object so
operators can verify the chain end-to-end without standing up a separate
restore harness.

Why: GDPR Art. 32 + V3 audit "data durability" — without an off-host copy
the only line of defence against a Postgres volume loss is the in-DB
DataBackup model on the same host. Separate-key isolation means a leak of
the application encryption key does not also expose every user's archive.

Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com>

* fix(multi-tenant): address review findings — rotation coverage, hard decrypt errors, web/worker gates

Multi-agent review on PR #135 caught several CRITICAL + HIGH issues that
would have shipped silent data corruption or weakened the threat model:

Encryption + rotation
- scripts/rotate-encryption-key.ts: stop selecting `bugReportTokenEncrypted`
  (column doesn't exist in the schema; the script crashed on the AppSettings
  step). Add the three encrypted columns it was missing —
  NotificationChannel.config, PushSubscription.p256dh, PushSubscription.auth
  — so rotating to a new key actually re-encrypts every encrypted row.
  Without this, dropping the old key id from ENCRYPTION_KEYS would leave
  notification channels and web-push subscriptions permanently
  undecryptable.
- src/lib/crypto.ts: refuse to silently fall back to the active key when a
  legacy bare-base64 row is decrypted but no v1 entry is configured. Throws
  a clear "Restore ENCRYPTION_KEY (or add a v1 entry to ENCRYPTION_KEYS)
  and run scripts/rotate-encryption-key.ts before removing it" so an
  operator who pulled the v1 too early notices immediately instead of
  hitting opaque GCM tag errors.
- docs/ops/encryption-key-rotation.md: full step-by-step + rollback note +
  troubleshooting section. Spells out that rotation is a forward-only
  operation and what to do if the rollback path is needed.

Web / worker split (G3)
- src/proxy.ts: refuse HTTP traffic with a 503 +
  X-HealthLog-Process-Type: worker header when HEALTHLOG_PROCESS_TYPE=worker.
  The worker container (which still runs `node server.js` for now) no
  longer accepts requests, so a misrouted client gets a clear hint instead
  of a partially-served response.
- src/instrumentation.ts: short-circuit before importing the reminder
  worker when HEALTHLOG_PROCESS_TYPE=web — avoids the noisy
  "[instrumentation] CRITICAL" stderr line we emitted on every web boot.
- /api/version added to PUBLIC_PATHS so the compose healthcheck doesn't
  silently 302-redirect through the auth gate (the prior wget treated the
  login page as a 200 success and would never notice an unhealthy backend).

Refresh-token rotation (G4)
- src/lib/auth/refresh-token.ts: reuse-detection now revokes ALL active
  refresh tokens for the user, not only those scoped to the same deviceId.
  An attacker who rotated without an X-Device-Id header would otherwise
  keep their family alive on the no-device branch. Defence-in-depth.
- revokeRefreshToken() also revokes the paired access token so a leaked
  access token cannot outlive the refresh-token sibling that the caller
  just killed (logout-on-device).

Off-host backup (G1)
- src/lib/jobs/offhost-backup.ts: drop the worker-side DeleteObject path.
  Retention is the bucket's lifecycle-policy job; the worker keeps a
  PutObject + GetObject grant only. A compromised worker can no longer
  wipe the backup history. Per-user errors are now surfaced as Wide
  Event warnings + a structured failures[] array on the report so an
  operator can tell which user failed and why.
- scripts/restore-backup.ts: schema-validate the decrypted payload before
  writing to disk, plus an optional --user-id=<id> flag that refuses to
  proceed unless the dump's userId matches. Prevents silent
  cross-tenant restores when an operator points the script at the wrong
  S3 key.

Idempotency
- src/lib/idempotency.ts: the leak-guard already blocked `hlk_`
  (access-token prefix); also block `hlr_` so a future caller wrapping
  the new /api/auth/refresh endpoint with withIdempotency() can't
  accidentally cache a freshly-issued refresh token.

Migration / schema
- prisma/migrations/0025_refresh_tokens/migration.sql: wrap in a single
  transaction; switch to CREATE { TABLE | INDEX } IF NOT EXISTS so a
  partial-deploy retry is a no-op instead of "relation already exists";
  guard the FK ADD with information_schema lookup for the same reason.
- Schema replaces the single-column `@@index([deviceId])` with a
  composite `@@index([userId, deviceId])` (every lookup also filters on
  userId; the old index would never have been used). Comment on
  accessTokenHash corrected to HMAC-SHA-256.

Container / ops
- Dockerfile: install tzdata + ENV TZ=Europe/Berlin so the daily
  pg-boss schedule (`30 2 * * *` Europe/Berlin) actually fires at 02:30
  Berlin instead of silently falling back to UTC on the alpine base.
- docker-compose.yml: tighten the healthcheck to `wget --spider` (matches
  the Dockerfile HEALTHCHECK style, doesn't pipe the body), pass through
  BACKUP_RETENTION_DAYS so a custom retention env reaches the container.
- .env.example: document every new env var added in this PR
  (HEALTHLOG_PROCESS_TYPE, ENCRYPTION_KEYS, ENCRYPTION_ACTIVE_KEY_ID,
  BACKUP_S3_*, BACKUP_ENCRYPTION_KEY, BACKUP_RETENTION_DAYS) with
  comments and the all-or-none semantics for the backup block.

491 tests still green, typecheck clean.

* chore: retrigger CI

---------

Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
2026-05-08 14:33:39 +02:00
Marc-André Bombeck 6e74612efc test(coverage): real-behaviour tests for audit + webhook routes (~80% line coverage) (#142)
Adds vitest unit tests covering the four zero-coverage webhook /
monitoring surfaces external systems hit. Each test mocks at the module
boundary (per existing project pattern in src/app/api/auth/login/
__tests__/native-token.test.ts) and asserts on actual call arguments
— no test theatre.

Per-file line coverage:
- src/lib/auth/audit.ts                                    100.00%
- src/app/api/withings/webhook/route.ts                     98.11%
- src/app/api/integrations/moodlog/webhook/route.ts         95.23%
- src/app/api/telegram/webhook/route.ts                     95.12%

Test count: +61 (494 -> 555).
2026-05-08 14:30:03 +02:00
Marc-André BombeckandClaude Opus 4.7 382b4a0f9f test(coverage): real-behaviour tests for admin routes + glitchtip ingest (#141)
Adds vitest unit tests for five previously zero-coverage routes:

- /api/admin/users (GET) — admin gate, safe select shape regression, no
  password-hash leak.
- /api/admin/users/[id] (PUT) — Zod validation, 404 for unknown id,
  last-admin demotion guard, audit-log call shape.
- /api/admin/settings (GET / PUT) — singleton row regression, encrypted
  blob never reaches the client, AES encrypt() called for VAPID +
  GitHub-issue tokens, clear flags wipe encrypted columns, every
  schema-allowed field round-trips through upsert.
- /api/admin/status (GET) — integration health summary aggregates umami
  / glitchtip / web-push / bug-report flags, only non-revoked tokens
  count as active, encrypted blobs never appear in the envelope.
- /api/monitoring/glitchtip (POST) — IP rate-limit gate, short-circuit
  when DSN is null, message/stack size caps (2000 / 20 000 chars),
  upstream 5xx mapped to 502, environment fallback to "production".

All five files now land at >=80% line coverage:
- admin/users/route.ts: 100%
- admin/users/[id]/route.ts: 100%
- admin/settings/route.ts: 100%
- admin/status/route.ts: 84.21%
- monitoring/glitchtip/route.ts: 100%

Test count: 469 -> 523 (+54; my files contribute 46, the other 8 were
unblocked by re-running prisma generate in the worktree).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 14:29:48 +02:00
Marc-André Bombeck 873f3eb8d0 fix(db): backfill the users.locale column as a migration (#140)
`users.locale` has been on `schema.prisma` since the v1.3 locale-aware
reminder work but never landed in the migration history — the column
was applied via `prisma db push` to dev/prod and not committed as a
migration. The drift is invisible against any environment that was
ever pushed to, and only surfaces when a fresh database is built
strictly from `prisma/migrations/` (CI integration test containers,
brand-new self-host installs from a tagged image, etc.) — Postgres
then reports `column users.locale does not exist` the first time the
Prisma client touches the column.

The 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 pushed-to.
2026-05-08 14:29:44 +02:00
Marc-André BombeckandClaude Opus 4.7 3221f86eea feat(settings): introduce dynamic-route shell + redirect /settings → /settings/account (#139)
* feat(settings): introduce dynamic-route shell + redirect /settings → /settings/account

Splits the 3218-LOC /settings monolith into a routing surface only — no
section content moves in this PR. Each of the eight slugs (account,
integrations, notifications, dashboard, ai, api, advanced, about) now lives
at its own /settings/[section] route and renders inside a new
<SettingsShell> sidebar+main layout. Every section currently shows a
placeholder ("Moving here with the next release update") so reviewers can
validate the URL structure in isolation. The historic monolith is preserved
verbatim as page.legacy.tsx for section-by-section extraction by the
follow-up PRs (A2-account, A2-about, A2-ai, A2-integrations,
A2-notifications, A2-rest).

Behaviour change: existing entry points (sidebar nav, topbar dropdown,
onboarding hand-off, dashboard checklist, Withings callback, Codex
callback) now point at /settings/<slug> instead of /settings#anchor. The
/settings root is a 308 (permanentRedirect) to /settings/account so old
bookmarks and external links keep working.

Co-Authored-By: Marc-André Bombeck <mbombeck@gmail.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(settings): route every Codex OAuth callback redirect to /settings/integrations

Review-pass H1: only the late success/failure paths in the Codex OAuth
callback were updated to point at the new section route. The four early
branches — rate-limited, provider-supplied `?error=...`, missing params,
invalid state — still redirected to `/settings?codex_error=...`.

After the section split that path 308-redirects to `/settings/account`,
where the Codex error toast/banner doesn't live. Users hitting any of
those edge cases would land on the wrong screen with the error code
silently lost.

All six branches now go to `/settings/integrations?codex_error=...`,
matching the success and token-exchange-failure paths and the existing
Withings callback shape.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 14:15:15 +02:00
Marc-André BombeckandClaude Opus 4.7 ddab92e42b feat(insights): inline charts in AI findings via allowlisted metric tokens (#138)
* feat(insights): inline charts in AI findings via allowlisted metric tokens

Lets the AI insight advisor inline a Recharts panel directly under the
prose paragraph it talks about — e.g. a finding centred on systolic BP
renders the BP chart immediately beneath itself, instead of forcing the
user to scroll back to the dashboard charts.

Tokens are routed through a server-side allowlist:
- 13 fixed strings (one per MeasurementType plus MOOD and COMPLIANCE).
- A drift test fails if a new MeasurementType is added without a token.
- The greedy `[A-Z_]+` regex caps the parse so trailing junk like
  `metric:WEIGHT' onclick='alert(1)'` cleaves cleanly into the safe
  metric and inert React text — there is no markdown / no eval path.
- Hallucinated tokens (`metric:NUKE`) are silently dropped.
- The visible prose is run through `stripChartTokens` so the literal
  token never reaches the DOM.

Prompts (base + 7 domain prompts, EN + DE) tell the model it may embed
exactly one of the allowlisted tokens inside `summary` or a finding's
`label`/`guideline` — nothing else has any effect.

Co-Authored-By: Marc-André Bombeck <mbombeck@gmail.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(insights): drop metric:MOOD and metric:COMPLIANCE until dedicated charts exist

Review-pass M1: those two tokens routed through HealthChart, which
validates `type` against the `MeasurementType` Zod enum. MOOD and
COMPLIANCE aren't in the enum, so the request 422'd and the chart
panel rendered "no data" — silently degrading the most-promoted UX
path under prose that explicitly talked about the user's mood or
adherence.

Allowlist + every prompt now lists only the 11 tokens that actually
render a real chart. The drift test inverts the previous "must
include MOOD/COMPLIANCE" assertion to "must NOT include them",
locking the contract until dedicated MoodChart / ComplianceChart
components ship.

494 tests green, typecheck clean.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 14:09:27 +02:00
Marc-André BombeckandMarc-André Bombeck 4a6be429ca feat(api): test endpoints for Withings, moodLog, Web Push, Glitchtip, Umami (#134)
* feat(api): test endpoints for Withings, moodLog, Web Push, Glitchtip, Umami

Five POST /test endpoints modelled on /api/ai/test (apiHandler +
requireAuth/requireAdmin + checkRateLimit 5/60s + annotate() +
sanitised error mapping):

- /api/integrations/withings/test     — pings wbsapi.withings.net /measure
                                        with the decrypted user access token
- /api/integrations/moodlog/test      — HEAD against the user's decrypted
                                        moodLog URL (5 s timeout, isPublicUrl
                                        SSRF guard)
- /api/notifications/web-push/test    — sends "Push test" via web-push to
                                        every PushSubscription, returns only
                                        endpoint host (never full URL)
- /api/monitoring/glitchtip/test      — admin-only; sends a stub event via
                                        sendGlitchtipEvent() using the saved
                                        AppSettings.glitchtipDsn
- /api/monitoring/umami/test          — admin-only; HTTPS-only GET against
                                        AppSettings.umamiScriptUrl, checks
                                        body for "umami" marker

Each route has a vitest suite covering: happy path, rate-limit denial,
missing config (422), and upstream-401 sanitisation (no sk-/Bearer/full
URL leak).

Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>

* fix(integration-tests): address review findings — singleton key, redirect SSRF, token refresh, scoped push

Multi-agent review caught a regression set on the five integration test
endpoints. Fixes here address every CRITICAL + HIGH finding:

- AppSettings is keyed on `id: "singleton"` everywhere else; the Glitchtip
  and Umami test routes were querying `id: "default"` and would silently
  return 422 in production. Tests now assert the where argument so the
  typo can't sneak back.
- moodLog and Umami fetch with `redirect: "manual"` to neutralise the
  redirect-follow SSRF (a public host could 302 to 169.254.169.254 or
  RFC1918 ranges); 3xx is now treated as a configuration error.
- Withings test goes through the existing `getValidToken()` helper, so an
  expired access token refreshes the same way the real sync does instead
  of reporting "credentials rejected" until the next scheduled sync.
- Glitchtip DSN is validated as HTTPS + public host before any outbound
  call, and the dispatch is wrapped in a 5 s `Promise.race` deadline.
- Withings dispatch picks up an `AbortController` 5 s deadline; the
  Withings API-level `status` field (HTTP 200 + `status !== 0`) is now
  categorised separately from the HTTP status (e.g. `status: 401` →
  credentials_rejected, `status: 601` → rate_limited).
- moodLog test does an authenticated GET against the actual sync
  endpoint instead of a bare HEAD on the configured URL — a HEAD on the
  bare URL would return 200 from a generic landing page even when the
  API key was invalid.
- Web Push test sends to the most recent subscription only (was: every
  registered subscription, so a user with three devices got three buzzes
  per click); tests assert findFirst+orderBy so this can't regress.
- Web Push response shape is the same shape on success and upstream
  failure (errors land in the meta envelope, not in the visible
  response body) — the prior dead-code in the 401 sanitisation test is
  gone.
- Every error response now carries an `errorCode` in `meta` (e.g.
  `credentials_rejected`, `rate_limited`, `dsn_host_not_public`) so the
  upcoming UI can localise via `t("settings.testConnection.errors." + code)`
  instead of rendering the server's English fallback.
- Outbound error annotations strip `Bearer <token>` and full URLs
  before they reach Wide Events / Loki — push providers in particular
  embed the subscription endpoint (which is the routing secret) in
  410/404 error messages.
- Umami response read is capped at 256 KB so a hostile public host
  cannot stream gigabytes through us; the marker check is tightened to
  tracker shapes (`window.umami`, `umami.track`, `"umami"`) so a 404
  page that says "Powered by Umami" no longer false-positives.
- `apiError` now optionally carries a `meta` object so error responses
  can include the `errorCode` without breaking the existing
  `{ data: null, error: <string> }` envelope.

477 tests green.

---------

Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
2026-05-08 13:40:22 +02:00
Marc-André Bombeck b5bd730ec2 test(auth): tighten native-token hash assertions, isolate hmac env, expand coverage scope (#136)
Three small follow-ups to the v1.3.3 audit:

- src/lib/auth/__tests__/hmac.test.ts now stubs API_TOKEN_HMAC_KEY via
  vi.stubEnv per test instead of mutating process.env in beforeAll, so the
  variable is restored cleanly after every case (and worker leaks under
  --no-isolate are gone).
- The two native-token issuance tests (password login + passkey login) now
  mock hashToken as (raw) => "hashed:" + raw and assert the persisted
  tokenHash equals hashToken(plaintext) instead of accepting any string.
  Catches a regression where the route would hash the wrong value.
- vitest.config.mts adopts coverage.include for the full src/ tree (the
  vitest 4 replacement for coverage.all=true) so untouched files surface
  with 0% rather than being silently omitted, and excludes the future
  tests/integration/** suite so unit-test runs stay fast.

No production code touched; suite stays at 454 green.
2026-05-08 13:22:46 +02:00
Marc-André BombeckandMarc-André Bombeck 725cace865 feat(admin): status-first card grid + aggregator endpoint (#133)
Replaces the implicit, fluid status of the legacy admin monolith with a
6-card grid at the top of /admin per ui-guidelines.md §3.4. Each card
pairs a §2.6 colored dot with a text label so state never relies on
color alone (WCAG 1.4.1).

- New aggregator endpoint /api/admin/status-overview returns a single
  batched Promise.all (no N+1) covering Users, Integrations, Monitoring,
  Backups, Maintenance, Audit log — with severity precomputed server-side.
- New <StatusCardGrid> + reusable <StatusBadge> component in
  src/components/admin/. Pure buildCards() function makes severity
  mapping unit-testable.
- Tests: 14 new (8 aggregator severity/shape, 6 grid badge + a11y).
- Wires the grid above the existing admin sections; per-section
  extraction will land in follow-ups.

Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
2026-05-08 12:47:21 +02:00
Marc-André BombeckandMarc-André Bombeck f4a2e13985 feat(v1.4): onboarding redesign — dashboard-first checklist, 3 focused steps, inline trend hints (#131)
Redesigns the first-run experience around three patterns we see in
Linear, Vercel and Notion: a short focused wizard, a persistent
getting-started checklist on the dashboard, and contextual hints
inside still-empty charts.

- Onboarding shrinks from 4 steps (one was marketing-only) to 3:
  About you (display name + locale + height + DOB + gender) → log
  your first measurement (real action via the existing form) → pick
  one notification channel (or skip).
- Every step except the first now has a Back button and exactly one
  Skip link with the honest "you can finish setup later from
  Settings" tail. The legacy duplicate Skip is removed.
- A new dashboard "Getting started" card lists the five quick wins
  (profile, first measurement, medication, Withings, notifications).
  Each row links to the right place. Items can be hidden one-by-one
  or the whole card can be dismissed; both states persist in
  localStorage. The card auto-hides once you finish onboarding and
  log your fifth measurement.
- Charts that have ≥1 but <5 readings now show a subtle inline hint
  ("First trend after 5 readings — N more to go") so the user knows
  the line will appear soon.
- Display name is captured during onboarding and saved to the
  profile.

All new strings are translated to English and German via the
existing i18n pipeline.

Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
2026-05-08 12:42:48 +02:00
Marc-André BombeckandMarc-André Bombeck 14cbc69a2d feat(insights): wire previous-analysis memory into the five remaining generators (#130)
Extends the v1.4 AI insights memory pattern established in PR #129
(general-status, blood-pressure-status) to the rest of the insight
surfaces. Each generator now reads its previous cached narrative via
`getPreviousInsightContext()` and injects the formatted block into the
user prompt so the model can call out improvements / regressions
relative to the user's last analysis instead of restating the status
from scratch every day.

Scopes wired in this commit:
- weight-status
- pulse-status
- bmi-status
- mood-status
- medication-compliance-status

No UI changes are needed — the insights card already renders the
comparison phrases the model emits. No schema change either; the
helper reads from the existing audit-log cache rows each generator
already writes to.

Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
2026-05-08 12:41:24 +02:00
Marc-André BombeckandMarc-André Bombeck 751448494b feat(insights): AI insights remember the previous analysis + hero finding card (#129)
The AI insights surface had two long-standing weaknesses:

1. **Every analysis read like the user's first one.** The general-status
   and blood-pressure prompts saw only the latest 30 days of data, not
   the previous analysis the user already read last week. So the model
   restated the same status every day instead of saying "your BP is down
   4 mmHg from your last check" or "same elevated trend as last week".

2. **All findings looked the same.** The card listed every finding in a
   uniform compact row. The model is instructed to put the most
   clinically relevant finding first, but the UI didn't honour that
   ordering with any visual weight.

This PR closes both for the two highest-impact insight surfaces (general
status and blood pressure). Same pattern lifts to the other five
generators in a follow-up.

**`src/lib/insights/memory.ts`** — new helper that reads the user's
previous cached insight from the existing `audit_logs` cache rows
(scope-keyed `insights.{scope}.{locale}`), returns it as a typed
`PreviousInsightContext`, and formats it into a locale-aware
prompt-injection block. Default 12-hour age floor keeps the model from
treating today's earlier run as "previous". The block ends with an
explicit comparison instruction in the user's locale; passing `null`
context produces a "first analysis — no delta to surface" instruction
so the model doesn't hallucinate one.

**Wired into `general-status` and `blood-pressure-status` generators.**
Both prompts now accept an optional `previousContextBlock` argument.
When supplied, the model gets the previous narrative + the comparison
instruction inline above the snapshot. No schema change — caches keep
writing to the same audit-log rows the helper reads from. Empty for
first-run users.

**Hero finding card.** Top finding gets a severity-tinted gradient
(green/cyan/orange/red), 2-px coloured left border, larger typography,
and a descriptive eyebrow label ("Top finding" / "Worth attention" /
"Top alert"). Secondary findings keep the previous compact layout so
the visual hierarchy reads at a glance. Eyebrow labels translated
under `insights.heroFinding{Positive,Neutral,Attention,Warning}`.

Tests: 400 → 410 (+10 for the memory helper covering JSON cache,
legacy plaintext fallback, age-floor semantics, locale-aware
formatting).

Follow-up PRs:

- Wire the same memory helper into the remaining five generators
  (weight, pulse, BMI, mood, medication-compliance). Pattern is
  mechanical — the prompt signatures already accept the new optional
  argument.
- Dynamic chart inclusion via an allowlist token map
  (`metric:BLOOD_PRESSURE_SYS` → `<HealthChart>` inline). Out of scope
  for this PR.

Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
2026-05-08 12:30:31 +02:00
Marc-André BombeckandMarc-André Bombeck f131d6be2c feat(api): public /api/version endpoint for the v1.4 About surface (#128)
Adds a public GET endpoint that exposes the running build's version,
optional Git SHA, optional ISO build timestamp, license, and
canonical links (repository, changelog, docs site). Three consumers
already lined up against this:

- The Settings → About section (v1.4 plan A3) renders version + build
  metadata + a "Check for updates" button.
- The footer of every page can show the version dezent without
  another roundtrip.
- The "Check for updates" button compares the returned version
  against the GHCR API.

Build-time env vars (`NEXT_PUBLIC_APP_BUILD_SHA`,
`NEXT_PUBLIC_APP_BUILT_AT`) are read straight off `process.env` —
the existing `docker-publish.yml` workflow can wire them once it
needs to. Local `pnpm dev` returns `null` for both fields and the UI
falls back to "development" wording.

Static export (`force-static`) so the endpoint adds zero load on the
DB and survives in the static-cache page chunks.

Tests: 395 → 400 (+3 for the version envelope shape and env-var
plumbing). The remaining About UI lands in a follow-up so this PR
stays scoped to the API contract.

Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
2026-05-08 12:17:40 +02:00
Marc-André BombeckandMarc-André Bombeck 760b2c9ba0 feat(dashboard): enforce one-row tile strip with horizontal-scroll overflow (#127)
The dashboard tile grid (`grid-cols-2 sm:grid-cols-2 lg:grid-cols-4
xl:grid-cols-5`) wrapped to a second row whenever the user enabled
more than four or five widgets. Marc's UX vision and the
`feedback_dashboard_one_row.md` memory are explicit: the tile strip
must read as a single status bar above the charts, with a total width
that caps at the chart width below — never two rows that look like an
Excel grid.

Replaces the wrapping grid with a `flex snap-x snap-mandatory
overflow-x-auto` strip:

- One row, always.
- Each tile keeps a sensible minimum width (`basis-[10rem]`) so a
  single tile still looks substantial on a wide viewport.
- `grow` makes the row fill its parent when fewer tiles are active —
  the visual hierarchy stays the same regardless of how many widgets
  are toggled on.
- When the active tile count exceeds the viewport, the strip
  horizontal-scrolls (with snap points so it feels deliberate on
  touch); the user's path to "fewer tiles" is the existing
  Settings → Dashboard Layout editor at `/settings#dashboard-layout`.
  No automatic wrap.
- `data-tile-count` attribute on the strip so a future
  Playwright screenshot test can assert the one-row constraint
  visually.

Tests stay at 395/395; this is a layout-only change.

A follow-up will surface a banner inside the layout editor that
visualises "this many tiles overflows the strip" — out of scope for
this PR per the v1.4 plan A5 split.

Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
2026-05-08 12:17:35 +02:00
Marc-André BombeckandMarc-André Bombeck 03845ef5eb fix(analytics): correct chart-math edge cases caught by the v1.4 audit (#126)
Three correctness issues in `summarize()` / `trendSlope()` /
`weeklyAverages()` that the discovery audit reproduced:

1. **`summarize` and `trendSlope` used different time anchors.** The
   averages (`avg7`, `avg30`) snapped to `Date.now()`, while the slopes
   (`slope7`, `slope30`, `slope90`) snapped to the latest point in the
   series. A stale series (no readings for weeks) reported a slope from
   old data even though the dashboard tile correctly hid the average.
   Both surfaces now use `Date.now()` as the cutoff, so a stale series
   returns `null` from every windowed stat consistently.

2. **`summarize([])` returned `{ min: 0, max: 0, mean: 0 }`.** Those
   sentinels leaked into chart axes (Y-axis collapsed onto zero) and
   into AI-prompt feature bundles where the generator treated the zero
   as a real reading. Empty input now returns `null` for all three
   fields. The `DataSummary` type widens accordingly; the dashboard
   already null-checks the optional fields, so no UI regression.

3. **`weeklyAverages()` used system-local `Date.getDay()` /
   `getDate()`.** On a UTC server (which is what the production
   container runs as) a Sunday-evening Berlin reading bucketed into
   the next week. Now the ISO-Monday key is computed via
   `Intl.DateTimeFormat({ timeZone: "Europe/Berlin" })`, so the bucket
   lines up with how the user perceives "this week".

`pairByTimestamp` keeps the greedy nearest-match heuristic — adding
JSDoc that documents the limitation and the conditions under which a
Hungarian-style match would matter (sparse health data is well below
that bar).

Tests: 371 → 384 (+13). Two new direct regression tests:

- `weeklyAverages` Sunday-evening-Berlin bucket goes to the *previous*
  Monday.
- `summarize` with all readings 100 days old returns `null` for every
  windowed stat (no false trend on stale data).

Plus tightened the empty-summarize assertions.

Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
2026-05-08 12:14:26 +02:00
Marc-André BombeckandMarc-André Bombeck f9ff450e08 perf(insights): batch the medication-compliance and per-type latest queries (#125)
Two N+1 patterns the v1.4 discovery audit caught — the same shape v1.3
fixed in /api/insights/comprehensive but in adjacent code paths:

1. **`extractFeatures` (used by every AI-insight route)** issued one
   `prisma.medicationIntakeEvent.findMany` per active medication,
   in-loop. Replaced with a single windowed query
   (`medicationId IN (...)` + `scheduledFor >= 90 days`) and an
   in-memory Map group, mirroring the V3 fix in `/api/insights/
   comprehensive`. Latency now scales with total recent intakes, not
   `medications × intakes-per-medication`. The 90-day cutoff matches
   the longest window `calculateCompliance` consults.

2. **`/api/insights/targets`** issued one `prisma.measurement.findFirst`
   per `MeasurementType` (×~10) to fetch each type's most recent
   value. Replaced with a single `findMany({ distinct: ["type"],
   orderBy: { measuredAt: "desc" } })`. One round-trip instead of N.

Behaviour identical, just fewer round-trips. Tests stay at 371/371.

Note: the eager Recharts import in `app/insights/page.tsx` (the audit
also flagged it as a bundle bloat that neutralises the existing
`dynamic()` wrappers) is its own refactor — every ScatterChart usage
inside the page needs to be lifted into its own component before
dynamic-loading helps. Tracked for a follow-up perf PR so this one
stays scoped to query-side wins.

Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
2026-05-08 12:11:57 +02:00
Marc-André BombeckandMarc-André Bombeck a6524721da fix(i18n): localise medication reminders and dashboard streak per user (#124)
Closes the v3 audit "Locale Mixing" CRIT plus three of the cousin
findings the v1.4 discovery audit added:

1. **`getPhaseMessage` and `getPhaseKeyboard` were German-only.** Every
   medication reminder shipped via Telegram, ntfy, and Web Push read
   "Erinnerung", "Bald fällig", etc. regardless of the recipient's
   locale. This is the user-facing surface most affected by the v3
   "Locale Mixing" finding because the strings hit users on a 15-minute
   cron, not just an admin / settings page they might never open. The
   reminder worker now passes `med.user.locale` through; messages and
   button labels resolve from `messages/{de,en}.json` under
   `medicationReminders.*`. `callback_data` keys stay stable English
   identifiers so the bot dispatcher keeps matching them across locale
   changes.

2. **`/api/dashboard/summary` ships a hard-coded "Tage in Folge" label
   and "Hi, X" salutation.** Both now come from
   `dashboard.streakLabel` / `dashboard.greetingSalutation` resolved
   server-side against the user's locale.

3. **Mixed-locale Zod messages.** `validations/measurement.ts:69` had
   "Wert muss zwischen … liegen" while line 86 had "Value out of
   plausible range" — the response envelope leaked German into English
   sessions and vice versa. `validations/admin.ts` had four German
   messages (web-push subject, Umami URL, Glitchtip DSN, GitHub
   bug-report repo). All now resolve to a single English wording (the
   app is English-first; the German UI translates the field labels
   client-side, the server-side validation message is treated as a
   developer-facing string).

Adds 13 new tests in `reminder-phases.test.ts` covering English,
German, and unknown-locale fallbacks for messages and keyboards;
updates `validations/measurement.test.ts` to match the unified English
message. The audit's remaining items
(`classifications.ts` server-returned EN category strings,
`pulse-targets.ts`, `targets/route.ts` labels,
`/api/admin/notifications/reminder-check`) ride a follow-up i18n PR —
those are admin-only or client-renderable surfaces with lower
user-facing priority.

Tests: 358 → 371 (+13). Typecheck clean. Locale-integrity test still
green.

Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
2026-05-08 12:11:44 +02:00
Marc-André BombeckandMarc-André Bombeck 8adeb59396 v1.4 medical-safety: protect against silent DBP hypotension, align BP & step citations, ACE body-fat consolidation (#123)
* fix(medical): protect against silent DBP hypotension and align BP citations

Three correctness issues that the v1.3.3 ecosystem audit surfaced and
that we deferred until the v1.4 cycle:

1. **Diastolic blood-pressure orange band reached 60 mmHg.** With the
   default age-based targets (DBP 70-79), the lower orange wing was
   computed as `diaLow - 10 = 60`, putting a sustained reading of
   60 mmHg into "mildly low" territory — visually a yellow tile, not a
   red one. 60 mmHg is widely treated as a hypotension / perfusion-risk
   threshold (general-adult <90/60 from AHA/NIH; J-curve evidence in
   ONTARGET / TRANSCEND for treated hypertensives; ESH 2023's explicit
   caution against treating below 70 in the elderly). The orange floor
   is now clamped at 65 mmHg, so a reading of 60 lands in red. The
   user-override path stays untouched and is already audit-logged.

2. **"ESC/ESH 2023" doesn't exist as a joint guideline.** ESC withdrew
   from the 2023 authoring; the 2023 hypertension document is ESH-only.
   The codebase had a mix of "ESC/ESH 2018" (analytics) and
   "ESC/ESH 2023" (AI prompts). Both are wrong in different directions.
   Every site (`bp-targets.ts`, `targets/route.ts`,
   `ai/prompts/blood-pressure.ts`, `ai/prompts/base-system.ts`) now
   cites "ESH 2023" with the published source URL. Target bands are
   unchanged 2018 → 2023 — only the citation moves.

3. **"WHO ≥ 8 000 steps/day" was a recurring hallucination.** WHO's
   2020 Physical Activity guidelines publish minutes per week, not a
   step quota. The cohort study HealthLog actually relies on is
   Saint-Maurice et al., JAMA 2020 (mortality plateau 8 000–12 000
   steps). The v1.3.3 fix landed in `effective-range.ts` but four AI
   prompt strings (`base-system.ts` DE+EN, `general-status.ts` DE+EN)
   carried the old wording forward. Same fix shape: cite Saint-Maurice
   for the step number and add an explicit "WHO does not publish a
   step quota" note so the model doesn't regress. Sleep target moves
   from "ESC" to AASM 2015 in the same surfaces — ESC publishes no
   adult sleep duration guideline.

Adds `src/lib/medical-citations.ts` as the new single source of truth
for cited guidelines (id, name, year, url, caveat). v1.4 surfaces will
import these constants instead of duplicating strings, and the new
`medical-citations.test.ts` drift-test asserts that every entry has a
non-empty url + caveat, that ids are unique, and that the WHO-as-step-
source hallucination cannot reappear as a constant.

Tests: 358 → 363 (5 new for the master citations list). Typecheck and
existing analytics tests pass. No clinical numbers changed beyond the
DBP orange-floor clamp.

Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com>

* fix(medical): consolidate body-fat ACE bands and document bedtime-glucose source

Two related medical-truthfulness issues from the v1.3.3 ecosystem audit.

**Body-fat classification: ACE bands corrected.** v1.3.3's classifier
used `essential = 6 (M) / 14 (F)` as the *floor* of the "Essential"
category, which is actually ACE's *Athletes* lower bound. Anything
below was lumped under "Essential" — including readings (e.g. 1% male)
that are clinically concerning, not just "athletically extreme". The
classifier now mirrors the ACE table precisely:

- Below essential (M <2 / F <10): danger.
- Essential (M 2–5 / F 10–13): warning.
- Athletic (M 6–13 / F 14–20): normal.
- Fitness (M 14–17 / F 21–24): normal.
- Acceptable (M 18–24 / F 25–31): warning.
- Obese (M ≥25 / F ≥32): danger.

**Three-way drift in body-fat target ranges consolidated.** v1.3.3 had
three sites with three different green-band numbers:

- `value-bands.ts:118-120` returned M{10,20} / F{18,28}.
- `app/api/insights/targets/route.ts:336-338` hardcoded M{14,24} /
  F{21,31} with `source: "ACE"`.
- `classifications.ts:155-205` used a third overlapping set of
  thresholds for the band labels.

ACE fitness + acceptable (M 14–24 / F 21–31) is the realistic healthy
band for a non-athlete adult and is now the single source in
`getBodyFatTargetRange`. Targets route and chart value-bands import the
helper instead of duplicating the numbers.

**Bedtime glucose citation softened.** ADA Standards of Care 2024 §6
publishes pre-prandial 80–130 and post-prandial <180; the document does
NOT publish an adult bedtime target. The 90–150 mg/dL band HealthLog
ships is reasonable advice (within the daytime pre-prandial range,
slightly raised floor to avoid nocturnal hypoglycaemia) but it's not
ADA-published. The inline comment in `effective-range.ts` now states
the absence of an adult guideline explicitly and cites ISPAD 2022
(pediatric T1D, 80–140) as the closest comparator. Numbers unchanged.

Tests: 363 → 369 (6 new for the ACE bands). Existing assertions
unchanged.

Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com>

* fix(medical): align step-range target with the effective-range source

`getStepsRange()` in classifications.ts returned `{7 000, 10 000}`
while `effective-range.ts` (the canonical resolver behind
`getEffectiveRange`) returned `{8 000, 15 000}`. The two surfaces
showed different "green" bands to the same user — `/api/insights/
targets` used the lower pair, the dashboard chart-bands used the
upper pair.

Aligns both on the upper pair, anchored on Saint-Maurice et al. JAMA
2020 (mortality plateau 8 000–12 000 steps/day). Adds the citation
header to the steps section so the next audit pass can verify in
place. WHO is explicitly called out as NOT a source for a step quota
(only an activity-time guideline).

The classifier function `classifySteps` is unchanged — its 3 / 5 / 7.5
/ 10 k bands match Tudor-Locke 2004 pedometer indices and are kept
distinct from the two-number "target range" helper.

Tests stay at 369/369.

Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com>

---------

Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
2026-05-08 12:07:46 +02:00
Marc-André BombeckandMarc-André Bombeck 359d4d19cc v1.4 foundation: UI guidelines, primitives, AI test fix, autofill hardening (#122)
* docs(ui): add UI guidelines as single source of truth + shadcn MCP server

- `docs/ui-guidelines.md` lays out Dracula tokens, spacing, typography,
  component picks (button/input/dialog/sheet decision tree), layout
  rules (settings split target, dashboard one-row), accessibility
  baseline (WCAG 2.1 AA + axe-core in CI), the autofill / honeypot
  pattern for measurement forms, and a reusable "Test button" UX for
  every integration. New components in v1.4 reference this doc.

- `.mcp.json` wires the official `shadcn` MCP server so the design
  system can be browsed and components installed without leaving the
  editor.

- Adds `@vitest/coverage-v8` to dev dependencies — `pnpm test
  --coverage` now reports real per-file coverage (the v1.4 cycle uses
  this to lift critical-path coverage).

Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com>

* fix(settings/ai): connection-test honours the unsaved provider selection

Changing the AI provider in /settings, then clicking "Verbindung testen"
without saving first, used to silently run the test against the *saved*
provider instead — so the result felt unrelated to what the user had on
screen. This often surfaced as a confusing OK / failure depending on
whichever provider had previously been persisted.

The test endpoint now accepts an optional JSON body that mirrors the
form fields (provider, model, baseUrl, anthropicKey, localKey). When
present, the server resolves a transient provider from those values —
falling back to the persisted, encrypted credentials per field, so a
user with a stored Anthropic key can swap models in the dropdown
without re-typing the key. Plaintext keys never persist; the existing
SSRF guard for the Local provider's base URL is reused, the existing
rate limit (5/min) and the V3 error-leak shielding stay in place.

Empty body keeps the previous behaviour for any other caller.

Tests cover the new override path (provider forwarded, unknown
provider rejected with 422, empty body uses persisted config, config
errors surface the resolver's status). Whitespace in
`src/app/settings/page.tsx` is reformatted by Prettier as a
side-effect; logic touches only the AI section.

Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com>

* feat(ui): add Skeleton and EmptyState primitives

Two pieces every page in v1.4 needs and the codebase didn't have:

- `<Skeleton>` — a presentation `<div>` with the standard pulse
  animation, `aria-hidden`, and `motion-reduce:animate-none`. Used to
  shape the page while data loads instead of dropping a spinner. The
  visible layout no longer jumps when the first measurement arrives.
- `<EmptyState>` — the shape every list, tile, and chart panel
  reaches for when it has no data: optional icon, title, description,
  primary action; `card` (default) and `plain` variants;
  `default`/`compact` density. Lives inside an `aria-live="polite"`
  region so screen readers announce the message and its action.

Tests cover screen-reader semantics, prettier-merged class names,
density/variant switches.

These two land first because the v1.4 pages — the split settings
sections, the new dashboard hero, the redesigned admin status grid —
all assume the primitives exist. The patterns are documented in
`docs/ui-guidelines.md` §4.1–4.2.

Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com>

* fix(ui): block password-manager autofill on health-data inputs by default

Browsers and password managers were happily autofilling the user's
account credentials into measurement, medication, mood, and AI-token
fields whenever those forms didn't explicitly set `autoComplete`. The
codebase had zero `autoComplete` attributes across the data-entry
surfaces, so this happened often enough to be both a UX papercut
(account password landing in a "Notiz" field) and a real privacy issue
(secrets persisting through CSV/JSON export).

The base `<Input>` primitive now:

- Defaults `autoComplete` to `"off"` when the caller doesn't supply a
  value.
- Adds `data-lpignore="true"` and `data-1p-ignore="true"` whenever
  autoComplete resolves to `"off"`, so LastPass and 1Password skip
  the field too.
- Keeps the password manager active for any form that already passes a
  semantic `autoComplete` (`"username"`, `"email"`,
  `"current-password"`, `"new-password"`) — the existing login,
  register, and AI-key inputs continue to work unchanged.

Tests cover the three meaningful states (default off, explicit
semantic value, login-flow `username`).

Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com>

---------

Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
2026-05-08 12:05:15 +02:00
197 changed files with 26706 additions and 8256 deletions

No files matched your search

+38
View File
@@ -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
+83
View File
@@ -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
+34
View File
@@ -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
+5
View File
@@ -11,6 +11,11 @@
# testing
/coverage
# playwright e2e
/test-results
/playwright-report
/playwright/.cache
# next.js
/.next/
/out/
+8
View File
@@ -0,0 +1,8 @@
{
"mcpServers": {
"shadcn": {
"command": "npx",
"args": ["-y", "shadcn@latest", "mcp"]
}
}
}
+25 -11
View File
@@ -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
View File
@@ -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
+8 -2
View File
@@ -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
View File
@@ -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
+76 -54
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+232
View File
@@ -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.
+87
View File
@@ -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).
+107
View File
@@ -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.
+6
View File
@@ -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";
+260
View File
@@ -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.
+53
View File
@@ -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.
+464
View File
@@ -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._
+44
View File
@@ -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);
});
}
});
+34
View File
@@ -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();
});
});
+49
View File
@@ -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);
}
});
}
});
+53
View File
@@ -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 });
});
});
+30
View File
@@ -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
View File
@@ -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 &lt;Name&gt; — 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
View File
@@ -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 &lt;name&gt; — 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
View File
@@ -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"
+65
View File
@@ -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",
},
});
+2326 -21
View File
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;
+42
View File
@@ -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 {
+128
View File
@@ -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);
});
+246
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+36
View File
@@ -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);
});
});
+217
View File
@@ -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" });
});
});
+116 -11
View File
@@ -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");
});
});
+47 -4
View File
@@ -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);
}
+9 -9
View File
@@ -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;
}
+61 -9
View File
@@ -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;
}
+63 -9
View File
@@ -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,
+101
View File
@@ -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(),
});
});
+13 -6
View File
@@ -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,
+38 -24
View File
@@ -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/);
});
});
+129
View File
@@ -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 }],
});
}
});
+5
View File
@@ -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");
});
});
+39
View File
@@ -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",
});
});
+12 -7
View File
@@ -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);
});
});
+1 -1
View File
@@ -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;
+57 -49
View File
@@ -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}
>
+1 -1
View File
@@ -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>
+100
View File
@@ -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
View File
@@ -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
View File
@@ -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>
))}
</>
);
})()}
+76
View File
@@ -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
View File
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");
});
});
+276
View File
@@ -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>
);
}
+109
View File
@@ -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>
);
}
+172
View File
@@ -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>
);
}
+318
View File
@@ -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>
);
}
+68
View File
@@ -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>
);
}
+258
View File
@@ -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>
);
}
+173
View File
@@ -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.");
});
});
+41
View File
@@ -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");
});
});
+263 -86
View File
@@ -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>
+21 -9
View File
@@ -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",
)}
+1 -1
View File
@@ -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