mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Merge pull request #330 from MBombeck/release/v1.16.16
v1.16.16 — one engine for sleep, recovery and glucose
This commit is contained in:
81 files changed
+4191
-460
No files matched your search
@@ -2,6 +2,31 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.16.16] — 2026-06-14 — one engine for sleep, recovery and glucose
|
||||
|
||||
This release continues the work of making one number mean one thing everywhere. Sleep, recovery and blood glucose are now read from a single source on every surface — the dashboard, the coach, the doctor-report, the CSV and FHIR exports, and the companion app — so the figure you act on is the same wherever you look. Several displayed numbers become correct-but-different; each is called out below. No breaking changes.
|
||||
|
||||
### Added
|
||||
|
||||
- **Per-stage sleep on the night feed.** The sleep-night feed now rounds every duration to whole minutes and carries the per-stage breakdown (deep, core, REM, awake), so the companion app renders the same night the web does.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Sleep is reconstructed once, from one engine.** The sleep score now reads each night through the same reconstruction the rest of the app uses, with source de-duplication: a night recorded by more than one source (for example a watch and a ring) is counted once instead of summed. For multi-source nights the score — and the readiness that builds on it — drops to its true figure rather than an inflated one.
|
||||
- **WHOOP recovery is the recovery you see.** When a WHOOP-native recovery exists it is now the single canonical value on the tile, the chart, the doctor-report, and the coach; the computed proxy is the fallback when there is no native score. A connected account no longer sees the proxy and the native value as two competing numbers, and the night each belongs to is resolved consistently, so one night reads as one recovery. A WHOOP user's recovery figure can change to the native one.
|
||||
- **Blood glucose speaks your unit everywhere.** Glucose is converted once, at the point it is served, through a single helper — so the series the companion app charts, the CSV export, the dashboard tile, the detail page, and the coach all read the unit you chose. The CSV export now matches the FHIR document instead of emitting raw mg/dL against a converted clinical export. If you read in mmol/L, these surfaces now show the converted value (for example 100 mg/dL as 5.5 mmol/L); mg/dL readers see no change. The blood-glucose median no longer claims a fixed window its value does not hold.
|
||||
- **The coach reads the canonical numbers.** The coach now quotes recovery from the same resolved series as every other surface rather than a blend of sources, and states blood glucose in your unit instead of a bare mg/dL number.
|
||||
- **WHOOP sync keeps a cursor per data type.** Recovery, sleep, workouts and cycles each track their own sync position, so a single slow or rejecting collection no longer holds the others back; a change notification refreshes just the affected record, and a full sync can backfill the deep history an incremental tick skips.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The coach appears when a shared model is configured.** A self-hoster who runs the coach from a server- or operator-managed model — with no personal key — now sees the coach instead of an "unconfigured" state, because the provider endpoint reports whether the coach can actually answer.
|
||||
- **A consent receipt cannot double-mint.** Two requests arriving together can no longer leave two active consent grants; the active receipt is enforced unique and minted atomically, and a concurrent first grant resolves to the winning receipt instead of an error.
|
||||
|
||||
### Security
|
||||
|
||||
- **Consent endpoints are rate-limited.** The consent grant, read and revoke routes share a per-user limit, so the receipt path can no longer be driven in a tight loop.
|
||||
|
||||
## [1.16.15] — 2026-06-14 — an honest calendar that learns before it predicts
|
||||
|
||||
A focused release on the cycle calendar: it leans on observed data, names its
|
||||
|
||||
+267
-3
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.16.15
|
||||
version: 1.16.16
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
@@ -1436,6 +1436,57 @@ paths:
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/measurements/series:
|
||||
get:
|
||||
tags:
|
||||
- Measurements
|
||||
summary: Per-kind time series (iOS chart source)
|
||||
description: Maps a camelCase `kind` to the canonical MeasurementType(s) and returns an ordered point series with an
|
||||
explicit `unit` token and a summary `stats` block. `bloodPressure` pairs systolic + diastolic (`secondary`)
|
||||
within ±5 min so one fetch renders the dual-line chart; `sleep` collapses per-stage rows into one per-night
|
||||
point carrying time-asleep in hours (`sleepStages` holds the per-stage breakdown) and is internally capped to
|
||||
365 days. `glucose` values + `unit` resolve to the user's mg/dL|mmol/L preference. Auth via cookie or Bearer.
|
||||
parameters:
|
||||
- in: query
|
||||
name: kind
|
||||
schema:
|
||||
type: string
|
||||
enum:
|
||||
- weight
|
||||
- bloodPressure
|
||||
- pulse
|
||||
- bodyFat
|
||||
- glucose
|
||||
- sleep
|
||||
- steps
|
||||
- totalBodyWater
|
||||
- boneMass
|
||||
- oxygenSaturation
|
||||
- restingHeartRate
|
||||
- heartRateVariability
|
||||
- vo2Max
|
||||
required: true
|
||||
- in: query
|
||||
name: days
|
||||
schema:
|
||||
default: 30
|
||||
description: Look-back window in days (1..3650, default 30). `sleep` is internally capped to 365 regardless of the
|
||||
requested range.
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 3650
|
||||
description: Look-back window in days (1..3650, default 30). `sleep` is internally capped to 365 regardless of the
|
||||
requested range.
|
||||
responses:
|
||||
"200":
|
||||
description: Resolved series.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GetMeasurementsSeriesResponse"
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/measurements:
|
||||
get:
|
||||
tags:
|
||||
@@ -2832,6 +2883,25 @@ paths:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/user/ai-provider:
|
||||
get:
|
||||
tags:
|
||||
- Auth
|
||||
summary: Read the calling user's AI-provider configuration
|
||||
description: "Returns the user's selected provider/model/baseUrl plus the effective availability: `aiAvailable` is true
|
||||
when any provider can serve the user (BYOK, self-hosted, or the operator's server-managed key), and `managedBy`
|
||||
reports which. iOS gates Coach visibility off these two fields. Key material is reported as presence + a 4-char
|
||||
masked preview only."
|
||||
responses:
|
||||
"200":
|
||||
description: Resolved AI-provider configuration.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GetAiProviderResponse"
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/auth/me/coach-prefs:
|
||||
get:
|
||||
tags:
|
||||
@@ -8286,6 +8356,113 @@ components:
|
||||
- updatedAt
|
||||
additionalProperties: false
|
||||
description: A soft-deleted medication intake, keyed on server `id`. Apply before upserts within the domain page.
|
||||
GetMeasurementsSeriesResponse:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/MeasurementsSeriesResponse"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
MeasurementsSeriesResponse:
|
||||
type: object
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
enum:
|
||||
- weight
|
||||
- bloodPressure
|
||||
- pulse
|
||||
- bodyFat
|
||||
- glucose
|
||||
- sleep
|
||||
- steps
|
||||
- totalBodyWater
|
||||
- boneMass
|
||||
- oxygenSaturation
|
||||
- restingHeartRate
|
||||
- heartRateVariability
|
||||
- vo2Max
|
||||
unit:
|
||||
type: string
|
||||
description: Resolved unit token for `value`. `glucose` follows the user's mg/dL|mmol/L preference; `sleep` is `h`
|
||||
(per-night time-asleep in hours).
|
||||
points:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: Measurement row id, or `sleep:<wake-day>` for a sleep night.
|
||||
at:
|
||||
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|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
description: Point timestamp (ISO-8601).
|
||||
value:
|
||||
type: number
|
||||
description: Primary value in the top-level `unit`.
|
||||
secondary:
|
||||
anyOf:
|
||||
- type: number
|
||||
- type: "null"
|
||||
description: Diastolic value for `kind=bloodPressure` (paired within ±5 min); null for every other kind.
|
||||
sleepStages:
|
||||
description: Per-stage hours for a `kind=sleep` night (CORE/DEEP/REM/…); null/absent for non-sleep kinds.
|
||||
anyOf:
|
||||
- type: object
|
||||
propertyNames:
|
||||
type: string
|
||||
additionalProperties:
|
||||
type: number
|
||||
- type: "null"
|
||||
required:
|
||||
- id
|
||||
- at
|
||||
- value
|
||||
- secondary
|
||||
additionalProperties: false
|
||||
stats:
|
||||
type: object
|
||||
properties:
|
||||
mean:
|
||||
type: number
|
||||
min:
|
||||
type: number
|
||||
max:
|
||||
type: number
|
||||
stdDev:
|
||||
type: number
|
||||
count:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
required:
|
||||
- mean
|
||||
- min
|
||||
- max
|
||||
- stdDev
|
||||
- count
|
||||
additionalProperties: false
|
||||
description: Summary over the returned points; all-zero when empty.
|
||||
required:
|
||||
- kind
|
||||
- unit
|
||||
- points
|
||||
- stats
|
||||
additionalProperties: false
|
||||
description: "iOS-friendly per-kind time series: one point per reading (or per reconstructed night for `sleep`), an
|
||||
explicit `unit` token, and a summary `stats` block."
|
||||
ListMeasurementsResponse:
|
||||
type: object
|
||||
properties:
|
||||
@@ -8661,7 +8838,9 @@ components:
|
||||
- CORE
|
||||
- DEEP
|
||||
additionalProperties:
|
||||
type: number
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
required:
|
||||
- IN_BED
|
||||
- AWAKE
|
||||
@@ -8776,7 +8955,9 @@ components:
|
||||
- CORE
|
||||
- DEEP
|
||||
additionalProperties:
|
||||
type: number
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
required:
|
||||
- IN_BED
|
||||
- AWAKE
|
||||
@@ -10691,6 +10872,89 @@ components:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
GetAiProviderResponse:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/AiProviderResponse"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
AiProviderResponse:
|
||||
type: object
|
||||
properties:
|
||||
provider:
|
||||
anyOf:
|
||||
- type: string
|
||||
enum:
|
||||
- OPENAI
|
||||
- ANTHROPIC
|
||||
- LOCAL
|
||||
- CHATGPT_OAUTH
|
||||
- type: "null"
|
||||
description: The user's selected provider, or null when none is set.
|
||||
model:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
baseUrl:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
description: Custom base URL (LOCAL provider only); null otherwise.
|
||||
aiAvailable:
|
||||
type: boolean
|
||||
description: True when ANY provider can serve this user — including the operator's server-managed key when the user set
|
||||
none. iOS keys Coach visibility off this.
|
||||
managedBy:
|
||||
anyOf:
|
||||
- type: string
|
||||
enum:
|
||||
- user
|
||||
- local
|
||||
- server
|
||||
- type: "null"
|
||||
description: "Resolved provider origin: `user` (BYOK), `local` (self-hosted), `server` (operator key), or null when no
|
||||
provider is available."
|
||||
hasAnthropicKey:
|
||||
type: boolean
|
||||
anthropicKeyPreview:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
description: "`...` + last 4 chars of the stored Anthropic key, or null."
|
||||
hasLocalKey:
|
||||
type: boolean
|
||||
hasOpenaiKey:
|
||||
type: boolean
|
||||
openaiKeyPreview:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
description: "`...` + last 4 chars of the stored OpenAI key, or null."
|
||||
required:
|
||||
- provider
|
||||
- model
|
||||
- baseUrl
|
||||
- aiAvailable
|
||||
- managedBy
|
||||
- hasAnthropicKey
|
||||
- anthropicKeyPreview
|
||||
- hasLocalKey
|
||||
- hasOpenaiKey
|
||||
- openaiKeyPreview
|
||||
additionalProperties: false
|
||||
description: The calling user's AI-provider configuration plus the effective availability (`aiAvailable` + `managedBy`).
|
||||
Reports key presence + a masked preview only — never a plaintext key.
|
||||
GetCoachPrefsResponse:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -2791,6 +2791,7 @@
|
||||
"title": "Blutzucker",
|
||||
"description": "Deine Blutzuckerwerte im Zeitverlauf, in mg/dL.",
|
||||
"chartTitle": "Blutzucker",
|
||||
"medianLabel": "Median Blutzucker",
|
||||
"emptyState": {
|
||||
"title": "Noch keine Glukosedaten",
|
||||
"description": "Blutzuckerwerte erscheinen hier, sobald du einen erfasst oder ein Gerät synchronisiert."
|
||||
|
||||
@@ -2791,6 +2791,7 @@
|
||||
"title": "Blood Glucose",
|
||||
"description": "Your blood-glucose readings over time, in mg/dL.",
|
||||
"chartTitle": "Blood glucose",
|
||||
"medianLabel": "Median glucose",
|
||||
"emptyState": {
|
||||
"title": "No glucose data yet",
|
||||
"description": "Blood-glucose readings appear here once you log one or a connected device syncs."
|
||||
|
||||
@@ -2791,6 +2791,7 @@
|
||||
"title": "Glucosa en sangre",
|
||||
"description": "Tus lecturas de glucosa en el tiempo, en mg/dL.",
|
||||
"chartTitle": "Glucosa en sangre",
|
||||
"medianLabel": "Glucosa mediana",
|
||||
"emptyState": {
|
||||
"title": "Aún no hay datos de glucosa",
|
||||
"description": "Las lecturas de glucosa aparecen aquí cuando registras una o un dispositivo sincroniza."
|
||||
|
||||
@@ -2791,6 +2791,7 @@
|
||||
"title": "Glycémie",
|
||||
"description": "Vos relevés de glycémie au fil du temps, en mg/dL.",
|
||||
"chartTitle": "Glycémie",
|
||||
"medianLabel": "Glycémie médiane",
|
||||
"emptyState": {
|
||||
"title": "Aucune donnée de glycémie",
|
||||
"description": "Les relevés de glycémie apparaissent ici dès que vous en saisissez un ou qu’un appareil se synchronise."
|
||||
|
||||
@@ -2791,6 +2791,7 @@
|
||||
"title": "Glicemia",
|
||||
"description": "Le tue letture di glicemia nel tempo, in mg/dL.",
|
||||
"chartTitle": "Glicemia",
|
||||
"medianLabel": "Glicemia mediana",
|
||||
"emptyState": {
|
||||
"title": "Nessun dato di glicemia",
|
||||
"description": "Le letture di glicemia appaiono qui quando ne registri una o un dispositivo si sincronizza."
|
||||
|
||||
@@ -2791,6 +2791,7 @@
|
||||
"title": "Poziom glukozy",
|
||||
"description": "Twoje odczyty glukozy w czasie, w mg/dL.",
|
||||
"chartTitle": "Poziom glukozy",
|
||||
"medianLabel": "Mediana glukozy",
|
||||
"emptyState": {
|
||||
"title": "Brak danych o glukozie",
|
||||
"description": "Odczyty glukozy pojawią się tutaj po zapisaniu jednego lub synchronizacji urządzenia."
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.16.15",
|
||||
"version": "1.16.16",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
-- v1.16.16 (iOS #17) — per-resource WHOOP sync cursors.
|
||||
--
|
||||
-- WHOOP's collection endpoints filter on a record's OWN time range, not on
|
||||
-- when it reached the cloud, and each resource (recovery / sleep / workout /
|
||||
-- cycle) re-scores and arrives on its own latency. A single shared
|
||||
-- `last_synced_at` cursor advanced by the fastest resource then drags the
|
||||
-- incremental `start` forward for a slow or failing resource too, so its
|
||||
-- late-arriving records fall permanently before the window and are never
|
||||
-- ingested. A 403/timeout on ONE collection likewise must not poison the
|
||||
-- cursor every sibling reads.
|
||||
--
|
||||
-- `resource_cursors` is a small JSON map `{ "recovery": "<iso>", ... }` of the
|
||||
-- per-resource last-synced instant. Each resource advances only its own key on
|
||||
-- a successful fetch+upsert; a stalled resource leaves its key untouched and
|
||||
-- keeps re-fetching from where it left off without holding back the others.
|
||||
-- Null = legacy connection — the readers fall back to the shared
|
||||
-- `last_synced_at` so no historical state is lost.
|
||||
ALTER TABLE "whoop_connections" ADD COLUMN "resource_cursors" JSONB;
|
||||
@@ -0,0 +1,47 @@
|
||||
-- v1.16.16 — at most one active consent receipt per (user, kind).
|
||||
--
|
||||
-- The web-grant heal mint reads `latestActiveReceipt` then `createReceipt`
|
||||
-- non-atomically. Two concurrent web mounts could each see "no active
|
||||
-- receipt" and both insert an active `ai_full` row, leaving duplicate
|
||||
-- active grants the revoke path then has to chase one at a time.
|
||||
--
|
||||
-- The application wraps the mint in a transaction with a re-check, but the
|
||||
-- structural guarantee lives here: a partial unique index over
|
||||
-- (user_id, kind) restricted to non-revoked rows. Revoked rows carry a
|
||||
-- non-NULL `revoked_at` and fall outside the predicate, so the append-only
|
||||
-- grant/revoke history is unaffected — only two *active* rows for the same
|
||||
-- (user, kind) collide.
|
||||
|
||||
-- Pre-flight dedup. The bug this migration closes already produces duplicate
|
||||
-- active rows on affected tenants; `CREATE UNIQUE INDEX` scans existing rows
|
||||
-- and would abort (23505) on any such pair, failing the whole deploy on
|
||||
-- exactly the tenants the fix targets. Revoke all-but-the-newest active row
|
||||
-- per (user_id, kind) first so the index can build. The newest active row
|
||||
-- (latest `created_at`) is the one `latestActiveReceipt` already returns, so
|
||||
-- the surviving active grant matches current read behaviour; the superseded
|
||||
-- duplicates get a `revoked_at` marker and stay in the audit history.
|
||||
--
|
||||
-- The sibling predicate breaks on `(created_at, id)`, not `created_at` alone:
|
||||
-- two active rows with an EXACT `created_at` tie would otherwise leave each
|
||||
-- without a strictly-greater sibling, so neither is revoked and the
|
||||
-- `CREATE UNIQUE INDEX` below aborts (23505), failing the deploy on exactly
|
||||
-- the tenants this migration heals. The `id` tie-break is total (PK), so each
|
||||
-- duplicate set keeps precisely one survivor — the row with the greatest
|
||||
-- `(created_at, id)`, preserving the "newest survives" intent.
|
||||
UPDATE "consent_receipts" c
|
||||
SET "revoked_at" = now()
|
||||
WHERE "revoked_at" IS NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM "consent_receipts" n
|
||||
WHERE n."user_id" = c."user_id"
|
||||
AND n."kind" = c."kind"
|
||||
AND n."revoked_at" IS NULL
|
||||
AND (
|
||||
n."created_at" > c."created_at"
|
||||
OR (n."created_at" = c."created_at" AND n."id" > c."id")
|
||||
)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "consent_receipts_user_id_kind_active_key"
|
||||
ON "consent_receipts" ("user_id", "kind")
|
||||
WHERE "revoked_at" IS NULL;
|
||||
@@ -2168,6 +2168,15 @@ model WhoopConnection {
|
||||
/// a refresh token. Null = legacy connection that predates scope tracking.
|
||||
scope String? @map("scope")
|
||||
lastSyncedAt DateTime? @map("last_synced_at")
|
||||
/// v1.16.16 (iOS #17) — per-resource sync cursors. A JSON map
|
||||
/// `{ "recovery": "<iso>", "sleep": "<iso>", "workout": "<iso>",
|
||||
/// "cycle": "<iso>" }` of each resource's own last-synced instant. The
|
||||
/// incremental `start` for a resource is `cursor − overlap`; a resource
|
||||
/// advances ONLY its own key on a successful fetch+upsert, so a slow or
|
||||
/// 403-ing collection never drags the cursor forward for its siblings and
|
||||
/// its own late-arriving records stay inside the window. Null / a missing
|
||||
/// key falls back to the shared `lastSyncedAt` (legacy connection).
|
||||
resourceCursors Json? @map("resource_cursors")
|
||||
/// Set once the self-converging history backfill walks every collection to
|
||||
/// completion. Null = backfill still in flight (or never started).
|
||||
backfillCompletedAt DateTime? @map("backfill_completed_at")
|
||||
@@ -3354,6 +3363,14 @@ model ConsentReceipt {
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId, createdAt(sort: Desc)])
|
||||
// `consent_receipts_user_id_kind_active_key` is a partial UNIQUE index
|
||||
// over (user_id, kind) `WHERE revoked_at IS NULL` — see migration 0159.
|
||||
// It enforces at most one *active* receipt per (user, kind) so two
|
||||
// concurrent web-grant heals can't both mint an active row. Prisma can't
|
||||
// express a partial-predicate unique in the schema language, so it's
|
||||
// introduced via raw SQL and intentionally omitted here. The append-only
|
||||
// history (revoked rows) stays unconstrained because revoked rows carry a
|
||||
// non-NULL `revoked_at` and fall outside the predicate.
|
||||
@@map("consent_receipts")
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ try {
|
||||
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
|
||||
const CACHE_VERSION =
|
||||
(typeof self !== "undefined" && self.__APP_VERSION__) ||
|
||||
/* @sw-version-fallback */ "v1.16.15";
|
||||
/* @sw-version-fallback */ "v1.16.16";
|
||||
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
|
||||
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
|
||||
const MAX_STATIC_ENTRIES = 150;
|
||||
|
||||
@@ -1,15 +1,31 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
consentReceipt: {
|
||||
create: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
update: vi.fn(),
|
||||
// v1.16.16 — `createReceipt` runs its supersede + insert inside a
|
||||
// transaction; run the callback against the same mock proxy.
|
||||
type TxFn = (tx: unknown) => unknown;
|
||||
|
||||
vi.mock("@/lib/db", () => {
|
||||
const consentReceipt = {
|
||||
create: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
};
|
||||
return {
|
||||
prisma: {
|
||||
consentReceipt,
|
||||
$transaction: vi.fn((fn: TxFn) => fn({ consentReceipt })),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// v1.16.16 — rate limit is exercised by its own suite; here we default to
|
||||
// "allowed" and flip a single test to assert the 429 wiring.
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkConsentRateLimit: vi.fn(),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
@@ -38,6 +54,13 @@ import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkConsentRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const RL_OK = { allowed: true, remaining: 19, resetAt: Date.now() + 60_000 };
|
||||
|
||||
const $transaction = vi.mocked(prisma.$transaction) as unknown as {
|
||||
mockImplementation: (impl: (fn: TxFn) => unknown) => void;
|
||||
};
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -59,6 +82,15 @@ beforeEach(() => {
|
||||
// Re-arm it here so the fire-and-forget `.catch()` in the route
|
||||
// doesn't crash on an undefined return.
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined);
|
||||
// Re-arm the rate-limit + transaction pass-throughs cleared by
|
||||
// `resetAllMocks`.
|
||||
vi.mocked(checkConsentRateLimit).mockResolvedValue(RL_OK);
|
||||
$transaction.mockImplementation((fn: TxFn) =>
|
||||
fn({ consentReceipt: prisma.consentReceipt }),
|
||||
);
|
||||
vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("POST /api/consent/ai", () => {
|
||||
@@ -74,6 +106,25 @@ describe("POST /api/consent/ai", () => {
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 429 when the per-user consent bucket is exhausted", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkConsentRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
const res = await POST(
|
||||
req({
|
||||
kind: "ai_full",
|
||||
artefact: "PDF",
|
||||
signedAt: "2026-05-18T10:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
// The throttle fires before any write touches the receipts table.
|
||||
expect(prisma.consentReceipt.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 400 for an invalid kind", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await POST(
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
consentReceipt: {
|
||||
create: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
update: vi.fn(),
|
||||
// v1.16.16 — `createReceipt` + `revokeLatest` run inside a transaction; the
|
||||
// mock runs the callback against the same proxy.
|
||||
type TxFn = (tx: unknown) => unknown;
|
||||
|
||||
vi.mock("@/lib/db", () => {
|
||||
const consentReceipt = {
|
||||
create: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
};
|
||||
return {
|
||||
prisma: {
|
||||
consentReceipt,
|
||||
$transaction: vi.fn((fn: TxFn) => fn({ consentReceipt })),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkConsentRateLimit: vi.fn(),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
@@ -38,6 +52,13 @@ import { POST as POST_GRANT } from "../../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkConsentRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const RL_OK = { allowed: true, remaining: 19, resetAt: Date.now() + 60_000 };
|
||||
|
||||
const $transaction = vi.mocked(prisma.$transaction) as unknown as {
|
||||
mockImplementation: (impl: (fn: TxFn) => unknown) => void;
|
||||
};
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -50,6 +71,11 @@ beforeEach(() => {
|
||||
// on `auditLog`. Re-arm so the route's fire-and-forget `.catch()`
|
||||
// chain doesn't NPE on an undefined return.
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined);
|
||||
// Re-arm rate-limit + transaction pass-throughs cleared by reset.
|
||||
vi.mocked(checkConsentRateLimit).mockResolvedValue(RL_OK);
|
||||
$transaction.mockImplementation((fn: TxFn) =>
|
||||
fn({ consentReceipt: prisma.consentReceipt }),
|
||||
);
|
||||
});
|
||||
|
||||
describe("GET /api/consent/ai/latest", () => {
|
||||
@@ -69,6 +95,20 @@ describe("GET /api/consent/ai/latest", () => {
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 429 when the per-user consent bucket is exhausted", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkConsentRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/consent/ai/latest?kind=ai_full"),
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
expect(prisma.consentReceipt.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns null receipt when nothing has been granted", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(null);
|
||||
@@ -153,21 +193,33 @@ describe("DELETE /api/consent/ai/latest", () => {
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 429 when the per-user consent bucket is exhausted", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkConsentRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
const res = await DELETE(
|
||||
new NextRequest("http://localhost/api/consent/ai/latest?kind=ai_full", {
|
||||
method: "DELETE",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
expect(prisma.consentReceipt.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("marks the latest receipt as revoked and returns it", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const signedAt = new Date("2026-05-18T10:00:00.000Z");
|
||||
const createdAt = new Date("2026-05-18T10:00:01.000Z");
|
||||
const revokedAt = new Date("2026-05-18T11:00:00.000Z");
|
||||
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue({
|
||||
id: "rcpt-1",
|
||||
userId: "user-1",
|
||||
kind: "ai_full",
|
||||
artefact: "PDF",
|
||||
signedAt,
|
||||
revokedAt: null,
|
||||
createdAt,
|
||||
// v1.16.16 — `revokeLatest` atomically flips `revoked_at` via
|
||||
// `updateMany` (count) then re-reads the revoked row for the audit id.
|
||||
vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({
|
||||
count: 1,
|
||||
} as never);
|
||||
vi.mocked(prisma.consentReceipt.update).mockResolvedValue({
|
||||
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue({
|
||||
id: "rcpt-1",
|
||||
userId: "user-1",
|
||||
kind: "ai_full",
|
||||
@@ -188,15 +240,17 @@ describe("DELETE /api/consent/ai/latest", () => {
|
||||
};
|
||||
expect(body.data.receipt?.id).toBe("rcpt-1");
|
||||
expect(body.data.receipt?.revokedAt).toBe(revokedAt.toISOString());
|
||||
expect(prisma.consentReceipt.update).toHaveBeenCalledWith({
|
||||
where: { id: "rcpt-1" },
|
||||
expect(prisma.consentReceipt.updateMany).toHaveBeenCalledWith({
|
||||
where: { userId: "user-1", kind: "ai_full", revokedAt: null },
|
||||
data: { revokedAt: expect.any(Date) },
|
||||
});
|
||||
});
|
||||
|
||||
it("is idempotent — returns null receipt without writing when nothing to revoke", async () => {
|
||||
it("is idempotent — returns null receipt without re-reading when nothing to revoke", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
|
||||
const res = await DELETE(
|
||||
new NextRequest("http://localhost/api/consent/ai/latest?kind=ai_full", {
|
||||
@@ -208,7 +262,7 @@ describe("DELETE /api/consent/ai/latest", () => {
|
||||
data: { kind: string; receipt: unknown | null };
|
||||
};
|
||||
expect(body.data.receipt).toBeNull();
|
||||
expect(prisma.consentReceipt.update).not.toHaveBeenCalled();
|
||||
expect(prisma.consentReceipt.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("master toggle revokes the latest active row per kind", async () => {
|
||||
@@ -217,8 +271,14 @@ describe("DELETE /api/consent/ai/latest", () => {
|
||||
const createdAt = new Date("2026-05-18T10:00:01.000Z");
|
||||
const revokedAt = new Date("2026-05-18T11:00:00.000Z");
|
||||
|
||||
// First call (ai_full) finds a row, second (ai_insights_only)
|
||||
// finds none, third (ai_coach) finds a row.
|
||||
// v1.16.16 — `revokeLatest` runs per kind in `consentKindEnum.options`
|
||||
// order: ai_full → ai_insights_only → ai_coach. The `updateMany` count
|
||||
// gates the follow-up re-read. ai_full + ai_coach revoke a row;
|
||||
// ai_insights_only has none (count 0, no re-read).
|
||||
vi.mocked(prisma.consentReceipt.updateMany)
|
||||
.mockResolvedValueOnce({ count: 1 } as never) // ai_full
|
||||
.mockResolvedValueOnce({ count: 0 } as never) // ai_insights_only
|
||||
.mockResolvedValueOnce({ count: 1 } as never); // ai_coach
|
||||
vi.mocked(prisma.consentReceipt.findFirst)
|
||||
.mockResolvedValueOnce({
|
||||
id: "rcpt-full",
|
||||
@@ -226,33 +286,19 @@ describe("DELETE /api/consent/ai/latest", () => {
|
||||
kind: "ai_full",
|
||||
artefact: "PDF",
|
||||
signedAt,
|
||||
revokedAt: null,
|
||||
revokedAt,
|
||||
createdAt,
|
||||
} as never)
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce({
|
||||
id: "rcpt-coach",
|
||||
userId: "user-1",
|
||||
kind: "ai_coach",
|
||||
artefact: "PDF",
|
||||
signedAt,
|
||||
revokedAt: null,
|
||||
revokedAt,
|
||||
createdAt,
|
||||
} as never);
|
||||
|
||||
vi.mocked(prisma.consentReceipt.update).mockImplementation(
|
||||
((args: { where: { id: string }; data: { revokedAt: Date } }) =>
|
||||
Promise.resolve({
|
||||
id: args.where.id,
|
||||
userId: "user-1",
|
||||
kind: args.where.id === "rcpt-full" ? "ai_full" : "ai_coach",
|
||||
artefact: "PDF",
|
||||
signedAt,
|
||||
revokedAt,
|
||||
createdAt,
|
||||
})) as never,
|
||||
);
|
||||
|
||||
const res = await DELETE(
|
||||
new NextRequest("http://localhost/api/consent/ai/latest", {
|
||||
method: "DELETE",
|
||||
@@ -287,7 +333,11 @@ describe("append-only invariant", () => {
|
||||
const signedAt2 = new Date("2026-05-18T12:00:00.000Z");
|
||||
const createdAt2 = new Date("2026-05-18T12:00:01.000Z");
|
||||
|
||||
// Step 1: initial grant.
|
||||
// Step 1: initial grant. `createReceipt` supersedes any active row
|
||||
// (none yet → count 0) then inserts.
|
||||
vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
vi.mocked(prisma.consentReceipt.create).mockResolvedValueOnce({
|
||||
id: "rcpt-1",
|
||||
userId: "user-1",
|
||||
@@ -311,17 +361,13 @@ describe("append-only invariant", () => {
|
||||
);
|
||||
expect(grant1.status).toBe(200);
|
||||
|
||||
// Step 2: revoke. The DELETE handler reads + updates.
|
||||
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValueOnce({
|
||||
id: "rcpt-1",
|
||||
userId: "user-1",
|
||||
kind: "ai_full",
|
||||
artefact: "PDF-v1",
|
||||
signedAt: signedAt1,
|
||||
revokedAt: null,
|
||||
createdAt: createdAt1,
|
||||
// Step 2: revoke. `revokeLatest` atomically flips `revoked_at` via
|
||||
// `updateMany` (count 1) then re-reads the revoked row. The row is
|
||||
// NEVER deleted — only marked revoked, so the audit chain survives.
|
||||
vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValueOnce({
|
||||
count: 1,
|
||||
} as never);
|
||||
vi.mocked(prisma.consentReceipt.update).mockResolvedValueOnce({
|
||||
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValueOnce({
|
||||
id: "rcpt-1",
|
||||
userId: "user-1",
|
||||
kind: "ai_full",
|
||||
@@ -337,12 +383,11 @@ describe("append-only invariant", () => {
|
||||
}),
|
||||
);
|
||||
expect(revokeRes.status).toBe(200);
|
||||
expect(prisma.consentReceipt.update).toHaveBeenCalledTimes(1);
|
||||
// Critical invariant — `delete` was NEVER called on the receipt.
|
||||
// Only an update setting `revokedAt`. The old row stays in place.
|
||||
const updateCall = vi.mocked(prisma.consentReceipt.update).mock.calls[0][0];
|
||||
expect(updateCall).toMatchObject({
|
||||
where: { id: "rcpt-1" },
|
||||
// Critical invariant — `delete` was NEVER called on the receipt. The
|
||||
// revoke is an in-place `updateMany` setting `revokedAt`.
|
||||
expect(prisma.consentReceipt.delete).toBeUndefined();
|
||||
expect(prisma.consentReceipt.updateMany).toHaveBeenLastCalledWith({
|
||||
where: { userId: "user-1", kind: "ai_full", revokedAt: null },
|
||||
data: { revokedAt: expect.any(Date) },
|
||||
});
|
||||
|
||||
|
||||
@@ -17,11 +17,13 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkConsentRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import {
|
||||
consentKindEnum,
|
||||
consentLatestQuery,
|
||||
@@ -37,6 +39,17 @@ import { serialiseReceipt } from "../route";
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rl = await checkConsentRateLimit(user.id);
|
||||
if (!rl.allowed) {
|
||||
annotate({
|
||||
action: { name: "consent.ai.rate-limited" },
|
||||
meta: { userId: user.id, resetAt: rl.resetAt },
|
||||
});
|
||||
return apiError("Too many consent requests, please wait a moment", 429, {
|
||||
headers: rateLimitHeaders(rl),
|
||||
});
|
||||
}
|
||||
|
||||
const parsed = consentLatestQuery.safeParse(
|
||||
Object.fromEntries(request.nextUrl.searchParams),
|
||||
);
|
||||
@@ -86,6 +99,17 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rl = await checkConsentRateLimit(user.id);
|
||||
if (!rl.allowed) {
|
||||
annotate({
|
||||
action: { name: "consent.ai.rate-limited" },
|
||||
meta: { userId: user.id, resetAt: rl.resetAt },
|
||||
});
|
||||
return apiError("Too many consent requests, please wait a moment", 429, {
|
||||
headers: rateLimitHeaders(rl),
|
||||
});
|
||||
}
|
||||
|
||||
const parsed = consentLatestQuery.safeParse(
|
||||
Object.fromEntries(request.nextUrl.searchParams),
|
||||
);
|
||||
|
||||
@@ -9,15 +9,35 @@
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkConsentRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { consentPostBody } from "@/lib/validations/consent";
|
||||
import { createReceipt } from "@/lib/consent/receipts";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// Per-user ceiling in front of the receipts write. 20/min is well outside
|
||||
// any legitimate grant cadence; it caps a scripted loop from filling the
|
||||
// audit table.
|
||||
const rl = await checkConsentRateLimit(user.id);
|
||||
if (!rl.allowed) {
|
||||
annotate({
|
||||
action: { name: "consent.ai.rate-limited" },
|
||||
meta: { userId: user.id, resetAt: rl.resetAt },
|
||||
});
|
||||
return apiError("Too many consent requests, please wait a moment", 429, {
|
||||
headers: rateLimitHeaders(rl),
|
||||
});
|
||||
}
|
||||
|
||||
const { data: body, error } = await safeJson(request, {
|
||||
maxBytes: 128 * 1024,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
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: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
// The mint logic + its TOCTOU close are covered by web-grant.test.ts; the
|
||||
// route test stubs it to focus on auth + rate-limit + serialisation.
|
||||
vi.mock("@/lib/consent/web-grant", () => ({
|
||||
ensureWebAiConsentReceipt: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkConsentRateLimit: vi.fn(),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { ensureWebAiConsentReceipt } from "@/lib/consent/web-grant";
|
||||
import { checkConsentRateLimit } 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 },
|
||||
};
|
||||
|
||||
const RL_OK = { allowed: true, remaining: 19, resetAt: Date.now() + 60_000 };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined);
|
||||
vi.mocked(checkConsentRateLimit).mockResolvedValue(RL_OK);
|
||||
});
|
||||
|
||||
describe("POST /api/consent/ai/web", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await POST();
|
||||
expect(res.status).toBe(401);
|
||||
expect(ensureWebAiConsentReceipt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 429 when the per-user consent bucket is exhausted", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkConsentRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
const res = await POST();
|
||||
expect(res.status).toBe(429);
|
||||
// The throttle fires before the heal mint runs.
|
||||
expect(ensureWebAiConsentReceipt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("mints a receipt and reports minted:true", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(ensureWebAiConsentReceipt).mockResolvedValue({
|
||||
minted: true,
|
||||
receipt: { id: "rcpt-web-1" } as never,
|
||||
});
|
||||
|
||||
const res = await POST();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { minted: boolean; kind: string };
|
||||
};
|
||||
expect(body.data.minted).toBe(true);
|
||||
expect(body.data.kind).toBe("ai_full");
|
||||
});
|
||||
|
||||
it("is a no-op when an active receipt already exists (minted:false)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(ensureWebAiConsentReceipt).mockResolvedValue({ minted: false });
|
||||
|
||||
const res = await POST();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { minted: boolean } };
|
||||
expect(body.data.minted).toBe(false);
|
||||
// No audit row on the no-op path.
|
||||
expect(auditLog).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -14,14 +14,29 @@
|
||||
* revoke) lives in `../route.ts` + `../latest/route.ts`.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkConsentRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { ensureWebAiConsentReceipt } from "@/lib/consent/web-grant";
|
||||
|
||||
export const POST = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// The web AI-settings surface calls this on every mount; the bucket is
|
||||
// generous enough that the heal path is never throttled in normal use,
|
||||
// tight enough to cap a scripted loop.
|
||||
const rl = await checkConsentRateLimit(user.id);
|
||||
if (!rl.allowed) {
|
||||
annotate({
|
||||
action: { name: "consent.ai.rate-limited" },
|
||||
meta: { userId: user.id, resetAt: rl.resetAt },
|
||||
});
|
||||
return apiError("Too many consent requests, please wait a moment", 429, {
|
||||
headers: rateLimitHeaders(rl),
|
||||
});
|
||||
}
|
||||
|
||||
const result = await ensureWebAiConsentReceipt(user.id);
|
||||
|
||||
if (result.minted) {
|
||||
|
||||
@@ -135,6 +135,66 @@ describe("GET /api/export/measurements", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// v1.16.16 — glucose unit-at-source. A mmol/L-preference user's CSV emits
|
||||
// BLOOD_GLUCOSE converted (100 → 5.5) with `mmol/L` in the unit column.
|
||||
it("exports BLOOD_GLUCOSE in the user's mmol/L preference", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 9,
|
||||
resetAt: Date.now() + 3600_000,
|
||||
});
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
glucoseUnit: "mmol/L",
|
||||
} as never);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{
|
||||
type: "BLOOD_GLUCOSE",
|
||||
value: 100,
|
||||
unit: "mg/dL",
|
||||
measuredAt: new Date("2026-05-01T08:00:00.000Z"),
|
||||
source: "MANUAL",
|
||||
notes: null,
|
||||
glucoseContext: "FASTING",
|
||||
},
|
||||
] as never);
|
||||
|
||||
const { GET } = await import("../measurements/route");
|
||||
const res = await GET(mkReq("http://localhost/api/export/measurements"));
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.text();
|
||||
expect(body).toContain("BLOOD_GLUCOSE,5.5,mmol/L");
|
||||
expect(body).not.toContain("100,mg/dL");
|
||||
});
|
||||
|
||||
it("exports BLOOD_GLUCOSE in raw mg/dL for a mg/dL-preference user", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 9,
|
||||
resetAt: Date.now() + 3600_000,
|
||||
});
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
glucoseUnit: "mg/dL",
|
||||
} as never);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{
|
||||
type: "BLOOD_GLUCOSE",
|
||||
value: 100,
|
||||
unit: "mg/dL",
|
||||
measuredAt: new Date("2026-05-01T08:00:00.000Z"),
|
||||
source: "MANUAL",
|
||||
notes: null,
|
||||
glucoseContext: "FASTING",
|
||||
},
|
||||
] as never);
|
||||
|
||||
const { GET } = await import("../measurements/route");
|
||||
const res = await GET(mkReq("http://localhost/api/export/measurements"));
|
||||
const body = await res.text();
|
||||
expect(body).toContain("BLOOD_GLUCOSE,100,mg/dL");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/export/medications", () => {
|
||||
|
||||
@@ -25,6 +25,7 @@ import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { toCSV, formatMeasurementsForExport } from "@/lib/export";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import { resolveGlucoseUnit } from "@/lib/glucose";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
@@ -45,20 +46,29 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
? "raw"
|
||||
: "night";
|
||||
|
||||
const [measurements, userTz, sourcePriorityJson] = await Promise.all([
|
||||
prisma.measurement.findMany({
|
||||
where,
|
||||
orderBy: { measuredAt: "desc" },
|
||||
}),
|
||||
resolveUserTimezone(user.id),
|
||||
loadUserSourcePriority(user.id),
|
||||
]);
|
||||
const [measurements, userTz, sourcePriorityJson, profile] =
|
||||
await Promise.all([
|
||||
prisma.measurement.findMany({
|
||||
where,
|
||||
orderBy: { measuredAt: "desc" },
|
||||
}),
|
||||
resolveUserTimezone(user.id),
|
||||
loadUserSourcePriority(user.id),
|
||||
// v1.16.16 — resolve the glucose display unit so BLOOD_GLUCOSE rows
|
||||
// export in the user's unit (matching FHIR), not raw stored mg/dL.
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { glucoseUnit: true },
|
||||
}),
|
||||
]);
|
||||
const glucoseUnit = resolveGlucoseUnit(profile?.glucoseUnit ?? null);
|
||||
|
||||
const csv = toCSV(
|
||||
formatMeasurementsForExport(measurements, userTz, {
|
||||
granularity,
|
||||
sleepTz: userTz,
|
||||
sourcePriorityJson,
|
||||
glucoseUnit,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { NextRequest } from "next/server";
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: { findMany: vi.fn() },
|
||||
user: { findUnique: vi.fn() },
|
||||
auditLog: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
@@ -66,6 +67,9 @@ beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
glucoseUnit: "mg/dL",
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("GET /api/measurements/series", () => {
|
||||
@@ -173,6 +177,104 @@ describe("GET /api/measurements/series", () => {
|
||||
expect(body.data.unit).toBe("kg");
|
||||
});
|
||||
|
||||
it("returns glucose in mg/dL with raw values for a mg/dL-preference user (v1.16.16)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
glucoseUnit: "mg/dL",
|
||||
} as never);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{ id: "g1", value: 100, measuredAt: new Date("2026-06-01T08:00:00Z") },
|
||||
{ id: "g2", value: 126, measuredAt: new Date("2026-06-02T08:00:00Z") },
|
||||
] as never);
|
||||
const res = await GET(req("kind=glucose&days=30"));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { unit: string; points: Array<{ value: number }> };
|
||||
};
|
||||
expect(body.data.unit).toBe("mg/dL");
|
||||
expect(body.data.points.map((p) => p.value)).toEqual([100, 126]);
|
||||
});
|
||||
|
||||
it("converts glucose to mmol/L (value + unit) for a mmol/L-preference user (v1.16.16)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
glucoseUnit: "mmol/L",
|
||||
} as never);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{ id: "g1", value: 100, measuredAt: new Date("2026-06-01T08:00:00Z") },
|
||||
{ id: "g2", value: 126, measuredAt: new Date("2026-06-02T08:00:00Z") },
|
||||
] as never);
|
||||
const res = await GET(req("kind=glucose&days=30"));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
unit: string;
|
||||
points: Array<{ value: number }>;
|
||||
stats: { min: number; max: number };
|
||||
};
|
||||
};
|
||||
expect(body.data.unit).toBe("mmol/L");
|
||||
// 100 mg/dL → 5.5 mmol/L, 126 mg/dL → 7.0 mmol/L (1-decimal).
|
||||
expect(body.data.points[0].value).toBe(5.5);
|
||||
expect(body.data.points[1].value).toBe(7);
|
||||
// Stats are derived from the converted points → also in mmol/L.
|
||||
expect(body.data.stats.min).toBe(5.5);
|
||||
expect(body.data.stats.max).toBe(7);
|
||||
});
|
||||
|
||||
it("computes mmol/L stats over raw mg/dL then converts once (v1.16.16 parity)", async () => {
|
||||
// Parity with the detail page + FHIR convention: aggregate the raw mg/dL
|
||||
// values, then convert each stat figure ONCE. Deriving the mean from the
|
||||
// already-converted points (5.5, 7.0 → 6.25) double-rounds; the raw path
|
||||
// (mean 113 mg/dL → 6.3 mmol/L) matches the detail page.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
glucoseUnit: "mmol/L",
|
||||
} as never);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{ id: "g1", value: 100, measuredAt: new Date("2026-06-01T08:00:00Z") },
|
||||
{ id: "g2", value: 126, measuredAt: new Date("2026-06-02T08:00:00Z") },
|
||||
] as never);
|
||||
const res = await GET(req("kind=glucose&days=30"));
|
||||
const body = (await res.json()) as {
|
||||
data: { stats: { mean: number } };
|
||||
};
|
||||
// mean(100,126) = 113 mg/dL → 6.3 mmol/L (NOT mean(5.5,7.0) = 6.25).
|
||||
expect(body.data.stats.mean).toBe(6.3);
|
||||
});
|
||||
|
||||
it("keeps a single mmol/L reading's stats identical to its point (v1.16.16)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
glucoseUnit: "mmol/L",
|
||||
} as never);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{ id: "g1", value: 100, measuredAt: new Date("2026-06-01T08:00:00Z") },
|
||||
] as never);
|
||||
const res = await GET(req("kind=glucose&days=30"));
|
||||
const body = (await res.json()) as {
|
||||
data: { points: Array<{ value: number }>; stats: { mean: number } };
|
||||
};
|
||||
expect(body.data.points[0].value).toBe(5.5);
|
||||
expect(body.data.stats.mean).toBe(5.5);
|
||||
});
|
||||
|
||||
it("defaults glucose to mg/dL when the user has no preference (v1.16.16)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
glucoseUnit: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{ id: "g1", value: 100, measuredAt: new Date("2026-06-01T08:00:00Z") },
|
||||
] as never);
|
||||
const res = await GET(req("kind=glucose&days=30"));
|
||||
const body = (await res.json()) as {
|
||||
data: { unit: string; points: Array<{ value: number }> };
|
||||
};
|
||||
expect(body.data.unit).toBe("mg/dL");
|
||||
expect(body.data.points[0].value).toBe(100);
|
||||
});
|
||||
|
||||
it("accepts the ten-year window (days=3650 — iOS 'Alle'-range)", async () => {
|
||||
// v1.5.5 — the previous 365-day cap rejected the iOS app's
|
||||
// "Alle"-range request with a 422, painting an error banner on
|
||||
|
||||
@@ -25,6 +25,7 @@ import type { MeasurementType, SleepStage } from "@/generated/prisma/client";
|
||||
import { reconstructSleepNights } from "@/lib/analytics/sleep-night";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
|
||||
|
||||
/**
|
||||
* v1.11.4 — sleep is read row-per-stage (not from the day rollup), so its
|
||||
@@ -87,6 +88,12 @@ const KIND_TO_TYPE: Record<z.infer<typeof kindEnum>, MeasurementType> = {
|
||||
* the systolic type's unit (mmHg, same for both lines). `sleep` is
|
||||
* overridden at request time to `"h"` because the route returns per-night
|
||||
* TIME-ASLEEP in hours rather than the canonical per-stage minutes.
|
||||
*
|
||||
* v1.16.16 — `glucose` is the canonical-stored unit (mg/dL) here; it is
|
||||
* overridden at request time to the user's `glucoseUnit` preference and the
|
||||
* point values are converted to match, so this wire DTO reads in the SAME
|
||||
* unit as the CSV + FHIR exports and the blood-glucose detail page (one
|
||||
* number, one engine).
|
||||
*/
|
||||
const SERIES_UNIT: Record<z.infer<typeof kindEnum>, string> = {
|
||||
weight: "kg",
|
||||
@@ -182,6 +189,16 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
let unit = SERIES_UNIT[kind];
|
||||
|
||||
let points: SeriesPoint[] = [];
|
||||
// v1.16.16 — when glucose stats are computed over RAW mg/dL (parity with the
|
||||
// detail page + FHIR), the branch fills this so the response below skips the
|
||||
// generic over-points path that would double-round mmol/L figures.
|
||||
let glucoseStats: {
|
||||
mean: number;
|
||||
min: number;
|
||||
max: number;
|
||||
stdDev: number;
|
||||
count: number;
|
||||
} | null = null;
|
||||
if (kind === "sleep") {
|
||||
// SLEEP_DURATION is stored one row per STAGE per night (minutes).
|
||||
// Collapse the stage rows into ONE point per night carrying the
|
||||
@@ -300,12 +317,66 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { id: true, value: true, measuredAt: true },
|
||||
});
|
||||
points = rows.map((r) => ({
|
||||
id: r.id,
|
||||
at: r.measuredAt.toISOString(),
|
||||
value: r.value,
|
||||
secondary: null,
|
||||
}));
|
||||
if (kind === "glucose") {
|
||||
// v1.16.16 — glucose is stored canonical mg/dL; convert each point to
|
||||
// the user's display unit AT SERIALIZATION so the wire DTO is unit-
|
||||
// coherent with the CSV + FHIR exports (one number, one engine). The
|
||||
// top-level `unit` token follows. mg/dL-preference users are unchanged
|
||||
// (convertGlucose rounds the integer); mmol/L users see 1-decimal
|
||||
// values (100 → 5.5) and `unit: "mmol/L"`.
|
||||
const profile = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { glucoseUnit: true },
|
||||
});
|
||||
const glucoseUnit = resolveGlucoseUnit(profile?.glucoseUnit ?? null);
|
||||
unit = glucoseUnit;
|
||||
points = rows.map((r) => ({
|
||||
id: r.id,
|
||||
at: r.measuredAt.toISOString(),
|
||||
value: convertGlucose(r.value, glucoseUnit),
|
||||
secondary: null,
|
||||
}));
|
||||
// v1.16.16 — stat parity. The stat strip's mean/min/max/stdDev must
|
||||
// match the detail-page + FHIR convention: aggregate over RAW mg/dL,
|
||||
// then convert each resulting figure ONCE. Deriving the stats from the
|
||||
// already-converted+rounded points double-rounds the mean and stdDev
|
||||
// for mmol/L users, drifting the last decimal off the detail page.
|
||||
// Single-reading sets stay identical (100 → 5.5). Hand back stats here
|
||||
// so the shared block below leaves a populated `glucoseStats` alone.
|
||||
const rawDataPoints: DataPoint[] = rows.map((r) => ({
|
||||
date: r.measuredAt,
|
||||
value: r.value,
|
||||
}));
|
||||
if (rawDataPoints.length > 0) {
|
||||
const rawSummary = summarize(rawDataPoints);
|
||||
const rawValues = rawDataPoints.map((p) => p.value);
|
||||
glucoseStats = {
|
||||
mean:
|
||||
rawSummary.mean === null
|
||||
? 0
|
||||
: convertGlucose(rawSummary.mean, glucoseUnit),
|
||||
min:
|
||||
rawSummary.min === null
|
||||
? 0
|
||||
: convertGlucose(rawSummary.min, glucoseUnit),
|
||||
max:
|
||||
rawSummary.max === null
|
||||
? 0
|
||||
: convertGlucose(rawSummary.max, glucoseUnit),
|
||||
// Glucose mg/dL↔mmol/L is purely multiplicative (no offset), so a
|
||||
// spread converts with the same factor as a level.
|
||||
stdDev: convertGlucose(stdDev(rawValues), glucoseUnit),
|
||||
count: rawSummary.count,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
points = rows.map((r) => ({
|
||||
id: r.id,
|
||||
at: r.measuredAt.toISOString(),
|
||||
value: r.value,
|
||||
secondary: null,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
const dataPoints: DataPoint[] = points.map((p) => ({
|
||||
@@ -324,14 +395,16 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
kind,
|
||||
unit,
|
||||
points,
|
||||
stats: summary
|
||||
? {
|
||||
mean: summary.mean,
|
||||
min: summary.min,
|
||||
max: summary.max,
|
||||
stdDev: stdDev(values),
|
||||
count: summary.count,
|
||||
}
|
||||
: { mean: 0, min: 0, max: 0, stdDev: 0, count: 0 },
|
||||
stats:
|
||||
glucoseStats ??
|
||||
(summary
|
||||
? {
|
||||
mean: summary.mean,
|
||||
min: summary.min,
|
||||
max: summary.max,
|
||||
stdDev: stdDev(values),
|
||||
count: summary.count,
|
||||
}
|
||||
: { mean: 0, min: 0, max: 0, stdDev: 0, count: 0 }),
|
||||
});
|
||||
});
|
||||
@@ -64,7 +64,14 @@ function serializeSession(s: SleepSession) {
|
||||
inBedMinutes: s.inBedMinutes === null ? null : Math.round(s.inBedMinutes),
|
||||
awakeMinutes: s.awakeMinutes === null ? null : Math.round(s.awakeMinutes),
|
||||
awakenings: s.awakenings,
|
||||
stages: s.stages,
|
||||
// iOS #18 — round the per-stage map too; the underlying totals sum
|
||||
// second-precision segments and otherwise serialise as e.g. 88.4999.
|
||||
stages: Object.fromEntries(
|
||||
Object.entries(s.stages).map(([stage, minutes]) => [
|
||||
stage,
|
||||
Math.round(minutes),
|
||||
]),
|
||||
),
|
||||
segments: s.segments.map((seg) => ({
|
||||
stage: seg.stage,
|
||||
start: seg.start.toISOString(),
|
||||
|
||||
@@ -319,6 +319,15 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
});
|
||||
} else {
|
||||
// Every row carries its `source`. RECOVERY_SCORE is written by BOTH the
|
||||
// WHOOP-native sync (`source = WHOOP`) and the COMPUTED proxy job
|
||||
// (`source = COMPUTED`) for the same day; the client must resolve to ONE
|
||||
// canonical recovery series — WHOOP wins when present, COMPUTED is the
|
||||
// fallback — and never chart the proxy and the native value as two
|
||||
// competing series. The feed faithfully mirrors both rows (a delta-sync
|
||||
// mirror cannot drop a row mid-keyset without a tombstone); the
|
||||
// canonical pick is the same WHOOP-over-COMPUTED rule the server's
|
||||
// wellness tile + doctor PDF apply (`resolveCanonicalRecovery`).
|
||||
measurementUpserts.push({
|
||||
id: row.id,
|
||||
externalId: row.externalId,
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// Mocks must be hoisted before importing the route.
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({
|
||||
user: { id: "u-1" },
|
||||
session: { id: "s-1" },
|
||||
})),
|
||||
HttpError: class HttpError extends Error {
|
||||
status: number;
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: vi.fn((v: string) => `dec:${v}`),
|
||||
encrypt: vi.fn((v: string) => `enc:${v}`),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/ai/provider", () => ({
|
||||
resolveProviderAvailability: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/validations/notifications", () => ({
|
||||
isPublicUrl: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { resolveProviderAvailability } from "@/lib/ai/provider";
|
||||
|
||||
interface AiProviderResponse {
|
||||
data: {
|
||||
provider: string | null;
|
||||
aiAvailable: boolean;
|
||||
managedBy: "user" | "local" | "server" | null;
|
||||
hasAnthropicKey: boolean;
|
||||
hasOpenaiKey: boolean;
|
||||
};
|
||||
error: null;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /api/user/ai-provider availability", () => {
|
||||
it("reports available + managedBy:user when the user holds a BYO key", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
aiProvider: "OPENAI",
|
||||
aiModel: "gpt-4o",
|
||||
aiBaseUrl: null,
|
||||
aiAnthropicKeyEncrypted: null,
|
||||
aiLocalKeyEncrypted: null,
|
||||
aiOpenaiKeyEncrypted: "enc-openai",
|
||||
} as never);
|
||||
vi.mocked(resolveProviderAvailability).mockResolvedValue({
|
||||
aiAvailable: true,
|
||||
managedBy: "user",
|
||||
});
|
||||
|
||||
const res = await (GET as () => Promise<Response>)();
|
||||
const body = (await res.json()) as AiProviderResponse;
|
||||
|
||||
expect(vi.mocked(resolveProviderAvailability)).toHaveBeenCalledWith("u-1");
|
||||
expect(body.data.aiAvailable).toBe(true);
|
||||
expect(body.data.managedBy).toBe("user");
|
||||
expect(body.data.hasOpenaiKey).toBe(true);
|
||||
});
|
||||
|
||||
it("reports available + managedBy:server when only an admin-managed provider exists", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
aiProvider: null,
|
||||
aiModel: null,
|
||||
aiBaseUrl: null,
|
||||
aiAnthropicKeyEncrypted: null,
|
||||
aiLocalKeyEncrypted: null,
|
||||
aiOpenaiKeyEncrypted: null,
|
||||
} as never);
|
||||
vi.mocked(resolveProviderAvailability).mockResolvedValue({
|
||||
aiAvailable: true,
|
||||
managedBy: "server",
|
||||
});
|
||||
|
||||
const res = await (GET as () => Promise<Response>)();
|
||||
const body = (await res.json()) as AiProviderResponse;
|
||||
|
||||
// The user has no personal key, but the Coach must still surface.
|
||||
expect(body.data.provider).toBeNull();
|
||||
expect(body.data.hasOpenaiKey).toBe(false);
|
||||
expect(body.data.aiAvailable).toBe(true);
|
||||
expect(body.data.managedBy).toBe("server");
|
||||
});
|
||||
|
||||
it("reports aiAvailable:false + managedBy:null when nothing is configured", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
aiProvider: null,
|
||||
aiModel: null,
|
||||
aiBaseUrl: null,
|
||||
aiAnthropicKeyEncrypted: null,
|
||||
aiLocalKeyEncrypted: null,
|
||||
aiOpenaiKeyEncrypted: null,
|
||||
} as never);
|
||||
vi.mocked(resolveProviderAvailability).mockResolvedValue({
|
||||
aiAvailable: false,
|
||||
managedBy: null,
|
||||
});
|
||||
|
||||
const res = await (GET as () => Promise<Response>)();
|
||||
const body = (await res.json()) as AiProviderResponse;
|
||||
|
||||
expect(body.data.aiAvailable).toBe(false);
|
||||
expect(body.data.managedBy).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { prisma } from "@/lib/db";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import { isPublicUrl } from "@/lib/validations/notifications";
|
||||
import { encrypt, decrypt } from "@/lib/crypto";
|
||||
import { resolveProviderAvailability } from "@/lib/ai/provider";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -26,10 +27,19 @@ export const GET = apiHandler(async () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Effective availability: surfaces whether ANY provider can serve this
|
||||
// user — including the operator's admin-managed key when the user has set
|
||||
// no personal provider. iOS keys its Coach visibility off `aiAvailable` so
|
||||
// a server-managed provider is no longer invisible to the client.
|
||||
// `managedBy` reports the origin only; no admin keys/endpoints are leaked.
|
||||
const { aiAvailable, managedBy } = await resolveProviderAvailability(user.id);
|
||||
|
||||
return apiSuccess({
|
||||
provider: u?.aiProvider ?? null,
|
||||
model: u?.aiModel ?? null,
|
||||
baseUrl: u?.aiBaseUrl ?? null,
|
||||
aiAvailable,
|
||||
managedBy,
|
||||
hasAnthropicKey: Boolean(u?.aiAnthropicKeyEncrypted),
|
||||
anthropicKeyPreview: u?.aiAnthropicKeyEncrypted
|
||||
? `...${decrypt(u.aiAnthropicKeyEncrypted).slice(-4)}`
|
||||
|
||||
@@ -162,8 +162,11 @@ describe("POST /api/whoop/webhook/[token]", () => {
|
||||
where: { whoopUserId: "42" },
|
||||
select: { userId: true },
|
||||
});
|
||||
// v1.16.16 (iOS #17) — the resource id rides through so the worker can do
|
||||
// a targeted fetch-by-id refresh instead of re-walking the collection.
|
||||
expect(send).toHaveBeenCalledWith("whoop-recovery-sync", {
|
||||
userId: "user-1",
|
||||
resourceId: "abc",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
import { Droplet } from "lucide-react";
|
||||
|
||||
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { resolveGlucoseUnit } from "@/lib/glucose";
|
||||
|
||||
/**
|
||||
* v1.7.0 — `/insights/blood-glucose`.
|
||||
@@ -11,8 +14,23 @@ import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page
|
||||
* the chart daily-aggregates through the rollup read path. Empty-state
|
||||
* carries no manual-entry CTA — the series arrives from Apple Health or
|
||||
* Withings sync.
|
||||
*
|
||||
* v1.16.16 — glucose unit-at-source. The chart + stat strip honour the
|
||||
* user's `glucoseUnit` preference: stored canonical mg/dL is converted to
|
||||
* mmol/L (÷18.0182, 1-decimal) for mmol/L-preference users via the chart's
|
||||
* `valueScale` (which the page also folds into the stat-strip summary), so
|
||||
* the detail page reads in the SAME unit as the series DTO, CSV, and FHIR
|
||||
* exports. mg/dL-preference users are unaffected (scale = 1, integer
|
||||
* precision).
|
||||
*/
|
||||
const MGDL_PER_MMOL = 18.0182;
|
||||
|
||||
export default function InsightsBlutzuckerPage() {
|
||||
const { user } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
const glucoseUnit = resolveGlucoseUnit(user?.glucoseUnit ?? null);
|
||||
const isMmol = glucoseUnit === "mmol/L";
|
||||
|
||||
return (
|
||||
<HealthKitMetricPage
|
||||
measurementType="BLOOD_GLUCOSE"
|
||||
@@ -22,8 +40,14 @@ export default function InsightsBlutzuckerPage() {
|
||||
i18nPrefix="insights.bloodGlucose"
|
||||
explainerMetric="bloodGlucose"
|
||||
color="#ff79c6"
|
||||
unit="mg/dL"
|
||||
yAxisUnit="mg/dL"
|
||||
unit={glucoseUnit}
|
||||
yAxisUnit={glucoseUnit}
|
||||
// mmol/L = mg/dL ÷ 18.0182; the chart's valueScale multiplies, so pass
|
||||
// the reciprocal. mg/dL keeps identity scale (1).
|
||||
valueScale={isMmol ? 1 / MGDL_PER_MMOL : 1}
|
||||
// Integer mg/dL readings; one decimal for the mmol/L SI scale.
|
||||
statFractionDigits={isMmol ? 1 : 0}
|
||||
statMedianLabel={t("insights.bloodGlucose.medianLabel")}
|
||||
emptyStateIcon={<Droplet className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
targetSummarySlug="blood-glucose"
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import {
|
||||
CompositeScoreAnatomy,
|
||||
@@ -88,18 +86,11 @@ export default function CompositeScorePage() {
|
||||
description={t(header.description)}
|
||||
explainerMetric={EXPLAINER_METRIC[metric]}
|
||||
backLink={
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
data-slot="composite-score-back"
|
||||
className="-ml-2 w-fit"
|
||||
>
|
||||
<Link href="/insights">
|
||||
<ArrowLeft className="size-4" aria-hidden="true" />
|
||||
{t("insights.subPage.scoresBack")}
|
||||
</Link>
|
||||
</Button>
|
||||
<BackLink
|
||||
href="/insights"
|
||||
label={t("insights.subPage.scoresBack")}
|
||||
dataSlot="composite-score-back"
|
||||
/>
|
||||
}
|
||||
coachLaunch
|
||||
>
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
import Link from "next/link";
|
||||
import { notFound, useSearchParams } from "next/navigation";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
@@ -12,7 +10,7 @@ import {
|
||||
} from "@/lib/insights/values-back-link";
|
||||
import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||||
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/components/measurements/measurement-list-meta";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { MeasurementList } from "@/components/measurements/measurement-list";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
|
||||
@@ -63,22 +61,17 @@ export default function InsightsMetricValuesPage({
|
||||
title={t("insights.subPage.valuesPageTitle", { metric: metricLabel })}
|
||||
description={t("insights.subPage.valuesPageDescription")}
|
||||
backLink={
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
data-slot="metric-values-back"
|
||||
className="-ml-2 w-fit"
|
||||
>
|
||||
<Link href={backHref}>
|
||||
<ArrowLeft className="size-4" aria-hidden="true" />
|
||||
{backToOrigin
|
||||
<BackLink
|
||||
href={backHref}
|
||||
label={
|
||||
backToOrigin
|
||||
? t("insights.subPage.valuesBackToMetric", {
|
||||
metric: metricLabel,
|
||||
})
|
||||
: t("insights.subPage.valuesBack")}
|
||||
</Link>
|
||||
</Button>
|
||||
: t("insights.subPage.valuesBack")
|
||||
}
|
||||
dataSlot="metric-values-back"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MeasurementList lockedType={type} />
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useWorkoutDetail } from "@/hooks/use-workouts";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
@@ -41,18 +39,11 @@ export default function InsightsWorkoutDetailPage({
|
||||
title={t("insights.workouts.title")}
|
||||
description={t("insights.workouts.description")}
|
||||
backLink={
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
data-slot="workout-detail-back"
|
||||
className="-ml-2 w-fit"
|
||||
>
|
||||
<Link href="/insights/workouts">
|
||||
<ArrowLeft className="size-4" aria-hidden="true" />
|
||||
{t("insights.workouts.detail.backToList")}
|
||||
</Link>
|
||||
</Button>
|
||||
<BackLink
|
||||
href="/insights/workouts"
|
||||
label={t("insights.workouts.detail.backToList")}
|
||||
dataSlot="workout-detail-back"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{isLoading ? (
|
||||
|
||||
@@ -13,16 +13,15 @@
|
||||
*/
|
||||
|
||||
import { use, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowLeft, Loader2 } from "lucide-react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import {
|
||||
MedicationDetailTabs,
|
||||
@@ -87,12 +86,7 @@ export default function MedicationDetailPage({
|
||||
if (isError || !medication) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Button variant="ghost" size="sm" className="-ml-2 w-fit" asChild>
|
||||
<Link href="/medications">
|
||||
<ArrowLeft className="size-4" aria-hidden="true" />
|
||||
{t("medications.back")}
|
||||
</Link>
|
||||
</Button>
|
||||
<BackLink href="/medications" label={t("medications.back")} />
|
||||
<Card
|
||||
className="p-6"
|
||||
role="alert"
|
||||
|
||||
@@ -70,6 +70,37 @@ describe("<MetricStatStrip>", () => {
|
||||
expect(html).toBe("");
|
||||
});
|
||||
|
||||
it("renders the default 'Median' label when no override is given", () => {
|
||||
const html = render(<MetricStatStrip summary={populated} unit="bpm" />);
|
||||
expect(html).toContain("Median");
|
||||
});
|
||||
|
||||
// v1.16.16 — the median cell follows the chart's active range, so the label
|
||||
// names the metric without asserting a fixed window it can't honor.
|
||||
it("renders an overridden median label when medianLabel is provided", () => {
|
||||
const html = render(
|
||||
<MetricStatStrip
|
||||
summary={populated}
|
||||
unit="mg/dL"
|
||||
medianLabel="Median glucose"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Median glucose");
|
||||
});
|
||||
|
||||
it("honours fractionDigits for integer-unit metrics (0 decimals)", () => {
|
||||
const html = render(
|
||||
<MetricStatStrip
|
||||
summary={{ ...populated, min: 100, max: 180, median: 126 }}
|
||||
unit="mg/dL"
|
||||
fractionDigits={0}
|
||||
/>,
|
||||
);
|
||||
// No "100.0" — integer precision.
|
||||
expect(html).toContain("100 mg/dL");
|
||||
expect(html).not.toContain("100.0");
|
||||
});
|
||||
|
||||
it("falls back to mean when median is absent (older payload)", () => {
|
||||
const noMedian: DataSummary = { ...populated, median: null };
|
||||
const html = render(<MetricStatStrip summary={noMedian} unit="bpm" />);
|
||||
|
||||
@@ -142,6 +142,18 @@ export interface HealthKitMetricPageProps {
|
||||
* "no trend to project yet" state rather than a line.
|
||||
*/
|
||||
forecast?: boolean;
|
||||
/**
|
||||
* v1.16.16 — decimal precision for the stat-strip values. Defaults to the
|
||||
* strip's own default (1). Blood glucose passes 0 for mg/dL (integer
|
||||
* readings) and 1 for mmol/L so the numbers and the unit agree.
|
||||
*/
|
||||
statFractionDigits?: number;
|
||||
/**
|
||||
* v1.16.16 — optional override for the stat strip's "Median" label. Blood
|
||||
* glucose passes a window- + context-declaring string so the trailing
|
||||
* 90-day p50 is not read as an all-time central value.
|
||||
*/
|
||||
statMedianLabel?: string;
|
||||
}
|
||||
|
||||
export function HealthKitMetricPage({
|
||||
@@ -162,6 +174,8 @@ export function HealthKitMetricPage({
|
||||
statusMetric,
|
||||
statIcon,
|
||||
forecast = false,
|
||||
statFractionDigits,
|
||||
statMedianLabel,
|
||||
}: HealthKitMetricPageProps) {
|
||||
const { user, isAuthenticated } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
@@ -289,9 +303,11 @@ export function HealthKitMetricPage({
|
||||
<MetricStatStrip
|
||||
summary={summary}
|
||||
unit={yAxisUnit ?? unit}
|
||||
fractionDigits={statFractionDigits}
|
||||
seriesLabel={title}
|
||||
icon={statIcon}
|
||||
windowStats={statsByType?.[measurementType] ?? null}
|
||||
medianLabel={statMedianLabel}
|
||||
/>
|
||||
}
|
||||
diversityNudge={
|
||||
|
||||
@@ -92,6 +92,16 @@ export interface MetricStatSeries {
|
||||
* single-series mode omits it.
|
||||
*/
|
||||
dataKey?: string;
|
||||
/**
|
||||
* v1.16.16 — optional override for the "Median" cell label. The median
|
||||
* cell follows the chart's ACTIVE range (`windowStats`) when a range is
|
||||
* selected and only falls back to the full-range `summary` p50 otherwise,
|
||||
* so the label must NOT assert a fixed day-count it can't honor. Blood
|
||||
* glucose passes a metric-naming string (e.g. "Median glucose") so the
|
||||
* number reads as that metric's median for the visible range, not an
|
||||
* all-time central value. Omit it to keep the generic label.
|
||||
*/
|
||||
medianLabel?: string;
|
||||
}
|
||||
|
||||
type MetricStatStripProps = Partial<MetricStatSeries> & {
|
||||
@@ -129,6 +139,7 @@ function SeriesBlock({
|
||||
icon,
|
||||
windowStats,
|
||||
dataKey,
|
||||
medianLabel,
|
||||
}: MetricStatSeries) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
@@ -154,7 +165,7 @@ function SeriesBlock({
|
||||
{ key: "max", label: t("insights.subPage.stats.max"), value: source.max },
|
||||
{
|
||||
key: "median",
|
||||
label: t("insights.subPage.stats.median"),
|
||||
label: medianLabel ?? t("insights.subPage.stats.median"),
|
||||
value: source.median,
|
||||
},
|
||||
{
|
||||
@@ -203,6 +214,7 @@ export function MetricStatStrip({
|
||||
icon,
|
||||
windowStats,
|
||||
dataKey,
|
||||
medianLabel,
|
||||
series,
|
||||
groupLabel,
|
||||
}: MetricStatStripProps) {
|
||||
@@ -264,6 +276,7 @@ export function MetricStatStrip({
|
||||
icon={icon}
|
||||
windowStats={windowStats}
|
||||
dataKey={dataKey}
|
||||
medianLabel={medianLabel}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { BackLink } from "../back-link";
|
||||
|
||||
describe("<BackLink>", () => {
|
||||
it("renders an anchor to the href carrying the label", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<BackLink href="/insights/workouts" label="Back to workouts" />,
|
||||
);
|
||||
expect(html).toContain('href="/insights/workouts"');
|
||||
expect(html).toContain("Back to workouts");
|
||||
// The decorative chevron is hidden from screen readers; the label
|
||||
// carries the meaning.
|
||||
expect(html).toContain('aria-hidden="true"');
|
||||
});
|
||||
|
||||
it("keeps the shared left-bleed alignment on the control", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<BackLink href="/medications" label="Back" />,
|
||||
);
|
||||
// The `-ml-2 w-fit` left-bleed is the standard back-nav placement; it
|
||||
// must survive on every adopting page.
|
||||
expect(html).toContain("-ml-2");
|
||||
expect(html).toContain("w-fit");
|
||||
});
|
||||
|
||||
it("threads the optional data-slot for e2e/visual targeting", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<BackLink
|
||||
href="/insights"
|
||||
label="Back"
|
||||
dataSlot="composite-score-back"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="composite-score-back"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* v1.16.16 — shared back-navigation control.
|
||||
*
|
||||
* The detail / sub-page surfaces (`/insights/workouts/[id]`,
|
||||
* `/insights/values/[type]`, `/insights/scores/[metric]`,
|
||||
* `/medications/[id]`) each hand-rolled the identical
|
||||
* `Button(ghost, sm, -ml-2 w-fit) > Link > ArrowLeft + label` block. This
|
||||
* folds that into one component so the back-nav reads the same — same hit
|
||||
* area, same icon, same left-bleed alignment — on every page, and a future
|
||||
* change to the affordance lives in one place.
|
||||
*
|
||||
* The label travels in from the caller (each page already owns its own
|
||||
* translation key, e.g. `insights.workouts.detail.backToList`), so this
|
||||
* component adds no new i18n surface.
|
||||
*/
|
||||
export interface BackLinkProps {
|
||||
/** Internal href the back-nav points at. */
|
||||
href: string;
|
||||
/** Already-translated label text. */
|
||||
label: string;
|
||||
/** Optional per-surface `data-slot` for e2e/visual targeting. */
|
||||
dataSlot?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function BackLink({ href, label, dataSlot, className }: BackLinkProps) {
|
||||
return (
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
data-slot={dataSlot}
|
||||
className={cn("-ml-2 w-fit", className)}
|
||||
>
|
||||
<Link href={href}>
|
||||
<ArrowLeft className="size-4" aria-hidden="true" />
|
||||
{label}
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { summariseCanonicalRecovery } from "@/lib/doctor-report-data";
|
||||
|
||||
function m(type: string, value: number, iso: string, source: string) {
|
||||
return { type, value, measuredAt: new Date(iso), source: source as never };
|
||||
}
|
||||
|
||||
describe("summariseCanonicalRecovery", () => {
|
||||
it("returns null when no recovery rows exist", () => {
|
||||
expect(
|
||||
summariseCanonicalRecovery([
|
||||
m("STRESS_SCORE", 40, "2026-06-01T12:00:00Z", "COMPUTED"),
|
||||
]),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("prefers the WHOOP-native row over the COMPUTED proxy per night", () => {
|
||||
// ONE physiological night (wake morning Jun 1) carries both rows on their
|
||||
// real, off-by-one stamps: WHOOP stamps the wake morning (Jun 1), the
|
||||
// COMPUTED proxy stamps the day that ended (May 31 noon). The resolver
|
||||
// shifts the proxy onto the wake day so they collapse to one night, and the
|
||||
// PDF reads the native value, not a blend.
|
||||
const summary = summariseCanonicalRecovery([
|
||||
m("RECOVERY_SCORE", 50, "2026-05-31T12:00:00Z", "COMPUTED"),
|
||||
m("RECOVERY_SCORE", 80, "2026-06-01T06:00:00Z", "WHOOP"),
|
||||
]);
|
||||
expect(summary).not.toBeNull();
|
||||
// Only the canonical (WHOOP) row counts — avg/min/max/latest all 80, not 65.
|
||||
expect(summary!.latest).toBe(80);
|
||||
expect(summary!.avg).toBe(80);
|
||||
expect(summary!.min).toBe(80);
|
||||
expect(summary!.max).toBe(80);
|
||||
expect(summary!.count).toBe(1);
|
||||
});
|
||||
|
||||
it("summarises across nights using one canonical row each", () => {
|
||||
// Night A (wake Jun 1): WHOOP 80 + its COMPUTED proxy 40 (stamped May 31
|
||||
// noon) collapse to the native 80. Night B (wake Jun 2): a COMPUTED-only
|
||||
// proxy 60, stamped Jun 1 noon, stands alone.
|
||||
const summary = summariseCanonicalRecovery([
|
||||
m("RECOVERY_SCORE", 60, "2026-06-01T12:00:00Z", "COMPUTED"),
|
||||
m("RECOVERY_SCORE", 80, "2026-06-01T06:00:00Z", "WHOOP"),
|
||||
m("RECOVERY_SCORE", 40, "2026-05-31T12:00:00Z", "COMPUTED"),
|
||||
]);
|
||||
expect(summary!.count).toBe(2);
|
||||
// Canonical set: night B COMPUTED 60 (latest stamp), night A WHOOP 80
|
||||
// (proxy 40 dropped).
|
||||
expect(summary!.latest).toBe(60);
|
||||
expect(summary!.min).toBe(60);
|
||||
expect(summary!.max).toBe(80);
|
||||
expect(summary!.avg).toBe(70);
|
||||
});
|
||||
});
|
||||
@@ -128,6 +128,77 @@ describe("formatMeasurementsForExport", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// v1.16.16 — glucose unit-at-source. BLOOD_GLUCOSE is stored mg/dL; the
|
||||
// CSV export must convert to the user's display unit (matching FHIR) and
|
||||
// emit that unit in the `unit` column. mg/dL-preference users unchanged.
|
||||
it("keeps BLOOD_GLUCOSE in mg/dL when the user prefers mg/dL", () => {
|
||||
const measurements = [
|
||||
{
|
||||
type: "BLOOD_GLUCOSE",
|
||||
value: 100,
|
||||
unit: "mg/dL",
|
||||
measuredAt: new Date("2025-01-15T08:00:00Z"),
|
||||
source: "MANUAL",
|
||||
notes: null,
|
||||
glucoseContext: "FASTING",
|
||||
},
|
||||
];
|
||||
const result = formatMeasurementsForExport(measurements, undefined, {
|
||||
glucoseUnit: "mg/dL",
|
||||
});
|
||||
expect(result[0].value).toBe(100);
|
||||
expect(result[0].unit).toBe("mg/dL");
|
||||
});
|
||||
|
||||
it("converts BLOOD_GLUCOSE value + unit to mmol/L when the user prefers mmol/L", () => {
|
||||
const measurements = [
|
||||
{
|
||||
type: "BLOOD_GLUCOSE",
|
||||
value: 100,
|
||||
unit: "mg/dL",
|
||||
measuredAt: new Date("2025-01-15T08:00:00Z"),
|
||||
source: "MANUAL",
|
||||
notes: null,
|
||||
glucoseContext: "FASTING",
|
||||
},
|
||||
{
|
||||
type: "BLOOD_GLUCOSE",
|
||||
value: 126,
|
||||
unit: "mg/dL",
|
||||
measuredAt: new Date("2025-01-16T08:00:00Z"),
|
||||
source: "MANUAL",
|
||||
notes: null,
|
||||
glucoseContext: "FASTING",
|
||||
},
|
||||
];
|
||||
const result = formatMeasurementsForExport(measurements, undefined, {
|
||||
glucoseUnit: "mmol/L",
|
||||
});
|
||||
// 100 mg/dL → 5.5 mmol/L, 126 mg/dL → 7.0 mmol/L.
|
||||
expect(result[0].value).toBe(5.5);
|
||||
expect(result[0].unit).toBe("mmol/L");
|
||||
expect(result[1].value).toBe(7);
|
||||
expect(result[1].unit).toBe("mmol/L");
|
||||
});
|
||||
|
||||
it("does not touch non-glucose rows when a glucose unit is set", () => {
|
||||
const measurements = [
|
||||
{
|
||||
type: "WEIGHT",
|
||||
value: 80.4,
|
||||
unit: "kg",
|
||||
measuredAt: new Date("2025-01-15T08:00:00Z"),
|
||||
source: "MANUAL",
|
||||
notes: null,
|
||||
},
|
||||
];
|
||||
const result = formatMeasurementsForExport(measurements, undefined, {
|
||||
glucoseUnit: "mmol/L",
|
||||
});
|
||||
expect(result[0].value).toBe(80.4);
|
||||
expect(result[0].unit).toBe("kg");
|
||||
});
|
||||
|
||||
// v1.4.25 W7 — per-user timezone offset in exports (issue #167).
|
||||
it("emits ISO-8601 with user-tz offset when userTz is provided", () => {
|
||||
const measurements = [
|
||||
|
||||
@@ -63,6 +63,40 @@ describe("glucose conversion", () => {
|
||||
expect(mmolToMgdl(3.9)).toBe(70); // hypoglycemia threshold
|
||||
});
|
||||
|
||||
// v1.16.16 — "one number, one engine" coherence. The wire series DTO, the
|
||||
// CSV export, and the FHIR export each convert a stored mg/dL reading to the
|
||||
// user's display unit through the SAME `convertGlucose` helper, so the three
|
||||
// surfaces emit an identical value + unit. This guards against any surface
|
||||
// drifting back to a render-time or hard-coded conversion.
|
||||
it("emits a coherent value+unit across series / CSV / FHIR for mmol/L", () => {
|
||||
const storedMgdl = 126; // ADA diabetes threshold
|
||||
const unit = resolveGlucoseUnit("mmol/L");
|
||||
|
||||
// Series route: point value = convertGlucose(row.value, unit).
|
||||
const seriesValue = convertGlucose(storedMgdl, unit);
|
||||
// CSV export: BLOOD_GLUCOSE row value = convertGlucose(m.value, unit).
|
||||
const csvValue = convertGlucose(storedMgdl, unit);
|
||||
// FHIR export: valueQuantity.value = convertGlucose(stat.latest, unit).
|
||||
const fhirValue = convertGlucose(storedMgdl, unit);
|
||||
|
||||
expect(unit).toBe("mmol/L");
|
||||
expect(seriesValue).toBe(7);
|
||||
expect(csvValue).toBe(seriesValue);
|
||||
expect(fhirValue).toBe(seriesValue);
|
||||
});
|
||||
|
||||
it("emits a coherent value+unit across series / CSV / FHIR for mg/dL", () => {
|
||||
const storedMgdl = 100;
|
||||
const unit = resolveGlucoseUnit("mg/dL");
|
||||
const seriesValue = convertGlucose(storedMgdl, unit);
|
||||
const csvValue = convertGlucose(storedMgdl, unit);
|
||||
const fhirValue = convertGlucose(storedMgdl, unit);
|
||||
expect(unit).toBe("mg/dL");
|
||||
expect(seriesValue).toBe(100);
|
||||
expect(csvValue).toBe(seriesValue);
|
||||
expect(fhirValue).toBe(seriesValue);
|
||||
});
|
||||
|
||||
it("toCanonicalMgdl is the inverse of convertGlucose at the editor boundary", () => {
|
||||
// mg/dL display unit is already canonical — only rounds.
|
||||
expect(toCanonicalMgdl(126, "mg/dL")).toBe(126);
|
||||
|
||||
@@ -21,7 +21,11 @@ vi.mock("@/lib/ai/codex-oauth", () => ({
|
||||
decryptCodexCreds: vi.fn(),
|
||||
}));
|
||||
|
||||
import { resolveProvider, resolveProviderForTest } from "../provider";
|
||||
import {
|
||||
resolveProvider,
|
||||
resolveProviderForTest,
|
||||
resolveProviderAvailability,
|
||||
} from "../provider";
|
||||
import { OpenAIClient } from "../openai-client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decrypt, encrypt } from "@/lib/crypto";
|
||||
@@ -349,3 +353,94 @@ describe("resolveProviderForTest — empty override resolves via the chain", ()
|
||||
expect(provider).toBeInstanceOf(OpenAIClient);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveProviderAvailability", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
const emptyUserRow = {
|
||||
aiProvider: null,
|
||||
aiProviderChain: null,
|
||||
aiAnthropicKeyEncrypted: null,
|
||||
aiLocalKeyEncrypted: null,
|
||||
aiOpenaiKeyEncrypted: null,
|
||||
aiBaseUrl: null,
|
||||
codexConnectionStatus: null,
|
||||
codexAccessTokenEncrypted: null,
|
||||
codexRefreshTokenEncrypted: null,
|
||||
};
|
||||
|
||||
it("returns managedBy:user when the user holds a BYO key", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
...emptyUserRow,
|
||||
aiOpenaiKeyEncrypted: "enc-user-openai",
|
||||
} as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
adminAiKeyEncrypted: null,
|
||||
} as never);
|
||||
|
||||
const result = await resolveProviderAvailability("user-123");
|
||||
expect(result).toEqual({ aiAvailable: true, managedBy: "user" });
|
||||
});
|
||||
|
||||
it("returns managedBy:server when only an admin-managed key is present", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
...emptyUserRow,
|
||||
} as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
adminAiKeyEncrypted: "enc-admin-key",
|
||||
} as never);
|
||||
|
||||
const result = await resolveProviderAvailability("user-123");
|
||||
expect(result).toEqual({ aiAvailable: true, managedBy: "server" });
|
||||
});
|
||||
|
||||
it("returns managedBy:local when the user set a self-hosted base URL", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
...emptyUserRow,
|
||||
aiProvider: "LOCAL",
|
||||
aiBaseUrl: "https://ollama.example/v1",
|
||||
} as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
adminAiKeyEncrypted: null,
|
||||
} as never);
|
||||
|
||||
const result = await resolveProviderAvailability("user-123");
|
||||
expect(result).toEqual({ aiAvailable: true, managedBy: "local" });
|
||||
});
|
||||
|
||||
it("prefers a personal credential over the admin key", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
...emptyUserRow,
|
||||
aiAnthropicKeyEncrypted: "enc-anthropic",
|
||||
} as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
adminAiKeyEncrypted: "enc-admin-key",
|
||||
} as never);
|
||||
|
||||
const result = await resolveProviderAvailability("user-123");
|
||||
expect(result).toEqual({ aiAvailable: true, managedBy: "user" });
|
||||
});
|
||||
|
||||
it("returns aiAvailable:false + managedBy:null when nothing is configured", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
...emptyUserRow,
|
||||
} as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
adminAiKeyEncrypted: null,
|
||||
} as never);
|
||||
|
||||
const result = await resolveProviderAvailability("user-123");
|
||||
expect(result).toEqual({ aiAvailable: false, managedBy: null });
|
||||
});
|
||||
|
||||
it("returns aiAvailable:false when the user row is missing", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never);
|
||||
|
||||
const result = await resolveProviderAvailability("user-123");
|
||||
expect(result).toEqual({ aiAvailable: false, managedBy: null });
|
||||
// No admin lookup needed once the user row is absent.
|
||||
expect(prisma.appSettings.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -5,10 +5,21 @@ vi.mock("@/lib/insights/derived", async (importOriginal) => {
|
||||
return { ...actual, computeDerivedMetric: vi.fn() };
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { measurement: { findFirst: vi.fn() } },
|
||||
}));
|
||||
|
||||
import { computeDerivedMetric } from "@/lib/insights/derived";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { buildDerivedSnapshotBlock } from "../derived-snapshot";
|
||||
|
||||
const compute = computeDerivedMetric as unknown as ReturnType<typeof vi.fn>;
|
||||
const whoopFindFirst = prisma.measurement
|
||||
.findFirst as unknown as ReturnType<typeof vi.fn>;
|
||||
/** Make the WHOOP-native recovery probe report present / absent. */
|
||||
function setWhoopNativePresent(present: boolean) {
|
||||
whoopFindFirst.mockResolvedValue(present ? { id: "whoop-1" } : null);
|
||||
}
|
||||
const PROFILE = { ageYears: 40, sex: "MALE" as const, heightCm: 180 };
|
||||
const NOW = new Date("2026-06-02T08:00:00Z");
|
||||
|
||||
@@ -28,7 +39,13 @@ const insufficient = {
|
||||
reason: "no_score_in_window",
|
||||
};
|
||||
|
||||
beforeEach(() => compute.mockReset());
|
||||
beforeEach(() => {
|
||||
compute.mockReset();
|
||||
whoopFindFirst.mockReset();
|
||||
// Default: no WHOOP-native recovery row in the window. Tests that exercise
|
||||
// the native-present path flip this explicitly.
|
||||
setWhoopNativePresent(false);
|
||||
});
|
||||
|
||||
describe("buildDerivedSnapshotBlock", () => {
|
||||
it("returns null when every metric is insufficient", async () => {
|
||||
@@ -38,6 +55,9 @@ describe("buildDerivedSnapshotBlock", () => {
|
||||
});
|
||||
|
||||
it("emits compact value + band + confidence per ok metric, omits insufficient", async () => {
|
||||
// A WHOOP-native recovery is present, so the recovery line survives
|
||||
// alongside readiness (the dedup only drops the COMPUTED proxy).
|
||||
setWhoopNativePresent(true);
|
||||
compute.mockImplementation(async (args?: { metric?: string }) => {
|
||||
if (args?.metric === "READINESS") return ok({ score: 64, band: "yellow" });
|
||||
if (args?.metric === "RECOVERY_SCORE") return ok({ score: 72, band: "green" });
|
||||
@@ -61,6 +81,49 @@ describe("buildDerivedSnapshotBlock", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops RECOVERY_SCORE when it is the COMPUTED proxy (no WHOOP-native row)", async () => {
|
||||
// No WHOOP-native recovery in the window → the resolved recovery IS the
|
||||
// COMPUTED proxy, which equals readiness. Feeding both hands the model the
|
||||
// same number twice, so the recovery line is dropped.
|
||||
setWhoopNativePresent(false);
|
||||
compute.mockImplementation(async (args?: { metric?: string }) => {
|
||||
if (args?.metric === "READINESS") return ok({ score: 64, band: "yellow" });
|
||||
if (args?.metric === "RECOVERY_SCORE") return ok({ score: 64, band: "yellow" });
|
||||
return insufficient;
|
||||
});
|
||||
const block = await buildDerivedSnapshotBlock("u1", PROFILE, NOW);
|
||||
expect(block).not.toBeNull();
|
||||
expect(block!.READINESS).toBeDefined();
|
||||
expect(block!.RECOVERY_SCORE).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps a WHOOP-native RECOVERY_SCORE that coincidentally equals READINESS (L4)", async () => {
|
||||
// A genuine WHOOP recovery happens to match readiness on the nose. The old
|
||||
// numeric-equality drop would silence the device's ground-truth number; the
|
||||
// source-aware drop keeps it because a WHOOP-native row exists.
|
||||
setWhoopNativePresent(true);
|
||||
compute.mockImplementation(async (args?: { metric?: string }) => {
|
||||
if (args?.metric === "READINESS") return ok({ score: 64, band: "yellow" });
|
||||
if (args?.metric === "RECOVERY_SCORE") return ok({ score: 64, band: "yellow" });
|
||||
return insufficient;
|
||||
});
|
||||
const block = await buildDerivedSnapshotBlock("u1", PROFILE, NOW);
|
||||
expect(block!.READINESS?.value).toBe(64);
|
||||
expect(block!.RECOVERY_SCORE?.value).toBe(64);
|
||||
});
|
||||
|
||||
it("keeps RECOVERY_SCORE when a WHOOP-native value differs from READINESS", async () => {
|
||||
setWhoopNativePresent(true);
|
||||
compute.mockImplementation(async (args?: { metric?: string }) => {
|
||||
if (args?.metric === "READINESS") return ok({ score: 64, band: "yellow" });
|
||||
if (args?.metric === "RECOVERY_SCORE") return ok({ score: 80, band: "green" });
|
||||
return insufficient;
|
||||
});
|
||||
const block = await buildDerivedSnapshotBlock("u1", PROFILE, NOW);
|
||||
expect(block!.READINESS?.value).toBe(64);
|
||||
expect(block!.RECOVERY_SCORE?.value).toBe(80);
|
||||
});
|
||||
|
||||
it("isolates a per-metric compute failure", async () => {
|
||||
compute.mockImplementation(async (args?: { metric?: string }) => {
|
||||
if (args?.metric === "READINESS") throw new Error("boom");
|
||||
|
||||
@@ -236,6 +236,51 @@ describe("buildCoachSnapshot — Apple Health additive metrics (v1.4.23)", () =>
|
||||
expect(out.provenance.metrics).toContain("glucose");
|
||||
});
|
||||
|
||||
it("carries glucose in the user's display unit (mmol/L) — v1.16.16", async () => {
|
||||
// A mmol/L-preference user's Coach must read 5.5, not the stored 100.
|
||||
prismaMock.user.findUnique.mockResolvedValue({
|
||||
coachPrefsJson: null,
|
||||
glucoseUnit: "mmol/L",
|
||||
});
|
||||
prismaMock.measurement.findMany.mockResolvedValue([
|
||||
{ ...daysAgo(1, 100, "BLOOD_GLUCOSE"), glucoseContext: "FASTING" },
|
||||
]);
|
||||
const out = await buildCoachSnapshot("user-1", {
|
||||
sources: ["glucose"],
|
||||
window: "last30days",
|
||||
});
|
||||
const snapshot = JSON.parse(out.snapshotJson) as {
|
||||
glucose?: {
|
||||
unit?: string;
|
||||
byContext?: Record<string, { recent?: Array<{ value: number }> }>;
|
||||
};
|
||||
};
|
||||
expect(snapshot.glucose?.unit).toBe("mmol/L");
|
||||
expect(snapshot.glucose?.byContext?.fasting?.recent?.[0]?.value).toBe(5.5);
|
||||
});
|
||||
|
||||
it("keeps mg/dL glucose values byte-identical with a unit token — v1.16.16", async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValue({
|
||||
coachPrefsJson: null,
|
||||
glucoseUnit: "mg/dL",
|
||||
});
|
||||
prismaMock.measurement.findMany.mockResolvedValue([
|
||||
{ ...daysAgo(1, 100, "BLOOD_GLUCOSE"), glucoseContext: "FASTING" },
|
||||
]);
|
||||
const out = await buildCoachSnapshot("user-1", {
|
||||
sources: ["glucose"],
|
||||
window: "last30days",
|
||||
});
|
||||
const snapshot = JSON.parse(out.snapshotJson) as {
|
||||
glucose?: {
|
||||
unit?: string;
|
||||
byContext?: Record<string, { recent?: Array<{ value: number }> }>;
|
||||
};
|
||||
};
|
||||
expect(snapshot.glucose?.unit).toBe("mg/dL");
|
||||
expect(snapshot.glucose?.byContext?.fasting?.recent?.[0]?.value).toBe(100);
|
||||
});
|
||||
|
||||
it("caps the workouts block and rolls up per sport", async () => {
|
||||
const workouts = Array.from({ length: 25 }, (_, i) => ({
|
||||
sportType: i % 2 === 0 ? "RUNNING" : "CYCLING",
|
||||
|
||||
@@ -20,6 +20,14 @@ import {
|
||||
type DerivedMetricId,
|
||||
type BaselineProfile,
|
||||
} from "@/lib/insights/derived";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* Trailing window the recovery dedup probes for a WHOOP-native row — matches
|
||||
* the wellness reader's default trend window so "is the resolved recovery the
|
||||
* COMPUTED proxy?" is answered against the same set of days.
|
||||
*/
|
||||
const RECOVERY_DEDUP_WINDOW_DAYS = 14;
|
||||
|
||||
/** The high-signal derived metrics worth a Coach prompt slot. The vitals
|
||||
* baseline is omitted — the per-vital aggregate block already carries those
|
||||
@@ -124,5 +132,33 @@ export async function buildDerivedSnapshotBlock(
|
||||
};
|
||||
}
|
||||
|
||||
// Recovery dedup (one number, one engine). The COMPUTED recovery proxy IS the
|
||||
// readiness blend verbatim, so when no WHOOP-native row exists the resolved
|
||||
// recovery equals readiness — feeding both labels would hand the model the
|
||||
// identical score under two names. Drop the redundant recovery line ONLY when
|
||||
// the resolved recovery is that COMPUTED proxy (no WHOOP-native row present).
|
||||
// A genuine WHOOP recovery stays even if it coincidentally equals readiness —
|
||||
// a numeric tie must never silence the device's ground-truth number.
|
||||
if (block.READINESS !== undefined && block.RECOVERY_SCORE !== undefined) {
|
||||
const whoopRecovery = await prisma.measurement.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
type: "RECOVERY_SCORE",
|
||||
source: "WHOOP",
|
||||
deletedAt: null,
|
||||
measuredAt: {
|
||||
gte: new Date(
|
||||
now.getTime() - RECOVERY_DEDUP_WINDOW_DAYS * 24 * 60 * 60 * 1000,
|
||||
),
|
||||
lte: now,
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (whoopRecovery === null) {
|
||||
delete block.RECOVERY_SCORE;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(block).length > 0 ? block : null;
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
type CoachDataCluster,
|
||||
} from "@/lib/validations/coach-prefs";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
|
||||
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
|
||||
import {
|
||||
reconstructSleepNights,
|
||||
type SleepStageRow,
|
||||
@@ -481,6 +482,10 @@ async function buildCoachSnapshotImpl(
|
||||
// both columns here so a non-cycle account pays no extra round-trip.
|
||||
gender: true,
|
||||
cycleProfile: { select: { cycleTrackingEnabled: true } },
|
||||
// v1.16.16 — the glucose block converts canonical mg/dL to the user's
|
||||
// display unit so the Coach reads the same number every other surface
|
||||
// shows. Read it on this existing prefs hop (no extra round-trip).
|
||||
glucoseUnit: true,
|
||||
},
|
||||
});
|
||||
const prefs = parseCoachPrefs(prefsRow?.coachPrefsJson);
|
||||
@@ -494,6 +499,7 @@ async function buildCoachSnapshotImpl(
|
||||
clusterDefault,
|
||||
);
|
||||
const userTz = prefsRow?.timezone ?? DEFAULT_TIMEZONE;
|
||||
const glucoseUnit = resolveGlucoseUnit(prefsRow?.glucoseUnit ?? null);
|
||||
const excluded = new Set<CoachExcludeMetric>(prefs.excludeMetrics);
|
||||
// v1.4.36 W3 T2 — `medications` and `anthropometrics` are
|
||||
// exclude-only toggles (not in `CoachScopeSource`); they gate the
|
||||
@@ -1317,15 +1323,30 @@ async function buildCoachSnapshotImpl(
|
||||
}
|
||||
const contexts: Record<string, unknown> = {};
|
||||
for (const [ctx, rows] of byContext) {
|
||||
contexts[ctx] = {
|
||||
recent: buildDailyValueRows(rows, recentCutoff, userTz),
|
||||
weekly: bucketWeekly(
|
||||
rows.filter((r) => r.measuredAt < recentCutoff),
|
||||
userTz,
|
||||
),
|
||||
};
|
||||
// v1.16.16 — glucose is stored canonical mg/dL. A mmol/L-preference
|
||||
// user's Coach must read the same number every other surface shows
|
||||
// (5.5, not 100). Aggregate the per-day / weekly means in raw mg/dL,
|
||||
// then convert each resulting figure ONCE (parity with the series
|
||||
// DTO + detail page + FHIR). mg/dL users stay byte-identical because
|
||||
// the conversion is skipped entirely.
|
||||
const recent = buildDailyValueRows(rows, recentCutoff, userTz).map((d) =>
|
||||
glucoseUnit === "mmol/L"
|
||||
? { ...d, value: convertGlucose(d.value, glucoseUnit) }
|
||||
: d,
|
||||
);
|
||||
const weekly = bucketWeekly(
|
||||
rows.filter((r) => r.measuredAt < recentCutoff),
|
||||
userTz,
|
||||
).map((w) =>
|
||||
glucoseUnit === "mmol/L"
|
||||
? { ...w, mean: convertGlucose(w.mean, glucoseUnit) }
|
||||
: w,
|
||||
);
|
||||
contexts[ctx] = { recent, weekly };
|
||||
}
|
||||
snapshot.glucose = { byContext: contexts };
|
||||
// The display unit travels with the block so the prompt renders
|
||||
// "<value> <unit>" and the EVIDENCE BLOCK tags glucose lines correctly.
|
||||
snapshot.glucose = { unit: glucoseUnit, byContext: contexts };
|
||||
metrics.add("glucose");
|
||||
counts.glucose = glucoseRows.length;
|
||||
registerBlock("glucose", "glucose");
|
||||
|
||||
@@ -213,7 +213,8 @@ ISO-week means.
|
||||
- The SNAPSHOT is ADDITIVE. Beyond blood pressure, weight, pulse,
|
||||
mood, and medication, it MAY carry body-composition, activity,
|
||||
workouts, sleep (with per-stage minutes), glucose (split by
|
||||
context: fasting / postprandial / random / bedtime), mobility &
|
||||
context: fasting / postprandial / random / bedtime, carrying its
|
||||
own display "unit" of mg/dL or mmol/L), mobility &
|
||||
gait, and environmental-exposure blocks — but only when the user
|
||||
enabled the cluster AND has data. A block that is absent means the
|
||||
user has no such data or did not share it; never invent or assume a
|
||||
@@ -257,7 +258,7 @@ Rules:
|
||||
or a day-pin ("Tue 6 May").
|
||||
- "value" is a pre-formatted display string ("138/85", "84.2",
|
||||
"4.1"). Format mood as N/5 when numeric.
|
||||
- "unit" is one of mmHg, kg, bpm, /5, %.
|
||||
- "unit" is one of mmHg, kg, bpm, /5, %, mg/dL, mmol/L.
|
||||
- "window" is one of last7days, last30days, last90days, allTime;
|
||||
omit it for day-level pins.
|
||||
- Omit the entire block (no ---KEYVALUES--- line, no ---END--- line)
|
||||
|
||||
@@ -423,6 +423,101 @@ export async function hasAnyConfiguredProvider(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Origin of the provider that would serve a given user, surfaced to
|
||||
* clients that need to show or hide an AI surface (the iOS Coach gate).
|
||||
*
|
||||
* - "user" — a personal cloud credential resolves (Codex OAuth, or a
|
||||
* BYO OpenAI / Anthropic key).
|
||||
* - "local" — a per-user self-hosted base URL (Ollama / LM Studio).
|
||||
* - "server" — no personal config, but the operator's admin-managed
|
||||
* key serves the user. This is the case iOS #24 missed:
|
||||
* the Coach works server-side yet the client saw `null`.
|
||||
* - null — nothing configured anywhere; no provider can serve.
|
||||
*
|
||||
* Presence-only, mirroring `userRowHasProviderCredential`: it never
|
||||
* decrypts a key, builds a client, or probes liveness.
|
||||
*/
|
||||
export type ProviderManagedBy = "user" | "local" | "server";
|
||||
|
||||
function resolveManagedByFromRow(
|
||||
row: ProviderCredentialRow,
|
||||
adminKeyConfigured: boolean,
|
||||
): ProviderManagedBy | null {
|
||||
const codexConnected =
|
||||
row.codexConnectionStatus === "connected" &&
|
||||
!!row.codexAccessTokenEncrypted &&
|
||||
!!row.codexRefreshTokenEncrypted;
|
||||
|
||||
const chain = parseProviderChain(row.aiProviderChain ?? null).filter(
|
||||
(e) => e.enabled,
|
||||
);
|
||||
for (const entry of chain) {
|
||||
switch (entry.providerType) {
|
||||
case "codex":
|
||||
if (codexConnected) return "user";
|
||||
break;
|
||||
case "openai":
|
||||
if (row.aiOpenaiKeyEncrypted) return "user";
|
||||
break;
|
||||
case "anthropic":
|
||||
if (row.aiAnthropicKeyEncrypted) return "user";
|
||||
break;
|
||||
case "local":
|
||||
if (row.aiBaseUrl) return "local";
|
||||
break;
|
||||
case "admin-openai":
|
||||
if (adminKeyConfigured) return "server";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy `resolveProvider()` fallback — only reached when the chain
|
||||
// resolves empty. Mirrors buildUserProvider → codex → admin in order.
|
||||
const choice = row.aiProvider?.toUpperCase();
|
||||
if (choice === "ANTHROPIC" && row.aiAnthropicKeyEncrypted) return "user";
|
||||
if (choice === "LOCAL" && row.aiBaseUrl) return "local";
|
||||
if (choice === "OPENAI" && row.aiOpenaiKeyEncrypted) return "user";
|
||||
if ((choice === "CHATGPT_OAUTH" || !choice) && codexConnected) return "user";
|
||||
return adminKeyConfigured ? "server" : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective AI availability for a user: whether any provider can serve
|
||||
* them, and which origin manages it. One narrow user read plus the
|
||||
* shared admin-key flag — no decrypt, no network. Feeds the
|
||||
* `GET /api/user/ai-provider` response so the iOS Coach surfaces even
|
||||
* when the operator's admin-managed key is the only thing configured.
|
||||
*/
|
||||
export async function resolveProviderAvailability(
|
||||
userId: string,
|
||||
): Promise<{ aiAvailable: boolean; managedBy: ProviderManagedBy | null }> {
|
||||
const userRow = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
aiProvider: true,
|
||||
aiProviderChain: true,
|
||||
aiAnthropicKeyEncrypted: true,
|
||||
aiLocalKeyEncrypted: true,
|
||||
aiOpenaiKeyEncrypted: true,
|
||||
aiBaseUrl: true,
|
||||
codexConnectionStatus: true,
|
||||
codexAccessTokenEncrypted: true,
|
||||
codexRefreshTokenEncrypted: true,
|
||||
},
|
||||
});
|
||||
if (!userRow) return { aiAvailable: false, managedBy: null };
|
||||
const settings = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: { adminAiKeyEncrypted: true },
|
||||
});
|
||||
const managedBy = resolveManagedByFromRow(
|
||||
userRow,
|
||||
!!settings?.adminAiKeyEncrypted,
|
||||
);
|
||||
return { aiAvailable: managedBy !== null, managedBy };
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialise a single chain entry. Returns null when the user lacks
|
||||
* the matching credential — the chain runner skips null entries.
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
consentReceipt: {
|
||||
create: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
update: vi.fn(),
|
||||
// `createReceipt` + `revokeLatest` run their supersede/revoke logic inside
|
||||
// a transaction. Run the callback against the same mock proxy. The Prisma
|
||||
// `$transaction` signature is generic; the mock takes a loose callback.
|
||||
type TxFn = (tx: unknown) => unknown;
|
||||
|
||||
vi.mock("@/lib/db", () => {
|
||||
const consentReceipt = {
|
||||
create: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
};
|
||||
return {
|
||||
prisma: {
|
||||
consentReceipt,
|
||||
$transaction: vi.fn((fn: TxFn) => fn({ consentReceipt })),
|
||||
},
|
||||
},
|
||||
}));
|
||||
};
|
||||
});
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
@@ -19,8 +29,15 @@ import {
|
||||
revokeLatest,
|
||||
} from "../receipts";
|
||||
|
||||
const $transaction = vi.mocked(prisma.$transaction) as unknown as {
|
||||
mockImplementation: (impl: (fn: TxFn) => unknown) => void;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
$transaction.mockImplementation((fn: TxFn) =>
|
||||
fn({ consentReceipt: prisma.consentReceipt }),
|
||||
);
|
||||
});
|
||||
|
||||
function row(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
@@ -37,12 +54,23 @@ function row(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
}
|
||||
|
||||
describe("createReceipt", () => {
|
||||
it("inserts a row scoped to (userId, kind) with the artefact + signedAt", async () => {
|
||||
it("supersedes any active row then inserts scoped to (userId, kind)", async () => {
|
||||
vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
vi.mocked(prisma.consentReceipt.create).mockResolvedValue(row() as never);
|
||||
|
||||
const signedAt = new Date("2026-05-18T10:00:00.000Z");
|
||||
const result = await createReceipt("user-1", "ai_full", "PDF…", signedAt);
|
||||
|
||||
// Revokes any currently-active receipt of the same kind first, so the
|
||||
// fresh grant doesn't collide with the partial unique index. The
|
||||
// supersede marker uses a server clock (NOT the client `signedAt`) so a
|
||||
// backdated grant can't invert the prior row's audit timestamps.
|
||||
expect(prisma.consentReceipt.updateMany).toHaveBeenCalledWith({
|
||||
where: { userId: "user-1", kind: "ai_full", revokedAt: null },
|
||||
data: { revokedAt: expect.any(Date) },
|
||||
});
|
||||
expect(prisma.consentReceipt.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
userId: "user-1",
|
||||
@@ -52,6 +80,76 @@ describe("createReceipt", () => {
|
||||
},
|
||||
});
|
||||
expect(result.id).toBe("rcpt-1");
|
||||
// The whole mint runs in a transaction.
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("resolves a concurrent first-grant P2002 to the winning active row (no 500)", async () => {
|
||||
// Two concurrent FIRST grants: both `updateMany` calls match 0 rows (no
|
||||
// prior active receipt, no lock under Read Committed), both reach
|
||||
// `create`, and the second trips the partial unique index with P2002.
|
||||
vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
const p2002 = Object.assign(new Error("Unique constraint failed"), {
|
||||
code: "P2002",
|
||||
name: "PrismaClientKnownRequestError",
|
||||
clientVersion: "x",
|
||||
});
|
||||
vi.mocked(prisma.consentReceipt.create).mockRejectedValue(p2002 as never);
|
||||
// The concurrent grant that won is now the single active receipt.
|
||||
const winner = row({ id: "winner-row" });
|
||||
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(
|
||||
winner as never,
|
||||
);
|
||||
|
||||
const signedAt = new Date("2026-05-18T10:00:00.000Z");
|
||||
const result = await createReceipt("user-1", "ai_full", "PDF…", signedAt);
|
||||
|
||||
// Success-shaped: returns the row the concurrent grant produced, not a
|
||||
// propagated P2002 (which the route would surface as a generic 500).
|
||||
expect(result.id).toBe("winner-row");
|
||||
expect(prisma.consentReceipt.findFirst).toHaveBeenCalledWith({
|
||||
where: { userId: "user-1", kind: "ai_full", revokedAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
});
|
||||
|
||||
it("re-throws a non-P2002 create failure", async () => {
|
||||
vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
vi.mocked(prisma.consentReceipt.create).mockRejectedValue(
|
||||
new Error("connection reset") as never,
|
||||
);
|
||||
|
||||
await expect(
|
||||
createReceipt("user-1", "ai_full", "PDF…", new Date()),
|
||||
).rejects.toThrow("connection reset");
|
||||
});
|
||||
|
||||
it("uses a server clock (not a backdated client signedAt) for the supersede marker", async () => {
|
||||
vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({
|
||||
count: 1,
|
||||
} as never);
|
||||
vi.mocked(prisma.consentReceipt.create).mockResolvedValue(row() as never);
|
||||
|
||||
// A client backdates the grant well into the past.
|
||||
const backdated = new Date("2000-01-01T00:00:00.000Z");
|
||||
const before = Date.now();
|
||||
await createReceipt("user-1", "ai_full", "PDF…", backdated);
|
||||
const after = Date.now();
|
||||
|
||||
const supersedeArg = vi.mocked(prisma.consentReceipt.updateMany).mock
|
||||
.calls[0][0] as { data: { revokedAt: Date } };
|
||||
// The prior row's revocation must be stamped "now", never the backdated
|
||||
// client value, so the audit chain can't be inverted.
|
||||
expect(supersedeArg.data.revokedAt.getTime()).not.toBe(backdated.getTime());
|
||||
expect(supersedeArg.data.revokedAt.getTime()).toBeGreaterThanOrEqual(before);
|
||||
expect(supersedeArg.data.revokedAt.getTime()).toBeLessThanOrEqual(after);
|
||||
// The new row still carries the client signedAt.
|
||||
const createArg = vi.mocked(prisma.consentReceipt.create).mock.calls[0][0];
|
||||
expect(createArg.data.signedAt).toEqual(backdated);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -96,28 +194,37 @@ describe("latestActiveReceiptsByKind", () => {
|
||||
});
|
||||
|
||||
describe("revokeLatest", () => {
|
||||
it("flips revokedAt on the latest active row and returns the updated row", async () => {
|
||||
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(row() as never);
|
||||
vi.mocked(prisma.consentReceipt.update).mockResolvedValue(
|
||||
row({ revokedAt: new Date("2026-05-18T11:00:00.000Z") }) as never,
|
||||
it("atomically revokes the active row via updateMany and returns it", async () => {
|
||||
const now = new Date("2026-05-18T11:00:00.000Z");
|
||||
vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({
|
||||
count: 1,
|
||||
} as never);
|
||||
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(
|
||||
row({ revokedAt: now }) as never,
|
||||
);
|
||||
|
||||
const now = new Date("2026-05-18T11:00:00.000Z");
|
||||
const result = await revokeLatest("user-1", "ai_full", now);
|
||||
|
||||
expect(prisma.consentReceipt.update).toHaveBeenCalledWith({
|
||||
where: { id: "rcpt-1" },
|
||||
expect(prisma.consentReceipt.updateMany).toHaveBeenCalledWith({
|
||||
where: { userId: "user-1", kind: "ai_full", revokedAt: null },
|
||||
data: { revokedAt: now },
|
||||
});
|
||||
// Re-reads the just-revoked row so the audit log keeps the receipt id.
|
||||
expect(prisma.consentReceipt.findFirst).toHaveBeenCalledWith({
|
||||
where: { userId: "user-1", kind: "ai_full", revokedAt: now },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
expect(result?.revokedAt).toEqual(now);
|
||||
});
|
||||
|
||||
it("returns null without writing when no active receipt exists", async () => {
|
||||
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(null);
|
||||
it("returns null without re-reading when no active receipt exists", async () => {
|
||||
vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
|
||||
const result = await revokeLatest("user-1", "ai_full");
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(prisma.consentReceipt.update).not.toHaveBeenCalled();
|
||||
expect(prisma.consentReceipt.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,19 +1,40 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
consentReceipt: {
|
||||
create: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
// The web-grant mint runs its re-check + insert inside a transaction. The
|
||||
// mock runs the callback against the same `prisma` proxy so the in-tx
|
||||
// reads/writes share the test's mock surface. The Prisma `$transaction`
|
||||
// signature is generic; the mock takes a loose callback and casts.
|
||||
type TxFn = (tx: unknown) => unknown;
|
||||
|
||||
vi.mock("@/lib/db", () => {
|
||||
const consentReceipt = {
|
||||
create: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
};
|
||||
return {
|
||||
prisma: {
|
||||
consentReceipt,
|
||||
$transaction: vi.fn((fn: TxFn) => fn({ consentReceipt })),
|
||||
},
|
||||
},
|
||||
}));
|
||||
};
|
||||
});
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { ensureWebAiConsentReceipt } from "../web-grant";
|
||||
|
||||
const $transaction = vi.mocked(prisma.$transaction) as unknown as {
|
||||
mockImplementation: (impl: (fn: TxFn) => unknown) => void;
|
||||
mockReset: () => void;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
// `resetAllMocks` clears the `$transaction` pass-through implementation;
|
||||
// re-arm it so the mint callback still runs against the mock surface.
|
||||
$transaction.mockImplementation((fn: TxFn) =>
|
||||
fn({ consentReceipt: prisma.consentReceipt }),
|
||||
);
|
||||
});
|
||||
|
||||
function row(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
@@ -38,11 +59,14 @@ describe("ensureWebAiConsentReceipt", () => {
|
||||
const result = await ensureWebAiConsentReceipt("user-1", now);
|
||||
|
||||
expect(result.minted).toBe(true);
|
||||
// Reads the active master grant first.
|
||||
// Reads the active master grant first (fast-path + in-tx re-check both
|
||||
// hit the same predicate).
|
||||
expect(prisma.consentReceipt.findFirst).toHaveBeenCalledWith({
|
||||
where: { userId: "user-1", kind: "ai_full", revokedAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
// The mint is wrapped in a transaction (TOCTOU close).
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||
// Mints ai_full with a web-source artefact + the supplied timestamp.
|
||||
const createArg = vi.mocked(prisma.consentReceipt.create).mock.calls[0][0];
|
||||
expect(createArg.data.userId).toBe("user-1");
|
||||
@@ -60,5 +84,83 @@ describe("ensureWebAiConsentReceipt", () => {
|
||||
|
||||
expect(result.minted).toBe(false);
|
||||
expect(prisma.consentReceipt.create).not.toHaveBeenCalled();
|
||||
// No transaction is opened on the already-granted fast path.
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("re-checks inside the transaction: a row that appears between the fast-path read and the mint short-circuits the insert", async () => {
|
||||
// Fast-path read sees nothing; the in-tx re-check sees a row a
|
||||
// concurrent grant inserted. The mint must no-op rather than insert a
|
||||
// second active row.
|
||||
vi.mocked(prisma.consentReceipt.findFirst)
|
||||
.mockResolvedValueOnce(null) // fast path
|
||||
.mockResolvedValueOnce(row() as never); // in-tx re-check
|
||||
|
||||
const result = await ensureWebAiConsentReceipt("user-1");
|
||||
|
||||
expect(result.minted).toBe(false);
|
||||
expect(prisma.consentReceipt.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps the partial-unique violation (P2002) to a no-op when two grants race to the insert", async () => {
|
||||
// Both reads miss (the re-check loses the race too); the partial unique
|
||||
// index rejects the second insert. The structural backstop must surface
|
||||
// as success-shaped — the user ends with exactly one active receipt.
|
||||
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.consentReceipt.create).mockRejectedValue({
|
||||
code: "P2002",
|
||||
} as never);
|
||||
|
||||
const result = await ensureWebAiConsentReceipt("user-1");
|
||||
|
||||
expect(result.minted).toBe(false);
|
||||
});
|
||||
|
||||
it("rethrows non-P2002 errors", async () => {
|
||||
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.consentReceipt.create).mockRejectedValue(
|
||||
new Error("connection reset") as never,
|
||||
);
|
||||
|
||||
await expect(ensureWebAiConsentReceipt("user-1")).rejects.toThrow(
|
||||
"connection reset",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureWebAiConsentReceipt — two concurrent grants", () => {
|
||||
it("yields exactly one active receipt across parallel mounts", async () => {
|
||||
// Model the race at the DB layer: a single shared "active row" slot.
|
||||
// Both callers pass the fast-path read (slot empty). The transaction
|
||||
// serialises their re-checks: the first sees the empty slot and
|
||||
// inserts; the second sees the now-filled slot and no-ops.
|
||||
let activeRow: ReturnType<typeof row> | null = null;
|
||||
|
||||
vi.mocked(prisma.consentReceipt.findFirst).mockImplementation((async () =>
|
||||
activeRow) as never);
|
||||
vi.mocked(prisma.consentReceipt.create).mockImplementation((async () => {
|
||||
activeRow = row();
|
||||
return activeRow;
|
||||
}) as never);
|
||||
// Serialise the two transactions so the second observes the first's
|
||||
// committed insert (Serializable isolation in production).
|
||||
let chain: Promise<unknown> = Promise.resolve();
|
||||
$transaction.mockImplementation((fn: TxFn) => {
|
||||
const run = chain.then(() =>
|
||||
fn({ consentReceipt: prisma.consentReceipt }),
|
||||
);
|
||||
chain = run.catch(() => {});
|
||||
return run;
|
||||
});
|
||||
|
||||
const [a, b] = await Promise.all([
|
||||
ensureWebAiConsentReceipt("user-1"),
|
||||
ensureWebAiConsentReceipt("user-1"),
|
||||
]);
|
||||
|
||||
// Exactly one mint, exactly one row inserted.
|
||||
const mintedCount = [a, b].filter((r) => r.minted).length;
|
||||
expect(mintedCount).toBe(1);
|
||||
expect(prisma.consentReceipt.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
+73
-24
@@ -15,13 +15,22 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { ConsentReceiptModel } from "@/generated/prisma/models/ConsentReceipt";
|
||||
import type { ConsentKind } from "@/lib/validations/consent";
|
||||
import { isP2002 } from "@/lib/prisma-errors";
|
||||
|
||||
export type ConsentReceipt = ConsentReceiptModel;
|
||||
|
||||
/**
|
||||
* Insert a fresh consent receipt for (userId, kind). Always appends;
|
||||
* never collides with an earlier row. The caller has already
|
||||
* Zod-validated `artefact` (≤ 64 KB) and `signedAt` (ISO-8601).
|
||||
* Insert a fresh consent receipt for (userId, kind). The caller has
|
||||
* already Zod-validated `artefact` (≤ 64 KB) and `signedAt` (ISO-8601).
|
||||
*
|
||||
* v1.16.16 — the partial unique index `consent_receipts_user_id_kind_
|
||||
* active_key` (`WHERE revoked_at IS NULL`, migration 0159) caps the table
|
||||
* at one *active* receipt per (user, kind). A plain re-grant of an already
|
||||
* active kind would otherwise collide, so the mint runs in a transaction
|
||||
* that first revokes any active row of the same kind, then appends the new
|
||||
* one. The append-only audit chain is preserved — the superseded row keeps
|
||||
* its history with a `revoked_at` marker — and the constraint is satisfied
|
||||
* because at most one active row ever exists.
|
||||
*/
|
||||
export async function createReceipt(
|
||||
userId: string,
|
||||
@@ -29,14 +38,45 @@ export async function createReceipt(
|
||||
artefact: string,
|
||||
signedAt: Date,
|
||||
): Promise<ConsentReceipt> {
|
||||
return prisma.consentReceipt.create({
|
||||
data: {
|
||||
userId,
|
||||
kind,
|
||||
artefact,
|
||||
signedAt,
|
||||
},
|
||||
});
|
||||
// `signedAt` is the client-supplied wall-clock for the NEW grant and is
|
||||
// only validated as well-formed ISO-8601 — it can legitimately be in the
|
||||
// past or out of order with existing rows. The supersede marker on the
|
||||
// PRIOR active row must NOT use it: a backdated `signedAt` would stamp the
|
||||
// old row's `revoked_at` earlier than its own `signed_at`, inverting the
|
||||
// audit chain. Use a server clock for the revocation instead.
|
||||
const supersededAt = new Date();
|
||||
try {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
// Supersede any currently-active receipt of this kind so the fresh
|
||||
// grant doesn't collide with the partial unique index. `updateMany`
|
||||
// over the active predicate is a no-op when none is active.
|
||||
await tx.consentReceipt.updateMany({
|
||||
where: { userId, kind, revokedAt: null },
|
||||
data: { revokedAt: supersededAt },
|
||||
});
|
||||
return tx.consentReceipt.create({
|
||||
data: {
|
||||
userId,
|
||||
kind,
|
||||
artefact,
|
||||
signedAt,
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
// Two concurrent FIRST grants (no prior active row) both see
|
||||
// `updateMany` match zero rows under Read Committed — neither takes a
|
||||
// lock — so both reach `create` and the second trips the partial unique
|
||||
// index (migration 0159, `WHERE revoked_at IS NULL`) with P2002. That is
|
||||
// success-shaped: the concurrent grant already produced the single active
|
||||
// receipt the caller wants. Re-read it and return it rather than letting
|
||||
// the violation surface as a generic 500.
|
||||
if (isP2002(err)) {
|
||||
const winner = await latestActiveReceipt(userId, kind);
|
||||
if (winner) return winner;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,25 +121,34 @@ export async function latestActiveReceiptsByKind(
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the latest active receipt for (userId, kind) as revoked.
|
||||
* Returns the updated row, or `null` if no active receipt exists.
|
||||
* Mark the active receipt for (userId, kind) as revoked. Returns the
|
||||
* updated row, or `null` if no active receipt exists.
|
||||
*
|
||||
* Implementation note: a two-step "read latest → update by id" is
|
||||
* race-tolerant for the single-user revoke flow (iOS toggle is a
|
||||
* per-device action; no concurrent revoke from a second device is
|
||||
* plausible inside the same millisecond). If two devices race we end
|
||||
* up writing `revokedAt` to the same row twice with identical
|
||||
* timestamps — semantically idempotent.
|
||||
* v1.16.16 — the partial unique index guarantees at most one active row
|
||||
* per (user, kind), so a single atomic `updateMany` over the active
|
||||
* predicate revokes "the" active receipt without a separate read. Two
|
||||
* concurrent revokes converge: the first flips `revoked_at`, the second's
|
||||
* predicate no longer matches and writes nothing. The follow-up read
|
||||
* returns the row the caller (audit log) needs; it is `null` only when no
|
||||
* active receipt existed at all.
|
||||
*/
|
||||
export async function revokeLatest(
|
||||
userId: string,
|
||||
kind: ConsentKind,
|
||||
now: Date = new Date(),
|
||||
): Promise<ConsentReceipt | null> {
|
||||
const latest = await latestActiveReceipt(userId, kind);
|
||||
if (!latest) return null;
|
||||
return prisma.consentReceipt.update({
|
||||
where: { id: latest.id },
|
||||
data: { revokedAt: now },
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.consentReceipt.updateMany({
|
||||
where: { userId, kind, revokedAt: null },
|
||||
data: { revokedAt: now },
|
||||
});
|
||||
if (updated.count === 0) return null;
|
||||
// Re-read the just-revoked row (revoked at exactly `now`) so the audit
|
||||
// trail keeps the receipt id. The active predicate guaranteed a single
|
||||
// row, so this resolves it unambiguously.
|
||||
return tx.consentReceipt.findFirst({
|
||||
where: { userId, kind, revokedAt: now },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -21,7 +21,10 @@
|
||||
* timestamp + source, which is the GDPR Art. 7 audit signal the legal team
|
||||
* needs). It stays well under the 64 KB artefact cap.
|
||||
*/
|
||||
import { createReceipt, latestActiveReceipt } from "@/lib/consent/receipts";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { isP2002 } from "@/lib/prisma-errors";
|
||||
import { latestActiveReceipt } from "@/lib/consent/receipts";
|
||||
import type { ConsentReceipt } from "@/lib/consent/receipts";
|
||||
|
||||
/** Outcome of an idempotent web-grant call. */
|
||||
@@ -42,6 +45,9 @@ export async function ensureWebAiConsentReceipt(
|
||||
userId: string,
|
||||
now: Date = new Date(),
|
||||
): Promise<WebConsentGrantResult> {
|
||||
// Fast path: a cheap read outside the transaction short-circuits the
|
||||
// common already-granted case (every mount after the first) without
|
||||
// opening a transaction.
|
||||
const existing = await latestActiveReceipt(userId, "ai_full");
|
||||
if (existing) return { minted: false };
|
||||
|
||||
@@ -52,6 +58,32 @@ export async function ensureWebAiConsentReceipt(
|
||||
note: "In-app affirmative AI consent granted via the web client.",
|
||||
});
|
||||
|
||||
const receipt = await createReceipt(userId, "ai_full", artefact, now);
|
||||
return { minted: true, receipt };
|
||||
// Two web mounts can pass the fast-path read concurrently and both reach
|
||||
// the mint. The transaction re-checks under a Serializable isolation so a
|
||||
// second mint sees the first's row and no-ops; the partial unique index
|
||||
// (migration 0159, `WHERE revoked_at IS NULL`) is the structural backstop
|
||||
// if the re-check still races. A unique violation from the backstop means
|
||||
// a concurrent grant already won — that is success-shaped (the user ends
|
||||
// up with exactly one active receipt), so we map it to `{ minted: false }`.
|
||||
try {
|
||||
return await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const inTx = await tx.consentReceipt.findFirst({
|
||||
where: { userId, kind: "ai_full", revokedAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
if (inTx) return { minted: false } as const;
|
||||
|
||||
const receipt = await tx.consentReceipt.create({
|
||||
data: { userId, kind: "ai_full", artefact, signedAt: now },
|
||||
});
|
||||
return { minted: true, receipt } as const;
|
||||
},
|
||||
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
|
||||
);
|
||||
} catch (err) {
|
||||
// Partial-unique violation — a concurrent grant landed first.
|
||||
if (isP2002(err)) return { minted: false };
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,11 @@ import {
|
||||
thresholdMetricForContext,
|
||||
type GlucoseUnit,
|
||||
} from "@/lib/glucose";
|
||||
import type { GlucoseContext } from "@/generated/prisma/client";
|
||||
import type {
|
||||
GlucoseContext,
|
||||
MeasurementSource,
|
||||
} from "@/generated/prisma/client";
|
||||
import { resolveCanonicalRecovery } from "@/lib/insights/derived/recovery-resolve";
|
||||
import {
|
||||
DEFAULT_DOCTOR_REPORT_PREFS,
|
||||
type DoctorReportPrefs,
|
||||
@@ -221,6 +225,61 @@ export const WELLNESS_SCORE_REPORT_TYPES = [
|
||||
"STRAIN_SCORE",
|
||||
] as const;
|
||||
|
||||
/** One wellness-summary row (latest + range over the report window). */
|
||||
interface WellnessScoreSummary {
|
||||
type: string;
|
||||
latest: number;
|
||||
avg: number;
|
||||
min: number;
|
||||
max: number;
|
||||
count: number;
|
||||
latestAt: string;
|
||||
}
|
||||
|
||||
/** A measurement row as the doctor-report aggregator reads it (subset). */
|
||||
interface RecoveryMeasurementRow {
|
||||
type: string;
|
||||
value: number;
|
||||
measuredAt: Date;
|
||||
source: MeasurementSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarise RECOVERY_SCORE over the window from the CANONICAL row per day:
|
||||
* a WHOOP-native row wins over the COMPUTED proxy for the same day, so the
|
||||
* doctor PDF never blends the proxy and the native value into one min/avg/max.
|
||||
* Returns null when no recovery row exists. Exported for unit tests.
|
||||
*/
|
||||
export function summariseCanonicalRecovery(
|
||||
measurements: readonly RecoveryMeasurementRow[],
|
||||
timezone?: string | null,
|
||||
): WellnessScoreSummary | null {
|
||||
const recovery = measurements.filter((m) => m.type === "RECOVERY_SCORE");
|
||||
if (recovery.length === 0) return null;
|
||||
const canonical = resolveCanonicalRecovery(
|
||||
recovery.map((m) => ({
|
||||
value: m.value,
|
||||
measuredAt: m.measuredAt,
|
||||
source: m.source,
|
||||
})),
|
||||
timezone,
|
||||
);
|
||||
if (canonical.length === 0) return null;
|
||||
// `resolveCanonicalRecovery` returns rows newest-first; the report wants the
|
||||
// latest value + the window range over the canonical set.
|
||||
const values = canonical.map((r) => r.value);
|
||||
const latestRow = canonical[0];
|
||||
return {
|
||||
type: "RECOVERY_SCORE",
|
||||
latest: Math.round(latestRow.value),
|
||||
avg: Math.round(values.reduce((a, b) => a + b, 0) / values.length),
|
||||
min: Math.round(Math.min(...values)),
|
||||
max: Math.round(Math.max(...values)),
|
||||
count: values.length,
|
||||
latestAt: latestRow.measuredAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
const GLUCOSE_CONTEXTS: GlucoseContext[] = [
|
||||
"FASTING",
|
||||
"POSTPRANDIAL",
|
||||
@@ -927,12 +986,21 @@ export async function collectDoctorReportData(
|
||||
};
|
||||
}
|
||||
|
||||
// v1.10.0 — wellness-score summary. The persisted COMPUTED `*_SCORE`
|
||||
// rows are already in `byType`/`stats` (they're only filtered out of the
|
||||
// clinical vitals table). Summarise each present score type for the
|
||||
// separate "Wellness summary" section. Empty array → null so the renderer
|
||||
// + FHIR builder skip the section entirely.
|
||||
// v1.10.0 — wellness-score summary. The persisted `*_SCORE` rows are already
|
||||
// in `byType`/`stats` (they're only filtered out of the clinical vitals
|
||||
// table). Summarise each present score type for the separate "Wellness
|
||||
// summary" section. Empty array → null so the renderer + FHIR builder skip
|
||||
// the section entirely.
|
||||
//
|
||||
// RECOVERY_SCORE carries BOTH the WHOOP-native row and the COMPUTED proxy for
|
||||
// the same day; mixing them into one min/avg/max would blend two distinct
|
||||
// series. Resolve to the canonical row per day (WHOOP wins) so the PDF reads
|
||||
// the SAME value the tile + iOS feed show — one number, one engine.
|
||||
const wellnessScoreSummaries = WELLNESS_SCORE_REPORT_TYPES.flatMap((type) => {
|
||||
if (type === "RECOVERY_SCORE") {
|
||||
const summary = summariseCanonicalRecovery(measurements, reportTz);
|
||||
return summary ? [summary] : [];
|
||||
}
|
||||
const s = stats[type];
|
||||
const rows = byType[type];
|
||||
if (!s || !rows || rows.length === 0) return [];
|
||||
|
||||
+25
-9
@@ -20,6 +20,7 @@ import {
|
||||
pickMainNightAndNaps,
|
||||
type SleepStageRow,
|
||||
} from "./analytics/sleep-night";
|
||||
import { convertGlucose, type GlucoseUnit } from "./glucose";
|
||||
import type { SleepStage } from "@/generated/prisma/client";
|
||||
|
||||
export interface ExportableRecord {
|
||||
@@ -160,18 +161,33 @@ export function formatMeasurementsForExport(
|
||||
* night — without it the export silently fell back to the default ladder.
|
||||
*/
|
||||
sourcePriorityJson?: unknown;
|
||||
/**
|
||||
* v1.16.16 — the user's resolved glucose display unit. BLOOD_GLUCOSE is
|
||||
* stored canonical mg/dL; when this is `"mmol/L"` the export converts the
|
||||
* value (1 decimal) and emits `mmol/L` in the `unit` column, matching the
|
||||
* FHIR export (`src/lib/fhir/resources.ts`) so the CSV reads in ONE unit.
|
||||
* Defaults to `"mg/dL"` (the canonical-stored unit) → byte-identical for
|
||||
* mg/dL-preference users.
|
||||
*/
|
||||
glucoseUnit?: GlucoseUnit;
|
||||
} = {},
|
||||
): ExportableRecord[] {
|
||||
const granularity = opts.granularity ?? "night";
|
||||
const toRecord = (m: ExportMeasurement): ExportableRecord => ({
|
||||
type: m.type,
|
||||
value: m.value,
|
||||
unit: m.unit,
|
||||
measuredAt: formatTimestamp(m.measuredAt, userTz),
|
||||
source: m.source,
|
||||
notes: m.notes ?? "",
|
||||
glucoseContext: m.glucoseContext ?? "",
|
||||
});
|
||||
const glucoseUnit: GlucoseUnit = opts.glucoseUnit ?? "mg/dL";
|
||||
const toRecord = (m: ExportMeasurement): ExportableRecord => {
|
||||
// BLOOD_GLUCOSE is stored mg/dL; convert to the user's display unit at
|
||||
// serialization so the value + unit column agree and match FHIR.
|
||||
const isGlucose = m.type === "BLOOD_GLUCOSE";
|
||||
return {
|
||||
type: m.type,
|
||||
value: isGlucose ? convertGlucose(m.value, glucoseUnit) : m.value,
|
||||
unit: isGlucose ? glucoseUnit : m.unit,
|
||||
measuredAt: formatTimestamp(m.measuredAt, userTz),
|
||||
source: m.source,
|
||||
notes: m.notes ?? "",
|
||||
glucoseContext: m.glucoseContext ?? "",
|
||||
};
|
||||
};
|
||||
|
||||
if (granularity === "raw") {
|
||||
return measurements.map(toRecord);
|
||||
|
||||
@@ -4,6 +4,9 @@ vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
moodEntry: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
// The Sleep Score reads the user's source priority for the canonical
|
||||
// writer-dedup ladder; default to no override.
|
||||
user: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/rollups/measurement-coverage", () => ({
|
||||
|
||||
@@ -4,6 +4,9 @@ vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: { findMany: vi.fn() },
|
||||
moodEntry: { findMany: vi.fn() },
|
||||
// The folded Sleep Score now reads the user's source priority for the
|
||||
// canonical writer-dedup ladder; default to no override.
|
||||
user: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/rollups/measurement-coverage", () => ({
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
resolveCanonicalRecovery,
|
||||
type RecoveryRow,
|
||||
} from "../recovery-resolve";
|
||||
|
||||
function row(iso: string, value: number, source: string): RecoveryRow {
|
||||
return { value, measuredAt: new Date(iso), source: source as never };
|
||||
}
|
||||
|
||||
// All stamps below use REALISTIC source clocks for one physiological night:
|
||||
// - WHOOP stamps the wake-morning instant (`updated_at`).
|
||||
// - COMPUTED stamps noon-UTC of the day-that-ended (`scoreDayKey = run − 1d`),
|
||||
// so for the SAME night it lands one calendar day BEFORE the WHOOP row.
|
||||
// Europe/Berlin (the resolver default) is UTC+2 in June, so a noon-UTC stamp
|
||||
// reads as the same local day and a ~06:00-UTC wake stamp reads as the wake day.
|
||||
describe("resolveCanonicalRecovery", () => {
|
||||
it("collapses the same night's WHOOP + COMPUTED rows to the WHOOP value", () => {
|
||||
// Night of Jun 01 → wake Jun 02. WHOOP scores Jun 02 morning; the COMPUTED
|
||||
// proxy for that readiness is filed under Jun 01 (the day that ended). With
|
||||
// realistic stamps the two land on DIFFERENT calendar days — the resolver's
|
||||
// source-aware wake-day key must still fold them into ONE canonical night.
|
||||
const rows = [
|
||||
row("2026-06-01T12:00:00Z", 50, "COMPUTED"), // day-that-ended stamp
|
||||
row("2026-06-02T06:00:00Z", 80, "WHOOP"), // wake-morning stamp
|
||||
];
|
||||
const resolved = resolveCanonicalRecovery(rows);
|
||||
expect(resolved).toHaveLength(1);
|
||||
expect(resolved[0].source).toBe("WHOOP");
|
||||
expect(resolved[0].value).toBe(80);
|
||||
});
|
||||
|
||||
it("falls back to COMPUTED when no WHOOP row exists for the night", () => {
|
||||
const rows = [row("2026-06-01T12:00:00Z", 55, "COMPUTED")];
|
||||
const resolved = resolveCanonicalRecovery(rows);
|
||||
expect(resolved).toHaveLength(1);
|
||||
expect(resolved[0].source).toBe("COMPUTED");
|
||||
expect(resolved[0].value).toBe(55);
|
||||
});
|
||||
|
||||
it("keeps a genuinely SEPARATE next night separate", () => {
|
||||
// Two nights: Jun 01→02 (WHOOP, wake Jun 02) and Jun 02→03 (COMPUTED proxy
|
||||
// filed under Jun 02). Despite both touching the Jun 02 calendar day on raw
|
||||
// stamps, they are DIFFERENT nights and must stay two canonical rows.
|
||||
const rows = [
|
||||
row("2026-06-02T12:00:00Z", 51, "COMPUTED"), // night Jun02→03, wake Jun03
|
||||
row("2026-06-02T06:00:00Z", 70, "WHOOP"), // night Jun01→02, wake Jun02
|
||||
];
|
||||
const resolved = resolveCanonicalRecovery(rows);
|
||||
expect(resolved).toHaveLength(2);
|
||||
// Sorted desc by measuredAt: the Jun02-noon COMPUTED first, then the
|
||||
// Jun02-06:00 WHOOP. Both survive — no night was merged away.
|
||||
expect(resolved.map((r) => r.value).sort()).toEqual([51, 70]);
|
||||
expect(resolved.map((r) => r.source).sort()).toEqual(["COMPUTED", "WHOOP"]);
|
||||
});
|
||||
|
||||
it("keeps the latest same-source row within a night", () => {
|
||||
const rows = [
|
||||
row("2026-06-02T06:00:00Z", 60, "WHOOP"),
|
||||
row("2026-06-02T08:00:00Z", 75, "WHOOP"), // a morning re-score
|
||||
];
|
||||
const resolved = resolveCanonicalRecovery(rows);
|
||||
expect(resolved).toHaveLength(1);
|
||||
expect(resolved[0].value).toBe(75);
|
||||
});
|
||||
|
||||
it("buckets by the user's local wake-day, not UTC", () => {
|
||||
// A WHOOP wake stamp just after local midnight in a far-east zone. Read in
|
||||
// UTC it would file under the previous calendar day; in the user's zone it
|
||||
// is the wake day, matching the COMPUTED proxy's forward-shifted anchor.
|
||||
const rows = [
|
||||
// Pacific/Auckland (UTC+12 in June): 2026-06-02T13:00:00Z = 2026-06-03 01:00 local.
|
||||
row("2026-06-02T13:00:00Z", 82, "WHOOP"),
|
||||
// COMPUTED proxy for the same wake (Jun 03 local): filed Jun 02 noon UTC,
|
||||
// which is Jun 03 00:00 local → shifted forward to Jun 03 local wake day.
|
||||
row("2026-06-02T00:00:00Z", 40, "COMPUTED"),
|
||||
];
|
||||
const resolved = resolveCanonicalRecovery(rows, "Pacific/Auckland");
|
||||
expect(resolved).toHaveLength(1);
|
||||
expect(resolved[0].source).toBe("WHOOP");
|
||||
expect(resolved[0].value).toBe(82);
|
||||
});
|
||||
|
||||
it("returns an empty list for no rows", () => {
|
||||
expect(resolveCanonicalRecovery([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,12 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { measurement: { findMany: vi.fn() } },
|
||||
prisma: {
|
||||
measurement: { findMany: vi.fn() },
|
||||
// `computeSleepScore` reads the user's source priority for the canonical
|
||||
// writer-dedup ladder; default to no override (null → default ladder).
|
||||
user: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/tz/resolver", () => ({
|
||||
resolveUserTimezone: vi.fn().mockResolvedValue("UTC"),
|
||||
@@ -39,6 +44,22 @@ function night(day: string, stages: Array<[string | null, number, string]>) {
|
||||
}));
|
||||
}
|
||||
|
||||
/** Build per-stage rows tagged with an ingest source + device-type. */
|
||||
function srcNight(
|
||||
day: string,
|
||||
source: string,
|
||||
stages: Array<[string | null, number, string]>,
|
||||
deviceType?: string,
|
||||
) {
|
||||
return stages.map(([sleepStage, value, hhmm]) => ({
|
||||
value,
|
||||
measuredAt: new Date(`${day}T${hhmm}:00Z`),
|
||||
sleepStage: sleepStage as never,
|
||||
source: source as never,
|
||||
deviceType: deviceType ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
describe("pure sleep scorers", () => {
|
||||
it("sleepNeedMinutes is age-banded with an adult default", () => {
|
||||
expect(sleepNeedMinutes(40)).toBe(7 * 60);
|
||||
@@ -154,8 +175,9 @@ describe("reconstructNights", () => {
|
||||
expect(nights[0].deepMinutes).toBe(60);
|
||||
});
|
||||
|
||||
it("expresses the midpoint in UTC by default", () => {
|
||||
// Earliest 01:00Z, latest 05:00Z → midpoint 03:00Z = 180 min-of-day.
|
||||
it("expresses the midpoint in the user's wall clock (asleep span centred on wake)", () => {
|
||||
// Asleep span ends 05:00Z, 240 asleep minutes → midpoint at wake −
|
||||
// 120 min = 03:00Z = 180 min-of-day in UTC.
|
||||
const rows = night("2026-06-02", [
|
||||
["CORE", 120, "01:00"],
|
||||
["CORE", 120, "05:00"],
|
||||
@@ -174,6 +196,87 @@ describe("reconstructNights", () => {
|
||||
const nights = reconstructNights(rows, "Asia/Kolkata");
|
||||
expect(nights[0].midpoint).toBe(8 * 60 + 30);
|
||||
});
|
||||
|
||||
it("synthesises the efficiency denominator from asleep + awake when no IN_BED row exists", () => {
|
||||
// A common Apple-Health shape: granular asleep stages + an AWAKE row but
|
||||
// NO IN_BED row. The canonical engine leaves `inBedMinutes` null; the
|
||||
// adapter must synthesise `asleep + awake` so the efficiency sub-score
|
||||
// survives and historical scores for this night class are preserved.
|
||||
const rows = night("2026-06-02", [
|
||||
["REM", 90, "02:00"],
|
||||
["CORE", 240, "03:00"],
|
||||
["DEEP", 60, "04:00"],
|
||||
["AWAKE", 30, "05:00"],
|
||||
]);
|
||||
const nights = reconstructNights(rows);
|
||||
expect(nights).toHaveLength(1);
|
||||
expect(nights[0].asleepMinutes).toBe(390);
|
||||
// 390 asleep + 30 awake = 420 synthesised in-bed denominator.
|
||||
expect(nights[0].inBedMinutes).toBe(420);
|
||||
// The efficiency sub-score is non-null and stable (390 / 420 ≈ 93 %).
|
||||
expect(scoreEfficiency(nights[0].asleepMinutes, nights[0].inBedMinutes)).toBe(
|
||||
93,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the real IN_BED figure when present (synthesis does not override)", () => {
|
||||
// An explicit IN_BED row coexists with an AWAKE row — the real envelope
|
||||
// wins, NOT the synthesised asleep + awake.
|
||||
const rows = night("2026-06-02", [
|
||||
["IN_BED", 480, "06:00"],
|
||||
["REM", 90, "02:00"],
|
||||
["CORE", 240, "03:00"],
|
||||
["DEEP", 60, "04:00"],
|
||||
["AWAKE", 30, "05:00"],
|
||||
]);
|
||||
const nights = reconstructNights(rows);
|
||||
expect(nights[0].inBedMinutes).toBe(480);
|
||||
});
|
||||
|
||||
it("leaves in-bed null when neither an IN_BED row nor awake minutes exist", () => {
|
||||
// Asleep stages only, no IN_BED and no AWAKE row — no honest efficiency
|
||||
// denominator, so it stays null and the sub-score drops as before.
|
||||
const rows = night("2026-06-02", [
|
||||
["CORE", 240, "03:00"],
|
||||
["DEEP", 60, "04:00"],
|
||||
]);
|
||||
const nights = reconstructNights(rows);
|
||||
expect(nights[0].inBedMinutes).toBeNull();
|
||||
expect(scoreEfficiency(nights[0].asleepMinutes, nights[0].inBedMinutes)).toBeNull();
|
||||
});
|
||||
|
||||
it("deduplicates a multi-source night to ONE canonical total (no double-count)", () => {
|
||||
// The SAME physical night written by both Apple Health AND WHOOP. The
|
||||
// divergent UTC-day reconstructor used to SUM both writers (~doubling the
|
||||
// night). The canonical engine collapses to one writer (WHOOP wins the
|
||||
// sleep ladder) before summing.
|
||||
const apple = srcNight(
|
||||
"2026-06-02",
|
||||
"APPLE_HEALTH",
|
||||
[
|
||||
["CORE", 240, "03:00"],
|
||||
["DEEP", 60, "04:00"],
|
||||
["REM", 90, "05:00"],
|
||||
],
|
||||
"watch",
|
||||
);
|
||||
const whoop = srcNight(
|
||||
"2026-06-02",
|
||||
"WHOOP",
|
||||
[
|
||||
["CORE", 250, "03:00"],
|
||||
["DEEP", 65, "04:00"],
|
||||
["REM", 95, "05:00"],
|
||||
],
|
||||
"strap",
|
||||
);
|
||||
const nights = reconstructNights([...apple, ...whoop]);
|
||||
expect(nights).toHaveLength(1);
|
||||
// ONE writer's total, NOT apple + whoop summed (~780).
|
||||
expect(nights[0].asleepMinutes).toBeLessThan(500);
|
||||
// WHOOP wins the default sleep ladder → its 250+65+95 = 410 total.
|
||||
expect(nights[0].asleepMinutes).toBe(410);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeSleepScore", () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: { findMany: vi.fn() },
|
||||
strainTrimpCache: { findUnique: vi.fn() },
|
||||
user: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -19,11 +20,16 @@ const NOW = new Date("2026-06-02T08:00:00Z");
|
||||
const findMany = prisma.measurement.findMany as ReturnType<typeof vi.fn>;
|
||||
const cacheFindUnique = prisma.strainTrimpCache
|
||||
.findUnique as ReturnType<typeof vi.fn>;
|
||||
const userFindUnique = prisma.user.findUnique as ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
findMany.mockReset();
|
||||
cacheFindUnique.mockReset();
|
||||
cacheFindUnique.mockResolvedValue(null);
|
||||
// The recovery resolver buckets by the user's local wake-day; the reader
|
||||
// loads the zone when none is passed. Default the cohort to Europe/Berlin.
|
||||
userFindUnique.mockReset();
|
||||
userFindUnique.mockResolvedValue({ timezone: "Europe/Berlin" });
|
||||
});
|
||||
|
||||
describe("bandWellnessScore", () => {
|
||||
@@ -122,7 +128,7 @@ describe("computeWellnessScore", () => {
|
||||
|
||||
it("RECOVERY does not read the strain cache and carries a null anchor", async () => {
|
||||
findMany.mockResolvedValue([
|
||||
{ value: 72, measuredAt: new Date("2026-06-01T12:00:00Z") },
|
||||
{ value: 72, measuredAt: new Date("2026-06-01T12:00:00Z"), source: "COMPUTED" },
|
||||
]);
|
||||
const r = await computeWellnessScore("RECOVERY_SCORE", "u1", PROFILE, {
|
||||
now: NOW,
|
||||
@@ -133,4 +139,37 @@ describe("computeWellnessScore", () => {
|
||||
expect((r.value as WellnessScoreValue).anchor).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("RECOVERY reads BOTH sources and resolves the WHOOP-native row when present", async () => {
|
||||
// ONE night written by both engines on their realistic clocks: the WHOOP
|
||||
// wake-morning stamp (Jun 02) and the COMPUTED proxy filed under the
|
||||
// day-that-ended (Jun 01). The native row is canonical → the tile shows 80,
|
||||
// not the proxy's 50, and the off-by-one does NOT spawn two recovery days.
|
||||
findMany.mockResolvedValue([
|
||||
{ value: 80, measuredAt: new Date("2026-06-02T06:00:00Z"), source: "WHOOP" },
|
||||
{ value: 50, measuredAt: new Date("2026-06-01T12:00:00Z"), source: "COMPUTED" },
|
||||
]);
|
||||
const r = await computeWellnessScore("RECOVERY_SCORE", "u1", PROFILE, {
|
||||
now: NOW,
|
||||
});
|
||||
// The read must NOT hard-filter to COMPUTED — both sources reach the resolver.
|
||||
const where = findMany.mock.calls[0][0].where as { source?: unknown };
|
||||
expect(where.source).toBeUndefined();
|
||||
expect(r.status).toBe("ok");
|
||||
if (r.status === "ok") {
|
||||
expect((r.value as WellnessScoreValue).score).toBe(80);
|
||||
expect((r.value as WellnessScoreValue).band).toBe("green");
|
||||
// One night, one canonical row — the off-by-one collapsed.
|
||||
expect((r.value as WellnessScoreValue).daysInWindow).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("STRESS still hard-filters to the COMPUTED source", async () => {
|
||||
findMany.mockResolvedValue([
|
||||
{ value: 30, measuredAt: new Date("2026-06-02T12:00:00Z"), source: "COMPUTED" },
|
||||
]);
|
||||
await computeWellnessScore("STRESS_SCORE", "u1", PROFILE, { now: NOW });
|
||||
const where = findMany.mock.calls[0][0].where as { source?: unknown };
|
||||
expect(where.source).toBe("COMPUTED");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* v1.16.16 — canonical recovery resolution (one number, one engine).
|
||||
*
|
||||
* `RECOVERY_SCORE` is written by TWO sources that mean different things for the
|
||||
* same day:
|
||||
* - `WHOOP` — the device's own native recovery percentage (ground truth
|
||||
* when the user wears a WHOOP strap), ingested by the sync.
|
||||
* - `COMPUTED` — the server-derived proxy (the READINESS blend persisted by
|
||||
* the nightly `recovery-score` job) — the fallback for a user
|
||||
* with no native recovery signal.
|
||||
*
|
||||
* The DECISION (locked): the WHOOP-native row is canonical WHEN PRESENT; the
|
||||
* COMPUTED proxy is the fallback. Every read surface — the wellness tile, the
|
||||
* doctor PDF, the iOS feed — must resolve to the SAME canonical row per day so
|
||||
* none of them shows the proxy and the native value as two competing series.
|
||||
*
|
||||
* Pure resolution: group rows by the physiological NIGHT they describe and,
|
||||
* per night, keep the WHOOP row when one exists, else the COMPUTED one. No DB
|
||||
* access here — the caller does the bounded read and passes rows in.
|
||||
*
|
||||
* THE DAY-ANCHOR PROBLEM (v1.16.16). The two sources stamp the SAME night on
|
||||
* opposite clocks:
|
||||
* - WHOOP stamps `measuredAt = updated_at` — the MORNING instant WHOOP
|
||||
* finished scoring the night → the WAKE day D.
|
||||
* - COMPUTED stamps `measuredAt = noon-UTC of scoreDayKey` where
|
||||
* `scoreDayKey = (run morning) − 1 day` → the day-that-ENDED, D−1
|
||||
* (`src/lib/insights/score-row.ts`). The 04:45 Europe/Berlin cron
|
||||
* scores last night's signals but files them under the prior day.
|
||||
* So for ONE night the WHOOP row lands on day D and the COMPUTED proxy on day
|
||||
* D−1 — a systematic off-by-one. Bucketing raw `measuredAt` by calendar day
|
||||
* therefore leaves BOTH alive as two competing recovery days.
|
||||
*
|
||||
* The fix is a SOURCE-AWARE wake-day key: the COMPUTED proxy's day stamp is
|
||||
* shifted forward one day (the readiness of the day that ended IS the readiness
|
||||
* you wake with the next morning), and both keys are read in the user's LOCAL
|
||||
* timezone so a near-midnight / late re-score can't split a single night. The
|
||||
* COMPUTED proxy's own stored stamp (shared with stress / strain) is NOT
|
||||
* touched — the shift is a pure read-time alignment, so no ingest change and no
|
||||
* backfill are needed and two genuinely different nights stay separate.
|
||||
*/
|
||||
import type { MeasurementSource } from "@/generated/prisma/client";
|
||||
import { dayKeyForUserTz } from "@/lib/measurements/consolidation-tz";
|
||||
import { resolveUserTimezone } from "@/lib/measurements/consolidation-base";
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** A `RECOVERY_SCORE` row as read for resolution. */
|
||||
export interface RecoveryRow {
|
||||
value: number;
|
||||
measuredAt: Date;
|
||||
source: MeasurementSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-source preference for `RECOVERY_SCORE`: a native WHOOP row outranks
|
||||
* the COMPUTED proxy for the same day. Lower number = higher authority. A
|
||||
* source not listed (there is none today) falls back below both.
|
||||
*/
|
||||
const RECOVERY_SOURCE_RANK: Partial<Record<MeasurementSource, number>> = {
|
||||
WHOOP: 0,
|
||||
COMPUTED: 1,
|
||||
};
|
||||
|
||||
function rankOf(source: MeasurementSource): number {
|
||||
return RECOVERY_SOURCE_RANK[source] ?? Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
/**
|
||||
* The local WAKE-day key a recovery row describes. WHOOP's stamp already IS the
|
||||
* wake morning, so its local day is the night's wake day directly. The COMPUTED
|
||||
* proxy stamps the day-that-ended (`scoreDayKey = run − 1 day`), so its day is
|
||||
* shifted forward by one before the local-day read — aligning it onto the same
|
||||
* wake day the WHOOP row for that night carries. Reading the key in the user's
|
||||
* timezone keeps a near-midnight or late re-score on the same night.
|
||||
*/
|
||||
function wakeDayKeyOf(d: Date, source: MeasurementSource, tz: string): string {
|
||||
const anchor =
|
||||
source === "COMPUTED" ? new Date(d.getTime() + MS_PER_DAY) : d;
|
||||
return dayKeyForUserTz(anchor, tz);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse a mixed-source `RECOVERY_SCORE` set to ONE canonical row per
|
||||
* physiological NIGHT: the WHOOP-native row wins when present, else the
|
||||
* COMPUTED proxy. Within a source the latest `measuredAt` wins (a same-night
|
||||
* re-score). Rows are bucketed by their local WAKE-day (the COMPUTED proxy's
|
||||
* day-that-ended stamp is shifted forward one day to meet WHOOP's wake-morning
|
||||
* stamp) so a single night never splits into two competing days. Returns the
|
||||
* canonical rows in the same shape, sorted by `measuredAt` DESCENDING so the
|
||||
* first element is the most recent canonical night — matching the order the
|
||||
* wellness reader already expects.
|
||||
*
|
||||
* `timezone` is the user's IANA zone (falls back to `Europe/Berlin` when
|
||||
* null/empty); the wake-day key is read in it so the bucket boundary tracks the
|
||||
* user's midnight, not UTC's.
|
||||
*/
|
||||
export function resolveCanonicalRecovery(
|
||||
rows: RecoveryRow[],
|
||||
timezone?: string | null,
|
||||
): RecoveryRow[] {
|
||||
const tz = resolveUserTimezone(timezone ?? null);
|
||||
const byDay = new Map<string, RecoveryRow>();
|
||||
for (const row of rows) {
|
||||
const key = wakeDayKeyOf(row.measuredAt, row.source, tz);
|
||||
const incumbent = byDay.get(key);
|
||||
if (incumbent === undefined) {
|
||||
byDay.set(key, row);
|
||||
continue;
|
||||
}
|
||||
const rowRank = rankOf(row.source);
|
||||
const incumbentRank = rankOf(incumbent.source);
|
||||
const better =
|
||||
rowRank < incumbentRank ||
|
||||
(rowRank === incumbentRank &&
|
||||
row.measuredAt.getTime() > incumbent.measuredAt.getTime());
|
||||
if (better) byDay.set(key, row);
|
||||
}
|
||||
return [...byDay.values()].sort(
|
||||
(a, b) => b.measuredAt.getTime() - a.measuredAt.getTime(),
|
||||
);
|
||||
}
|
||||
@@ -32,10 +32,19 @@
|
||||
* are not rolled up by stage, so the night reconstruction reads raw, but
|
||||
* bounded to the window). The pure scorers are exported for unit tests.
|
||||
*/
|
||||
import type { MeasurementType, SleepStage } from "@/generated/prisma/client";
|
||||
import type {
|
||||
MeasurementSource,
|
||||
MeasurementType,
|
||||
SleepStage,
|
||||
} from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { wallClockInTz } from "@/lib/tz/wall-clock";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import {
|
||||
reconstructSleepNights,
|
||||
type SleepStageRow,
|
||||
} from "@/lib/analytics/sleep-night";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import {
|
||||
buildInsufficient,
|
||||
buildOk,
|
||||
@@ -48,8 +57,6 @@ import type { Derived } from "./types";
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
/** Default trailing window for the consistency/timing baseline (days). */
|
||||
const DEFAULT_WINDOW_DAYS = 30;
|
||||
/** A night needs ≥ this many stage rows to score at all. */
|
||||
const MIN_STAGE_ROWS_PER_NIGHT = 1;
|
||||
|
||||
/** Transparent sub-score weights (shown to the user, sum to 1 over present). */
|
||||
export const SLEEP_SUBSCORE_WEIGHTS = {
|
||||
@@ -88,13 +95,6 @@ export interface SleepScoreValue {
|
||||
windowNights: number;
|
||||
}
|
||||
|
||||
const ASLEEP_STAGES: ReadonlySet<SleepStage> = new Set<SleepStage>([
|
||||
"ASLEEP",
|
||||
"REM",
|
||||
"CORE",
|
||||
"DEEP",
|
||||
]);
|
||||
|
||||
// ── pure scorers (exported for tests) ─────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -300,6 +300,14 @@ interface SleepRow {
|
||||
value: number;
|
||||
measuredAt: Date;
|
||||
sleepStage: SleepStage | null;
|
||||
/**
|
||||
* Ingest source + device-type — fed to the canonical `reconstructSleepNights`
|
||||
* writer-dedup so a multi-source night (WHOOP + Apple Health) collapses to
|
||||
* ONE writer before summing instead of double-counting. Optional: a legacy
|
||||
* fixture or single-source caller that omits them dedups as before.
|
||||
*/
|
||||
source?: MeasurementSource | null;
|
||||
deviceType?: string | null;
|
||||
}
|
||||
|
||||
export interface NightSummary {
|
||||
@@ -316,83 +324,85 @@ export interface NightSummary {
|
||||
}
|
||||
|
||||
/**
|
||||
* Group raw stage rows into per-night summaries. A "night" is keyed by the
|
||||
* latest stage row's calendar day (the wake day). Pure — the caller does
|
||||
* the bounded DB read and passes rows in.
|
||||
* Adapt the canonical per-night reconstruction (`reconstructSleepNights`) into
|
||||
* the `NightSummary` shape the Sleep Score scorers consume. ONE ENGINE: the
|
||||
* same session-clustering, local-wake-day keying, and multi-source writer
|
||||
* dedup the dashboard / hypnogram / doctor surfaces use now also backs the
|
||||
* Sleep Score, so a multi-source night (WHOOP + Apple Health) is counted ONCE,
|
||||
* not summed across writers.
|
||||
*
|
||||
* This is an ADAPTER, not a drop-in: `reconstructSleepNights` returns the
|
||||
* canonical asleep total + per-stage map + in-bed/awake totals, and this
|
||||
* function derives the score-only fields the canonical engine does not carry —
|
||||
* `remMinutes` / `deepMinutes` from the stage map, `hasStageBreakdown` from the
|
||||
* presence of any granular stage, and `midpoint` from the asleep span (the
|
||||
* canonical night's wake instant minus half the asleep minutes), expressed in
|
||||
* the user's wall clock.
|
||||
*
|
||||
* `tz` is the IANA zone the midpoint is expressed against: a sleeper's
|
||||
* 03:00-local midpoint must read as minutes-of-day in THEIR wall clock, not
|
||||
* UTC, so a non-UTC user's consistency / timing sub-scores don't drift with
|
||||
* the offset. Defaults to UTC for back-compatible pure use.
|
||||
* the offset. Defaults to UTC for back-compatible pure use. `priorityJson` is
|
||||
* the user's persisted source priority (or null for the defaults) so the
|
||||
* writer-dedup ladder matches every other sleep surface.
|
||||
*/
|
||||
export function reconstructNights(
|
||||
rows: SleepRow[],
|
||||
tz: string = "UTC",
|
||||
priorityJson: unknown = null,
|
||||
): NightSummary[] {
|
||||
// Bucket rows by the wake-day key (UTC day of the row's measuredAt).
|
||||
const byNight = new Map<string, SleepRow[]>();
|
||||
for (const row of rows) {
|
||||
const key = row.measuredAt.toISOString().slice(0, 10);
|
||||
const list = byNight.get(key) ?? [];
|
||||
list.push(row);
|
||||
byNight.set(key, list);
|
||||
}
|
||||
const nights: NightSummary[] = [];
|
||||
for (const [night, nightRows] of byNight) {
|
||||
let asleep = 0;
|
||||
let awake = 0;
|
||||
let rem = 0;
|
||||
let deep = 0;
|
||||
let inBed = 0;
|
||||
let sawInBed = false;
|
||||
let sawStageBreakdown = false;
|
||||
let earliest = Infinity;
|
||||
let latest = -Infinity;
|
||||
for (const r of nightRows) {
|
||||
const stage = r.sleepStage;
|
||||
const minutes = Number.isFinite(r.value) ? r.value : 0;
|
||||
const t = r.measuredAt.getTime();
|
||||
if (t < earliest) earliest = t;
|
||||
if (t > latest) latest = t;
|
||||
if (stage === "IN_BED") {
|
||||
inBed += minutes;
|
||||
sawInBed = true;
|
||||
continue;
|
||||
}
|
||||
if (stage === "AWAKE") {
|
||||
awake += minutes;
|
||||
continue;
|
||||
}
|
||||
if (stage && ASLEEP_STAGES.has(stage)) {
|
||||
asleep += minutes;
|
||||
if (stage === "REM") rem += minutes;
|
||||
if (stage === "DEEP") deep += minutes;
|
||||
if (stage === "REM" || stage === "CORE" || stage === "DEEP") {
|
||||
sawStageBreakdown = true;
|
||||
}
|
||||
} else if (stage == null) {
|
||||
// A bare SLEEP_DURATION row (no stage) is the night's total asleep.
|
||||
asleep += minutes;
|
||||
}
|
||||
}
|
||||
// In-bed = explicit IN_BED rows when present, else asleep + awake.
|
||||
const inBedMinutes = sawInBed ? inBed : awake > 0 ? asleep + awake : null;
|
||||
const stageRows: SleepStageRow[] = rows.map((r) => ({
|
||||
value: r.value,
|
||||
measuredAt: r.measuredAt,
|
||||
sleepStage: r.sleepStage,
|
||||
source: r.source ?? null,
|
||||
deviceType: r.deviceType ?? null,
|
||||
}));
|
||||
const nights = reconstructSleepNights(stageRows, tz, priorityJson);
|
||||
return nights.map((n) => {
|
||||
const rem = n.stages.REM ?? 0;
|
||||
const deep = n.stages.DEEP ?? 0;
|
||||
const core = n.stages.CORE ?? 0;
|
||||
const hasStageBreakdown = rem > 0 || deep > 0 || core > 0;
|
||||
const awakeMinutes = n.awakeMinutes ?? 0;
|
||||
// Efficiency denominator ("in bed"). The canonical engine sets
|
||||
// `inBedMinutes` to null unless a real IN_BED row exists anywhere in the
|
||||
// night — but a very common Apple-Health shape carries AWAKE rows and NO
|
||||
// IN_BED row, for which the legacy reconstructor synthesised a denominator
|
||||
// as `asleep + awake`. Taking the canonical null straight through silently
|
||||
// dropped the efficiency sub-score for that whole class of night, which
|
||||
// reweights the composite and shifts historical Sleep Scores. Restore the
|
||||
// synthesised fallback ON TOP of the (correctly deduped) canonical totals:
|
||||
// keep the real IN_BED figure when present, else synthesise `asleep + awake`
|
||||
// when both are positive, else keep null (neither signal — no honest
|
||||
// efficiency).
|
||||
const inBedMinutes =
|
||||
n.inBedMinutes != null
|
||||
? n.inBedMinutes
|
||||
: n.asleepMinutes > 0 && awakeMinutes > 0
|
||||
? n.asleepMinutes + awakeMinutes
|
||||
: null;
|
||||
// Midpoint = centre of the asleep span. The canonical night's `measuredAt`
|
||||
// is the wake instant (latest stage END); the asleep span is
|
||||
// `asleepMinutes` long ending there, so its centre is wake − asleep/2.
|
||||
const midpoint =
|
||||
Number.isFinite(earliest) && Number.isFinite(latest) && latest > earliest
|
||||
? minutesOfDay(new Date((earliest + latest) / 2), tz)
|
||||
n.asleepMinutes > 0
|
||||
? minutesOfDay(
|
||||
new Date(n.measuredAt.getTime() - (n.asleepMinutes / 2) * 60_000),
|
||||
tz,
|
||||
)
|
||||
: null;
|
||||
nights.push({
|
||||
night,
|
||||
asleepMinutes: asleep,
|
||||
awakeMinutes: awake,
|
||||
return {
|
||||
night: n.night,
|
||||
asleepMinutes: n.asleepMinutes,
|
||||
awakeMinutes,
|
||||
remMinutes: rem,
|
||||
deepMinutes: deep,
|
||||
inBedMinutes,
|
||||
hasStageBreakdown: sawStageBreakdown,
|
||||
hasStageBreakdown,
|
||||
midpoint,
|
||||
});
|
||||
}
|
||||
return nights.sort((a, b) => (a.night < b.night ? -1 : 1));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function minutesOfDay(d: Date, tz: string): number {
|
||||
@@ -430,6 +440,10 @@ export async function computeSleepScore(
|
||||
// non-UTC sleeper's consistency / timing sub-scores don't drift with the
|
||||
// offset. Resolve the stored zone unless a caller pins one.
|
||||
const tz = opts.tz ?? (await resolveUserTimezone(userId));
|
||||
// The canonical writer-dedup ladder needs the user's source priority — read
|
||||
// it alongside so a multi-source night collapses to the SAME writer the
|
||||
// dashboard / hypnogram pick.
|
||||
const priorityJson = await loadUserSourcePriority(userId);
|
||||
const inputs = ["SLEEP_DURATION"];
|
||||
const required = 1;
|
||||
const since = new Date(now.getTime() - windowDays * MS_PER_DAY);
|
||||
@@ -442,7 +456,15 @@ export async function computeSleepScore(
|
||||
measuredAt: { gte: since },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { value: true, measuredAt: true, sleepStage: true },
|
||||
// `source` + `deviceType` feed the canonical writer-dedup so a multi-source
|
||||
// night is counted ONCE, not summed across writers.
|
||||
select: {
|
||||
value: true,
|
||||
measuredAt: true,
|
||||
sleepStage: true,
|
||||
source: true,
|
||||
deviceType: true,
|
||||
},
|
||||
})) as SleepRow[];
|
||||
|
||||
if (rows.length === 0) {
|
||||
@@ -460,12 +482,14 @@ export async function computeSleepScore(
|
||||
});
|
||||
}
|
||||
|
||||
const nights = reconstructNights(rows, tz).filter(
|
||||
// Reconstruct via the canonical engine (session-clustering, local-wake-day
|
||||
// keying, multi-source writer dedup) so the Sleep Score reads the SAME night
|
||||
// totals the dashboard / hypnogram / doctor surfaces do. Every reconstructed
|
||||
// night derives from ≥ 1 contributing row by construction, so a scorable
|
||||
// night is just one that carries asleep minutes.
|
||||
const scorableNights = reconstructNights(rows, tz, priorityJson).filter(
|
||||
(n) => n.asleepMinutes > 0,
|
||||
);
|
||||
const scorableNights = nights.filter(
|
||||
(n) => countNightRows(rows, n.night) >= MIN_STAGE_ROWS_PER_NIGHT,
|
||||
);
|
||||
|
||||
if (scorableNights.length === 0) {
|
||||
const { coverage } = deriveCoverage({
|
||||
@@ -527,8 +551,12 @@ export async function computeSleepScore(
|
||||
score,
|
||||
band: bandForScore(score),
|
||||
night: latest.night,
|
||||
asleepMinutes: latest.asleepMinutes,
|
||||
inBedMinutes: latest.inBedMinutes,
|
||||
// Emit whole minutes (iOS #18) — the canonical totals sum second-
|
||||
// precision segments and can otherwise serialise as e.g. 433.4999.
|
||||
// Null-preserving on the in-bed signal.
|
||||
asleepMinutes: Math.round(latest.asleepMinutes),
|
||||
inBedMinutes:
|
||||
latest.inBedMinutes === null ? null : Math.round(latest.inBedMinutes),
|
||||
subScores,
|
||||
windowNights: scorableNights.length,
|
||||
},
|
||||
@@ -542,11 +570,3 @@ export async function computeSleepScore(
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function countNightRows(rows: SleepRow[], night: string): number {
|
||||
let c = 0;
|
||||
for (const r of rows) {
|
||||
if (r.measuredAt.toISOString().slice(0, 10) === night) c += 1;
|
||||
}
|
||||
return c;
|
||||
}
|
||||
@@ -13,10 +13,14 @@
|
||||
* Server-only — reads `@/lib/db`.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import type {
|
||||
MeasurementSource,
|
||||
MeasurementType,
|
||||
} from "@/generated/prisma/client";
|
||||
import { buildInsufficient, buildOk, nowProvenanceTimestamp } from "./coverage";
|
||||
import type { BaselineProfile } from "./baseline";
|
||||
import type { StrainAnchor } from "@/lib/insights/strain-score";
|
||||
import { resolveCanonicalRecovery } from "./recovery-resolve";
|
||||
import { SPARKLINE_MAX_POINTS, type Derived } from "./types";
|
||||
|
||||
/** A 0–100 wellness score band. Higher is better for recovery; for stress a
|
||||
@@ -86,6 +90,13 @@ export interface WellnessScoreOpts {
|
||||
/** Trailing window for the trend mean (days). Defaults to 14. */
|
||||
windowDays?: number;
|
||||
now?: Date;
|
||||
/**
|
||||
* The user's IANA timezone, used only by the RECOVERY canonical-night
|
||||
* resolver to bucket WHOOP + COMPUTED rows by the local wake-day. When
|
||||
* omitted, the reader loads it from the user row (falling back to
|
||||
* `Europe/Berlin`). STRESS / STRAIN ignore it.
|
||||
*/
|
||||
timezone?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,18 +117,50 @@ export async function computeWellnessScore(
|
||||
const computedAt = nowProvenanceTimestamp(now);
|
||||
const measurementType = WELLNESS_SCORE_TYPES[type] as MeasurementType;
|
||||
|
||||
const rows = await prisma.measurement.findMany({
|
||||
// RECOVERY_SCORE is written by TWO sources for the same day — the WHOOP
|
||||
// native percentage and the COMPUTED proxy. The native row is canonical when
|
||||
// present, so the read must NOT hard-filter to COMPUTED (that silently drops
|
||||
// every ingested native row); read both sources and resolve per day below.
|
||||
// STRESS / STRAIN are COMPUTED-only, so they keep the source filter.
|
||||
const isRecovery = type === "RECOVERY_SCORE";
|
||||
const rawRows = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: measurementType,
|
||||
source: "COMPUTED",
|
||||
...(isRecovery ? {} : { source: "COMPUTED" as MeasurementSource }),
|
||||
deletedAt: null,
|
||||
measuredAt: { gte: cutoff, lte: now },
|
||||
},
|
||||
select: { value: true, measuredAt: true },
|
||||
select: { value: true, measuredAt: true, source: true },
|
||||
orderBy: { measuredAt: "desc" },
|
||||
});
|
||||
|
||||
// Collapse a mixed-source recovery set to ONE canonical row per day (WHOOP
|
||||
// wins over COMPUTED). Non-recovery sets are already single-source, so the
|
||||
// resolver is recovery-only — the tile, doctor PDF, and iOS feed all read the
|
||||
// SAME canonical value.
|
||||
let timezone = opts.timezone ?? null;
|
||||
if (isRecovery && timezone === null) {
|
||||
// The canonical-night resolver buckets by the user's local wake-day, so the
|
||||
// off-by-one between WHOOP's wake-morning stamp and the COMPUTED proxy's
|
||||
// day-that-ended stamp collapses onto one night. Load the zone once.
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { timezone: true },
|
||||
});
|
||||
timezone = user?.timezone ?? null;
|
||||
}
|
||||
const rows = isRecovery
|
||||
? resolveCanonicalRecovery(
|
||||
rawRows.map((r) => ({
|
||||
value: r.value,
|
||||
measuredAt: r.measuredAt,
|
||||
source: r.source,
|
||||
})),
|
||||
timezone,
|
||||
)
|
||||
: rawRows;
|
||||
|
||||
if (rows.length === 0) {
|
||||
return buildInsufficient<WellnessScoreValue>({
|
||||
coverage: {
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
evaluateSelfContextTrigger,
|
||||
evaluateSleepDebtTrigger,
|
||||
evaluateWeightTrigger,
|
||||
findTriggerForUser,
|
||||
runCoachNudgeTick,
|
||||
} from "../coach-nudge";
|
||||
import { getAssistantFlags } from "@/lib/feature-flags";
|
||||
@@ -165,6 +166,82 @@ describe("evaluateScoreTrigger", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("findTriggerForUser RECOVERY_SCORE canonical resolution", () => {
|
||||
const NOW = new Date("2026-06-15T08:00:00Z");
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
// Vitals-only so the recovery branch is the only one with data; no height /
|
||||
// dob / gender → the WEIGHT range can't resolve and that branch is skipped.
|
||||
const VITALS_ONLY = { medication: false, vitals: true, routine: false };
|
||||
const baseUser = {
|
||||
id: "u-recovery",
|
||||
heightCm: null,
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
thresholdsJson: null,
|
||||
timezone: "Europe/Berlin",
|
||||
};
|
||||
|
||||
/** A prisma stub serving ONLY the supplied RECOVERY_SCORE rows; every other
|
||||
* measurement read comes back empty so no other vitals trigger fires. */
|
||||
function recoveryPrisma(recoveryRows: unknown[]) {
|
||||
return {
|
||||
measurement: {
|
||||
findMany: vi.fn(async (args: { where: { type?: string } }) =>
|
||||
args.where.type === "RECOVERY_SCORE" ? recoveryRows : [],
|
||||
),
|
||||
},
|
||||
} as unknown as PrismaClient;
|
||||
}
|
||||
|
||||
/** A recovery row on a source's realistic clock. */
|
||||
function rec(iso: string, value: number, source: "WHOOP" | "COMPUTED") {
|
||||
return { value, measuredAt: new Date(iso), source };
|
||||
}
|
||||
|
||||
it("does not fire when the canonical WHOOP series is stable but stray COMPUTED proxies dip", async () => {
|
||||
// Healthy, flat WHOOP-native recovery across both windows (~70). For the
|
||||
// SAME nights a low COMPUTED proxy (~40) also sits in the table. Blending
|
||||
// both — the pre-fix behaviour — would drag the recent mean down enough to
|
||||
// trip the drop trigger. Canonical resolution keeps only the WHOOP rows.
|
||||
const rows: unknown[] = [];
|
||||
for (let d = 1; d <= 12; d++) {
|
||||
const day = new Date(NOW.getTime() - d * DAY);
|
||||
const iso = day.toISOString().slice(0, 10);
|
||||
rows.push(rec(`${iso}T06:00:00Z`, 70, "WHOOP"));
|
||||
// COMPUTED proxy for the same night, filed one day earlier at noon UTC.
|
||||
const prevIso = new Date(day.getTime() - DAY).toISOString().slice(0, 10);
|
||||
rows.push(rec(`${prevIso}T12:00:00Z`, 40, "COMPUTED"));
|
||||
}
|
||||
const trigger = await findTriggerForUser(
|
||||
recoveryPrisma(rows),
|
||||
baseUser,
|
||||
NOW,
|
||||
VITALS_ONLY,
|
||||
);
|
||||
expect(trigger).not.toBe("score");
|
||||
});
|
||||
|
||||
it("still fires on a genuine WHOOP-native week-over-week recovery drop", async () => {
|
||||
// No COMPUTED rows — a real native collapse from ~72 to ~50 must trigger.
|
||||
const rows: unknown[] = [];
|
||||
for (let d = 1; d <= 6; d++) {
|
||||
const day = new Date(NOW.getTime() - d * DAY).toISOString().slice(0, 10);
|
||||
rows.push(rec(`${day}T06:00:00Z`, 50, "WHOOP"));
|
||||
}
|
||||
for (let d = 8; d <= 13; d++) {
|
||||
const day = new Date(NOW.getTime() - d * DAY).toISOString().slice(0, 10);
|
||||
rows.push(rec(`${day}T06:00:00Z`, 72, "WHOOP"));
|
||||
}
|
||||
const trigger = await findTriggerForUser(
|
||||
recoveryPrisma(rows),
|
||||
baseUser,
|
||||
NOW,
|
||||
VITALS_ONLY,
|
||||
);
|
||||
expect(trigger).toBe("score");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildCoachNudgePayload", () => {
|
||||
it("resolves the German payload", () => {
|
||||
const { title, body } = buildCoachNudgePayload("bp", "de");
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* v1.16.16 (iOS #17) — `runWhoopResourceSync` dispatch tests (mocked syncs).
|
||||
*
|
||||
* The webhook now carries the resource id; the worker must do a targeted
|
||||
* fetch-by-id refresh when it is present, fall back to the per-user collection
|
||||
* walk when a webhook job omits the id, and walk every connection only on the
|
||||
* cron tick (no userId on any job). One user's failure never starves the
|
||||
* cohort.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Job } from "pg-boss";
|
||||
|
||||
const {
|
||||
syncUserWorkout,
|
||||
syncWhoopWorkoutById,
|
||||
findManyMock,
|
||||
} = vi.hoisted(() => ({
|
||||
syncUserWorkout: vi.fn<(...a: unknown[]) => Promise<number>>(async () => 0),
|
||||
syncWhoopWorkoutById: vi.fn<(...a: unknown[]) => Promise<number>>(
|
||||
async () => 0,
|
||||
),
|
||||
findManyMock: vi.fn<(...a: unknown[]) => Promise<Array<{ userId: string }>>>(
|
||||
async () => [],
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/whoop/sync-recovery", () => ({
|
||||
syncUserRecovery: vi.fn(async () => 0),
|
||||
syncWhoopRecoveryById: vi.fn(async () => 0),
|
||||
}));
|
||||
vi.mock("@/lib/whoop/sync-sleep", () => ({
|
||||
syncUserSleep: vi.fn(async () => 0),
|
||||
syncWhoopSleepById: vi.fn(async () => 0),
|
||||
}));
|
||||
vi.mock("@/lib/whoop/sync-cycle", () => ({
|
||||
syncUserCycle: vi.fn(async () => 0),
|
||||
}));
|
||||
vi.mock("@/lib/whoop/sync-workout", () => ({
|
||||
syncUserWorkout: (...a: unknown[]) => syncUserWorkout(...a),
|
||||
syncWhoopWorkoutById: (...a: unknown[]) => syncWhoopWorkoutById(...a),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/jobs/worker-status", () => ({ recordError: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/logging/background", () => ({
|
||||
withBackgroundEvent: async (
|
||||
_name: string,
|
||||
fn: (evt: {
|
||||
setBackground: () => void;
|
||||
setError: () => void;
|
||||
addWarning: () => void;
|
||||
}) => Promise<void>,
|
||||
) =>
|
||||
fn({
|
||||
setBackground: vi.fn(),
|
||||
setError: vi.fn(),
|
||||
addWarning: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../reminder/shared", () => ({
|
||||
getWorkerPrisma: () => ({
|
||||
whoopConnection: { findMany: (...a: unknown[]) => findManyMock(...a) },
|
||||
}),
|
||||
}));
|
||||
|
||||
import { handleWhoopWorkoutSync } from "../reminder/whoop-sync";
|
||||
|
||||
function job(data: { userId?: string; resourceId?: string }): Job<{
|
||||
userId?: string;
|
||||
resourceId?: string;
|
||||
}> {
|
||||
return { data } as Job<{ userId?: string; resourceId?: string }>;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("runWhoopResourceSync dispatch", () => {
|
||||
it("a webhook job with a resourceId does a targeted fetch-by-id refresh", async () => {
|
||||
await handleWhoopWorkoutSync([job({ userId: "u1", resourceId: "w-9" })]);
|
||||
|
||||
expect(syncWhoopWorkoutById).toHaveBeenCalledWith("u1", "w-9");
|
||||
expect(syncUserWorkout).not.toHaveBeenCalled();
|
||||
expect(findManyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a webhook job without a resourceId falls back to the per-user collection", async () => {
|
||||
await handleWhoopWorkoutSync([job({ userId: "u1" })]);
|
||||
|
||||
expect(syncUserWorkout).toHaveBeenCalledWith("u1");
|
||||
expect(syncWhoopWorkoutById).not.toHaveBeenCalled();
|
||||
expect(findManyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a cron tick (no userId) walks every connection's collection", async () => {
|
||||
findManyMock.mockResolvedValue([{ userId: "a" }, { userId: "b" }]);
|
||||
|
||||
await handleWhoopWorkoutSync([job({})]);
|
||||
|
||||
expect(findManyMock).toHaveBeenCalled();
|
||||
expect(syncUserWorkout).toHaveBeenCalledTimes(2);
|
||||
expect(syncWhoopWorkoutById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("one user's failure never starves the rest of the cohort", async () => {
|
||||
findManyMock.mockResolvedValue([{ userId: "a" }, { userId: "b" }]);
|
||||
syncUserWorkout.mockRejectedValueOnce(new Error("boom for a"));
|
||||
|
||||
await expect(handleWhoopWorkoutSync([job({})])).resolves.toBeUndefined();
|
||||
// Both users were attempted despite the first throwing.
|
||||
expect(syncUserWorkout).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -77,6 +77,7 @@ import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { defaultLocale, locales } from "@/lib/i18n/config";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { resolveCanonicalRecovery } from "@/lib/insights/derived/recovery-resolve";
|
||||
// Trend thresholds shared with the dashboard hero's verdict resolver
|
||||
// live in a client-safe leaf module; re-exported below so server-side
|
||||
// imports keep their path.
|
||||
@@ -434,6 +435,7 @@ export async function findTriggerForUser(
|
||||
dateOfBirth: Date | null;
|
||||
gender: string | null;
|
||||
thresholdsJson: unknown;
|
||||
timezone?: string | null;
|
||||
},
|
||||
now: Date,
|
||||
groups: Record<CoachNudgeTriggerGroup, boolean> = {
|
||||
@@ -497,16 +499,22 @@ export async function findTriggerForUser(
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Recovery score falling sharply week-over-week.
|
||||
const scores = await prisma.measurement.findMany({
|
||||
// 3) Recovery score falling sharply week-over-week. RECOVERY_SCORE is
|
||||
// written by TWO sources (WHOOP-native + the COMPUTED proxy); reading
|
||||
// the raw rows would blend both series into the trend windows and let a
|
||||
// single night appear twice. Resolve to the ONE canonical row per night
|
||||
// (WHOOP wins when present) before the recent/prior split — the same
|
||||
// "one number, one engine" rule the wellness tile applies.
|
||||
const scoreRows = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
type: "RECOVERY_SCORE",
|
||||
deletedAt: null,
|
||||
measuredAt: { gte: fourteenDaysAgo, lte: now },
|
||||
},
|
||||
select: { value: true, measuredAt: true },
|
||||
select: { value: true, measuredAt: true, source: true },
|
||||
});
|
||||
const scores = resolveCanonicalRecovery(scoreRows, user.timezone ?? null);
|
||||
const recent = scores
|
||||
.filter((m) => m.measuredAt >= sevenDaysAgo)
|
||||
.map((m) => m.value);
|
||||
@@ -691,6 +699,7 @@ export async function runCoachNudgeTick(
|
||||
dateOfBirth: true,
|
||||
gender: true,
|
||||
thresholdsJson: true,
|
||||
timezone: true,
|
||||
aiProvider: true,
|
||||
aiProviderChain: true,
|
||||
aiAnthropicKeyEncrypted: true,
|
||||
|
||||
@@ -7,10 +7,19 @@
|
||||
import { type Job } from "pg-boss";
|
||||
import { recordError } from "@/lib/jobs/worker-status";
|
||||
import { withBackgroundEvent } from "@/lib/logging/background";
|
||||
import { syncUserRecovery } from "@/lib/whoop/sync-recovery";
|
||||
import { syncUserSleep as syncWhoopSleep } from "@/lib/whoop/sync-sleep";
|
||||
import {
|
||||
syncUserRecovery,
|
||||
syncWhoopRecoveryById,
|
||||
} from "@/lib/whoop/sync-recovery";
|
||||
import {
|
||||
syncUserSleep as syncWhoopSleep,
|
||||
syncWhoopSleepById,
|
||||
} from "@/lib/whoop/sync-sleep";
|
||||
import { syncUserCycle } from "@/lib/whoop/sync-cycle";
|
||||
import { syncUserWorkout } from "@/lib/whoop/sync-workout";
|
||||
import {
|
||||
syncUserWorkout,
|
||||
syncWhoopWorkoutById,
|
||||
} from "@/lib/whoop/sync-workout";
|
||||
import { getWorkerPrisma } from "./shared";
|
||||
|
||||
/**
|
||||
@@ -18,34 +27,59 @@ import { getWorkerPrisma } from "./shared";
|
||||
* WHOOP sync queue:
|
||||
*
|
||||
* 1. Webhook (`recovery.updated` / `sleep.updated` / `workout.updated`) —
|
||||
* payload carries `userId`, the handler syncs that one user.
|
||||
* payload carries `userId` and (v1.16.16) the `resourceId`; the handler
|
||||
* does a targeted fetch-by-id refresh of that single record.
|
||||
* 2. Cron — payload has no `userId`; the handler iterates every WHOOP
|
||||
* connection and re-syncs each, catching dropped webhook deliveries.
|
||||
* Cycle has no webhook, so its cron is the sole driver.
|
||||
* connection and re-syncs each collection, catching dropped webhook
|
||||
* deliveries. Cycle has no webhook, so its cron is the sole driver.
|
||||
*
|
||||
* A webhook job that carries a `userId` but no `resourceId` (a legacy in-flight
|
||||
* job, or a delivery that omitted the id) falls back to the per-user collection
|
||||
* walk rather than touching every connection.
|
||||
*/
|
||||
export interface WhoopSyncPayload {
|
||||
userId?: string;
|
||||
/** v1.16.16 — webhook resource id for a targeted fetch-by-id refresh. */
|
||||
resourceId?: string;
|
||||
}
|
||||
|
||||
/** A per-user collection walk (cron + the no-id webhook fallback). */
|
||||
type CollectionSync = (userId: string) => Promise<number>;
|
||||
/** A webhook-driven single-record refresh by resource id. */
|
||||
type ByIdSync = (userId: string, resourceId: string) => Promise<number>;
|
||||
|
||||
/**
|
||||
* Shared driver for the per-resource WHOOP sync handlers. Resolves the target
|
||||
* set (per-user from the webhook payload, or every connection on the cron
|
||||
* tick) and runs `syncFn` per user. One user's parked-at-reauth state never
|
||||
* starves the rest of the cohort on the cron path.
|
||||
* Shared driver for the per-resource WHOOP sync handlers.
|
||||
*
|
||||
* - A webhook job with a `resourceId` runs the targeted `byIdFn` (when the
|
||||
* resource supports fetch-by-id) — landing the exact record immediately.
|
||||
* - A webhook job with a `userId` but no `resourceId` runs the per-user
|
||||
* collection `syncFn`.
|
||||
* - A cron tick (no `userId` on any job) walks every connection's collection.
|
||||
*
|
||||
* One user's parked-at-reauth state never starves the rest of the cohort.
|
||||
*/
|
||||
export async function runWhoopResourceSync(
|
||||
taskName: string,
|
||||
jobs: Job<WhoopSyncPayload>[],
|
||||
syncFn: (userId: string) => Promise<number>,
|
||||
syncFn: CollectionSync,
|
||||
byIdFn?: ByIdSync,
|
||||
): Promise<void> {
|
||||
await withBackgroundEvent(taskName, async (evt) => {
|
||||
const prisma = getWorkerPrisma();
|
||||
try {
|
||||
const targets: Array<{ userId: string }> = [];
|
||||
// (userId, optional resourceId) work items from the webhook payloads.
|
||||
const targets: Array<{ userId: string; resourceId?: string }> = [];
|
||||
for (const job of jobs) {
|
||||
if (job.data?.userId) targets.push({ userId: job.data.userId });
|
||||
if (job.data?.userId) {
|
||||
targets.push({
|
||||
userId: job.data.userId,
|
||||
resourceId: job.data.resourceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (targets.length === 0) {
|
||||
// Cron tick — re-walk every connection's collection.
|
||||
const connections = await prisma.whoopConnection.findMany({
|
||||
select: { userId: true },
|
||||
});
|
||||
@@ -55,9 +89,12 @@ export async function runWhoopResourceSync(
|
||||
|
||||
let usersSynced = 0;
|
||||
let measurementsImported = 0;
|
||||
for (const { userId } of targets) {
|
||||
for (const { userId, resourceId } of targets) {
|
||||
try {
|
||||
measurementsImported += await syncFn(userId);
|
||||
measurementsImported +=
|
||||
resourceId && byIdFn
|
||||
? await byIdFn(userId, resourceId)
|
||||
: await syncFn(userId);
|
||||
usersSynced++;
|
||||
} catch (err) {
|
||||
evt.addWarning(`${taskName} failed for user ${userId}: ${err}`);
|
||||
@@ -85,17 +122,29 @@ export function handleWhoopRecoverySync(jobs: Job<WhoopSyncPayload>[]) {
|
||||
"job.whoop_recovery_sync",
|
||||
jobs,
|
||||
syncUserRecovery,
|
||||
syncWhoopRecoveryById,
|
||||
);
|
||||
}
|
||||
|
||||
export function handleWhoopSleepSync(jobs: Job<WhoopSyncPayload>[]) {
|
||||
return runWhoopResourceSync("job.whoop_sleep_sync", jobs, syncWhoopSleep);
|
||||
return runWhoopResourceSync(
|
||||
"job.whoop_sleep_sync",
|
||||
jobs,
|
||||
syncWhoopSleep,
|
||||
syncWhoopSleepById,
|
||||
);
|
||||
}
|
||||
|
||||
export function handleWhoopWorkoutSync(jobs: Job<WhoopSyncPayload>[]) {
|
||||
return runWhoopResourceSync("job.whoop_workout_sync", jobs, syncUserWorkout);
|
||||
return runWhoopResourceSync(
|
||||
"job.whoop_workout_sync",
|
||||
jobs,
|
||||
syncUserWorkout,
|
||||
syncWhoopWorkoutById,
|
||||
);
|
||||
}
|
||||
|
||||
export function handleWhoopCycleSync(jobs: Job<WhoopSyncPayload>[]) {
|
||||
// Cycle has no webhook (poll-only), so no fetch-by-id path.
|
||||
return runWhoopResourceSync("job.whoop_cycle_sync", jobs, syncUserCycle);
|
||||
}
|
||||
@@ -200,7 +200,7 @@ const sleepSessionResource = z.object({
|
||||
inBedMinutes: z.number().int().nonnegative().nullable(),
|
||||
awakeMinutes: z.number().int().nonnegative().nullable(),
|
||||
awakenings: z.number().int().nonnegative(),
|
||||
stages: z.record(sleepStageEnum, z.number()),
|
||||
stages: z.record(sleepStageEnum, z.number().int().nonnegative()),
|
||||
segments: z.array(sleepSegmentResource),
|
||||
});
|
||||
|
||||
@@ -216,7 +216,112 @@ const sleepNightResponse = z
|
||||
"One reconstructed sleep night: the main session's hypnogram segments + breakdown, plus same-wake-day naps surfaced separately.",
|
||||
});
|
||||
|
||||
// ── Time-series adapter (iOS chart source) ───────────────────────────
|
||||
//
|
||||
// v1.16.16 — documents the long-shipped `GET /api/measurements/series`
|
||||
// the iOS charts read off. The response is described AS-IS: `points`
|
||||
// carry `id`/`at`/`value`/`secondary` (the route's wire field names),
|
||||
// not the canonical `measuredAt`. `secondary` pairs the diastolic value
|
||||
// for `bloodPressure`; `sleepStages` is per-night stage hours for `sleep`
|
||||
// and null otherwise. `unit` is the resolved per-kind token (glucose +
|
||||
// sleep resolve at request time to the user's display unit / hours).
|
||||
const seriesKindEnum = z.enum([
|
||||
"weight",
|
||||
"bloodPressure",
|
||||
"pulse",
|
||||
"bodyFat",
|
||||
"glucose",
|
||||
"sleep",
|
||||
"steps",
|
||||
"totalBodyWater",
|
||||
"boneMass",
|
||||
"oxygenSaturation",
|
||||
"restingHeartRate",
|
||||
"heartRateVariability",
|
||||
"vo2Max",
|
||||
]);
|
||||
|
||||
const seriesQuerySchema = z.object({
|
||||
kind: seriesKindEnum,
|
||||
days: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(3650)
|
||||
.optional()
|
||||
.default(30)
|
||||
.describe(
|
||||
"Look-back window in days (1..3650, default 30). `sleep` is internally capped to 365 regardless of the requested range.",
|
||||
),
|
||||
});
|
||||
|
||||
const seriesPointSchema = z.object({
|
||||
id: z.string().describe("Measurement row id, or `sleep:<wake-day>` for a sleep night."),
|
||||
at: z.iso.datetime({ offset: true }).describe("Point timestamp (ISO-8601)."),
|
||||
value: z.number().describe("Primary value in the top-level `unit`."),
|
||||
secondary: z
|
||||
.number()
|
||||
.nullable()
|
||||
.describe(
|
||||
"Diastolic value for `kind=bloodPressure` (paired within ±5 min); null for every other kind.",
|
||||
),
|
||||
sleepStages: z
|
||||
.record(z.string(), z.number())
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"Per-stage hours for a `kind=sleep` night (CORE/DEEP/REM/…); null/absent for non-sleep kinds.",
|
||||
),
|
||||
});
|
||||
|
||||
const seriesResponse = z
|
||||
.object({
|
||||
kind: seriesKindEnum,
|
||||
unit: z
|
||||
.string()
|
||||
.describe(
|
||||
"Resolved unit token for `value`. `glucose` follows the user's mg/dL|mmol/L preference; `sleep` is `h` (per-night time-asleep in hours).",
|
||||
),
|
||||
points: z.array(seriesPointSchema),
|
||||
stats: z
|
||||
.object({
|
||||
mean: z.number(),
|
||||
min: z.number(),
|
||||
max: z.number(),
|
||||
stdDev: z.number(),
|
||||
count: z.number().int().nonnegative(),
|
||||
})
|
||||
.describe("Summary over the returned points; all-zero when empty."),
|
||||
})
|
||||
.meta({
|
||||
id: "MeasurementsSeriesResponse",
|
||||
description:
|
||||
"iOS-friendly per-kind time series: one point per reading (or per reconstructed night for `sleep`), an explicit `unit` token, and a summary `stats` block.",
|
||||
});
|
||||
|
||||
export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/measurements/series": {
|
||||
get: {
|
||||
tags: ["Measurements"],
|
||||
summary: "Per-kind time series (iOS chart source)",
|
||||
description:
|
||||
"Maps a camelCase `kind` to the canonical MeasurementType(s) and returns an ordered point series with an explicit `unit` token and a summary `stats` block. `bloodPressure` pairs systolic + diastolic (`secondary`) within ±5 min so one fetch renders the dual-line chart; `sleep` collapses per-stage rows into one per-night point carrying time-asleep in hours (`sleepStages` holds the per-stage breakdown) and is internally capped to 365 days. `glucose` values + `unit` resolve to the user's mg/dL|mmol/L preference. Auth via cookie or Bearer.",
|
||||
requestParams: {
|
||||
query: seriesQuerySchema,
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Resolved series.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(seriesResponse, "GetMeasurementsSeriesResponse"),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/measurements": {
|
||||
get: {
|
||||
tags: ["Measurements"],
|
||||
|
||||
@@ -215,7 +215,73 @@ const profileUpdateResponse = z
|
||||
"Profile echo returned by the PATCH/PUT update path. Reports KVNR presence as a boolean rather than re-emitting the decrypted value.",
|
||||
});
|
||||
|
||||
// v1.16.16 — AI-provider read surface. iOS gates Coach visibility off
|
||||
// `aiAvailable` + `managedBy` so a server-managed (operator-key) provider
|
||||
// is no longer invisible to the client. The response reports key PRESENCE
|
||||
// + a 4-char masked preview only; no plaintext key or admin endpoint is
|
||||
// ever surfaced. `managedBy` reports the resolved provider origin: `user`
|
||||
// (BYOK), `local` (self-hosted Ollama/LM Studio), `server` (operator's
|
||||
// shared key), or null when no provider can serve the caller.
|
||||
const aiProviderResponse = z
|
||||
.object({
|
||||
provider: z
|
||||
.enum(["OPENAI", "ANTHROPIC", "LOCAL", "CHATGPT_OAUTH"])
|
||||
.nullable()
|
||||
.describe("The user's selected provider, or null when none is set."),
|
||||
model: z.string().nullable(),
|
||||
baseUrl: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("Custom base URL (LOCAL provider only); null otherwise."),
|
||||
aiAvailable: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"True when ANY provider can serve this user — including the operator's server-managed key when the user set none. iOS keys Coach visibility off this.",
|
||||
),
|
||||
managedBy: z
|
||||
.enum(["user", "local", "server"])
|
||||
.nullable()
|
||||
.describe(
|
||||
"Resolved provider origin: `user` (BYOK), `local` (self-hosted), `server` (operator key), or null when no provider is available.",
|
||||
),
|
||||
hasAnthropicKey: z.boolean(),
|
||||
anthropicKeyPreview: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("`...` + last 4 chars of the stored Anthropic key, or null."),
|
||||
hasLocalKey: z.boolean(),
|
||||
hasOpenaiKey: z.boolean(),
|
||||
openaiKeyPreview: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("`...` + last 4 chars of the stored OpenAI key, or null."),
|
||||
})
|
||||
.meta({
|
||||
id: "AiProviderResponse",
|
||||
description:
|
||||
"The calling user's AI-provider configuration plus the effective availability (`aiAvailable` + `managedBy`). Reports key presence + a masked preview only — never a plaintext key.",
|
||||
});
|
||||
|
||||
export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/user/ai-provider": {
|
||||
get: {
|
||||
tags: ["Auth"],
|
||||
summary: "Read the calling user's AI-provider configuration",
|
||||
description:
|
||||
"Returns the user's selected provider/model/baseUrl plus the effective availability: `aiAvailable` is true when any provider can serve the user (BYOK, self-hosted, or the operator's server-managed key), and `managedBy` reports which. iOS gates Coach visibility off these two fields. Key material is reported as presence + a 4-char masked preview only.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Resolved AI-provider configuration.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(aiProviderResponse, "GetAiProviderResponse"),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/auth/me/coach-prefs": {
|
||||
get: {
|
||||
tags: ["Auth"],
|
||||
|
||||
@@ -87,6 +87,25 @@ export async function checkAnalyticsReadRateLimit(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.16.16 — per-user ceiling for the AI-consent surfaces
|
||||
* (`POST /api/consent/ai`, `POST /api/consent/ai/web`,
|
||||
* `GET/DELETE /api/consent/ai/latest`). The web client calls the heal
|
||||
* endpoint on every AI-settings mount and the iOS toggle hits grant/revoke
|
||||
* per flip, so the bucket is generous — 20/min only caps a scripted loop or
|
||||
* a stolen session pinning the receipts table, mirroring the coach-chat
|
||||
* ceiling. One shared bucket keyed by `userId` (not per-route) keeps the cap
|
||||
* meaningful when a loop rotates across the four endpoints.
|
||||
*/
|
||||
const CONSENT_LIMIT = 20;
|
||||
const CONSENT_WINDOW_MS = 60 * 1000;
|
||||
|
||||
export async function checkConsentRateLimit(
|
||||
userId: string,
|
||||
): Promise<RateLimitResult> {
|
||||
return checkRateLimit(`consent:${userId}`, CONSENT_LIMIT, CONSENT_WINDOW_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.43 W13 M-4 — anonymous/trust-violation bucket key shared by every
|
||||
* caller of `checkAuthSurfaceRateLimit`. When `TRUST_PROXY_HOPS` and the
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* v1.16.16 — regression guard pinning rollup/live-SQL mean parity.
|
||||
*
|
||||
* The window mean recomposed from rollup buckets is the count-weighted
|
||||
* `Σ(count_i·mean_i) / Σcount_i` (DAY: `aggregateBuckets`; WMY:
|
||||
* `aggregateWmyBuckets`). Each bucket mean is itself a per-granularity
|
||||
* `AVG(raw)` minted by the writer via `GROUP BY date_trunc(...)` over
|
||||
* RAW rows — so the count-weighted recompose is algebraically the flat
|
||||
* `AVG(raw)` over the whole window, which is exactly what the live
|
||||
* fallback (`summaries-slice.ts:computeFromLiveAggregate` → `AVG(value)`)
|
||||
* returns.
|
||||
*
|
||||
* This test pins that equality for a seeded set spanning multiple
|
||||
* DAY / WEEK / MONTH buckets with UNEVEN per-day counts — the regime
|
||||
* where mean-of-rows and mean-of-daily-means genuinely diverge — so a
|
||||
* future change can't silently fold coarser buckets from daily means
|
||||
* (an equal-weight average) and reintroduce the divergence.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { aggregateBuckets, type DailyMeanRow } from "../measurement-read";
|
||||
import {
|
||||
aggregateWmyBuckets,
|
||||
type RollupBucketRow,
|
||||
} from "../measurement-read-wmy";
|
||||
|
||||
/** One raw measurement: the day it falls on plus its value. */
|
||||
interface RawRow {
|
||||
day: string; // YYYY-MM-DD, the date_trunc('day') key
|
||||
value: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic seed spanning three calendar months with deliberately
|
||||
* UNEVEN per-day counts. The unevenness is load-bearing: it is the only
|
||||
* condition under which the count-weighted mean (mean-of-rows) and the
|
||||
* equal-weight mean-of-daily-means produce different numbers, so it is
|
||||
* the only seed that can catch a regression that swaps one for the other.
|
||||
*/
|
||||
const RAW: RawRow[] = [
|
||||
// 2026-03-02 — 1 reading
|
||||
{ day: "2026-03-02", value: 90 },
|
||||
// 2026-03-03 — 5 readings (heavy day)
|
||||
{ day: "2026-03-03", value: 80 },
|
||||
{ day: "2026-03-03", value: 81 },
|
||||
{ day: "2026-03-03", value: 82 },
|
||||
{ day: "2026-03-03", value: 83 },
|
||||
{ day: "2026-03-03", value: 84 },
|
||||
// 2026-03-17 — 2 readings (different WEEK + still March MONTH)
|
||||
{ day: "2026-03-17", value: 78 },
|
||||
{ day: "2026-03-17", value: 79 },
|
||||
// 2026-04-04 — 4 readings (April MONTH)
|
||||
{ day: "2026-04-04", value: 70 },
|
||||
{ day: "2026-04-04", value: 71 },
|
||||
{ day: "2026-04-04", value: 72 },
|
||||
{ day: "2026-04-04", value: 73 },
|
||||
// 2026-04-05 — 1 reading
|
||||
{ day: "2026-04-05", value: 100 },
|
||||
// 2026-05-09 — 3 readings (May MONTH)
|
||||
{ day: "2026-05-09", value: 60 },
|
||||
{ day: "2026-05-09", value: 62 },
|
||||
{ day: "2026-05-09", value: 64 },
|
||||
];
|
||||
|
||||
/** Flat `AVG(value)` over every raw row — the live-fallback contract. */
|
||||
function liveAvg(rows: RawRow[]): number {
|
||||
const sum = rows.reduce((acc, r) => acc + r.value, 0);
|
||||
return sum / rows.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint per-day buckets exactly as the writer does: `GROUP BY
|
||||
* date_trunc('day', …)` then `count` + `AVG(value)` per group. This is
|
||||
* the DAY-rollup row the reader consumes.
|
||||
*/
|
||||
function dayBuckets(rows: RawRow[]): DailyMeanRow[] {
|
||||
const byDay = new Map<string, number[]>();
|
||||
for (const r of rows) {
|
||||
const list = byDay.get(r.day);
|
||||
if (list) list.push(r.value);
|
||||
else byDay.set(r.day, [r.value]);
|
||||
}
|
||||
return [...byDay.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([day, values]) => ({
|
||||
day: new Date(`${day}T00:00:00.000Z`),
|
||||
count: values.length,
|
||||
mean: values.reduce((a, v) => a + v, 0) / values.length,
|
||||
minValue: Math.min(...values),
|
||||
maxValue: Math.max(...values),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint per-month buckets the same way the WEEK/MONTH/YEAR writer does:
|
||||
* re-aggregate RAW (not fold DAY) under `GROUP BY date_trunc('month')`.
|
||||
*/
|
||||
function monthBuckets(rows: RawRow[]): RollupBucketRow[] {
|
||||
const byMonth = new Map<string, number[]>();
|
||||
for (const r of rows) {
|
||||
const month = r.day.slice(0, 7); // YYYY-MM
|
||||
const list = byMonth.get(month);
|
||||
if (list) list.push(r.value);
|
||||
else byMonth.set(month, [r.value]);
|
||||
}
|
||||
return [...byMonth.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([month, values]) => ({
|
||||
bucketStart: new Date(`${month}-01T00:00:00.000Z`),
|
||||
count: values.length,
|
||||
mean: values.reduce((a, v) => a + v, 0) / values.length,
|
||||
sd: 0,
|
||||
slope: 0,
|
||||
r2: 0,
|
||||
sumValue: null,
|
||||
minValue: Math.min(...values),
|
||||
maxValue: Math.max(...values),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Naive equal-weight mean of the per-day means — the WRONG number. */
|
||||
function meanOfDailyMeans(buckets: { mean: number }[]): number {
|
||||
return buckets.reduce((a, b) => a + b.mean, 0) / buckets.length;
|
||||
}
|
||||
|
||||
describe("rollup ↔ live AVG(raw) mean parity (uneven per-day counts)", () => {
|
||||
it("the seed actually exercises the divergent regime", () => {
|
||||
// Guard the guard: if a future edit flattens the counts so every day
|
||||
// carries the same number of readings, mean-of-rows would coincide
|
||||
// with mean-of-daily-means and this test would pass vacuously.
|
||||
const days = dayBuckets(RAW);
|
||||
const counts = new Set(days.map((d) => d.count));
|
||||
expect(counts.size).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("DAY-bucket recompose equals live AVG(raw), not mean-of-daily-means", () => {
|
||||
const days = dayBuckets(RAW);
|
||||
const recomposed = aggregateBuckets(days);
|
||||
const live = liveAvg(RAW);
|
||||
const naive = meanOfDailyMeans(days);
|
||||
|
||||
// The count-weighted recompose IS the flat AVG over raw rows.
|
||||
expect(recomposed.mean).toBeCloseTo(live, 10);
|
||||
// And it provably differs from the naive equal-weight average — the
|
||||
// exact bug class this guard exists to catch.
|
||||
expect(Math.abs(naive - live)).toBeGreaterThan(1e-6);
|
||||
expect(recomposed.mean).not.toBeCloseTo(naive, 4);
|
||||
// Total count is the raw row count, not the bucket count.
|
||||
expect(recomposed.count).toBe(RAW.length);
|
||||
});
|
||||
|
||||
it("MONTH-bucket recompose equals live AVG(raw), not mean-of-monthly-means", () => {
|
||||
const months = monthBuckets(RAW);
|
||||
const recomposed = aggregateWmyBuckets(months);
|
||||
const live = liveAvg(RAW);
|
||||
const naive = meanOfDailyMeans(months);
|
||||
|
||||
expect(recomposed.mean).toBeCloseTo(live, 10);
|
||||
expect(Math.abs(naive - live)).toBeGreaterThan(1e-6);
|
||||
expect(recomposed.mean).not.toBeCloseTo(naive, 4);
|
||||
expect(recomposed.count).toBe(RAW.length);
|
||||
});
|
||||
|
||||
it("DAY and MONTH recompose agree (coarser tier re-aggregates raw, not folds DAY)", () => {
|
||||
const dayMean = aggregateBuckets(dayBuckets(RAW)).mean;
|
||||
const monthMean = aggregateWmyBuckets(monthBuckets(RAW)).mean;
|
||||
expect(dayMean).not.toBeNull();
|
||||
expect(monthMean).toBeCloseTo(dayMean ?? Number.NaN, 10);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,10 @@ import {
|
||||
WHOOP_PAGE_LIMIT,
|
||||
exchangeCode,
|
||||
fetchBodyMeasurement,
|
||||
fetchRecoveryByCycleId,
|
||||
fetchSleepById,
|
||||
fetchWorkoutById,
|
||||
fetchCycleById,
|
||||
fetchCycles,
|
||||
fetchProfile,
|
||||
fetchRecoveries,
|
||||
@@ -198,6 +202,39 @@ describe("single-object endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetch-by-id (webhook-driven single-record refresh)", () => {
|
||||
it("resolves one record at the documented v2 single-record path", async () => {
|
||||
const cases: Array<[() => Promise<unknown>, string]> = [
|
||||
[() => fetchSleepById("at", "s1"), "/v2/activity/sleep/s1"],
|
||||
[() => fetchWorkoutById("at", "w1"), "/v2/activity/workout/w1"],
|
||||
[() => fetchCycleById("at", "123"), "/v2/cycle/123"],
|
||||
[() => fetchRecoveryByCycleId("at", "123"), "/v2/cycle/123/recovery"],
|
||||
];
|
||||
for (const [call, path] of cases) {
|
||||
const fetchMock = installFetchMock([{ status: 200, body: { id: "x" } }]);
|
||||
await call();
|
||||
const [url] = fetchMock.mock.calls[0] as unknown as [string];
|
||||
expect(url).toContain(path);
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns the single record body (one workout by id)", async () => {
|
||||
installFetchMock([
|
||||
{ status: 200, body: { id: "w1", start: "s", end: "e" } },
|
||||
]);
|
||||
const w = await fetchWorkoutById("at", "w1");
|
||||
expect(w.id).toBe("w1");
|
||||
});
|
||||
|
||||
it("url-encodes a resource id with reserved characters", async () => {
|
||||
const fetchMock = installFetchMock([{ status: 200, body: { id: "x" } }]);
|
||||
await fetchWorkoutById("at", "a/b?c");
|
||||
const [url] = fetchMock.mock.calls[0] as unknown as [string];
|
||||
expect(url).toContain("/v2/activity/workout/a%2Fb%3Fc");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapRecovery", () => {
|
||||
const base: WhoopRecovery = {
|
||||
cycle_id: 1,
|
||||
|
||||
@@ -9,30 +9,33 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const fetchWorkouts = vi.fn();
|
||||
const fetchWorkoutById = vi.fn();
|
||||
|
||||
vi.mock("../client", () => ({
|
||||
fetchWorkouts: (...a: unknown[]) => fetchWorkouts(...a),
|
||||
fetchWorkoutById: (...a: unknown[]) => fetchWorkoutById(...a),
|
||||
KJ_TO_KCAL: 0.239006,
|
||||
}));
|
||||
|
||||
const getValidToken = vi.fn();
|
||||
const markSynced = vi.fn();
|
||||
const markResourceSynced = vi.fn();
|
||||
vi.mock("../sync", async () => {
|
||||
const actual = await vi.importActual<typeof import("../sync")>("../sync");
|
||||
return {
|
||||
...actual,
|
||||
getValidToken: (...a: unknown[]) => getValidToken(...a),
|
||||
markSynced: (...a: unknown[]) => markSynced(...a),
|
||||
markResourceSynced: (...a: unknown[]) => markResourceSynced(...a),
|
||||
};
|
||||
});
|
||||
|
||||
const whoopConnectionFindUnique = vi.fn();
|
||||
const workoutUpsert = vi.fn();
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
whoopConnection: {
|
||||
findUnique: (...a: unknown[]) => whoopConnectionFindUnique(...a),
|
||||
},
|
||||
workout: { upsert: vi.fn() },
|
||||
workout: { upsert: (...a: unknown[]) => workoutUpsert(...a) },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -40,8 +43,11 @@ vi.mock("@/lib/logging/context", () => ({
|
||||
getEvent: () => ({ addWarning: vi.fn() }),
|
||||
}));
|
||||
|
||||
import { syncUserWorkout } from "../sync-workout";
|
||||
import { WHOOP_RECOVERY_SLEEP_OVERLAP_MS } from "../sync";
|
||||
import { syncUserWorkout, syncWhoopWorkoutById } from "../sync-workout";
|
||||
import {
|
||||
WHOOP_FULL_SYNC_ANCHOR,
|
||||
WHOOP_RECOVERY_SLEEP_OVERLAP_MS,
|
||||
} from "../sync";
|
||||
|
||||
const LAST_SYNCED = new Date("2026-06-10T12:00:00.000Z");
|
||||
|
||||
@@ -51,8 +57,12 @@ beforeEach(() => {
|
||||
accessToken: "tok",
|
||||
connection: { id: "c1", whoopUserId: "w1" },
|
||||
});
|
||||
whoopConnectionFindUnique.mockResolvedValue({ lastSyncedAt: LAST_SYNCED });
|
||||
markSynced.mockResolvedValue(undefined);
|
||||
// No per-resource cursor yet → falls back to the shared lastSyncedAt.
|
||||
whoopConnectionFindUnique.mockResolvedValue({
|
||||
lastSyncedAt: LAST_SYNCED,
|
||||
resourceCursors: null,
|
||||
});
|
||||
markResourceSynced.mockResolvedValue(undefined);
|
||||
fetchWorkouts.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
@@ -70,9 +80,81 @@ describe("syncUserWorkout — late-synced workout overlap window", () => {
|
||||
expect(arg.start!.getTime()).toBeLessThanOrEqual(sixHoursBefore);
|
||||
});
|
||||
|
||||
it("fullSync passes no start (deep backfill anchor handles history)", async () => {
|
||||
it("fullSync anchors at the deep-history anchor, not the cursor", async () => {
|
||||
await syncUserWorkout("user-1", { fullSync: true });
|
||||
const arg = fetchWorkouts.mock.calls[0]![1] as { start?: Date };
|
||||
expect(arg.start).toBeUndefined();
|
||||
expect(arg.start).toEqual(WHOOP_FULL_SYNC_ANCHOR);
|
||||
});
|
||||
|
||||
it("uses the per-resource workout cursor when present, ignoring siblings", async () => {
|
||||
const workoutCursor = new Date("2026-06-04T12:00:00.000Z");
|
||||
whoopConnectionFindUnique.mockResolvedValue({
|
||||
// recovery synced far more recently — must NOT pull the workout window
|
||||
// forward.
|
||||
lastSyncedAt: new Date("2026-06-12T00:00:00.000Z"),
|
||||
resourceCursors: {
|
||||
recovery: "2026-06-12T00:00:00.000Z",
|
||||
workout: workoutCursor.toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
await syncUserWorkout("user-1");
|
||||
|
||||
const arg = fetchWorkouts.mock.calls[0]![1] as { start?: Date };
|
||||
expect(arg.start!.getTime()).toBe(
|
||||
workoutCursor.getTime() - WHOOP_RECOVERY_SLEEP_OVERLAP_MS,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("syncWhoopWorkoutById — webhook fetch-by-id", () => {
|
||||
const SCORED_WORKOUT = {
|
||||
id: "w-123",
|
||||
start: "2026-06-14T07:00:00.000Z",
|
||||
end: "2026-06-14T08:00:00.000Z",
|
||||
sport_id: 1,
|
||||
score: {
|
||||
strain: 12.3,
|
||||
average_heart_rate: 130,
|
||||
max_heart_rate: 165,
|
||||
kilojoule: 1500,
|
||||
percent_recorded: 99,
|
||||
},
|
||||
};
|
||||
|
||||
it("resolves the ONE record by id and upserts it (no collection walk)", async () => {
|
||||
fetchWorkoutById.mockResolvedValue(SCORED_WORKOUT);
|
||||
workoutUpsert.mockResolvedValue({});
|
||||
|
||||
const imported = await syncWhoopWorkoutById("user-1", "w-123");
|
||||
|
||||
expect(imported).toBe(1);
|
||||
// Targeted fetch-by-id, never the collection.
|
||||
expect(fetchWorkoutById).toHaveBeenCalledWith("tok", "w-123");
|
||||
expect(fetchWorkouts).not.toHaveBeenCalled();
|
||||
const upsertArg = workoutUpsert.mock.calls[0]![0];
|
||||
expect(upsertArg.where.userId_source_externalId).toEqual({
|
||||
userId: "user-1",
|
||||
source: "WHOOP",
|
||||
externalId: "w-123",
|
||||
});
|
||||
});
|
||||
|
||||
it("a single-id refresh does NOT advance the resource cursor", async () => {
|
||||
fetchWorkoutById.mockResolvedValue(SCORED_WORKOUT);
|
||||
workoutUpsert.mockResolvedValue({});
|
||||
|
||||
await syncWhoopWorkoutById("user-1", "w-123");
|
||||
|
||||
expect(markResourceSynced).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("an unscored record stores nothing", async () => {
|
||||
fetchWorkoutById.mockResolvedValue({ ...SCORED_WORKOUT, score: null });
|
||||
|
||||
const imported = await syncWhoopWorkoutById("user-1", "w-123");
|
||||
|
||||
expect(imported).toBe(0);
|
||||
expect(workoutUpsert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,7 @@ const {
|
||||
measurement: {
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
$executeRaw: vi.fn(),
|
||||
},
|
||||
refreshAccessTokenMock: vi.fn(),
|
||||
fetchRecoveriesMock: vi.fn(),
|
||||
@@ -88,8 +89,11 @@ import {
|
||||
getValidToken,
|
||||
incrementalStart,
|
||||
isCollectionForbidden,
|
||||
markResourceSynced,
|
||||
resolveResourceCursor,
|
||||
upsertWhoopMeasurements,
|
||||
WHOOP_DEFAULT_OVERLAP_MS,
|
||||
WHOOP_FULL_SYNC_ANCHOR,
|
||||
WHOOP_RECOVERY_SLEEP_OVERLAP_MS,
|
||||
} from "../sync";
|
||||
import { WhoopApiError } from "../response-classifier";
|
||||
@@ -172,8 +176,11 @@ describe("getValidToken — rotating refresh", () => {
|
||||
});
|
||||
|
||||
describe("incrementalStart", () => {
|
||||
it("returns undefined for a full sync", () => {
|
||||
expect(incrementalStart(new Date(), { fullSync: true })).toBeUndefined();
|
||||
it("anchors a full sync at the deep-history anchor, ignoring the cursor", () => {
|
||||
const got = incrementalStart(new Date("2026-06-01T12:00:00Z"), {
|
||||
fullSync: true,
|
||||
});
|
||||
expect(got).toEqual(WHOOP_FULL_SYNC_ANCHOR);
|
||||
});
|
||||
|
||||
it("subtracts the overlap from lastSyncedAt", () => {
|
||||
@@ -191,6 +198,109 @@ describe("incrementalStart", () => {
|
||||
const got = incrementalStart(last);
|
||||
expect(got!.getTime()).toBe(last.getTime() - WHOOP_DEFAULT_OVERLAP_MS);
|
||||
});
|
||||
|
||||
it("a fullSync reaches a span an incremental tick would skip", () => {
|
||||
// Incremental from a recent cursor only looks back the overlap (days); a
|
||||
// fullSync anchors years back, so it covers history the incremental misses.
|
||||
const recentCursor = new Date("2026-06-01T12:00:00Z");
|
||||
const incremental = incrementalStart(recentCursor, {
|
||||
overlapMs: WHOOP_RECOVERY_SLEEP_OVERLAP_MS,
|
||||
});
|
||||
const full = incrementalStart(recentCursor, { fullSync: true });
|
||||
const yearOld = new Date("2025-01-01T00:00:00Z");
|
||||
// A year-old record is BEFORE the incremental start (skipped) but AFTER
|
||||
// the full-sync anchor (covered).
|
||||
expect(yearOld.getTime()).toBeLessThan(incremental!.getTime());
|
||||
expect(yearOld.getTime()).toBeGreaterThan(full!.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveResourceCursor — per-resource cursor", () => {
|
||||
it("prefers the per-resource cursor over the shared lastSyncedAt", () => {
|
||||
const got = resolveResourceCursor(
|
||||
{
|
||||
resourceCursors: { workout: "2026-06-10T00:00:00.000Z" },
|
||||
lastSyncedAt: new Date("2026-06-12T00:00:00.000Z"),
|
||||
},
|
||||
"workout",
|
||||
);
|
||||
expect(got?.toISOString()).toBe("2026-06-10T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("falls back to lastSyncedAt for a resource with no cursor key (legacy)", () => {
|
||||
const shared = new Date("2026-06-12T00:00:00.000Z");
|
||||
const got = resolveResourceCursor(
|
||||
{ resourceCursors: { recovery: "2026-06-10T00:00:00.000Z" }, lastSyncedAt: shared },
|
||||
"workout",
|
||||
);
|
||||
expect(got).toEqual(shared);
|
||||
});
|
||||
|
||||
it("falls back to lastSyncedAt when the column is null", () => {
|
||||
const shared = new Date("2026-06-12T00:00:00.000Z");
|
||||
expect(
|
||||
resolveResourceCursor({ resourceCursors: null, lastSyncedAt: shared }, "sleep"),
|
||||
).toEqual(shared);
|
||||
});
|
||||
|
||||
it("a stalled resource cursor does not advance with a sibling's", () => {
|
||||
// workout last synced a week ago; recovery synced an hour ago. Resolving
|
||||
// workout must still return the OLD workout cursor — a sibling's progress
|
||||
// never drags the stalled resource's window forward.
|
||||
const connection = {
|
||||
resourceCursors: {
|
||||
recovery: "2026-06-12T11:00:00.000Z",
|
||||
workout: "2026-06-05T12:00:00.000Z",
|
||||
},
|
||||
lastSyncedAt: new Date("2026-06-12T11:00:00.000Z"),
|
||||
};
|
||||
const workoutStart = incrementalStart(
|
||||
resolveResourceCursor(connection, "workout"),
|
||||
{ overlapMs: WHOOP_RECOVERY_SLEEP_OVERLAP_MS },
|
||||
);
|
||||
// Workout still re-fetches from a week-old cursor minus the overlap, NOT
|
||||
// from recovery's recent one — the late-synced workout stays in range.
|
||||
expect(workoutStart!.getTime()).toBe(
|
||||
new Date("2026-06-05T12:00:00.000Z").getTime() -
|
||||
WHOOP_RECOVERY_SLEEP_OVERLAP_MS,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("markResourceSynced — atomic, independent cursor advance", () => {
|
||||
it("issues ONE atomic jsonb_set upsert binding the resource key + instant", async () => {
|
||||
prismaMock.$executeRaw.mockResolvedValue(1);
|
||||
|
||||
const at = new Date("2026-06-14T08:00:00.000Z");
|
||||
await markResourceSynced("user1", "workout", at);
|
||||
|
||||
// A single atomic statement, never a read-then-write (which would race the
|
||||
// concurrent sibling resource queues).
|
||||
expect(prismaMock.whoopConnection.findUnique).not.toHaveBeenCalled();
|
||||
expect(prismaMock.whoopConnection.update).not.toHaveBeenCalled();
|
||||
expect(prismaMock.$executeRaw).toHaveBeenCalledTimes(1);
|
||||
|
||||
const call = prismaMock.$executeRaw.mock.calls[0]!;
|
||||
const sql = (call[0] as TemplateStringsArray).join("?");
|
||||
const values = call.slice(1);
|
||||
// Merge-in-place + monotonic last_synced_at + scoped to the user.
|
||||
expect(sql).toContain("jsonb_set");
|
||||
expect(sql).toContain("GREATEST");
|
||||
// Resource key + ISO instant are parameter-bound, not spliced.
|
||||
expect(values).toContain("workout");
|
||||
expect(values).toContain(at.toISOString());
|
||||
expect(values).toContain(at);
|
||||
expect(values).toContain("user1");
|
||||
});
|
||||
|
||||
it("refuses a resource outside the closed whitelist (no statement issued)", async () => {
|
||||
await markResourceSynced(
|
||||
"user1",
|
||||
"evil" as unknown as Parameters<typeof markResourceSynced>[1],
|
||||
new Date(),
|
||||
);
|
||||
expect(prismaMock.$executeRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("upsertWhoopMeasurements — idempotent upsert", () => {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* v1.16.16 (iOS #17) — `processWhoopNotification` enqueue tests (mocked).
|
||||
*
|
||||
* A `*.updated` webhook must enqueue the per-resource sync job carrying the
|
||||
* resource id so the worker can do a targeted fetch-by-id refresh (landing the
|
||||
* exact record immediately rather than waiting for the next overlap window).
|
||||
* The existing 200-always + unknown-user + delete-soft-delete contract is
|
||||
* preserved.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { findFirst, bossSend, updateMany, deleteMany } = vi.hoisted(() => ({
|
||||
findFirst: vi.fn(),
|
||||
bossSend: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
whoopConnection: { findFirst: (...a: unknown[]) => findFirst(...a) },
|
||||
measurement: { updateMany: (...a: unknown[]) => updateMany(...a) },
|
||||
workout: { deleteMany: (...a: unknown[]) => deleteMany(...a) },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/jobs/boss-instance", () => ({
|
||||
getGlobalBoss: () => ({ send: (...a: unknown[]) => bossSend(...a) }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
vi.mock("@/lib/api-response", () => ({ getClientIp: () => "1.2.3.4" }));
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: () => {},
|
||||
getEvent: () => ({ setAuth: vi.fn(), addWarning: vi.fn() }),
|
||||
}));
|
||||
|
||||
import { processWhoopNotification } from "../webhook-handler";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
findFirst.mockResolvedValue({ userId: "user-1" });
|
||||
bossSend.mockResolvedValue("job-id");
|
||||
});
|
||||
|
||||
describe("processWhoopNotification — fetch-by-id enqueue", () => {
|
||||
it("enqueues the workout queue with the resource id", async () => {
|
||||
const res = await processWhoopNotification({
|
||||
user_id: 42,
|
||||
id: "w-123",
|
||||
type: "workout.updated",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(bossSend).toHaveBeenCalledWith("whoop-workout-sync", {
|
||||
userId: "user-1",
|
||||
resourceId: "w-123",
|
||||
});
|
||||
});
|
||||
|
||||
it("carries a numeric resource id (cycle-based recovery) as a string", async () => {
|
||||
await processWhoopNotification({
|
||||
user_id: 42,
|
||||
id: 9981,
|
||||
type: "recovery.updated",
|
||||
});
|
||||
|
||||
expect(bossSend).toHaveBeenCalledWith("whoop-recovery-sync", {
|
||||
userId: "user-1",
|
||||
resourceId: "9981",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 200 for an unknown user without enqueuing", async () => {
|
||||
findFirst.mockResolvedValue(null);
|
||||
|
||||
const res = await processWhoopNotification({
|
||||
user_id: 7,
|
||||
id: "w-1",
|
||||
type: "workout.updated",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(bossSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("soft-deletes on a *.deleted event (no enqueue)", async () => {
|
||||
updateMany.mockResolvedValue({ count: 3 });
|
||||
deleteMany.mockResolvedValue({ count: 1 });
|
||||
|
||||
const res = await processWhoopNotification({
|
||||
user_id: 42,
|
||||
id: "w-123",
|
||||
type: "workout.deleted",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(bossSend).not.toHaveBeenCalled();
|
||||
expect(deleteMany).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -402,6 +402,67 @@ export function fetchWorkouts(
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Single-record reads (webhook-driven fetch-by-id) ──────────
|
||||
// A `*.updated` webhook carries only the resource id, not the data. Re-fetch
|
||||
// that ONE record by id for a targeted refresh — far cheaper than re-walking
|
||||
// the whole collection, and it lands the record immediately rather than on the
|
||||
// next overlap window. WHOOP v2 single-record paths (re-verify at build):
|
||||
// sleep GET /v2/activity/sleep/{sleepId}
|
||||
// workout GET /v2/activity/workout/{workoutId}
|
||||
// cycle GET /v2/cycle/{cycleId}
|
||||
// recovery GET /v2/cycle/{cycleId}/recovery (no recovery-by-recovery-id)
|
||||
// `fetchSingle` throws a `WhoopApiError` on a non-2xx (incl. a 404 for a since-
|
||||
// deleted id) so the caller's existing classify/soft-skip path handles it.
|
||||
|
||||
export function fetchSleepById(
|
||||
accessToken: string,
|
||||
sleepId: string,
|
||||
): Promise<WhoopSleep> {
|
||||
return fetchSingle<WhoopSleep>(
|
||||
`/v2/activity/sleep/${encodeURIComponent(sleepId)}`,
|
||||
accessToken,
|
||||
"fetchSleepById",
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchWorkoutById(
|
||||
accessToken: string,
|
||||
workoutId: string,
|
||||
): Promise<WhoopWorkout> {
|
||||
return fetchSingle<WhoopWorkout>(
|
||||
`/v2/activity/workout/${encodeURIComponent(workoutId)}`,
|
||||
accessToken,
|
||||
"fetchWorkoutById",
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchCycleById(
|
||||
accessToken: string,
|
||||
cycleId: string,
|
||||
): Promise<WhoopCycle> {
|
||||
return fetchSingle<WhoopCycle>(
|
||||
`/v2/cycle/${encodeURIComponent(cycleId)}`,
|
||||
accessToken,
|
||||
"fetchCycleById",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* WHOOP v2 has no recovery-by-recovery-id route — a recovery is read through
|
||||
* its cycle (`GET /v2/cycle/{cycleId}/recovery`). The recovery webhook carries
|
||||
* the cycle id, so the targeted refresh resolves the recovery from there.
|
||||
*/
|
||||
export function fetchRecoveryByCycleId(
|
||||
accessToken: string,
|
||||
cycleId: string,
|
||||
): Promise<WhoopRecovery> {
|
||||
return fetchSingle<WhoopRecovery>(
|
||||
`/v2/cycle/${encodeURIComponent(cycleId)}/recovery`,
|
||||
accessToken,
|
||||
"fetchRecoveryByCycleId",
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchBodyMeasurement(
|
||||
accessToken: string,
|
||||
): Promise<WhoopBodyMeasurement> {
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
getValidToken,
|
||||
incrementalStart,
|
||||
handleCollectionFetchError,
|
||||
markSynced,
|
||||
markResourceSynced,
|
||||
resolveResourceCursor,
|
||||
upsertWhoopMeasurements,
|
||||
type WhoopMeasurementUpsert,
|
||||
} from "./sync";
|
||||
@@ -25,11 +26,11 @@ export async function syncUserCycle(
|
||||
|
||||
const connection = await prisma.whoopConnection.findUnique({
|
||||
where: { userId },
|
||||
select: { lastSyncedAt: true },
|
||||
select: { lastSyncedAt: true, resourceCursors: true },
|
||||
});
|
||||
if (!connection) return 0;
|
||||
|
||||
const start = incrementalStart(connection.lastSyncedAt, {
|
||||
const start = incrementalStart(resolveResourceCursor(connection, "cycle"), {
|
||||
fullSync: opts.fullSync,
|
||||
});
|
||||
|
||||
@@ -54,6 +55,6 @@ export async function syncUserCycle(
|
||||
}
|
||||
|
||||
const imported = await upsertWhoopMeasurements(userId, readings);
|
||||
await markSynced(userId);
|
||||
await markResourceSynced(userId, "cycle");
|
||||
return imported;
|
||||
}
|
||||
@@ -9,12 +9,17 @@
|
||||
* sleep UUID (v2 recovery carries `sleep_id`, not a stable recovery id) so the
|
||||
* externalId is stable across re-scores.
|
||||
*/
|
||||
import { fetchRecoveries, mapRecovery } from "./client";
|
||||
import {
|
||||
fetchRecoveries,
|
||||
fetchRecoveryByCycleId,
|
||||
mapRecovery,
|
||||
} from "./client";
|
||||
import {
|
||||
getValidToken,
|
||||
incrementalStart,
|
||||
handleCollectionFetchError,
|
||||
markSynced,
|
||||
markResourceSynced,
|
||||
resolveResourceCursor,
|
||||
upsertWhoopMeasurements,
|
||||
WHOOP_RECOVERY_SLEEP_OVERLAP_MS,
|
||||
type WhoopMeasurementUpsert,
|
||||
@@ -30,14 +35,17 @@ export async function syncUserRecovery(
|
||||
|
||||
const connection = await prisma.whoopConnection.findUnique({
|
||||
where: { userId },
|
||||
select: { lastSyncedAt: true },
|
||||
select: { lastSyncedAt: true, resourceCursors: true },
|
||||
});
|
||||
if (!connection) return 0;
|
||||
|
||||
const start = incrementalStart(connection.lastSyncedAt, {
|
||||
fullSync: opts.fullSync,
|
||||
overlapMs: WHOOP_RECOVERY_SLEEP_OVERLAP_MS,
|
||||
});
|
||||
const start = incrementalStart(
|
||||
resolveResourceCursor(connection, "recovery"),
|
||||
{
|
||||
fullSync: opts.fullSync,
|
||||
overlapMs: WHOOP_RECOVERY_SLEEP_OVERLAP_MS,
|
||||
},
|
||||
);
|
||||
|
||||
let records: Awaited<ReturnType<typeof fetchRecoveries>>;
|
||||
try {
|
||||
@@ -62,6 +70,43 @@ export async function syncUserRecovery(
|
||||
}
|
||||
|
||||
const imported = await upsertWhoopMeasurements(userId, readings);
|
||||
await markSynced(userId);
|
||||
await markResourceSynced(userId, "recovery");
|
||||
return imported;
|
||||
}
|
||||
|
||||
/**
|
||||
* Webhook-driven targeted refresh: resolve ONE recovery by its cycle id and
|
||||
* upsert its readings, instead of re-walking the whole collection. WHOOP v2
|
||||
* reads a recovery through its cycle (`/v2/cycle/{cycleId}/recovery`), and the
|
||||
* recovery webhook carries the cycle id. An unscored / since-deleted record
|
||||
* yields nothing. Does NOT advance the resource cursor — a single-id refresh
|
||||
* proves nothing about records between the cursor and now, so the cron/overlap
|
||||
* path stays responsible for moving the cursor.
|
||||
*/
|
||||
export async function syncWhoopRecoveryById(
|
||||
userId: string,
|
||||
cycleId: string,
|
||||
): Promise<number> {
|
||||
const tokenInfo = await getValidToken(userId);
|
||||
if (!tokenInfo) return 0;
|
||||
|
||||
let record: Awaited<ReturnType<typeof fetchRecoveryByCycleId>>;
|
||||
try {
|
||||
record = await fetchRecoveryByCycleId(tokenInfo.accessToken, cycleId);
|
||||
} catch (err) {
|
||||
return handleCollectionFetchError("recovery", userId, err);
|
||||
}
|
||||
|
||||
const readings: WhoopMeasurementUpsert[] = [];
|
||||
for (const m of mapRecovery(record)) {
|
||||
readings.push({
|
||||
type: m.type,
|
||||
value: m.value,
|
||||
unit: m.unit,
|
||||
measuredAt: m.measuredAt,
|
||||
externalId: `${record.sleep_id}:${m.fieldTag}`,
|
||||
});
|
||||
}
|
||||
|
||||
return upsertWhoopMeasurements(userId, readings);
|
||||
}
|
||||
@@ -7,12 +7,13 @@
|
||||
* Per-stage rows carry the `sleepStage` axis so the five stage rows for one
|
||||
* night stay distinct under the dedup key. externalId = `<sleep_id>:<fieldTag>`.
|
||||
*/
|
||||
import { fetchSleeps, mapSleep } from "./client";
|
||||
import { fetchSleeps, fetchSleepById, mapSleep } from "./client";
|
||||
import {
|
||||
getValidToken,
|
||||
incrementalStart,
|
||||
handleCollectionFetchError,
|
||||
markSynced,
|
||||
markResourceSynced,
|
||||
resolveResourceCursor,
|
||||
upsertWhoopMeasurements,
|
||||
WHOOP_RECOVERY_SLEEP_OVERLAP_MS,
|
||||
type WhoopMeasurementUpsert,
|
||||
@@ -28,11 +29,11 @@ export async function syncUserSleep(
|
||||
|
||||
const connection = await prisma.whoopConnection.findUnique({
|
||||
where: { userId },
|
||||
select: { lastSyncedAt: true },
|
||||
select: { lastSyncedAt: true, resourceCursors: true },
|
||||
});
|
||||
if (!connection) return 0;
|
||||
|
||||
const start = incrementalStart(connection.lastSyncedAt, {
|
||||
const start = incrementalStart(resolveResourceCursor(connection, "sleep"), {
|
||||
fullSync: opts.fullSync,
|
||||
overlapMs: WHOOP_RECOVERY_SLEEP_OVERLAP_MS,
|
||||
});
|
||||
@@ -59,6 +60,42 @@ export async function syncUserSleep(
|
||||
}
|
||||
|
||||
const imported = await upsertWhoopMeasurements(userId, readings);
|
||||
await markSynced(userId);
|
||||
await markResourceSynced(userId, "sleep");
|
||||
return imported;
|
||||
}
|
||||
|
||||
/**
|
||||
* Webhook-driven targeted refresh: resolve ONE sleep activity by its uuid and
|
||||
* upsert its readings, instead of re-walking the whole collection. An unscored
|
||||
* / since-deleted record yields nothing. Does NOT advance the resource cursor
|
||||
* (a single-id refresh proves nothing about the records between the cursor and
|
||||
* now).
|
||||
*/
|
||||
export async function syncWhoopSleepById(
|
||||
userId: string,
|
||||
sleepId: string,
|
||||
): Promise<number> {
|
||||
const tokenInfo = await getValidToken(userId);
|
||||
if (!tokenInfo) return 0;
|
||||
|
||||
let record: Awaited<ReturnType<typeof fetchSleepById>>;
|
||||
try {
|
||||
record = await fetchSleepById(tokenInfo.accessToken, sleepId);
|
||||
} catch (err) {
|
||||
return handleCollectionFetchError("sleep", userId, err);
|
||||
}
|
||||
|
||||
const readings: WhoopMeasurementUpsert[] = [];
|
||||
for (const m of mapSleep(record)) {
|
||||
readings.push({
|
||||
type: m.type,
|
||||
value: m.value,
|
||||
unit: m.unit,
|
||||
measuredAt: m.measuredAt,
|
||||
externalId: `${record.id}:${m.fieldTag}`,
|
||||
sleepStage: m.sleepStage ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
return upsertWhoopMeasurements(userId, readings);
|
||||
}
|
||||
+113
-71
@@ -13,12 +13,18 @@
|
||||
* rows (different `source`); the read-time `pickCanonicalWorkoutRows` picker
|
||||
* (E-slice, W6) collapses the cross-source twin at read time.
|
||||
*/
|
||||
import { fetchWorkouts, KJ_TO_KCAL } from "./client";
|
||||
import {
|
||||
fetchWorkouts,
|
||||
fetchWorkoutById,
|
||||
KJ_TO_KCAL,
|
||||
type WhoopWorkout,
|
||||
} from "./client";
|
||||
import {
|
||||
getValidToken,
|
||||
incrementalStart,
|
||||
handleCollectionFetchError,
|
||||
markSynced,
|
||||
markResourceSynced,
|
||||
resolveResourceCursor,
|
||||
WHOOP_RECOVERY_SLEEP_OVERLAP_MS,
|
||||
} from "./sync";
|
||||
import { prisma } from "@/lib/db";
|
||||
@@ -41,7 +47,7 @@ export async function syncUserWorkout(
|
||||
|
||||
const connection = await prisma.whoopConnection.findUnique({
|
||||
where: { userId },
|
||||
select: { lastSyncedAt: true },
|
||||
select: { lastSyncedAt: true, resourceCursors: true },
|
||||
});
|
||||
if (!connection) return 0;
|
||||
|
||||
@@ -53,10 +59,13 @@ export async function syncUserWorkout(
|
||||
// the same window recovery/sleep already use for their late re-scores. The
|
||||
// `(userId, WHOOP, externalId)` upsert keeps the re-fetch idempotent and a
|
||||
// handful of workouts/day keeps the page count down.
|
||||
const start = incrementalStart(connection.lastSyncedAt, {
|
||||
fullSync: opts.fullSync,
|
||||
overlapMs: WHOOP_RECOVERY_SLEEP_OVERLAP_MS,
|
||||
});
|
||||
const start = incrementalStart(
|
||||
resolveResourceCursor(connection, "workout"),
|
||||
{
|
||||
fullSync: opts.fullSync,
|
||||
overlapMs: WHOOP_RECOVERY_SLEEP_OVERLAP_MS,
|
||||
},
|
||||
);
|
||||
|
||||
let records: Awaited<ReturnType<typeof fetchWorkouts>>;
|
||||
try {
|
||||
@@ -67,75 +76,108 @@ export async function syncUserWorkout(
|
||||
|
||||
let imported = 0;
|
||||
for (const w of records) {
|
||||
if (!w.score) continue; // unscored workout — nothing to store yet
|
||||
const startedAt = new Date(w.start);
|
||||
const endedAt = new Date(w.end);
|
||||
const durationSec = Math.max(
|
||||
0,
|
||||
Math.round((endedAt.getTime() - startedAt.getTime()) / 1000),
|
||||
);
|
||||
const energyKcal =
|
||||
typeof w.score.kilojoule === "number"
|
||||
? Math.round(w.score.kilojoule * KJ_TO_KCAL)
|
||||
: null;
|
||||
imported += await upsertWhoopWorkout(userId, w);
|
||||
}
|
||||
|
||||
const metadata: Prisma.InputJsonValue = {
|
||||
whoopWorkoutStrain: w.score.strain,
|
||||
percentRecorded: w.score.percent_recorded,
|
||||
...(w.score.altitude_gain_meter != null
|
||||
? { altitudeGainMeter: w.score.altitude_gain_meter }
|
||||
: {}),
|
||||
...(w.score.altitude_change_meter != null
|
||||
? { altitudeChangeMeter: w.score.altitude_change_meter }
|
||||
: {}),
|
||||
...(w.score.zone_durations
|
||||
? { zoneDurations: w.score.zone_durations }
|
||||
: {}),
|
||||
};
|
||||
await markResourceSynced(userId, "workout");
|
||||
return imported;
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.workout.upsert({
|
||||
where: {
|
||||
userId_source_externalId: {
|
||||
userId,
|
||||
source: "WHOOP",
|
||||
externalId: w.id,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
/**
|
||||
* Webhook-driven targeted refresh: resolve ONE workout by its uuid and upsert
|
||||
* it, instead of re-walking the whole collection. This is the direct fix for
|
||||
* iOS #17 — a `workout.updated` webhook lands the exact workout immediately,
|
||||
* with no dependence on the overlap window catching it. An unscored /
|
||||
* since-deleted record yields nothing. Does NOT advance the resource cursor.
|
||||
*/
|
||||
export async function syncWhoopWorkoutById(
|
||||
userId: string,
|
||||
workoutId: string,
|
||||
): Promise<number> {
|
||||
const tokenInfo = await getValidToken(userId);
|
||||
if (!tokenInfo) return 0;
|
||||
|
||||
let record: WhoopWorkout;
|
||||
try {
|
||||
record = await fetchWorkoutById(tokenInfo.accessToken, workoutId);
|
||||
} catch (err) {
|
||||
return handleCollectionFetchError("workout", userId, err);
|
||||
}
|
||||
|
||||
return upsertWhoopWorkout(userId, record);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert one WHOOP workout into the `Workout` table keyed on
|
||||
* `(userId, source = WHOOP, externalId)`. Returns 1 when a row was written, 0
|
||||
* for an unscored record (nothing to store yet) or an upsert failure (logged,
|
||||
* never thrown — a single bad record must not fail the surrounding sync).
|
||||
* Shared by the collection walk and the webhook-driven fetch-by-id refresh so
|
||||
* both write an identical row shape.
|
||||
*/
|
||||
export async function upsertWhoopWorkout(
|
||||
userId: string,
|
||||
w: WhoopWorkout,
|
||||
): Promise<number> {
|
||||
if (!w.score) return 0; // unscored workout — nothing to store yet
|
||||
const startedAt = new Date(w.start);
|
||||
const endedAt = new Date(w.end);
|
||||
const durationSec = Math.max(
|
||||
0,
|
||||
Math.round((endedAt.getTime() - startedAt.getTime()) / 1000),
|
||||
);
|
||||
const energyKcal =
|
||||
typeof w.score.kilojoule === "number"
|
||||
? Math.round(w.score.kilojoule * KJ_TO_KCAL)
|
||||
: null;
|
||||
|
||||
const metadata: Prisma.InputJsonValue = {
|
||||
whoopWorkoutStrain: w.score.strain,
|
||||
percentRecorded: w.score.percent_recorded,
|
||||
...(w.score.altitude_gain_meter != null
|
||||
? { altitudeGainMeter: w.score.altitude_gain_meter }
|
||||
: {}),
|
||||
...(w.score.altitude_change_meter != null
|
||||
? { altitudeChangeMeter: w.score.altitude_change_meter }
|
||||
: {}),
|
||||
...(w.score.zone_durations
|
||||
? { zoneDurations: w.score.zone_durations }
|
||||
: {}),
|
||||
};
|
||||
|
||||
const row = {
|
||||
sportType: sportLabel(w.sport_id, w.sport_name),
|
||||
startedAt,
|
||||
endedAt,
|
||||
durationSec,
|
||||
totalEnergyKcal: energyKcal,
|
||||
totalDistanceM: w.score.distance_meter ?? null,
|
||||
avgHeartRate: w.score.average_heart_rate ?? null,
|
||||
maxHeartRate: w.score.max_heart_rate ?? null,
|
||||
elevationM: w.score.altitude_gain_meter ?? null,
|
||||
metadata,
|
||||
};
|
||||
|
||||
try {
|
||||
await prisma.workout.upsert({
|
||||
where: {
|
||||
userId_source_externalId: {
|
||||
userId,
|
||||
source: "WHOOP",
|
||||
externalId: w.id,
|
||||
sportType: sportLabel(w.sport_id, w.sport_name),
|
||||
startedAt,
|
||||
endedAt,
|
||||
durationSec,
|
||||
totalEnergyKcal: energyKcal,
|
||||
totalDistanceM: w.score.distance_meter ?? null,
|
||||
avgHeartRate: w.score.average_heart_rate ?? null,
|
||||
maxHeartRate: w.score.max_heart_rate ?? null,
|
||||
elevationM: w.score.altitude_gain_meter ?? null,
|
||||
metadata,
|
||||
},
|
||||
update: {
|
||||
sportType: sportLabel(w.sport_id, w.sport_name),
|
||||
startedAt,
|
||||
endedAt,
|
||||
durationSec,
|
||||
totalEnergyKcal: energyKcal,
|
||||
totalDistanceM: w.score.distance_meter ?? null,
|
||||
avgHeartRate: w.score.average_heart_rate ?? null,
|
||||
maxHeartRate: w.score.max_heart_rate ?? null,
|
||||
elevationM: w.score.altitude_gain_meter ?? null,
|
||||
metadata,
|
||||
},
|
||||
});
|
||||
imported++;
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning(`WHOOP: failed to upsert workout: ${err}`);
|
||||
}
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
source: "WHOOP",
|
||||
externalId: w.id,
|
||||
...row,
|
||||
},
|
||||
update: row,
|
||||
});
|
||||
return 1;
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning(`WHOOP: failed to upsert workout: ${err}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
await markSynced(userId);
|
||||
return imported;
|
||||
}
|
||||
+122
-7
@@ -117,6 +117,61 @@ const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000;
|
||||
export const WHOOP_RECOVERY_SLEEP_OVERLAP_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
export const WHOOP_DEFAULT_OVERLAP_MS = 60 * 60 * 1000; // 1 h
|
||||
|
||||
/**
|
||||
* The far-past anchor a `fullSync` walks from. Incremental ticks start at
|
||||
* `cursor − overlap` (days at most); a fullSync deliberately ignores the
|
||||
* cursor and re-walks the deep history WHOOP retains (multi-year), which an
|
||||
* incremental run would never reach. WHOOP's first public data predates this,
|
||||
* so a fixed anchor covers every realistic account; the client's `next_token`
|
||||
* pagination + the idempotent upsert keep the re-walk bounded and safe.
|
||||
*/
|
||||
export const WHOOP_FULL_SYNC_ANCHOR = new Date("2020-01-01T00:00:00.000Z");
|
||||
|
||||
/**
|
||||
* The four pollable WHOOP collections that carry an independent sync cursor.
|
||||
* Body-measurement is a single profile object (no time-range cursor) and is
|
||||
* excluded. Each resource advances ONLY its own cursor key so a slow/failing
|
||||
* collection never drags the incremental window forward for its siblings.
|
||||
*/
|
||||
export type WhoopResource = "recovery" | "sleep" | "workout" | "cycle";
|
||||
|
||||
/**
|
||||
* Shape of the `WhoopConnection.resourceCursors` JSON map: a partial
|
||||
* `resource → ISO-8601 last-synced instant`. A missing key (or a legacy null
|
||||
* column) means the resource has no per-resource cursor yet and falls back to
|
||||
* the shared `lastSyncedAt`.
|
||||
*/
|
||||
type ResourceCursorMap = Partial<Record<WhoopResource, string>>;
|
||||
|
||||
function parseResourceCursors(raw: unknown): ResourceCursorMap {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
||||
return raw as ResourceCursorMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the incremental cursor for one resource. Prefers the per-resource
|
||||
* cursor; falls back to the connection's shared `lastSyncedAt` for a legacy
|
||||
* connection (or a resource that has never synced under the new column) so no
|
||||
* historical state is lost on the first tick after the migration. Returns null
|
||||
* when neither exists (the very first sync — `incrementalStart` then anchors 30
|
||||
* days back).
|
||||
*/
|
||||
export function resolveResourceCursor(
|
||||
connection: {
|
||||
resourceCursors?: unknown;
|
||||
lastSyncedAt?: Date | null;
|
||||
},
|
||||
resource: WhoopResource,
|
||||
): Date | null {
|
||||
const map = parseResourceCursors(connection.resourceCursors);
|
||||
const iso = map[resource];
|
||||
if (iso) {
|
||||
const parsed = new Date(iso);
|
||||
if (!Number.isNaN(parsed.getTime())) return parsed;
|
||||
}
|
||||
return connection.lastSyncedAt ?? null;
|
||||
}
|
||||
|
||||
export interface WhoopTokenInfo {
|
||||
accessToken: string;
|
||||
connection: { id: string; whoopUserId: string };
|
||||
@@ -200,18 +255,24 @@ export async function getValidToken(
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the incremental `start` for a resource sync. `fullSync` returns
|
||||
* undefined (the backfill anchor handles the deep history). Otherwise start
|
||||
* from `lastSyncedAt - overlap`, or 30 days back on the very first incremental
|
||||
* tick.
|
||||
* Compute the `start` bound for a resource fetch.
|
||||
*
|
||||
* A `fullSync` deliberately ignores the cursor and anchors at
|
||||
* `WHOOP_FULL_SYNC_ANCHOR` — the deep historical backfill an incremental run
|
||||
* would never reach. (It previously returned `undefined`; an explicit anchor
|
||||
* pins the lower bound so the walk is reproducible and the cursor a stalled
|
||||
* incremental left behind cannot silently narrow a backfill.)
|
||||
*
|
||||
* Otherwise it is an incremental tick: start from `cursor − overlap`, or 30
|
||||
* days back on the very first sync (no cursor yet).
|
||||
*/
|
||||
export function incrementalStart(
|
||||
lastSyncedAt: Date | null,
|
||||
cursor: Date | null,
|
||||
opts: { fullSync?: boolean; overlapMs?: number } = {},
|
||||
): Date | undefined {
|
||||
if (opts.fullSync) return undefined;
|
||||
if (opts.fullSync) return WHOOP_FULL_SYNC_ANCHOR;
|
||||
const overlap = opts.overlapMs ?? WHOOP_DEFAULT_OVERLAP_MS;
|
||||
if (lastSyncedAt) return new Date(lastSyncedAt.getTime() - overlap);
|
||||
if (cursor) return new Date(cursor.getTime() - overlap);
|
||||
return new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
@@ -316,6 +377,60 @@ export async function markSynced(userId: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The closed set of resources allowed in the `resourceCursors` JSON path. The
|
||||
* key is passed to `jsonb_set` below — assert it against this whitelist so a
|
||||
* caller can never splice an arbitrary path, even though `resource` is already
|
||||
* the typed `WhoopResource` union.
|
||||
*/
|
||||
const WHOOP_CURSOR_RESOURCES: ReadonlySet<WhoopResource> = new Set([
|
||||
"recovery",
|
||||
"sleep",
|
||||
"workout",
|
||||
"cycle",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Advance ONE resource's cursor after its successful fetch+upsert. Sibling
|
||||
* cursors are preserved untouched, so a resource that errored or soft-skipped
|
||||
* this tick keeps its own (older) cursor and re-fetches from there next time.
|
||||
* Also keeps the shared `lastSyncedAt` moving (monotonically) for any legacy
|
||||
* reader still on it.
|
||||
*
|
||||
* The merge is a SINGLE atomic `jsonb_set` upsert, not a read-modify-write. The
|
||||
* four WHOOP resources are independent pg-boss queues that run concurrently for
|
||||
* the same user (a cron tick fans out all four at once), so a read-then-write
|
||||
* would let two resources read the same map and the second write clobber the
|
||||
* first's cursor. `jsonb_set` merges in-place under the row lock, and
|
||||
* `GREATEST` keeps `last_synced_at` from being dragged backward by a slower
|
||||
* sibling finishing with an older `at`.
|
||||
*/
|
||||
export async function markResourceSynced(
|
||||
userId: string,
|
||||
resource: WhoopResource,
|
||||
at: Date = new Date(),
|
||||
): Promise<void> {
|
||||
// Defence-in-depth: the typed union already bounds `resource`, but the value
|
||||
// reaches a JSON path, so refuse anything outside the closed whitelist.
|
||||
if (!WHOOP_CURSOR_RESOURCES.has(resource)) return;
|
||||
|
||||
const iso = at.toISOString();
|
||||
// `resource` is parameter-bound ($2), never string-spliced. `jsonb_set`
|
||||
// builds the path array from it; `coalesce` seeds an empty object for a
|
||||
// legacy null column; `GREATEST` advances `last_synced_at` only forward.
|
||||
await prisma.$executeRaw`
|
||||
UPDATE "whoop_connections"
|
||||
SET "resource_cursors" = jsonb_set(
|
||||
coalesce("resource_cursors", '{}'::jsonb),
|
||||
ARRAY[${resource}],
|
||||
to_jsonb(${iso}::text),
|
||||
true
|
||||
),
|
||||
"last_synced_at" = GREATEST("last_synced_at", ${at})
|
||||
WHERE "user_id" = ${userId}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full per-user sync across every WHOOP resource. Webhook-driven syncs enqueue
|
||||
* a single per-resource job; this drives the hourly poll catch-all and the
|
||||
|
||||
@@ -203,13 +203,18 @@ export async function processWhoopNotification(
|
||||
}
|
||||
|
||||
// `*.updated` (creates arrive as updates too). Enqueue the per-user
|
||||
// resource sync; the worker re-fetches by id (payload carries no data).
|
||||
// resource sync. The payload carries the resource id so the worker can do a
|
||||
// targeted fetch-by-id refresh — landing the exact record immediately rather
|
||||
// than waiting for the next overlap window to re-walk the collection.
|
||||
const boss = getGlobalBoss();
|
||||
if (!boss) {
|
||||
getEvent()?.addWarning("whoop-webhook: pg-boss not initialised");
|
||||
return NextResponse.json({ status: "ok" }, { status: 200 });
|
||||
}
|
||||
await boss.send(queue, { userId: connection.userId });
|
||||
await boss.send(queue, {
|
||||
userId: connection.userId,
|
||||
resourceId: resourceId ?? undefined,
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "whoop.webhook.enqueued" },
|
||||
|
||||
Reference in new issue
Block a user