mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9569d1c528 | ||
|
|
45fb909c16 | ||
|
|
f05354796a | ||
|
|
461c1cfe09 | ||
|
|
273ead0304 | ||
|
|
7386630962 | ||
|
|
51d42f9422 | ||
|
|
3e917969c7 | ||
|
|
4852421f05 | ||
|
|
ea71ba2f37 | ||
|
|
0ba5334b57 | ||
|
|
8b1c5deac7 | ||
|
|
7a6598f7c3 | ||
|
|
82aa66b400 | ||
|
|
a614542cc3 | ||
|
|
132c113305 | ||
|
|
9bbd8a89dc | ||
|
|
136653e3d7 | ||
|
|
1e8d796c57 | ||
|
|
5445fc67ea | ||
|
|
a074286951 | ||
|
|
14a9a08a6e | ||
|
|
0b7d07dce4 | ||
|
|
bdae462948 | ||
|
|
bb1008f1a1 | ||
|
|
ac1105751d | ||
|
|
6d8fe06148 | ||
|
|
b57c517929 | ||
|
|
7592c73947 | ||
|
|
9b8b31c864 | ||
|
|
fa326c398a | ||
|
|
5b7ea48c46 | ||
|
|
f815e4a83f | ||
|
|
6712619495 | ||
|
|
a70139c16a | ||
|
|
c41ca511cb | ||
|
|
9ba34befce | ||
|
|
a21dc50cab | ||
|
|
4dfb207a6d | ||
|
|
14b61e1084 | ||
|
|
1375d20a9d | ||
|
|
85267f15b3 | ||
|
|
a4feabec81 |
No files matched your search
+31
-3
@@ -29,10 +29,11 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Withings OAuth -- optional integration
|
||||
# Withings -- optional integration
|
||||
# -----------------------------------------------------------------------------
|
||||
# WITHINGS_CLIENT_ID=""
|
||||
# WITHINGS_CLIENT_SECRET=""
|
||||
# The Withings OAuth client id/secret are per-user: each user registers their
|
||||
# own Withings developer app and pastes the pair into Settings (stored
|
||||
# encrypted in the DB). There is therefore no WITHINGS_CLIENT_ID/SECRET.
|
||||
# WITHINGS_WEBHOOK_SECRET=""
|
||||
|
||||
|
||||
@@ -102,6 +103,33 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
# STRAVA_CLIENT_SECRET=""
|
||||
# STRAVA_REDIRECT_URI=""
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# OIDC SSO login -- optional (self-hosted identity provider)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Register a confidential OIDC client for your IdP (Authentik, Keycloak,
|
||||
# Authelia, Google Workspace, etc.) with redirect URI
|
||||
# `${NEXT_PUBLIC_APP_URL}/api/auth/oidc/callback`. All three of ISSUER_URL/
|
||||
# CLIENT_ID/CLIENT_SECRET together enable the "Sign in with SSO" button on
|
||||
# the login page; set none to leave it disabled. First-time sign-in
|
||||
# auto-provisions (or links, by verified email) a HealthLog account. OIDC_ONLY
|
||||
# hides password/passkey login entirely once set to "true" -- but only takes
|
||||
# effect when the three vars above are also fully set, so a typo can't lock
|
||||
# every user out. Enforced server-side, not just a hidden button. The native
|
||||
# iOS client authenticates via password/passkey only -- there is no OIDC
|
||||
# flow for it yet -- so OIDC_ONLY="true" locks the iOS app out of sign-in
|
||||
# entirely until a native SSO flow ships. Do not enable it on a deployment
|
||||
# with active iOS users.
|
||||
# Serve the issuer over HTTPS wherever possible: sign-in material (the
|
||||
# authorization code, the token-endpoint response carrying the ID token)
|
||||
# crosses this connection. Plain HTTP is defensible only on a fully trusted
|
||||
# private network (IdP and app on the same box, a Tailscale mesh, a VPN).
|
||||
# OIDC_ISSUER_URL=""
|
||||
# OIDC_CLIENT_ID=""
|
||||
# OIDC_CLIENT_SECRET=""
|
||||
# OIDC_SCOPES="openid email profile"
|
||||
# OIDC_BUTTON_LABEL="Single Sign-On"
|
||||
# OIDC_ONLY="false"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Web Push -- VAPID keys (optional; the admin panel is the easier path)
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Targets the `develop` branch (not `main` — see [CONTRIBUTING.md](../CONTRIBUTING.md))
|
||||
- [ ] Targets the `main` branch (trunk-based — see [CONTRIBUTING.md](../CONTRIBUTING.md))
|
||||
- [ ] `pnpm typecheck` passes locally
|
||||
- [ ] `pnpm lint` passes locally
|
||||
- [ ] `pnpm test` passes locally
|
||||
|
||||
@@ -98,7 +98,7 @@ jobs:
|
||||
# Image is built but not pushed in that case.
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -293,7 +293,7 @@ jobs:
|
||||
echo "IMAGE_REF=${REGISTRY}/${IMAGE_NAME,,}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
+191
@@ -2,6 +2,197 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.28.41] — 2026-07-16 — Complete the translations, and guard the gap
|
||||
|
||||
One reminder-adjacent label — the recovery-driver "functional impact" track on
|
||||
the illness insights card — was showing its internal name instead of a
|
||||
translation in every language; it is now translated in all six. The class of
|
||||
bug behind it (a label resolved through a computed key the static check can't
|
||||
see, so a missing translation ships silently — the same seam as the well-being
|
||||
labels fixed a release ago) is now closed structurally: a new test walks each
|
||||
such key space against every language bundle, so a missing translation fails
|
||||
the build instead of reaching a user.
|
||||
|
||||
## [1.28.40] — 2026-07-16 — Insight assessments lead with meaning, not a number
|
||||
|
||||
The per-metric insight assessments read like a data readout — they opened on
|
||||
the current value. The overview texts already lead with the day's verdict in
|
||||
plain words and land the warm, motivating tone; the per-metric cards were told
|
||||
the opposite (name the level with a number first) and never received the
|
||||
opener-variation the other insight surfaces use. Now every metric assessment
|
||||
opens with what the reading MEANS and brings the number in right after as
|
||||
support, in the same warm register as the overview. A shared opening-shape
|
||||
contract keeps the surfaces from drifting apart again, and the deterministic
|
||||
first-paint fallbacks lead with the verdict too. No change to what the numbers
|
||||
say — only how the sentence is built.
|
||||
|
||||
## [1.28.39] — 2026-07-16 — Sync data-loss hardening
|
||||
|
||||
Four fixes to sync paths that could lose or corrupt data in edge cases:
|
||||
|
||||
- The nightly pass that folds raw cumulative samples (steps, energy, distance)
|
||||
into daily totals no longer re-counts a sample you had deleted — it was the
|
||||
one such pass missing the deleted-row filter its siblings all carry.
|
||||
- That same pass is now safe when two runs overlap (a deploy-timing overlap, or
|
||||
a manual admin trigger racing the nightly job): an advisory lock plus an
|
||||
in-transaction re-read make the fold idempotent, so an overlap can no longer
|
||||
permanently double a day's total.
|
||||
- A re-scored Withings sleep segment (its end time shifts) now updates the
|
||||
existing row in place instead of colliding with it and wedging that night —
|
||||
the same re-key rescue the other providers already carry.
|
||||
- Withings measurement sync now holds its position when a reading fails to
|
||||
write, instead of advancing past it and stranding the reading; the next sync
|
||||
retries it.
|
||||
|
||||
## [1.28.38] — 2026-07-16 — Documents polish, waist reminders, bulk sharing
|
||||
|
||||
The document vault gets a round of overdue polish. The list cards drop a
|
||||
stray glyph — the "read by AI" mark it carried moves into the document's
|
||||
detail view — and give the real filename its own line so it stops being cut
|
||||
off; the detail view's close button is aligned correctly (a shared sheet
|
||||
primitive was reserving space for a button it wasn't showing). The action
|
||||
that opens a document in the coach now reads "Coach fragen".
|
||||
|
||||
From a multi-selection in the overview you can now share several documents
|
||||
at once — one link holding the selection, with the same documents-only
|
||||
privacy as a single share (up to 50 per link).
|
||||
|
||||
Reminders can now be linked to waist circumference. And the well-being
|
||||
reminder types (WHO-5, sleep-condition) that were showing their internal
|
||||
names instead of a label are now translated in every language.
|
||||
|
||||
## [1.28.37] — 2026-07-15 — Provider step totals survive a restart
|
||||
|
||||
A boot-time maintenance pass that folds genuinely old raw step samples into
|
||||
daily totals had too broad a reach: it also matched the daily step totals that
|
||||
Google Health, Fitbit, and Withings write, and soft-deleted them on every
|
||||
worker start. Recent days came back on the next sync, but older days stayed
|
||||
gone until a full re-sync — which the next restart swept again. The pass now
|
||||
skips any row that is already a daily total and only ever touches the ingest
|
||||
paths that produced real legacy raw samples, so a provider's step history is
|
||||
left alone. A one-time repair restores the totals removed by earlier restarts
|
||||
(within the retention window; a single full sync recovers anything older) and
|
||||
cleans up the placeholder totals the bug minted in their place.
|
||||
|
||||
## [1.28.36] — 2026-07-15 — Re-importing after a failed Apple Health import actually retries
|
||||
|
||||
The v1.28.33 rollup and staging fixes were correct but never reached the
|
||||
reporter's case: the upload deduplicates by content hash, and the lookup
|
||||
matched a previously FAILED job too — so re-uploading the same export.zip
|
||||
returned the old failed job and replayed its stale error without ever running
|
||||
the import again. The dedup now ignores failed jobs; the same file retries
|
||||
cleanly. A redundant staged upload is also cleaned up on a dedup hit instead
|
||||
of lingering in the staging directory.
|
||||
|
||||
Two related hardenings: the boot-time reconcile that marks interrupted imports
|
||||
failed now performs a real liveness check (queue state plus a progress
|
||||
heartbeat) instead of unconditionally failing every in-flight job — so a split
|
||||
or multi-worker deployment no longer kills an import running in another worker.
|
||||
And the "staging file not found" message now names the separate-container cause
|
||||
explicitly, with a matching note in the scaling guide.
|
||||
|
||||
## [1.28.35] — 2026-07-14 — Timestamps honor the profile timezone
|
||||
|
||||
Client-rendered times previously fell back to a hardcoded display zone
|
||||
(Europe/Berlin) no matter what the profile timezone said; a user in Manila saw
|
||||
Berlin clock times on every card, chart, and table. Display now follows the
|
||||
profile timezone everywhere, with a strict fallback to the legacy display zone
|
||||
— never the browser zone, so screen, PDF, and export can no longer disagree.
|
||||
An invalid stored timezone can never break rendering; it falls back safely.
|
||||
|
||||
The same pass closed the latent inconsistencies the audit surfaced: the
|
||||
"Updated today" cards no longer mix one zone for the day boundary and another
|
||||
for the clock; calendar-date fields no longer shift a day for profiles west of
|
||||
UTC; the dose-history day headings and weekday labels render in the display
|
||||
zone; chart day/month labels stay correct for every zone (bucket math is
|
||||
untouched); and the clinician share view renders in the patient's timezone
|
||||
instead of the server container's. Day groupings, schedules, and aggregates
|
||||
were already timezone-correct server-side and are byte-identical.
|
||||
|
||||
## [1.28.34] — 2026-07-14 — Nutrient intake lands as quiet context (server side)
|
||||
|
||||
Supplement-style intake written to Apple Health by nutrition apps — vitamins,
|
||||
minerals, water, caffeine — can now sync to HealthLog as daily totals. This is
|
||||
deliberately not a nutrition feature: no food diary, no calorie tracking, no
|
||||
dashboard tile. The data lands in a dedicated store (26 nutrients with EFSA
|
||||
reference values), is visible on a read-only card under Settings → Sources,
|
||||
and will feed the Coach as context in a later release.
|
||||
|
||||
The module is opt-in and off by default; with it off, the server refuses the
|
||||
data entirely. Re-posting a day replaces it, units are guarded against
|
||||
µg/mg mix-ups, and implausible values are skipped per entry. This release is
|
||||
inert until the iOS companion app ships its reading side (coordination ticket
|
||||
filed); the wire contract is in the OpenAPI spec.
|
||||
|
||||
## [1.28.33] — 2026-07-14 — Re-importing a cumulative Apple Health export works again
|
||||
|
||||
Re-uploading a fresh export.zip over existing data could fail with a duplicate-key
|
||||
error in the rollup tier: a concurrent recompute (a live sync, the nightly drain)
|
||||
racing the import's full fold could collide on the same aggregation bucket — and
|
||||
the losing side silently dropped its freshly computed aggregate, leaving a stale
|
||||
bucket. Rollup writes are now conflict-safe upserts with deterministic lock
|
||||
ordering: a race can neither fail the import nor lose an aggregate, and buckets
|
||||
left stale by earlier races self-heal on their next recompute.
|
||||
|
||||
The misleading error is gone too. A long import could outlive its queue timeout;
|
||||
the redelivery re-opened the already-consumed staging file and overwrote the real
|
||||
failure — or even a successful completion — with a raw ENOENT. Import jobs now
|
||||
finish terminally (no redelivery after the staging file is consumed, six-hour
|
||||
timeout), a delivery for an already-finished import is a no-op, and a genuinely
|
||||
missing staging file reports an honest "upload the export again" message.
|
||||
|
||||
## [1.28.32] — 2026-07-12 — Fitbit: re-keyed re-imports can no longer be silently dropped
|
||||
|
||||
Single-fix release for the Fitbit integration, mirroring the Google Health
|
||||
fix from v1.28.28. The measurements table carries a second unique index on
|
||||
the natural key (type, time, source, sleep stage) that also covers
|
||||
soft-deleted rows; when an import carries new external ids for rows that
|
||||
were previously removed — the re-keyed re-import case — the insert collided
|
||||
with the old row and was dropped without a trace. Planned inserts now get a
|
||||
second probe by natural key: a match, live or removed, is updated in place
|
||||
with the fresh value and the new id instead. If the probe itself fails, the
|
||||
sync reports the failure and holds its watermark so the next run retries —
|
||||
nothing is lost either way. No other code path is touched in this release.
|
||||
|
||||
## [1.28.31] — 2026-07-12 — Heart-rate history keeps its daily shape
|
||||
|
||||
Long-term retention for the dense intra-day signals (heart rate, HRV, blood
|
||||
oxygen) now folds to hourly means instead of a single daily mean. The last
|
||||
14 days keep every raw sample as before; older days keep up to 24 points —
|
||||
enough to see how a day actually went, at roughly nine thousand rows per
|
||||
metric per year instead of an unbounded raw stream.
|
||||
|
||||
Because the old fold soft-deleted rather than erased, a one-time rebuild
|
||||
reconstructs the hourly shape for every already-folded day from the retained
|
||||
raw samples, then retires the old daily row in the same transaction — no
|
||||
reader can ever double-count a day. The rebuild runs automatically once per
|
||||
installation after the update; days whose raw samples are no longer
|
||||
available keep their daily mean. Daily min/max/mean statistics captured at
|
||||
fold time stay untouched throughout.
|
||||
|
||||
## [1.28.30] — 2026-07-12 — The daily briefing stops silently skipping days
|
||||
|
||||
The recurring "no briefing today" had a chain of causes, now closed end to end.
|
||||
Every failure path in the nightly generation names its cause in the logs
|
||||
(several classes previously failed without a trace). A failed night retries
|
||||
itself about forty-five minutes later instead of waiting for the next night.
|
||||
A cached insight payload that carries no briefing no longer counts as fresh —
|
||||
previously it could satisfy the daily window and even the unchanged-data check,
|
||||
so a once-stripped briefing could block its own regeneration indefinitely. And
|
||||
the briefing card now reports persistently why a briefing is absent (withheld
|
||||
by the number check, generation failed, or not yet attempted) with a retry
|
||||
button that always generates.
|
||||
|
||||
Documentation corrections, verified against the code: Withings credentials are
|
||||
per-user in the app (the documented environment-variable path never existed —
|
||||
and the webhook secret is now actually passed through in the compose file);
|
||||
the classic Fitbit portal no longer accepts new registrations (new users:
|
||||
Google Health); the backup-restore example no longer generates a fresh
|
||||
encryption key (which could never decrypt an existing backup) and uses the
|
||||
invocation that works in the production image; the certificate-pinning guide
|
||||
now describes the real model (two CA-level pins, leaf renewals need no app
|
||||
build); scaling and admin-endpoint references corrected.
|
||||
|
||||
## [1.28.29] — 2026-07-11 — Dashboard edits show up on the way back; one scrollbar again
|
||||
|
||||
Changing the dashboard tile selection now shows up immediately when you
|
||||
|
||||
@@ -165,6 +165,7 @@ The native SwiftUI client lives in a separate repository and rides public beta v
|
||||
- The `clientManaged` opt-in on `PATCH /api/auth/me/notification-prefs` suppresses server-side `MEDICATION_REMINDER` APNs for clients that manage their own local reminders. The cron skips dose-due APNs when the flag is `true` and emits a `medication_reminder.suppressed_client_managed` wide-event annotation per skip.
|
||||
- `push_attempts` table holds the last 90 days of APNS / Web-Push / Telegram / NTFY delivery attempts (channel + eventType + result + reason + createdAt). The admin diagnostic endpoint at `/api/admin/notifications/diagnostic` surfaces masked device tokens + channel state + last 20 attempts for the calling user.
|
||||
- Any request / response schema change must regen `docs/api/openapi.yaml` and commit the result. CI fails on drift.
|
||||
- The iOS app authenticates via password/passkey only (`src/lib/auth/native-client.ts`) — there is no OIDC flow for native clients yet. `OIDC_ONLY=true` is enforced server-side on those same routes, so enabling it locks every iOS user out of sign-in until a native SSO flow ships. Don't recommend it to an operator running the iOS app.
|
||||
|
||||
## DO-NOTs
|
||||
|
||||
|
||||
@@ -81,6 +81,7 @@ services:
|
||||
# docker-compose reads .env for `${VAR}` substitution at compose-up time).
|
||||
SESSION_COOKIE_SECURE: "${SESSION_COOKIE_SECURE:-}"
|
||||
NATIVE_CANVAS: "${NATIVE_CANVAS:-}"
|
||||
WITHINGS_WEBHOOK_SECRET: "${WITHINGS_WEBHOOK_SECRET:-}"
|
||||
# v1.18.11 (W3) — client-IP resolution + geo lookup. Behind Cloudflare
|
||||
# the real visitor IP is in `cf-connecting-ip`; set
|
||||
# TRUST_CF_CONNECTING_IP=1 ONLY when Cloudflare is in front (off by
|
||||
@@ -141,6 +142,21 @@ services:
|
||||
STRAVA_CLIENT_ID: "${STRAVA_CLIENT_ID:-}"
|
||||
STRAVA_CLIENT_SECRET: "${STRAVA_CLIENT_SECRET:-}"
|
||||
STRAVA_REDIRECT_URI: "${STRAVA_REDIRECT_URI:-}"
|
||||
# OIDC SSO login (self-hosted IdP: Authentik/Keycloak/Authelia/etc).
|
||||
# All three of ISSUER_URL/CLIENT_ID/CLIENT_SECRET together enable the
|
||||
# "Sign in with SSO" button; set none to leave it disabled. OIDC_ONLY
|
||||
# hides password/passkey login entirely, but only takes effect when
|
||||
# the three above are also fully set — a half-configured group never
|
||||
# locks every user out. It IS enforced server-side, and there is no
|
||||
# OIDC flow for the native iOS client yet — enabling it locks iOS
|
||||
# users out of sign-in entirely. Listed here so operator values in
|
||||
# .env reach the container (the `environment:` block is a whitelist).
|
||||
OIDC_ISSUER_URL: "${OIDC_ISSUER_URL:-}"
|
||||
OIDC_CLIENT_ID: "${OIDC_CLIENT_ID:-}"
|
||||
OIDC_CLIENT_SECRET: "${OIDC_CLIENT_SECRET:-}"
|
||||
OIDC_SCOPES: "${OIDC_SCOPES:-}"
|
||||
OIDC_BUTTON_LABEL: "${OIDC_BUTTON_LABEL:-}"
|
||||
OIDC_ONLY: "${OIDC_ONLY:-}"
|
||||
# Deploy-result webhook (optional). DEPLOY_WEBHOOK_SECRET authenticates
|
||||
# POST /api/internal/deploy-webhook; DEPLOY_LOGS_URL is the
|
||||
# deployment-dashboard URL (e.g. the Coolify UI) linked in
|
||||
|
||||
+3
-1
@@ -21,7 +21,9 @@ notes, and machine-readable specs that ship alongside the source:
|
||||
recipe. Routine install steps stay on `docs.healthlog.dev`. Includes
|
||||
[`self-hosting/mcp.md`](./self-hosting/mcp.md) — enabling the
|
||||
off-by-default MCP connector and pointing Claude.ai / ChatGPT /
|
||||
Claude Desktop at it.
|
||||
Claude Desktop at it, and [`self-hosting/sso.md`](./self-hosting/sso.md)
|
||||
— OIDC SSO login: IdP setup, the identity-pinning security model,
|
||||
`OIDC_ONLY` consequences, and the break-glass runbook.
|
||||
- [`migration/`](./migration/) — release-by-release migration notes.
|
||||
Read the entry for the version you're upgrading from.
|
||||
- [`audit/`](./audit/) — per-release audit summaries archived for
|
||||
|
||||
+334
-1
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.28.29
|
||||
version: 1.28.41
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
@@ -10355,6 +10355,128 @@ paths:
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/nutrients/batch:
|
||||
post:
|
||||
tags:
|
||||
- Nutrients
|
||||
summary: Ingest micronutrient day totals (v1.28)
|
||||
description: >-
|
||||
Bulk upsert of supplement-style daily totals synced from HealthKit — vitamins, minerals, water, caffeine. Day
|
||||
totals only: compute HKStatisticsCollectionQuery cumulative sums day-anchored in the user's current IANA
|
||||
timezone; `day` is yyyy-MM-dd in that timezone (the same rule as the `stats:` day keys). Never post raw samples.
|
||||
Upsert key is (day, nutrient): a re-post replaces the stored total and reports `updated`. Post the current +
|
||||
previous local day per sync; backfill 30 days on first enable. Max 500 entries; Idempotency-Key supported; rate
|
||||
limit 60/min. Per-entry skips (`unit_mismatch` | `value_out_of_range` | `day_invalid`) are terminal — log and
|
||||
drop, do not retry the entry. Behind the opt-in `nutrients` module (403 `module.disabled` when off). Query
|
||||
HealthKit with exactly the listed unit per code (`ug` = microgram; do not convert to IU):
|
||||
|
||||
|
||||
| code | HealthKit identifier | unit |
|
||||
|
||||
| --- | --- | --- |
|
||||
|
||||
| vitamin_a | HKQuantityTypeIdentifierDietaryVitaminA | ug |
|
||||
|
||||
| thiamin | HKQuantityTypeIdentifierDietaryThiamin | mg |
|
||||
|
||||
| riboflavin | HKQuantityTypeIdentifierDietaryRiboflavin | mg |
|
||||
|
||||
| niacin | HKQuantityTypeIdentifierDietaryNiacin | mg |
|
||||
|
||||
| pantothenic_acid | HKQuantityTypeIdentifierDietaryPantothenicAcid | mg |
|
||||
|
||||
| vitamin_b6 | HKQuantityTypeIdentifierDietaryVitaminB6 | mg |
|
||||
|
||||
| biotin | HKQuantityTypeIdentifierDietaryBiotin | ug |
|
||||
|
||||
| folate | HKQuantityTypeIdentifierDietaryFolate | ug |
|
||||
|
||||
| vitamin_b12 | HKQuantityTypeIdentifierDietaryVitaminB12 | ug |
|
||||
|
||||
| vitamin_c | HKQuantityTypeIdentifierDietaryVitaminC | mg |
|
||||
|
||||
| vitamin_d | HKQuantityTypeIdentifierDietaryVitaminD | ug |
|
||||
|
||||
| vitamin_e | HKQuantityTypeIdentifierDietaryVitaminE | mg |
|
||||
|
||||
| vitamin_k | HKQuantityTypeIdentifierDietaryVitaminK | ug |
|
||||
|
||||
| calcium | HKQuantityTypeIdentifierDietaryCalcium | mg |
|
||||
|
||||
| iron | HKQuantityTypeIdentifierDietaryIron | mg |
|
||||
|
||||
| magnesium | HKQuantityTypeIdentifierDietaryMagnesium | mg |
|
||||
|
||||
| phosphorus | HKQuantityTypeIdentifierDietaryPhosphorus | mg |
|
||||
|
||||
| zinc | HKQuantityTypeIdentifierDietaryZinc | mg |
|
||||
|
||||
| copper | HKQuantityTypeIdentifierDietaryCopper | mg |
|
||||
|
||||
| manganese | HKQuantityTypeIdentifierDietaryManganese | mg |
|
||||
|
||||
| selenium | HKQuantityTypeIdentifierDietarySelenium | ug |
|
||||
|
||||
| chromium | HKQuantityTypeIdentifierDietaryChromium | ug |
|
||||
|
||||
| molybdenum | HKQuantityTypeIdentifierDietaryMolybdenum | ug |
|
||||
|
||||
| iodine | HKQuantityTypeIdentifierDietaryIodine | ug |
|
||||
|
||||
| water | HKQuantityTypeIdentifierDietaryWater | ml |
|
||||
|
||||
| caffeine | HKQuantityTypeIdentifierDietaryCaffeine | mg |
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/NutrientBatchRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Batch processed; per-entry statuses inserted | updated | skipped.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/NutrientBatchEnvelope"
|
||||
"401": *a1
|
||||
"403": &a17
|
||||
description: The opt-in `nutrients` module is off for this account (errorCode `module.disabled`). Stop syncing and do
|
||||
not retry; offer the enable-in-settings hint. Only run the HealthKit read-authorization flow while the
|
||||
module is on.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/nutrients:
|
||||
get:
|
||||
tags:
|
||||
- Nutrients
|
||||
summary: Synced-nutrient window summary (v1.28)
|
||||
description: "Per-nutrient summary of the last `days` days (default 14): latest synced day + total and the
|
||||
days-with-data count, in catalog order. Read-only; feeds the settings card. Behind the opt-in `nutrients`
|
||||
module."
|
||||
parameters:
|
||||
- in: query
|
||||
name: days
|
||||
schema:
|
||||
default: 14
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 365
|
||||
responses:
|
||||
"200":
|
||||
description: The window summary.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/NutrientOverviewEnvelope"
|
||||
"401": *a1
|
||||
"403": *a17
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
components:
|
||||
schemas:
|
||||
EncryptedExportRequest:
|
||||
@@ -11501,6 +11623,8 @@ components:
|
||||
type: boolean
|
||||
mentalHealth:
|
||||
type: boolean
|
||||
nutrients:
|
||||
type: boolean
|
||||
additionalProperties: false
|
||||
description: Partial module enable/disable update. Each key is an optional boolean; an omitted key is left untouched,
|
||||
`false` disables the module, `true` (re-)enables it. Only the toggleable "secondary domains" are accepted — a
|
||||
@@ -11968,6 +12092,7 @@ components:
|
||||
- TOTAL_BODY_WATER
|
||||
- VISCERAL_FAT
|
||||
- BODY_MASS_INDEX
|
||||
- WAIST_CIRCUMFERENCE
|
||||
- PHQ9_SCORE
|
||||
- GAD7_SCORE
|
||||
- WHO5_SCORE
|
||||
@@ -12143,6 +12268,76 @@ components:
|
||||
- updatedAt
|
||||
description: Resumable module-tour progress point. `lastStopId` seeds the resume index; `status` is the running/terminal
|
||||
state.
|
||||
NutrientBatchRequest:
|
||||
type: object
|
||||
properties:
|
||||
entries:
|
||||
minItems: 1
|
||||
maxItems: 500
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/NutrientIntakeEntry"
|
||||
required:
|
||||
- entries
|
||||
description: Bulk day-total upsert, max 500 entries. Upsert key is (day, nutrient); a re-post replaces the stored total
|
||||
(last-writer-wins day-total contract).
|
||||
NutrientIntakeEntry:
|
||||
type: object
|
||||
properties:
|
||||
day:
|
||||
type: string
|
||||
pattern: ^\d{4}-\d{2}-\d{2}$
|
||||
nutrient:
|
||||
$ref: "#/components/schemas/NutrientCode"
|
||||
unit:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 10
|
||||
amount:
|
||||
type: number
|
||||
minimum: 0
|
||||
externalSourceVersion:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 120
|
||||
required:
|
||||
- day
|
||||
- nutrient
|
||||
- unit
|
||||
- amount
|
||||
description: One nutrient day total. day = YYYY-MM-DD in the user's IANA timezone; unit must equal the catalog's
|
||||
canonical unit for the code (mg | ug | ml) or the entry is skipped unit_mismatch.
|
||||
NutrientCode:
|
||||
type: string
|
||||
enum:
|
||||
- vitamin_a
|
||||
- thiamin
|
||||
- riboflavin
|
||||
- niacin
|
||||
- pantothenic_acid
|
||||
- vitamin_b6
|
||||
- biotin
|
||||
- folate
|
||||
- vitamin_b12
|
||||
- vitamin_c
|
||||
- vitamin_d
|
||||
- vitamin_e
|
||||
- vitamin_k
|
||||
- calcium
|
||||
- iron
|
||||
- magnesium
|
||||
- phosphorus
|
||||
- zinc
|
||||
- copper
|
||||
- manganese
|
||||
- selenium
|
||||
- chromium
|
||||
- molybdenum
|
||||
- iodine
|
||||
- water
|
||||
- caffeine
|
||||
description: Closed nutrient catalog code — 24 HealthKit Dietary vitamin/mineral types plus water and caffeine. Energy,
|
||||
macros and sodium/potassium are out of scope.
|
||||
MedicationTreatmentClass:
|
||||
type: string
|
||||
enum:
|
||||
@@ -19022,6 +19217,9 @@ components:
|
||||
mentalHealth:
|
||||
type: boolean
|
||||
description: Whether the "mentalHealth" module is enabled for this account.
|
||||
nutrients:
|
||||
type: boolean
|
||||
description: Whether the "nutrients" module is enabled for this account.
|
||||
required:
|
||||
- cycle
|
||||
- mood
|
||||
@@ -19040,6 +19238,7 @@ components:
|
||||
- mcp
|
||||
- inboundDocuments
|
||||
- mentalHealth
|
||||
- nutrients
|
||||
additionalProperties: false
|
||||
description: Fully-resolved per-user module enable/disable map. Every toggleable module key is present. `false` means
|
||||
the surface should disappear end-to-end (nav, dashboard, insights, …). `cycle` mirrors `cycleTrackingEnabled`;
|
||||
@@ -28359,6 +28558,140 @@ components:
|
||||
- progress
|
||||
additionalProperties: false
|
||||
description: The persisted completion flag and resume point after the write.
|
||||
NutrientBatchEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/NutrientBatchResponse"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
NutrientBatchResponse:
|
||||
type: object
|
||||
properties:
|
||||
processed:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
inserted:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
updated:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
skipped:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
index:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
reason:
|
||||
type: string
|
||||
required:
|
||||
- index
|
||||
- reason
|
||||
additionalProperties: false
|
||||
entries:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/NutrientEntryResult"
|
||||
required:
|
||||
- processed
|
||||
- inserted
|
||||
- updated
|
||||
- skipped
|
||||
- entries
|
||||
additionalProperties: false
|
||||
description: Always 200 when the envelope parses; per-entry failures surface as skipped entries, never a batch failure.
|
||||
NutrientEntryResult:
|
||||
type: object
|
||||
properties:
|
||||
index:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- inserted
|
||||
- updated
|
||||
- skipped
|
||||
reason:
|
||||
type: string
|
||||
required:
|
||||
- index
|
||||
- status
|
||||
additionalProperties: false
|
||||
description: "Per-entry ingest outcome. skipped reasons: unit_mismatch | value_out_of_range | day_invalid |
|
||||
upsert_failed. Log and drop a skipped entry — do not retry it."
|
||||
NutrientOverviewEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/NutrientIntakeOverview"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
NutrientIntakeOverview:
|
||||
type: object
|
||||
properties:
|
||||
windowDays:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 9007199254740991
|
||||
nutrients:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
nutrient:
|
||||
$ref: "#/components/schemas/NutrientCode"
|
||||
unit:
|
||||
type: string
|
||||
latestDay:
|
||||
type: string
|
||||
latestAmount:
|
||||
type: number
|
||||
daysWithData:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 9007199254740991
|
||||
required:
|
||||
- nutrient
|
||||
- unit
|
||||
- latestDay
|
||||
- latestAmount
|
||||
- daysWithData
|
||||
additionalProperties: false
|
||||
required:
|
||||
- windowDays
|
||||
- nutrients
|
||||
additionalProperties: false
|
||||
description: "Per-nutrient window summary in catalog order: latest synced day + total, and the count of days carrying
|
||||
data inside the window. Nutrients without data in the window are omitted."
|
||||
MedicationScheduleOutput:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -33,7 +33,9 @@ HealthLog supports two key-provisioning modes that coexist:
|
||||
`AppSettings.adminAiKeyEncrypted`. Users who have not configured
|
||||
their own key fall back to the admin key. This is the last entry
|
||||
in the default provider chain so it acts as a safety net rather
|
||||
than the default route.
|
||||
than the default route. Since v1.28.14 the operator can also share
|
||||
a subscription-based account that users opt into — see
|
||||
[Admin-shared subscription access](#admin-shared-subscription-access).
|
||||
|
||||
Single-user instances usually pick one mode and stop there.
|
||||
Multi-user instances tend to run BYOK with the admin-shared key as a
|
||||
@@ -209,6 +211,41 @@ documented in `docs/codex-protocol-spec.md` for anyone who wants to
|
||||
audit the request shape; users who just want the integration to
|
||||
work do not need to read it.
|
||||
|
||||
## Admin-shared subscription access
|
||||
|
||||
Besides the shared API key, the operator can connect a single
|
||||
subscription-based AI account for the whole instance — an
|
||||
operator-shared subscription provider that users without any
|
||||
personal AI setup can opt into.
|
||||
|
||||
- **Admin side.** Under **Admin → AI** the operator runs the same
|
||||
device-code OAuth flow as the per-user connection
|
||||
(`POST /api/admin/central-codex/device-start` +
|
||||
`device-poll`). The tokens land AES-256-GCM-encrypted in the
|
||||
`AppSettings` singleton and are never returned by any endpoint —
|
||||
status and disconnect go through `GET` / `DELETE
|
||||
/api/admin/central-codex`, which sit behind the cookie-only
|
||||
`requireAdmin()` boundary.
|
||||
- **User side.** Each user opts in individually in `/settings/ai`
|
||||
(`PATCH /api/auth/me/use-central-codex`, body
|
||||
`{ "useCentralCodex": boolean }`). The opt-in is off by default.
|
||||
- **Chain semantics.** The shared connection is the internal
|
||||
`admin-codex` chain entry. It is never part of the persisted chain
|
||||
and never offered in the chain editor — it is appended **last** to
|
||||
the resolved chain, and only when the user opted in AND the
|
||||
operator has connected the account
|
||||
(`src/lib/ai/provider-chain.ts`). A hand-crafted persisted entry
|
||||
resolves to nothing.
|
||||
- **Cost.** Generations through the shared connection run under the
|
||||
operator's account, so usage and rate limits land on the operator,
|
||||
not the user.
|
||||
|
||||
The settings UI states this plainly: the shared access is a single
|
||||
signed-in AI account the operator connected for everyone on the
|
||||
server — not the user's own key. Users who want their data to reach
|
||||
only a provider under their own contract should configure a personal
|
||||
provider instead; their own entries always resolve first.
|
||||
|
||||
## Privacy stance
|
||||
|
||||
- **OpenAI** and **Anthropic** see the prompt + structured snapshot
|
||||
|
||||
+20
-17
@@ -5,10 +5,10 @@
|
||||
|
||||
HealthLog reads Fitbit and Pixel Watch data through the **classic Fitbit Web
|
||||
API** (`api.fitbit.com`). The OAuth app is a **Fitbit developer app registered
|
||||
at [dev.fitbit.com](https://dev.fitbit.com/apps/new)** — **not** a Google Cloud
|
||||
at [dev.fitbit.com](https://dev.fitbit.com/)** — **not** a Google Cloud
|
||||
OAuth client. Like WHOOP, credentials are brought per user: each user (or the
|
||||
operator on their behalf) registers their own Fitbit app and pastes the client
|
||||
id/secret into Settings. The internal integration key is `fitbit`.
|
||||
operator on their behalf) supplies their own Fitbit app's client id/secret in
|
||||
Settings. The internal integration key is `fitbit`.
|
||||
|
||||
> **Common mistake:** the field wants a **Fitbit** Client ID (a short numeric
|
||||
> id from dev.fitbit.com), **not** a Google Cloud client id ending in
|
||||
@@ -16,30 +16,33 @@ id/secret into Settings. The internal integration key is `fitbit`.
|
||||
> consent screen with `unauthorized_client — Invalid client_id`, because Fitbit
|
||||
> and Google Cloud are separate client registries.
|
||||
|
||||
> **Heads-up — September 2026 sunset.** Google is retiring the classic Fitbit
|
||||
> Web API in **September 2026** and moving to the Google Health API behind Google
|
||||
> sign-in. That replacement currently requires Google Restricted-scope brand
|
||||
> verification plus an annual third-party CASA security assessment, which does
|
||||
> not fit a self-hosted bring-your-own-credentials model, so the path beyond the
|
||||
> sunset is still being worked out. Until then the setup below is the supported
|
||||
> way to connect Fitbit.
|
||||
> **Heads-up — new registrations are closed.** Fitbit has **discontinued
|
||||
> self-serve app registration**: the form at `dev.fitbit.com/apps/new` is
|
||||
> disabled for new applications, and the classic Fitbit Web API sunsets in
|
||||
> **September 2026**. If you already hold a registered Fitbit app, it keeps
|
||||
> working until the sunset and the setup below still applies. If you are
|
||||
> connecting Fitbit data for the first time, use the
|
||||
> [Google Health integration](google-health.md) instead — Fitbit devices sync
|
||||
> into Google's health stack, which HealthLog reads directly.
|
||||
|
||||
## 1. Register a Fitbit developer app
|
||||
## 1. Use an existing Fitbit developer app
|
||||
|
||||
1. Sign in at <https://dev.fitbit.com/apps/new> and register a new application.
|
||||
2. **OAuth 2.0 Application Type:**
|
||||
Registration of new apps is no longer possible (see the note above). For an
|
||||
app you registered before the shutdown, these are the settings that matter:
|
||||
|
||||
1. **OAuth 2.0 Application Type:**
|
||||
- **Personal** — the app only ever sees the **owner's own** Fitbit account,
|
||||
and gets intraday (per-minute) heart-rate/steps automatically. This is the
|
||||
right choice for a single-person self-host.
|
||||
- **Server** (or **Client**) — needed when the instance connects **other
|
||||
people's** accounts. Intraday/heart-rate access is then granted case by
|
||||
case via Fitbit's Intraday Data Access request form.
|
||||
3. **Callback URL:** `https://your-instance.example.com/api/fitbit/callback`
|
||||
2. **Callback URL:** `https://your-instance.example.com/api/fitbit/callback`
|
||||
— absolute, **HTTPS**, and matching the `redirect_uri` HealthLog sends (see
|
||||
the redirect-URI note below). One entry per environment you connect from.
|
||||
4. **Scopes:** tick the ones HealthLog requests (next section).
|
||||
5. Save. Fitbit issues a **Client ID** (short, numeric) and a **Client Secret**
|
||||
— keep the secret out of any chat log.
|
||||
3. **Scopes:** the ones HealthLog requests (next section).
|
||||
4. The app's settings page shows the **Client ID** (short, numeric) and the
|
||||
**Client Secret** — keep the secret out of any chat log.
|
||||
|
||||
## 2. Scopes
|
||||
|
||||
|
||||
@@ -50,8 +50,10 @@ Click **Connect WHOOP**. The flow:
|
||||
2. The user signs in on WHOOP and grants the requested scopes.
|
||||
3. WHOOP redirects back to `/api/whoop/callback` with an auth code.
|
||||
4. HealthLog exchanges the code for an access + refresh token, stores
|
||||
them encrypted, registers the webhook, and triggers an initial
|
||||
sync of recent history.
|
||||
them encrypted, and triggers an initial sync of recent history.
|
||||
(The webhook is **not** registered per user — it is configured once
|
||||
at app level in the WHOOP developer console; see the env-var
|
||||
section below.)
|
||||
5. Subsequent syncs run on every webhook delivery plus a safety-net
|
||||
cron pull.
|
||||
|
||||
|
||||
@@ -38,20 +38,25 @@ arrive via the sleep series endpoint.
|
||||
needed — `user.metrics` covers weight / BP / heart-list / sleep /
|
||||
SpO₂ / body comp / temperature / VO₂ max; `user.activity` is the
|
||||
workout-equivalent stream for steps / energy / distance / floors.
|
||||
See `src/lib/withings/client.ts:42` (`WITHINGS_OAUTH_SCOPE`).
|
||||
See `WITHINGS_OAUTH_SCOPE` in `src/lib/withings/client.ts`.
|
||||
4. Save the application. Withings will issue a **Client ID** and a
|
||||
**Client Secret** — keep the secret out of any chat log; it grants
|
||||
read access to every linked account's full health history.
|
||||
|
||||
## 2. Wire the credentials into HealthLog
|
||||
## 2. Wire the credentials in Settings → Integrations
|
||||
|
||||
Paste the client ID and secret into `.env`:
|
||||
Withings credentials are stored per user, not in `.env`. Sign in as
|
||||
the target user, open **Settings → Integrations → Withings**, and
|
||||
paste the client ID and secret. They are encrypted AES-256-GCM at
|
||||
rest (`src/lib/withings/credentials.ts`). On a shared instance every
|
||||
user brings their own Withings app credentials; there is no
|
||||
server-level client-id/secret fallback.
|
||||
|
||||
Two server-level env vars remain:
|
||||
|
||||
```env
|
||||
WITHINGS_CLIENT_ID="your-client-id"
|
||||
WITHINGS_CLIENT_SECRET="your-client-secret"
|
||||
WITHINGS_REDIRECT_URI="https://your-instance.example.com/api/withings/callback"
|
||||
WITHINGS_WEBHOOK_SECRET="$(openssl rand -hex 32)"
|
||||
WITHINGS_REDIRECT_URI="https://your-instance.example.com/api/withings/callback"
|
||||
```
|
||||
|
||||
`WITHINGS_WEBHOOK_SECRET` is the path-segment token the webhook
|
||||
@@ -60,10 +65,9 @@ that fits in a URL path segment works, but a 64-character hex string
|
||||
keeps it indistinguishable from random. Restart the `app` container
|
||||
after saving.
|
||||
|
||||
Per-user override is also possible: the Settings UI accepts a
|
||||
client-ID/secret pair scoped to one user, useful if you want a
|
||||
shared instance where every user brings their own Withings app
|
||||
credentials. Server-level env vars cover the default case.
|
||||
`WITHINGS_REDIRECT_URI` is optional — when unset the callback URL
|
||||
defaults to `${NEXT_PUBLIC_APP_URL}/api/withings/callback`. Set it
|
||||
only when the OAuth callback must differ from that default.
|
||||
|
||||
## 3. Set the webhook URL
|
||||
|
||||
@@ -90,9 +94,8 @@ callback. The portal only owns the OAuth callback URL.
|
||||
|
||||
## 4. Link from the Settings page
|
||||
|
||||
With env vars in place and the app restarted, sign in as the target
|
||||
user and open `/settings/integrations/withings`. Click **Connect
|
||||
Withings**. The flow:
|
||||
With the credentials saved in Settings, open
|
||||
`/settings/integrations/withings` and click **Connect Withings**. The flow:
|
||||
|
||||
1. The Connect button redirects to Withings' `oauth2/authorize`.
|
||||
2. The user signs in on Withings and grants the requested scopes.
|
||||
@@ -133,10 +136,10 @@ Override per-user via the Sources section of `/settings/thresholds`.
|
||||
|
||||
### The Connect button errors out
|
||||
|
||||
- **`Withings credentials not configured`** — `WITHINGS_CLIENT_ID`
|
||||
or `WITHINGS_CLIENT_SECRET` is missing from `.env`. Confirm both
|
||||
are set in the container's runtime environment (`docker compose
|
||||
exec app env | grep WITHINGS`).
|
||||
- **`Withings credentials not configured`** — the signed-in user has
|
||||
not saved a client ID + secret under **Settings → Integrations →
|
||||
Withings**. Credentials live per user in the database, not in
|
||||
`.env` — paste the pair from the developer portal and retry.
|
||||
- **`Callback URI mismatch`** — the URL HealthLog sends to Withings
|
||||
must match an entry in the developer-portal app's callback list.
|
||||
Add the public URL exactly, including the `https://` scheme and
|
||||
|
||||
@@ -68,20 +68,26 @@ The credentials are never returned.
|
||||
## Restore
|
||||
|
||||
Pick a key (e.g. `2026-05-08/user-clx123.json.enc`) from the bucket
|
||||
and run:
|
||||
and run the restore script with the same backup credentials and
|
||||
encryption key the backup was written under — a freshly generated
|
||||
`BACKUP_ENCRYPTION_KEY` cannot decrypt any existing object:
|
||||
|
||||
```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 \
|
||||
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=<the key the backup was written under> \
|
||||
pnpm dlx tsx scripts/restore-backup.ts \
|
||||
2026-05-08/user-clx123.json.enc \
|
||||
/tmp/restored.json
|
||||
```
|
||||
|
||||
The production standalone image strips `tsx`, so a bare
|
||||
`pnpm tsx scripts/...` fails inside the container — always invoke
|
||||
one-shot scripts via `pnpm dlx tsx`.
|
||||
|
||||
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).
|
||||
|
||||
@@ -44,7 +44,7 @@ under `v1` (the synthetic id assigned to the existing `ENCRYPTION_KEY`).
|
||||
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
|
||||
pnpm dlx 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,
|
||||
@@ -69,7 +69,7 @@ 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
|
||||
pnpm dlx tsx scripts/rotate-encryption-key.ts v3
|
||||
ENCRYPTION_KEYS='{"v3":"<new>"}'
|
||||
```
|
||||
|
||||
|
||||
+65
-48
@@ -1,26 +1,34 @@
|
||||
# TLS leaf SPKI pin — coupling, alarm, and re-pin runbook
|
||||
# TLS SPKI pinning — coupling, alarm, and re-pin runbook
|
||||
|
||||
The native client SPKI-pins the server's TLS **leaf** certificate. This is a
|
||||
deliberate hardening choice on the client side, but it couples the client to
|
||||
the exact leaf keypair the server serves: when that leaf rotates, a client
|
||||
that was only ever shipped the old pin refuses to connect. This page explains
|
||||
the coupling, the server-side alarm that watches for a rotation, and the
|
||||
operator / release-owner steps when it fires.
|
||||
The native client SPKI-pins the server's TLS chain at the **CA level**. It
|
||||
ships a small pin set — currently the issuing intermediate plus the root —
|
||||
and accepts a connection only when the served chain passes baseline X.509
|
||||
evaluation AND at least one certificate anywhere in the chain matches a pin
|
||||
(any-match). The leaf is deliberately **not** pinned, so routine leaf
|
||||
renewals are a client no-op. The outage risk is a **chain change**: the
|
||||
served chain stops terminating in the pinned CA keys — the server moves to a
|
||||
different CA, or the CA rotates its intermediate / root keys. This page
|
||||
explains the coupling, the server-side alarm that watches the served leaf,
|
||||
and the operator / release-owner steps when it fires.
|
||||
|
||||
## The coupling
|
||||
|
||||
A SPKI pin is `base64(sha256(DER subjectPublicKeyInfo))` — a hash of the
|
||||
A SPKI pin is `base64(sha256(DER subjectPublicKeyInfo))` — a hash of a
|
||||
certificate's public key, not of the whole certificate. The native client
|
||||
carries a set of these pins and rejects any TLS connection whose leaf public
|
||||
key is not in the set.
|
||||
carries a set of these pins (build-time config, ≥ 2 well-formed pins
|
||||
enforced for release builds) and rejects any TLS connection whose validated
|
||||
chain contains no pinned key. Hosts outside the client's pinned-host list
|
||||
fall back to plain system-trust validation, which is the documented model
|
||||
for self-hosted instances that ship their own chain.
|
||||
|
||||
The app host's certificate is issued by Google Trust Services (GTS) and
|
||||
auto-renews roughly every 90 days. Each renewal mints a **new leaf keypair**,
|
||||
so the SPKI pin changes on every renewal. Intermediate and root rolls have
|
||||
the same effect on the leaf chain. Because the client pins the leaf, every
|
||||
such change is a hard outage for any client that hasn't been shipped the new
|
||||
pin — and there is otherwise no server-side signal that a client is pinning
|
||||
the served leaf at all.
|
||||
The app host's certificate auto-renews roughly every 90 days and each
|
||||
renewal mints a new leaf keypair. Because the client pins the CA keys rather
|
||||
than the leaf, those renewals pass without a client build. What DOES break
|
||||
every pinned client is a chain that no longer terminates in a pinned key —
|
||||
and there is no server-side signal that clients are pinning the served chain
|
||||
at all. The alarm below exists to catch exactly that early: it watches the
|
||||
served **leaf**, the most frequently rotating element, as the canary for any
|
||||
chain movement.
|
||||
|
||||
## The alarm
|
||||
|
||||
@@ -46,13 +54,15 @@ successful probe returning a pin genuinely outside the known set.
|
||||
|
||||
### Baseline source: an env var, not auto-learning
|
||||
|
||||
`TLS_LEAF_SPKI_PINS` (comma-separated) is the known-good set. The baseline is
|
||||
an env var on purpose, **not** a persisted "last-seen" row:
|
||||
`TLS_LEAF_SPKI_PINS` (comma-separated) is the known-good set of **served
|
||||
leaf** pins. It is the operator's explicit record of what the server is
|
||||
expected to serve — distinct from the client's CA-level pin set, which lives
|
||||
in the client build config. The baseline is an env var on purpose, **not** a
|
||||
persisted "last-seen" row:
|
||||
|
||||
- The pinned client only trusts the pins it was shipped, so the operator must
|
||||
derive the client's pin set from a single explicit source of truth. The env
|
||||
var **is** that source — the same value goes into the client's pin set and
|
||||
into `TLS_LEAF_SPKI_PINS`.
|
||||
- An explicit baseline forces the operator to acknowledge every leaf
|
||||
rotation and check the new chain against the client's pinned CA keys
|
||||
before the alarm goes quiet.
|
||||
- A persisted last-seen baseline would silently adopt the first rotated pin
|
||||
and suppress the very alarm the pinned client needs.
|
||||
- When the env var is unset, the monitor fails **loud, not open**: it logs
|
||||
@@ -84,36 +94,43 @@ The variable is on the compose `environment:` whitelist and the
|
||||
whether it is set. It is optional: without it the monitor still runs and logs
|
||||
the served pin, but cannot alarm on a change.
|
||||
|
||||
## When the alarm fires — re-pin procedure
|
||||
## When the alarm fires
|
||||
|
||||
When you see a `tls.pin.leaf_changed` alert / `system.tls.pin_changed` audit
|
||||
row, the served leaf has rotated. The pinned client will stop connecting once
|
||||
its shipped pin no longer matches a served leaf. Act inside the certificate's
|
||||
remaining validity, with margin for app review and client roll-out — target
|
||||
**≥ 11 days before the old pin's certificate expires** (the alert body and
|
||||
audit row both carry the new leaf's `validTo`).
|
||||
row, the served leaf has rotated. First determine which of two cases you are
|
||||
in:
|
||||
|
||||
1. **Re-extract the new leaf pin** with the `openssl` pipeline above. It
|
||||
should equal the `servedPin` in the alert.
|
||||
2. **Dual-pin.** Add the new pin to the client's pin set **alongside** the old
|
||||
one (do not replace it yet), and update `TLS_LEAF_SPKI_PINS` to the
|
||||
comma-separated pair, e.g.
|
||||
`TLS_LEAF_SPKI_PINS="<old-pin>,<new-pin>"`. Holding both pins means clients
|
||||
on the old build keep working through the cutover and the server-side
|
||||
alarm goes quiet because the served pin is now in the known set. Re-deploy
|
||||
the server so the new env value takes effect.
|
||||
1. **Routine renewal, same chain.** Inspect the new chain (the `openssl`
|
||||
pipeline above, or the alert payload). If the issuing intermediate and
|
||||
root are unchanged, the client's CA-level pins still match and no client
|
||||
is at risk. Update `TLS_LEAF_SPKI_PINS` to the new served-leaf pin and
|
||||
re-deploy so the alarm goes quiet. No client build.
|
||||
2. **Chain change.** The new leaf chains through a different intermediate /
|
||||
root (CA switch, or the CA rotated its keys). Every pinned client will
|
||||
stop connecting once the old chain is no longer served — run the re-pin
|
||||
procedure below inside the old chain's remaining validity, with margin
|
||||
for app review and client roll-out (the alert body and audit row carry
|
||||
the new leaf's `validTo`).
|
||||
|
||||
### Re-pin procedure (chain change)
|
||||
|
||||
1. **Extract the new chain's CA pins** (intermediate + root) with the
|
||||
`openssl` pipeline / the client repo's SPKI-extraction script.
|
||||
2. **Dual-pin.** Add the new CA pins to the client's pin set **alongside**
|
||||
the old ones (do not replace them yet). Clients on the old build keep
|
||||
working as long as the old chain is still served; the dual-pinned build
|
||||
works against both chains. Update `TLS_LEAF_SPKI_PINS` to the currently
|
||||
served leaf pin(s) so the server-side alarm reflects reality.
|
||||
3. **Ship the client build** carrying the dual-pin set to TestFlight (and
|
||||
onward to release). Give it long enough to roll out to the installed base
|
||||
before the next renewal.
|
||||
4. **Retire the old pin** once the old leaf is no longer served and the
|
||||
dual-pinned client build has reached the installed base: drop the old pin
|
||||
from both the client pin set and `TLS_LEAF_SPKI_PINS`, leaving only the
|
||||
current leaf pin.
|
||||
onward to release). Give it long enough to roll out to the installed
|
||||
base before the old chain disappears.
|
||||
4. **Retire the old pins** once the old chain is no longer served and the
|
||||
dual-pinned client build has reached the installed base.
|
||||
|
||||
Treat the dual-pin window as standing practice: always ship the **next** pin
|
||||
before the current leaf rotates, so a renewal never catches a single-pinned
|
||||
client. If GTS renews on a fixed cadence, pre-extracting and dual-pinning the
|
||||
upcoming leaf ahead of the renewal turns every rotation into a no-op.
|
||||
CA-level pins move rarely — intermediates and roots carry multi-year
|
||||
lifetimes and rotations are announced well ahead. Track the pinned
|
||||
certificates' expiry dates and pre-stage the successor pins before the
|
||||
cutover, so a chain change never catches a single-chain client.
|
||||
|
||||
## Environment assumption
|
||||
|
||||
|
||||
@@ -24,15 +24,44 @@ deployed in three modes:
|
||||
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.
|
||||
atomically via row-level locking (`FOR UPDATE SKIP LOCKED`), so
|
||||
running multiple worker replicas is **safe** — no job runs twice —
|
||||
but it doubles the DB load and muddies telemetry without adding
|
||||
throughput a personal instance needs (`src/lib/process-type.ts`).
|
||||
Run **one** worker container and scale the `web` containers instead.
|
||||
|
||||
## Apple Health import staging (shared volume required for a split)
|
||||
|
||||
The Apple Health `export.zip` import streams the upload to a staging
|
||||
file on local disk (under the container's temp dir) and hands the
|
||||
worker only the file **path**. In single-container mode
|
||||
(`HEALTHLOG_PROCESS_TYPE=all`, the default) the web handler and the
|
||||
worker share the same filesystem, so the handoff just works.
|
||||
|
||||
In a web/worker split — or any topology where the container that
|
||||
accepts the upload is not the one that runs the job (a `web` +
|
||||
`worker` split, extra `all` replicas, or old+new containers coexisting
|
||||
during a rolling deploy) — the worker cannot see the web container's
|
||||
staging file. The import then fails with a staging-file-missing error
|
||||
("the import staging file is not visible to the worker"). Two things to
|
||||
know:
|
||||
|
||||
- A pure `web` container has no pg-boss handle, so an import kicked off
|
||||
there currently returns `503 Background worker is not running`. Run
|
||||
imports from a container that also runs the worker until send-only
|
||||
boss support lands.
|
||||
- If you run web and worker as separate containers, they **must share
|
||||
the import staging directory** — mount the same named volume at the
|
||||
temp path on both — or run imports in single-container (`all`) mode.
|
||||
Without a shared staging volume, imports in split mode cannot
|
||||
complete.
|
||||
|
||||
## 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`.
|
||||
`/api/admin/status`.
|
||||
|
||||
The web container does NOT depend on the worker, and vice versa, so
|
||||
neither container's startup can deadlock the other. The shared
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
# SSO login (OIDC)
|
||||
|
||||
HealthLog can delegate sign-in to a self-hosted OpenID Connect identity
|
||||
provider — Authentik, Keycloak, Authelia, Pocket-ID, or anything else
|
||||
that serves a standard discovery document. The flow is the OIDC
|
||||
authorization-code flow with PKCE against a single, env-configured
|
||||
provider; by default it is **additive** (the SSO button appears next to
|
||||
password and passkey login), with an explicit opt-in to make it the only
|
||||
way in.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Register a **confidential** OIDC client at your IdP with the redirect
|
||||
URI
|
||||
|
||||
```
|
||||
${NEXT_PUBLIC_APP_URL}/api/auth/oidc/callback
|
||||
```
|
||||
|
||||
The comparison at the IdP is byte-exact: scheme, host, port, and path
|
||||
all matter. `https://healthlog.example.com/api/auth/oidc/callback` and
|
||||
`http://healthlog.example.com/api/auth/oidc/callback` are different
|
||||
URIs, and a trailing slash breaks the match.
|
||||
|
||||
2. Set the three provider vars (all three together enable the login-page
|
||||
button; anything less keeps SSO fully disabled):
|
||||
|
||||
```env
|
||||
OIDC_ISSUER_URL="https://idp.example.com"
|
||||
OIDC_CLIENT_ID="healthlog"
|
||||
OIDC_CLIENT_SECRET="..."
|
||||
```
|
||||
|
||||
The issuer must serve
|
||||
`<issuer>/.well-known/openid-configuration`. Prefer HTTPS — the
|
||||
authorization code and the token response carrying the ID token cross
|
||||
this connection. Plain HTTP is defensible only on a fully trusted
|
||||
private network (IdP and app on the same box, a Tailscale mesh, a
|
||||
VPN); a LAN-only issuer is explicitly supported.
|
||||
|
||||
3. Optional tuning:
|
||||
|
||||
```env
|
||||
OIDC_SCOPES="openid email profile" # default shown
|
||||
OIDC_BUTTON_LABEL="Single Sign-On" # default shown
|
||||
OIDC_ONLY="false" # see below before changing
|
||||
```
|
||||
|
||||
4. Remember the compose env whitelist: every `OIDC_*` var must appear
|
||||
under `environment:` in `docker-compose.yml` (the shipped file lists
|
||||
them) — vars not on the whitelist never reach the container even when
|
||||
`.env` has them.
|
||||
|
||||
`pnpm check-env` knows all six vars and flags a partially configured
|
||||
provider group.
|
||||
|
||||
## Per-IdP notes
|
||||
|
||||
Client registration essentials only — the IdP's own docs cover the rest.
|
||||
|
||||
- **Authentik.** Create a _Provider_ of type OAuth2/OpenID (confidential
|
||||
client), bind it to an _Application_, and use the provider's issuer URL
|
||||
(shaped like `https://authentik.example.com/application/o/<app-slug>/`
|
||||
— the trailing slash is part of Authentik's issuer; the discovery
|
||||
check tolerates the difference). Signing key: any RSA/EC key; the
|
||||
default RS256 works.
|
||||
- **Keycloak.** Create a client with _Client authentication_ ON and the
|
||||
_Standard flow_ enabled. The issuer is
|
||||
`https://keycloak.example.com/realms/<realm>`. Keep the default RS256
|
||||
signature algorithm; do not switch the client to `HS256` — HealthLog
|
||||
rejects HMAC-signed ID tokens.
|
||||
- **Authelia.** Define a client under `identity_providers.oidc.clients`
|
||||
with `token_endpoint_auth_method: client_secret_post` (HealthLog sends
|
||||
the secret in the POST body) and the redirect URI above. The issuer is
|
||||
Authelia's root URL.
|
||||
- **Pocket-ID.** Add an OIDC client, copy the generated ID and secret,
|
||||
and use the Pocket-ID base URL as the issuer. Pocket-ID authenticates
|
||||
users by passkey at the IdP; to HealthLog it is a provider like any
|
||||
other.
|
||||
|
||||
## Security model
|
||||
|
||||
- **Identity is pinned, not guessed.** The first OIDC sign-in either
|
||||
provisions a fresh account or links to an existing account matched by
|
||||
**verified** email (`email_verified: true` — an IdP that does not
|
||||
assert verification is rejected). Either way the account is stamped
|
||||
with the provider's `(issuer, sub)` pair, and every later login
|
||||
matches on that pair alone. An email change at the IdP updates the
|
||||
displayed email; it can never re-point the login at a different
|
||||
account. An email that matches an account already pinned to a
|
||||
_different_ identity is rejected and audited.
|
||||
- **SSO sessions never satisfy step-up.** Destructive actions (disabling
|
||||
MFA, rotating encryption keys, encrypted export, account deletion)
|
||||
require a _fresh_ second factor confirmed by HealthLog itself. An OIDC
|
||||
login is a delegated factor — whatever the IdP checked, this app did
|
||||
not see it — so an SSO session always has to confirm a native factor
|
||||
(or the password path) when it hits one of those gates.
|
||||
- **Accounts with native 2FA keep it.** If the account has a confirmed
|
||||
TOTP secret or a registered security key, an OIDC login does not
|
||||
complete on its own: after the IdP round-trip the user lands in the
|
||||
same second-factor step password login uses. SSO never downgrades an
|
||||
account's own MFA.
|
||||
- **MFA belongs at the IdP.** For SSO-provisioned accounts, enforce
|
||||
MFA in the IdP's policy engine — that is the point of delegating
|
||||
authentication. HealthLog-side 2FA remains available on top for the
|
||||
step-up gates above.
|
||||
- **Linking is once.** There is no unlink/relink flow in this cut; the
|
||||
pin is deliberately sticky. Moving an account to a new IdP identity is
|
||||
an operator decision (a manual `users.oidc_issuer` / `users.oidc_sub`
|
||||
update), not something a login can do.
|
||||
|
||||
## OIDC_ONLY
|
||||
|
||||
`OIDC_ONLY="true"` disables password and passkey login **server-side**
|
||||
(the routes return 403; hiding the buttons is not the boundary).
|
||||
Consequences to accept before turning it on:
|
||||
|
||||
- **The native iOS app cannot sign in.** It authenticates via
|
||||
password/passkey only; there is no native SSO flow yet. Do not enable
|
||||
`OIDC_ONLY` on a deployment with active iOS users.
|
||||
- **Existing sessions survive.** The flag gates new sign-ins, not live
|
||||
sessions — nobody is thrown out at flip time, including
|
||||
password-authenticated sessions.
|
||||
- **Self-disabling on half-config.** The flag only takes effect while
|
||||
the three provider vars are fully set, so a typo in the provider group
|
||||
can never lock every user out.
|
||||
|
||||
### Break-glass (IdP down or misconfigured)
|
||||
|
||||
1. Unset `OIDC_ONLY` (or set it to `"false"`) in `.env`, then
|
||||
`docker compose up -d`. Password and passkey login are live again
|
||||
immediately.
|
||||
2. Accounts that were **provisioned** through SSO have no password. Set
|
||||
one from the host, per [docs/ops/password-reset.md](../ops/password-reset.md):
|
||||
|
||||
```sh
|
||||
docker compose exec app node scripts/reset-password.mjs <username-or-email>
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause / fix |
|
||||
| --------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Login page shows "Single sign-on failed", log says `discovery issuer mismatch` | `OIDC_ISSUER_URL` must equal the `issuer` inside the discovery document. A single trailing slash either way is tolerated; anything else (scheme, host, port, path, `//`) is a hard reject. |
|
||||
| `discovery request failed: 3xx` | Discovery redirects are not followed (egress policy pins `redirect: "manual"`). Point `OIDC_ISSUER_URL` at the final URL — e.g. include the realm/application path, match `http` vs `https`, add/remove `www`. |
|
||||
| IdP shows its own "invalid redirect URI" page | The mismatch happens **at the IdP**, before HealthLog is involved. Compare the registered URI against `${NEXT_PUBLIC_APP_URL}/api/auth/oidc/callback` byte for byte — scheme and port included. |
|
||||
| Sign-in loops back with "failed" and the log shows an `exp`/`nbf` claim error | Clock skew beyond the 60 s tolerance. Fix NTP on the IdP or app box. |
|
||||
| "Your identity provider did not mark your email address as verified" | The IdP omits `email_verified` or sends `false`. Mark the address verified at the IdP (Keycloak: user → Email verified; Authentik: verify the email stage) — HealthLog will not link or provision without it. |
|
||||
| "That email address belongs to an account that is already linked to a different SSO identity" | Deliberate: identities never re-bind silently. See "Linking is once" above. |
|
||||
| SSO button missing | One of the three provider vars is empty or not whitelisted in compose. `pnpm check-env` shows which. |
|
||||
+11
-19
@@ -357,26 +357,18 @@ test.describe("document vault — AI assist + content search", () => {
|
||||
page,
|
||||
}) => {
|
||||
// CONTENT_DOC_ID is seeded with source "vision" (an AI provider read it).
|
||||
// No mocks: the real list GET threads the provenance through. v1.28.22 —
|
||||
// the marker lives on the vault CARD's meta row (an inline glyph beside
|
||||
// date · size), not in the sheet. Surface the card through search (the
|
||||
// grid is virtualized, so a targeted card must be brought into view the
|
||||
// way a user would).
|
||||
await page.goto("/documents");
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Documents" }),
|
||||
).toBeVisible();
|
||||
await page
|
||||
.getByRole("searchbox", { name: "Search documents" })
|
||||
.fill(CONTENT_BODY_WORD);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Open Radiology note" }),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
// No mocks: the real detail GET threads the provenance through. v1.28.38 —
|
||||
// the vault card no longer carries the AI-read glyph (it looked wrong on
|
||||
// the card face); the provenance now lives as a calm muted meta line in the
|
||||
// document DETAIL view. Deep-link straight to the document so the sheet
|
||||
// opens deterministically (the vault grid is virtualized).
|
||||
await page.goto(`/documents?doc=${CONTENT_DOC_ID}`);
|
||||
const sheet = page.getByRole("dialog");
|
||||
await expect(sheet).toBeVisible();
|
||||
|
||||
const marker = page.locator(
|
||||
`[data-document-id="${CONTENT_DOC_ID}"] [data-slot="document-card-ai-read"]`,
|
||||
);
|
||||
await expect(marker.first()).toBeVisible();
|
||||
const marker = sheet.locator('[data-slot="document-detail-ai-read"]');
|
||||
await expect(marker).toBeVisible();
|
||||
await expect(marker).toHaveText("Read by AI");
|
||||
});
|
||||
|
||||
// ── (f) Text-mode refuses a non-image before any OCR/upload ──────────────
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
/**
|
||||
* Issue #490 — client-rendered timestamps follow the PROFILE timezone.
|
||||
*
|
||||
* `/api/auth/me` is mocked with an `Asia/Manila` profile; the sessions
|
||||
* card on /settings/privacy renders `lastActiveAt` through
|
||||
* `useFormatters()`, which reads the localStorage timezone mirror that
|
||||
* `fetchMe` fills from the mocked payload. A fixed UTC instant must
|
||||
* therefore paint the MANILA wall clock (14:30), never the Berlin
|
||||
* fallback (08:30) and never the browser zone (the Playwright context is
|
||||
* pinned to Europe/Berlin precisely so this assertion is meaningful).
|
||||
*
|
||||
* Asserts against the stable `data-slot="security-session-row"` hook,
|
||||
* not viewport-dependent text.
|
||||
*/
|
||||
|
||||
// 06:30 UTC = 14:30 Asia/Manila (+8, no DST) = 08:30 Europe/Berlin (CEST).
|
||||
const LAST_ACTIVE_UTC = "2026-07-14T06:30:00.000Z";
|
||||
|
||||
test.describe("profile-timezone display (#490)", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
test("sessions card renders lastActiveAt in the Manila profile zone", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.route("**/api/auth/me", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
id: "user_e2e",
|
||||
username: "e2e-tester",
|
||||
email: "e2e@healthlog.test",
|
||||
role: "USER",
|
||||
heightCm: 180,
|
||||
dateOfBirth: "1990-01-01",
|
||||
gender: "MALE",
|
||||
timezone: "Asia/Manila",
|
||||
timeFormat: "H24",
|
||||
dateFormat: "AUTO",
|
||||
onboardingCompletedAt: "2025-01-01T00:00:00.000Z",
|
||||
onboardingTourCompleted: true,
|
||||
gravatarUrl: null,
|
||||
glucoseUnit: "mg/dL",
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await page.route("**/api/auth/me/sessions", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
sessions: [
|
||||
{
|
||||
id: "sess_e2e_current",
|
||||
device: "Chrome on macOS",
|
||||
ipMasked: "192.168.1.xxx",
|
||||
location: null,
|
||||
lastActiveAt: LAST_ACTIVE_UTC,
|
||||
createdAt: LAST_ACTIVE_UTC,
|
||||
isCurrent: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto("/settings/privacy", { waitUntil: "domcontentloaded" });
|
||||
|
||||
// The sessions card is collapsed by default — expand it first. The
|
||||
// toggle's onClick can be dropped if Playwright clicks before React has
|
||||
// hydrated the handler, leaving the region `hidden` for the whole wait.
|
||||
// Retry the click until `aria-expanded` actually flips.
|
||||
const toggle = page.locator(
|
||||
'[data-slot="settings-security-sessions-toggle"]',
|
||||
);
|
||||
await expect(toggle).toBeVisible({ timeout: 10_000 });
|
||||
await expect(async () => {
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "true", {
|
||||
timeout: 2_000,
|
||||
});
|
||||
}).toPass({ timeout: 15_000 });
|
||||
|
||||
const row = page.locator('[data-slot="security-session-row"]').first();
|
||||
await expect(row).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Manila wall clock (H24). The mirror write from the mocked /me may
|
||||
// land after the first paint of the sessions row — the poll absorbs
|
||||
// the re-render.
|
||||
await expect(row).toContainText("14:30", { timeout: 10_000 });
|
||||
// Never the Berlin fallback for this instant…
|
||||
await expect(row).not.toContainText("08:30");
|
||||
// …and never a 12-hour rendering (the H24 preference rides the same
|
||||
// mirror pipeline).
|
||||
await expect(row).not.toContainText("PM");
|
||||
});
|
||||
});
|
||||
+66
-7
@@ -286,6 +286,15 @@
|
||||
"passwordTooShort": "Passwort muss mindestens {minLength} Zeichen lang sein.",
|
||||
"privacyPolicy": "Datenschutzerklärung",
|
||||
"passwordBreached": "Dieses Passwort taucht in bekannten Datenlecks auf. Bitte wähle ein anderes.",
|
||||
"oidc": {
|
||||
"signInDefault": "Mit SSO anmelden",
|
||||
"errorDenied": "Die Anmeldung wurde abgebrochen.",
|
||||
"errorNoEmail": "Dein Identitätsanbieter hat keine bestätigte E-Mail-Adresse übermittelt.",
|
||||
"errorEmailUnverified": "Dein Identitätsanbieter hat deine E-Mail-Adresse nicht als bestätigt markiert.",
|
||||
"errorIdentityConflict": "Diese E-Mail-Adresse gehört zu einem Konto, das bereits mit einer anderen SSO-Identität verknüpft ist.",
|
||||
"errorRegistrationDisabled": "Kein Konto passt zu dieser Identität, und neue Registrierungen sind derzeit geschlossen.",
|
||||
"errorFailed": "Single Sign-On ist fehlgeschlagen. Bitte versuche es erneut."
|
||||
},
|
||||
"mfa": {
|
||||
"title": "Zwei-Faktor-Authentifizierung",
|
||||
"totpHint": "Gib den 6-stelligen Code aus deiner Authenticator-App ein.",
|
||||
@@ -4238,7 +4247,16 @@
|
||||
"other": "Sonstige",
|
||||
"unknown": "Unbekannt"
|
||||
},
|
||||
"subtitle": "Lege fest, welche Quelle gewinnt, wenn zwei dieselbe Messgröße liefern."
|
||||
"subtitle": "Lege fest, welche Quelle gewinnt, wenn zwei dieselbe Messgröße liefern.",
|
||||
"nutrients": {
|
||||
"title": "Synchronisierte Nährstoffzufuhr",
|
||||
"description": "Tagessummen aus Apple Health der letzten {days} Tage. Nur lesend — die Quell-App besitzt die Werte.",
|
||||
"empty": "Noch keine Nährstoffdaten synchronisiert.",
|
||||
"latest": "{amount} {unit} am {date}",
|
||||
"daysWithData": "{count} von {days} Tagen",
|
||||
"moduleHint": "Die Synchronisierung steuert das Modul „Nährstoffzufuhr“.",
|
||||
"moduleHintLink": "Module verwalten"
|
||||
}
|
||||
},
|
||||
"ai": {
|
||||
"title": "KI-Anbieter",
|
||||
@@ -7160,8 +7178,11 @@
|
||||
"TOTAL_BODY_WATER": "Körperwasser",
|
||||
"VISCERAL_FAT": "Viszeralfett",
|
||||
"BODY_MASS_INDEX": "Body-Mass-Index (BMI)",
|
||||
"WAIST_CIRCUMFERENCE": "Taillenumfang",
|
||||
"PHQ9_SCORE": "PHQ-9 · Stimmung",
|
||||
"GAD7_SCORE": "GAD-7 · Sorgen"
|
||||
"GAD7_SCORE": "GAD-7 · Sorgen",
|
||||
"WHO5_SCORE": "WHO-5 · Wohlbefinden",
|
||||
"SCI_SCORE": "SCI · Schlaf"
|
||||
},
|
||||
"typeGroups": {
|
||||
"vitals": "Vitalwerte",
|
||||
@@ -7570,6 +7591,10 @@
|
||||
"mentalHealth": {
|
||||
"label": "Seelisches Wohlbefinden",
|
||||
"description": "Freiwillige PHQ-9- und GAD-7-Selbsttests ergänzend zum Stimmungstagebuch. Standardmäßig aus; Antworten sind verschlüsselt und bleiben aus dem KI-Coach heraus."
|
||||
},
|
||||
"nutrients": {
|
||||
"label": "Nährstoffzufuhr",
|
||||
"description": "Speichert tägliche Mikronährstoff-Summen (Vitamine, Mineralstoffe, Wasser, Koffein) aus Apple Health. Standardmäßig aus."
|
||||
}
|
||||
},
|
||||
"illness": {
|
||||
@@ -7726,12 +7751,12 @@
|
||||
"RECOVERY_SCORE": "Recovery",
|
||||
"OXYGEN_SATURATION": "Sauerstoffsättigung",
|
||||
"BODY_TEMPERATURE": "Körpertemperatur",
|
||||
"SKIN_TEMPERATURE": "Hauttemperatur",
|
||||
"RESPIRATORY_RATE": "Atemfrequenz",
|
||||
"BLOOD_PRESSURE_SYS": "Systolischer Blutdruck",
|
||||
"BLOOD_PRESSURE_DIA": "Diastolischer Blutdruck",
|
||||
"PULSE": "Pulse",
|
||||
"WEIGHT": "Weight"
|
||||
"WEIGHT": "Weight",
|
||||
"FUNCTIONAL_IMPACT": "Funktionelle Beeinträchtigung"
|
||||
},
|
||||
"listLoadError": "Episoden konnten nicht geladen werden."
|
||||
},
|
||||
@@ -8280,7 +8305,9 @@
|
||||
"deleted": "{count} Dokument(e) gelöscht",
|
||||
"restored": "{count} Dokument(e) wiederhergestellt",
|
||||
"partialFailure": "{failed} von {total} konnten nicht aktualisiert werden",
|
||||
"failed": "Die Aktion konnte nicht ausgeführt werden"
|
||||
"failed": "Die Aktion konnte nicht ausgeführt werden",
|
||||
"share": "Teilen",
|
||||
"shareTooMany": "Ein Freigabelink fasst höchstens {max} Dokumente. Wähle einige ab und versuche es erneut."
|
||||
},
|
||||
"episodeCard": {
|
||||
"title": "Dokumente",
|
||||
@@ -8344,6 +8371,8 @@
|
||||
},
|
||||
"share": {
|
||||
"title": "Dieses Dokument teilen",
|
||||
"multiTitle": "Diese Dokumente teilen",
|
||||
"multiLabel": "{count} Dokumente",
|
||||
"description": "Erstelle einen schreibgeschützten Ärzte-Link mit diesem angehängten Dokument. Link und Passphrase erscheinen nur einmal."
|
||||
},
|
||||
"chat": {
|
||||
@@ -8363,8 +8392,8 @@
|
||||
"errorCredential": "Der KI-Anbieter muss in den Einstellungen neu verbunden werden.",
|
||||
"errorConsent": "Aktiviere die KI-Zustimmung in den Einstellungen, um über dieses Dokument zu chatten.",
|
||||
"errorNetwork": "Du scheinst offline zu sein. Prüfe deine Verbindung und versuche es erneut.",
|
||||
"openAria": "Über dieses Dokument chatten",
|
||||
"openButton": "Zu diesem Dokument fragen",
|
||||
"openAria": "Coach über dieses Dokument fragen",
|
||||
"openButton": "Coach fragen",
|
||||
"scopeBadge": "Chat über: {title}"
|
||||
}
|
||||
},
|
||||
@@ -8452,5 +8481,35 @@
|
||||
"valuesDescription": "Alle erfassten Werte dieser Metrik.",
|
||||
"backToMetric": "Zurück zu {metric}"
|
||||
}
|
||||
},
|
||||
"nutrients": {
|
||||
"names": {
|
||||
"vitamin_a": "Vitamin A",
|
||||
"thiamin": "Thiamin (B1)",
|
||||
"riboflavin": "Riboflavin (B2)",
|
||||
"niacin": "Niacin (B3)",
|
||||
"pantothenic_acid": "Pantothensäure (B5)",
|
||||
"vitamin_b6": "Vitamin B6",
|
||||
"biotin": "Biotin (B7)",
|
||||
"folate": "Folat (B9)",
|
||||
"vitamin_b12": "Vitamin B12",
|
||||
"vitamin_c": "Vitamin C",
|
||||
"vitamin_d": "Vitamin D",
|
||||
"vitamin_e": "Vitamin E",
|
||||
"vitamin_k": "Vitamin K",
|
||||
"calcium": "Calcium",
|
||||
"iron": "Eisen",
|
||||
"magnesium": "Magnesium",
|
||||
"phosphorus": "Phosphor",
|
||||
"zinc": "Zink",
|
||||
"copper": "Kupfer",
|
||||
"manganese": "Mangan",
|
||||
"selenium": "Selen",
|
||||
"chromium": "Chrom",
|
||||
"molybdenum": "Molybdän",
|
||||
"iodine": "Jod",
|
||||
"water": "Wasser",
|
||||
"caffeine": "Koffein"
|
||||
}
|
||||
}
|
||||
}
|
||||
+66
-7
@@ -286,6 +286,15 @@
|
||||
"passwordTooShort": "Password must be at least {minLength} characters long.",
|
||||
"privacyPolicy": "Privacy policy",
|
||||
"passwordBreached": "This password has appeared in known data breaches. Please choose a different one.",
|
||||
"oidc": {
|
||||
"signInDefault": "Sign in with SSO",
|
||||
"errorDenied": "Sign-in was cancelled.",
|
||||
"errorNoEmail": "Your identity provider did not share a verified email address.",
|
||||
"errorEmailUnverified": "Your identity provider did not mark your email address as verified.",
|
||||
"errorIdentityConflict": "That email address belongs to an account that is already linked to a different SSO identity.",
|
||||
"errorRegistrationDisabled": "No account matches that identity, and new sign-ups are currently closed.",
|
||||
"errorFailed": "Single sign-on failed. Please try again."
|
||||
},
|
||||
"mfa": {
|
||||
"title": "Two-factor authentication",
|
||||
"totpHint": "Enter the 6-digit code from your authenticator app.",
|
||||
@@ -4238,7 +4247,16 @@
|
||||
"other": "Other",
|
||||
"unknown": "Unknown"
|
||||
},
|
||||
"subtitle": "Decide which source wins when two report the same metric."
|
||||
"subtitle": "Decide which source wins when two report the same metric.",
|
||||
"nutrients": {
|
||||
"title": "Synced nutrient intake",
|
||||
"description": "Daily totals synced from Apple Health over the last {days} days. Read-only — the source app owns the numbers.",
|
||||
"empty": "No nutrient data has been synced yet.",
|
||||
"latest": "{amount} {unit} on {date}",
|
||||
"daysWithData": "{count} of {days} days",
|
||||
"moduleHint": "Syncing is controlled by the Nutrient intake module.",
|
||||
"moduleHintLink": "Manage modules"
|
||||
}
|
||||
},
|
||||
"ai": {
|
||||
"title": "AI provider",
|
||||
@@ -7160,8 +7178,11 @@
|
||||
"TOTAL_BODY_WATER": "Body water",
|
||||
"VISCERAL_FAT": "Visceral fat",
|
||||
"BODY_MASS_INDEX": "Body mass index (BMI)",
|
||||
"WAIST_CIRCUMFERENCE": "Waist circumference",
|
||||
"PHQ9_SCORE": "PHQ-9 · mood",
|
||||
"GAD7_SCORE": "GAD-7 · worry"
|
||||
"GAD7_SCORE": "GAD-7 · worry",
|
||||
"WHO5_SCORE": "WHO-5 · wellbeing",
|
||||
"SCI_SCORE": "SCI · sleep"
|
||||
},
|
||||
"typeGroups": {
|
||||
"vitals": "Vitals",
|
||||
@@ -7570,6 +7591,10 @@
|
||||
"mentalHealth": {
|
||||
"label": "Mental wellbeing",
|
||||
"description": "Opt-in PHQ-9 and GAD-7 screeners beside mood tracking. Off by default; answers are encrypted and kept out of the AI Coach."
|
||||
},
|
||||
"nutrients": {
|
||||
"label": "Nutrient intake",
|
||||
"description": "Store daily micronutrient totals (vitamins, minerals, water, caffeine) synced from Apple Health. Off by default."
|
||||
}
|
||||
},
|
||||
"illness": {
|
||||
@@ -7726,12 +7751,12 @@
|
||||
"RECOVERY_SCORE": "Recovery",
|
||||
"OXYGEN_SATURATION": "Oxygen saturation",
|
||||
"BODY_TEMPERATURE": "Body temperature",
|
||||
"SKIN_TEMPERATURE": "Skin temperature",
|
||||
"RESPIRATORY_RATE": "Respiratory rate",
|
||||
"BLOOD_PRESSURE_SYS": "Systolic blood pressure",
|
||||
"BLOOD_PRESSURE_DIA": "Diastolic blood pressure",
|
||||
"PULSE": "Pulse",
|
||||
"WEIGHT": "Weight"
|
||||
"WEIGHT": "Weight",
|
||||
"FUNCTIONAL_IMPACT": "Functional impact"
|
||||
},
|
||||
"listLoadError": "Could not load your episodes."
|
||||
},
|
||||
@@ -8280,7 +8305,9 @@
|
||||
"deleted": "{count} document(s) deleted",
|
||||
"restored": "{count} document(s) restored",
|
||||
"partialFailure": "{failed} of {total} could not be updated",
|
||||
"failed": "The action could not be completed"
|
||||
"failed": "The action could not be completed",
|
||||
"share": "Share",
|
||||
"shareTooMany": "A share link can hold at most {max} documents. Deselect some and try again."
|
||||
},
|
||||
"episodeCard": {
|
||||
"title": "Documents",
|
||||
@@ -8344,6 +8371,8 @@
|
||||
},
|
||||
"share": {
|
||||
"title": "Share this document",
|
||||
"multiTitle": "Share these documents",
|
||||
"multiLabel": "{count} documents",
|
||||
"description": "Create a read-only clinician link with this document attached. The link and its passphrase appear once."
|
||||
},
|
||||
"chat": {
|
||||
@@ -8363,8 +8392,8 @@
|
||||
"errorCredential": "The AI provider needs reconnecting in settings.",
|
||||
"errorConsent": "Turn on AI consent in settings to chat about this document.",
|
||||
"errorNetwork": "You appear to be offline. Check your connection and try again.",
|
||||
"openAria": "Chat about this document",
|
||||
"openButton": "Ask about this document",
|
||||
"openAria": "Ask the Coach about this document",
|
||||
"openButton": "Ask the Coach",
|
||||
"scopeBadge": "Chatting about: {title}"
|
||||
}
|
||||
},
|
||||
@@ -8452,5 +8481,35 @@
|
||||
"valuesDescription": "Every logged value for this metric.",
|
||||
"backToMetric": "Back to {metric}"
|
||||
}
|
||||
},
|
||||
"nutrients": {
|
||||
"names": {
|
||||
"vitamin_a": "Vitamin A",
|
||||
"thiamin": "Thiamin (B1)",
|
||||
"riboflavin": "Riboflavin (B2)",
|
||||
"niacin": "Niacin (B3)",
|
||||
"pantothenic_acid": "Pantothenic acid (B5)",
|
||||
"vitamin_b6": "Vitamin B6",
|
||||
"biotin": "Biotin (B7)",
|
||||
"folate": "Folate (B9)",
|
||||
"vitamin_b12": "Vitamin B12",
|
||||
"vitamin_c": "Vitamin C",
|
||||
"vitamin_d": "Vitamin D",
|
||||
"vitamin_e": "Vitamin E",
|
||||
"vitamin_k": "Vitamin K",
|
||||
"calcium": "Calcium",
|
||||
"iron": "Iron",
|
||||
"magnesium": "Magnesium",
|
||||
"phosphorus": "Phosphorus",
|
||||
"zinc": "Zinc",
|
||||
"copper": "Copper",
|
||||
"manganese": "Manganese",
|
||||
"selenium": "Selenium",
|
||||
"chromium": "Chromium",
|
||||
"molybdenum": "Molybdenum",
|
||||
"iodine": "Iodine",
|
||||
"water": "Water",
|
||||
"caffeine": "Caffeine"
|
||||
}
|
||||
}
|
||||
}
|
||||
+66
-7
@@ -286,6 +286,15 @@
|
||||
"passwordTooShort": "La contraseña debe tener al menos {minLength} caracteres.",
|
||||
"privacyPolicy": "Política de privacidad",
|
||||
"passwordBreached": "Esta contraseña aparece en filtraciones de datos conocidas. Elige otra.",
|
||||
"oidc": {
|
||||
"signInDefault": "Iniciar sesión con SSO",
|
||||
"errorDenied": "El inicio de sesión se canceló.",
|
||||
"errorNoEmail": "Tu proveedor de identidad no compartió un correo electrónico verificado.",
|
||||
"errorEmailUnverified": "Tu proveedor de identidad no marcó tu correo electrónico como verificado.",
|
||||
"errorIdentityConflict": "Ese correo electrónico pertenece a una cuenta que ya está vinculada a otra identidad SSO.",
|
||||
"errorRegistrationDisabled": "Ninguna cuenta coincide con esa identidad y los nuevos registros están cerrados actualmente.",
|
||||
"errorFailed": "El inicio de sesión único falló. Inténtalo de nuevo."
|
||||
},
|
||||
"mfa": {
|
||||
"title": "Autenticación de dos factores",
|
||||
"totpHint": "Introduce el código de 6 dígitos de tu aplicación de autenticación.",
|
||||
@@ -4238,7 +4247,16 @@
|
||||
"other": "Otro",
|
||||
"unknown": "Desconocido"
|
||||
},
|
||||
"subtitle": "Decide qué fuente prevalece cuando dos informan la misma métrica."
|
||||
"subtitle": "Decide qué fuente prevalece cuando dos informan la misma métrica.",
|
||||
"nutrients": {
|
||||
"title": "Ingesta de nutrientes sincronizada",
|
||||
"description": "Totales diarios sincronizados desde Apple Health en los últimos {days} días. Solo lectura: la app de origen es la dueña de los valores.",
|
||||
"empty": "Todavía no se han sincronizado datos de nutrientes.",
|
||||
"latest": "{amount} {unit} el {date}",
|
||||
"daysWithData": "{count} de {days} días",
|
||||
"moduleHint": "La sincronización se controla con el módulo de ingesta de nutrientes.",
|
||||
"moduleHintLink": "Gestionar módulos"
|
||||
}
|
||||
},
|
||||
"ai": {
|
||||
"title": "Proveedor de IA",
|
||||
@@ -7160,8 +7178,11 @@
|
||||
"TOTAL_BODY_WATER": "Agua corporal",
|
||||
"VISCERAL_FAT": "Grasa visceral",
|
||||
"BODY_MASS_INDEX": "Índice de masa corporal (IMC)",
|
||||
"WAIST_CIRCUMFERENCE": "Perímetro de cintura",
|
||||
"PHQ9_SCORE": "PHQ-9 · ánimo",
|
||||
"GAD7_SCORE": "GAD-7 · preocupación"
|
||||
"GAD7_SCORE": "GAD-7 · preocupación",
|
||||
"WHO5_SCORE": "WHO-5 · bienestar",
|
||||
"SCI_SCORE": "SCI · sueño"
|
||||
},
|
||||
"typeGroups": {
|
||||
"vitals": "Constantes vitales",
|
||||
@@ -7570,6 +7591,10 @@
|
||||
"mentalHealth": {
|
||||
"label": "Bienestar mental",
|
||||
"description": "Cuestionarios PHQ-9 y GAD-7 opcionales, junto al registro de ánimo. Desactivado por defecto; las respuestas se cifran y se mantienen fuera del Coach de IA."
|
||||
},
|
||||
"nutrients": {
|
||||
"label": "Ingesta de nutrientes",
|
||||
"description": "Guarda los totales diarios de micronutrientes (vitaminas, minerales, agua, cafeína) sincronizados desde Apple Health. Desactivado por defecto."
|
||||
}
|
||||
},
|
||||
"illness": {
|
||||
@@ -7726,12 +7751,12 @@
|
||||
"RECOVERY_SCORE": "Recovery",
|
||||
"OXYGEN_SATURATION": "Saturación de oxígeno",
|
||||
"BODY_TEMPERATURE": "Temperatura corporal",
|
||||
"SKIN_TEMPERATURE": "Temperatura de la piel",
|
||||
"RESPIRATORY_RATE": "Frecuencia respiratoria",
|
||||
"BLOOD_PRESSURE_SYS": "Presión arterial sistólica",
|
||||
"BLOOD_PRESSURE_DIA": "Presión arterial diastólica",
|
||||
"PULSE": "Pulse",
|
||||
"WEIGHT": "Weight"
|
||||
"WEIGHT": "Weight",
|
||||
"FUNCTIONAL_IMPACT": "Impacto funcional"
|
||||
},
|
||||
"listLoadError": "No se pudieron cargar tus episodios."
|
||||
},
|
||||
@@ -8280,7 +8305,9 @@
|
||||
"deleted": "{count} documento(s) eliminados",
|
||||
"restored": "{count} documento(s) restaurados",
|
||||
"partialFailure": "No se pudieron actualizar {failed} de {total}",
|
||||
"failed": "No se pudo completar la acción"
|
||||
"failed": "No se pudo completar la acción",
|
||||
"share": "Compartir",
|
||||
"shareTooMany": "Un enlace para compartir admite como máximo {max} documentos. Deselecciona algunos e inténtalo de nuevo."
|
||||
},
|
||||
"episodeCard": {
|
||||
"title": "Documentos",
|
||||
@@ -8344,6 +8371,8 @@
|
||||
},
|
||||
"share": {
|
||||
"title": "Compartir este documento",
|
||||
"multiTitle": "Compartir estos documentos",
|
||||
"multiLabel": "{count} documentos",
|
||||
"description": "Crea un enlace de solo lectura para profesionales con este documento adjunto. El enlace y su frase de contraseña aparecen una sola vez."
|
||||
},
|
||||
"chat": {
|
||||
@@ -8363,8 +8392,8 @@
|
||||
"errorCredential": "Hay que volver a conectar el proveedor de IA en los ajustes.",
|
||||
"errorConsent": "Activa el consentimiento de IA en los ajustes para chatear sobre este documento.",
|
||||
"errorNetwork": "Parece que no tienes conexión. Comprueba tu red e inténtalo de nuevo.",
|
||||
"openAria": "Chatea sobre este documento",
|
||||
"openButton": "Pregunta sobre este documento",
|
||||
"openAria": "Pregunta al Coach sobre este documento",
|
||||
"openButton": "Preguntar al Coach",
|
||||
"scopeBadge": "Chateando sobre: {title}"
|
||||
}
|
||||
},
|
||||
@@ -8452,5 +8481,35 @@
|
||||
"valuesDescription": "Todos los valores registrados de esta métrica.",
|
||||
"backToMetric": "Volver a {metric}"
|
||||
}
|
||||
},
|
||||
"nutrients": {
|
||||
"names": {
|
||||
"vitamin_a": "Vitamina A",
|
||||
"thiamin": "Tiamina (B1)",
|
||||
"riboflavin": "Riboflavina (B2)",
|
||||
"niacin": "Niacina (B3)",
|
||||
"pantothenic_acid": "Ácido pantoténico (B5)",
|
||||
"vitamin_b6": "Vitamina B6",
|
||||
"biotin": "Biotina (B7)",
|
||||
"folate": "Folato (B9)",
|
||||
"vitamin_b12": "Vitamina B12",
|
||||
"vitamin_c": "Vitamina C",
|
||||
"vitamin_d": "Vitamina D",
|
||||
"vitamin_e": "Vitamina E",
|
||||
"vitamin_k": "Vitamina K",
|
||||
"calcium": "Calcio",
|
||||
"iron": "Hierro",
|
||||
"magnesium": "Magnesio",
|
||||
"phosphorus": "Fósforo",
|
||||
"zinc": "Zinc",
|
||||
"copper": "Cobre",
|
||||
"manganese": "Manganeso",
|
||||
"selenium": "Selenio",
|
||||
"chromium": "Cromo",
|
||||
"molybdenum": "Molibdeno",
|
||||
"iodine": "Yodo",
|
||||
"water": "Agua",
|
||||
"caffeine": "Cafeína"
|
||||
}
|
||||
}
|
||||
}
|
||||
+66
-7
@@ -286,6 +286,15 @@
|
||||
"passwordTooShort": "Le mot de passe doit comporter au moins {minLength} caractères.",
|
||||
"privacyPolicy": "Politique de confidentialité",
|
||||
"passwordBreached": "Ce mot de passe figure dans des fuites de données connues. Choisissez-en un autre.",
|
||||
"oidc": {
|
||||
"signInDefault": "Se connecter avec le SSO",
|
||||
"errorDenied": "La connexion a été annulée.",
|
||||
"errorNoEmail": "Votre fournisseur d'identité n'a pas transmis d'adresse e-mail vérifiée.",
|
||||
"errorEmailUnverified": "Votre fournisseur d'identité n'a pas marqué votre adresse e-mail comme vérifiée.",
|
||||
"errorIdentityConflict": "Cette adresse e-mail appartient à un compte déjà lié à une autre identité SSO.",
|
||||
"errorRegistrationDisabled": "Aucun compte ne correspond à cette identité et les nouvelles inscriptions sont actuellement fermées.",
|
||||
"errorFailed": "L'authentification unique a échoué. Veuillez réessayer."
|
||||
},
|
||||
"mfa": {
|
||||
"title": "Authentification à deux facteurs",
|
||||
"totpHint": "Saisissez le code à 6 chiffres de votre application d'authentification.",
|
||||
@@ -4238,7 +4247,16 @@
|
||||
"other": "Autre",
|
||||
"unknown": "Inconnu"
|
||||
},
|
||||
"subtitle": "Choisissez la source prioritaire en cas de doublon de mesure."
|
||||
"subtitle": "Choisissez la source prioritaire en cas de doublon de mesure.",
|
||||
"nutrients": {
|
||||
"title": "Apports en nutriments synchronisés",
|
||||
"description": "Totaux quotidiens synchronisés depuis Apple Health sur les {days} derniers jours. Lecture seule : l'app source reste propriétaire des valeurs.",
|
||||
"empty": "Aucune donnée de nutriments synchronisée pour l'instant.",
|
||||
"latest": "{amount} {unit} le {date}",
|
||||
"daysWithData": "{count} jours sur {days}",
|
||||
"moduleHint": "La synchronisation est contrôlée par le module Apports en nutriments.",
|
||||
"moduleHintLink": "Gérer les modules"
|
||||
}
|
||||
},
|
||||
"ai": {
|
||||
"title": "Fournisseur d'IA",
|
||||
@@ -7160,8 +7178,11 @@
|
||||
"TOTAL_BODY_WATER": "Eau corporelle",
|
||||
"VISCERAL_FAT": "Graisse viscérale",
|
||||
"BODY_MASS_INDEX": "Indice de masse corporelle (IMC)",
|
||||
"WAIST_CIRCUMFERENCE": "Tour de taille",
|
||||
"PHQ9_SCORE": "PHQ-9 · humeur",
|
||||
"GAD7_SCORE": "GAD-7 · inquiétude"
|
||||
"GAD7_SCORE": "GAD-7 · inquiétude",
|
||||
"WHO5_SCORE": "WHO-5 · bien-être",
|
||||
"SCI_SCORE": "SCI · sommeil"
|
||||
},
|
||||
"typeGroups": {
|
||||
"vitals": "Constantes vitales",
|
||||
@@ -7570,6 +7591,10 @@
|
||||
"mentalHealth": {
|
||||
"label": "Bien-être mental",
|
||||
"description": "Questionnaires PHQ-9 et GAD-7 facultatifs, en complément du suivi de l'humeur. Désactivé par défaut ; les réponses sont chiffrées et tenues à l'écart du Coach IA."
|
||||
},
|
||||
"nutrients": {
|
||||
"label": "Apports en nutriments",
|
||||
"description": "Enregistre les totaux quotidiens de micronutriments (vitamines, minéraux, eau, caféine) synchronisés depuis Apple Health. Désactivé par défaut."
|
||||
}
|
||||
},
|
||||
"illness": {
|
||||
@@ -7726,12 +7751,12 @@
|
||||
"RECOVERY_SCORE": "Recovery",
|
||||
"OXYGEN_SATURATION": "Saturation en oxygène",
|
||||
"BODY_TEMPERATURE": "Température corporelle",
|
||||
"SKIN_TEMPERATURE": "Température cutanée",
|
||||
"RESPIRATORY_RATE": "Fréquence respiratoire",
|
||||
"BLOOD_PRESSURE_SYS": "Pression artérielle systolique",
|
||||
"BLOOD_PRESSURE_DIA": "Pression artérielle diastolique",
|
||||
"PULSE": "Pulse",
|
||||
"WEIGHT": "Weight"
|
||||
"WEIGHT": "Weight",
|
||||
"FUNCTIONAL_IMPACT": "Retentissement fonctionnel"
|
||||
},
|
||||
"listLoadError": "Impossible de charger vos épisodes."
|
||||
},
|
||||
@@ -8280,7 +8305,9 @@
|
||||
"deleted": "{count} document(s) supprimés",
|
||||
"restored": "{count} document(s) restaurés",
|
||||
"partialFailure": "Impossible de mettre à jour {failed} sur {total}",
|
||||
"failed": "L'action n'a pas pu être effectuée"
|
||||
"failed": "L'action n'a pas pu être effectuée",
|
||||
"share": "Partager",
|
||||
"shareTooMany": "Un lien de partage contient au maximum {max} documents. Désélectionnez-en quelques-uns et réessayez."
|
||||
},
|
||||
"episodeCard": {
|
||||
"title": "Documents",
|
||||
@@ -8344,6 +8371,8 @@
|
||||
},
|
||||
"share": {
|
||||
"title": "Partager ce document",
|
||||
"multiTitle": "Partager ces documents",
|
||||
"multiLabel": "{count} documents",
|
||||
"description": "Créez un lien clinicien en lecture seule avec ce document joint. Le lien et sa phrase secrète n’apparaissent qu’une seule fois."
|
||||
},
|
||||
"chat": {
|
||||
@@ -8363,8 +8392,8 @@
|
||||
"errorCredential": "Le fournisseur d'IA doit être reconnecté dans les paramètres.",
|
||||
"errorConsent": "Activez le consentement à l'IA dans les paramètres pour discuter de ce document.",
|
||||
"errorNetwork": "Vous semblez hors ligne. Vérifiez votre connexion et réessayez.",
|
||||
"openAria": "Discuter de ce document",
|
||||
"openButton": "Poser une question sur ce document",
|
||||
"openAria": "Interroger le Coach sur ce document",
|
||||
"openButton": "Demander au Coach",
|
||||
"scopeBadge": "Discussion sur : {title}"
|
||||
}
|
||||
},
|
||||
@@ -8452,5 +8481,35 @@
|
||||
"valuesDescription": "Toutes les valeurs enregistrées pour cette mesure.",
|
||||
"backToMetric": "Retour à {metric}"
|
||||
}
|
||||
},
|
||||
"nutrients": {
|
||||
"names": {
|
||||
"vitamin_a": "Vitamine A",
|
||||
"thiamin": "Thiamine (B1)",
|
||||
"riboflavin": "Riboflavine (B2)",
|
||||
"niacin": "Niacine (B3)",
|
||||
"pantothenic_acid": "Acide pantothénique (B5)",
|
||||
"vitamin_b6": "Vitamine B6",
|
||||
"biotin": "Biotine (B7)",
|
||||
"folate": "Folates (B9)",
|
||||
"vitamin_b12": "Vitamine B12",
|
||||
"vitamin_c": "Vitamine C",
|
||||
"vitamin_d": "Vitamine D",
|
||||
"vitamin_e": "Vitamine E",
|
||||
"vitamin_k": "Vitamine K",
|
||||
"calcium": "Calcium",
|
||||
"iron": "Fer",
|
||||
"magnesium": "Magnésium",
|
||||
"phosphorus": "Phosphore",
|
||||
"zinc": "Zinc",
|
||||
"copper": "Cuivre",
|
||||
"manganese": "Manganèse",
|
||||
"selenium": "Sélénium",
|
||||
"chromium": "Chrome",
|
||||
"molybdenum": "Molybdène",
|
||||
"iodine": "Iode",
|
||||
"water": "Eau",
|
||||
"caffeine": "Caféine"
|
||||
}
|
||||
}
|
||||
}
|
||||
+66
-7
@@ -286,6 +286,15 @@
|
||||
"passwordTooShort": "La password deve avere almeno {minLength} caratteri.",
|
||||
"privacyPolicy": "Informativa sulla privacy",
|
||||
"passwordBreached": "Questa password compare in violazioni di dati note. Scegline un'altra.",
|
||||
"oidc": {
|
||||
"signInDefault": "Accedi con SSO",
|
||||
"errorDenied": "L'accesso è stato annullato.",
|
||||
"errorNoEmail": "Il tuo provider di identità non ha condiviso un indirizzo email verificato.",
|
||||
"errorEmailUnverified": "Il tuo provider di identità non ha contrassegnato il tuo indirizzo email come verificato.",
|
||||
"errorIdentityConflict": "Questo indirizzo email appartiene a un account già collegato a un'altra identità SSO.",
|
||||
"errorRegistrationDisabled": "Nessun account corrisponde a questa identità e le nuove registrazioni sono attualmente chiuse.",
|
||||
"errorFailed": "L'accesso Single Sign-On non è riuscito. Riprova."
|
||||
},
|
||||
"mfa": {
|
||||
"title": "Autenticazione a due fattori",
|
||||
"totpHint": "Inserisci il codice a 6 cifre della tua app di autenticazione.",
|
||||
@@ -4238,7 +4247,16 @@
|
||||
"other": "Altro",
|
||||
"unknown": "Sconosciuto"
|
||||
},
|
||||
"subtitle": "Decidi quale fonte prevale quando due riportano la stessa metrica."
|
||||
"subtitle": "Decidi quale fonte prevale quando due riportano la stessa metrica.",
|
||||
"nutrients": {
|
||||
"title": "Assunzione di nutrienti sincronizzata",
|
||||
"description": "Totali giornalieri sincronizzati da Apple Health negli ultimi {days} giorni. Sola lettura: i valori appartengono all'app di origine.",
|
||||
"empty": "Nessun dato sui nutrienti ancora sincronizzato.",
|
||||
"latest": "{amount} {unit} il {date}",
|
||||
"daysWithData": "{count} giorni su {days}",
|
||||
"moduleHint": "La sincronizzazione è controllata dal modulo Assunzione di nutrienti.",
|
||||
"moduleHintLink": "Gestisci moduli"
|
||||
}
|
||||
},
|
||||
"ai": {
|
||||
"title": "Provider IA",
|
||||
@@ -7160,8 +7178,11 @@
|
||||
"TOTAL_BODY_WATER": "Acqua corporea",
|
||||
"VISCERAL_FAT": "Grasso viscerale",
|
||||
"BODY_MASS_INDEX": "Indice di massa corporea (IMC)",
|
||||
"WAIST_CIRCUMFERENCE": "Circonferenza vita",
|
||||
"PHQ9_SCORE": "PHQ-9 · umore",
|
||||
"GAD7_SCORE": "GAD-7 · preoccupazione"
|
||||
"GAD7_SCORE": "GAD-7 · preoccupazione",
|
||||
"WHO5_SCORE": "WHO-5 · benessere",
|
||||
"SCI_SCORE": "SCI · sonno"
|
||||
},
|
||||
"typeGroups": {
|
||||
"vitals": "Parametri vitali",
|
||||
@@ -7570,6 +7591,10 @@
|
||||
"mentalHealth": {
|
||||
"label": "Benessere mentale",
|
||||
"description": "Questionari PHQ-9 e GAD-7 facoltativi, accanto al diario dell'umore. Disattivato per impostazione predefinita; le risposte sono cifrate e tenute fuori dal Coach IA."
|
||||
},
|
||||
"nutrients": {
|
||||
"label": "Assunzione di nutrienti",
|
||||
"description": "Salva i totali giornalieri di micronutrienti (vitamine, minerali, acqua, caffeina) sincronizzati da Apple Health. Disattivato per impostazione predefinita."
|
||||
}
|
||||
},
|
||||
"illness": {
|
||||
@@ -7726,12 +7751,12 @@
|
||||
"RECOVERY_SCORE": "Recovery",
|
||||
"OXYGEN_SATURATION": "Saturazione di ossigeno",
|
||||
"BODY_TEMPERATURE": "Temperatura corporea",
|
||||
"SKIN_TEMPERATURE": "Temperatura cutanea",
|
||||
"RESPIRATORY_RATE": "Frequenza respiratoria",
|
||||
"BLOOD_PRESSURE_SYS": "Pressione arteriosa sistolica",
|
||||
"BLOOD_PRESSURE_DIA": "Pressione arteriosa diastolica",
|
||||
"PULSE": "Pulse",
|
||||
"WEIGHT": "Weight"
|
||||
"WEIGHT": "Weight",
|
||||
"FUNCTIONAL_IMPACT": "Impatto funzionale"
|
||||
},
|
||||
"listLoadError": "Impossibile caricare gli episodi."
|
||||
},
|
||||
@@ -8280,7 +8305,9 @@
|
||||
"deleted": "{count} documenti eliminati",
|
||||
"restored": "{count} documenti ripristinati",
|
||||
"partialFailure": "Impossibile aggiornare {failed} su {total}",
|
||||
"failed": "Impossibile completare l'azione"
|
||||
"failed": "Impossibile completare l'azione",
|
||||
"share": "Condividi",
|
||||
"shareTooMany": "Un link di condivisione contiene al massimo {max} documenti. Deseleziona alcuni documenti e riprova."
|
||||
},
|
||||
"episodeCard": {
|
||||
"title": "Documenti",
|
||||
@@ -8344,6 +8371,8 @@
|
||||
},
|
||||
"share": {
|
||||
"title": "Condividi questo documento",
|
||||
"multiTitle": "Condividi questi documenti",
|
||||
"multiLabel": "{count} documenti",
|
||||
"description": "Crea un link di sola lettura per il medico con questo documento allegato. Il link e la relativa passphrase compaiono una sola volta."
|
||||
},
|
||||
"chat": {
|
||||
@@ -8363,8 +8392,8 @@
|
||||
"errorCredential": "Il provider IA va ricollegato nelle impostazioni.",
|
||||
"errorConsent": "Attiva il consenso all'IA nelle impostazioni per chattare su questo documento.",
|
||||
"errorNetwork": "Sembri offline. Controlla la connessione e riprova.",
|
||||
"openAria": "Chatta su questo documento",
|
||||
"openButton": "Fai una domanda su questo documento",
|
||||
"openAria": "Chiedi al Coach su questo documento",
|
||||
"openButton": "Chiedi al Coach",
|
||||
"scopeBadge": "Stai chattando su: {title}"
|
||||
}
|
||||
},
|
||||
@@ -8452,5 +8481,35 @@
|
||||
"valuesDescription": "Tutti i valori registrati per questa metrica.",
|
||||
"backToMetric": "Torna a {metric}"
|
||||
}
|
||||
},
|
||||
"nutrients": {
|
||||
"names": {
|
||||
"vitamin_a": "Vitamina A",
|
||||
"thiamin": "Tiamina (B1)",
|
||||
"riboflavin": "Riboflavina (B2)",
|
||||
"niacin": "Niacina (B3)",
|
||||
"pantothenic_acid": "Acido pantotenico (B5)",
|
||||
"vitamin_b6": "Vitamina B6",
|
||||
"biotin": "Biotina (B7)",
|
||||
"folate": "Folati (B9)",
|
||||
"vitamin_b12": "Vitamina B12",
|
||||
"vitamin_c": "Vitamina C",
|
||||
"vitamin_d": "Vitamina D",
|
||||
"vitamin_e": "Vitamina E",
|
||||
"vitamin_k": "Vitamina K",
|
||||
"calcium": "Calcio",
|
||||
"iron": "Ferro",
|
||||
"magnesium": "Magnesio",
|
||||
"phosphorus": "Fosforo",
|
||||
"zinc": "Zinco",
|
||||
"copper": "Rame",
|
||||
"manganese": "Manganese",
|
||||
"selenium": "Selenio",
|
||||
"chromium": "Cromo",
|
||||
"molybdenum": "Molibdeno",
|
||||
"iodine": "Iodio",
|
||||
"water": "Acqua",
|
||||
"caffeine": "Caffeina"
|
||||
}
|
||||
}
|
||||
}
|
||||
+66
-7
@@ -286,6 +286,15 @@
|
||||
"passwordTooShort": "Hasło musi mieć co najmniej {minLength} znaków.",
|
||||
"privacyPolicy": "Polityka prywatności",
|
||||
"passwordBreached": "To hasło pojawia się w znanych wyciekach danych. Wybierz inne.",
|
||||
"oidc": {
|
||||
"signInDefault": "Zaloguj się przez SSO",
|
||||
"errorDenied": "Logowanie zostało anulowane.",
|
||||
"errorNoEmail": "Twój dostawca tożsamości nie przekazał zweryfikowanego adresu e-mail.",
|
||||
"errorEmailUnverified": "Twój dostawca tożsamości nie oznaczył Twojego adresu e-mail jako zweryfikowanego.",
|
||||
"errorIdentityConflict": "Ten adres e-mail należy do konta powiązanego już z inną tożsamością SSO.",
|
||||
"errorRegistrationDisabled": "Żadne konto nie pasuje do tej tożsamości, a nowe rejestracje są obecnie zamknięte.",
|
||||
"errorFailed": "Logowanie jednokrotne nie powiodło się. Spróbuj ponownie."
|
||||
},
|
||||
"mfa": {
|
||||
"title": "Uwierzytelnianie dwuskładnikowe",
|
||||
"totpHint": "Wprowadź 6-cyfrowy kod z aplikacji uwierzytelniającej.",
|
||||
@@ -4238,7 +4247,16 @@
|
||||
"other": "Inne",
|
||||
"unknown": "Nieznane"
|
||||
},
|
||||
"subtitle": "Zdecyduj, które źródło wygrywa, gdy dwa podają tę samą metrykę."
|
||||
"subtitle": "Zdecyduj, które źródło wygrywa, gdy dwa podają tę samą metrykę.",
|
||||
"nutrients": {
|
||||
"title": "Zsynchronizowane spożycie składników",
|
||||
"description": "Dzienne sumy z Apple Health z ostatnich {days} dni. Tylko do odczytu — wartości należą do aplikacji źródłowej.",
|
||||
"empty": "Nie zsynchronizowano jeszcze danych o składnikach odżywczych.",
|
||||
"latest": "{amount} {unit} dnia {date}",
|
||||
"daysWithData": "{count} z {days} dni",
|
||||
"moduleHint": "Synchronizacją steruje moduł spożycia składników odżywczych.",
|
||||
"moduleHintLink": "Zarządzaj modułami"
|
||||
}
|
||||
},
|
||||
"ai": {
|
||||
"title": "Dostawca AI",
|
||||
@@ -7160,8 +7178,11 @@
|
||||
"TOTAL_BODY_WATER": "Woda w organizmie",
|
||||
"VISCERAL_FAT": "Tłuszcz trzewny",
|
||||
"BODY_MASS_INDEX": "Wskaźnik masy ciała (BMI)",
|
||||
"WAIST_CIRCUMFERENCE": "Obwód talii",
|
||||
"PHQ9_SCORE": "PHQ-9 · nastrój",
|
||||
"GAD7_SCORE": "GAD-7 · lęk"
|
||||
"GAD7_SCORE": "GAD-7 · lęk",
|
||||
"WHO5_SCORE": "WHO-5 · dobrostan",
|
||||
"SCI_SCORE": "SCI · sen"
|
||||
},
|
||||
"typeGroups": {
|
||||
"vitals": "Parametry życiowe",
|
||||
@@ -7570,6 +7591,10 @@
|
||||
"mentalHealth": {
|
||||
"label": "Dobrostan psychiczny",
|
||||
"description": "Dobrowolne testy PHQ-9 i GAD-7 obok dziennika nastroju. Domyślnie wyłączone; odpowiedzi są szyfrowane i pozostają poza Coachem AI."
|
||||
},
|
||||
"nutrients": {
|
||||
"label": "Spożycie składników odżywczych",
|
||||
"description": "Zapisuje dzienne sumy mikroskładników (witaminy, minerały, woda, kofeina) synchronizowane z Apple Health. Domyślnie wyłączone."
|
||||
}
|
||||
},
|
||||
"illness": {
|
||||
@@ -7726,12 +7751,12 @@
|
||||
"RECOVERY_SCORE": "Recovery",
|
||||
"OXYGEN_SATURATION": "Saturacja tlenem",
|
||||
"BODY_TEMPERATURE": "Temperatura ciała",
|
||||
"SKIN_TEMPERATURE": "Temperatura skóry",
|
||||
"RESPIRATORY_RATE": "Częstość oddechów",
|
||||
"BLOOD_PRESSURE_SYS": "Ciśnienie skurczowe",
|
||||
"BLOOD_PRESSURE_DIA": "Ciśnienie rozkurczowe",
|
||||
"PULSE": "Pulse",
|
||||
"WEIGHT": "Weight"
|
||||
"WEIGHT": "Weight",
|
||||
"FUNCTIONAL_IMPACT": "Wpływ na funkcjonowanie"
|
||||
},
|
||||
"listLoadError": "Nie udało się załadować epizodów."
|
||||
},
|
||||
@@ -8280,7 +8305,9 @@
|
||||
"deleted": "Usunięto dokumenty: {count}",
|
||||
"restored": "Przywrócono dokumenty: {count}",
|
||||
"partialFailure": "Nie udało się zaktualizować {failed} z {total}",
|
||||
"failed": "Nie udało się wykonać akcji"
|
||||
"failed": "Nie udało się wykonać akcji",
|
||||
"share": "Udostępnij",
|
||||
"shareTooMany": "Link do udostępniania obejmuje maksymalnie {max} dokumentów. Odznacz kilka i spróbuj ponownie."
|
||||
},
|
||||
"episodeCard": {
|
||||
"title": "Dokumenty",
|
||||
@@ -8344,6 +8371,8 @@
|
||||
},
|
||||
"share": {
|
||||
"title": "Udostępnij ten dokument",
|
||||
"multiTitle": "Udostępnij te dokumenty",
|
||||
"multiLabel": "{count} dokumentów",
|
||||
"description": "Utwórz link tylko do odczytu dla lekarza z dołączonym tym dokumentem. Link i hasło pojawią się tylko raz."
|
||||
},
|
||||
"chat": {
|
||||
@@ -8363,8 +8392,8 @@
|
||||
"errorCredential": "Dostawcę AI trzeba ponownie połączyć w ustawieniach.",
|
||||
"errorConsent": "Włącz zgodę na AI w ustawieniach, aby porozmawiać o tym dokumencie.",
|
||||
"errorNetwork": "Wygląda na to, że jesteś offline. Sprawdź połączenie i spróbuj ponownie.",
|
||||
"openAria": "Porozmawiaj o tym dokumencie",
|
||||
"openButton": "Zapytaj o ten dokument",
|
||||
"openAria": "Zapytaj Coacha o ten dokument",
|
||||
"openButton": "Zapytaj Coacha",
|
||||
"scopeBadge": "Rozmowa o: {title}"
|
||||
}
|
||||
},
|
||||
@@ -8452,5 +8481,35 @@
|
||||
"valuesDescription": "Wszystkie zapisane wartości tej metryki.",
|
||||
"backToMetric": "Powrót do {metric}"
|
||||
}
|
||||
},
|
||||
"nutrients": {
|
||||
"names": {
|
||||
"vitamin_a": "Witamina A",
|
||||
"thiamin": "Tiamina (B1)",
|
||||
"riboflavin": "Ryboflawina (B2)",
|
||||
"niacin": "Niacyna (B3)",
|
||||
"pantothenic_acid": "Kwas pantotenowy (B5)",
|
||||
"vitamin_b6": "Witamina B6",
|
||||
"biotin": "Biotyna (B7)",
|
||||
"folate": "Foliany (B9)",
|
||||
"vitamin_b12": "Witamina B12",
|
||||
"vitamin_c": "Witamina C",
|
||||
"vitamin_d": "Witamina D",
|
||||
"vitamin_e": "Witamina E",
|
||||
"vitamin_k": "Witamina K",
|
||||
"calcium": "Wapń",
|
||||
"iron": "Żelazo",
|
||||
"magnesium": "Magnez",
|
||||
"phosphorus": "Fosfor",
|
||||
"zinc": "Cynk",
|
||||
"copper": "Miedź",
|
||||
"manganese": "Mangan",
|
||||
"selenium": "Selen",
|
||||
"chromium": "Chrom",
|
||||
"molybdenum": "Molibden",
|
||||
"iodine": "Jod",
|
||||
"water": "Woda",
|
||||
"caffeine": "Kofeina"
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.28.29",
|
||||
"version": "1.28.41",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
@@ -65,7 +65,7 @@
|
||||
"@prisma/adapter-pg": "^7.8.0",
|
||||
"@prisma/client": "^7.8.0",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@simplewebauthn/server": "^13.3.1",
|
||||
"@simplewebauthn/server": "^13.3.2",
|
||||
"@tanstack/react-query": "5.101.2",
|
||||
"@tanstack/react-virtual": "^3.14.5",
|
||||
"@types/sax": "^1.2.7",
|
||||
@@ -74,6 +74,7 @@
|
||||
"date-fns": "^4.4.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"fflate": "^0.8.3",
|
||||
"jose": "^6.2.3",
|
||||
"jspdf": "^4.2.1",
|
||||
"jspdf-autotable": "^5.0.8",
|
||||
"lucide-react": "^1.18.0",
|
||||
@@ -110,7 +111,7 @@
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@testcontainers/postgresql": "^12.0.3",
|
||||
"@types/node": "26.1.0",
|
||||
"@types/node": "26.1.1",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
@@ -122,13 +123,13 @@
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.6",
|
||||
"knip": "^6.16.1",
|
||||
"lefthook": "^2.1.9",
|
||||
"lefthook": "^2.1.10",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"pdfjs-dist": "5.4.296",
|
||||
"pg": "^8.22.0",
|
||||
"prettier": "^3.9.1",
|
||||
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||
"shadcn": "^4.9.0",
|
||||
"shadcn": "^4.13.0",
|
||||
"tailwindcss": "^4",
|
||||
"testcontainers": "^12.0.3",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
|
||||
@@ -59,6 +59,12 @@ export default defineConfig({
|
||||
// matches what real users render on first paint and is the only honest
|
||||
// a11y baseline for this app.
|
||||
colorScheme: "dark",
|
||||
// Issue #490 — pin the browser timezone. Without this the context
|
||||
// inherits the HOST zone (UTC on CI, Europe/Berlin on a local Mac),
|
||||
// so any surface that renders a clock or a day boundary could pass
|
||||
// locally and skew two hours / a calendar day on CI. Berlin matches
|
||||
// the app's own display fallback, so CI and local render identically.
|
||||
timezoneId: "Europe/Berlin",
|
||||
},
|
||||
|
||||
projects: [
|
||||
|
||||
Generated
+558
-404
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,13 @@
|
||||
-- v1.28 — OIDC SSO identity pinning.
|
||||
--
|
||||
-- The (oidc_issuer, oidc_sub) pair pins an account to exactly one IdP
|
||||
-- identity: stamped on first OIDC login (auto-provision or one-time link by
|
||||
-- verified email), matched on every later OIDC login. Email stays a display
|
||||
-- field after the stamp — an IdP-side email change can never re-point the
|
||||
-- login at a different account. Both columns are NULL on accounts that never
|
||||
-- signed in via SSO; Postgres unique indexes ignore NULL pairs, so the
|
||||
-- constraint only bites on actual duplicate identities.
|
||||
ALTER TABLE "users" ADD COLUMN "oidc_issuer" TEXT;
|
||||
ALTER TABLE "users" ADD COLUMN "oidc_sub" TEXT;
|
||||
|
||||
CREATE UNIQUE INDEX "users_oidc_issuer_oidc_sub_key" ON "users"("oidc_issuer", "oidc_sub");
|
||||
@@ -0,0 +1,37 @@
|
||||
-- v1.28 — micronutrient intake daily totals (opt-in `nutrients` module).
|
||||
--
|
||||
-- One row per (user, local day, nutrient): vitamins, minerals, water and
|
||||
-- caffeine as supplement-style day totals synced from Apple Health. The
|
||||
-- composite PK is the idempotency key — an iOS re-post of a grown day
|
||||
-- total replaces the row in place (last-writer-wins day-total contract),
|
||||
-- so there is no externalId column and no soft delete. `day` is a
|
||||
-- YYYY-MM-DD TEXT key anchored to the user's IANA timezone (the
|
||||
-- EnvironmentContext / MedicationComplianceRollup day-key precedent);
|
||||
-- `nutrient` is a bare TEXT code enforced in code against the closed
|
||||
-- catalog (`src/lib/nutrients/catalog.ts`) so adding a code never needs
|
||||
-- a migration. `unit` is denormalised from the catalog on purpose: rows
|
||||
-- stay self-describing in exports even if the catalog ever drifts.
|
||||
CREATE TABLE "nutrient_intake_days" (
|
||||
"user_id" TEXT NOT NULL,
|
||||
"day" TEXT NOT NULL,
|
||||
"nutrient" TEXT NOT NULL,
|
||||
"amount" DOUBLE PRECISION NOT NULL,
|
||||
"unit" TEXT NOT NULL,
|
||||
"source" TEXT NOT NULL DEFAULT 'APPLE_HEALTH',
|
||||
"external_source_version" TEXT,
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "nutrient_intake_days_pkey" PRIMARY KEY ("user_id", "day", "nutrient")
|
||||
);
|
||||
|
||||
-- Read path: the settings-card / read-endpoint window scan per user, and
|
||||
-- the later per-nutrient series fetch (day DESC for "latest first").
|
||||
CREATE INDEX "nutrient_intake_days_user_id_nutrient_day_idx"
|
||||
ON "nutrient_intake_days" ("user_id", "nutrient", "day" DESC);
|
||||
|
||||
-- Account deletion cascades; rows are worthless without their owner.
|
||||
ALTER TABLE "nutrient_intake_days"
|
||||
ADD CONSTRAINT "nutrient_intake_days_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Import-job liveness heartbeat (issue #486 follow-up).
|
||||
--
|
||||
-- `reconcileOrphanImportJobs` used to flip EVERY non-terminal ImportJob
|
||||
-- row to `failed` on any worker boot — killing an import that was still
|
||||
-- actively parsing in another worker (multi-replica / rolling deploy).
|
||||
-- The reconcile now gates on this heartbeat: a non-terminal row is only
|
||||
-- treated as orphaned when its pg-boss job is gone/terminal OR its
|
||||
-- heartbeat has gone stale. `@updatedAt` bumps this column on every
|
||||
-- write, including each mid-run progress tick, so a live import keeps it
|
||||
-- fresh. Existing rows backfill to the ALTER time via the DB default.
|
||||
ALTER TABLE "import_jobs"
|
||||
ADD COLUMN "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||
@@ -42,6 +42,13 @@ model User {
|
||||
email String? @unique
|
||||
passwordHash String? @map("password_hash") // argon2id, optional fallback
|
||||
role String @default("USER") // "ADMIN" | "USER"
|
||||
// OIDC SSO — the (issuer, sub) pair pins the account to exactly one IdP
|
||||
// identity. Stamped on first OIDC login (auto-provision or one-time link
|
||||
// by verified email); every later OIDC login matches on this pair, never
|
||||
// on the email, so an IdP-side email change can update the display email
|
||||
// but can never re-point the login at a different account.
|
||||
oidcIssuer String? @map("oidc_issuer")
|
||||
oidcSub String? @map("oidc_sub")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@ -805,6 +812,10 @@ model User {
|
||||
// that win over the home location for the days they cover.
|
||||
environmentContexts EnvironmentContext[]
|
||||
environmentTravelLocations EnvironmentTravelLocation[]
|
||||
// v1.28 — micronutrient intake daily totals synced from Apple Health
|
||||
// (vitamins, minerals, water, caffeine). Opt-in `nutrients` module, off
|
||||
// by default; the ingest route refuses while the module is off.
|
||||
nutrientIntakeDays NutrientIntakeDay[]
|
||||
// v1.25 (W-DOCS-IN) — inbound clinical documents (doctor reports /
|
||||
// discharge letters) the user uploads. The raw document is encrypted at
|
||||
// rest; extracted facts land in a review-then-confirm staging area
|
||||
@@ -867,6 +878,10 @@ model User {
|
||||
// v1.16.0 — per-signup invite redemption ledger (multi-use history).
|
||||
inviteRedemptions InviteRedemption[] @relation("InviteRedemptions")
|
||||
|
||||
// One account per IdP identity — the (issuer, sub) pair is the durable
|
||||
// OIDC key (both NULL on accounts that never signed in via SSO; Postgres
|
||||
// unique indexes ignore NULL pairs).
|
||||
@@unique([oidcIssuer, oidcSub])
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
@@ -4074,6 +4089,12 @@ model ImportJob {
|
||||
exportedAt DateTime? @map("exported_at")
|
||||
startedAt DateTime @default(now()) @map("started_at")
|
||||
completedAt DateTime? @map("completed_at")
|
||||
/// Bumped on every write, including each mid-run progress tick. Acts
|
||||
/// as a liveness heartbeat: `reconcileOrphanImportJobs` treats a
|
||||
/// non-terminal row whose heartbeat has gone stale as orphaned, so a
|
||||
/// job still actively parsing in another worker (fresh heartbeat) is
|
||||
/// never flipped to `failed` (issue #486 follow-up).
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
/// JSON envelope mirroring `ImportJobProgress` (`src/lib/measurements/
|
||||
/// import-apple-health-export.ts`). Updated mid-run.
|
||||
progress Json @default("{}")
|
||||
@@ -6201,3 +6222,43 @@ model DocumentThumbnail {
|
||||
@@index([userId])
|
||||
@@map("document_thumbnails")
|
||||
}
|
||||
|
||||
// ─── Nutrient intake (v1.28) ───────────────────────────────────────
|
||||
//
|
||||
// Micronutrient daily totals synced from Apple Health — vitamins,
|
||||
// minerals, water, caffeine as supplement-style "substances taken",
|
||||
// never a food diary (energy / macros are out of scope by directive).
|
||||
// One row per (user, local day, nutrient); the composite PK IS the
|
||||
// idempotency key, so an iOS re-post of a grown day total replaces the
|
||||
// row in place (last-writer-wins day-total contract — the `stats:`
|
||||
// overwrite semantics without externalId string parsing). `nutrient`
|
||||
// is a bare string enforced in code against the closed catalog in
|
||||
// `src/lib/nutrients/catalog.ts` (the IntegrationStatus precedent: no
|
||||
// migration to add a code). No soft delete: rows are source-owned day
|
||||
// totals; replace-on-repost is the correction mechanism and account
|
||||
// deletion cascades. Behind the opt-in `nutrients` module
|
||||
// (refuse-ingest-when-off).
|
||||
|
||||
model NutrientIntakeDay {
|
||||
userId String @map("user_id")
|
||||
/// YYYY-MM-DD anchored to the user's IANA timezone (iOS-computed —
|
||||
/// the same day-key contract as the `stats:` externalIds).
|
||||
day String
|
||||
/// Closed catalog code ("vitamin_d", "magnesium", "water", ...).
|
||||
nutrient String
|
||||
/// Daily total in the catalog's canonical unit.
|
||||
amount Float
|
||||
/// Canonical unit echoed from the catalog ("mg" | "ug" | "ml") —
|
||||
/// denormalised on purpose: rows stay self-describing in exports
|
||||
/// even if the catalog ever drifts.
|
||||
unit String
|
||||
source String @default("APPLE_HEALTH")
|
||||
externalSourceVersion String? @map("external_source_version")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([userId, day, nutrient])
|
||||
@@index([userId, nutrient, day(sort: Desc)])
|
||||
@@map("nutrient_intake_days")
|
||||
}
|
||||
+1
-1
@@ -36,7 +36,7 @@ try {
|
||||
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
|
||||
const CACHE_VERSION =
|
||||
(typeof self !== "undefined" && self.__APP_VERSION__) ||
|
||||
/* @sw-version-fallback */ "v1.28.29";
|
||||
/* @sw-version-fallback */ "v1.28.41";
|
||||
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
|
||||
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
|
||||
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
|
||||
|
||||
+52
-12
@@ -37,17 +37,9 @@
|
||||
},
|
||||
{
|
||||
"name": "Withings OAuth",
|
||||
"description": "Required to enable the Withings integration. App boots without these but Withings sync stays disabled.",
|
||||
"description": "Withings API credentials are entered per user in Settings → Integrations (no env-var path). Only the webhook shared secret is operator-level.",
|
||||
"required": false,
|
||||
"variables": [
|
||||
{
|
||||
"name": "WITHINGS_CLIENT_ID",
|
||||
"purpose": "Withings OAuth client_id from https://developer.withings.com."
|
||||
},
|
||||
{
|
||||
"name": "WITHINGS_CLIENT_SECRET",
|
||||
"purpose": "Withings OAuth client_secret."
|
||||
},
|
||||
{
|
||||
"name": "WITHINGS_WEBHOOK_SECRET",
|
||||
"purpose": "Random secret carried as a path segment in the Withings webhook callback URL. Generate with `openssl rand -hex 32`."
|
||||
@@ -86,6 +78,45 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "OIDC SSO login",
|
||||
"description": "Required to enable SSO login against a self-hosted identity provider (Authentik, Keycloak, Authelia, Google Workspace, etc.). App boots without these but the login page's SSO button stays hidden. Register a confidential client with redirect URI ${NEXT_PUBLIC_APP_URL}/api/auth/oidc/callback.",
|
||||
"required": false,
|
||||
"allOrNone": true,
|
||||
"variables": [
|
||||
{
|
||||
"name": "OIDC_ISSUER_URL",
|
||||
"purpose": "OIDC provider issuer URL (discovery doc served at <issuer>/.well-known/openid-configuration)."
|
||||
},
|
||||
{
|
||||
"name": "OIDC_CLIENT_ID",
|
||||
"purpose": "OIDC confidential client id."
|
||||
},
|
||||
{
|
||||
"name": "OIDC_CLIENT_SECRET",
|
||||
"purpose": "OIDC confidential client secret."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "OIDC SSO options",
|
||||
"description": "Optional tuning for an enabled OIDC provider. Each stands alone (deliberately NOT allOrNone — any subset is valid) and each has a safe default when unset.",
|
||||
"required": false,
|
||||
"variables": [
|
||||
{
|
||||
"name": "OIDC_SCOPES",
|
||||
"purpose": "Space-separated scopes requested at authorization. Default: \"openid email profile\"."
|
||||
},
|
||||
{
|
||||
"name": "OIDC_BUTTON_LABEL",
|
||||
"purpose": "Label on the login page's SSO button. Default: \"Single Sign-On\"."
|
||||
},
|
||||
{
|
||||
"name": "OIDC_ONLY",
|
||||
"purpose": "\"true\" disables password/passkey login server-side (only effective when the OIDC provider group is fully configured). Default: additive login. Locks the native iOS app out of sign-in."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "APNs (iOS push)",
|
||||
"description": "All four core APNS_* vars together. Missing any disables APNs silently — the v1.4.40 AP-2 gap that motivated this manifest. allOrNone trips the synthetic <all-or-none> required row whenever 1-3 of the 4 are set.",
|
||||
@@ -167,9 +198,18 @@
|
||||
"name": "BACKUP_S3_ENDPOINT",
|
||||
"purpose": "S3-compatible endpoint URL."
|
||||
},
|
||||
{ "name": "BACKUP_S3_BUCKET", "purpose": "Bucket name." },
|
||||
{ "name": "BACKUP_S3_ACCESS_KEY", "purpose": "S3 access key." },
|
||||
{ "name": "BACKUP_S3_SECRET_KEY", "purpose": "S3 secret key." },
|
||||
{
|
||||
"name": "BACKUP_S3_BUCKET",
|
||||
"purpose": "Bucket name."
|
||||
},
|
||||
{
|
||||
"name": "BACKUP_S3_ACCESS_KEY",
|
||||
"purpose": "S3 access key."
|
||||
},
|
||||
{
|
||||
"name": "BACKUP_S3_SECRET_KEY",
|
||||
"purpose": "S3 secret key."
|
||||
},
|
||||
{
|
||||
"name": "BACKUP_ENCRYPTION_KEY",
|
||||
"purpose": "Separate AES key for the backup body — MUST NOT match ENCRYPTION_KEY."
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { resolveKey } from "@/lib/i18n/resolve-key";
|
||||
import {
|
||||
ILLNESS_SCAN_TYPES,
|
||||
FUNCTIONAL_IMPACT_RETURN_KEY,
|
||||
} from "@/lib/illness/correlation";
|
||||
import {
|
||||
illnessTypeEnum,
|
||||
illnessLifecycleEnum,
|
||||
} from "@/lib/validations/illness";
|
||||
import {
|
||||
allergyCategoryEnum,
|
||||
allergyTypeEnum,
|
||||
allergySeverityEnum,
|
||||
allergyStatusEnum,
|
||||
} from "@/lib/validations/allergy";
|
||||
import { familyRelationshipEnum } from "@/lib/validations/family-history";
|
||||
import { INSTRUMENTS } from "@/lib/mental-health/instruments";
|
||||
|
||||
/**
|
||||
* Enum-driven exhaustiveness guard for DYNAMICALLY-built i18n keys.
|
||||
*
|
||||
* The three existing i18n guards each see one face of the problem and miss the
|
||||
* fourth:
|
||||
* - `i18n-call-site-coverage.test.ts` sees only literal `t("ns.key")` calls,
|
||||
* so a runtime-assembled `t(\`ns.${x}\`)` key is invisible to it.
|
||||
* - `i18n-reverse-coverage.test.ts` sees bundle orphans, but treats any
|
||||
* `ns.${x}` template as covering its whole subtree — so a MEMBER missing
|
||||
* from the subtree is invisible.
|
||||
* - `i18n-locale-integrity.test.ts` sees cross-locale parity, so it only
|
||||
* fires when a key is present in SOME locale but not all.
|
||||
*
|
||||
* None of them assert the class that actually ships broken: "for enum E, every
|
||||
* member's dynamically-built key resolves, non-empty, in every locale." Because
|
||||
* parity is perfect, a key missing from ONE locale is missing from ALL SIX at
|
||||
* once — exactly the seam that let `measurementReminders.types.{WHO5_SCORE,
|
||||
* SCI_SCORE,WAIST_CIRCUMFERENCE}` (v1.28.38) and `illness.vital.FUNCTIONAL_IMPACT`
|
||||
* (v1.28.41) ship absent from every bundle.
|
||||
*
|
||||
* This test derives the EXPECTED key set from the SOURCE enum/const and asserts
|
||||
* every `prefix + member` resolves. Adding an enum member without a bundle key
|
||||
* fails CI by construction — no hand-maintained floor to drift.
|
||||
*
|
||||
* ── Covered key spaces (source is a clean, finite, import-safe enum/const) ──
|
||||
* - illness.vital.* ILLNESS_SCAN_TYPES ∪ FUNCTIONAL_IMPACT_RETURN_KEY
|
||||
* - illness.type.* illnessTypeEnum
|
||||
* - illness.lifecycle.* illnessLifecycleEnum
|
||||
* - records.allergies.category.* allergyCategoryEnum
|
||||
* - records.allergies.type.* allergyTypeEnum
|
||||
* - records.allergies.severity.* allergySeverityEnum ∪ "NONE" sentinel
|
||||
* - records.allergies.status.* allergyStatusEnum
|
||||
* - records.family.relationship.* familyRelationshipEnum
|
||||
* - mentalHealth.instrument.* } INSTRUMENTS i18nKey slugs
|
||||
* - mentalHealth.instrumentSub.* } (PHQ-9 / GAD-7 / WHO-5 / SCI —
|
||||
* - mentalHealth.instrumentDescription.* } the WHO-5/SCI class that bit before)
|
||||
*
|
||||
* ── Deliberately EXCLUDED (would produce false positives) ──
|
||||
* - `insights.workouts.sport.*` — the Strava render path stores a raw
|
||||
* `sport_type` and the render site legitimately falls back to that raw
|
||||
* string, so a "missing" key is a graceful degradation, not a broken token
|
||||
* (audit LOW #2). Asserting it would fail on a path that is by-design lax.
|
||||
* - `settings.sections.*.description`, `settings.ai.providerChain.types.admin-codex`,
|
||||
* `insights.<metric>.emptyState.cta` — structurally unreachable behind a
|
||||
* tsc-exhaustive map / a `!= null` guard / a server-only filter (audit LOW).
|
||||
* Registering them would demand bundle keys for code paths users never hit.
|
||||
* - `measurementReminders.types.*` — already exhaustively covered by
|
||||
* `measurement-reminders/__tests__/type-labels-i18n.test.ts`; its source
|
||||
* const lives in a client component, not a lib-level export.
|
||||
* - Prisma cycle enums, medication/document/lab/nutrient catalogs — clean
|
||||
* enums the audit flags as candidates; left for a follow-up extension. Add
|
||||
* them here once their exact prefix→member mapping is confirmed 1:1 (the
|
||||
* cycle cervix* prefixes and catalog slug maps need per-space verification
|
||||
* to stay false-positive-free).
|
||||
*/
|
||||
|
||||
const MESSAGES_DIR = join(__dirname, "../../messages");
|
||||
|
||||
const LOCALES = readdirSync(MESSAGES_DIR)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.map((f) => ({
|
||||
locale: f.replace(/\.json$/, ""),
|
||||
messages: JSON.parse(readFileSync(join(MESSAGES_DIR, f), "utf8")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
}));
|
||||
|
||||
/** PHQ-9 / GAD-7 / WHO-5 / SCI lowercase i18n slugs, straight from the source. */
|
||||
const INSTRUMENT_SLUGS = Object.values(INSTRUMENTS).map((i) => i.i18nKey);
|
||||
|
||||
interface KeySpace {
|
||||
/** The dotted prefix the key is built from (`t(\`prefix.${member}\`)`). */
|
||||
prefix: string;
|
||||
/** The finite member set, derived from the SOURCE enum/const. */
|
||||
members: readonly string[];
|
||||
}
|
||||
|
||||
const REGISTRY: readonly KeySpace[] = [
|
||||
// illness.* — the currently-broken seed (FUNCTIONAL_IMPACT wins recovery-gap ties).
|
||||
{
|
||||
prefix: "illness.vital",
|
||||
members: [...ILLNESS_SCAN_TYPES.map(String), FUNCTIONAL_IMPACT_RETURN_KEY],
|
||||
},
|
||||
{ prefix: "illness.type", members: illnessTypeEnum.options },
|
||||
{ prefix: "illness.lifecycle", members: illnessLifecycleEnum.options },
|
||||
|
||||
// records.allergies.* — Zod enums; severity carries a "NONE" (not-assessed) sentinel.
|
||||
{
|
||||
prefix: "records.allergies.category",
|
||||
members: allergyCategoryEnum.options,
|
||||
},
|
||||
{ prefix: "records.allergies.type", members: allergyTypeEnum.options },
|
||||
{
|
||||
prefix: "records.allergies.severity",
|
||||
members: [...allergySeverityEnum.options, "NONE"],
|
||||
},
|
||||
{ prefix: "records.allergies.status", members: allergyStatusEnum.options },
|
||||
|
||||
// records.family.relationship.*
|
||||
{
|
||||
prefix: "records.family.relationship",
|
||||
members: familyRelationshipEnum.options,
|
||||
},
|
||||
|
||||
// mentalHealth.* — the WHO-5/SCI class whose absence originally motivated this guard.
|
||||
{ prefix: "mentalHealth.instrument", members: INSTRUMENT_SLUGS },
|
||||
{ prefix: "mentalHealth.instrumentSub", members: INSTRUMENT_SLUGS },
|
||||
{ prefix: "mentalHealth.instrumentDescription", members: INSTRUMENT_SLUGS },
|
||||
];
|
||||
|
||||
describe("dynamic-key exhaustiveness (enum-derived)", () => {
|
||||
it("discovers all six shipped locales", () => {
|
||||
expect(LOCALES.map((l) => l.locale).sort()).toEqual([
|
||||
"de",
|
||||
"en",
|
||||
"es",
|
||||
"fr",
|
||||
"it",
|
||||
"pl",
|
||||
]);
|
||||
});
|
||||
|
||||
it("every registered key space has a non-empty member set", () => {
|
||||
for (const space of REGISTRY) {
|
||||
expect(
|
||||
space.members.length,
|
||||
`${space.prefix} resolved to an empty member set — source import broke`,
|
||||
).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
for (const { locale, messages } of LOCALES) {
|
||||
for (const space of REGISTRY) {
|
||||
for (const member of space.members) {
|
||||
const key = `${space.prefix}.${member}`;
|
||||
it(`resolves ${key} in ${locale}`, () => {
|
||||
const value = resolveKey(messages, key);
|
||||
expect(value, `${key} missing in ${locale}.json`).toBeTypeOf(
|
||||
"string",
|
||||
);
|
||||
expect((value ?? "").trim().length).toBeGreaterThan(0);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -111,6 +111,13 @@ const MODULE_ROUTE_TREES: ReadonlyArray<string> = [
|
||||
// ungated insights AI route fails this test BY NAME rather than leaking the
|
||||
// surface over a Bearer token when the account turned insights off.
|
||||
"src/app/api/insights",
|
||||
// v1.28 — the nutrient-intake sync (opt-in `nutrients` module). Unlike the
|
||||
// data-layer-exempt siblings this domain is REFUSE-INGEST-WHEN-OFF (the
|
||||
// mental-health posture): both the batch ingest and the window-summary
|
||||
// read call `requireModuleEnabled(user.id, "nutrients")` directly, so a
|
||||
// phone whose user never opted in cannot land rows server-side. Walking
|
||||
// the tree means a NEW ungated nutrients route fails BY NAME.
|
||||
"src/app/api/nutrients",
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -32,6 +32,8 @@ const mocks = vi.hoisted(() => ({
|
||||
importJobFindFirst: vi.fn().mockResolvedValue(null),
|
||||
importJobUpdate: vi.fn(),
|
||||
userFindUnique: vi.fn(),
|
||||
streamToDisk: vi.fn(),
|
||||
unlink: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
@@ -42,6 +44,15 @@ vi.mock("@/lib/jobs/boss-instance", () => ({
|
||||
getGlobalBoss: mocks.getGlobalBoss,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/multipart/stream-to-disk", () => ({
|
||||
streamMultipartToDisk: mocks.streamToDisk,
|
||||
}));
|
||||
|
||||
vi.mock("node:fs/promises", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:fs/promises")>();
|
||||
return { ...actual, unlink: mocks.unlink };
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
importJob: {
|
||||
@@ -67,6 +78,8 @@ const USER_SESSION = {
|
||||
user: { id: "user-2", username: "alice", role: "USER" as const },
|
||||
};
|
||||
|
||||
const STAGED_PATH = "/tmp/healthlog-admin-apple-health-import-abc.bin";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mocks.checkRateLimit.mockResolvedValue({ allowed: true });
|
||||
@@ -76,8 +89,27 @@ beforeEach(() => {
|
||||
mocks.importJobFindFirst.mockResolvedValue(null);
|
||||
mocks.importJobUpdate.mockResolvedValue({ id: "ij-1" });
|
||||
mocks.userFindUnique.mockResolvedValue({ id: "user-2", username: "alice" });
|
||||
mocks.streamToDisk.mockResolvedValue({
|
||||
filePath: STAGED_PATH,
|
||||
bytes: 100,
|
||||
sha256: "deadbeef",
|
||||
originalFilename: "export.zip",
|
||||
textFields: { userId: "user-2" },
|
||||
});
|
||||
mocks.unlink.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
function multipartReq(): NextRequest {
|
||||
return new NextRequest(
|
||||
"http://localhost/api/admin/import-apple-health-export",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "multipart/form-data; boundary=---x" },
|
||||
body: "stub",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
describe("POST /api/admin/import-apple-health-export", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
@@ -139,4 +171,45 @@ describe("POST /api/admin/import-apple-health-export", () => {
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(429);
|
||||
});
|
||||
|
||||
it("re-stages and enqueues when the only prior job for the same bytes is failed (issue #486)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(ADMIN_SESSION as never);
|
||||
mocks.importJobFindFirst.mockImplementation(async (args: unknown) => {
|
||||
const where = (args as { where?: { status?: { not?: string } } }).where;
|
||||
if (where?.status?.not === "failed") return null;
|
||||
return { id: "old-failed-job", status: "failed" };
|
||||
});
|
||||
|
||||
const res = await POST(multipartReq());
|
||||
expect(res.status).toBe(202);
|
||||
const body = await res.json();
|
||||
expect(body.data.status).toBe("queued");
|
||||
expect(body.data.idempotent).toBeUndefined();
|
||||
expect(body.data.jobId).not.toBe("old-failed-job");
|
||||
expect(mocks.bossSend).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.importJobCreate).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.importJobFindFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({ status: { not: "failed" } }),
|
||||
}),
|
||||
);
|
||||
expect(mocks.unlink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("short-circuits to a viable prior job and unlinks the redundant upload", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(ADMIN_SESSION as never);
|
||||
mocks.importJobFindFirst.mockResolvedValue({
|
||||
id: "live-job",
|
||||
status: "parsing",
|
||||
});
|
||||
|
||||
const res = await POST(multipartReq());
|
||||
expect(res.status).toBe(202);
|
||||
const body = await res.json();
|
||||
expect(body.data.idempotent).toBe(true);
|
||||
expect(body.data.jobId).toBe("live-job");
|
||||
expect(mocks.bossSend).not.toHaveBeenCalled();
|
||||
expect(mocks.importJobCreate).not.toHaveBeenCalled();
|
||||
expect(mocks.unlink).toHaveBeenCalledWith(STAGED_PATH);
|
||||
});
|
||||
});
|
||||
@@ -22,9 +22,11 @@ import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import {
|
||||
APPLE_HEALTH_IMPORT_QUEUE,
|
||||
APPLE_HEALTH_IMPORT_SEND_OPTIONS,
|
||||
type AppleHealthImportPayload,
|
||||
} from "@/lib/jobs/apple-health-import-worker";
|
||||
import { streamMultipartToDisk } from "@/lib/multipart/stream-to-disk";
|
||||
import { unlink } from "node:fs/promises";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -111,15 +113,22 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
// Same content-hash idempotency as the user-facing route, but scoped
|
||||
// by the target user — an admin re-uploading the same file for the
|
||||
// same user resolves to the previous job.
|
||||
// same user resolves to the previous job. As on the user route, a
|
||||
// prior `failed` job is excluded (issue #486) so the same export can
|
||||
// be retried; only still-viable jobs (queued / in-flight / done)
|
||||
// short-circuit.
|
||||
const existing = await prisma.importJob.findFirst({
|
||||
where: {
|
||||
userId: targetUser.id,
|
||||
uploadSha256: uploaded.sha256,
|
||||
status: { not: "failed" },
|
||||
},
|
||||
orderBy: { startedAt: "desc" },
|
||||
});
|
||||
if (existing) {
|
||||
// Redundant staging file — the existing viable job owns the bytes.
|
||||
// Unlink so a deduped admin re-upload does not leak `/tmp`.
|
||||
await unlink(uploaded.filePath).catch(() => {});
|
||||
annotate({ meta: { idempotent_hit: true, job_id: existing.id } });
|
||||
return apiSuccess(
|
||||
{
|
||||
@@ -158,7 +167,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
uploadBytes: uploaded.bytes,
|
||||
enqueuedAt: new Date().toISOString(),
|
||||
};
|
||||
const bossJobId = await boss.send(APPLE_HEALTH_IMPORT_QUEUE, payload);
|
||||
// No retries + wide expiration: the staged upload is consumed by the
|
||||
// first run, so a redelivery could only fail on the deleted `/tmp`
|
||||
// file and mask the real outcome (see the worker's send-options doc).
|
||||
const bossJobId = await boss.send(
|
||||
APPLE_HEALTH_IMPORT_QUEUE,
|
||||
payload,
|
||||
APPLE_HEALTH_IMPORT_SEND_OPTIONS,
|
||||
);
|
||||
|
||||
await prisma.importJob.update({
|
||||
where: { id: importJob.id },
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* only signal operators need; the identifier itself is PII and stays
|
||||
* out of the row.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
@@ -135,3 +135,48 @@ describe("POST /api/auth/login — auth.login.failed audit row (H-1)", () => {
|
||||
expect(JSON.stringify(payload)).not.toContain(TYPED_EMAIL);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/auth/login — OIDC_ONLY server-side enforcement", () => {
|
||||
const OIDC_ENV_KEYS = [
|
||||
"OIDC_ISSUER_URL",
|
||||
"OIDC_CLIENT_ID",
|
||||
"OIDC_CLIENT_SECRET",
|
||||
"OIDC_ONLY",
|
||||
] as const;
|
||||
const original: Record<string, string | undefined> = {};
|
||||
|
||||
beforeEach(() => {
|
||||
for (const key of OIDC_ENV_KEYS) original[key] = process.env[key];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of OIDC_ENV_KEYS) {
|
||||
if (original[key] === undefined) delete process.env[key];
|
||||
else process.env[key] = original[key];
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects password login before touching the DB when OIDC_ONLY is set", async () => {
|
||||
process.env.OIDC_ISSUER_URL = "https://idp.example.com";
|
||||
process.env.OIDC_CLIENT_ID = "client-1";
|
||||
process.env.OIDC_CLIENT_SECRET = "secret-1";
|
||||
process.env.OIDC_ONLY = "true";
|
||||
|
||||
const res = await POST(makeRequest());
|
||||
expect(res.status).toBe(403);
|
||||
expect(prisma.user.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still allows password login when OIDC_ONLY is set but the provider is half-configured", async () => {
|
||||
delete process.env.OIDC_ISSUER_URL;
|
||||
process.env.OIDC_CLIENT_ID = "client-1";
|
||||
process.env.OIDC_CLIENT_SECRET = "secret-1";
|
||||
process.env.OIDC_ONLY = "true";
|
||||
|
||||
const res = await POST(makeRequest());
|
||||
// Falls through to normal password verification (which fails here
|
||||
// since verifyPassword is mocked to return false) rather than 403 —
|
||||
// a half-set OIDC group must never lock everyone out.
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -13,8 +13,18 @@ import { finishLogin } from "@/lib/auth/login-response";
|
||||
import { createMfaChallenge } from "@/lib/auth/mfa/challenge";
|
||||
import { consumeTrustedDevice } from "@/lib/auth/trusted-device";
|
||||
import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
|
||||
import { isOidcOnly } from "@/lib/auth/oidc";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// OIDC_ONLY means password login must be a dead end, not just a hidden
|
||||
// button — otherwise a pre-existing password (or a leaked/reused one)
|
||||
// stays a live bypass of the operator's SSO-only policy.
|
||||
if (isOidcOnly()) {
|
||||
return apiError("Password login is disabled. Sign in with SSO.", 403, {
|
||||
errorCode: "oidc_only",
|
||||
});
|
||||
}
|
||||
|
||||
// v1.4.43 W13 M-4 — `checkAuthSurfaceRateLimit` swaps to a tighter
|
||||
// global bucket when the trust chain is misconfigured; otherwise it
|
||||
// is byte-equivalent to the previous per-IP `auth:login:{ip}` key.
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: () => ({ setError: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkAuthSurfaceRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: vi.fn((s: string) => s.replace(/^enc\(/, "").replace(/\)$/, "")),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findFirst: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
count: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
appSettings: { findUnique: vi.fn() },
|
||||
webauthnMfaCredential: { count: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
|
||||
vi.mock("@/lib/auth/session", () => ({ createSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/login-alert", () => ({ recordSignInDevice: vi.fn() }));
|
||||
vi.mock("@/lib/auth/mfa/challenge", () => ({
|
||||
createMfaChallenge: vi.fn(async () => ({
|
||||
ticket: "mfa-ticket-1",
|
||||
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/auth/mfa-enrollment", () => ({
|
||||
syncMfaEnrollCookie: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/auth/secure-cookie", () => ({
|
||||
shouldEmitSecureCookie: () => false,
|
||||
}));
|
||||
vi.mock("@/lib/tz/resolver", () => ({
|
||||
resolveServerDefaultTimezone: vi.fn(async () => "Europe/Berlin"),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/oidc", () => ({
|
||||
getOidcConfig: vi.fn(),
|
||||
discoverOidcMetadata: vi.fn(),
|
||||
exchangeCodeForTokens: vi.fn(),
|
||||
verifyIdToken: vi.fn(),
|
||||
fetchUserinfoEmail: vi.fn(),
|
||||
getOidcRedirectUri: () =>
|
||||
"https://healthlog.example.com/api/auth/oidc/callback",
|
||||
deriveUniqueUsername: vi.fn(async (email: string) => email.split("@")[0]),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { createSession } from "@/lib/auth/session";
|
||||
import { createMfaChallenge } from "@/lib/auth/mfa/challenge";
|
||||
import {
|
||||
getOidcConfig,
|
||||
discoverOidcMetadata,
|
||||
exchangeCodeForTokens,
|
||||
verifyIdToken,
|
||||
} from "@/lib/auth/oidc";
|
||||
|
||||
const CONFIG = {
|
||||
issuerUrl: "https://idp.example.com",
|
||||
clientId: "client-1",
|
||||
clientSecret: "secret-1",
|
||||
scopes: "openid email profile",
|
||||
buttonLabel: "SSO",
|
||||
};
|
||||
const METADATA = {
|
||||
issuer: "https://idp.example.com",
|
||||
authorization_endpoint: "https://idp.example.com/authorize",
|
||||
token_endpoint: "https://idp.example.com/token",
|
||||
jwks_uri: "https://idp.example.com/jwks",
|
||||
};
|
||||
|
||||
/** A minimal user row shaped like what the callback consumes. */
|
||||
function userRow(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "user-1",
|
||||
email: "existing@example.com",
|
||||
onboardingCompletedAt: new Date(),
|
||||
oidcIssuer: null,
|
||||
oidcSub: null,
|
||||
totpConfirmedAt: null,
|
||||
mfaEnforced: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The callback's `prisma.user.findFirst` calls are distinguishable by
|
||||
* their `where` shape: the (issuer, sub) identity lookup, the display-
|
||||
* email collision probe (`NOT`), and the link/provision email match.
|
||||
*/
|
||||
function mockUserLookups(opts: {
|
||||
byIdentity?: ReturnType<typeof userRow> | null;
|
||||
byEmail?: ReturnType<typeof userRow> | null;
|
||||
emailTaken?: boolean;
|
||||
}) {
|
||||
vi.mocked(prisma.user.findFirst).mockImplementation(((args: {
|
||||
where: Record<string, unknown>;
|
||||
}) => {
|
||||
if (args.where.oidcIssuer !== undefined) {
|
||||
return Promise.resolve(opts.byIdentity ?? null);
|
||||
}
|
||||
if (args.where.NOT !== undefined) {
|
||||
return Promise.resolve(opts.emailTaken ? { id: "user-other" } : null);
|
||||
}
|
||||
return Promise.resolve(opts.byEmail ?? null);
|
||||
}) as never);
|
||||
}
|
||||
|
||||
function makeRequest(opts: { query?: string; cookie?: string }): NextRequest {
|
||||
const req = new NextRequest(
|
||||
`http://localhost/api/auth/oidc/callback${opts.query ?? ""}`,
|
||||
);
|
||||
if (opts.cookie) {
|
||||
req.cookies.set("oidc_auth_state", opts.cookie);
|
||||
}
|
||||
return req;
|
||||
}
|
||||
|
||||
function stateCookie(payload: Record<string, unknown>): string {
|
||||
return `enc(${JSON.stringify(payload)})`;
|
||||
}
|
||||
|
||||
/** A request whose query/state pass the CSRF gate — the default fixture. */
|
||||
function validRequest(next = "/"): NextRequest {
|
||||
return makeRequest({
|
||||
query: "?code=abc&state=STATE",
|
||||
cookie: stateCookie({
|
||||
state: "STATE",
|
||||
nonce: "nonce-1",
|
||||
codeVerifier: "verifier-1",
|
||||
next,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function mockIdentity(overrides: Record<string, unknown> = {}) {
|
||||
vi.mocked(exchangeCodeForTokens).mockResolvedValue({ id_token: "idt" });
|
||||
vi.mocked(verifyIdToken).mockResolvedValue({
|
||||
sub: "sub-1",
|
||||
email: "existing@example.com",
|
||||
emailVerified: true,
|
||||
name: null,
|
||||
...overrides,
|
||||
} as never);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 20,
|
||||
reset: 0,
|
||||
ip: "1.2.3.4",
|
||||
} as never);
|
||||
vi.mocked(getOidcConfig).mockReturnValue(CONFIG);
|
||||
vi.mocked(discoverOidcMetadata).mockResolvedValue(METADATA);
|
||||
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0);
|
||||
vi.mocked(createMfaChallenge).mockResolvedValue({
|
||||
ticket: "mfa-ticket-1",
|
||||
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/auth/oidc/callback", () => {
|
||||
it("redirects with oidc_denied when the IdP reports an error", async () => {
|
||||
const res = await GET(makeRequest({ query: "?error=access_denied" }));
|
||||
expect(res.headers.get("location")).toContain("error=oidc_denied");
|
||||
});
|
||||
|
||||
it("redirects with oidc_state when code/state are missing", async () => {
|
||||
const res = await GET(makeRequest({}));
|
||||
expect(res.headers.get("location")).toContain("error=oidc_state");
|
||||
});
|
||||
|
||||
it("redirects with oidc_state when the state cookie is missing", async () => {
|
||||
const res = await GET(makeRequest({ query: "?code=abc&state=xyz" }));
|
||||
expect(res.headers.get("location")).toContain("error=oidc_state");
|
||||
});
|
||||
|
||||
it("redirects with oidc_state on a state/cookie mismatch (CSRF)", async () => {
|
||||
const res = await GET(
|
||||
makeRequest({
|
||||
query: "?code=abc&state=WRONG",
|
||||
cookie: stateCookie({
|
||||
state: "RIGHT",
|
||||
nonce: "nonce-1",
|
||||
codeVerifier: "verifier-1",
|
||||
next: "/",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(res.headers.get("location")).toContain("error=oidc_state");
|
||||
});
|
||||
|
||||
it("redirects with oidc_no_email when the identity has no email at all", async () => {
|
||||
mockIdentity({ email: null, emailVerified: undefined });
|
||||
mockUserLookups({ byIdentity: null });
|
||||
const res = await GET(validRequest());
|
||||
expect(res.headers.get("location")).toContain("error=oidc_no_email");
|
||||
});
|
||||
|
||||
it("redirects with oidc_email_unverified when email_verified is absent", async () => {
|
||||
// An IdP that never asserts verification cannot anchor a link or a
|
||||
// provision — absent is a reject, not a benefit of the doubt.
|
||||
mockIdentity({ emailVerified: undefined });
|
||||
mockUserLookups({ byIdentity: null, byEmail: null });
|
||||
const res = await GET(validRequest());
|
||||
expect(res.headers.get("location")).toContain(
|
||||
"error=oidc_email_unverified",
|
||||
);
|
||||
expect(prisma.user.create).not.toHaveBeenCalled();
|
||||
expect(createSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("redirects with oidc_email_unverified when email_verified is false", async () => {
|
||||
mockIdentity({ emailVerified: false });
|
||||
mockUserLookups({ byIdentity: null, byEmail: null });
|
||||
const res = await GET(validRequest());
|
||||
expect(res.headers.get("location")).toContain(
|
||||
"error=oidc_email_unverified",
|
||||
);
|
||||
});
|
||||
|
||||
it("logs in by (issuer, sub) without consulting the email", async () => {
|
||||
// Even with a brand-new email at the IdP, the stamped identity wins —
|
||||
// no second account is provisioned and no re-link happens.
|
||||
mockIdentity({ email: "renamed@example.com", emailVerified: true });
|
||||
mockUserLookups({
|
||||
byIdentity: userRow({
|
||||
oidcIssuer: METADATA.issuer,
|
||||
oidcSub: "sub-1",
|
||||
}),
|
||||
emailTaken: false,
|
||||
});
|
||||
vi.mocked(prisma.user.update).mockResolvedValue(
|
||||
userRow({
|
||||
email: "renamed@example.com",
|
||||
oidcIssuer: METADATA.issuer,
|
||||
oidcSub: "sub-1",
|
||||
}) as never,
|
||||
);
|
||||
|
||||
const res = await GET(validRequest("/dashboard"));
|
||||
|
||||
expect(prisma.user.create).not.toHaveBeenCalled();
|
||||
// Display email refresh — the only field the update touches.
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: { email: "renamed@example.com" },
|
||||
});
|
||||
expect(createSession).toHaveBeenCalled();
|
||||
expect(res.headers.get("location")).toContain("/dashboard");
|
||||
});
|
||||
|
||||
it("skips the display-email refresh when another account holds the address", async () => {
|
||||
mockIdentity({ email: "collide@example.com", emailVerified: true });
|
||||
mockUserLookups({
|
||||
byIdentity: userRow({
|
||||
oidcIssuer: METADATA.issuer,
|
||||
oidcSub: "sub-1",
|
||||
}),
|
||||
emailTaken: true,
|
||||
});
|
||||
|
||||
const res = await GET(validRequest());
|
||||
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
expect(createSession).toHaveBeenCalled();
|
||||
expect(res.status).toBe(307);
|
||||
});
|
||||
|
||||
it("links an existing email-matched account ONCE, stamping (issuer, sub)", async () => {
|
||||
mockIdentity();
|
||||
mockUserLookups({ byIdentity: null, byEmail: userRow() });
|
||||
vi.mocked(prisma.user.update).mockResolvedValue(
|
||||
userRow({ oidcIssuer: METADATA.issuer, oidcSub: "sub-1" }) as never,
|
||||
);
|
||||
|
||||
const res = await GET(validRequest("/dashboard"));
|
||||
|
||||
expect(prisma.user.create).not.toHaveBeenCalled();
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: { oidcIssuer: METADATA.issuer, oidcSub: "sub-1" },
|
||||
});
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"auth.oidc.linked",
|
||||
expect.objectContaining({ userId: "user-1" }),
|
||||
);
|
||||
expect(createSession).toHaveBeenCalled();
|
||||
expect(res.headers.get("location")).toContain("/dashboard");
|
||||
});
|
||||
|
||||
it("rejects a login whose email matches an account bound to a DIFFERENT identity", async () => {
|
||||
mockIdentity({ sub: "sub-intruder" });
|
||||
mockUserLookups({
|
||||
byIdentity: null,
|
||||
byEmail: userRow({
|
||||
oidcIssuer: "https://other-idp.example.com",
|
||||
oidcSub: "sub-original",
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await GET(validRequest());
|
||||
|
||||
expect(res.headers.get("location")).toContain(
|
||||
"error=oidc_identity_conflict",
|
||||
);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
expect(prisma.user.create).not.toHaveBeenCalled();
|
||||
expect(createSession).not.toHaveBeenCalled();
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"auth.oidc.link_conflict",
|
||||
expect.objectContaining({ userId: "user-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("auto-provisions a new user when registration is open", async () => {
|
||||
mockIdentity({
|
||||
sub: "sub-2",
|
||||
email: "New@Example.com",
|
||||
emailVerified: true,
|
||||
});
|
||||
mockUserLookups({ byIdentity: null, byEmail: null });
|
||||
vi.mocked(prisma.user.count).mockResolvedValue(3);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
registrationEnabled: true,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.create).mockResolvedValue(
|
||||
userRow({ id: "user-new", onboardingCompletedAt: null }) as never,
|
||||
);
|
||||
|
||||
const res = await GET(validRequest());
|
||||
|
||||
expect(prisma.user.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
// Lowercased at provision time; stamped with the durable identity
|
||||
// and the same server-default timezone the register route resolves.
|
||||
email: "new@example.com",
|
||||
passwordHash: null,
|
||||
role: "USER",
|
||||
timezone: "Europe/Berlin",
|
||||
oidcIssuer: METADATA.issuer,
|
||||
oidcSub: "sub-2",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(createSession).toHaveBeenCalled();
|
||||
expect(res.status).toBe(307);
|
||||
});
|
||||
|
||||
it("refuses to auto-provision when registration is closed", async () => {
|
||||
mockIdentity({ sub: "sub-3", email: "blocked@example.com" });
|
||||
mockUserLookups({ byIdentity: null, byEmail: null });
|
||||
vi.mocked(prisma.user.count).mockResolvedValue(5);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
registrationEnabled: false,
|
||||
} as never);
|
||||
|
||||
const res = await GET(validRequest());
|
||||
|
||||
expect(prisma.user.create).not.toHaveBeenCalled();
|
||||
expect(createSession).not.toHaveBeenCalled();
|
||||
expect(res.headers.get("location")).toContain(
|
||||
"error=oidc_registration_disabled",
|
||||
);
|
||||
});
|
||||
|
||||
it("never stamps the session step-up fresh — the 5th createSession arg is null", async () => {
|
||||
mockIdentity();
|
||||
mockUserLookups({ byIdentity: null, byEmail: userRow() });
|
||||
vi.mocked(prisma.user.update).mockResolvedValue(
|
||||
userRow({ oidcIssuer: METADATA.issuer, oidcSub: "sub-1" }) as never,
|
||||
);
|
||||
|
||||
await GET(validRequest());
|
||||
|
||||
expect(createSession).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
false,
|
||||
"1.2.3.4",
|
||||
null,
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it("routes a TOTP-enrolled account through the MFA challenge instead of logging in", async () => {
|
||||
mockIdentity();
|
||||
mockUserLookups({
|
||||
byIdentity: userRow({
|
||||
oidcIssuer: METADATA.issuer,
|
||||
oidcSub: "sub-1",
|
||||
totpConfirmedAt: new Date(),
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await GET(validRequest("/dashboard"));
|
||||
|
||||
// No session — the partial state lives in the single-use ticket.
|
||||
expect(createSession).not.toHaveBeenCalled();
|
||||
expect(createMfaChallenge).toHaveBeenCalledWith("user-1", "login");
|
||||
|
||||
const location = res.headers.get("location")!;
|
||||
expect(location).toContain("/auth/login");
|
||||
expect(location).not.toContain("error=");
|
||||
// `next` continuation rides the query string into the login page.
|
||||
expect(location).toContain(`next=${encodeURIComponent("/dashboard")}`);
|
||||
|
||||
// The ticket + methods ride the login-page-scoped handoff cookie.
|
||||
const setCookie = res.headers.getSetCookie().join("\n");
|
||||
expect(setCookie).toContain("oidc_mfa_handoff=");
|
||||
expect(setCookie).toContain(encodeURIComponent("mfa-ticket-1"));
|
||||
expect(setCookie.toLowerCase()).toContain("path=/auth/login");
|
||||
expect(decodeURIComponent(setCookie)).toContain('"totp"');
|
||||
});
|
||||
|
||||
it("offers only the security-key method for a WebAuthn-only account", async () => {
|
||||
mockIdentity();
|
||||
mockUserLookups({
|
||||
byIdentity: userRow({
|
||||
oidcIssuer: METADATA.issuer,
|
||||
oidcSub: "sub-1",
|
||||
totpConfirmedAt: null,
|
||||
}),
|
||||
});
|
||||
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(1);
|
||||
|
||||
const res = await GET(validRequest());
|
||||
|
||||
expect(createSession).not.toHaveBeenCalled();
|
||||
const setCookie = decodeURIComponent(res.headers.getSetCookie().join("\n"));
|
||||
expect(setCookie).toContain('"webauthn"');
|
||||
expect(setCookie).not.toContain('"totp"');
|
||||
});
|
||||
|
||||
it("redirects with oidc_failed when the token exchange throws", async () => {
|
||||
vi.mocked(exchangeCodeForTokens).mockRejectedValue(new Error("network"));
|
||||
const res = await GET(validRequest());
|
||||
expect(res.headers.get("location")).toContain("error=oidc_failed");
|
||||
});
|
||||
|
||||
it("deletes the state cookie with the exact path it was set under", async () => {
|
||||
// RFC 6265 keys cookies by (name, domain, path) — a deletion without
|
||||
// `Path=/api/auth/oidc` would target a `/`-scoped cookie that does not
|
||||
// exist and leave the real single-use blob alive.
|
||||
const errorRes = await GET(makeRequest({ query: "?error=access_denied" }));
|
||||
const errorCookie = errorRes.headers
|
||||
.getSetCookie()
|
||||
.find((c) => c.startsWith("oidc_auth_state="))!;
|
||||
expect(errorCookie.toLowerCase()).toContain("path=/api/auth/oidc");
|
||||
|
||||
mockIdentity();
|
||||
mockUserLookups({
|
||||
byIdentity: userRow({ oidcIssuer: METADATA.issuer, oidcSub: "sub-1" }),
|
||||
});
|
||||
const successRes = await GET(validRequest());
|
||||
const successCookie = successRes.headers
|
||||
.getSetCookie()
|
||||
.find((c) => c.startsWith("oidc_auth_state="))!;
|
||||
expect(successCookie.toLowerCase()).toContain("path=/api/auth/oidc");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,394 @@
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { createSession } from "@/lib/auth/session";
|
||||
import { recordSignInDevice } from "@/lib/auth/login-alert";
|
||||
import {
|
||||
deriveUniqueUsername,
|
||||
discoverOidcMetadata,
|
||||
exchangeCodeForTokens,
|
||||
fetchUserinfoEmail,
|
||||
getOidcConfig,
|
||||
getOidcRedirectUri,
|
||||
verifyIdToken,
|
||||
} from "@/lib/auth/oidc";
|
||||
import {
|
||||
OIDC_MFA_COOKIE,
|
||||
OIDC_MFA_COOKIE_PATH,
|
||||
OIDC_MFA_TTL_MS,
|
||||
OIDC_STATE_COOKIE,
|
||||
OIDC_STATE_COOKIE_PATH,
|
||||
} from "@/lib/auth/oidc-cookie";
|
||||
import { createMfaChallenge } from "@/lib/auth/mfa/challenge";
|
||||
import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
|
||||
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
|
||||
import { resolveServerDefaultTimezone } from "@/lib/tz/resolver";
|
||||
|
||||
const LOGIN_ERROR_URL = "/auth/login";
|
||||
|
||||
function errorRedirect(req: NextRequest, reason: string): NextResponse {
|
||||
const response = NextResponse.redirect(
|
||||
new URL(`${LOGIN_ERROR_URL}?error=${reason}`, req.url),
|
||||
);
|
||||
deleteStateCookie(response);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* The state cookie is set with `path: "/api/auth/oidc"`; the delete must
|
||||
* repeat that path (RFC 6265 keys cookies by name+domain+path — a bare
|
||||
* delete would target `/` and never match, leaving the single-use blob
|
||||
* alive for the rest of its TTL).
|
||||
*/
|
||||
function deleteStateCookie(response: NextResponse): void {
|
||||
response.cookies.delete({
|
||||
name: OIDC_STATE_COOKIE,
|
||||
path: OIDC_STATE_COOKIE_PATH,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Closed set of RFC 6749 §4.1.2.1 / OIDC Core §3.1.2.6 authorization error
|
||||
* codes. The wide-event meta only ever carries a member of this set (or
|
||||
* "other") — never the raw, attacker-influenceable query value.
|
||||
*/
|
||||
const KNOWN_IDP_ERROR_CODES = new Set([
|
||||
"access_denied",
|
||||
"invalid_request",
|
||||
"unauthorized_client",
|
||||
"unsupported_response_type",
|
||||
"invalid_scope",
|
||||
"server_error",
|
||||
"temporarily_unavailable",
|
||||
"interaction_required",
|
||||
"login_required",
|
||||
"account_selection_required",
|
||||
"consent_required",
|
||||
"invalid_request_uri",
|
||||
"invalid_request_object",
|
||||
"request_not_supported",
|
||||
"request_uri_not_supported",
|
||||
"registration_not_supported",
|
||||
]);
|
||||
|
||||
interface StoredOidcState {
|
||||
state: string;
|
||||
nonce: string;
|
||||
codeVerifier: string;
|
||||
next: string;
|
||||
}
|
||||
|
||||
function isStoredOidcState(value: unknown): value is StoredOidcState {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
typeof (value as Record<string, unknown>).state === "string" &&
|
||||
typeof (value as Record<string, unknown>).nonce === "string" &&
|
||||
typeof (value as Record<string, unknown>).codeVerifier === "string" &&
|
||||
typeof (value as Record<string, unknown>).next === "string"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* OIDC callback. Must work with NO existing session — this route
|
||||
* authenticates the user in the first place, unlike the per-account
|
||||
* integration callbacks (Withings/Polar/etc.) which all require
|
||||
* `requireAuth()`. Every failure branch redirects to `/auth/login?error=...`
|
||||
* (never JSON — this is a top-level browser navigation) and deletes the
|
||||
* single-use state cookie, mirroring `src/app/api/withings/callback/route.ts`.
|
||||
*/
|
||||
export const GET = apiHandler(async (req: NextRequest) => {
|
||||
annotate({ action: { name: "auth.oidc.callback" } });
|
||||
|
||||
const config = getOidcConfig();
|
||||
if (!config) return errorRedirect(req, "oidc_disabled");
|
||||
|
||||
const rl = await checkAuthSurfaceRateLimit(
|
||||
req,
|
||||
"auth:oidc:callback",
|
||||
20,
|
||||
15 * 60 * 1000,
|
||||
);
|
||||
const ip = rl.ip ?? "unknown";
|
||||
if (!rl.allowed) return errorRedirect(req, "oidc_rate_limited");
|
||||
|
||||
const { searchParams } = req.nextUrl;
|
||||
const idpError = searchParams.get("error");
|
||||
if (idpError) {
|
||||
annotate({
|
||||
meta: {
|
||||
reason: "idp_denied",
|
||||
idpError: KNOWN_IDP_ERROR_CODES.has(idpError) ? idpError : "other",
|
||||
},
|
||||
});
|
||||
return errorRedirect(req, "oidc_denied");
|
||||
}
|
||||
|
||||
const code = searchParams.get("code");
|
||||
const state = searchParams.get("state");
|
||||
if (!code || !state) return errorRedirect(req, "oidc_state");
|
||||
|
||||
const rawCookie = req.cookies.get(OIDC_STATE_COOKIE)?.value;
|
||||
let stored: StoredOidcState | null = null;
|
||||
if (rawCookie) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(decrypt(rawCookie));
|
||||
if (isStoredOidcState(parsed)) stored = parsed;
|
||||
} catch {
|
||||
stored = null;
|
||||
}
|
||||
}
|
||||
if (!stored) return errorRedirect(req, "oidc_state");
|
||||
|
||||
// CSRF check — timing-safe, byte-for-byte, mirroring the Withings callback.
|
||||
if (
|
||||
state.length !== stored.state.length ||
|
||||
!timingSafeEqual(Buffer.from(state), Buffer.from(stored.state))
|
||||
) {
|
||||
annotate({ meta: { reason: "csrf" } });
|
||||
return errorRedirect(req, "oidc_state");
|
||||
}
|
||||
|
||||
try {
|
||||
const metadata = await discoverOidcMetadata(config);
|
||||
const redirectUri = getOidcRedirectUri();
|
||||
|
||||
const tokens = await exchangeCodeForTokens({
|
||||
metadata,
|
||||
config,
|
||||
code,
|
||||
codeVerifier: stored.codeVerifier,
|
||||
redirectUri,
|
||||
});
|
||||
if (!tokens.id_token) return errorRedirect(req, "oidc_failed");
|
||||
|
||||
const identity = await verifyIdToken({
|
||||
metadata,
|
||||
config,
|
||||
idToken: tokens.id_token,
|
||||
nonce: stored.nonce,
|
||||
});
|
||||
|
||||
let email = identity.email;
|
||||
let emailVerified = identity.emailVerified;
|
||||
if (!email && tokens.access_token) {
|
||||
const userinfo = await fetchUserinfoEmail({
|
||||
metadata,
|
||||
accessToken: tokens.access_token,
|
||||
});
|
||||
email = userinfo.email;
|
||||
emailVerified = userinfo.emailVerified;
|
||||
}
|
||||
|
||||
// (1) Durable identity match — the (issuer, sub) pair stamped on a
|
||||
// previous login, link, or provision. Email is deliberately NOT
|
||||
// consulted here: after the stamp it is a display field, so an
|
||||
// IdP-side email change can refresh it below but can never re-point
|
||||
// the login at a different account.
|
||||
let user = await prisma.user.findFirst({
|
||||
where: { oidcIssuer: metadata.issuer, oidcSub: identity.sub },
|
||||
});
|
||||
|
||||
if (user) {
|
||||
// Refresh the display email only — and only from a claim the IdP
|
||||
// explicitly marks verified. Skipped when another account already
|
||||
// holds the address (`email` is unique); the login itself proceeds
|
||||
// either way, keyed by (issuer, sub).
|
||||
const displayEmail = email ? email.toLowerCase() : null;
|
||||
if (
|
||||
displayEmail &&
|
||||
emailVerified === true &&
|
||||
user.email?.toLowerCase() !== displayEmail
|
||||
) {
|
||||
const emailTaken = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: { equals: displayEmail, mode: "insensitive" },
|
||||
NOT: { id: user.id },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (!emailTaken) {
|
||||
user = await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { email: displayEmail },
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No stamped identity yet — both remaining paths (link-once,
|
||||
// provision) bind this (issuer, sub) to an account keyed by email,
|
||||
// so the claim must be explicitly `email_verified: true`. An ABSENT
|
||||
// claim is a reject, not a benefit of the doubt: an IdP that does
|
||||
// not assert verification cannot anchor a link that later gates a
|
||||
// health record.
|
||||
if (!email) {
|
||||
annotate({ meta: { reason: "no_email" } });
|
||||
return errorRedirect(req, "oidc_no_email");
|
||||
}
|
||||
if (emailVerified !== true) {
|
||||
annotate({ meta: { reason: "email_unverified" } });
|
||||
return errorRedirect(req, "oidc_email_unverified");
|
||||
}
|
||||
|
||||
const byEmail = await prisma.user.findFirst({
|
||||
where: { email: { equals: email, mode: "insensitive" } },
|
||||
});
|
||||
|
||||
if (byEmail) {
|
||||
// (2) Link ONCE. An account already pinned to a different IdP
|
||||
// identity is never silently re-bound — a rebind would let a
|
||||
// second IdP identity that acquires the same email capture the
|
||||
// account. Audited so the operator can see the collision.
|
||||
if (byEmail.oidcIssuer !== null || byEmail.oidcSub !== null) {
|
||||
await auditLog("auth.oidc.link_conflict", {
|
||||
userId: byEmail.id,
|
||||
ipAddress: ip,
|
||||
});
|
||||
annotate({ meta: { reason: "identity_conflict" } });
|
||||
return errorRedirect(req, "oidc_identity_conflict");
|
||||
}
|
||||
user = await prisma.user.update({
|
||||
where: { id: byEmail.id },
|
||||
data: { oidcIssuer: metadata.issuer, oidcSub: identity.sub },
|
||||
});
|
||||
await auditLog("auth.oidc.linked", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
});
|
||||
} else {
|
||||
// (3) Provision.
|
||||
const userCount = await prisma.user.count();
|
||||
let registrationEnabled = true;
|
||||
try {
|
||||
const settings = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
});
|
||||
if (settings && !settings.registrationEnabled && userCount > 0) {
|
||||
registrationEnabled = false;
|
||||
}
|
||||
} catch {
|
||||
// Table may not exist yet; allow provisioning (matches register/route.ts).
|
||||
}
|
||||
if (!registrationEnabled) {
|
||||
annotate({ meta: { reason: "registration_disabled" } });
|
||||
return errorRedirect(req, "oidc_registration_disabled");
|
||||
}
|
||||
|
||||
const username = await deriveUniqueUsername(email, (candidate) =>
|
||||
prisma.user
|
||||
.findUnique({ where: { username: candidate } })
|
||||
.then((u: unknown) => u !== null),
|
||||
);
|
||||
|
||||
user = await prisma.user.create({
|
||||
data: {
|
||||
email: email.toLowerCase(),
|
||||
username,
|
||||
passwordHash: null,
|
||||
role: userCount === 0 ? "ADMIN" : "USER",
|
||||
// Same server-default resolution the register route uses — an
|
||||
// SSO-provisioned account has no registration form to carry the
|
||||
// browser-detected timezone.
|
||||
timezone: await resolveServerDefaultTimezone(),
|
||||
oidcIssuer: metadata.issuer,
|
||||
oidcSub: identity.sub,
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog("auth.oidc.provisioned", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Native second factor. OIDC is a DELEGATED factor: it proves the IdP
|
||||
// authenticated someone, but it never substitutes for this app's own
|
||||
// second factor. An account with a confirmed TOTP secret or a
|
||||
// registered security key gets the same MFA challenge step password
|
||||
// login gets — no session is minted here; the single-use ticket rides
|
||||
// the handoff cookie to the login page, which renders the same
|
||||
// challenge UI and completes at `/api/auth/mfa/verify`. (The
|
||||
// trusted-device skip cannot apply on this path: its cookie is
|
||||
// SameSite=Strict, which the browser withholds on an IdP-initiated
|
||||
// redirect chain.)
|
||||
const hasTotp = Boolean(user.totpConfirmedAt);
|
||||
const webauthnKeyCount = await prisma.webauthnMfaCredential.count({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
const hasWebauthn = webauthnKeyCount > 0;
|
||||
|
||||
if (hasTotp || hasWebauthn) {
|
||||
const challenge = await createMfaChallenge(user.id, "login");
|
||||
const methods: ("totp" | "recovery" | "webauthn")[] = [];
|
||||
if (hasTotp) methods.push("totp", "recovery");
|
||||
if (hasWebauthn) methods.push("webauthn");
|
||||
await auditLog("auth.mfa.challenge", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: { source: "login.oidc" },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.mfa.challenge" },
|
||||
meta: { mfa_required: true },
|
||||
});
|
||||
const loginUrl = new URL(LOGIN_ERROR_URL, req.url);
|
||||
if (stored.next !== "/") {
|
||||
loginUrl.searchParams.set("next", stored.next);
|
||||
}
|
||||
const response = NextResponse.redirect(loginUrl);
|
||||
deleteStateCookie(response);
|
||||
response.cookies.set(
|
||||
OIDC_MFA_COOKIE,
|
||||
JSON.stringify({ ticket: challenge.ticket, methods }),
|
||||
{
|
||||
// Not httpOnly by design — see the constant's doc comment.
|
||||
httpOnly: false,
|
||||
secure: shouldEmitSecureCookie(),
|
||||
sameSite: "lax",
|
||||
maxAge: Math.floor(OIDC_MFA_TTL_MS / 1000),
|
||||
path: OIDC_MFA_COOKIE_PATH,
|
||||
},
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
const ua = req.headers.get("user-agent");
|
||||
|
||||
// v1.23 parity with password login — the admin-enforced-MFA hint cookie
|
||||
// sends a single-factor account into forced enrollment after sign-in.
|
||||
await syncMfaEnrollCookie(user.id, {
|
||||
totpConfirmedAt: user.totpConfirmedAt,
|
||||
mfaEnforced: user.mfaEnforced,
|
||||
});
|
||||
|
||||
// The 5th argument stays null: OIDC is a delegated factor, so an SSO
|
||||
// session must never satisfy `requireFreshMfa` step-up (MFA disable,
|
||||
// encryption-key rotation, encrypted export, account deletion) — the
|
||||
// same treatment a trusted-device login gets.
|
||||
await createSession(
|
||||
user.id,
|
||||
user.onboardingCompletedAt === null,
|
||||
ip,
|
||||
ua,
|
||||
null,
|
||||
);
|
||||
|
||||
void recordSignInDevice({ userId: user.id, ip, userAgent: ua });
|
||||
|
||||
await auditLog("auth.oidc.login", { userId: user.id, ipAddress: ip });
|
||||
|
||||
const response = NextResponse.redirect(new URL(stored.next, req.url));
|
||||
deleteStateCookie(response);
|
||||
return response;
|
||||
} catch (err) {
|
||||
getEvent()?.setError(err);
|
||||
annotate({ meta: { reason: "exchange_or_verify_failed" } });
|
||||
return errorRedirect(req, "oidc_failed");
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkAuthSurfaceRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
encrypt: vi.fn((s: string) => `enc(${s})`),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/secure-cookie", () => ({
|
||||
shouldEmitSecureCookie: () => true,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/oidc", async () => {
|
||||
// Keep the real `sanitizeOidcNextPath` — it's the security-relevant part
|
||||
// of this route, and the tests below assert its output round-trips into
|
||||
// the state cookie rather than re-deriving the same logic here.
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/lib/auth/oidc")>("@/lib/auth/oidc");
|
||||
return {
|
||||
getOidcConfig: vi.fn(),
|
||||
discoverOidcMetadata: vi.fn(),
|
||||
buildAuthorizationUrl: vi.fn(() => "https://idp.example.com/authorize?x=1"),
|
||||
getOidcRedirectUri: () =>
|
||||
"https://healthlog.example.com/api/auth/oidc/callback",
|
||||
sanitizeOidcNextPath: actual.sanitizeOidcNextPath,
|
||||
};
|
||||
});
|
||||
|
||||
import { GET } from "../route";
|
||||
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
import { getOidcConfig, discoverOidcMetadata } from "@/lib/auth/oidc";
|
||||
|
||||
function makeRequest(path = "/api/auth/oidc/login"): NextRequest {
|
||||
return new NextRequest(`http://localhost${path}`);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 10,
|
||||
reset: 0,
|
||||
ip: "1.2.3.4",
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("GET /api/auth/oidc/login", () => {
|
||||
it("redirects to /auth/login?error=oidc_disabled when unconfigured", async () => {
|
||||
vi.mocked(getOidcConfig).mockReturnValue(null);
|
||||
const res = await GET(makeRequest());
|
||||
expect(res.status).toBe(307);
|
||||
expect(res.headers.get("location")).toContain(
|
||||
"/auth/login?error=oidc_disabled",
|
||||
);
|
||||
});
|
||||
|
||||
it("redirects to the rate-limit error page when throttled", async () => {
|
||||
vi.mocked(getOidcConfig).mockReturnValue({
|
||||
issuerUrl: "https://idp.example.com",
|
||||
clientId: "client-1",
|
||||
clientSecret: "secret-1",
|
||||
scopes: "openid email profile",
|
||||
buttonLabel: "SSO",
|
||||
});
|
||||
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
reset: 0,
|
||||
ip: "1.2.3.4",
|
||||
} as never);
|
||||
const res = await GET(makeRequest());
|
||||
expect(res.headers.get("location")).toContain("error=oidc_rate_limited");
|
||||
});
|
||||
|
||||
it("redirects to the IdP and sets the encrypted state cookie on success", async () => {
|
||||
vi.mocked(getOidcConfig).mockReturnValue({
|
||||
issuerUrl: "https://idp.example.com",
|
||||
clientId: "client-1",
|
||||
clientSecret: "secret-1",
|
||||
scopes: "openid email profile",
|
||||
buttonLabel: "SSO",
|
||||
});
|
||||
vi.mocked(discoverOidcMetadata).mockResolvedValue({
|
||||
issuer: "https://idp.example.com",
|
||||
authorization_endpoint: "https://idp.example.com/authorize",
|
||||
token_endpoint: "https://idp.example.com/token",
|
||||
jwks_uri: "https://idp.example.com/jwks",
|
||||
});
|
||||
|
||||
const res = await GET(makeRequest("/api/auth/oidc/login?next=/dashboard"));
|
||||
expect(res.headers.get("location")).toBe(
|
||||
"https://idp.example.com/authorize?x=1",
|
||||
);
|
||||
const cookie = res.cookies.get("oidc_auth_state");
|
||||
expect(cookie?.value).toMatch(/^enc\(/);
|
||||
expect(cookie?.httpOnly).toBe(true);
|
||||
expect(cookie?.sameSite).toBe("lax");
|
||||
});
|
||||
|
||||
it("neutralizes a backslash-normalization open-redirect payload in next", async () => {
|
||||
vi.mocked(getOidcConfig).mockReturnValue({
|
||||
issuerUrl: "https://idp.example.com",
|
||||
clientId: "client-1",
|
||||
clientSecret: "secret-1",
|
||||
scopes: "openid email profile",
|
||||
buttonLabel: "SSO",
|
||||
});
|
||||
vi.mocked(discoverOidcMetadata).mockResolvedValue({
|
||||
issuer: "https://idp.example.com",
|
||||
authorization_endpoint: "https://idp.example.com/authorize",
|
||||
token_endpoint: "https://idp.example.com/token",
|
||||
jwks_uri: "https://idp.example.com/jwks",
|
||||
});
|
||||
|
||||
const res = await GET(
|
||||
makeRequest(
|
||||
`/api/auth/oidc/login?next=${encodeURIComponent("/\\evil.com")}`,
|
||||
),
|
||||
);
|
||||
const cookie = res.cookies.get("oidc_auth_state");
|
||||
const stored = JSON.parse(
|
||||
cookie!.value.replace(/^enc\(/, "").replace(/\)$/, ""),
|
||||
);
|
||||
expect(stored.next).toBe("/");
|
||||
});
|
||||
|
||||
it("falls back to the login error page on discovery failure", async () => {
|
||||
vi.mocked(getOidcConfig).mockReturnValue({
|
||||
issuerUrl: "https://idp.example.com",
|
||||
clientId: "client-1",
|
||||
clientSecret: "secret-1",
|
||||
scopes: "openid email profile",
|
||||
buttonLabel: "SSO",
|
||||
});
|
||||
vi.mocked(discoverOidcMetadata).mockRejectedValue(new Error("boom"));
|
||||
const res = await GET(makeRequest());
|
||||
expect(res.headers.get("location")).toContain("error=oidc_failed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
|
||||
import { s256Challenge } from "@/lib/mcp/oauth/pkce";
|
||||
import {
|
||||
buildAuthorizationUrl,
|
||||
discoverOidcMetadata,
|
||||
getOidcConfig,
|
||||
getOidcRedirectUri,
|
||||
sanitizeOidcNextPath,
|
||||
} from "@/lib/auth/oidc";
|
||||
import {
|
||||
OIDC_STATE_COOKIE,
|
||||
OIDC_STATE_COOKIE_PATH,
|
||||
OIDC_STATE_TTL_MS,
|
||||
} from "@/lib/auth/oidc-cookie";
|
||||
|
||||
/**
|
||||
* Redirects the browser to the configured OIDC provider's authorization
|
||||
* endpoint. Full-page navigation, not a fetch — every failure branch
|
||||
* redirects to `/auth/login?error=...` rather than returning a JSON
|
||||
* envelope, mirroring `src/app/api/withings/connect/route.ts`.
|
||||
*/
|
||||
export const GET = apiHandler(async (req: NextRequest) => {
|
||||
annotate({ action: { name: "auth.oidc.login" } });
|
||||
|
||||
const config = getOidcConfig();
|
||||
if (!config) {
|
||||
return NextResponse.redirect(
|
||||
new URL("/auth/login?error=oidc_disabled", req.url),
|
||||
);
|
||||
}
|
||||
|
||||
const rl = await checkAuthSurfaceRateLimit(
|
||||
req,
|
||||
"auth:oidc:login",
|
||||
10,
|
||||
15 * 60 * 1000,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return NextResponse.redirect(
|
||||
new URL("/auth/login?error=oidc_rate_limited", req.url),
|
||||
);
|
||||
}
|
||||
|
||||
let metadata;
|
||||
try {
|
||||
metadata = await discoverOidcMetadata(config);
|
||||
} catch (err) {
|
||||
getEvent()?.setError(err);
|
||||
return NextResponse.redirect(
|
||||
new URL("/auth/login?error=oidc_failed", req.url),
|
||||
);
|
||||
}
|
||||
|
||||
const state = randomBytes(32).toString("base64url");
|
||||
const nonce = randomBytes(32).toString("base64url");
|
||||
const codeVerifier = randomBytes(64).toString("base64url");
|
||||
const codeChallenge = s256Challenge(codeVerifier);
|
||||
|
||||
const safeNext = sanitizeOidcNextPath(
|
||||
req.nextUrl.searchParams.get("next"),
|
||||
req.url,
|
||||
);
|
||||
|
||||
const redirectUri = getOidcRedirectUri();
|
||||
const authorizationUrl = buildAuthorizationUrl({
|
||||
metadata,
|
||||
config,
|
||||
state,
|
||||
nonce,
|
||||
codeChallenge,
|
||||
redirectUri,
|
||||
});
|
||||
|
||||
const response = NextResponse.redirect(authorizationUrl);
|
||||
const cookiePayload = encrypt(
|
||||
JSON.stringify({ state, nonce, codeVerifier, next: safeNext }),
|
||||
);
|
||||
response.cookies.set(OIDC_STATE_COOKIE, cookiePayload, {
|
||||
httpOnly: true,
|
||||
secure: shouldEmitSecureCookie(),
|
||||
// Cross-site top-level redirect back from the IdP, same reasoning as
|
||||
// the session cookie and the Withings OAuth state cookie.
|
||||
sameSite: "lax",
|
||||
maxAge: Math.floor(OIDC_STATE_TTL_MS / 1000),
|
||||
// Shared constant — the callback's delete must repeat this exact path
|
||||
// (RFC 6265 keys cookies by name+domain+path).
|
||||
path: OIDC_STATE_COOKIE_PATH,
|
||||
});
|
||||
|
||||
return response;
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { getOidcConfig, isOidcOnly } from "@/lib/auth/oidc";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** Public — the login page reads this pre-session to decide whether to
|
||||
* render the SSO button and whether to hide password/passkey login. Pure
|
||||
* env reads; nothing here can throw. */
|
||||
export const GET = apiHandler(async () => {
|
||||
annotate({ action: { name: "auth.oidc.status" } });
|
||||
const config = getOidcConfig();
|
||||
return apiSuccess({
|
||||
enabled: config !== null,
|
||||
buttonLabel: config?.buttonLabel ?? null,
|
||||
only: isOidcOnly(),
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/auth/passkey", () => ({
|
||||
createAuthenticationOptions: vi.fn().mockResolvedValue({
|
||||
options: {},
|
||||
challengeId: "ch-1",
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkAuthSurfaceRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 9,
|
||||
reset: 0,
|
||||
ip: "203.0.113.1",
|
||||
}),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { createAuthenticationOptions } from "@/lib/auth/passkey";
|
||||
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const OIDC_ENV_KEYS = [
|
||||
"OIDC_ISSUER_URL",
|
||||
"OIDC_CLIENT_ID",
|
||||
"OIDC_CLIENT_SECRET",
|
||||
"OIDC_ONLY",
|
||||
] as const;
|
||||
const original: Record<string, string | undefined> = {};
|
||||
|
||||
function makeRequest(): NextRequest {
|
||||
return new NextRequest("http://localhost/api/auth/passkey/login-options", {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
for (const key of OIDC_ENV_KEYS) original[key] = process.env[key];
|
||||
vi.mocked(createAuthenticationOptions).mockResolvedValue({
|
||||
options: {},
|
||||
challengeId: "ch-1",
|
||||
} as never);
|
||||
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 9,
|
||||
reset: 0,
|
||||
ip: "203.0.113.1",
|
||||
} as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of OIDC_ENV_KEYS) {
|
||||
if (original[key] === undefined) delete process.env[key];
|
||||
else process.env[key] = original[key];
|
||||
}
|
||||
});
|
||||
|
||||
describe("POST /api/auth/passkey/login-options — OIDC_ONLY server-side enforcement", () => {
|
||||
it("rejects before minting a WebAuthn challenge when OIDC_ONLY is set", async () => {
|
||||
process.env.OIDC_ISSUER_URL = "https://idp.example.com";
|
||||
process.env.OIDC_CLIENT_ID = "client-1";
|
||||
process.env.OIDC_CLIENT_SECRET = "secret-1";
|
||||
process.env.OIDC_ONLY = "true";
|
||||
|
||||
const res = await POST(makeRequest());
|
||||
expect(res.status).toBe(403);
|
||||
expect(createAuthenticationOptions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still allows passkey login-options when OIDC_ONLY is set but the provider is half-configured", async () => {
|
||||
delete process.env.OIDC_ISSUER_URL;
|
||||
process.env.OIDC_CLIENT_ID = "client-1";
|
||||
process.env.OIDC_CLIENT_SECRET = "secret-1";
|
||||
process.env.OIDC_ONLY = "true";
|
||||
|
||||
const res = await POST(makeRequest());
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,19 @@
|
||||
import { createAuthenticationOptions } from "@/lib/auth/passkey";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { NextResponse } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { isOidcOnly } from "@/lib/auth/oidc";
|
||||
|
||||
export const POST = apiHandler(async (request: Request) => {
|
||||
// OIDC_ONLY must block passkey login too, not just password login.
|
||||
if (isOidcOnly()) {
|
||||
return apiError("Passkey login is disabled. Sign in with SSO.", 403, {
|
||||
errorCode: "oidc_only",
|
||||
});
|
||||
}
|
||||
|
||||
// v1.4.43 W13 M-4 — tighter shared bucket on trust-chain misconfig.
|
||||
const rl = await checkAuthSurfaceRateLimit(
|
||||
request,
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn() },
|
||||
apiToken: { create: vi.fn() },
|
||||
refreshToken: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/passkey", () => ({
|
||||
verifyAuthentication: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({
|
||||
createSession: vi.fn().mockResolvedValue("session-id"),
|
||||
setOnboardingPendingCookie: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/mfa-enrollment", () => ({
|
||||
syncMfaEnrollCookie: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/login-alert", () => ({
|
||||
recordSignInDevice: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
checkAuthSurfaceRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/hmac", () => ({
|
||||
hashToken: vi.fn((raw: string) => `hashed:${raw}`),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { verifyAuthentication } from "@/lib/auth/passkey";
|
||||
import { checkRateLimit, checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const OIDC_ENV_KEYS = [
|
||||
"OIDC_ISSUER_URL",
|
||||
"OIDC_CLIENT_ID",
|
||||
"OIDC_CLIENT_SECRET",
|
||||
"OIDC_ONLY",
|
||||
] as const;
|
||||
const original: Record<string, string | undefined> = {};
|
||||
|
||||
function makeRequest(): NextRequest {
|
||||
return new NextRequest("http://localhost/api/auth/passkey/login-verify", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ challengeId: "ch-1", credential: { id: "cred-1" } }),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
for (const key of OIDC_ENV_KEYS) original[key] = process.env[key];
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
id: "user-1",
|
||||
username: "testuser",
|
||||
email: "user@example.com",
|
||||
} as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 9,
|
||||
reset: 0,
|
||||
} as never);
|
||||
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 9,
|
||||
resetAt: 0,
|
||||
ip: "203.0.113.1",
|
||||
} as never);
|
||||
vi.mocked(verifyAuthentication).mockResolvedValue({
|
||||
verification: { verified: true },
|
||||
passkey: { userId: "user-1" },
|
||||
} as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of OIDC_ENV_KEYS) {
|
||||
if (original[key] === undefined) delete process.env[key];
|
||||
else process.env[key] = original[key];
|
||||
}
|
||||
});
|
||||
|
||||
describe("POST /api/auth/passkey/login-verify — OIDC_ONLY server-side enforcement", () => {
|
||||
it("rejects passkey login before touching WebAuthn verification when OIDC_ONLY is set", async () => {
|
||||
process.env.OIDC_ISSUER_URL = "https://idp.example.com";
|
||||
process.env.OIDC_CLIENT_ID = "client-1";
|
||||
process.env.OIDC_CLIENT_SECRET = "secret-1";
|
||||
process.env.OIDC_ONLY = "true";
|
||||
|
||||
const res = await POST(makeRequest());
|
||||
expect(res.status).toBe(403);
|
||||
expect(verifyAuthentication).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still allows passkey login when OIDC_ONLY is set but the provider is half-configured", async () => {
|
||||
delete process.env.OIDC_ISSUER_URL;
|
||||
process.env.OIDC_CLIENT_ID = "client-1";
|
||||
process.env.OIDC_CLIENT_SECRET = "secret-1";
|
||||
process.env.OIDC_ONLY = "true";
|
||||
|
||||
const res = await POST(makeRequest());
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -17,8 +17,17 @@ import {
|
||||
isCookielessNativeCaller,
|
||||
} from "@/lib/auth/native-client";
|
||||
import { issueAccessAndRefresh } from "@/lib/auth/refresh-token";
|
||||
import { isOidcOnly } from "@/lib/auth/oidc";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// OIDC_ONLY must block passkey login too, not just password login — a
|
||||
// credential minted before SSO was mandated must not stay a live bypass.
|
||||
if (isOidcOnly()) {
|
||||
return apiError("Passkey login is disabled. Sign in with SSO.", 403, {
|
||||
errorCode: "oidc_only",
|
||||
});
|
||||
}
|
||||
|
||||
await ensureDbCompatibility();
|
||||
|
||||
// v1.4.43 W13 M-4 — tighter shared bucket on trust-chain misconfig.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* v1.4.43 W6 — multi-issue 422 envelope on POST /api/auth/register.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
@@ -118,3 +118,50 @@ describe("POST /api/auth/register — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
async function postPathThrough(body: unknown): Promise<Response> {
|
||||
return POST(postReq(body));
|
||||
}
|
||||
|
||||
describe("POST /api/auth/register — OIDC_ONLY server-side enforcement", () => {
|
||||
const OIDC_ENV_KEYS = [
|
||||
"OIDC_ISSUER_URL",
|
||||
"OIDC_CLIENT_ID",
|
||||
"OIDC_CLIENT_SECRET",
|
||||
"OIDC_ONLY",
|
||||
] as const;
|
||||
const original: Record<string, string | undefined> = {};
|
||||
|
||||
beforeEach(() => {
|
||||
for (const key of OIDC_ENV_KEYS) original[key] = process.env[key];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of OIDC_ENV_KEYS) {
|
||||
if (original[key] === undefined) delete process.env[key];
|
||||
else process.env[key] = original[key];
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects self-registration before any DB lookup when OIDC_ONLY is set", async () => {
|
||||
process.env.OIDC_ISSUER_URL = "https://idp.example.com";
|
||||
process.env.OIDC_CLIENT_ID = "client-1";
|
||||
process.env.OIDC_CLIENT_SECRET = "secret-1";
|
||||
process.env.OIDC_ONLY = "true";
|
||||
|
||||
const res = await postPathThrough({
|
||||
email: "new@example.com",
|
||||
username: "newuser",
|
||||
password: "a-very-strong-password-123",
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("still allows registration when OIDC_ONLY is set but the provider is half-configured", async () => {
|
||||
delete process.env.OIDC_ISSUER_URL;
|
||||
process.env.OIDC_CLIENT_ID = "client-1";
|
||||
process.env.OIDC_CLIENT_SECRET = "secret-1";
|
||||
process.env.OIDC_ONLY = "true";
|
||||
|
||||
const res = await postPathThrough({ email: "not-an-email", username: "x" });
|
||||
// Falls through to normal validation (422 here) rather than 403 — a
|
||||
// half-set OIDC group must never lock everyone out.
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
@@ -26,8 +26,18 @@ import {
|
||||
isValidTimezone,
|
||||
resolveServerDefaultTimezone,
|
||||
} from "@/lib/tz/resolver";
|
||||
import { isOidcOnly } from "@/lib/auth/oidc";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// OIDC_ONLY must block self-registration too — otherwise anyone can
|
||||
// self-provision a fresh password account and bypass the operator's
|
||||
// SSO-only policy outright, invite token or not.
|
||||
if (isOidcOnly()) {
|
||||
return apiError("Registration is disabled. Sign in with SSO.", 403, {
|
||||
errorCode: "oidc_only",
|
||||
});
|
||||
}
|
||||
|
||||
// v1.4.43 W13 M-4 — tighten to a global bucket when the trust chain
|
||||
// is misconfigured; otherwise byte-equivalent per-IP semantics.
|
||||
const rl = await checkAuthSurfaceRateLimit(
|
||||
|
||||
@@ -33,6 +33,8 @@ const mocks = vi.hoisted(() => ({
|
||||
importJobCreate: vi.fn(),
|
||||
importJobFindFirst: vi.fn().mockResolvedValue(null),
|
||||
importJobUpdate: vi.fn(),
|
||||
streamToDisk: vi.fn(),
|
||||
unlink: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
@@ -43,6 +45,15 @@ vi.mock("@/lib/jobs/boss-instance", () => ({
|
||||
getGlobalBoss: mocks.getGlobalBoss,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/multipart/stream-to-disk", () => ({
|
||||
streamMultipartToDisk: mocks.streamToDisk,
|
||||
}));
|
||||
|
||||
vi.mock("node:fs/promises", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:fs/promises")>();
|
||||
return { ...actual, unlink: mocks.unlink };
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
importJob: {
|
||||
@@ -61,6 +72,8 @@ const SESSION_OK = {
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
const STAGED_PATH = "/tmp/healthlog-apple-health-import-abc.bin";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mocks.checkRateLimit.mockResolvedValue({ allowed: true });
|
||||
@@ -69,8 +82,24 @@ beforeEach(() => {
|
||||
mocks.importJobCreate.mockResolvedValue({ id: "ij-1" });
|
||||
mocks.importJobFindFirst.mockResolvedValue(null);
|
||||
mocks.importJobUpdate.mockResolvedValue({ id: "ij-1" });
|
||||
mocks.streamToDisk.mockResolvedValue({
|
||||
filePath: STAGED_PATH,
|
||||
bytes: 100,
|
||||
sha256: "deadbeef",
|
||||
originalFilename: "export.zip",
|
||||
textFields: {},
|
||||
});
|
||||
mocks.unlink.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
function multipartReq(): NextRequest {
|
||||
return new NextRequest("http://localhost/api/import/apple-health-export", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "multipart/form-data; boundary=---x" },
|
||||
body: "stub",
|
||||
});
|
||||
}
|
||||
|
||||
describe("POST /api/import/apple-health-export", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
@@ -119,3 +148,53 @@ describe("POST /api/import/apple-health-export", () => {
|
||||
expect(res.status).toBe(429);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/import/apple-health-export — dedup by content hash (issue #486)", () => {
|
||||
it("re-stages and enqueues when the only prior job for the same bytes is failed", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
// The DB holds a FAILED prior job for this sha. The dedup query
|
||||
// excludes failed rows, so it returns null and the upload must fall
|
||||
// through to a fresh stage + boss.send instead of replaying the dead
|
||||
// job's id/failureReason forever.
|
||||
mocks.importJobFindFirst.mockImplementation(async (args: unknown) => {
|
||||
const where = (args as { where?: { status?: { not?: string } } }).where;
|
||||
if (where?.status?.not === "failed") return null;
|
||||
return { id: "old-failed-job", status: "failed" };
|
||||
});
|
||||
|
||||
const res = await POST(multipartReq());
|
||||
expect(res.status).toBe(202);
|
||||
const body = await res.json();
|
||||
expect(body.data.status).toBe("queued");
|
||||
expect(body.data.idempotent).toBeUndefined();
|
||||
expect(body.data.jobId).not.toBe("old-failed-job");
|
||||
expect(mocks.bossSend).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.importJobCreate).toHaveBeenCalledTimes(1);
|
||||
// The dedup lookup must exclude failed rows.
|
||||
expect(mocks.importJobFindFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({ status: { not: "failed" } }),
|
||||
}),
|
||||
);
|
||||
// A fresh job was staged — nothing to unlink on this path.
|
||||
expect(mocks.unlink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("short-circuits to a viable prior job and unlinks the redundant upload", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
mocks.importJobFindFirst.mockResolvedValue({
|
||||
id: "live-job",
|
||||
status: "parsing",
|
||||
});
|
||||
|
||||
const res = await POST(multipartReq());
|
||||
expect(res.status).toBe(202);
|
||||
const body = await res.json();
|
||||
expect(body.data.idempotent).toBe(true);
|
||||
expect(body.data.jobId).toBe("live-job");
|
||||
expect(mocks.bossSend).not.toHaveBeenCalled();
|
||||
expect(mocks.importJobCreate).not.toHaveBeenCalled();
|
||||
// The freshly staged upload is redundant and must be cleaned up.
|
||||
expect(mocks.unlink).toHaveBeenCalledWith(STAGED_PATH);
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,10 @@
|
||||
* the bytes) short-circuits to the previous `ImportJob` id without
|
||||
* re-queueing — the iOS client cannot reliably emit a stable
|
||||
* `Idempotency-Key` for a 1 GB file, so we hash the bytes and dedup
|
||||
* on content.
|
||||
* on content. The dedup only covers still-viable jobs (queued,
|
||||
* in-flight, or already `done`); a prior `failed` job is NOT matched,
|
||||
* so the same export can always be retried with the same bytes
|
||||
* (issue #486 — a failed row must never tombstone its own hash).
|
||||
*
|
||||
* Locks per `.planning/research/v1434-r-1-xml-import.md` §5.3.
|
||||
*/
|
||||
@@ -27,9 +30,11 @@ import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import {
|
||||
APPLE_HEALTH_IMPORT_QUEUE,
|
||||
APPLE_HEALTH_IMPORT_SEND_OPTIONS,
|
||||
type AppleHealthImportPayload,
|
||||
} from "@/lib/jobs/apple-health-import-worker";
|
||||
import { streamMultipartToDisk } from "@/lib/multipart/stream-to-disk";
|
||||
import { unlink } from "node:fs/promises";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -103,15 +108,25 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
|
||||
// Content-hash idempotency: a re-upload of the same bytes resolves
|
||||
// to the previous ImportJob row instead of re-queueing.
|
||||
// to the previous ImportJob row instead of re-queueing — but ONLY
|
||||
// when that prior job is still viable (queued / in-flight / done). A
|
||||
// `failed` prior job must NOT short-circuit (issue #486): matching it
|
||||
// returned its stale failureReason forever and made retrying the same
|
||||
// export impossible. Excluding `failed` lets the same bytes fall
|
||||
// through to a fresh stage + enqueue.
|
||||
const existing = await prisma.importJob.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
uploadSha256: uploaded.sha256,
|
||||
status: { not: "failed" },
|
||||
},
|
||||
orderBy: { startedAt: "desc" },
|
||||
});
|
||||
if (existing) {
|
||||
// The existing viable job owns the canonical bytes; the upload we
|
||||
// just streamed to `/tmp` is redundant. Unlink it so a deduped
|
||||
// re-upload does not leak a gigabyte-scale staging file.
|
||||
await unlink(uploaded.filePath).catch(() => {});
|
||||
annotate({ meta: { idempotent_hit: true, job_id: existing.id } });
|
||||
return apiSuccess(
|
||||
{
|
||||
@@ -148,7 +163,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
uploadBytes: uploaded.bytes,
|
||||
enqueuedAt: new Date().toISOString(),
|
||||
};
|
||||
const bossJobId = await boss.send(APPLE_HEALTH_IMPORT_QUEUE, payload);
|
||||
// No retries + wide expiration: the staged upload is consumed by the
|
||||
// first run, so a redelivery could only fail on the deleted `/tmp`
|
||||
// file and mask the real outcome (see the worker's send-options doc).
|
||||
const bossJobId = await boss.send(
|
||||
APPLE_HEALTH_IMPORT_QUEUE,
|
||||
payload,
|
||||
APPLE_HEALTH_IMPORT_SEND_OPTIONS,
|
||||
);
|
||||
|
||||
await prisma.importJob.update({
|
||||
where: { id: importJob.id },
|
||||
|
||||
@@ -405,11 +405,16 @@ describe("POST /api/insights/generate — cache write (v1.16.8)", () => {
|
||||
|
||||
it("does NOT touch the cache when serving from the 24h DB cache", async () => {
|
||||
// Cached path: route returns early without touching the LLM or the
|
||||
// cache write.
|
||||
// cache write. v1.28.30 — the short-circuit requires the cached
|
||||
// payload to CARRY a briefing (a briefingless cache is stale for a
|
||||
// briefing-expecting caller), so the fixture carries one.
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
insightsPrivacyMode: "aggregated",
|
||||
insightsCachedAt: new Date(),
|
||||
insightsCachedText: JSON.stringify({ changed: "still fresh" }),
|
||||
insightsCachedText: JSON.stringify({
|
||||
changed: "still fresh",
|
||||
dailyBriefing: { paragraph: "ok", keyFindings: [] },
|
||||
}),
|
||||
locale: "en",
|
||||
} as never);
|
||||
|
||||
@@ -445,7 +450,10 @@ describe("POST /api/insights/generate — cache write (v1.16.8)", () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
insightsPrivacyMode: "aggregated",
|
||||
insightsCachedAt: new Date(),
|
||||
insightsCachedText: JSON.stringify({ changed: "still fresh" }),
|
||||
insightsCachedText: JSON.stringify({
|
||||
changed: "still fresh",
|
||||
dailyBriefing: { paragraph: "ok", keyFindings: [] },
|
||||
}),
|
||||
locale: "en",
|
||||
} as never);
|
||||
|
||||
@@ -467,6 +475,99 @@ describe("POST /api/insights/generate — cache write (v1.16.8)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// v1.28.30 — the "no briefing today" chain: a failed nightly warm left a
|
||||
// fresh-but-briefingless cache inside the 24 h window, and the POST's
|
||||
// unconditional short-circuit served it to every regenerate attempt
|
||||
// (`cached: true` three times in prod, zero generations, a full day
|
||||
// without a briefing). The short-circuit now only holds when the cached
|
||||
// payload carries a briefing OR the account has no usable provider.
|
||||
describe("POST /api/insights/generate — briefingless fresh cache (v1.28.30)", () => {
|
||||
function mockBriefinglessFreshCache() {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
insightsPrivacyMode: "aggregated",
|
||||
insightsCachedAt: new Date(Date.now() - 6 * 60 * 60 * 1000),
|
||||
insightsCachedText: JSON.stringify({
|
||||
changed: "fresh but no briefing",
|
||||
dailyBriefing: null,
|
||||
}),
|
||||
insightsExcludeMetrics: [],
|
||||
locale: "en",
|
||||
} as never);
|
||||
}
|
||||
|
||||
it("POST without force + fresh cache WITH briefing → served cached", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
insightsPrivacyMode: "aggregated",
|
||||
insightsCachedAt: new Date(Date.now() - 6 * 60 * 60 * 1000),
|
||||
insightsCachedText: JSON.stringify({
|
||||
dailyBriefing: { paragraph: "today", keyFindings: [] },
|
||||
}),
|
||||
locale: "en",
|
||||
} as never);
|
||||
|
||||
const res = await POST(jsonRequest({}) as never);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { cached: boolean } };
|
||||
expect(body.data.cached).toBe(true);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("POST without force + fresh cache WITHOUT briefing + provider → regenerates", async () => {
|
||||
makeWorkingProvider();
|
||||
mockBriefinglessFreshCache();
|
||||
|
||||
const res = await POST(jsonRequest({}) as never);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { cached: boolean } };
|
||||
// The stale-for-briefing cache was bypassed: a real generation ran and
|
||||
// wrote a fresh cache row.
|
||||
expect(body.data.cached).toBe(false);
|
||||
expect(prisma.user.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("POST without force + fresh cache WITHOUT briefing + NO provider → serves cached (regeneration is futile)", async () => {
|
||||
vi.mocked(hasUsableStatusProvider).mockResolvedValueOnce(false);
|
||||
mockBriefinglessFreshCache();
|
||||
|
||||
const res = await POST(jsonRequest({}) as never);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { cached: boolean } };
|
||||
expect(body.data.cached).toBe(true);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("degrades to the cached payload instead of 429 when the briefingless fall-through is rate-limited", async () => {
|
||||
makeWorkingProvider();
|
||||
mockBriefinglessFreshCache();
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 3600_000,
|
||||
});
|
||||
|
||||
const res = await POST(jsonRequest({}) as never);
|
||||
// A POST-as-read caller never used to see a 429 on a fresh cache;
|
||||
// exhausting the quota falls back to the old cached-serve behaviour.
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { cached: boolean } };
|
||||
expect(body.data.cached).toBe(true);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("explicit force keeps the honest 429 when rate-limited", async () => {
|
||||
makeWorkingProvider();
|
||||
mockBriefinglessFreshCache();
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 3600_000,
|
||||
});
|
||||
|
||||
const res = await POST(jsonRequest({ force: true }) as never);
|
||||
expect(res.status).toBe(429);
|
||||
});
|
||||
});
|
||||
|
||||
// v1.4.36 QA H1 — when both the raw-mode extraction AND the aggregated
|
||||
// retry cross the 5 MB ceiling, the route used to throw uncaught → 500.
|
||||
// It now wraps the retry, downgrades further via the exclude filter,
|
||||
@@ -619,6 +720,66 @@ describe("GET /api/insights/generate — read-only advisor read", () => {
|
||||
expect(body.data.revalidating).toBe(false);
|
||||
});
|
||||
|
||||
// v1.28.30 — a briefing the grounding gate withheld used to be visible
|
||||
// only on the transient POST response; every later read showed a generic
|
||||
// "no briefing yet". The marker written after the cache write now
|
||||
// surfaces the omission on the read path, WITHOUT reading as a failure.
|
||||
it("surfaces briefingOmittedReason from a briefing-ungrounded marker (not as a failure)", async () => {
|
||||
const cachedAt = new Date(Date.now() - 60 * 60 * 1000);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
insightsCachedAt: cachedAt,
|
||||
insightsCachedText: JSON.stringify({ dailyBriefing: null }),
|
||||
locale: "en",
|
||||
} as never);
|
||||
// Marker newer than the last successful generation (written right
|
||||
// after the cache write on the strip path).
|
||||
vi.mocked(prisma.auditLog.findFirst).mockResolvedValueOnce({
|
||||
createdAt: new Date(cachedAt.getTime() + 1000),
|
||||
details: JSON.stringify({ reason: "briefing-ungrounded" }),
|
||||
} as never);
|
||||
|
||||
const res = await (GET as unknown as (req: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/insights/generate"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
briefingOmittedReason: string | null;
|
||||
generationFailed: boolean;
|
||||
generationFailureClass: string | null;
|
||||
};
|
||||
};
|
||||
expect(body.data.briefingOmittedReason).toBe("ungrounded");
|
||||
// The generation SUCCEEDED (the briefing was withheld) — the card must
|
||||
// render "withheld", not "couldn't generate".
|
||||
expect(body.data.generationFailed).toBe(false);
|
||||
expect(body.data.generationFailureClass).toBeNull();
|
||||
});
|
||||
|
||||
it("still reports generationFailed for a genuine failure marker", async () => {
|
||||
const cachedAt = new Date(Date.now() - 60 * 60 * 1000);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
insightsCachedAt: cachedAt,
|
||||
insightsCachedText: JSON.stringify({
|
||||
dailyBriefing: { paragraph: "held", keyFindings: [] },
|
||||
}),
|
||||
locale: "en",
|
||||
} as never);
|
||||
vi.mocked(prisma.auditLog.findFirst).mockResolvedValueOnce({
|
||||
createdAt: new Date(cachedAt.getTime() + 1000),
|
||||
details: JSON.stringify({ reason: "provider-error", httpStatus: 503 }),
|
||||
} as never);
|
||||
|
||||
const res = await (GET as unknown as (req: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/insights/generate"),
|
||||
);
|
||||
const body = (await res.json()) as {
|
||||
data: { briefingOmittedReason: string | null; generationFailed: boolean };
|
||||
};
|
||||
expect(body.data.generationFailed).toBe(true);
|
||||
expect(body.data.briefingOmittedReason).toBeNull();
|
||||
});
|
||||
|
||||
it("reports hasProvider: true on a normal cached read", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
insightsCachedAt: new Date(),
|
||||
|
||||
@@ -81,6 +81,7 @@ import {
|
||||
buildComparisonSnapshotForUser,
|
||||
enqueueStatusRefillForUser,
|
||||
} from "@/lib/insights/comprehensive-generate";
|
||||
import { cachedPayloadCarriesBriefing } from "@/lib/insights/briefing-payload";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -168,10 +169,21 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
userId,
|
||||
since: cachedAt,
|
||||
});
|
||||
const generationFailed = briefingFailure !== null;
|
||||
// v1.28.30 — a `briefing-ungrounded` marker is NOT a failure: the
|
||||
// generation succeeded but the grounding gate withheld the briefing
|
||||
// (marker written after the cache write, so it survives the `since`
|
||||
// filter). Surface it as the persistent omission signal — the card's
|
||||
// "briefing withheld" state used to exist only on the transient POST
|
||||
// response, so a nightly strip read as a silent generic empty card.
|
||||
const briefingOmittedReason: "ungrounded" | null =
|
||||
briefingFailure?.reason === "briefing-ungrounded" ? "ungrounded" : null;
|
||||
const generationFailed =
|
||||
briefingFailure !== null && briefingOmittedReason === null;
|
||||
// v1.25.3 — the failure class lets the empty state point its hint at the
|
||||
// right lever (raise the response timeout vs re-check the provider).
|
||||
const generationFailureClass = briefingFailure?.failureClass ?? null;
|
||||
const generationFailureClass = generationFailed
|
||||
? (briefingFailure?.failureClass ?? null)
|
||||
: null;
|
||||
|
||||
// Read-only: never block on the provider. Warm out of band only when the
|
||||
// cached briefing is stale / missing AND a provider is configured (a
|
||||
@@ -222,6 +234,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// v1.25.3 — coarse failure class for the empty-state hint (null when
|
||||
// the last attempt succeeded).
|
||||
generationFailureClass,
|
||||
// v1.28.30 — persistent grounding-omission signal (see above), so a
|
||||
// nightly-stripped briefing renders "withheld", not "no briefing yet".
|
||||
briefingOmittedReason,
|
||||
});
|
||||
} catch {
|
||||
// Invalid cache row — fall through to the empty payload below. The
|
||||
@@ -246,6 +261,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
generationFailed,
|
||||
// v1.25.3 — coarse failure class for the empty-state hint.
|
||||
generationFailureClass,
|
||||
// v1.28.30 — see the cached branch above.
|
||||
briefingOmittedReason,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -301,6 +318,24 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
if (jsonError) return jsonError;
|
||||
const forceRefresh = body.force === true;
|
||||
|
||||
// v1.28.30 — a fresh cache WITHOUT a briefing must not satisfy a
|
||||
// briefing-expecting caller: after a failed nightly warm the cached
|
||||
// payload can sit inside the 24 h window all day, and the old
|
||||
// unconditional short-circuit served it to every POST (three
|
||||
// regenerate attempts, three `cached: true` responses, zero
|
||||
// generations). The short-circuit now holds only when the cached
|
||||
// payload actually carries a briefing OR the account has no usable
|
||||
// provider (regenerating would be futile — serve what exists). The
|
||||
// read-only GET is untouched: it never generates and its out-of-band
|
||||
// revalidation behaviour stays as-is. When the briefingless fall-
|
||||
// through is rate-limited below, the cached payload is served after
|
||||
// all so a POST-as-read client degrades to the old behaviour instead
|
||||
// of a 429.
|
||||
let briefinglessCacheFallback: {
|
||||
cached: unknown;
|
||||
legacyPayload: boolean;
|
||||
cachedAt: Date;
|
||||
} | null = null;
|
||||
if (
|
||||
!forceRefresh &&
|
||||
dbUser?.insightsCachedAt &&
|
||||
@@ -314,15 +349,33 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
// We don't auto-regenerate — that would burn rate-limit tokens
|
||||
// on a cache-hit silently. User-initiated only.
|
||||
const legacyPayload = isLegacyInsightPayload(cached);
|
||||
annotate({
|
||||
action: { name: "insights.generate" },
|
||||
meta: { cached: true, legacyPayload },
|
||||
});
|
||||
return apiSuccess({
|
||||
insights: cached,
|
||||
cached: true,
|
||||
cachedAt: dbUser.insightsCachedAt,
|
||||
const carriesBriefing = cachedPayloadCarriesBriefing(
|
||||
dbUser.insightsCachedText,
|
||||
);
|
||||
if (carriesBriefing || !(await hasUsableStatusProvider(userId))) {
|
||||
annotate({
|
||||
action: { name: "insights.generate" },
|
||||
meta: {
|
||||
cached: true,
|
||||
legacyPayload,
|
||||
briefingless: !carriesBriefing,
|
||||
},
|
||||
});
|
||||
return apiSuccess({
|
||||
insights: cached,
|
||||
cached: true,
|
||||
cachedAt: dbUser.insightsCachedAt,
|
||||
legacyPayload,
|
||||
});
|
||||
}
|
||||
briefinglessCacheFallback = {
|
||||
cached,
|
||||
legacyPayload,
|
||||
cachedAt: dbUser.insightsCachedAt,
|
||||
};
|
||||
annotate({
|
||||
action: { name: "insights.generate.briefingless_cache_bypassed" },
|
||||
meta: { locale },
|
||||
});
|
||||
} catch {
|
||||
// Invalid cache, regenerate
|
||||
@@ -346,6 +399,22 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
60 * 60 * 1000,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
// v1.28.30 — a POST-as-read caller whose fresh-but-briefingless cache
|
||||
// fell through to regeneration degrades to the cached payload when
|
||||
// the hourly quota is exhausted, instead of surfacing a 429 it never
|
||||
// used to see. Explicit `force` keeps the honest 429.
|
||||
if (briefinglessCacheFallback !== null) {
|
||||
annotate({
|
||||
action: { name: "insights.generate" },
|
||||
meta: { cached: true, rate_limited_fallback: true },
|
||||
});
|
||||
return apiSuccess({
|
||||
insights: briefinglessCacheFallback.cached,
|
||||
cached: true,
|
||||
cachedAt: briefinglessCacheFallback.cachedAt,
|
||||
legacyPayload: briefinglessCacheFallback.legacyPayload,
|
||||
});
|
||||
}
|
||||
return apiError(`Maximum ${limit} insight generations per hour.`, 429);
|
||||
}
|
||||
|
||||
@@ -855,6 +924,20 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
},
|
||||
});
|
||||
|
||||
// v1.28.30 — persist the grounding omission (marker AFTER the cache
|
||||
// write so it is newer than `insightsCachedAt`). The transient
|
||||
// `briefingOmittedReason` on this response only reached the caller that
|
||||
// forced the regenerate; every later read showed a generic "no briefing
|
||||
// yet". The dated marker lets the GET surface the same honest
|
||||
// "briefing withheld" state until the next successful generation.
|
||||
if (briefingOmittedReason === "ungrounded") {
|
||||
void recordBriefingFailure({
|
||||
userId,
|
||||
reason: "briefing-ungrounded",
|
||||
locale,
|
||||
});
|
||||
}
|
||||
|
||||
// v1.16.8 — no blanket per-status eviction here any more (nuking ~45
|
||||
// cache rows per manual regenerate deleted every hash baseline and was
|
||||
// the source of the post-regenerate card-regeneration storm). Instead,
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* v1.28 — `GET /api/nutrients` window-summary contract tests.
|
||||
*
|
||||
* Pins the read shape the settings card consumes: catalog-ordered
|
||||
* per-nutrient summaries (latest day + total, days-with-data), the
|
||||
* module gate, and the `days` query validation.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
nutrientIntakeDay: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
requireModuleEnabled: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiError } from "@/lib/api-response";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
|
||||
function getReq(query = ""): NextRequest {
|
||||
return new NextRequest(`http://localhost/api/nutrients${query}`);
|
||||
}
|
||||
|
||||
interface OverviewResponse {
|
||||
data: {
|
||||
windowDays: number;
|
||||
nutrients: Array<{
|
||||
nutrient: string;
|
||||
unit: string;
|
||||
latestDay: string;
|
||||
latestAmount: number;
|
||||
daysWithData: number;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
|
||||
vi.mocked(prisma.nutrientIntakeDay.findMany).mockResolvedValue([] as never);
|
||||
});
|
||||
|
||||
describe("GET /api/nutrients", () => {
|
||||
it("refuses with the 403 module.disabled envelope when the module is off", async () => {
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({
|
||||
enabled: false,
|
||||
response: apiError('Module "nutrients" is not enabled', 403, {
|
||||
errorCode: "module.disabled",
|
||||
module: "nutrients",
|
||||
}),
|
||||
});
|
||||
const res = await GET(getReq());
|
||||
expect(res.status).toBe(403);
|
||||
const body = (await res.json()) as { meta?: { errorCode?: string } };
|
||||
expect(body.meta?.errorCode).toBe("module.disabled");
|
||||
expect(prisma.nutrientIntakeDay.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an out-of-range days value with 422", async () => {
|
||||
for (const bad of ["0", "366", "abc"]) {
|
||||
const res = await GET(getReq(`?days=${bad}`));
|
||||
expect(res.status, `days=${bad}`).toBe(422);
|
||||
}
|
||||
});
|
||||
|
||||
it("defaults to a 14-day window and returns an empty list for a fresh account", async () => {
|
||||
const res = await GET(getReq());
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as OverviewResponse;
|
||||
expect(body.data).toEqual({ windowDays: 14, nutrients: [] });
|
||||
});
|
||||
|
||||
it("folds rows into catalog-ordered summaries with latest day/amount and day counts", async () => {
|
||||
// Rows arrive nutrient ASC, day DESC (the route's orderBy) — the
|
||||
// fold takes the first row per nutrient as the latest.
|
||||
vi.mocked(prisma.nutrientIntakeDay.findMany).mockResolvedValue([
|
||||
{ nutrient: "caffeine", unit: "mg", day: "2026-07-13", amount: 310 },
|
||||
{ nutrient: "caffeine", unit: "mg", day: "2026-07-12", amount: 250 },
|
||||
{ nutrient: "caffeine", unit: "mg", day: "2026-07-11", amount: 400 },
|
||||
{ nutrient: "vitamin_d", unit: "ug", day: "2026-07-13", amount: 22.5 },
|
||||
{ nutrient: "vitamin_d", unit: "ug", day: "2026-07-10", amount: 20 },
|
||||
] as never);
|
||||
|
||||
const res = await GET(getReq("?days=7"));
|
||||
const body = (await res.json()) as OverviewResponse;
|
||||
expect(body.data.windowDays).toBe(7);
|
||||
// Catalog order: vitamin_d (vitamins block) before caffeine (tail).
|
||||
expect(body.data.nutrients).toEqual([
|
||||
{
|
||||
nutrient: "vitamin_d",
|
||||
unit: "ug",
|
||||
latestDay: "2026-07-13",
|
||||
latestAmount: 22.5,
|
||||
daysWithData: 2,
|
||||
},
|
||||
{
|
||||
nutrient: "caffeine",
|
||||
unit: "mg",
|
||||
latestDay: "2026-07-13",
|
||||
latestAmount: 310,
|
||||
daysWithData: 3,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("scopes the query to the authenticated user and the window floor", async () => {
|
||||
await GET(getReq("?days=14"));
|
||||
expect(prisma.nutrientIntakeDay.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
userId: "user-1",
|
||||
day: { gte: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/) },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,354 @@
|
||||
/**
|
||||
* v1.28 — `POST /api/nutrients/batch` contract tests.
|
||||
*
|
||||
* Pins the ingest posture: module gate first (403 `module.disabled`),
|
||||
* per-entry skip guards (unit mismatch / plausibility / day key), the
|
||||
* insert-vs-update statuses off the composite-PK probe, and the batch
|
||||
* envelope errors (`too_large`, multi-issue `invalid`).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
nutrientIntakeDay: {
|
||||
findUnique: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
requireModuleEnabled: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
vi.mock("@/lib/idempotency", () => ({
|
||||
withIdempotency:
|
||||
<Args extends unknown[]>(fn: (...args: Args) => Promise<Response>) =>
|
||||
(...args: Args) =>
|
||||
fn(...args),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiError } from "@/lib/api-response";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
|
||||
function postReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/nutrients/batch", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
/** Yesterday's UTC day key — always a valid, in-window day. */
|
||||
function recentDay(offsetDays = 1): string {
|
||||
return new Date(Date.now() - offsetDays * 24 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
interface BatchResponse {
|
||||
data: {
|
||||
processed: number;
|
||||
inserted: number;
|
||||
updated: number;
|
||||
skipped: Array<{ index: number; reason: string }>;
|
||||
entries: Array<{ index: number; status: string; reason?: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined as never);
|
||||
vi.mocked(prisma.nutrientIntakeDay.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(prisma.nutrientIntakeDay.upsert).mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
describe("POST /api/nutrients/batch — module gate", () => {
|
||||
it("refuses ingest with the 403 module.disabled envelope when the module is off", async () => {
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({
|
||||
enabled: false,
|
||||
response: apiError('Module "nutrients" is not enabled', 403, {
|
||||
errorCode: "module.disabled",
|
||||
module: "nutrients",
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{ day: recentDay(), nutrient: "vitamin_d", unit: "ug", amount: 20 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
meta?: { errorCode?: string; module?: string };
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.meta?.errorCode).toBe("module.disabled");
|
||||
expect(body.meta?.module).toBe("nutrients");
|
||||
expect(prisma.nutrientIntakeDay.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/nutrients/batch — envelope errors", () => {
|
||||
it("rejects a 501-entry batch with 422 nutrient.batch.too_large before Zod", async () => {
|
||||
const entries = Array.from({ length: 501 }, () => ({
|
||||
day: recentDay(),
|
||||
nutrient: "vitamin_c",
|
||||
unit: "mg",
|
||||
amount: 100,
|
||||
}));
|
||||
const res = await POST(postReq({ entries }));
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as { meta?: { errorCode?: string } };
|
||||
expect(body.meta?.errorCode).toBe("nutrient.batch.too_large");
|
||||
});
|
||||
|
||||
it("surfaces multiple simultaneous Zod issues as one 422 nutrient.batch.invalid", async () => {
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{ day: "not-a-day", nutrient: "vitamin_d", unit: "ug", amount: 20 },
|
||||
{ day: recentDay(), nutrient: "kryptonite", unit: "mg", amount: 1 },
|
||||
{ day: recentDay(), nutrient: "iron", unit: "mg", amount: -5 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: unknown[] };
|
||||
meta?: { errorCode?: string };
|
||||
};
|
||||
expect(body.meta?.errorCode).toBe("nutrient.batch.invalid");
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("returns 429 when the per-user rate bucket is exhausted", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: false } as never);
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{ day: recentDay(), nutrient: "water", unit: "ml", amount: 1500 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/nutrients/batch — per-entry skip guards", () => {
|
||||
it("skips a unit mismatch (µg posted as mg) without touching the store", async () => {
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
// vitamin_d is canonically µg; posting mg is the 1000× hazard.
|
||||
{ day: recentDay(), nutrient: "vitamin_d", unit: "mg", amount: 20 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as BatchResponse;
|
||||
expect(body.data.entries[0]).toEqual({
|
||||
index: 0,
|
||||
status: "skipped",
|
||||
reason: "unit_mismatch",
|
||||
});
|
||||
expect(body.data.skipped).toEqual([{ index: 0, reason: "unit_mismatch" }]);
|
||||
expect(prisma.nutrientIntakeDay.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips a value above the plausibility cap", async () => {
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
// caffeine cap is 2000 mg.
|
||||
{ day: recentDay(), nutrient: "caffeine", unit: "mg", amount: 2001 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const body = (await res.json()) as BatchResponse;
|
||||
expect(body.data.entries[0].status).toBe("skipped");
|
||||
expect(body.data.entries[0].reason).toBe("value_out_of_range");
|
||||
expect(prisma.nutrientIntakeDay.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips an impossible calendar day and a far-future day as day_invalid", async () => {
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
// Regex-shaped but not a real date.
|
||||
{ day: "2026-02-31", nutrient: "iron", unit: "mg", amount: 10 },
|
||||
// Beyond tomorrow in every IANA timezone.
|
||||
{ day: "2999-01-01", nutrient: "iron", unit: "mg", amount: 10 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const body = (await res.json()) as BatchResponse;
|
||||
expect(body.data.entries.map((e) => e.reason)).toEqual([
|
||||
"day_invalid",
|
||||
"day_invalid",
|
||||
]);
|
||||
expect(prisma.nutrientIntakeDay.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a skipped entry never fails the batch — siblings still land", async () => {
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{ day: recentDay(), nutrient: "vitamin_d", unit: "mg", amount: 20 },
|
||||
{ day: recentDay(), nutrient: "magnesium", unit: "mg", amount: 300 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const body = (await res.json()) as BatchResponse;
|
||||
expect(body.data.processed).toBe(2);
|
||||
expect(body.data.inserted).toBe(1);
|
||||
expect(body.data.skipped).toHaveLength(1);
|
||||
expect(prisma.nutrientIntakeDay.upsert).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/nutrients/batch — upsert semantics", () => {
|
||||
it("reports inserted for a fresh (day, nutrient) and writes the catalog's canonical unit", async () => {
|
||||
const day = recentDay();
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{
|
||||
day,
|
||||
nutrient: "vitamin_d",
|
||||
unit: "ug",
|
||||
amount: 22.5,
|
||||
externalSourceVersion: "yazio-9.9",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const body = (await res.json()) as BatchResponse;
|
||||
expect(body.data.inserted).toBe(1);
|
||||
expect(body.data.updated).toBe(0);
|
||||
expect(body.data.entries[0].status).toBe("inserted");
|
||||
|
||||
expect(prisma.nutrientIntakeDay.upsert).toHaveBeenCalledWith({
|
||||
where: {
|
||||
userId_day_nutrient: {
|
||||
userId: "user-1",
|
||||
day,
|
||||
nutrient: "vitamin_d",
|
||||
},
|
||||
},
|
||||
create: {
|
||||
userId: "user-1",
|
||||
day,
|
||||
nutrient: "vitamin_d",
|
||||
amount: 22.5,
|
||||
unit: "ug",
|
||||
externalSourceVersion: "yazio-9.9",
|
||||
},
|
||||
update: {
|
||||
amount: 22.5,
|
||||
unit: "ug",
|
||||
externalSourceVersion: "yazio-9.9",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("reports updated (never duplicate) when the composite key already exists", async () => {
|
||||
vi.mocked(prisma.nutrientIntakeDay.findUnique).mockResolvedValue({
|
||||
userId: "user-1",
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{ day: recentDay(), nutrient: "caffeine", unit: "mg", amount: 310 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const body = (await res.json()) as BatchResponse;
|
||||
expect(body.data.inserted).toBe(0);
|
||||
expect(body.data.updated).toBe(1);
|
||||
expect(body.data.entries[0].status).toBe("updated");
|
||||
expect(
|
||||
body.data.entries.some((e) => e.status === ("duplicate" as string)),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("writes the audit-ledger breadcrumb only when at least one row landed", async () => {
|
||||
await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{ day: recentDay(), nutrient: "vitamin_d", unit: "mg", amount: 20 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(auditLog).not.toHaveBeenCalled();
|
||||
|
||||
await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{ day: recentDay(), nutrient: "water", unit: "ml", amount: 1800 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"nutrient.batch.ingest",
|
||||
expect.objectContaining({ userId: "user-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("a thrown upsert marks only that entry skipped upsert_failed", async () => {
|
||||
vi.mocked(prisma.nutrientIntakeDay.upsert)
|
||||
.mockRejectedValueOnce(new Error("boom"))
|
||||
.mockResolvedValueOnce({} as never);
|
||||
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{ day: recentDay(), nutrient: "zinc", unit: "mg", amount: 10 },
|
||||
{ day: recentDay(), nutrient: "iron", unit: "mg", amount: 12 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const body = (await res.json()) as BatchResponse;
|
||||
expect(body.data.entries[0].status).toBe("skipped");
|
||||
expect(body.data.entries[1].status).toBe("inserted");
|
||||
expect(body.data.inserted).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* `POST /api/nutrients/batch` — micronutrient day-total ingest (v1.28).
|
||||
*
|
||||
* iOS posts daily totals for the closed 26-code catalog (vitamins,
|
||||
* minerals, water, caffeine) computed via `HKStatisticsCollectionQuery`
|
||||
* day-anchored cumulative sums; `day` arrives as YYYY-MM-DD in the
|
||||
* user's IANA timezone (the `stats:` day-key contract — the server
|
||||
* trusts the string after regex + calendar sanity, no re-derivation).
|
||||
*
|
||||
* Upsert key = the composite PK (userId, day, nutrient). Re-post
|
||||
* replaces amount / unit / externalSourceVersion — last-writer-wins,
|
||||
* the day-total contract; per-entry status is `updated` then, never
|
||||
* `duplicate`. Per-entry failures come back `skipped` with a reason
|
||||
* (`unit_mismatch` | `value_out_of_range` | `day_invalid` |
|
||||
* `upsert_failed`), never a batch failure — the measurements-batch
|
||||
* posture.
|
||||
*
|
||||
* Module gate FIRST: the opt-in `nutrients` module (default off)
|
||||
* refuses ingest with the 403 `module.disabled` envelope, so a phone
|
||||
* whose user never opted in cannot land rows server-side
|
||||
* (refuse-ingest posture, mirroring mental health — not surface-only).
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { NUTRIENT_CATALOG } from "@/lib/nutrients/catalog";
|
||||
import {
|
||||
MAX_NUTRIENT_ENTRIES_PER_BATCH,
|
||||
nutrientBatchSchema,
|
||||
} from "@/lib/validations/nutrients";
|
||||
|
||||
const BATCH_RATE_LIMIT_MAX = 60;
|
||||
const BATCH_RATE_LIMIT_WINDOW_MS = 60 * 1000;
|
||||
|
||||
/**
|
||||
* Calendar sanity for an already regex-shaped YYYY-MM-DD key: reject
|
||||
* impossible dates (2026-02-31) via a UTC round-trip.
|
||||
*/
|
||||
function isRealCalendarDay(day: string): boolean {
|
||||
const [y, m, d] = day.split("-").map(Number);
|
||||
const dt = new Date(Date.UTC(y, m - 1, d));
|
||||
return (
|
||||
dt.getUTCFullYear() === y &&
|
||||
dt.getUTCMonth() === m - 1 &&
|
||||
dt.getUTCDate() === d
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upper day bound with timezone slack: "tomorrow" in the most-ahead
|
||||
* IANA zone (UTC+14) is at most the UTC calendar date + 2 days, so a
|
||||
* key beyond that cannot be a legitimate local day and is corruption.
|
||||
*/
|
||||
function maxAcceptableDay(now: Date): string {
|
||||
const limit = new Date(now.getTime() + 2 * 24 * 60 * 60 * 1000);
|
||||
return limit.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
type EntryStatus = "inserted" | "updated" | "skipped";
|
||||
interface EntryResult {
|
||||
index: number;
|
||||
status: EntryStatus;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBatch));
|
||||
|
||||
async function postBatch(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// Module gate first: a disabled module must refuse before any body
|
||||
// work, so iOS can branch on the 403 `module.disabled` errorCode and
|
||||
// stop syncing rather than retry.
|
||||
const gate = await requireModuleEnabled(user.id, "nutrients");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`nutrients:batch:${user.id}`,
|
||||
BATCH_RATE_LIMIT_MAX,
|
||||
BATCH_RATE_LIMIT_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many batch submissions, try again later", 429);
|
||||
}
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 1024 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
if (
|
||||
typeof rawBody === "object" &&
|
||||
rawBody !== null &&
|
||||
"entries" in rawBody &&
|
||||
Array.isArray((rawBody as { entries: unknown }).entries) &&
|
||||
(rawBody as { entries: unknown[] }).entries.length >
|
||||
MAX_NUTRIENT_ENTRIES_PER_BATCH
|
||||
) {
|
||||
return apiError(
|
||||
`Batch exceeds the ${MAX_NUTRIENT_ENTRIES_PER_BATCH}-entry limit`,
|
||||
422,
|
||||
{ errorCode: "nutrient.batch.too_large" },
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = nutrientBatchSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "nutrient.batch.validation-failed" },
|
||||
meta: { issue_count: parsed.error.issues.length },
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "nutrient.batch.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const { entries } = parsed.data;
|
||||
const dayCeiling = maxAcceptableDay(new Date());
|
||||
|
||||
const results: EntryResult[] = [];
|
||||
const skipped: Array<{ index: number; reason: string }> = [];
|
||||
let inserted = 0;
|
||||
let updated = 0;
|
||||
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const entry = entries[i];
|
||||
const definition = NUTRIENT_CATALOG[entry.nutrient];
|
||||
|
||||
// The wire carries the unit deliberately: a µg/mg confusion is a
|
||||
// silent 1000× corruption; anything but the catalog's canonical
|
||||
// unit skips the entry rather than converting.
|
||||
if (entry.unit !== definition.unit) {
|
||||
skipped.push({ index: i, reason: "unit_mismatch" });
|
||||
results.push({ index: i, status: "skipped", reason: "unit_mismatch" });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.amount > definition.plausibleDailyMax) {
|
||||
skipped.push({ index: i, reason: "value_out_of_range" });
|
||||
results.push({
|
||||
index: i,
|
||||
status: "skipped",
|
||||
reason: "value_out_of_range",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isRealCalendarDay(entry.day) || entry.day > dayCeiling) {
|
||||
skipped.push({ index: i, reason: "day_invalid" });
|
||||
results.push({ index: i, status: "skipped", reason: "day_invalid" });
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const key = {
|
||||
userId_day_nutrient: {
|
||||
userId: user.id,
|
||||
day: entry.day,
|
||||
nutrient: entry.nutrient,
|
||||
},
|
||||
};
|
||||
|
||||
// Probe-then-upsert so the response reliably distinguishes
|
||||
// `inserted` from `updated` (the mood-bulk pattern; two round
|
||||
// trips per entry is fine under the 500-entry cap).
|
||||
const existing = await prisma.nutrientIntakeDay.findUnique({
|
||||
where: key,
|
||||
select: { userId: true },
|
||||
});
|
||||
|
||||
await prisma.nutrientIntakeDay.upsert({
|
||||
where: key,
|
||||
create: {
|
||||
userId: user.id,
|
||||
day: entry.day,
|
||||
nutrient: entry.nutrient,
|
||||
amount: entry.amount,
|
||||
unit: definition.unit,
|
||||
externalSourceVersion: entry.externalSourceVersion ?? null,
|
||||
},
|
||||
update: {
|
||||
// Last-writer-wins on the day total: iOS re-posts the current
|
||||
// and previous local day on every sync as the totals grow.
|
||||
amount: entry.amount,
|
||||
unit: definition.unit,
|
||||
externalSourceVersion: entry.externalSourceVersion ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
updated += 1;
|
||||
results.push({ index: i, status: "updated" });
|
||||
} else {
|
||||
inserted += 1;
|
||||
results.push({ index: i, status: "inserted" });
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const reason =
|
||||
err instanceof Error ? err.message.slice(0, 120) : "upsert_failed";
|
||||
skipped.push({ index: i, reason });
|
||||
results.push({ index: i, status: "skipped", reason });
|
||||
}
|
||||
}
|
||||
|
||||
if (inserted + updated > 0) {
|
||||
await auditLog("nutrient.batch.ingest", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
processed: entries.length,
|
||||
inserted,
|
||||
updated,
|
||||
skipped: skipped.length,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "nutrient.batch.ingest" },
|
||||
meta: {
|
||||
processed: entries.length,
|
||||
inserted,
|
||||
updated,
|
||||
skipped: skipped.length,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
processed: entries.length,
|
||||
inserted,
|
||||
updated,
|
||||
skipped,
|
||||
entries: results,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* `GET /api/nutrients?days=N` — synced-nutrient window summary (v1.28).
|
||||
*
|
||||
* Feeds the read-only settings card (Settings → Sources): per nutrient
|
||||
* with data inside the window, the latest synced day + total and the
|
||||
* count of days carrying data — the smallest honest surface for "what
|
||||
* does the server hold". Catalog order, no pagination (≤ 26 rows).
|
||||
* Module-gated like the ingest path: 403 `module.disabled` when the
|
||||
* opt-in `nutrients` module is off. `userId` is narrowed from auth.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { NUTRIENT_CODES, isNutrientCode } from "@/lib/nutrients/catalog";
|
||||
import { nutrientOverviewQuerySchema } from "@/lib/validations/nutrients";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const gate = await requireModuleEnabled(user.id, "nutrients");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const parsed = nutrientOverviewQuerySchema.safeParse({
|
||||
days: request.nextUrl.searchParams.get("days") ?? undefined,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "nutrient.read.invalid",
|
||||
});
|
||||
}
|
||||
const { days } = parsed.data;
|
||||
|
||||
// Window floor as a UTC day key. The stored `day` is a local-timezone
|
||||
// key, so the UTC floor is off by at most one calendar day at the
|
||||
// window edge — fine for a "last N days" summary card.
|
||||
const since = new Date(Date.now() - (days - 1) * 24 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
|
||||
const rows = await prisma.nutrientIntakeDay.findMany({
|
||||
where: { userId: user.id, day: { gte: since } },
|
||||
orderBy: [{ nutrient: "asc" }, { day: "desc" }],
|
||||
select: { nutrient: true, unit: true, day: true, amount: true },
|
||||
});
|
||||
|
||||
// Fold to one summary row per nutrient. Rows arrive day-DESC inside
|
||||
// each nutrient, so the first row seen per code is the latest.
|
||||
const byCode = new Map<
|
||||
string,
|
||||
{ unit: string; latestDay: string; latestAmount: number; days: number }
|
||||
>();
|
||||
for (const row of rows) {
|
||||
const existing = byCode.get(row.nutrient);
|
||||
if (existing) {
|
||||
existing.days += 1;
|
||||
} else {
|
||||
byCode.set(row.nutrient, {
|
||||
unit: row.unit,
|
||||
latestDay: row.day,
|
||||
latestAmount: row.amount,
|
||||
days: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Catalog order; a stored code that ever fell out of the catalog is
|
||||
// dropped from the card rather than crashing the label lookup.
|
||||
const nutrients = NUTRIENT_CODES.filter(
|
||||
(code) => isNutrientCode(code) && byCode.has(code),
|
||||
).map((code) => {
|
||||
const summary = byCode.get(code)!;
|
||||
return {
|
||||
nutrient: code,
|
||||
unit: summary.unit,
|
||||
latestDay: summary.latestDay,
|
||||
latestAmount: summary.latestAmount,
|
||||
daysWithData: summary.days,
|
||||
};
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "nutrient.intake.read" },
|
||||
meta: {
|
||||
window_days: days,
|
||||
nutrient_count: nutrients.length,
|
||||
row_count: rows.length,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({ windowDays: days, nutrients });
|
||||
});
|
||||
+189
-93
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { KeyRound, Lock, Loader2 } from "lucide-react";
|
||||
import { KeyRound, Lock, Loader2, Shield } from "lucide-react";
|
||||
import { Logo } from "@/components/ui/logo";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -20,9 +20,11 @@ import {
|
||||
apiPost,
|
||||
} from "@/lib/api/api-fetch";
|
||||
import { MfaLoginStep, type MfaMethod } from "@/components/auth/mfa-login-step";
|
||||
import { OIDC_MFA_COOKIE, OIDC_MFA_COOKIE_PATH } from "@/lib/auth/oidc-cookie";
|
||||
import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag";
|
||||
import { prefetchDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot";
|
||||
import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister";
|
||||
import { sanitizeSameOriginPath } from "@/lib/url-safety";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
@@ -32,7 +34,18 @@ export default function LoginPage() {
|
||||
const [mode, setMode] = useState<"passkey" | "password">("passkey");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(() => {
|
||||
const oidcError = searchParams.get("error");
|
||||
if (!oidcError) return null;
|
||||
const key: Record<string, string> = {
|
||||
oidc_denied: "auth.oidc.errorDenied",
|
||||
oidc_no_email: "auth.oidc.errorNoEmail",
|
||||
oidc_email_unverified: "auth.oidc.errorEmailUnverified",
|
||||
oidc_identity_conflict: "auth.oidc.errorIdentityConflict",
|
||||
oidc_registration_disabled: "auth.oidc.errorRegistrationDisabled",
|
||||
};
|
||||
return t(key[oidcError] ?? "auth.oidc.errorFailed");
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
// v1.23 — second step of login when the password response carries
|
||||
// `meta.mfaRequired`. Holds the opaque ticket + the factors the account can
|
||||
@@ -66,14 +79,32 @@ export default function LoginPage() {
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
const { data: oidcStatus } = useQuery({
|
||||
queryKey: queryKeys.authOidcStatus(),
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await apiGet<{
|
||||
enabled?: boolean;
|
||||
buttonLabel?: string | null;
|
||||
only?: boolean;
|
||||
}>("/api/auth/oidc/status", { cache: "no-store" });
|
||||
} catch {
|
||||
return { enabled: false, buttonLabel: null, only: false };
|
||||
}
|
||||
},
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
function handleOidcLogin() {
|
||||
const params = new URLSearchParams({ next: getRedirectTarget() });
|
||||
window.location.href = `/api/auth/oidc/login?${params}`;
|
||||
}
|
||||
|
||||
function getRedirectTarget(): string {
|
||||
const next = searchParams.get("next");
|
||||
if (!next) return "/";
|
||||
// Prevent open redirects: only allow local absolute paths.
|
||||
if (next.startsWith("/") && !next.startsWith("//")) {
|
||||
return next;
|
||||
}
|
||||
return "/";
|
||||
return sanitizeSameOriginPath(
|
||||
searchParams.get("next"),
|
||||
window.location.href,
|
||||
);
|
||||
}
|
||||
|
||||
// v1.16.6 first-load waterfall fix — the session cookie exists the
|
||||
@@ -188,11 +219,50 @@ export default function LoginPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// OIDC → second-factor handoff. The OAuth callback cannot render UI: for
|
||||
// a 2FA-enrolled account it mints the same single-use MFA ticket password
|
||||
// login uses and parks it in a short-lived, login-page-scoped cookie
|
||||
// (`oidc-cookie.ts` documents why it is readable here). Pick it up once,
|
||||
// clear it, and enter the exact same MfaLoginStep the password flow uses —
|
||||
// `next` continuation rides the query string as usual.
|
||||
useEffect(() => {
|
||||
const raw = document.cookie
|
||||
.split("; ")
|
||||
.find((c) => c.startsWith(`${OIDC_MFA_COOKIE}=`))
|
||||
?.slice(OIDC_MFA_COOKIE.length + 1);
|
||||
if (!raw) return;
|
||||
document.cookie = `${OIDC_MFA_COOKIE}=; path=${OIDC_MFA_COOKIE_PATH}; max-age=0`;
|
||||
try {
|
||||
const parsed = JSON.parse(decodeURIComponent(raw)) as {
|
||||
ticket?: string;
|
||||
methods?: MfaMethod[];
|
||||
};
|
||||
if (parsed.ticket) {
|
||||
// A lazy useState initializer cannot replace this: `document` does
|
||||
// not exist during the server render, and seeding client-only state
|
||||
// there would desync hydration. Mount-time pickup of an external
|
||||
// one-shot store is exactly the effect escape hatch.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setMfaChallenge({
|
||||
ticket: parsed.ticket,
|
||||
methods: parsed.methods ?? ["totp", "recovery"],
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Malformed handoff — fall through to the normal login UI.
|
||||
}
|
||||
}, []);
|
||||
|
||||
// v1.23 — passkey conditional-UI autofill. When the browser supports it,
|
||||
// arm a discoverable-credential assertion bound to the username field so a
|
||||
// returning user can pick their passkey straight from the autofill prompt.
|
||||
// Best-effort: any abort (the user types a password instead) is swallowed.
|
||||
// Waits for the OIDC status and stays off entirely under OIDC_ONLY —
|
||||
// passkey login is a dead end there (server-enforced), so arming the
|
||||
// browser's credential prompt would only dangle a door that is locked.
|
||||
useEffect(() => {
|
||||
if (oidcStatus === undefined) return;
|
||||
if (oidcStatus.only === true) return;
|
||||
if (autofillStarted.current) return;
|
||||
autofillStarted.current = true;
|
||||
let cancelled = false;
|
||||
@@ -231,7 +301,7 @@ export default function LoginPage() {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [oidcStatus]);
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-sm flex-col gap-4">
|
||||
@@ -268,102 +338,128 @@ export default function LoginPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-8 space-y-4">
|
||||
{/* Phase A5 / B-mobile: bumped from default size (h-9, 36px)
|
||||
to size="lg" so the login CTAs meet WCAG 2.5.5 (44px
|
||||
minimum) on mobile. Login is the most-tapped flow on a
|
||||
fresh install. */}
|
||||
<Button
|
||||
onClick={handlePasskeyLogin}
|
||||
className="min-h-11 w-full"
|
||||
size="lg"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading && mode === "passkey" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<KeyRound className="h-4 w-4" />
|
||||
)}
|
||||
{t("auth.loginWithPasskey")}
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Separator className="flex-1" />
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("common.or")}
|
||||
</span>
|
||||
<Separator className="flex-1" />
|
||||
</div>
|
||||
|
||||
{mode === "passkey" ? (
|
||||
{oidcStatus?.enabled && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleOidcLogin}
|
||||
variant={oidcStatus.only ? "default" : "outline"}
|
||||
className="min-h-11 w-full"
|
||||
size="lg"
|
||||
onClick={() => setMode("password")}
|
||||
>
|
||||
<Lock className="h-4 w-4" />
|
||||
{t("auth.loginWithPassword")}
|
||||
<Shield className="h-4 w-4" />
|
||||
{oidcStatus.buttonLabel || t("auth.oidc.signInDefault")}
|
||||
</Button>
|
||||
) : (
|
||||
<form onSubmit={handlePasswordLogin} className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">{t("auth.emailOrUsername")}</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="text"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
// `webauthn` token arms the conditional-UI passkey
|
||||
// autofill prompt on this field (see the mount effect).
|
||||
autoComplete="username webauthn"
|
||||
inputMode="email"
|
||||
enterKeyHint="next"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
aria-required="true"
|
||||
aria-invalid={!!error || undefined}
|
||||
aria-describedby={errorDescriptor}
|
||||
placeholder={t("auth.emailOrUsernamePlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">{t("auth.password")}</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
enterKeyHint="go"
|
||||
aria-required="true"
|
||||
aria-invalid={!!error || undefined}
|
||||
aria-describedby={errorDescriptor}
|
||||
placeholder="********"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{oidcStatus?.enabled && !oidcStatus.only && (
|
||||
<div className="flex items-center gap-3">
|
||||
<Separator className="flex-1" />
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("common.or")}
|
||||
</span>
|
||||
<Separator className="flex-1" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!oidcStatus?.only && (
|
||||
<>
|
||||
{/* Phase A5 / B-mobile: bumped from default size (h-9, 36px)
|
||||
to size="lg" so the login CTAs meet WCAG 2.5.5 (44px
|
||||
minimum) on mobile. Login is the most-tapped flow on a
|
||||
fresh install. */}
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={handlePasskeyLogin}
|
||||
className="min-h-11 w-full"
|
||||
size="lg"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading && mode === "password" ? (
|
||||
{loading && mode === "passkey" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Lock className="h-4 w-4" />
|
||||
<KeyRound className="h-4 w-4" />
|
||||
)}
|
||||
{t("auth.login")}
|
||||
{t("auth.loginWithPasskey")}
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode("passkey")}
|
||||
className="text-muted-foreground hover:text-foreground inline-flex min-h-11 w-full items-center justify-center text-center text-xs"
|
||||
>
|
||||
{t("auth.backToPasskey")}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Separator className="flex-1" />
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("common.or")}
|
||||
</span>
|
||||
<Separator className="flex-1" />
|
||||
</div>
|
||||
|
||||
{mode === "passkey" ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="min-h-11 w-full"
|
||||
size="lg"
|
||||
onClick={() => setMode("password")}
|
||||
>
|
||||
<Lock className="h-4 w-4" />
|
||||
{t("auth.loginWithPassword")}
|
||||
</Button>
|
||||
) : (
|
||||
<form onSubmit={handlePasswordLogin} className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">{t("auth.emailOrUsername")}</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="text"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
// `webauthn` token arms the conditional-UI passkey
|
||||
// autofill prompt on this field (see the mount effect).
|
||||
autoComplete="username webauthn"
|
||||
inputMode="email"
|
||||
enterKeyHint="next"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
aria-required="true"
|
||||
aria-invalid={!!error || undefined}
|
||||
aria-describedby={errorDescriptor}
|
||||
placeholder={t("auth.emailOrUsernamePlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">{t("auth.password")}</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
enterKeyHint="go"
|
||||
aria-required="true"
|
||||
aria-invalid={!!error || undefined}
|
||||
aria-describedby={errorDescriptor}
|
||||
placeholder="********"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
className="min-h-11 w-full"
|
||||
size="lg"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading && mode === "password" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Lock className="h-4 w-4" />
|
||||
)}
|
||||
{t("auth.login")}
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode("passkey")}
|
||||
className="text-muted-foreground hover:text-foreground inline-flex min-h-11 w-full items-center justify-center text-center text-xs"
|
||||
>
|
||||
{t("auth.backToPasskey")}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
@@ -377,7 +473,7 @@ export default function LoginPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{registrationEnabled === true && (
|
||||
{!oidcStatus?.only && registrationEnabled === true && (
|
||||
<p className="text-muted-foreground text-center text-xs">
|
||||
{t("auth.noAccount")}{" "}
|
||||
<Link
|
||||
|
||||
@@ -36,6 +36,11 @@ vi.mock("@/lib/clinician-share/resolve-share-token", () => ({
|
||||
vi.mock("@/lib/clinician-share/share-view-data", () => ({
|
||||
loadShareViewData: vi.fn(),
|
||||
}));
|
||||
// Issue #490 — the page resolves the share OWNER's timezone for the date
|
||||
// rendering; the Prisma-backed resolver is stubbed like the data loader.
|
||||
vi.mock("@/lib/tz/resolver", () => ({
|
||||
resolveUserTimezone: vi.fn(async () => "Europe/Berlin"),
|
||||
}));
|
||||
vi.mock("@/components/clinician/clinician-view", () => ({
|
||||
ClinicianView: () => null,
|
||||
}));
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { defaultLocale, locales, type Locale } from "@/lib/i18n/config";
|
||||
import { parseLocaleFromAcceptLanguage } from "@/lib/format-locale";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { ClinicianView } from "@/components/clinician/clinician-view";
|
||||
import { ShareUnlockGate } from "@/components/clinician/share-unlock-gate";
|
||||
|
||||
@@ -103,8 +104,15 @@ export default async function ClinicianSharePage({
|
||||
const context = await resolveShareToken(token);
|
||||
if (!context) notFound();
|
||||
|
||||
const [{ report, sections, documents, documentOnly }, locale] =
|
||||
await Promise.all([loadShareViewData(context), resolveLocale()]);
|
||||
// Issue #490 — period/expiry dates render in the share OWNER's (patient's)
|
||||
// profile timezone so they agree with the patient-tz aggregation behind the
|
||||
// stats and with the doctor-report PDF (never the container's zone).
|
||||
const [{ report, sections, documents, documentOnly }, locale, ownerTimezone] =
|
||||
await Promise.all([
|
||||
loadShareViewData(context),
|
||||
resolveLocale(),
|
||||
resolveUserTimezone(context.ownerUserId),
|
||||
]);
|
||||
const { t } = getServerTranslator(locale);
|
||||
|
||||
return (
|
||||
@@ -118,6 +126,7 @@ export default async function ClinicianSharePage({
|
||||
documentOnly={documentOnly}
|
||||
token={token}
|
||||
locale={locale}
|
||||
timezone={ownerTimezone}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -41,7 +41,7 @@ interface ComprehensiveMoodData {
|
||||
}
|
||||
|
||||
export default function InsightsStimmungPage() {
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated);
|
||||
|
||||
@@ -107,11 +107,7 @@ export default function InsightsStimmungPage() {
|
||||
MeasurementType series, so the period-over-period range read
|
||||
(`/api/analytics/range`, keyed on a MeasurementType enum) has
|
||||
nothing to aggregate. */}
|
||||
<MoodChart
|
||||
chartKey="mood"
|
||||
compareBaseline={compareBaseline}
|
||||
userTimezone={user?.timezone}
|
||||
/>
|
||||
<MoodChart chartKey="mood" compareBaseline={compareBaseline} />
|
||||
|
||||
<MetricTargetSummary slug="mood" />
|
||||
|
||||
|
||||
@@ -1375,7 +1375,6 @@ export default function DashboardPageClient() {
|
||||
onDataReady={() => markChartReady("mood-chart")}
|
||||
compareBaseline={compareBaseline}
|
||||
chartKey="mood"
|
||||
userTimezone={user?.timezone}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -35,7 +35,7 @@ import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
import { computePaddedYDomain } from "@/lib/insights/chart-y-domain";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { makeFormatters } from "@/lib/format-locale";
|
||||
import { makeBucketLabelFormatters } from "@/lib/charts/bucket-label";
|
||||
import {
|
||||
bucketTimeSeries,
|
||||
pickBucket,
|
||||
@@ -595,14 +595,15 @@ export function HealthChart({
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
const { t, locale } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
// v1.4.25 W7b — tz-aware formatter for x-axis tick labels + tooltip
|
||||
// date strings. `useFormatters()` reads the active UI locale only;
|
||||
// this builds a locale + userTz pair so a Pacific/Auckland user reads
|
||||
// their own day on the axis.
|
||||
const tzFmt = useMemo(
|
||||
() => makeFormatters(locale, userTimezone),
|
||||
[locale, userTimezone],
|
||||
);
|
||||
// Issue #490 — x-axis tick + tooltip date labels. Every timestamp this
|
||||
// chart formats encodes a CALENDAR DAY (noon-UTC of a profile-tz day
|
||||
// key, or a `bucketTimeSeries` Berlin-day UTC-midnight bucket start),
|
||||
// so labels render UTC-pinned via `makeBucketLabelFormatters` — the
|
||||
// encoded day paints verbatim in every profile zone. Rendering these
|
||||
// encodings through a profile-tz formatter shifted week/month bucket
|
||||
// labels a day/month back for zones west of Berlin. Day-key BUCKETING
|
||||
// below still uses `userTimezone` — only labels are pinned.
|
||||
const tzFmt = useMemo(() => makeBucketLabelFormatters(locale), [locale]);
|
||||
// v1.4.25 W7b — per-row day-key formatter used to bucket measurement
|
||||
// rows by the user's local calendar day. Memoised on userTimezone so
|
||||
// the inner per-row loop reuses a single Intl.DateTimeFormat.
|
||||
|
||||
@@ -19,7 +19,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { makeFormatters } from "@/lib/format-locale";
|
||||
import { makeBucketLabelFormatters } from "@/lib/charts/bucket-label";
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
import {
|
||||
bucketTimeSeries,
|
||||
@@ -90,12 +90,6 @@ interface MoodChartProps {
|
||||
* only surface — the cog stays hidden and overlays are clean.
|
||||
*/
|
||||
chartKey?: ChartOverlayKey;
|
||||
/**
|
||||
* v1.4.25 W7b — per-user display timezone. When passed, the x-axis
|
||||
* tick labels and tooltip date render in the user's tz. Defaults to
|
||||
* "Europe/Berlin" so older mount sites stay byte-identical.
|
||||
*/
|
||||
userTimezone?: string;
|
||||
/**
|
||||
* v1.16.0 — fires once the mood analytics query has settled (initial
|
||||
* load finished). The dashboard's shared reveal gate listens here so
|
||||
@@ -304,17 +298,17 @@ export function MoodChart({
|
||||
windowOverride,
|
||||
compareBaseline = "none",
|
||||
chartKey,
|
||||
userTimezone = "Europe/Berlin",
|
||||
onDataReady,
|
||||
}: MoodChartProps) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { t, locale } = useTranslations();
|
||||
// v1.4.25 W7b — tz-aware formatter for x-axis tick labels + tooltip
|
||||
// date strings.
|
||||
const tzFmt = useMemo(
|
||||
() => makeFormatters(locale, userTimezone),
|
||||
[locale, userTimezone],
|
||||
);
|
||||
// Issue #490 — x-axis tick + tooltip date labels. Every timestamp this
|
||||
// chart formats encodes a CALENDAR DAY (noon-UTC of a server day key,
|
||||
// or a `bucketTimeSeries` Berlin-day UTC-midnight bucket start), so
|
||||
// labels render UTC-pinned via `makeBucketLabelFormatters` — the
|
||||
// encoded day paints verbatim in every profile zone (a profile-tz
|
||||
// formatter shifted week/month bucket labels for zones west of Berlin).
|
||||
const tzFmt = useMemo(() => makeBucketLabelFormatters(locale), [locale]);
|
||||
const initialRangePoints = windowOverride
|
||||
? MINI_RANGE_POINTS[windowOverride]
|
||||
: 30;
|
||||
|
||||
@@ -17,6 +17,8 @@ import { formatBytes } from "@/components/documents/vault-utils";
|
||||
import type { ShareViewDocument } from "@/lib/clinician-share/share-view-data";
|
||||
import { DOCTOR_REPORT_TYPE_LABEL_KEYS } from "@/lib/doctor-report/type-label-keys";
|
||||
import type { DoctorReportData } from "@/lib/doctor-report-data";
|
||||
import { makeFormatters } from "@/lib/format-locale";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents";
|
||||
import type { DoctorReportPrefs } from "@/lib/validations/doctor-report-prefs";
|
||||
|
||||
@@ -53,7 +55,17 @@ interface ClinicianViewProps {
|
||||
/** The raw share token from the path — used to build serve-route URLs. */
|
||||
token?: string;
|
||||
/** Viewer locale (byte formatting only). Defaults to English. */
|
||||
locale?: string;
|
||||
locale?: Locale;
|
||||
/**
|
||||
* Issue #490 — the share OWNER's (patient's) profile timezone. Period
|
||||
* start/end and the expiry date render in this zone so the dates agree
|
||||
* with the patient-tz aggregation behind the stats (and with the
|
||||
* doctor-report PDF). Pre-fix the dates rendered via a bare
|
||||
* `toLocaleDateString()` — the server CONTAINER's zone (≈ UTC), neither
|
||||
* the patient's nor the viewer's, with US-English field order regardless
|
||||
* of the resolved page locale.
|
||||
*/
|
||||
timezone?: string;
|
||||
}
|
||||
|
||||
/** Human-readable display per persisted wellness-score type (i18n key suffix). */
|
||||
@@ -197,8 +209,12 @@ export function ClinicianView({
|
||||
documentOnly = false,
|
||||
token = "",
|
||||
locale = "en",
|
||||
timezone,
|
||||
}: ClinicianViewProps) {
|
||||
const fmtDate = (iso: string) => new Date(iso).toLocaleDateString();
|
||||
// Owner-tz, locale-aware date rendering (issue #490) — `makeFormatters`
|
||||
// guards the zone and falls back to Europe/Berlin on garbage/absence.
|
||||
const fmt = makeFormatters(locale, timezone);
|
||||
const fmtDate = (iso: string) => fmt.date(new Date(iso));
|
||||
const fmtNum = (n: number) => Math.round(n * 100) / 100;
|
||||
|
||||
// A documents-only share carries no report — the aggregator is never called
|
||||
|
||||
@@ -3,9 +3,10 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
/**
|
||||
* The floating bulk bar, pinned via single-pass static renders: a labelled
|
||||
* toolbar carrying the selected count and the four bulk verbs. The
|
||||
* link-condition menu only renders when the account actually has episodes
|
||||
* to link — no dead affordance on an episode-free account.
|
||||
* toolbar carrying the selected count and the bulk verbs (set type, link
|
||||
* condition, share, delete, clear). The link-condition menu only renders when
|
||||
* the account actually has episodes to link — no dead affordance on an
|
||||
* episode-free account.
|
||||
*/
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { DocumentBulkBar } from "../document-bulk-bar";
|
||||
@@ -19,6 +20,7 @@ function render(episodes: { id: string; label: string }[]) {
|
||||
busy={false}
|
||||
onSetKind={() => {}}
|
||||
onLinkEpisode={() => {}}
|
||||
onShare={() => {}}
|
||||
onDelete={() => {}}
|
||||
onClear={() => {}}
|
||||
/>
|
||||
@@ -34,6 +36,8 @@ describe("<DocumentBulkBar>", () => {
|
||||
expect(html).toContain("3 selected");
|
||||
expect(html).toContain("Change type");
|
||||
expect(html).toContain("Link condition");
|
||||
expect(html).toContain('data-slot="document-bulk-share"');
|
||||
expect(html).toContain("Share");
|
||||
expect(html).toContain("Delete");
|
||||
expect(html).toContain("Clear selection");
|
||||
});
|
||||
|
||||
@@ -115,6 +115,53 @@ describe("<DocumentCard>", () => {
|
||||
expect(html).not.toContain("/thumbnail");
|
||||
});
|
||||
|
||||
it("does NOT render the AI-read glyph on the card face, even when AI-read", () => {
|
||||
// The AI-read state stays surfaced authoritatively in the detail sheet's
|
||||
// DocumentAiSection; the card face no longer duplicates it (v1.28.38).
|
||||
const html = render(
|
||||
<DocumentCard
|
||||
document={doc({ hasContentIndex: true, contentIndexSource: "vision" })}
|
||||
selected={false}
|
||||
onToggleSelected={noop}
|
||||
onOpen={noop}
|
||||
highlighted={false}
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="document-card-ai-read"');
|
||||
});
|
||||
|
||||
it("renders the filename on its own line with the full name in title", () => {
|
||||
const html = render(
|
||||
<DocumentCard
|
||||
document={doc({
|
||||
title: "MRT Knie",
|
||||
filename: "mrt-knie-2025-10-04.pdf",
|
||||
})}
|
||||
selected={false}
|
||||
onToggleSelected={noop}
|
||||
onOpen={noop}
|
||||
highlighted={false}
|
||||
/>,
|
||||
);
|
||||
// Dedicated filename line, muted text-xs, full name reachable on hover.
|
||||
expect(html).toContain('data-slot="document-card-filename"');
|
||||
expect(html).toContain('title="mrt-knie-2025-10-04.pdf"');
|
||||
expect(html).toContain("mrt-knie-2025-10-04.pdf");
|
||||
});
|
||||
|
||||
it("omits the filename line when the filename equals the title", () => {
|
||||
const html = render(
|
||||
<DocumentCard
|
||||
document={doc({ title: "report.pdf", filename: "report.pdf" })}
|
||||
selected={false}
|
||||
onToggleSelected={noop}
|
||||
onOpen={noop}
|
||||
highlighted={false}
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="document-card-filename"');
|
||||
});
|
||||
|
||||
it("falls back to the untitled label and rings when highlighted", () => {
|
||||
const html = render(
|
||||
<DocumentCard
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
/**
|
||||
* The document-launched share sheet now accepts an ARRAY of documents so the
|
||||
* bulk multi-select can fold the whole selection into ONE documents-only link.
|
||||
* The single-doc (detail sheet) caller passes a one-element array.
|
||||
*
|
||||
* ResponsiveSheet wraps its body in a Radix portal that renderToStaticMarkup
|
||||
* won't materialise, so both it and the create form are mocked down to plain
|
||||
* wrappers that surface the title + the seeded form props.
|
||||
*/
|
||||
vi.mock("@/components/ui/responsive-sheet", () => ({
|
||||
ResponsiveSheet: ({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
[key: string]: unknown;
|
||||
}) => (
|
||||
<div data-slot="mock-responsive-sheet">
|
||||
<div data-slot="sheet-title">{title}</div>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/settings/share-link-create-form", () => ({
|
||||
ShareLinkCreateForm: (props: {
|
||||
documentOnly?: boolean;
|
||||
initialDocuments?: { id: string; title: string }[];
|
||||
initialLabel?: string;
|
||||
}) => (
|
||||
<div
|
||||
data-slot="mock-share-form"
|
||||
data-document-only={String(props.documentOnly ?? false)}
|
||||
data-initial-label={props.initialLabel ?? ""}
|
||||
data-doc-ids={(props.initialDocuments ?? []).map((d) => d.id).join(",")}
|
||||
data-doc-count={String((props.initialDocuments ?? []).length)}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { DocumentShareSheet } from "../document-share-sheet";
|
||||
|
||||
function render(documents: { id: string; title: string }[]) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<DocumentShareSheet open onOpenChange={() => {}} documents={documents} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<DocumentShareSheet> — one link for the selection", () => {
|
||||
it("seeds a single-doc share with the document title as the label", () => {
|
||||
const html = render([{ id: "d1", title: "Blood panel" }]);
|
||||
expect(html).toContain("Share this document");
|
||||
expect(html).toContain('data-document-only="true"');
|
||||
expect(html).toContain('data-doc-ids="d1"');
|
||||
expect(html).toContain('data-initial-label="Blood panel"');
|
||||
});
|
||||
|
||||
it("folds N selected docs into ONE documents-only link with a count label", () => {
|
||||
const html = render([
|
||||
{ id: "d1", title: "A" },
|
||||
{ id: "d2", title: "B" },
|
||||
{ id: "d3", title: "C" },
|
||||
]);
|
||||
// Multi title, a "{count} documents" summary label, all ids in one link.
|
||||
expect(html).toContain("Share these documents");
|
||||
expect(html).toContain('data-doc-ids="d1,d2,d3"');
|
||||
expect(html).toContain('data-doc-count="3"');
|
||||
expect(html).toContain('data-initial-label="3 documents"');
|
||||
// Privacy: documents-only stays frozen on for the launched share.
|
||||
expect(html).toContain('data-document-only="true"');
|
||||
});
|
||||
|
||||
it("does not mount the form when closed", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<DocumentShareSheet
|
||||
open={false}
|
||||
onOpenChange={() => {}}
|
||||
documents={[{ id: "d1", title: "A" }]}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="mock-share-form"');
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
formatMonthLabel,
|
||||
parseUploadResponse,
|
||||
parseVaultSearchParams,
|
||||
resolveBulkShareDocuments,
|
||||
SHARE_LINK_MAX_DOCUMENTS,
|
||||
vaultFiltersToSearch,
|
||||
} from "../vault-utils";
|
||||
|
||||
@@ -284,3 +286,60 @@ describe("shift-click range selection", () => {
|
||||
]).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveBulkShareDocuments", () => {
|
||||
const corpus = [
|
||||
doc({ id: "d1", title: "Blood panel", filename: "bp.pdf" }),
|
||||
doc({ id: "d2", title: null, filename: "scan.jpg" }),
|
||||
doc({ id: "d3", title: null, filename: null }),
|
||||
doc({ id: "d4", title: "Referral" }),
|
||||
];
|
||||
|
||||
it("maps the selection to {id,title}, falling back title → filename → untitled", () => {
|
||||
const result = resolveBulkShareDocuments(
|
||||
corpus,
|
||||
new Set(["d1", "d2", "d3"]),
|
||||
"Untitled",
|
||||
);
|
||||
expect(result.overCap).toBe(false);
|
||||
if (result.overCap) throw new Error("unexpected over-cap");
|
||||
expect(result.documents).toEqual([
|
||||
{ id: "d1", title: "Blood panel" },
|
||||
{ id: "d2", title: "scan.jpg" },
|
||||
{ id: "d3", title: "Untitled" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves corpus order and ignores ids not in the corpus", () => {
|
||||
const result = resolveBulkShareDocuments(
|
||||
corpus,
|
||||
new Set(["d4", "d1", "ghost"]),
|
||||
"Untitled",
|
||||
);
|
||||
if (result.overCap) throw new Error("unexpected over-cap");
|
||||
expect(result.documents.map((d) => d.id)).toEqual(["d1", "d4"]);
|
||||
});
|
||||
|
||||
it("caps at SHARE_LINK_MAX_DOCUMENTS (50) — over the cap refuses rather than truncates", () => {
|
||||
const many = Array.from({ length: 60 }, (_, i) => doc({ id: `x${i}` }));
|
||||
const selected = new Set(many.map((d) => d.id));
|
||||
expect(selected.size).toBeGreaterThan(SHARE_LINK_MAX_DOCUMENTS);
|
||||
expect(resolveBulkShareDocuments(many, selected, "Untitled")).toEqual({
|
||||
overCap: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("allows exactly 50 selected documents", () => {
|
||||
const fifty = Array.from({ length: SHARE_LINK_MAX_DOCUMENTS }, (_, i) =>
|
||||
doc({ id: `y${i}` }),
|
||||
);
|
||||
const result = resolveBulkShareDocuments(
|
||||
fifty,
|
||||
new Set(fifty.map((d) => d.id)),
|
||||
"Untitled",
|
||||
);
|
||||
expect(result.overCap).toBe(false);
|
||||
if (result.overCap) throw new Error("unexpected over-cap");
|
||||
expect(result.documents).toHaveLength(SHARE_LINK_MAX_DOCUMENTS);
|
||||
});
|
||||
});
|
||||
@@ -4,15 +4,16 @@
|
||||
* Floating bulk-action bar for the vault's multi-select mode. Appears once
|
||||
* at least one document is selected, pinned above the bottom nav on phones
|
||||
* and near the bottom edge on desktop. Carries the selected count and the
|
||||
* four bulk verbs — set type, link condition, delete (undo-able), clear —
|
||||
* all driving `POST /api/documents/inbound/bulk` from the page.
|
||||
* bulk verbs — set type, link condition, share (one link for the whole
|
||||
* selection), delete (undo-able), clear — all driving the page's handlers.
|
||||
*
|
||||
* A `role="toolbar"` with proper labels; the destructive verb sits last
|
||||
* before Clear so a stray tap sequence never ends on Delete. The bar is a
|
||||
* deliberate hand-rolled shell (a floating toolbar is not a Card surface):
|
||||
* dense-tile padding `p-3` per the standards.
|
||||
* before Clear so a stray tap sequence never ends on Delete. Share sits just
|
||||
* before Delete (the safe actions lead). The bar is a deliberate hand-rolled
|
||||
* shell (a floating toolbar is not a Card surface): dense-tile padding `p-3`
|
||||
* per the standards.
|
||||
*/
|
||||
import { FolderPlus, Tag, Trash2, X } from "lucide-react";
|
||||
import { FolderPlus, Share2, Tag, Trash2, X } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -37,6 +38,7 @@ export function DocumentBulkBar({
|
||||
busy,
|
||||
onSetKind,
|
||||
onLinkEpisode,
|
||||
onShare,
|
||||
onDelete,
|
||||
onClear,
|
||||
}: {
|
||||
@@ -47,6 +49,12 @@ export function DocumentBulkBar({
|
||||
busy: boolean;
|
||||
onSetKind: (kind: InboundDocumentKindValue) => void;
|
||||
onLinkEpisode: (episodeId: string) => void;
|
||||
/**
|
||||
* Share the whole selection as ONE documents-only link. The page caps the
|
||||
* selection at `SHARE_LINK_MAX_DOCUMENTS` and surfaces the over-cap hint —
|
||||
* this handler just opens the share sheet seeded with the selection.
|
||||
*/
|
||||
onShare: () => void;
|
||||
onDelete: () => void;
|
||||
onClear: () => void;
|
||||
}) {
|
||||
@@ -108,6 +116,17 @@ export function DocumentBulkBar({
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={onShare}
|
||||
data-slot="document-bulk-share"
|
||||
>
|
||||
<Share2 className="size-4" aria-hidden />
|
||||
{t("documents.bulk.share")}
|
||||
</Button>
|
||||
|
||||
{/* Solid destructive (matching the detail sheet's Delete) — the
|
||||
outline variant's destructive text on the card surface fails the
|
||||
WCAG contrast gate. */}
|
||||
|
||||
@@ -9,13 +9,14 @@
|
||||
* Anatomy per the design standards: a leading edge that shows a small preview
|
||||
* thumbnail tile when one has been rendered (`hasThumbnail`) and otherwise the
|
||||
* kind icon `text-foreground size-5`, title in foreground (`text-sm
|
||||
* font-medium`, truncated), one muted meta line (date · size · filename),
|
||||
* font-medium`, truncated), a muted meta line (date · size) with the real
|
||||
* filename on its own muted line below (full name on hover via `title`),
|
||||
* condition-tag pills, an attachment-class badge for download-only formats,
|
||||
* and a selection checkbox that appears on hover / focus / while selected. The
|
||||
* whole card is clickable through an invisible overlay button; the checkbox
|
||||
* floats above it.
|
||||
*/
|
||||
import { Download, Sparkles, X } from "lucide-react";
|
||||
import { Download, X } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -24,10 +25,7 @@ import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
isAiReadSource,
|
||||
type InboundDocumentDto,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
|
||||
import { DOCUMENT_KIND_ICONS } from "./document-kind-meta";
|
||||
import type { UploadQueueItem } from "./use-document-upload";
|
||||
import { documentDateKey, formatBytes } from "./vault-utils";
|
||||
@@ -87,8 +85,6 @@ export function DocumentCard({
|
||||
const size = formatBytes(document.byteSize, locale);
|
||||
const showFilename =
|
||||
document.filename !== null && document.filename !== title;
|
||||
const aiRead =
|
||||
document.hasContentIndex && isAiReadSource(document.contentIndexSource);
|
||||
|
||||
return (
|
||||
<Card
|
||||
@@ -133,22 +129,20 @@ export function DocumentCard({
|
||||
)}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<p className="truncate text-sm font-medium">{title}</p>
|
||||
<p className="text-muted-foreground flex min-w-0 items-center gap-1 text-xs">
|
||||
<span className="truncate">
|
||||
{date} · {size}
|
||||
{showFilename ? ` · ${document.filename}` : ""}
|
||||
</span>
|
||||
{aiRead ? (
|
||||
// AI-read marker rides INLINE on the meta row — a bare glyph,
|
||||
// no pill line (the tag ate a whole row in the dense grid).
|
||||
<Sparkles
|
||||
data-slot="document-card-ai-read"
|
||||
role="img"
|
||||
aria-label={t("documents.ai.statusAiRead")}
|
||||
className="text-primary size-3 shrink-0"
|
||||
/>
|
||||
) : null}
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
{date} · {size}
|
||||
</p>
|
||||
{showFilename ? (
|
||||
// Filename on its OWN muted line so it never clips the date/size
|
||||
// meta; the full name stays reachable on hover via `title`.
|
||||
<p
|
||||
data-slot="document-card-filename"
|
||||
className="text-muted-foreground truncate text-xs"
|
||||
title={document.filename ?? undefined}
|
||||
>
|
||||
{document.filename}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
|
||||
@@ -62,6 +62,7 @@ import { invalidateKeys, queryKeys } from "@/lib/query-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
INBOUND_DOCUMENT_KINDS,
|
||||
isAiReadSource,
|
||||
type DocumentSuggestionDto,
|
||||
type DocumentSummaryMode,
|
||||
type InboundDocumentDetailDto,
|
||||
@@ -420,6 +421,12 @@ export function DocumentDetailSheet({
|
||||
const title = doc?.title ?? doc?.filename ?? t("documents.card.untitled");
|
||||
const Icon = doc ? DOCUMENT_KIND_ICONS[doc.kind] : null;
|
||||
const originalHref = doc ? `/api/documents/inbound/${doc.id}/original` : "#";
|
||||
// AI-read provenance — surfaced here (the detail view) as calm muted meta
|
||||
// since v1.28.38 dropped the vault-card glyph. Same condition the AI section
|
||||
// uses: a content index exists AND its source is a provider vision/text read.
|
||||
const aiRead = doc
|
||||
? doc.hasContentIndex && isAiReadSource(doc.contentIndexSource)
|
||||
: false;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -752,6 +759,15 @@ export function DocumentDetailSheet({
|
||||
})}
|
||||
{doc.filename ? ` · ${doc.filename}` : ""}
|
||||
</p>
|
||||
|
||||
{aiRead ? (
|
||||
<p
|
||||
data-slot="document-detail-ai-read"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("documents.ai.statusAiRead")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -761,8 +777,7 @@ export function DocumentDetailSheet({
|
||||
<DocumentShareSheet
|
||||
open={shareOpen}
|
||||
onOpenChange={setShareOpen}
|
||||
documentId={doc.id}
|
||||
documentTitle={title}
|
||||
documents={[{ id: doc.id, title }]}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -3,46 +3,62 @@
|
||||
/**
|
||||
* The document-launched entry point into the clinician share-link create flow.
|
||||
* Mounts the shared `ShareLinkCreateForm` inside a `ResponsiveSheet` (bottom
|
||||
* sheet on phones, dialog on desktop) with the current document pre-attached
|
||||
* and its title pre-filled as the link label — the owner shares the document
|
||||
* they were looking at without leaving for Settings, and the one-time QR /
|
||||
* passphrase reveal lands in the same surface.
|
||||
* sheet on phones, dialog on desktop) with the picked document(s) pre-attached
|
||||
* and a label pre-filled — the owner shares the document(s) they were looking
|
||||
* at (detail sheet) or selected (bulk multi-select) without leaving for
|
||||
* Settings, and the one-time QR / passphrase reveal lands in the same surface.
|
||||
*
|
||||
* Accepts an ARRAY: a single-document share (detail sheet) passes one entry; a
|
||||
* bulk share passes the whole selection, all folded into ONE `documentOnly`
|
||||
* link (the share model carries up to `SHARE_LINK_MAX_DOCUMENTS` docs per
|
||||
* link). The label defaults to the single title, or a `{count} documents`
|
||||
* summary for a multi-doc share.
|
||||
*
|
||||
* The frozen-write-once + ≤50 rules, the EXIF note, and expiry all come from
|
||||
* the shared form as-is; the same `POST /api/share-links` handles the create.
|
||||
* The form is mounted only while the sheet is open so each open starts a fresh
|
||||
* link (the one-time secret is never re-shown for a stale create).
|
||||
* the shared form as-is; the same `POST /api/share-links` handles the create
|
||||
* and re-verifies every id as the owner's own live document. The form is
|
||||
* mounted only while the sheet is open so each open starts a fresh link (the
|
||||
* one-time secret is never re-shown for a stale create).
|
||||
*/
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import type { PickedDocument } from "@/components/settings/share-document-picker";
|
||||
import { ShareLinkCreateForm } from "@/components/settings/share-link-create-form";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
export function DocumentShareSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
documentId,
|
||||
documentTitle,
|
||||
documents,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
documentId: string;
|
||||
documentTitle: string;
|
||||
/** The document(s) to pre-attach — one entry (detail) or many (bulk). */
|
||||
documents: PickedDocument[];
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
const multiple = documents.length > 1;
|
||||
// Single doc → its title; multiple → a "{count} documents" summary. The
|
||||
// owner can still edit the label in the form before minting the link.
|
||||
const label = multiple
|
||||
? t("documents.share.multiLabel", { count: documents.length })
|
||||
: (documents[0]?.title ?? "");
|
||||
|
||||
return (
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t("documents.share.title")}
|
||||
title={
|
||||
multiple ? t("documents.share.multiTitle") : t("documents.share.title")
|
||||
}
|
||||
description={t("documents.share.description")}
|
||||
contentWidth="2xl"
|
||||
>
|
||||
{open ? (
|
||||
<ShareLinkCreateForm
|
||||
documentOnly
|
||||
initialDocuments={[{ id: documentId, title: documentTitle }]}
|
||||
initialLabel={documentTitle}
|
||||
initialDocuments={documents}
|
||||
initialLabel={label}
|
||||
/>
|
||||
) : null}
|
||||
</ResponsiveSheet>
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
import { DocumentBulkBar } from "./document-bulk-bar";
|
||||
import { DocumentDetailSheet } from "./document-detail-sheet";
|
||||
import { DocumentShareSheet } from "./document-share-sheet";
|
||||
import { DocumentFilterBar, type ConditionChip } from "./document-filter-bar";
|
||||
import { DocumentTimeline } from "./document-timeline";
|
||||
import { UploadZone } from "./upload-zone";
|
||||
@@ -65,6 +66,8 @@ import {
|
||||
documentDateKey,
|
||||
expandRangeSelection,
|
||||
parseVaultSearchParams,
|
||||
resolveBulkShareDocuments,
|
||||
SHARE_LINK_MAX_DOCUMENTS,
|
||||
vaultFiltersToSearch,
|
||||
} from "./vault-utils";
|
||||
|
||||
@@ -481,6 +484,32 @@ export function DocumentsView() {
|
||||
[bulk, clearSelection, restoreBulk, t],
|
||||
);
|
||||
|
||||
// ── Bulk share ────────────────────────────────────────────────────────
|
||||
// ONE documents-only link for the whole selection (the share model carries
|
||||
// up to SHARE_MAX_DOCUMENTS docs per link). The titles come from the loaded
|
||||
// corpus — a selected id is always a rendered row. Over the cap we surface a
|
||||
// hint and refuse rather than silently dropping docs from the link.
|
||||
const [bulkShareOpen, setBulkShareOpen] = useState(false);
|
||||
const [bulkShareDocs, setBulkShareDocs] = useState<
|
||||
{ id: string; title: string }[]
|
||||
>([]);
|
||||
const openBulkShare = useCallback(() => {
|
||||
const resolved = resolveBulkShareDocuments(
|
||||
documents,
|
||||
selectedIds,
|
||||
t("documents.card.untitled"),
|
||||
);
|
||||
if (resolved.overCap) {
|
||||
toast.error(
|
||||
t("documents.bulk.shareTooMany", { max: SHARE_LINK_MAX_DOCUMENTS }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (resolved.documents.length === 0) return;
|
||||
setBulkShareDocs(resolved.documents);
|
||||
setBulkShareOpen(true);
|
||||
}, [documents, selectedIds, t]);
|
||||
|
||||
// ── Detail sheet ──────────────────────────────────────────────────────
|
||||
const [detailId, setDetailId] = useState<string | null>(null);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
@@ -662,11 +691,18 @@ export function DocumentsView() {
|
||||
busy={bulk.isPending}
|
||||
onSetKind={(kind) => runBulk("setKind", { kind })}
|
||||
onLinkEpisode={(episodeId) => runBulk("linkEpisode", { episodeId })}
|
||||
onShare={openBulkShare}
|
||||
onDelete={() => deleteBulk([...selectedIds])}
|
||||
onClear={clearSelection}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<DocumentShareSheet
|
||||
open={bulkShareOpen}
|
||||
onOpenChange={setBulkShareOpen}
|
||||
documents={bulkShareDocs}
|
||||
/>
|
||||
|
||||
{/* Page-wide drop overlay — pure decoration (aria-hidden); the intake
|
||||
itself announces through the live region below. */}
|
||||
{dropActive ? (
|
||||
|
||||
@@ -223,6 +223,42 @@ export function expandRangeSelection(
|
||||
return next;
|
||||
}
|
||||
|
||||
// ─── Bulk share selection ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Client mirror of the server's `SHARE_LINK_MAX_DOCUMENTS` — one share link
|
||||
* carries at most 50 documents. Kept as a local literal (NOT imported from the
|
||||
* clinician-share validations module, which pulls the Prisma client into scope
|
||||
* and would drag the DB into the client bundle); the server re-enforces the cap
|
||||
* on create regardless. The bulk-SELECT cap (`DOCUMENT_BULK_MAX_IDS`) is higher
|
||||
* (100), so a large selection is capped for the share path with an explicit hint.
|
||||
*/
|
||||
export const SHARE_LINK_MAX_DOCUMENTS = 50;
|
||||
|
||||
/**
|
||||
* Map a selection over the loaded corpus onto the `{ id, title }` list a share
|
||||
* link seeds from. Returns `{ overCap: true }` when the selection exceeds
|
||||
* `SHARE_LINK_MAX_DOCUMENTS` — the caller surfaces the hint and refuses rather
|
||||
* than silently dropping documents from the link. `untitledLabel` is injected
|
||||
* so the helper stays pure / i18n-free.
|
||||
*/
|
||||
export function resolveBulkShareDocuments(
|
||||
documents: readonly InboundDocumentDto[],
|
||||
selected: ReadonlySet<string>,
|
||||
untitledLabel: string,
|
||||
):
|
||||
| { overCap: true }
|
||||
| { overCap: false; documents: { id: string; title: string }[] } {
|
||||
if (selected.size > SHARE_LINK_MAX_DOCUMENTS) return { overCap: true };
|
||||
const picked = documents
|
||||
.filter((d) => selected.has(d.id))
|
||||
.map((d) => ({
|
||||
id: d.id,
|
||||
title: d.title ?? d.filename ?? untitledLabel,
|
||||
}));
|
||||
return { overCap: false, documents: picked };
|
||||
}
|
||||
|
||||
// ─── Upload response contract (§3.2) ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* v1.28.30 — wire-contract for the explicit regenerate.
|
||||
*
|
||||
* House rule: generation = nightly cron + explicit button. The button's
|
||||
* mutation therefore MUST issue `POST { force: true }` — without `force`
|
||||
* the route's 24 h short-circuit re-serves the cached payload and the
|
||||
* button reads as doing nothing (one half of the recurring
|
||||
* "no briefing today" chain). The read path stays a plain GET.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const apiFetchRaw = vi.fn();
|
||||
|
||||
vi.mock("@/lib/api/api-fetch", () => ({
|
||||
apiFetchRaw: (...a: unknown[]) => apiFetchRaw(...a),
|
||||
}));
|
||||
|
||||
import { fetchAdvisor } from "../use-insights-advisor";
|
||||
|
||||
function jsonResponse(data: unknown): Response {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ data }),
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
apiFetchRaw.mockResolvedValue(
|
||||
jsonResponse({ insights: { dailyBriefing: null }, cached: false }),
|
||||
);
|
||||
});
|
||||
|
||||
describe("fetchAdvisor — explicit regenerate forces a generation", () => {
|
||||
it("POSTs force: true on the regenerate path", async () => {
|
||||
const result = await fetchAdvisor({ force: true });
|
||||
|
||||
expect(apiFetchRaw).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = apiFetchRaw.mock.calls[0] as [
|
||||
string,
|
||||
{ method: string; body?: string },
|
||||
];
|
||||
expect(url).toBe("/api/insights/generate");
|
||||
expect(init.method).toBe("POST");
|
||||
expect(JSON.parse(init.body ?? "{}")).toEqual({ force: true });
|
||||
expect(result.outcome).toBe("fresh");
|
||||
});
|
||||
|
||||
it("uses a read-only GET (no body, no force) on the read path", async () => {
|
||||
await fetchAdvisor();
|
||||
|
||||
expect(apiFetchRaw).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = apiFetchRaw.mock.calls[0] as [
|
||||
string,
|
||||
{ method: string; body?: string },
|
||||
];
|
||||
expect(url).toBe("/api/insights/generate");
|
||||
expect(init.method).toBe("GET");
|
||||
expect(init.body).toBeUndefined();
|
||||
});
|
||||
|
||||
it("lifts briefingOmittedReason through the payload for the card", async () => {
|
||||
apiFetchRaw.mockResolvedValue(
|
||||
jsonResponse({
|
||||
insights: { dailyBriefing: null },
|
||||
cached: true,
|
||||
briefingOmittedReason: "ungrounded",
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await fetchAdvisor();
|
||||
expect(result.payload?.briefingOmittedReason).toBe("ungrounded");
|
||||
expect(result.payload?.dailyBriefing).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -25,10 +25,13 @@ import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { ListRow } from "@/components/ui/list-row";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { SectionHeading } from "@/components/insights/section-heading";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import {
|
||||
useTranslations,
|
||||
useFormatters,
|
||||
useDisplayTimezone,
|
||||
} from "@/lib/i18n/context";
|
||||
import { formatUpdatedLabel } from "@/lib/i18n/relative-time";
|
||||
import { stripChartTokens } from "@/lib/insights/chart-tokens";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
DailyBriefing as DailyBriefingPayload,
|
||||
@@ -371,7 +374,9 @@ export function DailyBriefing({
|
||||
}: DailyBriefingProps) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const { user } = useAuth();
|
||||
// Issue #490 — the SAME resolved zone `fmt.time` renders in (mirror →
|
||||
// Berlin), so the today/yesterday boundary and the clock can never split.
|
||||
const displayTz = useDisplayTimezone();
|
||||
|
||||
// v1.25.3 — when there is no last good text and the last attempt failed,
|
||||
// point the hint at the lever that matches the failure class. A timeout (the
|
||||
@@ -493,7 +498,7 @@ export function DailyBriefing({
|
||||
t,
|
||||
fmt.dateShort,
|
||||
fmt.time,
|
||||
user?.timezone,
|
||||
displayTz,
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
import Link from "next/link";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import {
|
||||
useTranslations,
|
||||
useFormatters,
|
||||
useDisplayTimezone,
|
||||
} from "@/lib/i18n/context";
|
||||
import { formatUpdatedLabel } from "@/lib/i18n/relative-time";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useModuleEnabled } from "@/hooks/use-module-enabled";
|
||||
@@ -188,6 +192,9 @@ export function HeroStrip({
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const { user } = useAuth();
|
||||
// Issue #490 — the SAME resolved zone `fmt.time` renders in (mirror →
|
||||
// Berlin), so the today/yesterday boundary and the clock can never split.
|
||||
const displayTz = useDisplayTimezone();
|
||||
|
||||
// v1.21.2 (A5) — localise the Tension Verdict's contributor keys into the
|
||||
// card's already-localised `{ positive, negative }` shape. Only forwarded when
|
||||
@@ -237,7 +244,7 @@ export function HeroStrip({
|
||||
// hero no longer reads relative while the cards below it read absolute. The
|
||||
// day boundary follows the user's profile timezone.
|
||||
const generatedLine = updatedAt
|
||||
? formatUpdatedLabel(updatedAt, t, fmt.dateShort, fmt.time, user?.timezone)
|
||||
? formatUpdatedLabel(updatedAt, t, fmt.dateShort, fmt.time, displayTz)
|
||||
: null;
|
||||
|
||||
return (
|
||||
|
||||
@@ -5,12 +5,15 @@ import Link from "next/link";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import {
|
||||
useTranslations,
|
||||
useFormatters,
|
||||
useDisplayTimezone,
|
||||
} from "@/lib/i18n/context";
|
||||
import { formatUpdatedLabel } from "@/lib/i18n/relative-time";
|
||||
import { stripChartTokens } from "@/lib/insights/chart-tokens";
|
||||
import { ProseBlocks } from "@/components/insights/prose-blocks";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
import { AskCoachIconButton } from "@/components/insights/ask-coach-action";
|
||||
import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context";
|
||||
@@ -289,7 +292,9 @@ function StatusBody({ text }: { text: string }) {
|
||||
function LastUpdatedFooter({ updatedAt }: { updatedAt: string | null }) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const { user } = useAuth();
|
||||
// Issue #490 — boundary zone == the zone `fmt.time` renders in (mirror →
|
||||
// Berlin), so the today/yesterday bucket and the clock can never split.
|
||||
const displayTz = useDisplayTimezone();
|
||||
if (!updatedAt) return null;
|
||||
return (
|
||||
// v1.11.5 — right-aligned so the timestamp tucks to the trailing edge
|
||||
@@ -300,13 +305,7 @@ function LastUpdatedFooter({ updatedAt }: { updatedAt: string | null }) {
|
||||
// matching the briefing + per-metric cards. The day boundary follows the
|
||||
// user's profile timezone, not the browser's.
|
||||
<p className="text-muted-foreground text-right text-xs">
|
||||
{formatUpdatedLabel(
|
||||
updatedAt,
|
||||
t,
|
||||
fmt.dateShort,
|
||||
fmt.time,
|
||||
user?.timezone,
|
||||
)}
|
||||
{formatUpdatedLabel(updatedAt, t, fmt.dateShort, fmt.time, displayTz)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -4,10 +4,13 @@ import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { CalendarRange } from "lucide-react";
|
||||
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import {
|
||||
useTranslations,
|
||||
useFormatters,
|
||||
useDisplayTimezone,
|
||||
} from "@/lib/i18n/context";
|
||||
import { formatUpdatedLabel } from "@/lib/i18n/relative-time";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { SectionHeading } from "@/components/insights/section-heading";
|
||||
@@ -77,7 +80,9 @@ export function PeriodNarrativeCard({
|
||||
}: PeriodNarrativeCardProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const { user } = useAuth();
|
||||
// Issue #490 — the SAME resolved zone `fmt.time` renders in (mirror →
|
||||
// Berlin), so the today/yesterday boundary and the clock can never split.
|
||||
const displayTz = useDisplayTimezone();
|
||||
const [period, setPeriod] = useState<NarrativePeriod>("week");
|
||||
|
||||
const query = useQuery({
|
||||
@@ -197,7 +202,7 @@ export function PeriodNarrativeCard({
|
||||
t,
|
||||
fmt.dateShort,
|
||||
fmt.time,
|
||||
user?.timezone,
|
||||
displayTz,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -261,11 +261,7 @@ function RationaleCard({
|
||||
compliance get dedicated wrappers; everything else routes
|
||||
through HealthChart with the metric-key vocabulary. */}
|
||||
{isMoodMetric(metricType) ? (
|
||||
<MoodChart
|
||||
mini
|
||||
windowOverride={rationale.dataWindow}
|
||||
userTimezone={user?.timezone}
|
||||
/>
|
||||
<MoodChart mini windowOverride={rationale.dataWindow} />
|
||||
) : isComplianceMetric(metricType) ? null : chartTypes.length > 0 ? (
|
||||
<HealthChart
|
||||
types={chartTypes}
|
||||
|
||||
@@ -112,7 +112,7 @@ function MetricChart({
|
||||
const { user } = useAuth();
|
||||
const userTimezone = user?.timezone;
|
||||
if (config.kind === "mood") {
|
||||
return <MoodChart title={title} mini userTimezone={userTimezone} />;
|
||||
return <MoodChart title={title} mini />;
|
||||
}
|
||||
return (
|
||||
<HealthChartDynamicMini
|
||||
|
||||
@@ -92,8 +92,12 @@ export interface InsightAdvisorPayload {
|
||||
* v1.28.28 (#470) — set by the POST when the number-grounding gate stripped
|
||||
* the freshly generated briefing (after its one corrective retry). Without
|
||||
* it the 200 carried a silently-null briefing and the card's "no briefing
|
||||
* yet" made the regenerate button read as doing nothing. Only ever present
|
||||
* on a force-regenerate response; absent on the read path.
|
||||
* yet" made the regenerate button read as doing nothing.
|
||||
*
|
||||
* v1.28.30 — also carried by the read-only GET, backed by a dated
|
||||
* server-side marker: a briefing the nightly warm stripped now renders
|
||||
* the same honest "withheld" state on every read until the next
|
||||
* successful generation, not only on the regenerate response.
|
||||
*/
|
||||
briefingOmittedReason?: "ungrounded" | null;
|
||||
}
|
||||
@@ -169,7 +173,14 @@ interface AdvisorFetchResult {
|
||||
outcome: AdvisorFetchOutcome;
|
||||
}
|
||||
|
||||
async function fetchAdvisor(
|
||||
/**
|
||||
* Exported as a test seam (like `nextAdvisorPollInterval`): the explicit
|
||||
* regenerate MUST post `force: true` — generation happens on the nightly
|
||||
* cron and on explicit intent only, and a force-less button silently
|
||||
* re-reads the 24 h cache (the "button does nothing" half of the
|
||||
* no-briefing-today chain). The wire-contract test pins it.
|
||||
*/
|
||||
export async function fetchAdvisor(
|
||||
options: { force?: boolean } = {},
|
||||
): Promise<AdvisorFetchResult> {
|
||||
const controller = new AbortController();
|
||||
@@ -409,9 +420,10 @@ export function useInsightsAdvisorQuery(
|
||||
generationFailed: query.data?.payload?.generationFailed ?? false,
|
||||
// Absent → no specific lever hint; the generic failed-description holds.
|
||||
generationFailureClass: query.data?.payload?.generationFailureClass ?? null,
|
||||
// v1.28.28 (#470) — only a force-regenerate response carries the field
|
||||
// (setQueryData writes that response into this cache); the read path's
|
||||
// GET never does, so the hint clears on the next successful read.
|
||||
// v1.28.28 (#470) — a force-regenerate response carries the field via
|
||||
// setQueryData; since v1.28.30 the GET carries it too (marker-backed),
|
||||
// so the "withheld" state persists across reads and clears on the next
|
||||
// successful generation.
|
||||
briefingOmittedReason: query.data?.payload?.briefingOmittedReason ?? null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { resolveKey } from "@/lib/i18n/resolve-key";
|
||||
|
||||
/**
|
||||
* The Vorsorge reminder type labels + the document bulk-share copy resolve
|
||||
* through DYNAMIC keys (`t(\`measurementReminders.types.${type}\`)`), which the
|
||||
* static i18n call-site coverage guard cannot see. This test asserts the
|
||||
* dynamic keys exist and resolve to a non-empty string in EVERY locale bundle —
|
||||
* the guard that would have caught the missing WHO5_SCORE / SCI_SCORE labels
|
||||
* (which rendered as the raw key prefix "measurementReminders.types.…").
|
||||
*/
|
||||
const MESSAGES_DIR = join(__dirname, "../../../../messages");
|
||||
|
||||
const LOCALES = readdirSync(MESSAGES_DIR)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.map((f) => ({
|
||||
locale: f.replace(/\.json$/, ""),
|
||||
messages: JSON.parse(readFileSync(join(MESSAGES_DIR, f), "utf8")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
}));
|
||||
|
||||
// The keys that MUST resolve in every locale (dynamic-key floor).
|
||||
const REQUIRED_KEYS = [
|
||||
// Newly added Vorsorge reminder type labels.
|
||||
"measurementReminders.types.WAIST_CIRCUMFERENCE",
|
||||
"measurementReminders.types.WHO5_SCORE",
|
||||
"measurementReminders.types.SCI_SCORE",
|
||||
// Pre-existing screening labels (regression floor).
|
||||
"measurementReminders.types.PHQ9_SCORE",
|
||||
"measurementReminders.types.GAD7_SCORE",
|
||||
// Bulk-share copy.
|
||||
"documents.bulk.share",
|
||||
"documents.bulk.shareTooMany",
|
||||
"documents.share.multiTitle",
|
||||
"documents.share.multiLabel",
|
||||
];
|
||||
|
||||
describe("measurement-reminder + bulk-share dynamic i18n keys", () => {
|
||||
it("discovers all six shipped locales", () => {
|
||||
expect(LOCALES.map((l) => l.locale).sort()).toEqual([
|
||||
"de",
|
||||
"en",
|
||||
"es",
|
||||
"fr",
|
||||
"it",
|
||||
"pl",
|
||||
]);
|
||||
});
|
||||
|
||||
for (const { locale, messages } of LOCALES) {
|
||||
for (const key of REQUIRED_KEYS) {
|
||||
it(`resolves ${key} in ${locale}`, () => {
|
||||
const value = resolveKey(messages, key);
|
||||
expect(value, `${key} missing in ${locale}.json`).toBeTypeOf("string");
|
||||
expect((value ?? "").trim().length).toBeGreaterThan(0);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -40,7 +40,11 @@ import {
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
useFormatters,
|
||||
useTranslations,
|
||||
useDisplayTimezone,
|
||||
} from "@/lib/i18n/context";
|
||||
import { relativeCalendarDate } from "@/lib/i18n/relative-time";
|
||||
import { startOfLocalDayInTz } from "@/lib/tz/local-day";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -113,6 +117,7 @@ const TYPE_GROUPS = [
|
||||
"TOTAL_BODY_WATER",
|
||||
"VISCERAL_FAT",
|
||||
"BODY_MASS_INDEX",
|
||||
"WAIST_CIRCUMFERENCE",
|
||||
],
|
||||
},
|
||||
// v1.27.6 — the mental-wellbeing screenings become plannable like any
|
||||
@@ -804,6 +809,9 @@ function VorsorgeCard({
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
// Issue #490 — day-boundary zone for the relative "today / yesterday"
|
||||
// bucket must match the zone `fmt.date` renders in (mirror → Berlin).
|
||||
const displayTz = useDisplayTimezone();
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const due = relativeDueKey(reminder.nextDueAt, now);
|
||||
const cadence =
|
||||
@@ -1017,7 +1025,12 @@ function VorsorgeCard({
|
||||
// the same `medications.today` / `medications.yesterday` keys the medication
|
||||
// card renders. Falls back to "—" when there is no last-done value.
|
||||
const lastValue = reminder.lastSatisfiedAt
|
||||
? relativeCalendarDate(reminder.lastSatisfiedAt, t, (d) => fmt.date(d))
|
||||
? relativeCalendarDate(
|
||||
reminder.lastSatisfiedAt,
|
||||
t,
|
||||
(d) => fmt.date(d),
|
||||
displayTz,
|
||||
)
|
||||
: "—";
|
||||
|
||||
// v1.18.9 (#39) — the next-due status reads as DISCREET COLOURED TEXT (never
|
||||
@@ -1100,6 +1113,10 @@ function VorsorgeCard({
|
||||
|
||||
{reminder.endsOn && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{/* `endsOn` is a real instant (`now + courseDays` at Coach
|
||||
accept, plain timestamptz — NOT a `@db.Date` UTC-midnight
|
||||
calendar date), so the profile-tz formatter renders the
|
||||
correct local course-end day for every zone. */}
|
||||
{t("measurementReminders.until", {
|
||||
date: fmt.date(new Date(reminder.endsOn)),
|
||||
})}
|
||||
|
||||
@@ -77,7 +77,12 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import {
|
||||
useTranslations,
|
||||
useFormatters,
|
||||
useDateFormatPreference,
|
||||
} from "@/lib/i18n/context";
|
||||
import { formatDateWithWeekday } from "@/lib/date-format";
|
||||
import {
|
||||
invalidateKeys,
|
||||
medicationDependentKeys,
|
||||
@@ -152,8 +157,9 @@ export function DoseHistoryLedger({
|
||||
schedules,
|
||||
windowDays = 90,
|
||||
}: DoseHistoryLedgerProps) {
|
||||
const { t } = useTranslations();
|
||||
const { t, locale } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const dateFormatPref = useDateFormatPreference();
|
||||
const { user } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const timeZone = user?.timezone || "Europe/Berlin";
|
||||
@@ -437,7 +443,12 @@ export function DoseHistoryLedger({
|
||||
data-day={group.dayKey}
|
||||
>
|
||||
<h3 className="text-muted-foreground px-1 text-xs font-medium">
|
||||
{fmt.dateWithWeekday(new Date(`${group.dayKey}T00:00:00`))}
|
||||
{/* Issue #490 — `dayKey` is a profile-timezone calendar day.
|
||||
The old browser-local-midnight parse re-interpreted it in
|
||||
the DEVICE zone and could shift the heading a day when
|
||||
browser ≠ profile zone; the day-key label renders
|
||||
UTC-pinned instead (zone-independent, weekday included). */}
|
||||
{formatDateWithWeekday(group.dayKey, dateFormatPref, locale)}
|
||||
</h3>
|
||||
<ul className="border-border/60 divide-border/60 divide-y rounded-md border">
|
||||
{group.rows.map((row, i) => (
|
||||
|
||||
@@ -9,7 +9,11 @@ import { MedicationStateBadges } from "@/components/medications/card-parts/medic
|
||||
import { MedicationCardBody } from "@/components/medications/card-parts/medication-card-body";
|
||||
import { useMedicationIntake } from "@/components/medications/use-medication-intake";
|
||||
import { useWeekdayLabel } from "@/components/medications/card-parts/medication-next-last-slot";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import {
|
||||
useTranslations,
|
||||
useFormatters,
|
||||
useDisplayTimezone,
|
||||
} from "@/lib/i18n/context";
|
||||
import {
|
||||
invalidateKeys,
|
||||
medicationDependentKeys,
|
||||
@@ -19,6 +23,7 @@ import { apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
import { formatDateTime, formatTime } from "@/lib/format";
|
||||
import { getMedicationCategoryLabel } from "@/lib/medications/category-label";
|
||||
import { formatDose } from "@/lib/medications/format-dose";
|
||||
import { getDayOfWeekInTz } from "@/lib/tz/local-day";
|
||||
import { type InjectionSiteKey } from "@/lib/medications/injection-sites";
|
||||
import { LogInjectionSiteDialog } from "@/components/medications/log-injection-site-dialog";
|
||||
import { useGlobalExcludedInjectionSites } from "@/lib/medications/use-injection-site-prefs";
|
||||
@@ -185,6 +190,11 @@ export function Glp1MedicationCard({
|
||||
const { user } = useAuth();
|
||||
const userTz = user?.timezone || "Europe/Berlin";
|
||||
const fmt = useFormatters();
|
||||
// Issue #490 — the zone `fmt` renders dates in (mirror → Berlin). The
|
||||
// weekday name paired with `fmt.dateShort` must be derived in this SAME
|
||||
// zone: `Date.getDay()` reads the BROWSER zone and could name a weekday
|
||||
// that contradicts the date printed next to it.
|
||||
const displayTz = useDisplayTimezone();
|
||||
const weekdayLabel = useWeekdayLabel();
|
||||
const [, forceUpdate] = useReducer((x: number) => x + 1, 0);
|
||||
// v1.8.5 — post-dose injection-site prompt (see medication-card.tsx).
|
||||
@@ -403,7 +413,7 @@ export function Glp1MedicationCard({
|
||||
if (!next) return null;
|
||||
if (next.daysAway === 0) return t("medications.glp1NextInjectionToday");
|
||||
if (next.daysAway === 1) return t("medications.glp1NextInjectionTomorrow");
|
||||
const dayName = weekdayLabel(next.date.getDay());
|
||||
const dayName = weekdayLabel(getDayOfWeekInTz(next.date, displayTz));
|
||||
const dateShort = fmt.dateShort(next.date);
|
||||
return t("medications.glp1NextInjectionDays", {
|
||||
label: `${dayName}, ${dateShort}`,
|
||||
@@ -453,7 +463,7 @@ export function Glp1MedicationCard({
|
||||
medication.nextDueOverdue && next
|
||||
? diffDays(next.date, now) === 0
|
||||
? formatTime(next.date.toISOString())
|
||||
: `${weekdayLabel(next.date.getDay())}, ${fmt.dateShort(next.date)}`
|
||||
: `${weekdayLabel(getDayOfWeekInTz(next.date, displayTz))}, ${fmt.dateShort(next.date)}`
|
||||
: null;
|
||||
|
||||
const nextLine =
|
||||
|
||||
@@ -21,7 +21,12 @@ import {
|
||||
summariseCadence,
|
||||
type MedicationPayload,
|
||||
} from "@/components/medications/wizard/wizard-payload";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import {
|
||||
useTranslations,
|
||||
useFormatters,
|
||||
useDateFormatPreference,
|
||||
} from "@/lib/i18n/context";
|
||||
import { formatDate as formatCalendarDate } from "@/lib/date-format";
|
||||
|
||||
export interface MedicationDetailSummaryProps {
|
||||
name: string;
|
||||
@@ -58,8 +63,9 @@ export function MedicationDetailSummary({
|
||||
asNeeded = false,
|
||||
startsOn,
|
||||
}: MedicationDetailSummaryProps) {
|
||||
const { t } = useTranslations();
|
||||
const { t, locale } = useTranslations();
|
||||
const formatters = useFormatters();
|
||||
const dateFormatPref = useDateFormatPreference();
|
||||
const status = resolveStatus(active, endsOn);
|
||||
|
||||
const statusLabel =
|
||||
@@ -80,10 +86,15 @@ export function MedicationDetailSummary({
|
||||
? "bg-warning"
|
||||
: "bg-muted-foreground";
|
||||
|
||||
// Issue #490 — `Medication.startsOn` is a `@db.Date` calendar date
|
||||
// (serialised as UTC midnight). The old `formatters.dateTime(startsOn)`
|
||||
// re-read that instant in the display zone: it invented a meaningless
|
||||
// clock time and shifted the day for zones west of UTC. A calendar date
|
||||
// renders UTC-pinned (date only), correct in every zone.
|
||||
const cadenceLine = oneShot
|
||||
? startsOn
|
||||
? t("medications.detail.cadence.oneShotOn", {
|
||||
date: formatters.dateTime(startsOn),
|
||||
date: formatCalendarDate(new Date(startsOn), dateFormatPref, locale),
|
||||
})
|
||||
: t("medications.detail.cadence.oneShotPending")
|
||||
: asNeeded
|
||||
|
||||
@@ -215,6 +215,12 @@ export function ScheduleHistoryTimeline({
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{/* `validFrom` / `validUntil` (like `currentSince`
|
||||
above) are real era-boundary instants
|
||||
(`@db.Timestamptz`, the moment an edit archived the
|
||||
plan) — NOT `@db.Date` calendar dates — so the
|
||||
profile-timezone formatter renders the correct
|
||||
local day (issue #490 audit). */}
|
||||
<p className="text-muted-foreground text-xs tabular-nums">
|
||||
{fmt.date(revision.validFrom)}
|
||||
{" – "}
|
||||
|
||||
@@ -40,7 +40,11 @@ import {
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { MedicationCardHeader } from "@/components/medications/medication-card-header";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
useFormatters,
|
||||
useTranslations,
|
||||
useDisplayTimezone,
|
||||
} from "@/lib/i18n/context";
|
||||
import { relativeCalendarDate } from "@/lib/i18n/relative-time";
|
||||
import {
|
||||
INSTRUMENTS,
|
||||
@@ -67,6 +71,9 @@ export function InstrumentCard({
|
||||
}) {
|
||||
const { t, locale } = useTranslations();
|
||||
const { date: formatDate } = useFormatters();
|
||||
// Issue #490 — day-boundary zone for the relative "today / yesterday"
|
||||
// bucket must match the zone `formatDate` renders in (mirror → Berlin).
|
||||
const displayTz = useDisplayTimezone();
|
||||
const def = INSTRUMENTS[instrument];
|
||||
const key = def.i18nKey;
|
||||
const title = t(`mentalHealth.instrument.${key}`);
|
||||
@@ -139,7 +146,7 @@ export function InstrumentCard({
|
||||
{t("mentalHealth.lastResult")}
|
||||
</span>
|
||||
<span className="text-foreground text-right">
|
||||
{relativeCalendarDate(last.takenAt, t, formatDate)}
|
||||
{relativeCalendarDate(last.takenAt, t, formatDate, displayTz)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground flex items-baseline justify-between gap-3">
|
||||
|
||||
@@ -11,7 +11,11 @@
|
||||
* deliberate click, never pushed at the moment someone arrives to check in.
|
||||
*/
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
useFormatters,
|
||||
useTranslations,
|
||||
useDisplayTimezone,
|
||||
} from "@/lib/i18n/context";
|
||||
import { relativeCalendarDate } from "@/lib/i18n/relative-time";
|
||||
import { INSTRUMENTS } from "@/lib/mental-health/instruments";
|
||||
|
||||
@@ -31,6 +35,9 @@ export function InstrumentDetail({
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { date: formatDate } = useFormatters();
|
||||
// Issue #490 — day-boundary zone for the relative "today / yesterday"
|
||||
// bucket must match the zone `formatDate` renders in (mirror → Berlin).
|
||||
const displayTz = useDisplayTimezone();
|
||||
const def = INSTRUMENTS[instrument];
|
||||
const last = rows.find((r) => r.instrument === instrument);
|
||||
|
||||
@@ -45,7 +52,7 @@ export function InstrumentDetail({
|
||||
{t("mentalHealth.lastResult")}
|
||||
</span>
|
||||
<span className="text-foreground text-right">
|
||||
{relativeCalendarDate(last.takenAt, t, formatDate)}
|
||||
{relativeCalendarDate(last.takenAt, t, formatDate, displayTz)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground flex items-baseline justify-between gap-3">
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* v1.28 — `<NutrientIntakeCard>` render contract.
|
||||
*
|
||||
* The read-only Settings → Sources list for the opt-in `nutrients`
|
||||
* module: renders NOTHING while the module is off (the Modules hub is
|
||||
* the consent surface), and a flat name / latest-total / days-count
|
||||
* list when it is on. Test strategy mirrors `modules-section.test.tsx`:
|
||||
* mock `@tanstack/react-query` + `useAuth`, render under SSR.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { AuthUser } from "@/hooks/use-auth";
|
||||
|
||||
interface QueryState {
|
||||
data?: unknown;
|
||||
isLoading: boolean;
|
||||
}
|
||||
let queryState: QueryState = { data: undefined, isLoading: false };
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => queryState,
|
||||
}));
|
||||
|
||||
const authSpy = vi.fn<() => { user: AuthUser | null }>();
|
||||
vi.mock("@/hooks/use-auth", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/hooks/use-auth")>(
|
||||
"@/hooks/use-auth",
|
||||
);
|
||||
return { ...actual, useAuth: () => authSpy() };
|
||||
});
|
||||
|
||||
import { NutrientIntakeCard } from "../nutrient-intake-card";
|
||||
|
||||
function user(modules: Record<string, boolean>): AuthUser {
|
||||
return { id: "user-1", modules } as unknown as AuthUser;
|
||||
}
|
||||
|
||||
function render(): string {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<NutrientIntakeCard />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
queryState = { data: undefined, isLoading: false };
|
||||
authSpy.mockReturnValue({ user: user({ nutrients: true }) });
|
||||
});
|
||||
|
||||
describe("<NutrientIntakeCard>", () => {
|
||||
it("renders nothing while the nutrients module is off", () => {
|
||||
authSpy.mockReturnValue({ user: user({ nutrients: false }) });
|
||||
expect(render()).toBe("");
|
||||
|
||||
authSpy.mockReturnValue({ user: user({}) });
|
||||
expect(render()).toBe("");
|
||||
});
|
||||
|
||||
it("renders the synced list with name, latest total (µg rendered), and day count", () => {
|
||||
queryState = {
|
||||
isLoading: false,
|
||||
data: {
|
||||
windowDays: 14,
|
||||
nutrients: [
|
||||
{
|
||||
nutrient: "vitamin_d",
|
||||
unit: "ug",
|
||||
latestDay: "2026-07-13",
|
||||
latestAmount: 22.5,
|
||||
daysWithData: 5,
|
||||
},
|
||||
{
|
||||
nutrient: "caffeine",
|
||||
unit: "mg",
|
||||
latestDay: "2026-07-13",
|
||||
latestAmount: 310,
|
||||
daysWithData: 14,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const html = render();
|
||||
expect(html).toContain('data-slot="nutrient-intake-card"');
|
||||
expect(html).toContain("Vitamin D");
|
||||
expect(html).toContain("Caffeine");
|
||||
// Wire `ug` renders as µg; the day key renders verbatim.
|
||||
expect(html).toContain("µg");
|
||||
expect(html).toContain("2026-07-13");
|
||||
const rows = html.match(/data-slot="nutrient-intake-row"/g) ?? [];
|
||||
expect(rows).toHaveLength(2);
|
||||
// Read-only surface: the only interactive element is the modules link.
|
||||
expect(html).toContain('data-slot="nutrient-intake-modules-link"');
|
||||
expect(html).not.toContain("<button");
|
||||
});
|
||||
|
||||
it("renders the empty state when the module is on but nothing has synced", () => {
|
||||
queryState = {
|
||||
isLoading: false,
|
||||
data: { windowDays: 14, nutrients: [] },
|
||||
};
|
||||
const html = render();
|
||||
expect(html).toContain("No nutrient data has been synced yet.");
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,7 @@ import { AboutMeSection } from "@/components/settings/about-me-section";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { TimezonePicker } from "@/components/settings/timezone-picker";
|
||||
import { storeTimezone } from "@/lib/timezone-mirror";
|
||||
import { TimeFormatSelect } from "@/components/settings/time-format-select";
|
||||
import { DateFormatSelect } from "@/components/settings/date-format-select";
|
||||
import { UnitPreferenceSelect } from "@/components/settings/unit-preference-select";
|
||||
@@ -142,6 +143,13 @@ export function AccountSection() {
|
||||
]);
|
||||
|
||||
if (profileRes.ok && tzRes.ok) {
|
||||
// Instant same-tab flip of every rendered timestamp (issue #490):
|
||||
// mirror the accepted zone before the `/me` refetch confirms it —
|
||||
// the same synchronous-update pattern the time/date-format selects
|
||||
// use. `refetch()` → `fetchMe` re-writes the mirror authoritatively.
|
||||
if (user && timezone && timezone !== user.timezone) {
|
||||
storeTimezone(timezone);
|
||||
}
|
||||
setSaveMsg({ key: "settings.profileSaved" });
|
||||
setSaveMsgType("success");
|
||||
await refetch();
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
FileScan,
|
||||
FileText,
|
||||
MessageCircleHeart,
|
||||
Leaf,
|
||||
Moon,
|
||||
Plug,
|
||||
Pill,
|
||||
@@ -96,6 +97,8 @@ const MODULE_ICONS: Record<ModuleKey, LucideIcon> = {
|
||||
inboundDocuments: FileScan,
|
||||
// v1.25.0 — opt-in mental-health screeners (PHQ-9 / GAD-7).
|
||||
mentalHealth: Brain,
|
||||
// v1.28 — opt-in micronutrient-intake sync (Apple Health day totals).
|
||||
nutrients: Leaf,
|
||||
};
|
||||
|
||||
export function ModulesSection() {
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* `<NutrientIntakeCard>` — Settings → Sources, read-only synced-nutrient
|
||||
* list (v1.28).
|
||||
*
|
||||
* The smallest honest surface for the opt-in `nutrients` module: a flat
|
||||
* list of what the server holds — per nutrient the latest synced day
|
||||
* total and the days-with-data count over the last 14 days — so the
|
||||
* user can verify the sync works and see exactly what the account
|
||||
* stores. No route slug, no nav entry, no chart, no edit affordance;
|
||||
* the module toggle in the Modules hub is the consent surface and the
|
||||
* card links to it. Rendered only when the module is on (resolved
|
||||
* module map from `useAuth().user.modules`, the environment-section
|
||||
* pattern); reads unwrap through `apiGet`, the key rides the
|
||||
* centralized factory.
|
||||
*/
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { Leaf } from "lucide-react";
|
||||
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
const WINDOW_DAYS = 14;
|
||||
|
||||
/** Wire `ug` renders as µg; mg / ml pass through. */
|
||||
const UNIT_LABELS: Record<string, string> = { mg: "mg", ug: "µg", ml: "ml" };
|
||||
|
||||
interface NutrientOverviewRow {
|
||||
nutrient: string;
|
||||
unit: string;
|
||||
latestDay: string;
|
||||
latestAmount: number;
|
||||
daysWithData: number;
|
||||
}
|
||||
|
||||
interface NutrientIntakeOverview {
|
||||
windowDays: number;
|
||||
nutrients: NutrientOverviewRow[];
|
||||
}
|
||||
|
||||
function formatAmount(amount: number): string {
|
||||
// Day totals arrive as floats; one decimal is plenty for a glance
|
||||
// list and trailing zeros read as noise.
|
||||
return String(Math.round(amount * 10) / 10);
|
||||
}
|
||||
|
||||
export function NutrientIntakeCard() {
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
|
||||
const enabled = user?.modules?.nutrients === true;
|
||||
|
||||
const overview = useQuery({
|
||||
queryKey: queryKeys.nutrientIntake(WINDOW_DAYS),
|
||||
enabled,
|
||||
queryFn: () =>
|
||||
apiGet<NutrientIntakeOverview>(`/api/nutrients?days=${WINDOW_DAYS}`),
|
||||
});
|
||||
|
||||
if (!enabled) return null;
|
||||
|
||||
const rows = overview.data?.nutrients ?? [];
|
||||
|
||||
return (
|
||||
<SettingsCard data-slot="nutrient-intake-card">
|
||||
<SettingsCardHeader
|
||||
icon={Leaf}
|
||||
title={t("settings.sections.sources.nutrients.title")}
|
||||
description={t("settings.sections.sources.nutrients.description", {
|
||||
days: WINDOW_DAYS,
|
||||
})}
|
||||
className="mb-3"
|
||||
/>
|
||||
{overview.isLoading ? (
|
||||
<div className="space-y-1 pl-7" aria-hidden="true">
|
||||
<Skeleton className="h-9 w-full rounded-md" />
|
||||
<Skeleton className="h-9 w-full rounded-md" />
|
||||
<Skeleton className="h-9 w-full rounded-md" />
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<p className="text-muted-foreground pl-7 text-sm">
|
||||
{t("settings.sections.sources.nutrients.empty")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-border divide-y rounded-md border" role="list">
|
||||
{rows.map((row) => (
|
||||
<li
|
||||
key={row.nutrient}
|
||||
data-slot="nutrient-intake-row"
|
||||
className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5 p-2"
|
||||
>
|
||||
<span className="text-sm">
|
||||
{t(`nutrients.names.${row.nutrient}`)}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{t("settings.sections.sources.nutrients.latest", {
|
||||
amount: formatAmount(row.latestAmount),
|
||||
unit: UNIT_LABELS[row.unit] ?? row.unit,
|
||||
date: row.latestDay,
|
||||
})}
|
||||
{" · "}
|
||||
{t("settings.sections.sources.nutrients.daysWithData", {
|
||||
count: row.daysWithData,
|
||||
days: WINDOW_DAYS,
|
||||
})}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<p className="text-muted-foreground mt-3 text-xs">
|
||||
{t("settings.sections.sources.nutrients.moduleHint")}{" "}
|
||||
<Link
|
||||
href="/settings/modules"
|
||||
className="text-primary underline underline-offset-2"
|
||||
data-slot="nutrient-intake-modules-link"
|
||||
>
|
||||
{t("settings.sections.sources.nutrients.moduleHintLink")}
|
||||
</Link>
|
||||
</p>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -44,6 +44,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { NutrientIntakeCard } from "@/components/settings/nutrient-intake-card";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
@@ -580,6 +581,10 @@ export function SourcesSection() {
|
||||
</Link>
|
||||
</p>
|
||||
</SettingsCard>
|
||||
|
||||
{/* v1.28 — read-only synced-nutrient list for the opt-in `nutrients`
|
||||
module (renders nothing while the module is off). */}
|
||||
<NutrientIntakeCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -193,6 +193,69 @@ describe("<ResponsiveSheet>", () => {
|
||||
expect(html).toContain("Edit medication");
|
||||
});
|
||||
|
||||
it("reserves the close-button gutter on the Sheet header by default", () => {
|
||||
mobile = true;
|
||||
const html = renderToStaticMarkup(
|
||||
<ResponsiveSheet open onOpenChange={() => {}} title="Detail">
|
||||
<p>body</p>
|
||||
</ResponsiveSheet>,
|
||||
);
|
||||
// Default showCloseButton=true → the primitive paints its absolute X, so
|
||||
// the header reserves the right gutter for it.
|
||||
expect(html).toContain("pr-12");
|
||||
mobile = false;
|
||||
});
|
||||
|
||||
it("drops the Sheet header gutter when showCloseButton is false", () => {
|
||||
mobile = true;
|
||||
const html = renderToStaticMarkup(
|
||||
<ResponsiveSheet
|
||||
open
|
||||
onOpenChange={() => {}}
|
||||
title="Detail"
|
||||
showCloseButton={false}
|
||||
headerAction={<button type="button">X</button>}
|
||||
>
|
||||
<p>body</p>
|
||||
</ResponsiveSheet>,
|
||||
);
|
||||
// No primitive close button → no reserved gutter; the headerAction sits
|
||||
// flush to the header's p-4 right edge.
|
||||
expect(html).not.toContain("pr-12");
|
||||
mobile = false;
|
||||
});
|
||||
|
||||
it("reserves the Dialog header gutter for headerAction by default", () => {
|
||||
mobile = false;
|
||||
const html = renderToStaticMarkup(
|
||||
<ResponsiveSheet
|
||||
open
|
||||
onOpenChange={() => {}}
|
||||
title="Detail"
|
||||
headerAction={<button type="button">X</button>}
|
||||
>
|
||||
<p>body</p>
|
||||
</ResponsiveSheet>,
|
||||
);
|
||||
expect(html).toContain("pr-9");
|
||||
});
|
||||
|
||||
it("drops the Dialog header gutter when showCloseButton is false", () => {
|
||||
mobile = false;
|
||||
const html = renderToStaticMarkup(
|
||||
<ResponsiveSheet
|
||||
open
|
||||
onOpenChange={() => {}}
|
||||
title="Detail"
|
||||
showCloseButton={false}
|
||||
headerAction={<button type="button">X</button>}
|
||||
>
|
||||
<p>body</p>
|
||||
</ResponsiveSheet>,
|
||||
);
|
||||
expect(html).not.toContain("pr-9");
|
||||
});
|
||||
|
||||
it("does not throw when onOpenChange is invoked (controlled-state plumbing)", () => {
|
||||
mobile = false;
|
||||
const onOpenChange = vi.fn();
|
||||
|
||||
Loaded 100 of 211 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user