Merge iOS server asks and client work into release/v1.42.0

# Conflicts:
#	docs/api/openapi.yaml
#	src/lib/openapi/routes/auth.ts
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 11:27:42 +02:00
commit 7eb5659ab9
87 files changed
+4294 -548

No files matched your search

+5 -4
View File
@@ -1,12 +1,13 @@
{
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch — the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands — here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged. Raised to 3700 at v1.37.32 for the seventh interface language (Korean): the lazy message catalogs went 6->7 chunks (1026 KB gz measured), pushing the aggregate to 3631 against the old 3500. The raise is entirely the new catalog; no route budget moved, and the four eager route ceilings are unchanged and still passing. Most of the aggregate is the dynamic locale-switch and fallback path that a first paint never loads (see issue #253), so this ceiling guards bytes almost no visitor requests; 3700 leaves ~69 KB for ordinary drift until that double-count is cleaned up. v1.38.14 re-measures the four eager routes and changes what the number means. The ceilings had been re-stated but never re-based since v1.37.4, so /inLine truncated
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch — the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands — here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged. Raised to 3700 at v1.37.32 for the seventh interface language (Korean): the lazy message catalogs went 6->7 chunks (1026 KB gz measured), pushing the aggregate to 3631 against the old 3500. The raise is entirely the new catalog; no route budget moved, and the four eager route ceilings are unchanged and still passing. Most of the aggregate is the dynamic locale-switch and fallback path that a first paint never loads (see issue #253), so this ceiling guards bytes almost no visitor requests; 3700 leaves ~69 KB for ordinary drift until that double-count is cleaned up. v1.38.14 re-measures the four eager routes and changes what the number means. The ceilings had been re-stated but never re-based since v1.37.4, so /inLine truncated
"routeBaselineKbGz": {
"/page": 429,
"/insights/page": 368,
"/insights/page": 304,
"/measurements/page": 411,
"/insights/mood/page": 379
"/insights/mood/page": 379,
"/settings/[section]/page": 403
},
"routeDriftAllowanceKbGz": 20,
"totalClientKbGz": 4182,
"totalClientKbGz": 4335,
"maxRechartsChunks": 1
}
+349 -61
View File
@@ -2644,7 +2644,15 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/PasskeyLoginVerifyResponse"
"401": *a1
"401":
description: The sign-in did not prove anything (v1.42.0 codes; before, the first case answered 500). `meta.errorCode` =
`passkey.challenge.expired` when the challenge is gone — it expired after five minutes, was already used, or
was begun for a different account — so start a new sign-in; `passkey.verification.failed` when the assertion
was checked and refused (signature, origin, relying-party id or challenge mismatch).
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"403":
description: Passkey login is disabled — the operator runs `OIDC_ONLY=true`. `meta.errorCode` = `oidc_only`.
content:
@@ -2652,12 +2660,20 @@ paths:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"404":
description: The assertion resolved to a user row that no longer exists.
description: "`meta.errorCode` = `passkey.unknown` (v1.42.0): no passkey with this credential id is registered on this
server — it was removed here, or belongs to another server. Tell the person to sign in another way. Without
a code: the assertion resolved to a user row that no longer exists."
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422":
description: "`challengeId` or `credential` is missing, or (`meta.errorCode` = `passkey.response.invalid`, v1.42.0)
`credential` is not a WebAuthn authentication response."
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a4
"429": *a5
/api/auth/refresh:
post:
@@ -4491,7 +4507,12 @@ paths:
(`measurement.batch.invalid`, with every issue under `details.issues`), or
(`measurement.batch.source_not_permitted`) carried an entry naming a source, any source, under a narrow
`measurements:write` credential. Nothing was written in any of the three cases, and all three are permanent
for the batch as sent: resending it unchanged is refused the same way."
for the batch as sent: resending it unchanged is refused the same way. Which issues belong to an entry:
every issue caused by one entry has a `path` that starts `entries.<n>` (the entry's index in the request,
then the field, e.g. `entries.3.startDate`, or just `entries.4` when the entry is not an object). Those are
the only per-entry issues, so a client may drop the named entries and resend the rest. An issue whose `path`
is `\"\"` (the body is not an object), `entries` (missing, not an array, or empty) or `syncTrigger` is about
the batch itself, and no split recovers it."
content:
application/json:
schema:
@@ -5125,11 +5146,14 @@ paths:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422":
description: Body validation failed (multi-issue envelope, breadcrumbed as
description: "Body validation failed (multi-issue envelope, breadcrumbed as
`medications.intake.update.validation-failed`), or `meta.errorCode` =
`medications.intake.injection_site.disallowed` when the site is outside the medication's effective allowed
set, or `medications.intake.force_slot.invalid` when `forceSlotInstant` is not a real slot of the
medication. The latter two carry no issue list — branch on the code.
medication, or (v1.42.0) `medication.intake.notTracked` when the event is still open and its medication is
kept as a record only (`trackIntake: false`): resolving or snoozing it would record a new dose that is
counted nowhere. Correcting an event that is already taken or skipped stays open. The coded refusals carry
no issue list — branch on the code."
content:
application/json:
schema:
@@ -6391,7 +6415,16 @@ paths:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"409": *a7
"422": *a4
"422":
description: "Body validation failed (multi-issue envelope), or `meta.errorCode` names the refusal:
`medications.intake.injection_site.disallowed`, `medications.intake.force_slot.invalid`, or (v1.42.0)
`medication.intake.notTracked` when the medication is kept as a record only (`trackIntake: false`). Its
stored history stays; a new dose is refused rather than stored and counted nowhere. The check runs after
ownership, so a foreign medication is still a 404."
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"429": *a12
get:
tags:
@@ -6715,13 +6748,36 @@ paths:
tags:
- Medications
summary: Batched adherence read for every medication of the caller
description: Returns one compact row per non-PRN medication. A scheduled medication with no local schedule remains in
the array with applicable=false and reason NO_LOCAL_SCHEDULE. compliance7/compliance30 stay non-null as all-zero
description: >-
Returns one compact row per non-PRN medication. A scheduled medication with no local schedule remains in the
array with applicable=false and reason NO_LOCAL_SCHEDULE. compliance7/compliance30 stay non-null as all-zero
compatibility placeholders for released clients; aware clients must ignore those percentages when
applicable=false. complianceDisplay is null.
v1.42.0, additive: `meta.aggregate` carries one figure for the whole account (`MedicationComplianceAggregate`,
weighted by expected doses across the applicable medications; `null` when none applies), so a client never
averages the rows itself. `days` (30, 60, 90, 180 or 365) adds a `complianceN` block in the same shape to every
row and to the aggregate, for a report that covers its own window; `meta.days` echoes it. Any other `days` is a
422 rather than a rounded window.
parameters:
- in: query
name: days
schema:
description: Report window in days. 30 is already `compliance30`; the others add `compliance60` / `compliance90` /
`compliance180` / `compliance365`.
type: string
enum:
- "30"
- "60"
- "90"
- "180"
- "365"
description: Report window in days. 30 is already `compliance30`; the others add `compliance60` / `compliance90` /
`compliance180` / `compliance365`.
responses:
"200":
description: One adherence row per medication.
description: One adherence row per medication, and the account aggregate in `meta`.
content:
application/json:
schema:
@@ -8767,9 +8823,10 @@ paths:
summary: Update per-user notification preferences
description: 'Deep-merges the supplied partial shape over the persisted row — a PATCH touching only one category leaves
the siblings intact. Always returns the fully-resolved next state so clients can hard-set their optimistic
update. `medication.clientManaged: true` (or `deliveryDefault: "client"`) suppresses server-side
MEDICATION_REMINDER APNs; `medication.lowStockRunwayDays` (1–60, nullable, default 7) tunes the low-stock alert
— null switches it off. Rate-limit 60/min per user.'
update. `medication.clientManaged: true` (or `deliveryDefault: "client"`) keeps the MEDICATION_REMINDER that
announces a dose off APNs and leaves the follow-ups for a dose still open on it (v1.42.0);
`medication.lowStockRunwayDays` (1–60, nullable, default 7) tunes the low-stock alert — null switches it off.
Rate-limit 60/min per user.'
requestBody:
required: true
content:
@@ -11043,6 +11100,23 @@ paths:
"422": *a4
"429": *a5
/api/admin/maintenance/measurements:
get:
tags:
- Admin
summary: Where measurement table maintenance stands
description: "The newest maintenance run and its state, whether the compaction-tombstone purge is still at work, and the
table's size right now. The admin card polls it while a run is queued or running. Cookie auth only:
`requireAdmin()` refuses every Bearer caller."
responses:
"200":
description: The status.
content:
application/json:
schema:
$ref: "#/components/schemas/AdminMeasurementMaintenanceStatusResponse"
"401": *a1
"422": *a4
"429": *a5
post:
tags:
- Admin
@@ -12620,7 +12694,20 @@ paths:
window the prior aggregate is served immediately while one background recompute warms a new one, so an active
logger never re-pays the cold compute. Module-gated on `mood`; a disabled module answers 403 `module.disabled`
even for a valid Bearer token. Cookie or Bearer auth; the caller is always resolved as themselves, so this read
cannot be delegated to a shared record — which makes it the one mood surface a delegate cannot reach."
cannot be delegated to a shared record — which makes it the one mood surface a delegate cannot reach. v1.42.0 —
`days` cuts `stability` to the period a client shows; every other block is unchanged by it."
parameters:
- in: query
name: days
schema:
description: "Trailing window for `stability`, in days. Omitted: 365. Any other value is a 422."
type: string
enum:
- "30"
- "90"
- "180"
- "365"
description: "Trailing window for `stability`, in days. Omitted: 365. Any other value is a 422."
responses:
"200":
description: The aggregate bundle.
@@ -22369,7 +22456,10 @@ paths:
"422":
description: "Validation failed, with every issue listed rather than the first — an integration operator should see the
whole shape at once. An empty or unstable `idempotencyKey` fails here: the column is unique table-wide, so
an empty key would resolve to whichever unrelated row stored it first."
an empty key would resolve to whichever unrelated row stored it first. Also 422 with `meta.errorCode` =
`medication.intake.notTracked` (v1.42.0) when the named medication is kept as a record only (`trackIntake:
false`); checked after the token's scope, so a token without this medication's scope learns nothing about
it."
content:
application/json:
schema:
@@ -33687,25 +33777,26 @@ components:
measurement.batch.source_not_permitted, measurement.batch.too_large, measurement.bulk-delete.invalid,
measurement.create.invalid, measurement.create.source_not_permitted, measurement.delete.too_large,
measurement.duplicate_timestamp, measurement.restore.invalid, measurement.update.server_owned_source;
medication — medication.intake.import.invalid_format; medications — medications.category.invalid,
medications.category.limitReached, medications.category.unknown, medications.course.currentOpen,
medications.course.invalid, medications.course.invalidRange, medications.course.notFound,
medications.course.oneShot, medications.course.overlap, medications.course.windowRequired,
medications.intake.bulk.apple_health_not_mirrored, medications.intake.bulk.invalid,
medications.intake.bulk.too_large, medications.intake.force_slot.invalid,
medications.intake.force_slot.occupied, medications.intake.injection_site.disallowed,
medications.intake.taken_at.before_start, medications.mirror.limit_exceeded; mentalHealth —
mentalHealth.rateLimited; module — module.disabled; modules — modules.body.invalid_json,
modules.invalid; mood — mood.bulk-delete.invalid, mood.bulk.invalid, mood.bulk.too_large,
mood.create.external_id_not_delegable, mood.create.invalid, mood.duplicate_timestamp,
mood.linkedContext.invalid, mood.list.invalid, mood.not_found, mood.ratedFactor.out_of_range,
mood.restore.invalid, mood.update.invalid; notification-prefs — notification-prefs.body.invalid_json;
nutrient — nutrient.batch.invalid, nutrient.batch.too_large, nutrient.daily.invalid,
nutrient.read.invalid, nutrient.water.invalid, nutrient.water.invalid_day; onboarding —
onboarding.answers.invalid, onboarding.answers.rateLimited, onboarding.complete.invalid,
medication — medication.intake.import.invalid_format, medication.intake.notTracked; medications —
medications.category.invalid, medications.category.limitReached, medications.category.unknown,
medications.course.currentOpen, medications.course.invalid, medications.course.invalidRange,
medications.course.notFound, medications.course.oneShot, medications.course.overlap,
medications.course.windowRequired, medications.intake.bulk.apple_health_not_mirrored,
medications.intake.bulk.invalid, medications.intake.bulk.too_large,
medications.intake.force_slot.invalid, medications.intake.force_slot.occupied,
medications.intake.injection_site.disallowed, medications.intake.taken_at.before_start,
medications.mirror.limit_exceeded; mentalHealth — mentalHealth.rateLimited; module — module.disabled;
modules — modules.body.invalid_json, modules.invalid; mood — mood.bulk-delete.invalid,
mood.bulk.invalid, mood.bulk.too_large, mood.create.external_id_not_delegable, mood.create.invalid,
mood.duplicate_timestamp, mood.linkedContext.invalid, mood.list.invalid, mood.not_found,
mood.ratedFactor.out_of_range, mood.restore.invalid, mood.update.invalid; notification-prefs —
notification-prefs.body.invalid_json; nutrient — nutrient.batch.invalid, nutrient.batch.too_large,
nutrient.daily.invalid, nutrient.read.invalid, nutrient.water.invalid, nutrient.water.invalid_day;
onboarding — onboarding.answers.invalid, onboarding.answers.rateLimited, onboarding.complete.invalid,
onboarding.complete.recordNotFound, onboarding.complete.recordTargetMismatch,
onboarding.disclaimer.invalid, onboarding.restart.invalid, onboarding.restart.rateLimited,
onboarding.tour.invalid; personal_records — personal_records.invalid_query; practitioner —
onboarding.tour.invalid; passkey — passkey.challenge.expired, passkey.response.invalid, passkey.unknown,
passkey.verification.failed; personal_records — personal_records.invalid_query; practitioner —
practitioner.invalid; profile — profile.update.emailInUse, profile.update.emailRateLimited,
profile.update.emailSecondFactorRequired, profile.update.invalidBody, profile.update.nothingSaved;
profile_claim — profile_claim.invalid, profile_claim.oidc_only_unsupported, profile_claim.taken;
@@ -42652,19 +42743,7 @@ components:
accepted once the window rolls over."
type: array
items:
type: object
properties:
path:
type: string
code:
type: string
message:
type: string
required:
- path
- code
- message
additionalProperties: false
$ref: "#/components/schemas/ValidationIssue"
required:
- id
- username
@@ -46663,6 +46742,11 @@ components:
description: "`inserted`/`updated`/`duplicate` — the row landed (advance the cursor). `skipped` — not stored; see
`reason`."
reason:
description: "Why a `skipped` entry was not stored. Among others: `medication_not_found`, `unstable_external_id`,
`force_slot_invalid`, `force_slot_occupied`, `injection_site_not_allowed`, and (v1.42.0)
`intake_not_tracked` for an entry against a medication kept as a record only (`trackIntake: false`), the
per-entry form of `medication.intake.notTracked`. A skip is final: resending the entry gives the same
answer."
type: string
id:
description: The landed row id (absent on skips).
@@ -48257,12 +48341,33 @@ components:
meta:
type: object
properties:
aggregate:
anyOf:
- $ref: "#/components/schemas/MedicationComplianceAggregate"
- type: "null"
days:
description: The `days` the request asked for.
anyOf:
- type: number
const: 30
- type: number
const: 60
- type: number
const: 90
- type: number
const: 180
- type: number
const: 365
requestId:
type: string
required:
- aggregate
additionalProperties: false
description: Absent on servers older than v1.42.0; a client that reads it treats absence as no aggregate.
required:
- data
- error
- meta
additionalProperties: false
MedicationComplianceSummaryEntry:
type: object
@@ -48285,6 +48390,18 @@ components:
compliance30:
description: Thirty-day adherence summary, or an all-zero compatibility placeholder when applicable is false.
$ref: "#/components/schemas/ComplianceResult"
compliance60:
description: "Present only on `?days=60`: the trailing 60 days."
$ref: "#/components/schemas/ComplianceResult"
compliance90:
description: "Present only on `?days=90`: the trailing 90 days."
$ref: "#/components/schemas/ComplianceResult"
compliance180:
description: "Present only on `?days=180`: the trailing 180 days."
$ref: "#/components/schemas/ComplianceResult"
compliance365:
description: "Present only on `?days=365`: the trailing 365 days."
$ref: "#/components/schemas/ComplianceResult"
complianceDisplay:
anyOf:
- $ref: "#/components/schemas/ComplianceDisplay"
@@ -48510,6 +48627,45 @@ components:
additionalProperties: false
description: The two-row card block whose windows scale with dosing cadence (dense meds keep 7 / 30 days, sparse meds
step both windows up). NOT the 30-day denominator — read `compliance30.totalExpected` for that.
MedicationComplianceAggregate:
type: object
properties:
compliance7:
$ref: "#/components/schemas/ComplianceResult"
compliance30:
$ref: "#/components/schemas/ComplianceResult"
compliance60:
anyOf:
- $ref: "#/components/schemas/ComplianceResult"
- type: "null"
compliance90:
anyOf:
- $ref: "#/components/schemas/ComplianceResult"
- type: "null"
compliance180:
anyOf:
- $ref: "#/components/schemas/ComplianceResult"
- type: "null"
compliance365:
anyOf:
- $ref: "#/components/schemas/ComplianceResult"
- type: "null"
medicationCount:
type: integer
exclusiveMinimum: 0
maximum: 9007199254740991
description: How many applicable medications the figure is built from.
required:
- compliance7
- compliance30
- medicationCount
additionalProperties: false
description: "One adherence figure for the whole account in the `ComplianceResult` shape, weighted by expected doses:
`taken`, `skipped` and `missed` are summed across every medication with `applicable: true`, and `rate` is
recomputed from the sums by the per-medication rule (round(100 · taken / (taken + missed)), capped at 100, 100
when nothing was expected). Averaging the per-medication rates would let a weekly injection weigh as much as a
three-times-daily tablet. `streak` is the shortest per-medication streak. The `complianceN` block for a
requested `days` is present when `days` was sent and is not 30."
GetMedicationComplianceResponse:
type: object
properties:
@@ -52562,19 +52718,7 @@ components:
it was published only on PUT /api/auth/profile."
type: array
items:
type: object
properties:
path:
type: string
code:
type: string
message:
type: string
required:
- path
- code
- message
additionalProperties: false
$ref: "#/components/schemas/ValidationIssue"
required:
- username
- email
@@ -52620,8 +52764,10 @@ components:
properties:
clientManaged:
type: boolean
description: True when the iOS app owns local medication reminders; the server-side MEDICATION_REMINDER APNs cron is
suppressed.
description: True when the iOS app delivers medication reminders locally. The server then keeps the reminder that
announces a dose off APNs (the phone shows its own at dose time) and still pushes the follow-ups for a
dose that stays open, which the phone does not send (v1.42.0; before, every MEDICATION_REMINDER push was
suppressed). Other channels are never affected.
deliveryDefault:
type: string
enum:
@@ -57673,6 +57819,104 @@ components:
- data
- error
additionalProperties: false
AdminMeasurementMaintenanceStatusResponse:
type: object
properties:
data:
$ref: "#/components/schemas/AdminMeasurementMaintenanceStatus"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
AdminMeasurementMaintenanceStatus:
type: object
properties:
available:
type: boolean
description: False when there is no background queue to ask (a web-only deployment); nothing can be started then.
purgePending:
type: boolean
description: The compaction-tombstone purge is queued or running. A run started now finishes at once with
`refused_purge_running`.
run:
anyOf:
- type: object
properties:
state:
type: string
enum:
- queued
- running
- completed
- failed
requestedAt:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$
startedAt:
anyOf:
- type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$
- type: "null"
finishedAt:
anyOf:
- type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$
- type: "null"
outcome:
anyOf:
- type: string
enum:
- completed
- refused_purge_running
- already_running
- type: "null"
description: "How a `completed` run ended: `completed` did the work, the two refusals finished without touching the
table. Null for any other state."
required:
- state
- requestedAt
- startedAt
- finishedAt
- outcome
additionalProperties: false
- type: "null"
description: The newest run pg-boss still keeps (terminal rows live seven days), or null when there is none.
sizes:
anyOf:
- type: object
properties:
tableBytes:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
description: "`measurements` with its indexes and TOAST."
indexBytes:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
description: Its indexes alone.
required:
- tableBytes
- indexBytes
additionalProperties: false
- type: "null"
required:
- available
- purgePending
- run
- sizes
additionalProperties: false
AdminMeasurementMaintenanceResponse:
type: object
properties:
@@ -63475,7 +63719,20 @@ components:
- days
additionalProperties: false
- type: "null"
description: Null for a sparse logger below the day floor.
description: Day-to-day steadiness over the trailing `stabilityWindowDays` days. Null below the day floor (seven days
with an entry) inside that window, so a short window can be null where the year is not.
stabilityWindowDays:
anyOf:
- type: number
const: 30
- type: number
const: 90
- type: number
const: 180
- type: number
const: 365
description: "The window `stability` covers: the `days` query parameter, or 365 (the whole daily series) when it was not
sent. Added in v1.42.0; absent from older servers, whose score always covers the year."
tags:
type: array
items:
@@ -63931,6 +64188,7 @@ components:
- weekday
- timeOfDay
- stability
- stabilityWindowDays
- tags
- contextComparison
- structuredTags
@@ -76524,6 +76782,33 @@ components:
type: string
username:
type: string
timezone:
type: string
description: "The IANA zone the server cuts this account's days in: the stored zone, or the instance default when the
stored one is unusable. Resolved, never the raw column, so bucket day keys by this value."
glucoseUnit:
anyOf:
- type: string
enum:
- mg/dL
- mmol/L
- type: "null"
description: The account's chosen glucose display unit, as stored. `null` means the person never chose one (or the
stored value is neither unit), and the server then shows glucose in mg/dL.
features:
type: object
properties:
trackIntake:
type: boolean
const: true
description: "Medications take `trackIntake`: a medication can be kept as a record whose doses are never due, and a dose
written against one is refused with `medication.intake.notTracked`."
required:
- trackIntake
additionalProperties: false
description: What this server understands, for a client released apart from it. Constant across accounts; a flag appears
once the server supports the feature and stays `true`. Absent on servers older than v1.42.0. A client that
meets a flag it does not know ignores it.
accountAccess:
$ref: "#/components/schemas/AccountAccess"
moduleAccess:
@@ -76555,6 +76840,9 @@ components:
required:
- id
- username
- timezone
- glucoseUnit
- features
- accountAccess
- moduleAccess
- ai
+206
View File
@@ -0,0 +1,206 @@
import type { Page, Route } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
/**
* A saved conversation opens at its last answer, and stays there after that
* answer's chart arrives.
*
* Before: on a wide screen the thread opened at the top (scroll 0), and on a
* phone it stopped about 70 px short of the end, because the answer's chart
* loaded after the jump. The conversation here is long enough to scroll on
* both widths, and the stored table under the last answer is served late on
* purpose, so the end moves after the thread first paints.
*/
const CONVERSATION_ID = "coach-open-at-end-e2e";
const LAST_ID = "coach-open-at-end-last";
const TABLE = {
ref: "r1",
source: {
tool: "get_metric_table",
domain: "pulse",
window: "last30days",
period: "current",
granularity: "day",
},
shape: "timeSeries",
titleKey: "coach.result.title.byDay",
title: "Pulse by day",
rowCount: 14,
chartKind: "line",
displayed: true,
columns: [
{
key: "day",
kind: "period",
labelKey: "coach.result.column.day",
label: "Day",
},
{
key: "pulse",
kind: "number",
labelKey: "coach.result.column.value",
label: "Pulse",
unit: "bpm",
decimals: 0,
},
],
rows: Array.from({ length: 14 }, (_, i) => [
new Date(Date.UTC(2026, 8, 10 + i)).toISOString().slice(0, 10),
60 + (i % 5),
]),
truncated: false,
chart: { kind: "line", x: "day", series: ["pulse"] },
};
const PARAGRAPH =
"Your resting pulse held steady across the fortnight, with the usual small dip after the longer nights and a slight rise on the two days you logged a cold.";
function messages() {
const out: Array<Record<string, unknown>> = [];
for (let i = 0; i < 8; i += 1) {
const at = `2026-09-24T09:${String(i * 2).padStart(2, "0")}:00.000Z`;
out.push({
id: `u-${i}`,
role: "user",
content: `Question ${i + 1}: how did my pulse look?`,
createdAt: at,
metricSource: null,
providerType: null,
promptVersion: null,
tokensUsed: null,
model: null,
});
const last = i === 7;
out.push({
id: last ? LAST_ID : `a-${i}`,
role: "assistant",
content: `${PARAGRAPH} ${PARAGRAPH}`,
createdAt: at,
metricSource: last
? {
windows: ["last30days"],
metrics: ["pulse"],
results: [
{
ref: TABLE.ref,
source: TABLE.source,
shape: TABLE.shape,
titleKey: TABLE.titleKey,
title: TABLE.title,
rowCount: TABLE.rowCount,
chartKind: TABLE.chartKind,
displayed: TABLE.displayed,
},
],
}
: null,
providerType: "mock",
promptVersion: "e2e",
tokensUsed: 42,
model: "mock",
});
}
return out;
}
function fulfilJson(route: Route, data: unknown) {
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data, error: null }),
});
}
async function mockConversation(page: Page) {
await page.route(
/\/api\/insights\/chat(?:\/[^?]+)?(?:\?.*)?$/,
async (route) => {
const url = new URL(route.request().url());
if (
url.pathname ===
`/api/insights/chat/${CONVERSATION_ID}/messages/${LAST_ID}/results`
) {
// Late on purpose: the chart lands after the thread has painted.
await new Promise((resolve) => setTimeout(resolve, 900));
return fulfilJson(route, { results: [TABLE] });
}
if (url.pathname.startsWith(`/api/insights/chat/${CONVERSATION_ID}/`)) {
return fulfilJson(route, { results: [] });
}
if (url.pathname === `/api/insights/chat/${CONVERSATION_ID}`) {
return fulfilJson(route, {
id: CONVERSATION_ID,
title: "Pulse over two weeks",
createdAt: "2026-09-24T09:00:00.000Z",
updatedAt: "2026-09-24T09:14:00.000Z",
messageCount: 16,
fenced: false,
attachments: [],
documentTitle: null,
attachmentCount: 0,
summary: null,
messages: messages(),
});
}
return fulfilJson(route, { conversations: [], nextCursor: null });
},
);
await page.route("**/api/insights/coach/nudge-status*", (route) =>
fulfilJson(route, { nudgedAt: null, unread: false }),
);
await page.route("**/api/coach/about-me/questions*", (route) =>
fulfilJson(route, { questions: [] }),
);
}
/** Distance from the thread's current view to its end, in px. */
async function distanceFromEnd(page: Page): Promise<number> {
return page
.locator('[data-slot="coach-message-thread"]')
.evaluate((el) => el.scrollHeight - el.scrollTop - el.clientHeight);
}
test.describe("Coach: a saved conversation opens at its end", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeEach(async ({ page }) => {
await serveAiBlock(page, aiBlockAvailable());
await mockConversation(page);
});
for (const width of [390, 1440] as const) {
test(`at ${width} px it lands on the last answer and stays after its chart loads`, async ({
page,
}) => {
await page.setViewportSize({
width,
height: width === 390 ? 844 : 900,
});
await page.goto(`/coach?c=${CONVERSATION_ID}`, {
waitUntil: "domcontentloaded",
});
const thread = page.locator('[data-slot="coach-message-thread"]');
await expect(thread).toBeVisible({ timeout: 15_000 });
// The thread scrolls at all: otherwise "at the end" proves nothing.
expect(
await thread.evaluate((el) => el.scrollHeight > el.clientHeight + 200),
).toBe(true);
const chart = page.locator(
'[data-slot="coach-result-chart"][data-ref="r1"]',
);
await expect(chart).toBeVisible({ timeout: 15_000 });
// The chart grew the thread after the first jump; the view followed.
await expect.poll(() => distanceFromEnd(page)).toBeLessThanOrEqual(2);
await expect(
page.locator(`[data-slot="coach-assistant-turn"]`).last(),
).toBeInViewport();
});
}
});
+51
View File
@@ -151,6 +151,57 @@ test.describe("document vault", () => {
await ensureVaultFixture();
});
// ── v1.42 — the archive as a month grid of preview tiles ──────────────
for (const [width, columns] of [
[390, 2],
[1440, 4],
] as const) {
test(`the archive is a month grid of preview tiles, ${columns} across at ${width} px`, async ({
page,
}) => {
await page.setViewportSize({ width, height: width === 390 ? 844 : 900 });
await page.goto("/documents");
const firstRow = page.locator('[data-slot="document-month-row"]').first();
await expect(firstRow).toBeVisible({ timeout: 15_000 });
// Month headings stay the grid's section labels.
await expect(
page.locator('[data-slot="document-timeline"] h2').first(),
).toBeVisible();
// Tiles with the preview on top, and at most `columns` side by side:
// the fullest mounted row holds exactly that many, on one line.
const tiles = page.locator(
'[data-slot="document-month-row"] [data-slot="document-card"]',
);
await expect(tiles.first()).toHaveAttribute("data-variant", "tile");
await expect(
tiles.first().locator('[data-slot="document-preview"]'),
).toBeVisible();
const rows = await page
.locator('[data-slot="document-month-row"]')
.evaluateAll((els) =>
els.map((row) =>
Array.from(row.querySelectorAll('[data-slot="document-card"]')).map(
(el) => Math.round(el.getBoundingClientRect().top),
),
),
);
const fullest = rows.reduce((a, b) => (b.length > a.length ? b : a), []);
expect(fullest.length).toBe(columns);
expect(new Set(fullest).size).toBe(1);
// Nothing on the page scrolls sideways.
const overflow = await page.evaluate(
() =>
document.documentElement.scrollWidth -
document.documentElement.clientWidth,
);
expect(overflow).toBeLessThanOrEqual(0);
});
}
// ── §1A — the 30-second doctor flow, three independent routes ─────────
test("doctor flow A: the type filter reaches the MRT report", async ({
+28
View File
@@ -7867,6 +7867,34 @@
"backupsStillNeed": "Alle Zeilen sind rotiert, aber Sicherungen brauchen noch {keys}",
"backupsUnrecordedBadge": "Alle Zeilen sind rotiert, aber ältere Sicherungen können noch einen ausgemusterten Schlüssel brauchen",
"backupsDetail": "Die Rotation verschlüsselt jede Zeile neu, aber eine Sicherung behält den Schlüssel, unter dem ihr Inhalt geschrieben wurde, und ihre Wiederherstellung braucht ihn. Behalte einen ausgemusterten Schlüssel in ENCRYPTION_KEYS, bis er unten nicht mehr aufgeführt ist."
},
"maintenance": {
"title": "Pflege der Messwert-Tabelle",
"description": "Gibt den Platz frei, den alte Sync-Reste belegt haben, sobald die Bereinigung gelaufen ist.",
"body": "Sie räumt die Messwert-Tabelle auf und baut danach ihre Indizes neu auf, einen nach dem anderen. Lesen und Speichern funktionieren währenddessen weiter. Wähle eine ruhige Stunde und halte freien Speicher für eine weitere Kopie des größten Index bereit.",
"tableSize": "Tabelle mit Indizes",
"indexSize": "Indizes",
"loading": "Stand der Pflege wird geprüft…",
"loadError": "Der Stand der Pflege konnte nicht gelesen werden.",
"unavailable": "Es ist kein Hintergrunddienst verbunden, deshalb kann die Pflege hier nicht laufen.",
"purgePending": "Die Bereinigung alter Sync-Reste läuft noch. Die Pflege kann starten, sobald sie fertig ist.",
"state": {
"idle": "Noch nicht gelaufen.",
"queued": "Wartet auf den Hintergrunddienst.",
"running": "Läuft seit {since}. Auf einer großen Instanz kann das eine Weile dauern.",
"completed": "Fertig am {at}.",
"refusedPurge": "Nicht gestartet, weil die Bereinigung noch lief. Starte sie erneut, sobald die Bereinigung fertig ist.",
"alreadyRunning": "Nicht gestartet, weil bereits ein anderer Lauf arbeitete.",
"failed": "Angehalten am {at}. Das App-Protokoll nennt den Schritt, das Runbook erklärt, was zu prüfen ist."
},
"start": "Pflege starten",
"confirmTitle": "Pflege jetzt starten?",
"confirmBody": "Sie läuft im Hintergrund, Schritt für Schritt, und kann eine Weile dauern. Wenn du heute noch keine Sicherung erstellt hast, erstelle zuerst eine.",
"confirm": "Starten",
"cancel": "Abbrechen",
"started": "Pflege gestartet.",
"alreadyQueued": "Ein Pflegelauf ist bereits eingeplant oder läuft.",
"startFailed": "Die Pflege konnte nicht gestartet werden."
}
},
"carrier": "Netzbetreiber",
+28
View File
@@ -7867,6 +7867,34 @@
"backupsStillNeed": "Every row is rotated, but backups still need {keys}",
"backupsUnrecordedBadge": "Every row is rotated, but older backups may still need a retired key",
"backupsDetail": "Rotation re-encrypts every row, but a backup keeps the key its content was written under, and restoring it needs that key. Keep a retired key in ENCRYPTION_KEYS until nothing below lists it."
},
"maintenance": {
"title": "Measurement table maintenance",
"description": "Gives back the space old sync leftovers held, once the cleanup has run.",
"body": "It vacuums the measurements table and then rebuilds its indexes, one at a time. Reading and saving keep working while it runs. Pick a quiet hour, and have free disk for one more copy of the largest index.",
"tableSize": "Table with indexes",
"indexSize": "Indexes",
"loading": "Checking where the maintenance stands…",
"loadError": "Could not read the maintenance status.",
"unavailable": "No background worker is connected, so the maintenance cannot run here.",
"purgePending": "The cleanup of old sync leftovers is still running. The maintenance can start once it has finished.",
"state": {
"idle": "Not run yet.",
"queued": "Waiting for the background worker.",
"running": "Running since {since}. This can take a while on a large instance.",
"completed": "Finished on {at}.",
"refusedPurge": "Did not start, because the cleanup was still running. Start it again once the cleanup has finished.",
"alreadyRunning": "Did not start, because another run was already working.",
"failed": "Stopped on {at}. The app log names the step, and the runbook explains what to check."
},
"start": "Start maintenance",
"confirmTitle": "Start the maintenance now?",
"confirmBody": "It runs in the background, one step at a time, and can take a while. If you have not taken a backup today, take one first.",
"confirm": "Start",
"cancel": "Cancel",
"started": "Maintenance started.",
"alreadyQueued": "A maintenance run is already queued or running.",
"startFailed": "Could not start the maintenance."
}
},
"carrier": "Carrier",
+28
View File
@@ -7867,6 +7867,34 @@
"backupsStillNeed": "Todas las filas están rotadas, pero las copias aún necesitan {keys}",
"backupsUnrecordedBadge": "Todas las filas están rotadas, pero copias antiguas pueden necesitar aún una clave retirada",
"backupsDetail": "La rotación vuelve a cifrar cada fila, pero una copia conserva la clave con la que se escribió su contenido, y restaurarla necesita esa clave. Mantén una clave retirada en ENCRYPTION_KEYS hasta que no aparezca abajo."
},
"maintenance": {
"title": "Mantenimiento de la tabla de mediciones",
"description": "Libera el espacio que ocupaban los restos antiguos de sincronización, cuando la limpieza ya ha terminado.",
"body": "Ejecuta un vacuum de la tabla de mediciones y después reconstruye sus índices, uno a uno. Leer y guardar siguen funcionando mientras se ejecuta. Elige una hora tranquila y deja espacio libre en disco para una copia más del índice más grande.",
"tableSize": "Tabla con índices",
"indexSize": "Índices",
"loading": "Comprobando el estado del mantenimiento…",
"loadError": "No se pudo leer el estado del mantenimiento.",
"unavailable": "No hay ningún proceso en segundo plano conectado, así que el mantenimiento no puede ejecutarse aquí.",
"purgePending": "La limpieza de restos antiguos de sincronización sigue en marcha. El mantenimiento podrá empezar cuando termine.",
"state": {
"idle": "Aún no se ha ejecutado.",
"queued": "Esperando al proceso en segundo plano.",
"running": "En marcha desde las {since}. En una instancia grande puede tardar un rato.",
"completed": "Terminado el {at}.",
"refusedPurge": "No empezó porque la limpieza seguía en marcha. Vuelve a iniciarlo cuando la limpieza haya terminado.",
"alreadyRunning": "No empezó porque ya había otra ejecución trabajando.",
"failed": "Detenido el {at}. El registro de la aplicación indica el paso y el runbook explica qué revisar."
},
"start": "Iniciar mantenimiento",
"confirmTitle": "¿Iniciar el mantenimiento ahora?",
"confirmBody": "Se ejecuta en segundo plano, paso a paso, y puede tardar un rato. Si hoy aún no has hecho una copia de seguridad, hazla antes.",
"confirm": "Iniciar",
"cancel": "Cancelar",
"started": "Mantenimiento iniciado.",
"alreadyQueued": "Ya hay un mantenimiento en cola o en marcha.",
"startFailed": "No se pudo iniciar el mantenimiento."
}
},
"carrier": "Operador",
+28
View File
@@ -7867,6 +7867,34 @@
"backupsStillNeed": "Toutes les lignes sont renouvelées, mais des sauvegardes ont encore besoin de {keys}",
"backupsUnrecordedBadge": "Toutes les lignes sont renouvelées, mais d'anciennes sauvegardes peuvent encore avoir besoin d'une clé retirée",
"backupsDetail": "La rotation rechiffre chaque ligne, mais une sauvegarde garde la clé sous laquelle son contenu a été écrit, et sa restauration en a besoin. Gardez une clé retirée dans ENCRYPTION_KEYS tant qu'elle figure ci-dessous."
},
"maintenance": {
"title": "Maintenance de la table des mesures",
"description": "Libère la place qu'occupaient les anciens restes de synchronisation, une fois le nettoyage terminé.",
"body": "Elle exécute un vacuum de la table des mesures puis reconstruit ses index, un par un. La lecture et l'enregistrement continuent de fonctionner pendant ce temps. Choisissez une heure calme et prévoyez de l'espace disque libre pour une copie supplémentaire du plus grand index.",
"tableSize": "Table avec index",
"indexSize": "Index",
"loading": "Vérification de l'état de la maintenance…",
"loadError": "Impossible de lire l'état de la maintenance.",
"unavailable": "Aucun processus d'arrière-plan n'est connecté, la maintenance ne peut donc pas s'exécuter ici.",
"purgePending": "Le nettoyage des anciens restes de synchronisation est toujours en cours. La maintenance pourra démarrer une fois qu'il sera terminé.",
"state": {
"idle": "Pas encore exécutée.",
"queued": "En attente du processus d'arrière-plan.",
"running": "En cours depuis {since}. Sur une grande instance, cela peut prendre un moment.",
"completed": "Terminée le {at}.",
"refusedPurge": "Pas démarrée, car le nettoyage était encore en cours. Relancez-la une fois le nettoyage terminé.",
"alreadyRunning": "Pas démarrée, car une autre exécution était déjà en cours.",
"failed": "Arrêtée le {at}. Le journal de l'application indique l'étape et le runbook explique quoi vérifier."
},
"start": "Lancer la maintenance",
"confirmTitle": "Lancer la maintenance maintenant ?",
"confirmBody": "Elle s'exécute en arrière-plan, étape par étape, et peut prendre un moment. Si vous n'avez pas encore fait de sauvegarde aujourd'hui, faites-en une d'abord.",
"confirm": "Lancer",
"cancel": "Annuler",
"started": "Maintenance lancée.",
"alreadyQueued": "Une maintenance est déjà en file d'attente ou en cours.",
"startFailed": "Impossible de lancer la maintenance."
}
},
"carrier": "Opérateur",
+28
View File
@@ -7867,6 +7867,34 @@
"backupsStillNeed": "Tutte le righe sono ruotate, ma i backup hanno ancora bisogno di {keys}",
"backupsUnrecordedBadge": "Tutte le righe sono ruotate, ma backup più vecchi potrebbero ancora richiedere una chiave ritirata",
"backupsDetail": "La rotazione cifra di nuovo ogni riga, ma un backup conserva la chiave con cui è stato scritto il suo contenuto, e ripristinarlo richiede quella chiave. Tieni una chiave ritirata in ENCRYPTION_KEYS finché compare qui sotto."
},
"maintenance": {
"title": "Manutenzione della tabella delle misurazioni",
"description": "Libera lo spazio occupato dai vecchi residui di sincronizzazione, una volta terminata la pulizia.",
"body": "Esegue un vacuum della tabella delle misurazioni e poi ricostruisce i suoi indici, uno alla volta. Lettura e salvataggio continuano a funzionare durante l'esecuzione. Scegli un'ora tranquilla e lascia spazio libero su disco per un'altra copia dell'indice più grande.",
"tableSize": "Tabella con indici",
"indexSize": "Indici",
"loading": "Verifica dello stato della manutenzione…",
"loadError": "Impossibile leggere lo stato della manutenzione.",
"unavailable": "Nessun processo in background è collegato, quindi la manutenzione non può essere eseguita qui.",
"purgePending": "La pulizia dei vecchi residui di sincronizzazione è ancora in corso. La manutenzione potrà partire quando sarà terminata.",
"state": {
"idle": "Non ancora eseguita.",
"queued": "In attesa del processo in background.",
"running": "In corso dalle {since}. Su un'istanza grande può richiedere un po' di tempo.",
"completed": "Terminata il {at}.",
"refusedPurge": "Non avviata, perché la pulizia era ancora in corso. Riavviala quando la pulizia sarà terminata.",
"alreadyRunning": "Non avviata, perché un'altra esecuzione era già in corso.",
"failed": "Interrotta il {at}. Il registro dell'app indica il passaggio e il runbook spiega cosa controllare."
},
"start": "Avvia manutenzione",
"confirmTitle": "Avviare la manutenzione adesso?",
"confirmBody": "Viene eseguita in background, un passaggio alla volta, e può richiedere un po' di tempo. Se oggi non hai ancora creato un backup, creane uno prima.",
"confirm": "Avvia",
"cancel": "Annulla",
"started": "Manutenzione avviata.",
"alreadyQueued": "Una manutenzione è già in coda o in corso.",
"startFailed": "Impossibile avviare la manutenzione."
}
},
"carrier": "Operatore",
+28
View File
@@ -7843,6 +7843,34 @@
"backupsStillNeed": "모든 행이 회전되었지만 백업에 아직 {keys}가 필요합니다",
"backupsUnrecordedBadge": "모든 행이 회전되었지만 이전 백업에는 아직 폐기한 키가 필요할 수 있습니다",
"backupsDetail": "회전은 모든 행을 다시 암호화하지만, 백업은 내용이 기록된 키를 그대로 유지하며 복원에는 그 키가 필요합니다. 아래에 더 이상 표시되지 않을 때까지 폐기한 키를 ENCRYPTION_KEYS에 유지하세요."
},
"maintenance": {
"title": "측정값 테이블 유지 관리",
"description": "정리가 끝난 뒤, 오래된 동기화 잔여 데이터가 차지하던 공간을 되돌려 줍니다.",
"body": "측정값 테이블에 vacuum을 실행한 다음 인덱스를 하나씩 다시 만듭니다. 실행 중에도 읽기와 저장은 계속 작동합니다. 한가한 시간을 고르고, 가장 큰 인덱스를 한 번 더 복사할 만큼의 디스크 여유 공간을 확보하세요.",
"tableSize": "인덱스 포함 테이블",
"indexSize": "인덱스",
"loading": "유지 관리 상태를 확인하는 중…",
"loadError": "유지 관리 상태를 읽을 수 없습니다.",
"unavailable": "연결된 백그라운드 작업자가 없어 여기에서는 유지 관리를 실행할 수 없습니다.",
"purgePending": "오래된 동기화 잔여 데이터 정리가 아직 진행 중입니다. 정리가 끝나면 유지 관리를 시작할 수 있습니다.",
"state": {
"idle": "아직 실행되지 않았습니다.",
"queued": "백그라운드 작업자를 기다리는 중입니다.",
"running": "{since}부터 실행 중입니다. 큰 인스턴스에서는 시간이 걸릴 수 있습니다.",
"completed": "{at}에 완료되었습니다.",
"refusedPurge": "정리가 아직 진행 중이어서 시작되지 않았습니다. 정리가 끝난 뒤 다시 시작하세요.",
"alreadyRunning": "다른 실행이 이미 작업 중이어서 시작되지 않았습니다.",
"failed": "{at}에 중단되었습니다. 앱 로그에 단계가 나와 있고, 런북에서 확인할 사항을 설명합니다."
},
"start": "유지 관리 시작",
"confirmTitle": "지금 유지 관리를 시작할까요?",
"confirmBody": "백그라운드에서 한 단계씩 실행되며 시간이 걸릴 수 있습니다. 오늘 아직 백업하지 않았다면 먼저 백업하세요.",
"confirm": "시작",
"cancel": "취소",
"started": "유지 관리를 시작했습니다.",
"alreadyQueued": "유지 관리가 이미 대기 중이거나 실행 중입니다.",
"startFailed": "유지 관리를 시작할 수 없습니다."
}
},
"carrier": "통신사",
+28
View File
@@ -7867,6 +7867,34 @@
"backupsStillNeed": "Wszystkie wiersze są zrotowane, ale kopie nadal potrzebują {keys}",
"backupsUnrecordedBadge": "Wszystkie wiersze są zrotowane, ale starsze kopie mogą nadal potrzebować wycofanego klucza",
"backupsDetail": "Rotacja ponownie szyfruje każdy wiersz, ale kopia zachowuje klucz, którym zapisano jej zawartość, a jej przywrócenie wymaga tego klucza. Trzymaj wycofany klucz w ENCRYPTION_KEYS, dopóki widnieje poniżej."
},
"maintenance": {
"title": "Konserwacja tabeli pomiarów",
"description": "Zwalnia miejsce zajmowane przez stare pozostałości synchronizacji, gdy czyszczenie już się zakończy.",
"body": "Wykonuje vacuum tabeli pomiarów, a potem odbudowuje jej indeksy, jeden po drugim. Odczyt i zapis działają w tym czasie dalej. Wybierz spokojną godzinę i zapewnij wolne miejsce na dysku na jeszcze jedną kopię największego indeksu.",
"tableSize": "Tabela z indeksami",
"indexSize": "Indeksy",
"loading": "Sprawdzanie stanu konserwacji…",
"loadError": "Nie udało się odczytać stanu konserwacji.",
"unavailable": "Nie jest podłączony żaden proces w tle, więc konserwacja nie może tu działać.",
"purgePending": "Czyszczenie starych pozostałości synchronizacji wciąż trwa. Konserwacja może się rozpocząć, gdy się zakończy.",
"state": {
"idle": "Jeszcze nie uruchomiona.",
"queued": "Czeka na proces w tle.",
"running": "Trwa od {since}. Na dużej instancji może to chwilę potrwać.",
"completed": "Zakończona {at}.",
"refusedPurge": "Nie rozpoczęła się, bo czyszczenie wciąż trwało. Uruchom ją ponownie, gdy czyszczenie się zakończy.",
"alreadyRunning": "Nie rozpoczęła się, bo inny przebieg już działał.",
"failed": "Zatrzymana {at}. Dziennik aplikacji podaje krok, a runbook wyjaśnia, co sprawdzić."
},
"start": "Uruchom konserwację",
"confirmTitle": "Uruchomić konserwację teraz?",
"confirmBody": "Działa w tle, krok po kroku, i może chwilę potrwać. Jeśli dziś nie zrobiono jeszcze kopii zapasowej, najpierw ją zrób.",
"confirm": "Uruchom",
"cancel": "Anuluj",
"started": "Konserwacja uruchomiona.",
"alreadyQueued": "Konserwacja jest już w kolejce lub trwa.",
"startFailed": "Nie udało się uruchomić konserwacji."
}
},
"carrier": "Operator",
@@ -153,6 +153,12 @@ const ACTIVE_RECORD_ONLY_FIELDS = [
* only when the claim is true and checkable.
*/
const NO_CLIENT_CONSUMER: Record<string, string> = {
features:
"A capability probe for clients released apart from the server: the native app asks " +
"whether this server takes the record-only medication switch before the account has a " +
"medication to learn it from (iOS #116, item 18). The web client is built into the same " +
"image as the server, so everything it renders is by construction supported; a web read " +
"of the flag would be a read written to satisfy this guard.",
insurerIkNumber:
"No web surface reads or writes it — the account form offers the insurer name and the " +
"insurance number, not the institution number. The native client does consume it: it " +
@@ -190,6 +190,17 @@ const ALLOWED: Readonly<Record<string, { count: number; reason: string }>> = {
count: 1,
reason: "See readActiveJobsStartedBefore: null means no queue schema.",
},
"lib/jobs/measurement-maintenance-status.ts::readMeasurementMaintenanceStatus":
{
count: 1,
reason:
"No pgboss schema on a web-only deployment; the admin card reads `available: false` and says so.",
},
"lib/jobs/measurement-maintenance-status.ts::readSizes": {
count: 1,
reason:
"A size probe for an admin card: null shows no figure, and the status read must not fail over it.",
},
"lib/jobs/job-observer.ts::emitJobLine": {
count: 1,
reason:
@@ -97,6 +97,11 @@ const PINNED_AFFORDANCES: Record<
"toast.success": 1,
},
"src/components/admin/glitchtip-section.tsx": { "toast.success": 1 },
// Fires only when the route answered `enqueued: true`; a run already queued
// reads as an info toast instead, and the outcome itself is the status line.
"src/components/admin/measurement-maintenance-card.tsx": {
"toast.success": 1,
},
"src/components/admin/invite-tokens-section.tsx": {
"text-success": 2,
"toast.success": 3,
+4 -1
View File
@@ -5,7 +5,7 @@
* a given slug. Lives next to the route page so the shell can stay a
* server component while the auth-gated body remains a client component.
*
* This is the equivalent of `SECTION_COMPONENTS` in
* This is the equivalent of `SettingsSectionLoader` for
* `src/app/settings/[section]/page.tsx`, lifted to a separate file so
* the route page can be a server component (Settings can keep
* `"use client"`-leaning components in its page because none of them
@@ -36,6 +36,7 @@ import { ModuleAvailabilitySection } from "@/components/admin/module-availabilit
import { RemindersSection } from "@/components/admin/reminders-section";
import { ServicesSection } from "@/components/admin/services-section";
import { SystemStatusSection } from "@/components/admin/system-status-section";
import { MeasurementMaintenanceCard } from "@/components/admin/measurement-maintenance-card";
import { InviteTokensSection } from "@/components/admin/invite-tokens-section";
import { UserManagementSection } from "@/components/admin/user-management-section";
import type { AdminSectionSlug } from "@/components/admin/section-slugs";
@@ -60,6 +61,8 @@ export function AdminSectionRenderer({
return (
<SectionFrame>
<SystemStatusSection />
{/* v1.42 — the table maintenance after the tombstone purge. */}
<MeasurementMaintenanceCard />
</SectionFrame>
);
case "general":
@@ -119,6 +119,14 @@ const DELIBERATELY_OPEN: Record<string, { count: number; reason: string }> = {
count: 1,
reason: "read filter over whole searchParams",
},
"medications/compliance/route.ts": {
count: 1,
reason: "read filter over whole searchParams",
},
"mood/insights/route.ts": {
count: 1,
reason: "read filter over whole searchParams",
},
// ---------------------------------------------------------------
// 3. Native-client ingest and device registration. This is where the
@@ -0,0 +1,143 @@
/**
* `GET /api/admin/maintenance/measurements` — the status the admin card
* starts and follows the run with. Admin-only, and a web-only deployment
* (no pg-boss schema) answers `available: false` instead of failing.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({ prisma: { $queryRaw: vi.fn() } }));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { runStateOf } from "@/lib/jobs/measurement-maintenance-status";
const ADMIN = {
session: { id: "s-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "admin-1", username: "admin", role: "ADMIN" as const },
};
const USER = {
session: { id: "s-2", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "bob", role: "USER" as const },
};
const get = () =>
(GET as unknown as (r: NextRequest) => Promise<Response>)(
new NextRequest("http://localhost/api/admin/maintenance/measurements"),
);
beforeEach(() => {
vi.resetAllMocks();
});
describe("GET /api/admin/maintenance/measurements", () => {
it("is refused to anyone but an admin", async () => {
vi.mocked(getSession).mockResolvedValue(USER as never);
expect((await get()).status).toBe(403);
expect(prisma.$queryRaw).not.toHaveBeenCalled();
});
it("reports a running pass, the purge and the table size", async () => {
vi.mocked(getSession).mockResolvedValue(ADMIN as never);
vi.mocked(prisma.$queryRaw)
// sizes
.mockResolvedValueOnce([
{ table_bytes: BigInt(5_000_000_000), index_bytes: BigInt(2e9) },
] as never)
// newest run
.mockResolvedValueOnce([
{
state: "active",
created_on: new Date("2026-10-08T08:00:00Z"),
started_on: new Date("2026-10-08T08:00:05Z"),
completed_on: null,
outcome: null,
},
] as never)
// purge queue
.mockResolvedValueOnce([{ n: 0 }] as never);
const body = await (await get()).json();
expect(body.data).toEqual({
available: true,
purgePending: false,
run: {
state: "running",
requestedAt: "2026-10-08T08:00:00.000Z",
startedAt: "2026-10-08T08:00:05.000Z",
finishedAt: null,
outcome: null,
},
sizes: { tableBytes: 5_000_000_000, indexBytes: 2_000_000_000 },
});
});
it("says when a finished run refused because the purge was still working", async () => {
vi.mocked(getSession).mockResolvedValue(ADMIN as never);
vi.mocked(prisma.$queryRaw)
.mockResolvedValueOnce([
{ table_bytes: BigInt(1), index_bytes: BigInt(1) },
] as never)
.mockResolvedValueOnce([
{
state: "completed",
created_on: new Date("2026-10-08T08:00:00Z"),
started_on: new Date("2026-10-08T08:00:01Z"),
completed_on: new Date("2026-10-08T08:00:02Z"),
outcome: "refused_purge_running",
},
] as never)
.mockResolvedValueOnce([{ n: 1 }] as never);
const body = await (await get()).json();
expect(body.data.purgePending).toBe(true);
expect(body.data.run.outcome).toBe("refused_purge_running");
});
it("answers available: false where there is no queue to ask", async () => {
vi.mocked(getSession).mockResolvedValue(ADMIN as never);
vi.mocked(prisma.$queryRaw)
.mockResolvedValueOnce([
{ table_bytes: BigInt(10), index_bytes: BigInt(4) },
] as never)
.mockRejectedValue(new Error('relation "pgboss.job" does not exist'));
const res = await get();
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data).toEqual({
available: false,
purgePending: false,
run: null,
sizes: { tableBytes: 10, indexBytes: 4 },
});
});
});
describe("runStateOf", () => {
it("maps every pg-boss state onto the card's four", () => {
expect(runStateOf("created")).toBe("queued");
expect(runStateOf("retry")).toBe("queued");
expect(runStateOf("active")).toBe("running");
expect(runStateOf("completed")).toBe("completed");
expect(runStateOf("failed")).toBe("failed");
expect(runStateOf("cancelled")).toBe("failed");
});
});
@@ -8,6 +8,9 @@
* start while the compaction-tombstone purge is still working. At most one
* run is queued or running; a second press answers `enqueued: false`.
* Runbook: `docs/ops/measurement-maintenance.md`.
*
* `GET` reads where the newest run stands, for the admin card that starts it
* and follows it.
*/
import { NextRequest } from "next/server";
import { z } from "zod/v4";
@@ -26,6 +29,7 @@ import {
MEASUREMENT_MAINTENANCE_SEND_OPTIONS,
type MeasurementMaintenancePayload,
} from "@/lib/jobs/measurement-maintenance";
import { readMeasurementMaintenanceStatus } from "@/lib/jobs/measurement-maintenance-status";
import { annotate } from "@/lib/logging/context";
/** Two booleans; anything longer is not a request for this route. */
@@ -90,3 +94,17 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiSuccess({ enqueued }, 202);
});
export const GET = apiHandler(async () => {
await requireAdmin();
const status = await readMeasurementMaintenanceStatus();
annotate({
action: { name: "admin.maintenance.measurements.status" },
meta: {
available: status.available,
run_state: status.run?.state ?? null,
purge_pending: status.purgePending,
},
});
return apiSuccess(status);
});
+16 -1
View File
@@ -74,9 +74,17 @@ import { recordSessionForPayload } from "@/lib/sharing/record-session-fence";
import { loadAiCapabilities } from "@/lib/ai/capabilities/load";
import { loadCoachReasoningState } from "@/lib/ai/reasoning/controls";
import { parseCoachPrefs } from "@/lib/validations/coach-prefs";
import { storedGlucoseUnit } from "@/lib/glucose";
export const dynamic = "force-dynamic";
/**
* Capability flags for clients that are not shipped with the server. Constant
* by design: a feature the server supports is supported for every account,
* and a per-account answer belongs in `modules` or `ai`, not here.
*/
const SERVER_FEATURES = { trackIntake: true } as const;
export const GET = apiHandler(async () => {
const auth = await requireActorAuth();
const { user } = auth;
@@ -331,7 +339,8 @@ export const GET = apiHandler(async () => {
avatarUrl: user.avatarUpdatedAt
? buildAvatarUrl(user.id, user.avatarUpdatedAt)
: null,
glucoseUnit: user.glucoseUnit ?? null,
// The stored choice, published as the closed pair the contract names.
glucoseUnit: storedGlucoseUnit(user.glucoseUnit),
// v1.7.0 — global metric/imperial display preference. Canonical
// storage stays SI; this only drives the display-time transform
// branch. Null defaults to "metric" on the client.
@@ -442,5 +451,11 @@ export const GET = apiHandler(async () => {
// behind it have one reader by design, and a route that projected them
// itself would be a second statement of what a record context is.
recordSession: recordSessionForPayload(auth),
// v1.42 — what this server understands, for a client released on its own
// schedule (iOS #116, item 18). `trackIntake: true` says medications take
// the record-only switch, so an add form can offer it before the account
// has a medication to learn it from. A flag only ever appears here once
// the server supports it, and stays `true`; absence means an older server.
features: SERVER_FEATURES,
});
});
@@ -0,0 +1,164 @@
/**
* A passkey sign-in that cannot be checked answers with its own status and
* `meta.errorCode`, never a 500. The real `verifyAuthentication` runs here
* against a mocked database, so the test pins the whole path from the
* library's refusal to the wire: an expired or already-used challenge, a
* credential this server does not know, a body that is not an assertion, and
* an assertion the WebAuthn library refuses.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
$queryRaw: vi.fn(),
passkey: { findUnique: vi.fn(), update: vi.fn() },
user: { findUnique: vi.fn() },
},
}));
vi.mock("@simplewebauthn/server", async (importOriginal) => ({
...(await importOriginal<typeof import("@simplewebauthn/server")>()),
verifyAuthenticationResponse: vi.fn(),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: 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/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
import { verifyAuthenticationResponse } from "@simplewebauthn/server";
const ASSERTION = {
id: "cred-1",
rawId: "cred-1",
type: "public-key",
response: {
clientDataJSON: "e30",
authenticatorData: "AA",
signature: "AA",
},
};
function request(credential: unknown = ASSERTION): 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 }),
});
}
async function refusal(res: Response) {
const body = (await res.json()) as {
data: null;
meta?: { errorCode?: string };
};
return { status: res.status, code: body.meta?.errorCode, data: body.data };
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
resetAt: 0,
ip: "203.0.113.1",
} as never);
vi.mocked(prisma.$queryRaw).mockResolvedValue([
{ challenge: "c-1", user_id: null },
] as never);
vi.mocked(prisma.passkey.findUnique).mockResolvedValue({
id: "pk-1",
userId: "user-1",
credentialId: "cred-1",
credentialPublicKey: new Uint8Array([1]),
counter: BigInt(0),
transports: [],
} as never);
});
describe("POST /api/auth/passkey/login-verify — named refusals", () => {
it("answers an expired or already-used challenge with 401 passkey.challenge.expired", async () => {
vi.mocked(prisma.$queryRaw).mockResolvedValue([] as never);
expect(await refusal(await POST(request()))).toEqual({
status: 401,
code: "passkey.challenge.expired",
data: null,
});
expect(auditLog).toHaveBeenCalledWith(
"auth.login.failed",
expect.objectContaining({
details: { reason: "passkey_challenge_expired" },
}),
);
});
it("answers a challenge begun for another account the same way", async () => {
vi.mocked(prisma.$queryRaw).mockResolvedValue([
{ challenge: "c-1", user_id: "someone-else" },
] as never);
const out = await refusal(await POST(request()));
expect(out.status).toBe(401);
expect(out.code).toBe("passkey.challenge.expired");
});
it("answers a passkey that is not registered here with 404 passkey.unknown", async () => {
vi.mocked(prisma.passkey.findUnique).mockResolvedValue(null as never);
expect(await refusal(await POST(request()))).toEqual({
status: 404,
code: "passkey.unknown",
data: null,
});
expect(auditLog).toHaveBeenCalledWith(
"auth.login.failed",
expect.objectContaining({ details: { reason: "passkey_unknown" } }),
);
});
it("answers a body that is not an assertion with 422 passkey.response.invalid", async () => {
const out = await refusal(await POST(request({ id: "cred-1" })));
expect(out.status).toBe(422);
expect(out.code).toBe("passkey.response.invalid");
});
it("answers an assertion the library refuses with 401 passkey.verification.failed", async () => {
vi.mocked(verifyAuthenticationResponse).mockRejectedValue(
new Error("Unexpected authentication response origin"),
);
const out = await refusal(await POST(request()));
expect(out.status).toBe(401);
expect(out.code).toBe("passkey.verification.failed");
expect(prisma.user.findUnique).not.toHaveBeenCalled();
});
it("still lets a real fault surface as one", async () => {
vi.mocked(prisma.passkey.findUnique).mockRejectedValue(
new Error("connection reset"),
);
const res = await POST(request());
expect(res.status).toBe(500);
});
});
+69 -6
View File
@@ -1,4 +1,8 @@
import { verifyAuthentication } from "@/lib/auth/passkey";
import {
PasskeyAuthError,
verifyAuthentication,
type PasskeyAuthErrorKind,
} from "@/lib/auth/passkey";
import { createSession } from "@/lib/auth/session";
import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
import { recordSignInDevice } from "@/lib/auth/login-alert";
@@ -20,6 +24,43 @@ import { issueAccessAndRefresh } from "@/lib/auth/refresh-token";
import { isOidcOnly } from "@/lib/auth/oidc";
import { completePendingOidcLink } from "@/lib/auth/oidc-pending-link";
const PASSKEY_VERIFICATION_FAILED_CODE = "passkey.verification.failed";
/**
* How each passkey refusal answers. The challenge and verification cases are
* 401 (the sign-in did not prove anything), the unknown credential is 404 (the
* passkey is not registered on this server), and a malformed body is 422.
*/
const PASSKEY_REFUSALS: Record<
PasskeyAuthErrorKind,
{ status: number; errorCode: string; message: string; auditReason: string }
> = {
challenge_expired: {
status: 401,
errorCode: "passkey.challenge.expired",
message: "The sign-in took too long or was already used. Try again.",
auditReason: "passkey_challenge_expired",
},
unknown_passkey: {
status: 404,
errorCode: "passkey.unknown",
message: "This passkey is not registered here.",
auditReason: "passkey_unknown",
},
malformed: {
status: 422,
errorCode: "passkey.response.invalid",
message: "The passkey response could not be read.",
auditReason: "passkey_response_invalid",
},
verification_failed: {
status: 401,
errorCode: PASSKEY_VERIFICATION_FAILED_CODE,
message: "Passkey verification failed",
auditReason: "passkey_verification_failed",
},
};
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.
@@ -55,17 +96,39 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("challengeId and credential required", 422);
}
const { verification, passkey } = await verifyAuthentication(
challengeId,
credential,
);
let outcome: Awaited<ReturnType<typeof verifyAuthentication>>;
try {
outcome = await verifyAuthentication(challengeId, credential);
} catch (err) {
// A known client-side case — an expired challenge, a passkey that is no
// longer registered here, a malformed or refused assertion — answers
// with its own status and code (iOS #116, item 20) so the app can tell
// the person what to do instead of reporting a server error. Anything
// else is a real fault and keeps propagating.
if (!(err instanceof PasskeyAuthError)) throw err;
const refusal = PASSKEY_REFUSALS[err.kind];
await auditLog("auth.login.failed", {
ipAddress: ip,
details: { reason: refusal.auditReason },
});
annotate({
action: { name: "auth.login.passkey.refused" },
meta: { reason: err.kind },
});
return apiError(refusal.message, refusal.status, {
errorCode: refusal.errorCode,
});
}
const { verification, passkey } = outcome;
if (!verification.verified) {
await auditLog("auth.login.failed", {
ipAddress: ip,
details: { reason: "passkey_verification_failed" },
});
return apiError("Passkey verification failed", 401);
return apiError("Passkey verification failed", 401, {
errorCode: PASSKEY_VERIFICATION_FAILED_CODE,
});
}
const user = await prisma.user.findUnique({
@@ -577,4 +577,22 @@ describe("POST /api/ingest/medication — the rate-limit bucket", () => {
);
expect(res.status).toBe(429);
});
it("refuses a dose for a record-only medication with medication.intake.notTracked", async () => {
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue(
null as never,
);
vi.mocked(prisma.medication.findFirst).mockResolvedValue({
id: "med-1",
userId: "user-1",
name: "Metformin",
trackIntake: false,
} as never);
const res = await POST(
postReq({ medicationName: "Metformin", idempotencyKey: "k-record-001" }),
);
expect(res.status).toBe(422);
const body = await res.json();
expect(body.meta?.errorCode).toBe("medication.intake.notTracked");
});
});
+7
View File
@@ -31,6 +31,8 @@ import {
import { consumeForIntake } from "@/lib/medications/inventory/consumption";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { isRecordOnly } from "@/lib/medications/intake-tracking";
import { refuseUntrackedIntake } from "@/lib/medications/route-guards";
import { NextRequest } from "next/server";
const RATE_LIMIT = 60;
@@ -224,6 +226,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("API endpoint for this medication is disabled", 403);
}
// A record-only medication takes no new dose — the same refusal every
// dose-writing route gives (iOS #116, item 15). Checked after the scope
// gate so a token without this medication's scope learns nothing about it.
if (isRecordOnly(medication)) return refuseUntrackedIntake();
// The external ingest path runs without a User record in scope;
// resolve the timezone once — band attribution and the compliance
// recompute below both anchor on the user's local day.
@@ -0,0 +1,183 @@
/**
* `POST /api/measurements/batch` — which issue belongs to which entry.
*
* A whole-batch 422 (`measurement.batch.invalid`) lists every problem in
* `details.issues`. The native client splits such a batch: it drops the
* entries the issues name and resends the rest, so one bad reading does not
* cost the valid ones. That only works if every problem caused by one entry
* carries a path that starts `entries.<n>`, and if the problems that are not
* about an entry (the wrapper itself) never do. Both halves are pinned here,
* and the first case proves the split by resending what is left.
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
user: { update: vi.fn() },
measurement: { findMany: vi.fn() },
$transaction: vi.fn(async (fn: unknown) => {
if (typeof fn === "function") {
return (fn as (tx: unknown) => unknown)(prisma);
}
}),
},
}));
vi.mock("@/lib/measurements/reconcile-external-measurement", () => ({
reconcileExternalMeasurement: vi.fn(
async (_tx: unknown, input: Record<string, unknown>) => ({
status: "inserted",
row: { id: "row-1", ...input },
}),
),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
vi.mock("@/lib/jobs/pr-detection", () => ({
enqueuePrDetection: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/jobs/reminder-satisfy", () => ({
enqueueReminderSatisfy: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserMeasurements: vi.fn(),
}));
vi.mock("@/lib/rollups/after-measurement-mutation", () => ({
afterMeasurementMutation: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/daily/morning-refresh-trigger", () => ({
maybeEnqueueMorningRefresh: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/arrivals/emit-shared", () => ({
emitDataArrival: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function post(body: unknown) {
return POST(
new NextRequest("http://localhost/api/measurements/batch", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}),
);
}
const AT = new Date(Date.now() - 3_600_000).toISOString();
function entry(i: number, overrides: Record<string, unknown> = {}) {
return {
hkIdentifier: "HKQuantityTypeIdentifierHeartRate",
value: 64,
unit: "count/min",
startDate: AT,
endDate: AT,
externalId: `uuid-${i}`,
...overrides,
};
}
type Issue = { path: string; code: string };
async function issuesFor(body: unknown): Promise<Issue[]> {
const res = await post(body);
expect(res.status).toBe(422);
const json = (await res.json()) as {
details: { issues: Issue[] };
meta?: { errorCode?: string };
};
expect(json.meta?.errorCode).toBe("measurement.batch.invalid");
return json.details.issues;
}
/** The entry index an issue names, or null when it names the batch itself. */
function entryIndex(path: string): number | null {
const m = /^entries\.(\d+)(?:\.|$)/.exec(path);
return m ? Number(m[1]) : null;
}
/** The paths that are about the batch wrapper and no single entry. */
const BATCH_LEVEL_PATHS = new Set(["", "entries", "syncTrigger"]);
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 60,
resetAt: Date.now() + 60_000,
});
vi.mocked(prisma.measurement.findMany).mockResolvedValue([]);
});
describe("POST /api/measurements/batch — every entry problem names its entry", () => {
it("names each bad entry as entries.<n>, so the rest of the batch can be resent", async () => {
const entries = [
entry(0),
entry(1, { startDate: "yesterday" }),
entry(2),
entry(3, { value: "64" }),
"not an entry",
entry(5, { hkIdentifier: "", unit: "" }),
entry(6),
];
const issues = await issuesFor({ entries });
expect(issues.length).toBeGreaterThan(0);
for (const issue of issues) {
expect(entryIndex(issue.path), issue.path).not.toBeNull();
}
const bad = new Set(issues.map((i) => entryIndex(i.path)));
expect([...bad].sort()).toEqual([1, 3, 4, 5]);
// The split the client performs: drop the named entries, resend the rest.
const kept = entries.filter((_, i) => !bad.has(i));
const res = await post({ entries: kept });
expect(res.status).toBe(200);
});
it("names the batch itself, never an entry, when the wrapper is wrong", async () => {
const cases: unknown[] = [
[],
{ entries: [] },
{ entries: "x" },
{ entries: [entry(0)], syncTrigger: "sometimes" },
];
let seen = 0;
for (const body of cases) {
for (const issue of await issuesFor(body)) {
expect(BATCH_LEVEL_PATHS.has(issue.path), issue.path).toBe(true);
expect(entryIndex(issue.path)).toBeNull();
seen += 1;
}
}
expect(seen).toBeGreaterThanOrEqual(cases.length);
});
});
@@ -7,6 +7,7 @@ import {
buildCompliancePayload,
complianceCacheKey,
type CompliancePayload,
type CompliancePayloadWire,
} from "@/lib/medications/compliance-payload";
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
import { checkRateLimit } from "@/lib/rate-limit";
@@ -113,6 +114,16 @@ export const GET = apiHandler(
},
});
return apiSuccess(payload);
// The report windows ride the cached cell for the batched read only; this
// route's published shape does not carry them.
const wire: CompliancePayloadWire = {
applicable: payload.applicable,
notApplicableReason: payload.notApplicableReason,
compliance7: payload.compliance7,
compliance30: payload.compliance30,
dailyCompliance: payload.dailyCompliance,
complianceDisplay: payload.complianceDisplay,
};
return apiSuccess(wire);
},
);
@@ -71,6 +71,7 @@ vi.mock("@/lib/medications/inventory/consumption", () => ({
vi.mock("@/lib/medications/route-guards", () => ({
assertMedicationOwnership: vi.fn().mockResolvedValue(null),
assertMedicationTakesIntake: vi.fn().mockResolvedValue(null),
}));
vi.mock("@/lib/cache/invalidate", () => ({
@@ -79,6 +79,7 @@ vi.mock("@/lib/medications/lifecycle", () => ({
vi.mock("@/lib/medications/route-guards", () => ({
assertMedicationOwnership: vi.fn().mockResolvedValue(null),
assertMedicationTakesIntake: vi.fn().mockResolvedValue(null),
}));
vi.mock("@/lib/cache/invalidate", () => ({
+9 -3
View File
@@ -27,7 +27,10 @@ import {
restoreForIntake,
} from "@/lib/medications/inventory/consumption";
import { reconcileOneShotState } from "@/lib/medications/lifecycle";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import {
assertMedicationOwnership,
assertMedicationTakesIntake,
} from "@/lib/medications/route-guards";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
import { dispatchMedicationIntakeWebClear } from "@/lib/notifications/web-push-clear";
@@ -111,8 +114,11 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
const intakeSource = authMethod === "bearer" ? "API" : "WEB";
const { id } = await params;
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper.
const guard = await assertMedicationOwnership(id, user.id);
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper. This is a
// dose write, so the guard also refuses a record-only medication with
// `medication.intake.notTracked` (iOS #116, item 15): its history stays,
// but a new dose would be stored and then counted nowhere.
const guard = await assertMedicationTakesIntake(id, user.id);
if (guard) return guard;
const { data: body, error: jsonError } = await safeJson(request, {
@@ -274,4 +274,70 @@ describe("GET /api/medications/compliance", () => {
);
expect(prisma.medication.findMany).not.toHaveBeenCalled();
});
it("carries one account-wide figure in meta.aggregate, from applicable medications only", async () => {
vi.mocked(prisma.medication.findMany).mockResolvedValue([
medication("med-1"),
medication("med-2"),
{
...medication("mirror-1"),
externalSource: "APPLE_HEALTH",
externalId: "hk-concept-1",
schedules: [],
},
] as never);
const res = await (GET as (req: Request) => Promise<Response>)(
new Request("http://localhost"),
);
const body = await res.json();
const items = body.data as Array<{
compliance7: { totalExpected: number; missed: number };
compliance30: { totalExpected: number };
}>;
expect(body.meta.aggregate.medicationCount).toBe(2);
// Summed over the two scheduled medications; the mirror adds nothing.
expect(body.meta.aggregate.compliance7.totalExpected).toBe(
items[0].compliance7.totalExpected + items[1].compliance7.totalExpected,
);
expect(body.meta.aggregate.compliance30.totalExpected).toBe(
items[0].compliance30.totalExpected + items[1].compliance30.totalExpected,
);
expect(body.meta.aggregate.compliance7.totalExpected).toBeGreaterThan(0);
expect(body.meta).not.toHaveProperty("days");
});
it("answers meta.aggregate null when no medication's adherence applies", async () => {
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
const res = await (GET as (req: Request) => Promise<Response>)(
new Request("http://localhost"),
);
const body = await res.json();
expect(body.meta).toEqual({ aggregate: null });
});
it("adds a complianceN block per medication and to the aggregate on ?days=N", async () => {
vi.mocked(prisma.medication.findMany).mockResolvedValue([
medication("med-1"),
] as never);
const res = await (GET as (req: Request) => Promise<Response>)(
new Request("http://localhost/api/medications/compliance?days=90"),
);
expect(res.status).toBe(200);
const body = await res.json();
const item = body.data[0] as Record<string, { totalExpected: number }>;
expect(item.compliance90.totalExpected).toBeGreaterThan(
item.compliance30.totalExpected,
);
expect(body.meta.days).toBe(90);
expect(body.meta.aggregate.compliance90).toEqual(item.compliance90);
});
it("refuses a window the ledger does not serve", async () => {
const res = await (GET as (req: Request) => Promise<Response>)(
new Request("http://localhost/api/medications/compliance?days=45"),
);
expect(res.status).toBe(422);
expect(prisma.medication.findMany).not.toHaveBeenCalled();
});
});
+87 -13
View File
@@ -1,15 +1,22 @@
import pLimit from "p-limit";
import { z } from "zod/v4";
import { COURSES_COMPLIANCE_SELECT } from "@/lib/analytics/compliance";
import { prisma } from "@/lib/db";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess, apiError } from "@/lib/api-response";
import {
apiError,
apiSuccessWithMeta,
returnAllZodIssues,
} from "@/lib/api-response";
import {
aggregateCompliance,
buildCompliancePayload,
complianceCacheKey,
type CompliancePayload,
} from "@/lib/medications/compliance-payload";
import { COMPLIANCE_REPORT_DAYS } from "@/lib/analytics/compliance";
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
import { checkRateLimit } from "@/lib/rate-limit";
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
@@ -28,9 +35,29 @@ import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
* wire shape — the cards render rates / streak / display block only; the
* detail page keeps the per-id route for the grid.
*/
export const GET = apiHandler(async () => {
/**
* `?days=N` adds a `complianceN` block per medication and to the account
* aggregate, for a report that covers its own window rather than the fixed 7
* and 30 days. Only the windows the ledger serves are accepted; anything else
* is a 422, never a silently rounded window.
*/
const querySchema = z.object({
days: z
.enum(COMPLIANCE_REPORT_DAYS.map(String) as [string, ...string[]])
.transform(Number)
.optional(),
});
export const GET = apiHandler(async (request: Request) => {
const { user, actor } = await requireRecordAuth("read", "medications");
const query = querySchema.safeParse(
Object.fromEntries(new URL(request.url).searchParams),
);
if (!query.success) return returnAllZodIssues(query.error, 422);
const days = query.data.days as
(typeof COMPLIANCE_REPORT_DAYS)[number] | undefined;
// v1.36.0 — the bucket keys on the ACTOR while everything below it scopes to
// the record. Two reasons, and both are the same reason from opposite ends:
// a delegate hammering this route must burn their own allowance rather than
@@ -74,7 +101,7 @@ export const GET = apiHandler(async () => {
// on a cold cache. Warm / stale cells return without touching the
// database at all; `Promise.all` keeps the response in list order.
const limit = pLimit(3);
const items = await Promise.all(
const results = await Promise.all(
medications.map((medication) =>
limit(async () => {
const payload = await cachedSwr(
@@ -83,22 +110,69 @@ export const GET = apiHandler(async () => {
() => buildCompliancePayload(medication, user.id, userTz),
annotate,
);
return {
medicationId: medication.id,
applicable: payload.applicable,
notApplicableReason: payload.notApplicableReason,
compliance7: payload.compliance7,
compliance30: payload.compliance30,
complianceDisplay: payload.complianceDisplay,
};
return { payload, medicationId: medication.id };
}),
),
);
// A cell cached before the report windows existed carries none; it reads
// as the not-applicable placeholder until its next rebuild rather than
// failing the whole read.
const windowFor = (payload: CompliancePayload, n: number) =>
payload.reportWindows?.[n as keyof CompliancePayload["reportWindows"]];
const items = results.map(({ payload, medicationId }) => ({
medicationId,
applicable: payload.applicable,
notApplicableReason: payload.notApplicableReason,
compliance7: payload.compliance7,
compliance30: payload.compliance30,
...(days !== undefined && days !== 30
? { [`compliance${days}`]: windowFor(payload, days) ?? null }
: {}),
complianceDisplay: payload.complianceDisplay,
}));
// The account-wide figure the widget ring and the report show, built only
// from medications whose adherence means something (`applicable`).
const applicable = results
.map((r) => r.payload)
.filter((payload) => payload.applicable);
const reportWindow =
days !== undefined && days !== 30
? applicable.map((payload) => windowFor(payload, days))
: [];
const aggregate =
applicable.length === 0
? null
: {
compliance7: aggregateCompliance(
applicable.map((p) => p.compliance7),
),
compliance30: aggregateCompliance(
applicable.map((p) => p.compliance30),
),
...(days !== undefined && days !== 30
? {
[`compliance${days}`]: reportWindow.every(Boolean)
? aggregateCompliance(
reportWindow as NonNullable<
(typeof reportWindow)[number]
>[],
)
: null,
}
: {}),
medicationCount: applicable.length,
};
annotate({
action: { name: "medication.compliance_summary.read" },
meta: { count: items.length },
meta: { count: items.length, days: days ?? null },
});
return apiSuccess(items);
return apiSuccessWithMeta(items, {
aggregate,
...(days !== undefined ? { days } : {}),
});
});
@@ -381,6 +381,45 @@ describe("POST /api/medications/intake", () => {
expect(res.status).toBe(422);
});
it("refuses to resolve an open dose of a record-only medication with medication.intake.notTracked", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({
id: "e1",
userId: "user-1",
medicationId: "m1",
takenAt: null,
skipped: false,
scheduledFor: new Date("2026-05-18T10:00:00.000Z"),
medication: { trackIntake: false },
} as never);
const res = await POST(req({ intakeId: "e1", status: "taken" }));
expect(res.status).toBe(422);
const body = await res.json();
expect(body.meta?.errorCode).toBe("medication.intake.notTracked");
expect(prisma.medicationIntakeEvent.update).not.toHaveBeenCalled();
expect(consumeForIntake).not.toHaveBeenCalled();
});
it("still lets the owner correct a dose a record-only medication already has", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({
id: "e1",
userId: "user-1",
medicationId: "m1",
takenAt: new Date("2026-05-18T10:05:00.000Z"),
skipped: false,
scheduledFor: new Date("2026-05-18T10:00:00.000Z"),
medication: { trackIntake: false },
} as never);
vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValue({
id: "e1",
skipped: true,
takenAt: null,
} as never);
const res = await POST(req({ intakeId: "e1", status: "skipped" }));
expect(res.status).toBe(200);
});
it("marks event as skipped", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({
@@ -244,6 +244,29 @@ describe("POST /api/medications/intake/bulk — v1.8.2 reconcile", () => {
vi.useRealTimers();
});
it("skips an entry for a record-only medication as intake_not_tracked and keeps the rest", async () => {
vi.mocked(prisma.medication.findMany).mockResolvedValue([
{ id: "med-1", trackIntake: false },
] as never);
const res = await POST(
postReq({
entries: [
{
medicationId: "med-1",
scheduledFor: "2026-06-15T05:00:00.000Z",
takenAt: "2026-06-15T05:01:00.000Z",
},
],
}),
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.entries).toEqual([
{ index: 0, status: "skipped", reason: "intake_not_tracked" },
]);
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
});
it("C2 — a pending echo onto an already-TAKEN slot is reported duplicate, NOT a downgrade", async () => {
// Existing TAKEN row at the 07:00 slot. The bulk entry is a pending
// echo (no takenAt, skipped false) — must NOT clear takenAt.
@@ -86,6 +86,10 @@ import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
import { dispatchMedicationIntakeWebClearBulk } from "@/lib/notifications/web-push-clear";
import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses";
import {
INTAKE_NOT_TRACKED_REASON,
isRecordOnly,
} from "@/lib/medications/intake-tracking";
const MAX_ENTRIES_PER_BATCH = 500;
const BATCH_RATE_LIMIT_MAX = 60;
@@ -256,6 +260,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
trackInjectionSites: true,
allowedInjectionSites: true,
externalSource: true,
trackIntake: true,
},
});
const ownedSet = new Set(ownedMedications.map((m) => m.id));
@@ -366,6 +371,16 @@ async function postBulk(request: NextRequest): Promise<Response> {
continue;
}
// A record-only medication takes no new dose (iOS #116, item 15). The
// single-dose routes answer 422 `medication.intake.notTracked`; here the
// same decision is one entry's outcome, so the rest of the batch lands.
if (isRecordOnly(medById.get(entry.medicationId) ?? {})) {
const reason = INTAKE_NOT_TRACKED_REASON;
skipped.push({ index: i, reason });
results.push({ index: i, status: "skipped", reason });
continue;
}
// Both dedup keys on this route are only idempotent while the value
// is STABLE across client launches: `(userId, externalId)` for the
// Apple dose event, and the `@unique` `idempotency_key` column for
+16 -1
View File
@@ -54,7 +54,11 @@ import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake
import { dispatchMedicationIntakeWebClear } from "@/lib/notifications/web-push-clear";
import { notifyDelegatedIntake } from "@/lib/notifications/delegated-intake";
import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses";
import { TRACKED_INTAKE_EVENT_WHERE } from "@/lib/medications/intake-tracking";
import {
TRACKED_INTAKE_EVENT_WHERE,
isRecordOnly,
} from "@/lib/medications/intake-tracking";
import { refuseUntrackedIntake } from "@/lib/medications/route-guards";
// The query + body schemas moved to `@/lib/validations/medication` when this
// endpoint joined the published contract: the OpenAPI registry generates from
@@ -265,6 +269,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
deliveryForm: true,
trackInjectionSites: true,
allowedInjectionSites: true,
trackIntake: true,
},
},
},
@@ -273,6 +278,16 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("Intake event not found", 404);
}
// A record-only medication keeps the doses it already has, and an owner may
// still correct one of those (that is an edit of history). What it no longer
// takes is a NEW dose: resolving or snoozing an event that is still open
// would record one, and it would then be counted nowhere (iOS #116, item
// 15). Refused by name, the same code the per-medication route answers with.
const stillOpen = existing.takenAt === null && !existing.skipped;
if (stillOpen && isRecordOnly(existing.medication)) {
return refuseUntrackedIntake();
}
// v1.36.1 — this route is admitted for delegates because marking a dose is
// the thing a person standing next to the patient needs to do. It is an
// UPDATE, though, and two of the transitions it can express are not that.
@@ -0,0 +1,86 @@
/**
* `GET /api/mood/insights?days=N` serves the stability score for the period
* a client shows. The aggregate is cached once per user with every window in
* it; the route picks one, names it, and keeps the per-window map off the
* wire.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
}));
vi.mock("@/lib/cache/server-cache", () => ({
cachedSwr: vi.fn(),
caches: { moodInsights: {} },
}));
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 { getSession } from "@/lib/auth/session";
import { cachedSwr } from "@/lib/cache/server-cache";
import { requireModuleEnabled } from "@/lib/modules/gate";
const year = { score: 40, stdDev: 0.9, band: "variable", days: 300 };
const month = { score: 90, stdDev: 0.2, band: "verySteady", days: 28 };
const AGGREGATE = {
summary: { totalEntries: 300 },
heatmap: { windowDays: 365, cells: [] },
tags: [],
stability: year,
stabilityByWindow: { 30: month, 90: null, 180: year, 365: year },
};
const call = (query = "") =>
(GET as (req: Request) => Promise<Response>)(
new Request(`http://localhost/api/mood/insights${query}`),
);
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue({
session: { id: "s", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "u", role: "USER", timezone: "UTC" },
} as never);
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never);
vi.mocked(cachedSwr).mockResolvedValue(AGGREGATE as never);
});
describe("GET /api/mood/insights — stability per period", () => {
it("serves the whole year by default and names the window", async () => {
const body = await (await call()).json();
expect(body.data.stability).toEqual(year);
expect(body.data.stabilityWindowDays).toBe(365);
expect(body.data).not.toHaveProperty("stabilityByWindow");
});
it("serves the chosen window as stability on ?days=30", async () => {
const body = await (await call("?days=30")).json();
expect(body.data.stability).toEqual(month);
expect(body.data.stabilityWindowDays).toBe(30);
});
it("answers null, not the year's score, when the window has too few days", async () => {
const body = await (await call("?days=90")).json();
expect(body.data.stability).toBeNull();
expect(body.data.stabilityWindowDays).toBe(90);
});
it("refuses a window it does not offer", async () => {
const res = await call("?days=45");
expect(res.status).toBe(422);
expect(cachedSwr).not.toHaveBeenCalled();
});
});
+38 -3
View File
@@ -1,10 +1,14 @@
import { apiSuccess } from "@/lib/api-response";
import { z } from "zod/v4";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
import {
fetchMoodAggregates,
MOOD_STABILITY_WINDOWS,
type MoodAggregates,
type MoodStabilityWindow,
} from "@/lib/insights/mood-aggregates";
import { requireModuleEnabled } from "@/lib/modules/gate";
@@ -26,9 +30,28 @@ export const dynamic = "force-dynamic";
* aggregate is served immediately and a single background recompute
* warms a fresh one, so an active logger never re-pays the cold compute.
*/
export const GET = apiHandler(async () => {
/**
* `?days=N` cuts `stability` to the trailing N days, so a period picker shows
* the steadiness of the period it shows. Without it the score covers the
* whole year, as it always has; `stabilityWindowDays` names the window the
* served score covers either way.
*/
const querySchema = z.object({
days: z
.enum(MOOD_STABILITY_WINDOWS.map(String) as [string, ...string[]])
.transform((v) => Number(v) as MoodStabilityWindow)
.optional(),
});
export const GET = apiHandler(async (request: Request) => {
const { user } = await requireAuth();
const query = querySchema.safeParse(
Object.fromEntries(new URL(request.url).searchParams),
);
if (!query.success) return returnAllZodIssues(query.error, 422);
const stabilityWindowDays: MoodStabilityWindow = query.data.days ?? 365;
// Per-domain gate: the mood-insights aggregate surface only serves the
// mood module's analysis page, so it must be gated on the mood module
// (mirrors `/api/insights/mood-status`). Disabled ⇒ 403 module.disabled.
@@ -51,5 +74,17 @@ export const GET = apiHandler(async () => {
},
});
return apiSuccess(result);
// The per-window map is the cache's, not the wire's: the chosen window is
// served as `stability`. An aggregate cached before the map existed has
// only the year, so a shorter window reads as not yet computed (`null`)
// until the next rebuild rather than as the year's score under a new name.
const { stabilityByWindow, ...wire } = result;
return apiSuccess({
...wire,
stability:
stabilityWindowDays === 365
? result.stability
: (stabilityByWindow?.[stabilityWindowDays] ?? null),
stabilityWindowDays,
});
});
+20 -66
View File
@@ -1,33 +1,14 @@
import { notFound } from "next/navigation";
import type { JSX } from "react";
import { AccessSection } from "@/components/settings/access-section";
import { AccountSection } from "@/components/settings/account-section";
import { AnamnesisSection } from "@/components/settings/anamnesis-section";
import { SecuritySection } from "@/components/settings/security-section";
import { AboutSection } from "@/components/settings/about-section";
import { AdvancedSection } from "@/components/settings/advanced-section";
import { AiSection } from "@/components/settings/ai-section";
import { ApiSection } from "@/components/settings/api-section";
import { CoachSection } from "@/components/settings/coach-section";
import { EnvironmentSection } from "@/components/settings/environment-section";
import { ExportSection } from "@/components/settings/export-section";
import { GesundheitsakteSection } from "@/components/settings/gesundheitsakte-section";
import { IntegrationsSection } from "@/components/settings/integrations-section";
import { LayoutSection } from "@/components/settings/layout-section";
import { McpSection } from "@/components/settings/mcp-section";
import { ModulesSection } from "@/components/settings/modules-section";
import { NotificationsSection } from "@/components/settings/notifications-section";
import { PrivacySection } from "@/components/settings/privacy-section";
import { ScoreSection } from "@/components/settings/score-section";
import { SourcesSection } from "@/components/settings/sources-section";
import { ThresholdsSection } from "@/components/settings/thresholds-section";
import {
SETTINGS_SECTION_SLUGS,
isSettingsSectionSlug,
type SettingsSectionSlug,
} from "@/components/settings/section-slugs";
import { SettingsShell } from "@/components/settings/settings-shell";
import {
SettingsSectionLoader,
SettingsSectionPreload,
} from "@/components/settings/settings-section-loader";
import { RecordSettingsSectionGate } from "@/components/settings/record-settings-section-gate";
import { getIntegrationCallbackUrls } from "@/lib/integrations/callback-urls";
@@ -47,44 +28,6 @@ export function generateStaticParams() {
return SETTINGS_SECTION_SLUGS.map((section) => ({ section }));
}
/**
* Integrations is the one section that needs a server-resolved value: the
* OAuth callback URL each provider registers. It is read from the runtime env
* here, per request, and handed to the client section as a prop, because a
* `NEXT_PUBLIC_*` read inside a client module is inlined at build time and the
* published image is built without it.
*/
function IntegrationsSectionWithCallbackUrls() {
return <IntegrationsSection callbackUrls={getIntegrationCallbackUrls()} />;
}
const SECTION_COMPONENTS: Record<
SettingsSectionSlug,
() => JSX.Element | null
> = {
account: AccountSection,
access: AccessSection,
security: SecuritySection,
modules: ModulesSection,
about: AboutSection,
ai: AiSection,
coach: CoachSection,
integrations: IntegrationsSectionWithCallbackUrls,
sources: SourcesSection,
notifications: NotificationsSection,
layout: LayoutSection,
environment: EnvironmentSection,
anamnesis: AnamnesisSection,
score: ScoreSection,
thresholds: ThresholdsSection,
api: ApiSection,
mcp: McpSection,
gesundheitsakte: GesundheitsakteSection,
export: ExportSection,
advanced: AdvancedSection,
privacy: PrivacySection,
};
interface PageProps {
// Next.js 16 made route `params` an async Promise. We `await` it before use.
params: Promise<{ section: string }>;
@@ -100,10 +43,13 @@ export default async function SettingsSectionPage({ params }: PageProps) {
notFound();
}
// `SECTION_COMPONENTS` is an exhaustive Record over the slug union, so a
// slug without a wired component is a compile error — the old runtime
// `<SectionPlaceholder>` fallback could never render and was removed.
const SectionComponent = SECTION_COMPONENTS[section];
// Each section is its own chunk (see `SettingsSectionLoader`), so the route
// ships the one it shows rather than all twenty-one. Integrations is the
// one section that needs a server-resolved value: the OAuth callback URL
// each provider registers, read from the runtime env here, per request,
// because a `NEXT_PUBLIC_*` read inside a client module is inlined at build
// time and the published image is built without it.
//
// v1.18.6.1 — the heading + subtitle (and the Layout-hub "← back" link)
// live in `<SettingsShell>`, which places them in their own grid row
// spanning only the content column so the left nav's first item lines up
@@ -112,12 +58,20 @@ export default async function SettingsSectionPage({ params }: PageProps) {
// `settings-section-<slug>-title` `aria-labelledby` linkage still resolves.
return (
<SettingsShell active={section}>
<SettingsSectionPreload section={section} />
<RecordSettingsSectionGate section={section}>
<section
aria-labelledby={`settings-section-${section}-title`}
className="space-y-6"
>
<SectionComponent />
<SettingsSectionLoader
section={section}
callbackUrls={
section === "integrations"
? getIntegrationCallbackUrls()
: undefined
}
/>
</section>
</RecordSettingsSectionGate>
</SettingsShell>
@@ -0,0 +1,247 @@
"use client";
/**
* `<MeasurementMaintenanceCard>` — start the measurement table maintenance
* and follow it, from the admin console instead of a `curl` against the
* route (runbook: `docs/ops/measurement-maintenance.md`).
*
* Calm on purpose. The button asks once, says what the run does and that it
* can take a while, and then the card shows where the run stands: waiting,
* running since when, finished, or stopped. It polls only while a run is
* queued or running. It stays disabled while the compaction-tombstone purge
* is still at work, because a run started then would refuse at once.
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Database, Loader2, Wrench } from "lucide-react";
import { toast } from "sonner";
import { StatTile } from "@/components/admin/_stat-tile";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { SettingsCard } from "@/components/settings/settings-card";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import { formatBytes } from "@/lib/format/bytes";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { MeasurementMaintenanceStatus } from "@/lib/jobs/measurement-maintenance-status";
import { queryKeys } from "@/lib/query-keys";
import { getApiErrorMessage } from "./_shared";
const ENDPOINT = "/api/admin/maintenance/measurements";
/** How often the card asks while a run is waiting or working. */
const POLL_MS = 10_000;
const GIB = 1024 * 1024 * 1024;
function sizeLabel(
bytes: number,
fmt: ReturnType<typeof useFormatters>,
): string {
// The shared formatter stops at MB; this table is routinely gigabytes.
return bytes >= GIB
? `${fmt.number(bytes / GIB, 2)} GB`
: formatBytes(bytes, fmt);
}
export function MeasurementMaintenanceCard() {
const { t } = useTranslations();
const fmt = useFormatters();
const queryClient = useQueryClient();
const status = useQuery({
queryKey: queryKeys.adminMeasurementMaintenance(),
queryFn: () =>
apiGet<MeasurementMaintenanceStatus>(ENDPOINT, {
credentials: "include",
}),
refetchInterval: (query) => {
const state = query.state.data?.run?.state;
return state === "queued" || state === "running" ? POLL_MS : false;
},
});
const start = useMutation({
mutationFn: async (): Promise<{ enqueued: boolean }> => {
const res = await apiFetchRaw(ENDPOINT, {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify({ vacuum: true, reindex: true }),
});
if (!res.ok) throw new Error(await getApiErrorMessage(res));
return ((await res.json()) as { data: { enqueued: boolean } }).data;
},
onSuccess: ({ enqueued }) => {
if (enqueued) toast.success(t("admin.section.maintenance.started"));
else toast.info(t("admin.section.maintenance.alreadyQueued"));
void queryClient.invalidateQueries({
queryKey: queryKeys.adminMeasurementMaintenance(),
});
},
onError: (err: Error) => {
toast.error(err.message || t("admin.section.maintenance.startFailed"));
},
});
const header = (
<SettingsCardHeader
icon={Database}
title={t("admin.section.maintenance.title")}
description={t("admin.section.maintenance.description")}
/>
);
if (status.isError) {
return (
<QueryErrorCard
title={t("admin.section.maintenance.loadError")}
onRetry={() => void status.refetch()}
/>
);
}
const s = status.data;
const run = s?.run ?? null;
const active = run?.state === "queued" || run?.state === "running";
const blocked = !s || !s.available || s.purgePending || active;
return (
<SettingsCard data-testid="admin-measurement-maintenance">
{header}
<p className="text-sm">{t("admin.section.maintenance.body")}</p>
{s?.sizes ? (
<div className="grid gap-3 sm:grid-cols-2">
<StatTile
label={t("admin.section.maintenance.tableSize")}
value={sizeLabel(s.sizes.tableBytes, fmt)}
/>
<StatTile
label={t("admin.section.maintenance.indexSize")}
value={sizeLabel(s.sizes.indexBytes, fmt)}
/>
</div>
) : null}
<div
className="text-sm"
role="status"
aria-live="polite"
data-slot="maintenance-state"
data-state={status.isLoading ? "loading" : (run?.state ?? "idle")}
>
{status.isLoading ? (
<span className="text-muted-foreground flex items-center gap-2">
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
{t("admin.section.maintenance.loading")}
</span>
) : !s?.available ? (
<span className="text-muted-foreground">
{t("admin.section.maintenance.unavailable")}
</span>
) : run?.state === "queued" ? (
<span className="flex items-center gap-2">
<Loader2
className="text-muted-foreground size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
{t("admin.section.maintenance.state.queued")}
</span>
) : run?.state === "running" ? (
<span className="flex items-center gap-2">
<Loader2
className="text-muted-foreground size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
{t("admin.section.maintenance.state.running", {
since: fmt.time(run.startedAt ?? run.requestedAt),
})}
</span>
) : run?.state === "completed" ? (
<span>
{run.outcome === "refused_purge_running"
? t("admin.section.maintenance.state.refusedPurge")
: run.outcome === "already_running"
? t("admin.section.maintenance.state.alreadyRunning")
: t("admin.section.maintenance.state.completed", {
at: fmt.dateTime(run.finishedAt ?? run.requestedAt),
})}
</span>
) : run?.state === "failed" ? (
<span className="text-destructive">
{t("admin.section.maintenance.state.failed", {
at: fmt.dateTime(run.finishedAt ?? run.requestedAt),
})}
</span>
) : (
<span className="text-muted-foreground">
{t("admin.section.maintenance.state.idle")}
</span>
)}
</div>
{s?.available && s.purgePending ? (
<p className="text-muted-foreground text-xs">
{t("admin.section.maintenance.purgePending")}
</p>
) : null}
<SettingsCardActions>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
size="sm"
className="min-h-11 sm:min-h-9"
disabled={blocked || start.isPending}
data-testid="admin-maintenance-start"
>
{start.isPending ? (
<Loader2
className="size-3.5 animate-spin motion-reduce:animate-none"
aria-hidden
/>
) : (
<Wrench className="size-3.5" aria-hidden />
)}
{t("admin.section.maintenance.start")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("admin.section.maintenance.confirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("admin.section.maintenance.confirmBody")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("admin.section.maintenance.cancel")}
</AlertDialogCancel>
<AlertDialogAction onClick={() => start.mutate()}>
{t("admin.section.maintenance.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</SettingsCardActions>
</SettingsCard>
);
}
@@ -336,3 +336,40 @@ describe("<UploadStateCard>", () => {
expect(html).toContain("1 GB");
});
});
describe('<DocumentCard variant="tile"> (the archive grid)', () => {
it("puts a 4:3 preview above the title, falling back to the kind icon", () => {
const html = render(
<DocumentCard
variant="tile"
document={doc()}
selected={false}
onToggleSelected={noop}
onOpen={noop}
highlighted={false}
/>,
);
expect(html).toContain('data-variant="tile"');
const preview = html.indexOf('data-slot="document-preview"');
expect(preview).toBeGreaterThan(-1);
expect(html).toMatch(/data-slot="document-preview"[^>]*aspect-\[4\/3\]/);
// Preview first, title after it.
expect(html.indexOf("MRT Knie")).toBeGreaterThan(preview);
// No thumbnail rendered yet: the kind icon fills the preview, no <img>.
expect(html).not.toContain("<img");
});
it("keeps the row card as the default for every other surface", () => {
const html = render(
<DocumentCard
document={doc()}
selected={false}
onToggleSelected={noop}
onOpen={noop}
highlighted={false}
/>,
);
expect(html).toContain('data-variant="row"');
expect(html).not.toContain('data-slot="document-preview"');
});
});
@@ -0,0 +1,52 @@
/**
* The archive grid's shape: two tiles side by side on a phone, three or four
* on a desktop, measured from the grid's own width (the AuthShell inset is
* already taken off by the time the grid measures itself).
*/
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import {
columnsForWidth,
DocumentMonthHeading,
estimatedRowHeight,
} from "../document-month-grid";
describe("columnsForWidth", () => {
it("lays a 390 px phone out in two columns, the narrowest screens in one", () => {
// 390 viewport minus the shell's 16 px gutters.
expect(columnsForWidth(358)).toBe(2);
expect(columnsForWidth(320 - 32)).toBe(1);
});
it("uses three columns on a small desktop and four on a wide one", () => {
expect(columnsForWidth(1024 - 48)).toBe(3);
// 1440 viewport: the shell caps the container at 1280 minus its gutters.
expect(columnsForWidth(1280 - 48)).toBe(4);
});
it("never asks for a fifth column", () => {
expect(columnsForWidth(4000)).toBe(4);
});
});
describe("estimatedRowHeight", () => {
it("grows with the tile's width, so the first guess is close to the measured row", () => {
expect(estimatedRowHeight(358, 2)).toBeLessThan(
estimatedRowHeight(1232, 2),
);
expect(estimatedRowHeight(358, 2)).toBeGreaterThan(200);
});
});
describe("<DocumentMonthHeading>", () => {
it("keeps the month heading as the grid's section label", () => {
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<DocumentMonthHeading label="March 2026" />
</I18nProvider>,
);
expect(html).toMatch(/^<h2 [^>]*uppercase[^>]*>March 2026<\/h2>$/);
});
});
+112 -67
View File
@@ -52,7 +52,14 @@ export function DocumentCard({
tabIndex = 0,
onCardFocus,
onPrefetch,
variant = "row",
}: {
/**
* `row` (default): the compact card with the preview at its leading edge.
* `tile`: the archive grid's tile, the preview filling the top of the card
* so a page is recognisable before its title is read.
*/
variant?: "row" | "tile";
document: InboundDocumentDto;
selected: boolean;
/** `range` = extend the selection from the last anchor (shift-click). */
@@ -127,9 +134,76 @@ export function DocumentCard({
wasProcessingRef.current = processing;
}, [processing]);
const tile = variant === "tile";
const showThumbnail = !!thumbnailUrl && !thumbnailFailed;
// An object URL over bytes the app transport already fetched, not a
// `/api/…` subresource: the browser issues a subresource itself and so
// cannot carry the record-session assertion, which the fence refuses on any
// session that has been inside a shared record. `next/image` is wrong here
// for the older reason too — it would proxy a PHI blob we deliberately
// never cache. Decorative (alt="") — the title beside it names the
// document. The laziness that `loading="lazy"` used to provide now lives in
// the hook's null path: a card with no thumbnail, or one whose thumbnail
// already failed, issues no request at all. onError falls back to the kind
// icon.
const thumbnail = showThumbnail ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={thumbnailUrl}
alt=""
decoding="async"
onError={() => setThumbFailed(true)}
className={cn("size-full object-cover", tile && "object-top")}
/>
) : null;
const selection = onToggleSelected ? (
<Checkbox
checked={selected}
// Explicit click handling instead of onCheckedChange: the mouse
// event carries `shiftKey` for file-manager range selection.
// preventDefault stops Radix's internal toggle (state is fully
// controlled by the page's selection set anyway).
onClick={(e) => {
e.preventDefault();
onToggleSelected(document.id, e.shiftKey);
}}
aria-label={t("documents.card.selectLabel", { title })}
className={cn(
"relative z-10 shrink-0 transition-opacity",
"opacity-0 group-focus-within:opacity-100 group-hover:opacity-100",
selected && "opacity-100",
// On a tile the box floats over the preview, which can be any
// colour; the card background behind it keeps it readable.
tile && "bg-background absolute top-4 right-4",
)}
/>
) : null;
const titleBlock = (
<div className="flex min-w-0 flex-1 flex-col">
<p className="truncate text-sm font-medium">{title}</p>
<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>
);
return (
<Card
data-slot="document-card"
data-variant={variant}
data-document-id={document.id}
className={cn(
"group relative h-full gap-2 py-3 transition-shadow md:py-4",
@@ -137,76 +211,47 @@ export function DocumentCard({
highlighted && "ring-primary ring-2",
)}
>
<CardContent className="flex h-full flex-col gap-2 px-4">
<div className="flex items-start gap-2">
{thumbnailUrl && !thumbnailFailed ? (
// Leading-edge preview tile. Decorative (alt="") — the title
// beside it names the document. The laziness that `loading="lazy"`
// used to provide now lives in the hook's null path: a card with no
// thumbnail, or one whose thumbnail already failed, issues no
// request at all. onError falls back to the kind icon.
<CardContent
className={cn(
"flex h-full flex-col gap-2",
// A dense tile (UI standards §1): two or more of them sit side by
// side on a phone, where the default inset would eat the preview.
tile ? "px-3" : "px-4",
)}
>
{tile ? (
<>
<span
data-slot="document-thumbnail"
className="bg-muted mt-0.5 block size-12 shrink-0 overflow-hidden rounded-md"
data-slot="document-preview"
className="bg-muted flex aspect-[4/3] w-full items-center justify-center overflow-hidden rounded-lg"
>
{/* An object URL over bytes the app transport already fetched,
not a `/api/…` subresource: the browser issues a subresource
itself and so cannot carry the record-session assertion, which
the fence refuses on any session that has been inside a shared
record. `next/image` is wrong here for the older reason too —
it would proxy a PHI blob we deliberately never cache. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={thumbnailUrl}
alt=""
decoding="async"
onError={() => setThumbFailed(true)}
className="size-full object-cover"
/>
</span>
) : (
<Icon
className="text-foreground mt-0.5 size-5 shrink-0"
aria-hidden
/>
)}
<div className="flex min-w-0 flex-1 flex-col">
<p className="truncate text-sm font-medium">{title}</p>
<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>
{onToggleSelected && (
<Checkbox
checked={selected}
// Explicit click handling instead of onCheckedChange: the mouse
// event carries `shiftKey` for file-manager range selection.
// preventDefault stops Radix's internal toggle (state is fully
// controlled by the page's selection set anyway).
onClick={(e) => {
e.preventDefault();
onToggleSelected(document.id, e.shiftKey);
}}
aria-label={t("documents.card.selectLabel", { title })}
className={cn(
"relative z-10 shrink-0 transition-opacity",
"opacity-0 group-focus-within:opacity-100 group-hover:opacity-100",
selected && "opacity-100",
{thumbnail ?? (
<Icon className="text-foreground size-8" aria-hidden />
)}
/>
)}
</div>
</span>
{titleBlock}
{selection}
</>
) : (
<div className="flex items-start gap-2">
{thumbnail ? (
// Leading-edge preview tile.
<span
data-slot="document-thumbnail"
className="bg-muted mt-0.5 block size-12 shrink-0 overflow-hidden rounded-md"
>
{thumbnail}
</span>
) : (
<Icon
className="text-foreground mt-0.5 size-5 shrink-0"
aria-hidden
/>
)}
{titleBlock}
{selection}
</div>
)}
{document.conditionLinks.length > 0 ||
document.servingClass === "attachment" ||
processing ||
@@ -0,0 +1,92 @@
"use client";
/**
* The archive's month grid: a month heading, then that month's documents as
* preview tiles, so a page is recognisable by its look before its title is
* read. The timeline owns windowing and keyboard focus; this module owns how
* one windowed item is laid out and how many tiles share a row.
*
* Two tiles side by side on a phone (one on the narrowest screens), three or
* four on a desktop. The count comes from the grid's measured width, not from
* viewport breakpoints, so the virtualizer's row model and the painted grid
* can never disagree.
*/
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
import { DocumentCard } from "./document-card";
/** Measured grid width → tiles per row. */
export function columnsForWidth(width: number): number {
if (width >= 1100) return 4;
if (width >= 760) return 3;
if (width >= 330) return 2;
return 1;
}
/** Gap between tiles and between rows, in px (`gap-4` / `pb-4`). */
const GRID_GAP_PX = 16;
/**
* First guess at a row's height before the virtualizer measures it: a 4:3
* preview across the tile's width plus the title and meta lines under it.
*/
export function estimatedRowHeight(width: number, columns: number): number {
const cols = Math.max(1, columns);
const tileWidth = Math.max(0, (width - GRID_GAP_PX * (cols - 1)) / cols);
return Math.round(tileWidth * 0.75) + 96 + GRID_GAP_PX;
}
export function DocumentMonthHeading({ label }: { label: string }) {
return (
<h2 className="text-muted-foreground pt-2 pb-3 text-xs font-medium tracking-wide uppercase">
{label}
</h2>
);
}
export function DocumentMonthRow({
documents,
columns,
selectedIds,
onToggleSelected,
onOpen,
onDelete,
highlightId,
rovingId,
onCardFocus,
onPrefetch,
}: {
documents: InboundDocumentDto[];
columns: number;
selectedIds: ReadonlySet<string>;
onToggleSelected?: (id: string, range?: boolean) => void;
onOpen: (id: string) => void;
onDelete?: (id: string) => void;
highlightId: string | null;
rovingId: string | null;
onCardFocus: (id: string) => void;
onPrefetch?: (id: string) => void;
}) {
return (
<div
data-slot="document-month-row"
className="grid gap-4 pb-4"
style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }}
>
{documents.map((doc) => (
<DocumentCard
key={doc.id}
variant="tile"
document={doc}
selected={selectedIds.has(doc.id)}
onToggleSelected={onToggleSelected}
onOpen={onOpen}
onDelete={onDelete}
highlighted={highlightId === doc.id}
tabIndex={rovingId === doc.id ? 0 : -1}
onCardFocus={onCardFocus}
onPrefetch={onPrefetch}
/>
))}
</div>
);
}
+36 -38
View File
@@ -1,7 +1,8 @@
"use client";
/**
* The vault timeline: a virtualized, month-sectioned card grid windowed
* The vault timeline: a virtualized, month-sectioned grid of preview tiles
* (`document-month-grid.tsx`) windowed
* with `@tanstack/react-virtual` over the shell's scroll container
* (`#main-content` — single scroll owner per the design standards; the
* timeline never brings its own scrollport). The flat item list (month
@@ -9,8 +10,9 @@
* mounted DOM stays bounded (< ~400 nodes) regardless of corpus size.
*
* Columns are measured, not breakpoint-classed: a ResizeObserver on the
* grid container drives the per-row chunking (4 / 3 / 2 / 1), which keeps
* the virtualizer's row model and the painted grid in lockstep.
* grid container drives the per-row chunking (4 / 3 on a desktop, 2 on a
* phone, 1 on the narrowest screens), which keeps the virtualizer's row model
* and the painted grid in lockstep.
*
* In-flight / failed uploads render as a small non-virtualized grid above
* the timeline — they are few (client concurrency 3) and must appear
@@ -28,20 +30,18 @@ import { useVirtualizer } from "@tanstack/react-virtual";
import { useTranslations } from "@/lib/i18n/context";
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
import { DocumentCard, UploadStateCard } from "./document-card";
import { UploadStateCard } from "./document-card";
import {
columnsForWidth,
DocumentMonthHeading,
DocumentMonthRow,
estimatedRowHeight,
} from "./document-month-grid";
import type { UploadQueueItem } from "./use-document-upload";
import { buildTimelineItems, formatMonthLabel } from "./vault-utils";
const SCROLL_CONTAINER_ID = "main-content";
/** Measured-width → column count (desktop 4/3, tablet 2, phone 1). */
function columnsForWidth(width: number): number {
if (width >= 1200) return 4;
if (width >= 900) return 3;
if (width >= 600) return 2;
return 1;
}
export function DocumentTimeline({
documents,
uploadItems,
@@ -77,6 +77,7 @@ export function DocumentTimeline({
const { t, locale } = useTranslations();
const listRef = useRef<HTMLDivElement | null>(null);
const [columns, setColumns] = useState(1);
const [gridWidth, setGridWidth] = useState(0);
const [scrollMargin, setScrollMargin] = useState(0);
// Roving tabindex over the card grid: exactly one card is tabbable; the
@@ -88,7 +89,10 @@ export function DocumentTimeline({
useEffect(() => {
const el = listRef.current;
if (!el) return;
const update = () => setColumns(columnsForWidth(el.clientWidth));
const update = () => {
setColumns(columnsForWidth(el.clientWidth));
setGridWidth(el.clientWidth);
};
update();
const ro = new ResizeObserver(update);
ro.observe(el);
@@ -127,7 +131,10 @@ export function DocumentTimeline({
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => document.getElementById(SCROLL_CONTAINER_ID),
estimateSize: (index) => (items[index].type === "month" ? 40 : 140),
estimateSize: (index) =>
items[index].type === "month"
? 40
: estimatedRowHeight(gridWidth, columns),
getItemKey: (index) => items[index].key,
overscan: 6,
scrollMargin,
@@ -282,31 +289,22 @@ export function DocumentTimeline({
}}
>
{item.type === "month" ? (
<h2 className="text-muted-foreground pt-2 pb-3 text-xs font-medium tracking-wide uppercase">
{formatMonthLabel(item.key, locale)}
</h2>
<DocumentMonthHeading
label={formatMonthLabel(item.key, locale)}
/>
) : (
<div
className="grid gap-4 pb-4"
style={{
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
}}
>
{item.documents.map((doc) => (
<DocumentCard
key={doc.id}
document={doc}
selected={selectedIds.has(doc.id)}
onToggleSelected={onToggleSelected}
onOpen={onOpen}
onDelete={onDelete}
highlighted={highlightId === doc.id}
tabIndex={rovingId === doc.id ? 0 : -1}
onCardFocus={setActiveId}
onPrefetch={onPrefetch}
/>
))}
</div>
<DocumentMonthRow
documents={item.documents}
columns={columns}
selectedIds={selectedIds}
onToggleSelected={onToggleSelected}
onOpen={onOpen}
onDelete={onDelete}
highlightId={highlightId}
rovingId={rovingId}
onCardFocus={setActiveId}
onPrefetch={onPrefetch}
/>
)}
</div>
);
+10 -3
View File
@@ -993,10 +993,17 @@ export function DocumentsView() {
{list.isPending ? (
<div
data-slot="documents-loading"
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
// The archive's tile shape (a 4:3 preview over two text lines), in
// the timeline's column counts, so the grid does not jump when the
// first page lands.
className="grid grid-cols-2 gap-4 lg:grid-cols-3 xl:grid-cols-4"
>
{Array.from({ length: 9 }, (_, i) => (
<Skeleton key={i} className="h-28 rounded-xl" />
{Array.from({ length: 8 }, (_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="aspect-[4/3] w-full rounded-xl" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
))}
</div>
) : list.isError ? (
@@ -0,0 +1,144 @@
/**
* A saved conversation opens at its last answer and stays there while late
* layout (charts, tables) grows the thread, until the reader takes over.
* Driven against a fake scroller: the test environment has no layout, and
* the behaviour under test is which scroll the thread asks for, and when.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { OPEN_AT_END_SETTLE_MS, openAtEnd } from "../open-at-end";
type Listener = () => void;
function fakeScroller(children = 3) {
const listeners = new Map<string, Set<Listener>>();
const el = {
scrollHeight: 1000,
children: Array.from({ length: children }, () => ({}) as Element),
scrollTo: vi.fn(),
addEventListener: (type: string, fn: Listener) => {
if (!listeners.has(type)) listeners.set(type, new Set());
listeners.get(type)!.add(fn);
},
removeEventListener: (type: string, fn: Listener) => {
listeners.get(type)?.delete(fn);
},
fire: (type: string) => {
for (const fn of [...(listeners.get(type) ?? [])]) fn();
},
listenerCount: () =>
[...listeners.values()].reduce((n, set) => n + set.size, 0),
};
return el;
}
/** A ResizeObserver whose callback the test fires by hand. */
function fakeResizeObserver() {
const instances: Array<{ fire: () => void; observed: unknown[] }> = [];
class FakeResizeObserver {
observed: unknown[] = [];
disconnected = false;
constructor(private readonly cb: () => void) {
instances.push(this as never);
}
observe(node: unknown) {
this.observed.push(node);
}
disconnect() {
this.disconnected = true;
}
fire() {
if (!this.disconnected) this.cb();
}
}
return {
Ctor: FakeResizeObserver as unknown as typeof ResizeObserver,
instances,
};
}
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe("openAtEnd", () => {
it("jumps to the end at once, without the smooth animation that lost it", () => {
const el = fakeScroller();
openAtEnd(el, { ResizeObserver: undefined, MutationObserver: undefined });
expect(el.scrollTo).toHaveBeenCalledTimes(1);
expect(el.scrollTo).toHaveBeenCalledWith({
top: 1000,
behavior: "instant",
});
});
it("follows the end when a chart that loads later grows the thread", () => {
const el = fakeScroller();
const ro = fakeResizeObserver();
openAtEnd(el, { ResizeObserver: ro.Ctor, MutationObserver: undefined });
expect(ro.instances[0].observed).toHaveLength(3);
el.scrollHeight = 1070; // one chart's height, the phone shortfall
ro.instances[0].fire();
expect(el.scrollTo).toHaveBeenLastCalledWith({
top: 1070,
behavior: "instant",
});
});
it("lets go the moment the reader scrolls, touches or presses a key", () => {
for (const event of ["wheel", "touchstart", "keydown", "pointerdown"]) {
const el = fakeScroller();
const ro = fakeResizeObserver();
openAtEnd(el, { ResizeObserver: ro.Ctor, MutationObserver: undefined });
el.fire(event);
el.scrollHeight = 2000;
ro.instances[0].fire();
expect(el.scrollTo, event).toHaveBeenCalledTimes(1);
expect(el.listenerCount(), event).toBe(0);
}
});
it("stops following on its own once the layout has had time to settle", () => {
const el = fakeScroller();
const ro = fakeResizeObserver();
openAtEnd(el, { ResizeObserver: ro.Ctor, MutationObserver: undefined });
vi.advanceTimersByTime(OPEN_AT_END_SETTLE_MS);
el.scrollHeight = 3000;
ro.instances[0].fire();
expect(el.scrollTo).toHaveBeenCalledTimes(1);
expect(el.listenerCount()).toBe(0);
});
it("stops when the caller says so, as on switching conversations", () => {
const el = fakeScroller();
const ro = fakeResizeObserver();
const stop = openAtEnd(el, {
ResizeObserver: ro.Ctor,
MutationObserver: undefined,
});
stop();
ro.instances[0].fire();
expect(el.scrollTo).toHaveBeenCalledTimes(1);
});
});
describe("the thread wires it to the conversation it opens", () => {
it("runs on a layout effect keyed on the conversation", async () => {
const { readFileSync } = await import("node:fs");
const { join } = await import("node:path");
const source = readFileSync(
join(
process.cwd(),
"src/components/insights/coach-panel/message-thread.tsx",
),
"utf8",
);
expect(source).toMatch(
/useLayoutEffect\(\(\) => \{[\s\S]*?return openAtEnd\(el\);[\s\S]*?\}, \[conversationId, hasMessages\]\);/,
);
});
});
@@ -4,6 +4,7 @@ import {
Fragment,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
@@ -18,6 +19,7 @@ import { ChatBubble, type FollowUpOffer, type ReplyOffer } from "./chat-bubble";
import type { SuggestedReply } from "./suggested-replies";
import { CoachMessageDatesProvider } from "./coach-results";
import { ScrollToBottomButton } from "./scroll-to-bottom-button";
import { openAtEnd } from "./open-at-end";
import { focusCoachComposer } from "./composer-focus";
import {
EMPTY_LIVE_TURN_KEYS,
@@ -579,6 +581,21 @@ export function MessageThread({
el.scrollTo({ top: el.scrollHeight, behavior: scrollBehaviorForUser() });
}, []);
// Opening a saved conversation lands on its last answer, and stays there
// while the answers' charts and tables finish loading (see `open-at-end`).
// Keyed on the conversation, so switching to another one re-opens at its
// end even when both have the same number of messages; a layout effect, so
// the first painted frame is already the end rather than the top.
const conversationId = conversation?.id ?? null;
const hasMessages = messages.length > 0;
useLayoutEffect(() => {
const el = scrollerRef.current;
if (!el || !conversationId || !hasMessages) return;
wasPinnedRef.current = true;
setPinned(true);
return openAtEnd(el);
}, [conversationId, hasMessages]);
// Auto-scroll on new messages OR streaming-content growth, but only
// when the user was already at the bottom. v1.4.25 W5 — the
// optimistic user bubble counts as a new message; scroll on its
@@ -0,0 +1,102 @@
/**
* Open a saved conversation at its end, and keep it there while the answers
* finish laying out.
*
* A conversation opened from the history used to land at the top on a wide
* screen and short of the last answer on a phone. Two things worked against
* the thread's ordinary auto-scroll. It scrolled smoothly, and the scroll
* events of that animation passed through positions that were not at the
* bottom, so the thread concluded the reader had scrolled away and stopped
* following. And the answers' charts and tables load after the messages, so
* the end moved down after the scroll had already finished: about 70 px on a
* phone, one chart's height.
*
* So the opening jump is instant, and for a short while afterwards every
* growth of the thread's content moves the view to the new end. The settling
* stops early the moment the reader does anything that means "let me look
* around" (a wheel, a touch, a key, a pointer press inside the thread), and
* on its own after {@link OPEN_AT_END_SETTLE_MS}.
*/
/** How long late layout (charts, tables, images) may still move the end. */
export const OPEN_AT_END_SETTLE_MS = 4000;
/** Events that mean the reader has taken over the scroll position. */
const READER_EVENTS = [
"wheel",
"touchstart",
"keydown",
"pointerdown",
] as const;
/** The part of an element the settling needs; a real `HTMLElement` fits. */
export interface OpenAtEndScroller {
readonly scrollHeight: number;
readonly children: ArrayLike<Element>;
scrollTo(options: ScrollToOptions): void;
addEventListener(type: string, listener: () => void): void;
removeEventListener(type: string, listener: () => void): void;
}
/** The observers to use; the browser's by default, fakes in a test. */
interface Observers {
ResizeObserver?: typeof ResizeObserver;
MutationObserver?: typeof MutationObserver;
}
/**
* Jump to the end now and follow the end while the content settles. Returns
* the function that stops following; call it on unmount or when another
* conversation opens.
*/
export function openAtEnd(
el: OpenAtEndScroller,
observers: Observers = {
ResizeObserver: globalThis.ResizeObserver,
MutationObserver: globalThis.MutationObserver,
},
): () => void {
// `instant`, not `auto`: the thread carries `scroll-smooth`, and under it
// `auto` animates, which is exactly the motion that lost the end before.
const toEnd = () =>
el.scrollTo({ top: el.scrollHeight, behavior: "instant" });
toEnd();
let stopped = false;
const resize = observers.ResizeObserver
? new observers.ResizeObserver(() => {
if (!stopped) toEnd();
})
: null;
const watch = (node: Element) => resize?.observe(node);
for (const child of Array.from(el.children)) watch(child);
// A bubble that mounts after the opening (a lazily loaded answer part)
// is watched from then on, and its own arrival moves the end too.
const mutations = observers.MutationObserver
? new observers.MutationObserver((records) => {
if (stopped) return;
for (const record of records) {
for (const node of Array.from(record.addedNodes)) {
// An element (nodeType 1), not a text node: only elements resize.
if (node.nodeType === 1) watch(node as Element);
}
}
toEnd();
})
: null;
mutations?.observe(el as unknown as Node, { childList: true });
const stop = () => {
if (stopped) return;
stopped = true;
resize?.disconnect();
mutations?.disconnect();
clearTimeout(timer);
for (const type of READER_EVENTS) el.removeEventListener(type, stop);
};
for (const type of READER_EVENTS) el.addEventListener(type, stop);
const timer = setTimeout(stop, OPEN_AT_END_SETTLE_MS);
return stop;
}
@@ -0,0 +1,193 @@
"use client";
import dynamic from "next/dynamic";
import { useEffect, type ComponentType } from "react";
import { Skeleton } from "@/components/ui/skeleton";
import { SettingsCard } from "@/components/settings/settings-card";
import type { SettingsSectionSlug } from "@/components/settings/section-slugs";
import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls";
/**
* One settings section, loaded on its own.
*
* The section route renders exactly one of twenty-one sections, but importing
* them statically put all twenty-one (and everything under them: the import
* panel, the integration cards, the MCP and API token tables, the score
* editor) into the route's entry bundle, so opening "About" downloaded the
* whole settings tree. Each section is now its own chunk. A first load still
* renders the section on the server (`ssr` stays on), so nothing jumps; the
* placeholder below only shows while a client-side switch between sections
* fetches the next chunk.
*/
/** Props a section takes. Only Integrations takes any. */
interface SectionProps {
callbackUrls?: IntegrationCallbackUrls;
}
function SectionLoading() {
// The settings card shell itself, so the section does not move when the
// real cards arrive (UI standards §13: a loading state paints the card).
return (
<div className="space-y-6" data-slot="settings-section-loading">
<SettingsCard data-slot="settings-section-loading-card">
<Skeleton className="h-6 w-40" />
<Skeleton className="h-4 w-full max-w-md" />
<Skeleton className="h-9 w-full" />
</SettingsCard>
<SettingsCard data-slot="settings-section-loading-card">
<Skeleton className="h-6 w-32" />
<Skeleton className="h-4 w-full max-w-sm" />
</SettingsCard>
</div>
);
}
/**
* One import per section, shared by the lazy component below and by the
* preload. One call site per file keeps the bundler to one chunk per section;
* a second `import()` of the same file elsewhere emitted a second copy.
*/
const LOADERS: Record<
SettingsSectionSlug,
() => Promise<ComponentType<SectionProps>>
> = {
account: () =>
import("@/components/settings/account-section").then(
(m) => m.AccountSection,
),
access: () =>
import("@/components/settings/access-section").then((m) => m.AccessSection),
security: () =>
import("@/components/settings/security-section").then(
(m) => m.SecuritySection,
),
modules: () =>
import("@/components/settings/modules-section").then(
(m) => m.ModulesSection,
),
about: () =>
import("@/components/settings/about-section").then((m) => m.AboutSection),
ai: () => import("@/components/settings/ai-section").then((m) => m.AiSection),
coach: () =>
import("@/components/settings/coach-section").then((m) => m.CoachSection),
integrations: () =>
import("@/components/settings/integrations-section").then(
(m) =>
function IntegrationsWithUrls({ callbackUrls }: SectionProps) {
// The route always passes the server-resolved URLs; the guard
// keeps the section's own prop contract required.
if (!callbackUrls) return null;
return <m.IntegrationsSection callbackUrls={callbackUrls} />;
},
),
sources: () =>
import("@/components/settings/sources-section").then(
(m) => m.SourcesSection,
),
notifications: () =>
import("@/components/settings/notifications-section").then(
(m) => m.NotificationsSection,
),
layout: () =>
import("@/components/settings/layout-section").then((m) => m.LayoutSection),
environment: () =>
import("@/components/settings/environment-section").then(
(m) => m.EnvironmentSection,
),
anamnesis: () =>
import("@/components/settings/anamnesis-section").then(
(m) => m.AnamnesisSection,
),
score: () =>
import("@/components/settings/score-section").then((m) => m.ScoreSection),
thresholds: () =>
import("@/components/settings/thresholds-section").then(
(m) => m.ThresholdsSection,
),
api: () =>
import("@/components/settings/api-section").then((m) => m.ApiSection),
mcp: () =>
import("@/components/settings/mcp-section").then((m) => m.McpSection),
gesundheitsakte: () =>
import("@/components/settings/gesundheitsakte-section").then(
(m) => m.GesundheitsakteSection,
),
export: () =>
import("@/components/settings/export-section").then((m) => m.ExportSection),
advanced: () =>
import("@/components/settings/advanced-section").then(
(m) => m.AdvancedSection,
),
privacy: () =>
import("@/components/settings/privacy-section").then(
(m) => m.PrivacySection,
),
};
const SECTIONS: Record<SettingsSectionSlug, ComponentType<SectionProps>> = {
account: dynamic(LOADERS.account, { loading: () => <SectionLoading /> }),
access: dynamic(LOADERS.access, { loading: () => <SectionLoading /> }),
security: dynamic(LOADERS.security, { loading: () => <SectionLoading /> }),
modules: dynamic(LOADERS.modules, { loading: () => <SectionLoading /> }),
about: dynamic(LOADERS.about, { loading: () => <SectionLoading /> }),
ai: dynamic(LOADERS.ai, { loading: () => <SectionLoading /> }),
coach: dynamic(LOADERS.coach, { loading: () => <SectionLoading /> }),
integrations: dynamic(LOADERS.integrations, {
loading: () => <SectionLoading />,
}),
sources: dynamic(LOADERS.sources, { loading: () => <SectionLoading /> }),
notifications: dynamic(LOADERS.notifications, {
loading: () => <SectionLoading />,
}),
layout: dynamic(LOADERS.layout, { loading: () => <SectionLoading /> }),
environment: dynamic(LOADERS.environment, {
loading: () => <SectionLoading />,
}),
anamnesis: dynamic(LOADERS.anamnesis, { loading: () => <SectionLoading /> }),
score: dynamic(LOADERS.score, { loading: () => <SectionLoading /> }),
thresholds: dynamic(LOADERS.thresholds, {
loading: () => <SectionLoading />,
}),
api: dynamic(LOADERS.api, { loading: () => <SectionLoading /> }),
mcp: dynamic(LOADERS.mcp, { loading: () => <SectionLoading /> }),
gesundheitsakte: dynamic(LOADERS.gesundheitsakte, {
loading: () => <SectionLoading />,
}),
export: dynamic(LOADERS.export, { loading: () => <SectionLoading /> }),
advanced: dynamic(LOADERS.advanced, { loading: () => <SectionLoading /> }),
privacy: dynamic(LOADERS.privacy, { loading: () => <SectionLoading /> }),
};
/**
* The section mounts only once the record gate has the account
* (`RecordSettingsSectionGate`), so without a head start its chunk would be
* fetched after that and the reader would see two loading states in a row.
* This starts the fetch at hydration, beside the gate's own read; the module
* cache makes the later lazy render of the same file free.
*/
export function SettingsSectionPreload({
section,
}: {
section: SettingsSectionSlug;
}) {
useEffect(() => {
void LOADERS[section]().catch(() => {
// The section's own lazy import retries and reports; a failed head
// start changes nothing.
});
}, [section]);
return null;
}
export function SettingsSectionLoader({
section,
callbackUrls,
}: {
section: SettingsSectionSlug;
callbackUrls?: IntegrationCallbackUrls;
}) {
const Section = SECTIONS[section];
return <Section callbackUrls={callbackUrls} />;
}
+1 -1
View File
@@ -37,7 +37,7 @@ import {
settleRefusedRecordSessionTransition,
} from "@/lib/query-keys/record-session-transition";
import type { AccountAccess } from "@/lib/sharing/account-access-view";
import { parseAccountAccess } from "@/lib/sharing/account-access-schema";
import { parseAccountAccess } from "@/lib/sharing/account-access-parse";
/**
* v1.36.0 — an account with no sharing at all. The shape `fetchMe` falls back
+19
View File
@@ -496,9 +496,20 @@ export function calculateCompliance(
* `[ledgerFrom, now]`; the caller carves the 90-day heatmap out of it by
* filtering on `row.at`.
*/
/**
* The report windows a client may ask the batched compliance read for
* (`GET /api/medications/compliance?days=N`), beyond the 7 and 30 days every
* payload carries. Each is a suffix of the 365-day ledger window, so all of
* them are tallies over the rows the bundle already minted.
*/
export const COMPLIANCE_REPORT_DAYS = [30, 60, 90, 180, 365] as const;
export type ComplianceReportDays = (typeof COMPLIANCE_REPORT_DAYS)[number];
export interface MedicationComplianceBundle {
compliance7: ComplianceResult;
compliance30: ComplianceResult;
/** One result per report window, keyed by its length in days. */
reportWindows: Record<ComplianceReportDays, ComplianceResult>;
complianceDisplay: ComplianceDisplay;
/** Unified ledger rows over `[ledgerFrom, now]`, chronological. */
ledgerRows: DoseHistoryRow[];
@@ -704,9 +715,17 @@ export function buildMedicationComplianceBundle(
currentDose,
};
const reportWindows = Object.fromEntries(
COMPLIANCE_REPORT_DAYS.map((days) => [
days,
days === 30 ? compliance30 : resultForWindow(days),
]),
) as Record<ComplianceReportDays, ComplianceResult>;
return {
compliance7,
compliance30,
reportWindows,
complianceDisplay,
ledgerRows,
ledgerFrom,
+14
View File
@@ -6,6 +6,20 @@ export function apiSuccess<T>(data: T, status = 200) {
return NextResponse.json({ data, error: null }, { status });
}
/**
* A success envelope that also carries `meta`: figures ABOUT the payload
* (an aggregate across its rows, the window it was cut to) that do not belong
* inside any one row. `data` keeps the shape the route always had, so a
* client that never reads `meta` decodes the response unchanged.
*/
export function apiSuccessWithMeta<T>(
data: T,
meta: Record<string, unknown>,
status = 200,
) {
return NextResponse.json({ data, error: null, meta }, { status });
}
/**
* Sanitised view of a single Zod issue. We surface `path`, `code`,
* `message` and — for `unrecognized_keys` only — the bounded `keys`
+63 -16
View File
@@ -304,6 +304,33 @@ export async function createAuthenticationOptions(userId?: string) {
return { options, challengeId: challenge.id };
}
/**
* Why a passkey assertion could not be checked at all — as opposed to an
* assertion that was checked and did not verify. Each kind is a case a
* client can name to the person (the passkey was removed here, the sign-in
* took too long), so the sign-in route answers it with its own status and
* `meta.errorCode` instead of letting it surface as a server fault.
*
* - `challenge_expired`: the challenge is gone (expired, already used, never
* issued) or was begun for a different account.
* - `unknown_passkey`: no passkey with this credential id is registered here.
* - `malformed`: the assertion body is not an authentication response.
* - `verification_failed`: the WebAuthn library refused the assertion
* (origin, RP id, challenge or signature mismatch).
*/
export type PasskeyAuthErrorKind =
"challenge_expired" | "unknown_passkey" | "malformed" | "verification_failed";
export class PasskeyAuthError extends Error {
constructor(
readonly kind: PasskeyAuthErrorKind,
message: string,
) {
super(message);
this.name = "PasskeyAuthError";
}
}
export async function verifyAuthentication(
challengeId: string,
response: unknown,
@@ -318,7 +345,10 @@ export async function verifyAuthentication(
type: "authentication",
});
if (!challenge) {
throw new Error("Challenge expired or not found");
throw new PasskeyAuthError(
"challenge_expired",
"Challenge expired or not found",
);
}
// v1.4.43 W13 L-3 — explicit Zod narrowing instead of the previous
@@ -329,7 +359,10 @@ export async function verifyAuthentication(
// type-narrowing gap a future refactor could trip on.
const parsed = authenticationResponseSchema.safeParse(response);
if (!parsed.success) {
throw new Error("Malformed passkey authentication response");
throw new PasskeyAuthError(
"malformed",
"Malformed passkey authentication response",
);
}
const typedResponse = parsed.data as unknown as AuthenticationResponseJSON;
@@ -340,27 +373,41 @@ export async function verifyAuthentication(
});
if (!passkey) {
throw new Error("Passkey not found");
throw new PasskeyAuthError("unknown_passkey", "Passkey not found");
}
// A challenge begun for one account (the re-proof arm names the account)
// is answered only by that account's passkey.
if (challenge.userId !== null && challenge.userId !== passkey.userId) {
throw new Error("Challenge expired or not found");
throw new PasskeyAuthError(
"challenge_expired",
"Challenge expired or not found",
);
}
const verification = await verifyAuthenticationResponse({
response: typedResponse,
expectedChallenge: challenge.challenge,
expectedOrigin: getExpectedOrigin(),
expectedRPID: getRpId(),
credential: {
id: passkey.credentialId,
publicKey: passkey.credentialPublicKey,
counter: Number(passkey.counter),
transports: passkey.transports as Transport[],
},
});
let verification: VerifiedAuthenticationResponse;
try {
verification = await verifyAuthenticationResponse({
response: typedResponse,
expectedChallenge: challenge.challenge,
expectedOrigin: getExpectedOrigin(),
expectedRPID: getRpId(),
credential: {
id: passkey.credentialId,
publicKey: passkey.credentialPublicKey,
counter: Number(passkey.counter),
transports: passkey.transports as Transport[],
},
});
} catch (err) {
// The library throws, rather than answering `verified: false`, for an
// origin, RP id or challenge mismatch and for a bad signature. Those are
// a refused assertion, not a server fault.
throw new PasskeyAuthError(
"verification_failed",
err instanceof Error ? err.message : "Passkey verification failed",
);
}
if (verification.verified) {
// Update counter + stamp last-used so the management UI can surface
+12
View File
@@ -95,6 +95,18 @@ export function glucoseBandParams(
};
}
/**
* The stored glucose-unit choice as the closed pair, or `null` when none was
* made. The column is free text (a restored backup writes it unchecked), so a
* value that is neither unit reads as no choice rather than passing through to
* a client that decodes the pair.
*/
export function storedGlucoseUnit(
value: string | null | undefined,
): GlucoseUnit | null {
return value === "mg/dL" || value === "mmol/L" ? value : null;
}
export function resolveGlucoseUnit(
userPreference: string | null | undefined,
): GlucoseUnit {
@@ -410,6 +410,23 @@ describe("computeMoodAggregates", () => {
]);
expect(agg.stability).toBeNull();
});
it("scores stability per trailing window, so a steady month after a turbulent year reads steady", () => {
// Days 0..29 sit at 4; days 30..119 swing between 1 and 5.
const entries: MoodAggregateEntry[] = [];
for (let d = 0; d < 30; d++) entries.push(entry(d, 4));
for (let d = 30; d < 120; d++) entries.push(entry(d, d % 2 ? 1 : 5));
const agg = computeMoodAggregates({ entries, measurements: [], now: NOW });
expect(agg.stabilityByWindow[30]?.score).toBe(100);
expect(agg.stabilityByWindow[30]?.days).toBe(30);
expect(agg.stabilityByWindow[90]?.score).toBeLessThan(
agg.stabilityByWindow[30]!.score,
);
// The whole-year entry is the score `stability` has always carried.
expect(agg.stabilityByWindow[365]).toEqual(agg.stability);
expect(agg.stability?.days).toBe(120);
});
});
describe("bucketForHour", () => {
+26
View File
@@ -328,6 +328,14 @@ export function pairDailyBuckets(
export type HeatmapWindowDays = 30 | 90 | 365;
/**
* The trailing windows the stability score is offered over. 365 is the
* whole daily series the aggregate keeps, which is what `stability` has
* always covered.
*/
export const MOOD_STABILITY_WINDOWS = [30, 90, 180, 365] as const;
export type MoodStabilityWindow = (typeof MOOD_STABILITY_WINDOWS)[number];
/**
* Adaptive heatmap window so the grid is never mostly-empty.
*
@@ -497,6 +505,13 @@ export interface MoodAggregates {
* descriptive band. `null` for a sparse logger (< STABILITY_MIN_DAYS).
*/
stability: MoodStability | null;
/**
* The same score over the shorter trailing windows a client can pick
* (`GET /api/mood/insights?days=N`). Cached with the rest of the aggregate
* so a period switch costs nothing; the route serves the chosen one as
* `stability` and does not publish this map.
*/
stabilityByWindow: Record<MoodStabilityWindow, MoodStability | null>;
tags: TagSummaryRow[];
/**
* v1.38 — how the day's pleasantness sat on days carrying each context
@@ -608,6 +623,16 @@ export function computeMoodAggregates(args: {
const weekday = computeWeekdayAverages(moodDaily, now, tz);
const timeOfDay = computeTimeOfDayAverages(entries);
const stability = computeMoodStability(moodDaily);
const stabilityByWindow = Object.fromEntries(
MOOD_STABILITY_WINDOWS.map((days) => [
days,
days === 365
? stability
: computeMoodStability(
moodDaily.filter((bucket) => bucket.dayOffset < days),
),
]),
) as Record<MoodStabilityWindow, MoodStability | null>;
const tags = computeTagSummary(entries, now);
const structuredTags = computeStructuredTagSummary(entries, now);
const tagInfluence = computeTagInfluence(entries, now);
@@ -689,6 +714,7 @@ export function computeMoodAggregates(args: {
weekday,
timeOfDay,
stability,
stabilityByWindow,
tags,
structuredTags,
tagInfluence,
@@ -223,6 +223,7 @@ describe("handleReminderCheck per-slot reminder windows", () => {
metadata: expect.objectContaining({
phase: "ORANGE",
timeOfDay: "08:00",
followUp: true,
}),
}),
);
@@ -248,6 +249,34 @@ describe("handleReminderCheck per-slot reminder windows", () => {
metadata: expect.objectContaining({
phase: "YELLOW",
timeOfDay: "08:00",
// GREEN announced this window; YELLOW escalates an open dose.
followUp: true,
}),
}),
);
});
it("marks the phase that announces the dose as the one reminder that is not a follow-up", async () => {
vi.useFakeTimers();
// 55 minutes before the 09:30 end of a 07:30 to 09:30 window: GREEN.
vi.setSystemTime(new Date("2026-07-28T08:35:00.000Z"));
prismaMock.medication.findMany.mockResolvedValue([
medicationWithSchedule({
windowEnd: "18:00",
timesOfDay: ["08:00", "18:00"],
doseWindows: [{ timeOfDay: "08:00", start: "07:30", end: "09:30" }],
}),
] as never);
setIntakeEvents([]);
await handleReminderCheck([]);
expect(dispatchNotification).toHaveBeenCalledWith(
expect.objectContaining({
metadata: expect.objectContaining({
phase: "GREEN",
timeOfDay: "08:00",
followUp: false,
}),
}),
);
@@ -3,6 +3,7 @@ import { describe, it, expect } from "vitest";
import {
determinePhase,
getPhaseKeyboard,
openingPhase,
getPhaseMessage,
resolvePhaseThresholds,
DEFAULT_PHASE_CONFIG,
@@ -142,3 +143,26 @@ describe("getPhaseKeyboard — localised", () => {
}
});
});
describe("openingPhase", () => {
it("is GREEN for a window longer than the YELLOW lead", () => {
const t = resolvePhaseThresholds(DEFAULT_PHASE_CONFIG, 120);
expect(openingPhase(t, 120)).toBe("GREEN");
});
it("is YELLOW for a point slot, which never shows GREEN", () => {
const t = resolvePhaseThresholds(DEFAULT_PHASE_CONFIG, 0);
expect(openingPhase(t, 0)).toBe("YELLOW");
// The same window walked tick by tick reaches YELLOW before anything else.
expect(determinePhase(30, -30, t)).toBe("YELLOW");
expect(determinePhase(45, -45, t)).toBeNull();
});
it("is YELLOW when the GREEN lead is not longer than the YELLOW lead", () => {
const t = resolvePhaseThresholds(
{ ...DEFAULT_PHASE_CONFIG, greenValue: 20, yellowValue: 30 },
120,
);
expect(openingPhase(t, 120)).toBe("YELLOW");
});
});
@@ -0,0 +1,129 @@
/**
* What the admin card shows about the measurement table maintenance: the
* newest run and where it stands, whether the compaction-tombstone purge is
* still at work (the run refuses to start while it is), and the table's size
* right now.
*
* Read straight from `pgboss.job`, the same source `job-failures.ts` reads,
* and fail-soft the same way: a web-only deployment has no `pgboss` schema,
* and the card says "no background worker" instead of failing.
*/
import { prisma } from "@/lib/db";
import { MEASUREMENT_MAINTENANCE_QUEUE } from "@/lib/jobs/measurement-maintenance";
const PURGE_QUEUE = "compaction-tombstone-purge";
/** Where the newest run stands, in the words the card uses. */
export type MaintenanceRunState = "queued" | "running" | "completed" | "failed";
/**
* How a finished run ended. `completed` did the work; the two refusals
* finished without touching the table.
*/
export type MaintenanceRunOutcome =
"completed" | "refused_purge_running" | "already_running";
export interface MeasurementMaintenanceStatus {
/** False when there is no background queue to ask (web-only deployment). */
available: boolean;
/** The purge is queued or running; a new run would refuse to start. */
purgePending: boolean;
run: {
state: MaintenanceRunState;
requestedAt: string;
startedAt: string | null;
finishedAt: string | null;
/** Only for `completed`: what the finished run actually did. */
outcome: MaintenanceRunOutcome | null;
} | null;
/** `measurements` with its indexes and TOAST, and its indexes alone. */
sizes: { tableBytes: number; indexBytes: number } | null;
}
const OUTCOMES: ReadonlySet<string> = new Set([
"completed",
"refused_purge_running",
"already_running",
]);
/** pg-boss's row state → the card's run state. */
export function runStateOf(state: string): MaintenanceRunState {
if (state === "created" || state === "retry") return "queued";
if (state === "active") return "running";
if (state === "completed") return "completed";
return "failed";
}
async function readSizes(): Promise<MeasurementMaintenanceStatus["sizes"]> {
try {
const rows = await prisma.$queryRaw<
Array<{ table_bytes: bigint; index_bytes: bigint }>
>`
SELECT pg_total_relation_size('measurements'::regclass) AS table_bytes,
pg_indexes_size('measurements'::regclass) AS index_bytes
`;
const row = rows[0];
if (!row) return null;
return {
tableBytes: Number(row.table_bytes),
indexBytes: Number(row.index_bytes),
};
} catch {
return null;
}
}
export async function readMeasurementMaintenanceStatus(): Promise<MeasurementMaintenanceStatus> {
const sizes = await readSizes();
try {
const [runs, purge] = await Promise.all([
prisma.$queryRaw<
Array<{
state: string;
created_on: Date;
started_on: Date | null;
completed_on: Date | null;
outcome: string | null;
}>
>`
SELECT state, created_on, started_on, completed_on,
output->'did'->>'outcome' AS outcome
FROM pgboss.job
WHERE name = ${MEASUREMENT_MAINTENANCE_QUEUE}
ORDER BY created_on DESC
LIMIT 1
`,
prisma.$queryRaw<Array<{ n: number }>>`
SELECT count(*)::int AS n
FROM pgboss.job
WHERE name = ${PURGE_QUEUE}
AND state IN ('created', 'retry', 'active')
`,
]);
const row = runs[0];
const state = row ? runStateOf(row.state) : null;
return {
available: true,
purgePending: (purge[0]?.n ?? 0) > 0,
run:
row && state
? {
state,
requestedAt: row.created_on.toISOString(),
startedAt: row.started_on?.toISOString() ?? null,
finishedAt: row.completed_on?.toISOString() ?? null,
outcome:
state === "completed" &&
row.outcome !== null &&
OUTCOMES.has(row.outcome)
? (row.outcome as MaintenanceRunOutcome)
: null,
}
: null,
sizes,
};
} catch {
// No `pgboss` schema (web-only deployment) or no permission to read it.
return { available: false, purgePending: false, run: null, sizes };
}
}
+25
View File
@@ -84,6 +84,31 @@ export function resolvePhaseThresholds(
};
}
/**
* The first phase a slot's window reaches, which is the reminder that
* announces the dose; every later phase is a follow-up for a dose that is
* still open.
*
* It is not always GREEN. GREEN needs the window to have opened AND to be
* within `greenMinBefore` of its end, while YELLOW needs only the second
* condition with the smaller `yellowMinBefore`. A window no longer than the
* YELLOW lead (a point slot such as 07:00 to 07:00 is zero minutes long)
* reaches YELLOW first and never shows GREEN, and so does any configuration
* whose GREEN lead is not longer than its YELLOW lead.
*/
export function openingPhase(
thresholds: ReturnType<typeof resolvePhaseThresholds>,
windowDurationMin: number,
): ReminderPhase {
if (
windowDurationMin <= thresholds.yellowMinBefore ||
thresholds.greenMinBefore <= thresholds.yellowMinBefore
) {
return "YELLOW";
}
return "GREEN";
}
/**
* Determine the current phase for a schedule based on minutes to window end.
*
@@ -31,6 +31,7 @@ import {
determinePhase,
getPhaseMessage,
getPhaseKeyboard,
openingPhase,
} from "@/lib/jobs/reminder-phases";
import {
claimNotificationEvent,
@@ -555,6 +556,9 @@ export async function handleReminderCheck(
windowStart: phaseWindow.start,
minutesToEnd,
currentPhase,
followUp:
currentPhase !==
openingPhase(thresholds, windowDuration),
}
: null;
})
@@ -570,6 +574,7 @@ export async function handleReminderCheck(
windowStart,
minutesToEnd,
currentPhase,
followUp,
} = phaseCandidate;
const slotInstant = slotScheduledFor.getTime();
if (liveEraStart !== null && slotInstant < liveEraStart) {
@@ -767,6 +772,12 @@ export async function handleReminderCheck(
medicationId: med.id,
scheduleId: schedule.id,
phase: currentPhase,
// False for the reminder that announces the dose (the
// slot's opening phase), true for every escalation after
// it. A client that reminds locally at dose time
// (`medication.clientManaged`) replaces only the first
// one, so only that one is kept off APNs.
followUp,
date: slotScheduledFor.toLocaleDateString("sv-SE", {
timeZone: userTz,
}),
+1 -1
View File
@@ -66,7 +66,7 @@ import {
resolveGlucoseUnit,
type GlucoseUnit,
} from "@/lib/glucose";
import { getUnitForType } from "@/lib/validations/measurement";
import { getUnitForType } from "@/lib/measurements/unit-map";
export type UnitPreference = "metric" | "imperial";
+163
View File
@@ -0,0 +1,163 @@
/**
* The canonical storage unit of every measurement type.
*
* Its own module, with no imports, because both ends read it: the write
* validation in `validations/measurement.ts` and the client's display
* transform (`display-transform.ts`), which runs on nearly every chart and
* tile. Living inside the validation module it dragged Zod into each of those
* routes for one table lookup. `validations/measurement.ts` re-exports
* `getUnitForType`, so server callers keep their import.
*/
const unitMap: Record<string, string> = {
WEIGHT: "kg",
BLOOD_PRESSURE_SYS: "mmHg",
BLOOD_PRESSURE_DIA: "mmHg",
PULSE: "bpm",
BODY_FAT: "%",
// v1.4.23 — sleep duration shifted from hours to minutes so HealthKit
// category-sample stages can be stored without precision loss. Older
// surfaces that need hours convert at read time (`minutes / 60`).
SLEEP_DURATION: "minutes",
ACTIVITY_STEPS: "steps",
BLOOD_GLUCOSE: "mg/dL",
TOTAL_BODY_WATER: "kg",
BONE_MASS: "kg",
OXYGEN_SATURATION: "%",
// ── v1.4.23 Apple Health canonical units ──
HEART_RATE_VARIABILITY: "ms",
RESTING_HEART_RATE: "bpm",
ACTIVE_ENERGY_BURNED: "kcal",
FLIGHTS_CLIMBED: "flights",
WALKING_RUNNING_DISTANCE: "m",
VO2_MAX: "mL/(kg·min)",
BODY_TEMPERATURE: "celsius",
// ── v1.4.25 W5d Withings full coverage ──
FAT_FREE_MASS: "kg",
FAT_MASS: "kg",
MUSCLE_MASS: "kg",
// Distinct from BODY_TEMPERATURE — surface temps run ~32 °C; sharing
// the bucket would corrupt analytics. Same canonical unit (°C).
SKIN_TEMPERATURE: "celsius",
PULSE_WAVE_VELOCITY: "m/s",
VASCULAR_AGE: "years",
// Withings reports visceral fat as a 1–12 rating, not a percent. The
// string mirrors what Withings prints in Health Mate.
VISCERAL_FAT: "rating",
// ── v1.4.25 W8d Apple Health server-prep ──
// Sound-pressure level — A-weighted decibels (dBA). HealthKit reports
// both audio-exposure metrics in dBASPL; we store the unweighted "dBA"
// label because the A-weighting is implicit (every HealthKit audio
// sample carries it). 30 dBA = quiet bedroom; 140 dBA = pain threshold.
AUDIO_EXPOSURE_ENV: "dBA",
AUDIO_EXPOSURE_HEADPHONE: "dBA",
// Daily-rollup pattern (one sample = one day's outdoor-light minutes).
// 0–1440 covers the 24-hour day; in practice indoor users sit near 0
// and outdoor athletes accumulate a few hours.
TIME_IN_DAYLIGHT: "minutes",
// ── v1.5.5 iOS-coord additions ──
// Respiratory rate breaths-per-minute — the HK identifier ships as
// `count/min`; we keep the more conventional clinical label.
RESPIRATORY_RATE: "breaths/min",
// BMI kg/m² — HealthKit ships the unitless ratio; the canonical
// display string mirrors clinical convention.
BODY_MASS_INDEX: "kg/m²",
// Lean body mass kg — body-composition partner to FAT_MASS.
LEAN_BODY_MASS: "kg",
// Walking heart rate average bpm — daily rollup; distinct from
// RESTING_HEART_RATE (sleep-window minimum) and spot PULSE.
WALKING_HEART_RATE_AVERAGE: "bpm",
// Walking gait percent (0-100 after server-side ×100 scaling).
// Same convention as WALKING_STEADINESS / BODY_FAT / OXYGEN_SATURATION
// — see the project convention block in `apple-health-mapping.ts`.
WALKING_ASYMMETRY: "%",
WALKING_DOUBLE_SUPPORT: "%",
// ── v1.5.5 iOS-coord follow-up — raw-SI gait pair ──
// Step length is metres; speed is metres per second. Both flow
// raw on the wire — no server-side scaling. The unit strings
// match HealthKit's `m` / `m/s` defaults.
WALKING_STEP_LENGTH: "m",
WALKING_SPEED: "m/s",
// ── v1.10.0 — additive HealthKit signals (WX-A) ──
// Cardio recovery is the bpm drop one minute after peak exercise.
CARDIO_RECOVERY: "bpm",
// Overnight wrist temperature in °C (absolute reading; Apple's own
// display frames it as a baseline deviation, we store the reading).
WRIST_TEMPERATURE: "celsius",
// Hard-fall detections — a plain count.
FALL_COUNT: "count",
// Apple's estimated six-minute-walk-test distance in metres.
SIX_MINUTE_WALK_DISTANCE: "m",
// Stair gait speeds — raw metres-per-second (no scaling).
STAIR_ASCENT_SPEED: "m/s",
STAIR_DESCENT_SPEED: "m/s",
// Per-night breathing-disturbance index — a unitless count Apple
// classifies as NotElevated / Elevated.
BREATHING_DISTURBANCES: "count",
// ── v1.10.0 — categorical events (WX-B) ──
// EVENT rows are dimensionless occurrences (value is always 1). The
// canonical unit is the bare "event" so any accidental numeric surfacing
// reads sensibly; the awareness timeline never displays the value.
IRREGULAR_RHYTHM_NOTIFICATION: "event",
HIGH_HEART_RATE_EVENT: "event",
LOW_HEART_RATE_EVENT: "event",
WALKING_STEADINESS_EVENT: "event",
BREATHING_DISTURBANCE_EVENT: "event",
// ── v1.10.0 — computed scores (WX-C) ──
// Server-derived 0–100 wellness scores. The canonical unit is the bare
// "score" so the value reads sensibly anywhere it surfaces.
RECOVERY_SCORE: "score",
STRESS_SCORE: "score",
STRAIN_SCORE: "score",
// ── v1.11.0 — WHOOP-native score classes ──
// RMSSD HRV is in milliseconds, same canonical unit as the SDNN
// HEART_RATE_VARIABILITY (different estimator, same dimension).
HRV_RMSSD: "ms",
// Day / workout strain ride WHOOP's bounded 0–21 scale; the bare "score"
// unit reads sensibly wherever the value surfaces (distinct from the
// 0–100 COMPUTED STRAIN_SCORE).
DAY_STRAIN: "score",
WORKOUT_STRAIN: "score",
// Sleep quality percentages (0–100).
SLEEP_PERFORMANCE: "%",
SLEEP_EFFICIENCY: "%",
SLEEP_CONSISTENCY: "%",
// Recommended sleep duration in minutes (WHOOP reports ms; mapper ÷60000).
SLEEP_NEED: "minutes",
// Day energy expenditure in kilojoules (WHOOP-native; kept in kJ so the
// device value round-trips rather than being converted to kcal).
ENERGY_EXPENDITURE_KJ: "kJ",
// ── v1.12.8 — WHOOP cycle + sleep coverage completion ──
// Daily average / max heart rate bpm — whole-cycle aggregates, distinct
// from the spot PULSE / RESTING_HEART_RATE / WALKING_HEART_RATE_AVERAGE.
AVERAGE_HEART_RATE: "bpm",
MAX_HEART_RATE: "bpm",
// Per-night sleep disturbance tally — a plain integer count.
SLEEP_DISTURBANCE_COUNT: "count",
// ── v1.17.1 — Polar Nightly Recharge + Training Load Pro components ──
ANS_CHARGE: "score",
CARDIO_LOAD: "score",
// ── v1.17.1 — Oura coverage completion ──
// Oura's headline 0–100 Sleep Score — bare "score" like the other 0–100 scores.
SLEEP_SCORE: "score",
// Signed body-temperature deviation in °C (Oura nightly baseline offset).
BODY_TEMPERATURE_DEVIATION: "celsius",
// ── v1.19.0 — Oura resilience ──
// Ordinal level scale (1=limited … 5=exceptional) — the categorical band
// encoded into the numeric value. See RESILIENCE_LEVELS in src/lib/oura/client.
RESILIENCE: "level",
// ── v1.25 — clinical-signals wave ──
PHQ9_SCORE: "score",
GAD7_SCORE: "score",
// ── v1.27.9 — screening scores ──
WHO5_SCORE: "score",
SCI_SCORE: "score",
GRIP_STRENGTH: "kg",
PAIN_NRS: "score",
WAIST_CIRCUMFERENCE: "cm",
WAIST_TO_HEIGHT: "ratio",
};
export function getUnitForType(type: string): string {
return unitMap[type] ?? "unknown";
}
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { aggregateCompliance } from "../compliance-payload";
function result(taken: number, missed: number, skipped = 0, streak = 0) {
return {
totalExpected: taken + missed + skipped,
taken,
skipped,
missed,
rate:
taken + missed > 0 ? Math.round((100 * taken) / (taken + missed)) : 100,
streak,
};
}
describe("aggregateCompliance", () => {
it("weights by expected doses rather than averaging the rates", () => {
// A three-times-daily tablet at 60/90 and a weekly injection at 4/4.
// The mean of the rates would read 83 %; the doses say 64/94 = 68 %.
const out = aggregateCompliance([result(60, 30), result(4, 0)]);
expect(out).toMatchObject({
totalExpected: 94,
taken: 64,
missed: 30,
rate: 68,
});
});
it("keeps skipped doses out of the rate, as a single medication's tally does", () => {
expect(aggregateCompliance([result(9, 1, 5)])?.rate).toBe(90);
expect(aggregateCompliance([result(9, 1, 5)])?.totalExpected).toBe(15);
});
it("takes the shortest streak, because a day counts only when every medication was taken", () => {
expect(
aggregateCompliance([result(5, 0, 0, 12), result(5, 0, 0, 3)])?.streak,
).toBe(3);
});
it("is null when nothing contributes", () => {
expect(aggregateCompliance([])).toBeNull();
});
});
@@ -1,6 +1,9 @@
import { describe, expect, it, vi } from "vitest";
import { assertMedicationOwnership } from "../route-guards";
import {
assertMedicationOwnership,
assertMedicationTakesIntake,
} from "../route-guards";
function buildClient(med: { id: string; userId: string } | null) {
return {
@@ -72,3 +75,34 @@ describe("assertMedicationOwnership", () => {
expect(call.select).toEqual({ id: true, userId: true });
});
});
describe("assertMedicationTakesIntake", () => {
function client(
med: { id: string; userId: string; trackIntake: boolean } | null,
) {
return { medication: { findUnique: vi.fn().mockResolvedValue(med) } };
}
it("admits a tracked medication the caller holds", async () => {
const c = client({ id: "med-1", userId: "user-1", trackIntake: true });
expect(await assertMedicationTakesIntake("med-1", "user-1", c)).toBeNull();
expect(c.medication.findUnique).toHaveBeenCalledWith({
where: { id: "med-1" },
select: { id: true, userId: true, trackIntake: true },
});
});
it("refuses a record-only medication by name", async () => {
const c = client({ id: "med-1", userId: "user-1", trackIntake: false });
const result = await assertMedicationTakesIntake("med-1", "user-1", c);
expect(result?.status).toBe(422);
const body = await result!.json();
expect(body.meta.errorCode).toBe("medication.intake.notTracked");
});
it("answers 404, not 422, for a record-only medication of another account", async () => {
const c = client({ id: "med-1", userId: "owner", trackIntake: false });
const result = await assertMedicationTakesIntake("med-1", "intruder", c);
expect(result?.status).toBe(404);
});
});
+64
View File
@@ -15,7 +15,9 @@ import {
buildMedicationComplianceBundle,
expectsDoses,
lastNonSkippedTakenAt,
COMPLIANCE_REPORT_DAYS,
type ComplianceDisplay,
type ComplianceReportDays,
type ComplianceResult,
type DailyComplianceEntry,
} from "@/lib/analytics/compliance";
@@ -47,8 +49,17 @@ export interface CompliancePayload {
compliance30: ComplianceResult;
dailyCompliance: Record<string, DailyComplianceEntry>;
complianceDisplay: ComplianceDisplay | null;
/**
* The report windows the batched read serves on `?days=N`. Cached with the
* rest of the cell so a report read costs no second ledger pass; NOT on the
* per-medication wire, which keeps its published shape.
*/
reportWindows: Record<ComplianceReportDays, ComplianceResult>;
}
/** The cached payload minus the fields only the batched read serves. */
export type CompliancePayloadWire = Omit<CompliancePayload, "reportWindows">;
/**
* Legacy-compatible placeholder for a medication whose adherence percentage
* is explicitly not applicable.
@@ -68,6 +79,13 @@ const NOT_APPLICABLE_LEGACY_COMPLIANCE: ComplianceResult = {
streak: 0,
};
const NOT_APPLICABLE_REPORT_WINDOWS = Object.fromEntries(
COMPLIANCE_REPORT_DAYS.map((days) => [
days,
NOT_APPLICABLE_LEGACY_COMPLIANCE,
]),
) as Record<ComplianceReportDays, ComplianceResult>;
/** The medication slice the payload builder consumes. */
export interface ComplianceMedicationInput {
id: string;
@@ -194,6 +212,7 @@ export async function buildCompliancePayload(
compliance30: NOT_APPLICABLE_LEGACY_COMPLIANCE,
dailyCompliance: {},
complianceDisplay: null,
reportWindows: NOT_APPLICABLE_REPORT_WINDOWS,
};
}
@@ -207,6 +226,7 @@ export async function buildCompliancePayload(
compliance30: NOT_APPLICABLE_LEGACY_COMPLIANCE,
dailyCompliance: {},
complianceDisplay: null,
reportWindows: NOT_APPLICABLE_REPORT_WINDOWS,
};
}
@@ -314,5 +334,49 @@ export async function buildCompliancePayload(
compliance30: bundle.compliance30,
dailyCompliance,
complianceDisplay: bundle.complianceDisplay,
reportWindows: bundle.reportWindows,
};
}
/**
* One adherence figure for the whole account, in the per-medication
* `ComplianceResult` shape, weighted by expected doses: the counts are summed
* across every applicable medication and the rate is recomputed from the sums
* with the same rule a single medication's tally uses
* (`round(100 · taken / (taken + missed))`, capped at 100, 100 when nothing
* was expected). Averaging the per-medication rates instead would let a
* weekly injection weigh as much as a three-times-daily tablet.
*
* `streak` is the shortest per-medication streak: a day only extends the
* account's streak when every medication's doses were taken that day.
*
* `null` when no medication contributes, so a client shows nothing rather
* than a vacuous 100 %.
*/
export function aggregateCompliance(
results: readonly ComplianceResult[],
): ComplianceResult | null {
if (results.length === 0) return null;
let taken = 0;
let skipped = 0;
let missed = 0;
let streak = Number.POSITIVE_INFINITY;
for (const r of results) {
taken += r.taken;
skipped += r.skipped;
missed += r.missed;
streak = Math.min(streak, r.streak);
}
const denominator = taken + missed;
return {
totalExpected: taken + skipped + missed,
taken,
skipped,
missed,
rate:
denominator > 0
? Math.min(100, Math.round((taken / denominator) * 100))
: 100,
streak,
};
}
+12
View File
@@ -66,3 +66,15 @@ export function scheduleWireFields<T>(
? { schedules: [], recordedSchedules: schedules }
: { schedules };
}
/**
* Error code for a dose written against a record-only medication. Every route
* that records a dose answers it the same way (iOS #116, item 15): the history
* the medication already has stays, but nothing new is counted, so a new
* write is refused by name rather than stored and then counted nowhere.
* The bulk route reports the same decision per entry as `intake_not_tracked`.
*/
export const INTAKE_NOT_TRACKED_ERROR_CODE = "medication.intake.notTracked";
/** Per-entry skip reason for the bulk route, the batch form of the code. */
export const INTAKE_NOT_TRACKED_REASON = "intake_not_tracked";
+46
View File
@@ -22,6 +22,7 @@
*/
import { apiError } from "@/lib/api-response";
import { INTAKE_NOT_TRACKED_ERROR_CODE } from "@/lib/medications/intake-tracking";
interface MedicationOwnershipPrisma {
medication: {
@@ -53,6 +54,51 @@ export async function assertMedicationOwnership(
return null;
}
/** The 422 a dose write against a record-only medication answers with. */
export function refuseUntrackedIntake(): Response {
return apiError(
"This medication is kept as a record; its intake is not tracked",
422,
{ errorCode: INTAKE_NOT_TRACKED_ERROR_CODE },
);
}
interface MedicationIntakeGuardPrisma {
medication: {
findUnique: (args: {
where: { id: string };
select: { id: true; userId: true; trackIntake: true };
}) => Promise<{ id: string; userId: string; trackIntake: boolean } | null>;
};
}
/**
* The ownership guard for a route that RECORDS a dose against the
* medication: the same 404 as {@link assertMedicationOwnership}, and then a
* 422 `medication.intake.notTracked` when the medication is kept as a record
* only (`trackIntake: false`). One read answers both, so the refusal cannot
* be reached for a medication the caller does not hold — an unknown id and a
* foreign one still look identical.
*/
export async function assertMedicationTakesIntake(
medicationId: string,
userId: string,
prismaClient?: MedicationIntakeGuardPrisma,
): Promise<Response | null> {
const client =
prismaClient ??
((await loadPrisma()) as unknown as MedicationIntakeGuardPrisma);
const med = await client.medication.findUnique({
where: { id: medicationId },
select: { id: true, userId: true, trackIntake: true },
});
if (!med || med.userId !== userId) {
return apiError("Medication not found", 404);
}
if (med.trackIntake === false) return refuseUntrackedIntake();
return null;
}
async function loadPrisma(): Promise<MedicationOwnershipPrisma> {
// Lazy import so test files that stub `@/lib/db` at the module level
// see their stub instead of the real Prisma client. Matches the
@@ -146,6 +146,43 @@ describe("dispatchNotification — client-managed APNs suppression", () => {
});
});
it("keeps a follow-up for a dose still open on APNs, because the phone sends none", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: { medication: { clientManaged: true } },
} as never);
await dispatchNotification({
...MEDICATION_PAYLOAD,
metadata: {
...MEDICATION_PAYLOAD.metadata,
phase: "ORANGE",
followUp: true,
},
});
expect(sendViaApnsMock).toHaveBeenCalledTimes(1);
expect(sendViaTelegramMock).toHaveBeenCalledTimes(1);
expect(recordPushAttemptMock).not.toHaveBeenCalled();
});
it("still keeps the reminder that announces the dose off APNs", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: { medication: { clientManaged: true } },
} as never);
await dispatchNotification({
...MEDICATION_PAYLOAD,
metadata: {
...MEDICATION_PAYLOAD.metadata,
phase: "YELLOW",
followUp: false,
},
});
expect(sendViaApnsMock).not.toHaveBeenCalled();
expect(sendViaTelegramMock).toHaveBeenCalledTimes(1);
});
it("honours the roaming deliveryDefault mapping", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: { medication: { deliveryDefault: "client" } },
+23 -4
View File
@@ -37,6 +37,12 @@ interface ClientManagedGate {
userId: string,
metadata: Record<string, unknown> | undefined,
) => Record<string, unknown>;
/**
* Which dispatches of the event the flag covers. Absent: all of them. The
* flag says the phone delivers this reminder itself, so it covers exactly
* what the phone replaces and nothing the phone does not send.
*/
covers?: (metadata: Record<string, unknown> | undefined) => boolean;
}
function str(value: unknown): string {
@@ -62,6 +68,13 @@ export const CLIENT_MANAGED_APNS_EVENTS: Record<string, ClientManagedGate> = {
phase: str(metadata?.phase),
dose_at: str(metadata?.scheduledAt),
}),
// The iOS client reminds once, at dose time, and sends no follow-ups of
// its own (iOS #116, item 21). So the flag keeps only the reminder that
// announces the dose off APNs; the escalations for a dose that is still
// open (`followUp: true`, set by the reminder cron) are pushed as usual.
// A dispatch that does not say is treated as the announcement, the
// behaviour every reminder had before the cron started saying.
covers: (metadata) => metadata?.followUp !== true,
},
MEASUREMENT_REMINDER: {
isClientManaged: isMeasurementReminderClientManaged,
@@ -71,9 +84,15 @@ export const CLIENT_MANAGED_APNS_EVENTS: Record<string, ClientManagedGate> = {
};
/**
* Does this event type have a client-managed opt-out at all? Cheap check
* the dispatcher runs before paying for the preferences read.
* Does a client-managed opt-out apply to this dispatch at all? Cheap check
* the dispatcher runs before paying for the preferences read: the event type
* has to have the opt-out, and the opt-out has to cover this dispatch.
*/
export function hasClientManagedApnsGate(eventType: string): boolean {
return eventType in CLIENT_MANAGED_APNS_EVENTS;
export function hasClientManagedApnsGate(
eventType: string,
metadata?: Record<string, unknown>,
): boolean {
const gate = CLIENT_MANAGED_APNS_EVENTS[eventType];
if (!gate) return false;
return gate.covers ? gate.covers(metadata) : true;
}
+4 -1
View File
@@ -326,7 +326,10 @@ export async function dispatchNotification(
if (
channel.type === "APNS" &&
!delivery.managed &&
hasClientManagedApnsGate(recipientPayload.eventType) &&
hasClientManagedApnsGate(
recipientPayload.eventType,
recipientPayload.metadata,
) &&
(await resolveClientManagedApns())
) {
const gate = CLIENT_MANAGED_APNS_EVENTS[recipientPayload.eventType];
+10 -1
View File
@@ -250,7 +250,10 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"measurement.restore.invalid",
"measurement.update.server_owned_source",
],
medication: ["medication.intake.import.invalid_format"],
medication: [
"medication.intake.import.invalid_format",
"medication.intake.notTracked",
],
medications: [
"medications.category.invalid",
"medications.category.limitReached",
@@ -308,6 +311,12 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"onboarding.restart.rateLimited",
"onboarding.tour.invalid",
],
passkey: [
"passkey.challenge.expired",
"passkey.response.invalid",
"passkey.unknown",
"passkey.verification.failed",
],
personal_records: ["personal_records.invalid_query"],
practitioner: ["practitioner.invalid"],
profile: [
+24
View File
@@ -501,6 +501,30 @@ const accountPayload = z
.looseObject({
id: z.string(),
username: z.string(),
// Enumerated because clients cut days and convert glucose by them: the
// day key and the unit must be the server's, not the device's.
timezone: z
.string()
.describe(
"The IANA zone the server cuts this account's days in: the stored zone, or the instance default when the stored one is unusable. Resolved, never the raw column, so bucket day keys by this value.",
),
glucoseUnit: z
.enum(["mg/dL", "mmol/L"])
.nullable()
.describe(
"The account's chosen glucose display unit, as stored. `null` means the person never chose one (or the stored value is neither unit), and the server then shows glucose in mg/dL.",
),
features: z
.object({
trackIntake: z
.literal(true)
.describe(
"Medications take `trackIntake`: a medication can be kept as a record whose doses are never due, and a dose written against one is refused with `medication.intake.notTracked`.",
),
})
.describe(
"What this server understands, for a client released apart from it. Constant across accounts; a flag appears once the server supports the feature and stays `true`. Absent on servers older than v1.42.0. A client that meets a flag it does not know ignores it.",
),
accountAccess: accountAccessBlock,
moduleAccess: moduleAccessMap,
// v1.39 — enumerated because every AI surface decides on it: a client
+61
View File
@@ -275,8 +275,69 @@ const adminMeasurementMaintenanceResult = z
})
.meta({ id: "AdminMeasurementMaintenanceResult" });
const adminMeasurementMaintenanceStatus = z
.object({
available: z
.boolean()
.describe(
"False when there is no background queue to ask (a web-only deployment); nothing can be started then.",
),
purgePending: z
.boolean()
.describe(
"The compaction-tombstone purge is queued or running. A run started now finishes at once with `refused_purge_running`.",
),
run: z
.object({
state: z.enum(["queued", "running", "completed", "failed"]),
requestedAt: z.iso.datetime(),
startedAt: z.iso.datetime().nullable(),
finishedAt: z.iso.datetime().nullable(),
outcome: z
.enum(["completed", "refused_purge_running", "already_running"])
.nullable()
.describe(
"How a `completed` run ended: `completed` did the work, the two refusals finished without touching the table. Null for any other state.",
),
})
.nullable()
.describe(
"The newest run pg-boss still keeps (terminal rows live seven days), or null when there is none.",
),
sizes: z
.object({
tableBytes: z
.number()
.int()
.describe("`measurements` with its indexes and TOAST."),
indexBytes: z.number().int().describe("Its indexes alone."),
})
.nullable(),
})
.meta({ id: "AdminMeasurementMaintenanceStatus" });
export const adminDiagnosticPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/admin/maintenance/measurements": {
get: {
tags: ["Admin"],
summary: "Where measurement table maintenance stands",
description:
"The newest maintenance run and its state, whether the compaction-tombstone purge is still at work, and the table's size right now. The admin card polls it while a run is queued or running. Cookie auth only: `requireAdmin()` refuses every Bearer caller.",
responses: {
"200": {
description: "The status.",
content: {
"application/json": {
schema: dataEnvelope(
adminMeasurementMaintenanceStatus,
"AdminMeasurementMaintenanceStatusResponse",
),
},
},
},
...stdResponses,
},
},
post: {
tags: ["Admin"],
summary: "Queue measurement table maintenance",
+13 -8
View File
@@ -46,6 +46,7 @@ import {
stdResponses,
errorEnvelope,
loginPasswordSchema,
validationIssue,
} from "./shared";
import { passkeyLoginOptionsSchema } from "@/lib/validations/auth";
import {
@@ -637,13 +638,7 @@ const authProfileUpdateResponse = z
"Whether an insurance number is stored. The number itself is encrypted at rest and is never returned — only its presence.",
),
rejectedFields: z
.array(
z.object({
path: z.string(),
code: z.string(),
message: z.string(),
}),
)
.array(validationIssue)
.optional()
.describe(
"Present only on a PARTIAL success: the fields that were skipped while the rest of the patch was written. A 200 carrying this key means the save was incomplete — surface it, do not treat the response as a clean save. `code` is the validator code for a field that failed validation, or `rate_limited` for an email-address change that has spent the account's hourly budget; that one is not a bad value and the same address will be accepted once the window rolls over.",
@@ -1442,10 +1437,20 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
"404": {
description:
"The assertion resolved to a user row that no longer exists.",
"`meta.errorCode` = `passkey.unknown` (v1.42.0): no passkey with this credential id is registered on this server — it was removed here, or belongs to another server. Tell the person to sign in another way. Without a code: the assertion resolved to a user row that no longer exists.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
"401": {
description:
"The sign-in did not prove anything (v1.42.0 codes; before, the first case answered 500). `meta.errorCode` = `passkey.challenge.expired` when the challenge is gone — it expired after five minutes, was already used, or was begun for a different account — so start a new sign-in; `passkey.verification.failed` when the assertion was checked and refused (signature, origin, relying-party id or challenge mismatch).",
content: { "application/json": { schema: errorEnvelope } },
},
"422": {
description:
"`challengeId` or `credential` is missing, or (`meta.errorCode` = `passkey.response.invalid`, v1.42.0) `credential` is not a WebAuthn authentication response.",
content: { "application/json": { schema: errorEnvelope } },
},
},
},
},
+1 -1
View File
@@ -87,7 +87,7 @@ export const ingestPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
"422": {
description:
"Validation failed, with every issue listed rather than the first — an integration operator should see the whole shape at once. An empty or unstable `idempotencyKey` fails here: the column is unique table-wide, so an empty key would resolve to whichever unrelated row stored it first.",
"Validation failed, with every issue listed rather than the first — an integration operator should see the whole shape at once. An empty or unstable `idempotencyKey` fails here: the column is unique table-wide, so an empty key would resolve to whichever unrelated row stored it first. Also 422 with `meta.errorCode` = `medication.intake.notTracked` (v1.42.0) when the named medication is kept as a record only (`trackIntake: false`); checked after the token's scope, so a token without this medication's scope learns nothing about it.",
content: { "application/json": { schema: errorEnvelope } },
},
"429": {
+1 -1
View File
@@ -1079,7 +1079,7 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
...stdResponses,
"422": {
description:
"The batch exceeded the 500-entry limit (`measurement.batch.too_large`), failed validation (`measurement.batch.invalid`, with every issue under `details.issues`), or (`measurement.batch.source_not_permitted`) carried an entry naming a source, any source, under a narrow `measurements:write` credential. Nothing was written in any of the three cases, and all three are permanent for the batch as sent: resending it unchanged is refused the same way.",
'The batch exceeded the 500-entry limit (`measurement.batch.too_large`), failed validation (`measurement.batch.invalid`, with every issue under `details.issues`), or (`measurement.batch.source_not_permitted`) carried an entry naming a source, any source, under a narrow `measurements:write` credential. Nothing was written in any of the three cases, and all three are permanent for the batch as sent: resending it unchanged is refused the same way. Which issues belong to an entry: every issue caused by one entry has a `path` that starts `entries.<n>` (the entry\'s index in the request, then the field, e.g. `entries.3.startDate`, or just `entries.4` when the entry is not an object). Those are the only per-entry issues, so a client may drop the named entries and resend the rest. An issue whose `path` is `""` (the body is not an object), `entries` (missing, not an array, or empty) or `syncTrigger` is about the batch itself, and no split recovers it.',
content: { "application/json": { schema: errorEnvelope } },
},
},
+51 -8
View File
@@ -88,6 +88,7 @@ import {
medicationCadenceResponse,
medicationComplianceResponse,
medicationComplianceSummaryEntry,
medicationComplianceAggregate,
scheduleRevisionResource,
scheduleRevisionListResponse,
medicationExtractRequest,
@@ -212,7 +213,12 @@ const bulkIntakeEntryResult = z
.describe(
"`inserted`/`updated`/`duplicate` — the row landed (advance the cursor). `skipped` — not stored; see `reason`.",
),
reason: z.string().optional(),
reason: z
.string()
.optional()
.describe(
"Why a `skipped` entry was not stored. Among others: `medication_not_found`, `unstable_external_id`, `force_slot_invalid`, `force_slot_occupied`, `injection_site_not_allowed`, and (v1.42.0) `intake_not_tracked` for an entry against a medication kept as a record only (`trackIntake: false`), the per-entry form of `medication.intake.notTracked`. A skip is final: resending the entry gives the same answer.",
),
id: z.string().optional().describe("The landed row id (absent on skips)."),
})
.meta({ id: "BulkMedicationIntakeEntryResult" });
@@ -312,7 +318,7 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
"422": {
description:
"Body validation failed (multi-issue envelope, breadcrumbed as `medications.intake.update.validation-failed`), or `meta.errorCode` = `medications.intake.injection_site.disallowed` when the site is outside the medication's effective allowed set, or `medications.intake.force_slot.invalid` when `forceSlotInstant` is not a real slot of the medication. The latter two carry no issue list — branch on the code.",
"Body validation failed (multi-issue envelope, breadcrumbed as `medications.intake.update.validation-failed`), or `meta.errorCode` = `medications.intake.injection_site.disallowed` when the site is outside the medication's effective allowed set, or `medications.intake.force_slot.invalid` when `forceSlotInstant` is not a real slot of the medication, or (v1.42.0) `medication.intake.notTracked` when the event is still open and its medication is kept as a record only (`trackIntake: false`): resolving or snoozing it would record a new dose that is counted nowhere. Correcting an event that is already taken or skipped stays open. The coded refusals carry no issue list — branch on the code.",
content: { "application/json": { schema: errorEnvelope } },
},
"401": stdResponses["401"],
@@ -1437,6 +1443,11 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
...stdResponses,
...recordWriteRateLimitResponse,
"422": {
description:
"Body validation failed (multi-issue envelope), or `meta.errorCode` names the refusal: `medications.intake.injection_site.disallowed`, `medications.intake.force_slot.invalid`, or (v1.42.0) `medication.intake.notTracked` when the medication is kept as a record only (`trackIntake: false`). Its stored history stays; a new dose is refused rather than stored and counted nowhere. The check runs after ownership, so a foreign medication is still a 404.",
content: { "application/json": { schema: errorEnvelope } },
},
},
},
get: {
@@ -1699,17 +1710,49 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Medications"],
summary: "Batched adherence read for every medication of the caller",
description:
"Returns one compact row per non-PRN medication. A scheduled medication with no local schedule remains in the array with applicable=false and reason NO_LOCAL_SCHEDULE. compliance7/compliance30 stay non-null as all-zero compatibility placeholders for released clients; aware clients must ignore those percentages when applicable=false. complianceDisplay is null.",
"Returns one compact row per non-PRN medication. A scheduled medication with no local schedule remains in the array with applicable=false and reason NO_LOCAL_SCHEDULE. compliance7/compliance30 stay non-null as all-zero compatibility placeholders for released clients; aware clients must ignore those percentages when applicable=false. complianceDisplay is null.\n\n" +
"v1.42.0, additive: `meta.aggregate` carries one figure for the whole account (`MedicationComplianceAggregate`, weighted by expected doses across the applicable medications; `null` when none applies), so a client never averages the rows itself. `days` (30, 60, 90, 180 or 365) adds a `complianceN` block in the same shape to every row and to the aggregate, for a report that covers its own window; `meta.days` echoes it. Any other `days` is a 422 rather than a rounded window.",
requestParams: {
query: z.object({
days: z
.enum(["30", "60", "90", "180", "365"])
.optional()
.describe(
"Report window in days. 30 is already `compliance30`; the others add `compliance60` / `compliance90` / `compliance180` / `compliance365`.",
),
}),
},
responses: {
...recordRefusal(),
"200": {
description: "One adherence row per medication.",
description:
"One adherence row per medication, and the account aggregate in `meta`.",
content: {
"application/json": {
schema: dataEnvelope(
z.array(medicationComplianceSummaryEntry),
"ListMedicationComplianceResponse",
),
schema: z
.object({
data: z.array(medicationComplianceSummaryEntry),
error: z.null(),
meta: z
.object({
aggregate: medicationComplianceAggregate.nullable(),
days: z
.union([
z.literal(30),
z.literal(60),
z.literal(90),
z.literal(180),
z.literal(365),
])
.optional()
.describe("The `days` the request asked for."),
requestId: z.string().optional(),
})
.describe(
"Absent on servers older than v1.42.0; a client that reads it treats absence as no aggregate.",
),
})
.meta({ id: "ListMedicationComplianceResponse" }),
},
},
},
@@ -936,6 +936,18 @@ export const medicationComplianceSummaryEntry = z
compliance30: complianceResult.describe(
"Thirty-day adherence summary, or an all-zero compatibility placeholder when applicable is false.",
),
compliance60: complianceResult
.optional()
.describe("Present only on `?days=60`: the trailing 60 days."),
compliance90: complianceResult
.optional()
.describe("Present only on `?days=90`: the trailing 90 days."),
compliance180: complianceResult
.optional()
.describe("Present only on `?days=180`: the trailing 180 days."),
compliance365: complianceResult
.optional()
.describe("Present only on `?days=365`: the trailing 365 days."),
complianceDisplay: complianceDisplay.nullable(),
})
.meta({
@@ -1732,3 +1744,27 @@ export const medicationSideEffect = z
description:
"One logged side effect. Not a clinical record — the account owns it, and it stays deletable at any time rather than locking after a retraction window.",
});
/**
* v1.42.0 — the account-wide adherence figure the batched read carries in
* `meta.aggregate`, beside the per-medication rows.
*/
export const medicationComplianceAggregate = z
.object({
compliance7: complianceResult,
compliance30: complianceResult,
compliance60: complianceResult.nullable().optional(),
compliance90: complianceResult.nullable().optional(),
compliance180: complianceResult.nullable().optional(),
compliance365: complianceResult.nullable().optional(),
medicationCount: z
.number()
.int()
.positive()
.describe("How many applicable medications the figure is built from."),
})
.meta({
id: "MedicationComplianceAggregate",
description:
"One adherence figure for the whole account in the `ComplianceResult` shape, weighted by expected doses: `taken`, `skipped` and `missed` are summed across every medication with `applicable: true`, and `rate` is recomputed from the sums by the per-medication rule (round(100 · taken / (taken + missed)), capped at 100, 100 when nothing was expected). Averaging the per-medication rates would let a weekly injection weigh as much as a three-times-daily tablet. `streak` is the shortest per-medication streak. The `complianceN` block for a requested `days` is present when `days` was sent and is not 30.",
});
+19 -2
View File
@@ -955,7 +955,14 @@ const moodAggregatesResponse = z
days: z.number().int(),
})
.nullable()
.describe("Null for a sparse logger below the day floor."),
.describe(
"Day-to-day steadiness over the trailing `stabilityWindowDays` days. Null below the day floor (seven days with an entry) inside that window, so a short window can be null where the year is not.",
),
stabilityWindowDays: z
.union([z.literal(30), z.literal(90), z.literal(180), z.literal(365)])
.describe(
"The window `stability` covers: the `days` query parameter, or 365 (the whole daily series) when it was not sent. Added in v1.42.0; absent from older servers, whose score always covers the year.",
),
tags: z
.array(
z.object({
@@ -1277,7 +1284,17 @@ export const moodPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Mood"],
summary: "Pre-computed mood aggregates (v1.8.5)",
description:
"The whole Mood Insights page in one read. The compute runs server-side and only pre-shaped blocks leave — no raw mood row is on this wire, and no LLM is reachable from this path. Stale-while-revalidate: past the fresh window the prior aggregate is served immediately while one background recompute warms a new one, so an active logger never re-pays the cold compute. Module-gated on `mood`; a disabled module answers 403 `module.disabled` even for a valid Bearer token. Cookie or Bearer auth; the caller is always resolved as themselves, so this read cannot be delegated to a shared record — which makes it the one mood surface a delegate cannot reach.",
"The whole Mood Insights page in one read. The compute runs server-side and only pre-shaped blocks leave — no raw mood row is on this wire, and no LLM is reachable from this path. Stale-while-revalidate: past the fresh window the prior aggregate is served immediately while one background recompute warms a new one, so an active logger never re-pays the cold compute. Module-gated on `mood`; a disabled module answers 403 `module.disabled` even for a valid Bearer token. Cookie or Bearer auth; the caller is always resolved as themselves, so this read cannot be delegated to a shared record — which makes it the one mood surface a delegate cannot reach. v1.42.0 — `days` cuts `stability` to the period a client shows; every other block is unchanged by it.",
requestParams: {
query: z.object({
days: z
.enum(["30", "90", "180", "365"])
.optional()
.describe(
"Trailing window for `stability`, in days. Omitted: 365. Any other value is a 422.",
),
}),
},
responses: {
"200": {
description: "The aggregate bundle.",
+4 -9
View File
@@ -56,6 +56,7 @@ import {
recordRefusal,
stdResponses,
updatedAtTokenField,
validationIssue,
} from "./shared";
// v1.18.0 — module enable/disable. The PATCH request is the REAL runtime
@@ -259,7 +260,7 @@ const notificationPrefsResolved = z
clientManaged: z
.boolean()
.describe(
"True when the iOS app owns local medication reminders; the server-side MEDICATION_REMINDER APNs cron is suppressed.",
"True when the iOS app delivers medication reminders locally. The server then keeps the reminder that announces a dose off APNs (the phone shows its own at dose time) and still pushes the follow-ups for a dose that stays open, which the phone does not send (v1.42.0; before, every MEDICATION_REMINDER push was suppressed). Other channels are never affected.",
),
deliveryDefault: z
.enum(["server", "client"])
@@ -587,13 +588,7 @@ const profileUpdateResponse = z
.boolean()
.describe("Whether a KVNR is on file (the value itself is not echoed)."),
rejectedFields: z
.array(
z.object({
path: z.string(),
code: z.string(),
message: z.string(),
}),
)
.array(validationIssue)
.optional()
.describe(
"Present only on a PARTIAL success: the fields that were skipped while the rest of the patch was written. A 200 carrying this key means the save was incomplete — surface it, do not treat the response as a clean save. `code` is the validator code for a field that failed validation, or `rate_limited` for an email-address change that has spent the account's hourly budget; that one is not a bad value and the same address will be accepted once the window rolls over. The handler has always been able to answer with this key on this path; it was published only on PUT /api/auth/profile.",
@@ -1638,7 +1633,7 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Notifications"],
summary: "Update per-user notification preferences",
description:
'Deep-merges the supplied partial shape over the persisted row — a PATCH touching only one category leaves the siblings intact. Always returns the fully-resolved next state so clients can hard-set their optimistic update. `medication.clientManaged: true` (or `deliveryDefault: "client"`) suppresses server-side MEDICATION_REMINDER APNs; `medication.lowStockRunwayDays` (1–60, nullable, default 7) tunes the low-stock alert — null switches it off. Rate-limit 60/min per user.',
'Deep-merges the supplied partial shape over the persisted row — a PATCH touching only one category leaves the siblings intact. Always returns the fully-resolved next state so clients can hard-set their optimistic update. `medication.clientManaged: true` (or `deliveryDefault: "client"`) keeps the MEDICATION_REMINDER that announces a dose off APNs and leaves the follow-ups for a dose still open on it (v1.42.0); `medication.lowStockRunwayDays` (1–60, nullable, default 7) tunes the low-stock alert — null switches it off. Rate-limit 60/min per user.',
requestBody: {
required: true,
content: {
+1 -1
View File
@@ -32,7 +32,7 @@ import { createBatchWorkoutSchema as createBatchWorkoutSchemaBase } from "@/lib/
* built for could not read it from the contract. `params` never ships: some
* Zod codes embed the offending value in it and that is user content.
*/
const validationIssue = z
export const validationIssue = z
.object({
path: z
.string()
+2
View File
@@ -48,6 +48,8 @@ export const adminKeys = {
adminBackupRestores: () => ["admin", "backup-restores"] as const,
/** v1.23 — encryption coverage + rotation progress (admin Encryption section). */
adminEncryptionStatus: () => ["admin", "encryption", "status"] as const,
adminMeasurementMaintenance: () =>
["admin", "maintenance", "measurements"] as const,
/** v1.40.0 — the "Back up your encryption key" step (admin + dashboard banner). */
adminEncryptionKeyBackup: () =>
["admin", "encryption", "key-backup"] as const,
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
import { parseAccountAccess } from "../account-access-schema";
import {
parseAccountAccess,
validateAccountAccessBlock,
} from "../account-access-parse";
import { accountAccessBlockSchema } from "../account-access-schema";
const sharedEntry = {
accountId: "record-a",
@@ -220,3 +224,108 @@ describe("parseAccountAccess", () => {
).toBeNull();
});
});
/**
* The browser parse carries no Zod (it rides the shell every route shares),
* so it is a second statement of the published schema. This holds the two to
* one verdict: over every fixture below, and every single-field breakage of
* each, the hand-written check accepts exactly what the schema accepts and
* returns the same stripped value. Change one without the other and this
* fails with the input that tells them apart.
*/
describe("validateAccountAccessBlock agrees with the published schema", () => {
const BAD_VALUES: unknown[] = [
undefined,
null,
0,
1,
true,
"",
"bogus",
[],
["labs"],
["labs", "labs"],
["nope"],
{},
];
const managerEntry = {
...sharedEntry,
accountId: "record-m",
username: "record-m",
access: "write",
level: "manage",
recordKind: "managed",
sections: null,
canWrite: true,
writableDomains: ["measurements", "mind"],
manageableDomains: ["measurements"],
};
const writerEntry = {
...sharedEntry,
accountId: "record-w",
username: "record-w",
access: "write",
level: "write",
sections: ["labs", "measurements"],
canWrite: true,
writableDomains: ["measurements"],
};
const bases: Record<string, unknown>[] = [
accessBlock(),
{ accounts: [], active: null, recordKind: "self", canSwitch: false },
{
accounts: [managerEntry, writerEntry],
active: null,
recordKind: "self",
canSwitch: true,
},
{
accounts: [writerEntry],
active: { ...writerEntry, extra: "stripped" },
recordKind: "shared",
canSwitch: true,
},
];
function variants(): unknown[] {
const out: unknown[] = [...bases, null, "x", [], 42];
for (const base of bases) {
for (const key of Object.keys(base)) {
for (const bad of BAD_VALUES) out.push({ ...base, [key]: bad });
}
const accounts = base.accounts as Record<string, unknown>[];
accounts.forEach((entry, index) => {
for (const key of [...Object.keys(entry), "unknownKey"]) {
for (const bad of BAD_VALUES) {
const broken = { ...entry, [key]: bad };
const next = [...accounts];
next[index] = broken;
out.push({ ...base, accounts: next });
if (base.active) out.push({ ...base, active: broken });
}
}
});
}
return out;
}
it("accepts and rejects the same blocks, with the same output", () => {
const corpus = variants();
expect(corpus.length).toBeGreaterThan(500);
let accepted = 0;
for (const input of corpus) {
const zod = accountAccessBlockSchema.safeParse(input);
const mine = validateAccountAccessBlock(input);
expect(mine !== null, JSON.stringify(input)).toBe(zod.success);
if (zod.success) {
expect(mine, JSON.stringify(input)).toEqual(zod.data);
accepted += 1;
}
}
// Both arms exercised: the corpus is not all-reject or all-accept.
expect(accepted).toBeGreaterThan(10);
expect(accepted).toBeLessThan(corpus.length - 100);
});
});
+212
View File
@@ -0,0 +1,212 @@
/**
* The browser's parse of the account-access block on `GET /api/auth/me`.
*
* Plain TypeScript, no Zod, on purpose. `use-auth.ts` runs this parse on every
* app boot, so whatever it imports lands in the shell every route shares, and
* this parse was the shell's only Zod import: about 60 KB gzip on every first
* paint for one structural check. A tree-shaken `zod/mini` build measured
* almost the same under Turbopack, because the shared core comes along either
* way.
*
* The Zod schema stays the contract: `account-access-schema.ts` publishes it
* to OpenAPI, and `account-access-parse.test.ts` runs both over the same
* corpus of good and broken blocks and fails the moment they disagree on
* whether a block is accepted or on what comes out of it.
*/
import type {
AccountAccess,
AccountAccessEntry,
} from "@/lib/sharing/account-access-view";
import { SHARE_DOMAINS, type ShareDomain } from "@/lib/sharing/scope";
const DOMAINS: ReadonlySet<string> = new Set(SHARE_DOMAINS);
type Raw = Record<string, unknown>;
function isRecord(value: unknown): value is Raw {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.length >= 1;
}
function isNullableString(value: unknown): value is string | null {
return value === null || typeof value === "string";
}
function domainList(value: unknown): ShareDomain[] | null {
if (!Array.isArray(value)) return null;
for (const domain of value) {
if (typeof domain !== "string" || !DOMAINS.has(domain)) return null;
}
return [...(value as ShareDomain[])];
}
function hasRepeat(domains: readonly string[]): boolean {
return new Set(domains).size !== domains.length;
}
function isSubset(
inner: readonly string[],
outer: readonly string[] | null,
): boolean {
if (outer === null) return true;
const set = new Set(outer);
return inner.every((domain) => set.has(domain));
}
/**
* One entry, checked field by field and copied out with only the fields the
* contract names (unknown keys are dropped, as the schema strips them), or
* null. The invariants after the field checks are the schema's refinements.
*/
function parseEntry(value: unknown): AccountAccessEntry | null {
if (!isRecord(value)) return null;
const {
accountId,
username,
displayName,
fullName,
access,
level,
recordKind,
sections: rawSections,
canWrite,
writableDomains: rawWritable,
manageableDomains: rawManageable,
} = value;
if (!isNonEmptyString(accountId) || !isNonEmptyString(username)) return null;
if (!isNullableString(displayName) || !isNullableString(fullName)) {
return null;
}
if (access !== "read" && access !== "write") return null;
if (level !== "read" && level !== "write" && level !== "manage") return null;
if (recordKind !== "shared" && recordKind !== "managed") return null;
const sections = rawSections === null ? null : domainList(rawSections);
if (rawSections !== null && sections === null) return null;
if (typeof canWrite !== "boolean") return null;
const writableDomains = domainList(rawWritable);
const manageableDomains = domainList(rawManageable);
if (writableDomains === null || manageableDomains === null) return null;
const isManage = level === "manage";
if (sections !== null && hasRepeat(sections)) return null;
if (isManage && (access !== "write" || sections !== null)) return null;
if (canWrite !== (level !== "read")) return null;
if (!isManage && access !== level) return null;
if (hasRepeat(writableDomains) || hasRepeat(manageableDomains)) return null;
if (
!isSubset(writableDomains, sections) ||
!isSubset(manageableDomains, sections)
) {
return null;
}
if (!isSubset(manageableDomains, writableDomains)) return null;
if (level === "read" && writableDomains.length > 0) return null;
if (!isManage && manageableDomains.length > 0) return null;
return {
accountId,
username,
displayName,
fullName,
access,
level,
recordKind,
sections,
canWrite,
writableDomains,
manageableDomains,
};
}
/**
* The block's structure, without the cross-entry checks: what the schema
* accepts, and the same stripped value it returns. Exported for the
* equivalence test; the app calls {@link parseAccountAccess}.
*/
export function validateAccountAccessBlock(
value: unknown,
): AccountAccess | null {
if (!isRecord(value)) return null;
const {
accounts: rawAccounts,
active: rawActive,
recordKind,
canSwitch,
} = value;
if (!Array.isArray(rawAccounts)) return null;
const accounts: AccountAccessEntry[] = [];
for (const raw of rawAccounts) {
const entry = parseEntry(raw);
if (!entry) return null;
accounts.push(entry);
}
const active = rawActive === null ? null : parseEntry(rawActive);
if (rawActive !== null && active === null) return null;
if (
recordKind !== "self" &&
recordKind !== "shared" &&
recordKind !== "managed"
) {
return null;
}
if (typeof canSwitch !== "boolean") return null;
return { accounts, active, recordKind, canSwitch };
}
/** Both published views of the active record must name the same resolved grant. */
function sameEntry(a: AccountAccessEntry, b: AccountAccessEntry): boolean {
const sameList = (x: readonly string[], y: readonly string[]) =>
x.length === y.length && x.every((domain, index) => domain === y[index]);
const sameSections =
a.sections === null || b.sections === null
? a.sections === b.sections
: sameList(a.sections, b.sections);
return (
sameList(a.writableDomains, b.writableDomains) &&
sameList(a.manageableDomains, b.manageableDomains) &&
a.accountId === b.accountId &&
a.username === b.username &&
a.displayName === b.displayName &&
a.fullName === b.fullName &&
a.access === b.access &&
a.level === b.level &&
a.recordKind === b.recordKind &&
a.canWrite === b.canWrite &&
sameSections
);
}
/** Parse the server-resolved access block as a closed presentation contract. */
export function parseAccountAccess(value: unknown): AccountAccess | null {
const block = validateAccountAccessBlock(value);
if (!block) return null;
const { accounts, active, recordKind, canSwitch } = block;
const accountIds = new Set(accounts.map((entry) => entry.accountId));
if (
accountIds.size !== accounts.length ||
canSwitch !== accounts.length > 0
) {
return null;
}
if (active === null) {
return recordKind === "self"
? { accounts, active: null, recordKind, canSwitch }
: null;
}
if (!accountIds.has(active.accountId) || recordKind !== active.recordKind) {
return null;
}
const canonicalActive = accounts.find(
(entry) => entry.accountId === active.accountId,
);
if (!canonicalActive || !sameEntry(active, canonicalActive)) return null;
return { accounts, active: canonicalActive, recordKind, canSwitch };
}
+7 -72
View File
@@ -1,6 +1,11 @@
/**
* The account-access block's contract as a Zod schema, for the OpenAPI
* document. The browser does not import it: its parse is the Zod-free
* `account-access-parse.ts`, kept out of the shell every route shares, and
* `account-access-parse.test.ts` holds the two to the same verdicts.
*/
import { z } from "zod/v4";
import type { AccountAccess } from "./account-access-view";
import { SHARE_DOMAINS } from "./scope";
const accountAccessLevelSchema = z.enum(["read", "write", "manage"]);
@@ -21,7 +26,7 @@ function isSubset(
return inner.every((domain) => set.has(domain));
}
/** The browser's resolved account-access payload, shared with OpenAPI. */
/** One account-access entry, as OpenAPI publishes it. */
export const accountAccessEntrySchema = z
.object({
accountId: z.string().min(1),
@@ -125,73 +130,3 @@ export const accountAccessBlockSchema = z.object({
recordKind: z.enum(["self", "shared", "managed"]),
canSwitch: z.boolean(),
});
/** Both published views of the active record must name the same resolved grant. */
function matchesCanonicalAccountAccessEntry(
active: z.infer<typeof accountAccessEntrySchema>,
canonical: z.infer<typeof accountAccessEntrySchema>,
): boolean {
const activeSections = active.sections;
const canonicalSections = canonical.sections;
const sameSections =
activeSections === null || canonicalSections === null
? activeSections === canonicalSections
: activeSections.length === canonicalSections.length &&
activeSections.every(
(section, index) => section === canonicalSections[index],
);
const sameList = (a: readonly string[], b: readonly string[]) =>
a.length === b.length && a.every((domain, index) => domain === b[index]);
return (
sameList(active.writableDomains, canonical.writableDomains) &&
sameList(active.manageableDomains, canonical.manageableDomains) &&
active.accountId === canonical.accountId &&
active.username === canonical.username &&
active.displayName === canonical.displayName &&
active.fullName === canonical.fullName &&
active.access === canonical.access &&
active.level === canonical.level &&
active.recordKind === canonical.recordKind &&
active.canWrite === canonical.canWrite &&
sameSections
);
}
/** Parse the server-resolved access block as a closed presentation contract. */
export function parseAccountAccess(value: unknown): AccountAccess | null {
const parsed = accountAccessBlockSchema.safeParse(value);
if (!parsed.success) return null;
const { accounts, active, recordKind, canSwitch } = parsed.data;
const accountIds = new Set(accounts.map((entry) => entry.accountId));
if (
accountIds.size !== accounts.length ||
canSwitch !== accounts.length > 0
) {
return null;
}
if (active === null) {
return recordKind === "self"
? { accounts, active: null, recordKind, canSwitch }
: null;
}
if (!accountIds.has(active.accountId) || recordKind !== active.recordKind) {
return null;
}
const canonicalActive = accounts.find(
(entry) => entry.accountId === active.accountId,
);
if (
!canonicalActive ||
!matchesCanonicalAccountAccessEntry(active, canonicalActive)
) {
return null;
}
return { accounts, active: canonicalActive, recordKind, canSwitch };
}
+3 -152
View File
@@ -298,158 +298,9 @@ export const USER_CORRECTABLE_MEASUREMENT_SOURCES = [
EXTERNAL_SOURCE,
] as const;
const unitMap: Record<string, string> = {
WEIGHT: "kg",
BLOOD_PRESSURE_SYS: "mmHg",
BLOOD_PRESSURE_DIA: "mmHg",
PULSE: "bpm",
BODY_FAT: "%",
// v1.4.23 — sleep duration shifted from hours to minutes so HealthKit
// category-sample stages can be stored without precision loss. Older
// surfaces that need hours convert at read time (`minutes / 60`).
SLEEP_DURATION: "minutes",
ACTIVITY_STEPS: "steps",
BLOOD_GLUCOSE: "mg/dL",
TOTAL_BODY_WATER: "kg",
BONE_MASS: "kg",
OXYGEN_SATURATION: "%",
// ── v1.4.23 Apple Health canonical units ──
HEART_RATE_VARIABILITY: "ms",
RESTING_HEART_RATE: "bpm",
ACTIVE_ENERGY_BURNED: "kcal",
FLIGHTS_CLIMBED: "flights",
WALKING_RUNNING_DISTANCE: "m",
VO2_MAX: "mL/(kg·min)",
BODY_TEMPERATURE: "celsius",
// ── v1.4.25 W5d Withings full coverage ──
FAT_FREE_MASS: "kg",
FAT_MASS: "kg",
MUSCLE_MASS: "kg",
// Distinct from BODY_TEMPERATURE — surface temps run ~32 °C; sharing
// the bucket would corrupt analytics. Same canonical unit (°C).
SKIN_TEMPERATURE: "celsius",
PULSE_WAVE_VELOCITY: "m/s",
VASCULAR_AGE: "years",
// Withings reports visceral fat as a 1–12 rating, not a percent. The
// string mirrors what Withings prints in Health Mate.
VISCERAL_FAT: "rating",
// ── v1.4.25 W8d Apple Health server-prep ──
// Sound-pressure level — A-weighted decibels (dBA). HealthKit reports
// both audio-exposure metrics in dBASPL; we store the unweighted "dBA"
// label because the A-weighting is implicit (every HealthKit audio
// sample carries it). 30 dBA = quiet bedroom; 140 dBA = pain threshold.
AUDIO_EXPOSURE_ENV: "dBA",
AUDIO_EXPOSURE_HEADPHONE: "dBA",
// Daily-rollup pattern (one sample = one day's outdoor-light minutes).
// 0–1440 covers the 24-hour day; in practice indoor users sit near 0
// and outdoor athletes accumulate a few hours.
TIME_IN_DAYLIGHT: "minutes",
// ── v1.5.5 iOS-coord additions ──
// Respiratory rate breaths-per-minute — the HK identifier ships as
// `count/min`; we keep the more conventional clinical label.
RESPIRATORY_RATE: "breaths/min",
// BMI kg/m² — HealthKit ships the unitless ratio; the canonical
// display string mirrors clinical convention.
BODY_MASS_INDEX: "kg/m²",
// Lean body mass kg — body-composition partner to FAT_MASS.
LEAN_BODY_MASS: "kg",
// Walking heart rate average bpm — daily rollup; distinct from
// RESTING_HEART_RATE (sleep-window minimum) and spot PULSE.
WALKING_HEART_RATE_AVERAGE: "bpm",
// Walking gait percent (0-100 after server-side ×100 scaling).
// Same convention as WALKING_STEADINESS / BODY_FAT / OXYGEN_SATURATION
// — see the project convention block in `apple-health-mapping.ts`.
WALKING_ASYMMETRY: "%",
WALKING_DOUBLE_SUPPORT: "%",
// ── v1.5.5 iOS-coord follow-up — raw-SI gait pair ──
// Step length is metres; speed is metres per second. Both flow
// raw on the wire — no server-side scaling. The unit strings
// match HealthKit's `m` / `m/s` defaults.
WALKING_STEP_LENGTH: "m",
WALKING_SPEED: "m/s",
// ── v1.10.0 — additive HealthKit signals (WX-A) ──
// Cardio recovery is the bpm drop one minute after peak exercise.
CARDIO_RECOVERY: "bpm",
// Overnight wrist temperature in °C (absolute reading; Apple's own
// display frames it as a baseline deviation, we store the reading).
WRIST_TEMPERATURE: "celsius",
// Hard-fall detections — a plain count.
FALL_COUNT: "count",
// Apple's estimated six-minute-walk-test distance in metres.
SIX_MINUTE_WALK_DISTANCE: "m",
// Stair gait speeds — raw metres-per-second (no scaling).
STAIR_ASCENT_SPEED: "m/s",
STAIR_DESCENT_SPEED: "m/s",
// Per-night breathing-disturbance index — a unitless count Apple
// classifies as NotElevated / Elevated.
BREATHING_DISTURBANCES: "count",
// ── v1.10.0 — categorical events (WX-B) ──
// EVENT rows are dimensionless occurrences (value is always 1). The
// canonical unit is the bare "event" so any accidental numeric surfacing
// reads sensibly; the awareness timeline never displays the value.
IRREGULAR_RHYTHM_NOTIFICATION: "event",
HIGH_HEART_RATE_EVENT: "event",
LOW_HEART_RATE_EVENT: "event",
WALKING_STEADINESS_EVENT: "event",
BREATHING_DISTURBANCE_EVENT: "event",
// ── v1.10.0 — computed scores (WX-C) ──
// Server-derived 0–100 wellness scores. The canonical unit is the bare
// "score" so the value reads sensibly anywhere it surfaces.
RECOVERY_SCORE: "score",
STRESS_SCORE: "score",
STRAIN_SCORE: "score",
// ── v1.11.0 — WHOOP-native score classes ──
// RMSSD HRV is in milliseconds, same canonical unit as the SDNN
// HEART_RATE_VARIABILITY (different estimator, same dimension).
HRV_RMSSD: "ms",
// Day / workout strain ride WHOOP's bounded 0–21 scale; the bare "score"
// unit reads sensibly wherever the value surfaces (distinct from the
// 0–100 COMPUTED STRAIN_SCORE).
DAY_STRAIN: "score",
WORKOUT_STRAIN: "score",
// Sleep quality percentages (0–100).
SLEEP_PERFORMANCE: "%",
SLEEP_EFFICIENCY: "%",
SLEEP_CONSISTENCY: "%",
// Recommended sleep duration in minutes (WHOOP reports ms; mapper ÷60000).
SLEEP_NEED: "minutes",
// Day energy expenditure in kilojoules (WHOOP-native; kept in kJ so the
// device value round-trips rather than being converted to kcal).
ENERGY_EXPENDITURE_KJ: "kJ",
// ── v1.12.8 — WHOOP cycle + sleep coverage completion ──
// Daily average / max heart rate bpm — whole-cycle aggregates, distinct
// from the spot PULSE / RESTING_HEART_RATE / WALKING_HEART_RATE_AVERAGE.
AVERAGE_HEART_RATE: "bpm",
MAX_HEART_RATE: "bpm",
// Per-night sleep disturbance tally — a plain integer count.
SLEEP_DISTURBANCE_COUNT: "count",
// ── v1.17.1 — Polar Nightly Recharge + Training Load Pro components ──
ANS_CHARGE: "score",
CARDIO_LOAD: "score",
// ── v1.17.1 — Oura coverage completion ──
// Oura's headline 0–100 Sleep Score — bare "score" like the other 0–100 scores.
SLEEP_SCORE: "score",
// Signed body-temperature deviation in °C (Oura nightly baseline offset).
BODY_TEMPERATURE_DEVIATION: "celsius",
// ── v1.19.0 — Oura resilience ──
// Ordinal level scale (1=limited … 5=exceptional) — the categorical band
// encoded into the numeric value. See RESILIENCE_LEVELS in src/lib/oura/client.
RESILIENCE: "level",
// ── v1.25 — clinical-signals wave ──
PHQ9_SCORE: "score",
GAD7_SCORE: "score",
// ── v1.27.9 — screening scores ──
WHO5_SCORE: "score",
SCI_SCORE: "score",
GRIP_STRENGTH: "kg",
PAIN_NRS: "score",
WAIST_CIRCUMFERENCE: "cm",
WAIST_TO_HEIGHT: "ratio",
};
export function getUnitForType(type: string): string {
return unitMap[type] ?? "unknown";
}
// The canonical unit per type lives in a Zod-free module so the client's
// display transform can read it without pulling Zod into every chart route.
export { getUnitForType } from "@/lib/measurements/unit-map";
// Plausible ranges per measurement type
export const VALUE_RANGES: Record<string, { min: number; max: number }> = {
@@ -197,6 +197,32 @@ describe("per-user timezone — Pacific/Auckland end-to-end", () => {
}
});
// The fields a native client decides on, enumerated in `AccountPayload`:
// the unit as the closed pair the contract names (a restored backup can
// write anything into the free-text column), and the capability flags.
it("GET /api/auth/me publishes the glucose unit as the closed pair and the server features", async () => {
const user = await seedAucklandUser();
const prisma = getPrismaClient();
const { GET } = await import("@/app/api/auth/me/route");
await prisma.user.update({
where: { id: user.id },
data: { glucoseUnit: "mmol/L" },
});
let body = (await (await GET()).json()) as {
data: { glucoseUnit: string | null; features: Record<string, unknown> };
};
expect(body.data.glucoseUnit).toBe("mmol/L");
expect(body.data.features).toEqual({ trackIntake: true });
await prisma.user.update({
where: { id: user.id },
data: { glucoseUnit: "mmol" },
});
body = (await (await GET()).json()) as typeof body;
expect(body.data.glucoseUnit).toBeNull();
});
it("PUT /api/auth/me/timezone rejects an invalid IANA zone with 422", async () => {
await seedAucklandUser();