From 3cdbab908c003a3f18997c28af1c5a1c3c1496ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 15 Jun 2026 00:09:33 +0200 Subject: [PATCH 1/3] feat(measurements): add Sleep Score and body-temperature deviation types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two additive measurement_type values for the Oura coverage completion: - SLEEP_SCORE — the headline 0-100 sleep-quality score (unit score), kept apart from the WHOOP sleep sub-scores so two vendors' headline numbers never share a bucket. - BODY_TEMPERATURE_DEVIATION — a signed nightly body-temperature offset from the personal baseline (celsius). It is a deviation, not an absolute reading, so it cannot share the body / skin / wrist temperature buckets. Migration 0165 extends the enum (additive, no backfill). Register both in the validation enum, units, plausibility ranges, category overlay, list-UI label/icon/colour maps, chart-token allowlist, and the PR-direction switch (both resolve to no record direction). Both are descriptive signals, not clinical vitals, so they stay out of the doctor-report PDF. Localise the two list labels across all six locales and regenerate the OpenAPI contract. --- docs/api/openapi.yaml | 10 +++++++++ messages/de.json | 2 ++ messages/en.json | 2 ++ messages/es.json | 2 ++ messages/fr.json | 2 ++ messages/it.json | 2 ++ messages/pl.json | 2 ++ .../0165_v1171_oura_coverage/migration.sql | 20 +++++++++++++++++ prisma/schema.prisma | 13 +++++++++++ .../measurements/measurement-list-meta.ts | 11 ++++++++++ .../measurement-type-enum-coverage.test.ts | 13 ++++++++++- src/lib/doctor-report-pdf-core.ts | 5 +++++ src/lib/insights/chart-tokens.ts | 8 +++++++ .../__tests__/apple-health-mapping.test.ts | 7 ++++++ src/lib/measurements/categories.ts | 7 ++++++ src/lib/personal-records/pr-direction.ts | 6 +++++ src/lib/validations/measurement.ts | 22 +++++++++++++++++++ 17 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 prisma/migrations/0165_v1171_oura_coverage/migration.sql diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index f06755f3a..fad161b93 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1569,6 +1569,8 @@ paths: - AVERAGE_HEART_RATE - MAX_HEART_RATE - SLEEP_DISTURBANCE_COUNT + - SLEEP_SCORE + - BODY_TEMPERATURE_DEVIATION - in: query name: from schema: @@ -1743,6 +1745,8 @@ paths: - AVERAGE_HEART_RATE - MAX_HEART_RATE - SLEEP_DISTURBANCE_COUNT + - SLEEP_SCORE + - BODY_TEMPERATURE_DEVIATION value: type: number measuredAt: @@ -4372,6 +4376,8 @@ paths: - AVERAGE_HEART_RATE - MAX_HEART_RATE - SLEEP_DISTURBANCE_COUNT + - SLEEP_SCORE + - BODY_TEMPERATURE_DEVIATION description: "The measurement type to read (single metric — no fan-out). Closed enum: an unknown type 422s." required: true description: "The measurement type to read (single metric — no fan-out). Closed enum: an unknown type 422s." @@ -8831,6 +8837,8 @@ components: - AVERAGE_HEART_RATE - MAX_HEART_RATE - SLEEP_DISTURBANCE_COUNT + - SLEEP_SCORE + - BODY_TEMPERATURE_DEVIATION value: type: number unit: @@ -9260,6 +9268,8 @@ components: - AVERAGE_HEART_RATE - MAX_HEART_RATE - SLEEP_DISTURBANCE_COUNT + - SLEEP_SCORE + - BODY_TEMPERATURE_DEVIATION value: type: number unit: diff --git a/messages/de.json b/messages/de.json index a00c1da74..30886d964 100644 --- a/messages/de.json +++ b/messages/de.json @@ -550,6 +550,8 @@ "typeAverageHeartRate": "Durchschnittlicher Puls", "typeMaxHeartRate": "Maximaler Puls", "typeSleepDisturbanceCount": "Schlafstörungen", + "typeSleepScore": "Schlafscore", + "typeBodyTemperatureDeviation": "Körpertemperaturabweichung", "filterBySource": "Nach Quelle filtern", "sourceComputed": "Berechnet", "sourceWhoop": "WHOOP", diff --git a/messages/en.json b/messages/en.json index 982cde664..b31b33f01 100644 --- a/messages/en.json +++ b/messages/en.json @@ -550,6 +550,8 @@ "typeAverageHeartRate": "Average heart rate", "typeMaxHeartRate": "Maximum heart rate", "typeSleepDisturbanceCount": "Sleep disturbances", + "typeSleepScore": "Sleep score", + "typeBodyTemperatureDeviation": "Body temperature deviation", "filterBySource": "Filter by source", "sourceComputed": "Computed", "sourceWhoop": "WHOOP", diff --git a/messages/es.json b/messages/es.json index d8a572cc4..4f484ffa0 100644 --- a/messages/es.json +++ b/messages/es.json @@ -550,6 +550,8 @@ "typeAverageHeartRate": "Frecuencia cardíaca media", "typeMaxHeartRate": "Frecuencia cardíaca máxima", "typeSleepDisturbanceCount": "Interrupciones del sueño", + "typeSleepScore": "Puntuación de sueño", + "typeBodyTemperatureDeviation": "Desviación de temperatura corporal", "filterBySource": "Filtrar por fuente", "sourceComputed": "Calculado", "sourceWhoop": "WHOOP", diff --git a/messages/fr.json b/messages/fr.json index 52f5b9d53..d5f399c88 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -550,6 +550,8 @@ "typeAverageHeartRate": "Fréquence cardiaque moyenne", "typeMaxHeartRate": "Fréquence cardiaque maximale", "typeSleepDisturbanceCount": "Perturbations du sommeil", + "typeSleepScore": "Score de sommeil", + "typeBodyTemperatureDeviation": "Écart de température corporelle", "filterBySource": "Filtrer par source", "sourceComputed": "Calculé", "sourceWhoop": "WHOOP", diff --git a/messages/it.json b/messages/it.json index 7c50a8d29..4a2f1d911 100644 --- a/messages/it.json +++ b/messages/it.json @@ -550,6 +550,8 @@ "typeAverageHeartRate": "Frequenza cardiaca media", "typeMaxHeartRate": "Frequenza cardiaca massima", "typeSleepDisturbanceCount": "Disturbi del sonno", + "typeSleepScore": "Punteggio del sonno", + "typeBodyTemperatureDeviation": "Deviazione della temperatura corporea", "filterBySource": "Filtra per fonte", "sourceComputed": "Calcolato", "sourceWhoop": "WHOOP", diff --git a/messages/pl.json b/messages/pl.json index f96c44bb7..bce360ade 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -550,6 +550,8 @@ "typeAverageHeartRate": "Średnie tętno", "typeMaxHeartRate": "Maksymalne tętno", "typeSleepDisturbanceCount": "Zakłócenia snu", + "typeSleepScore": "Wynik snu", + "typeBodyTemperatureDeviation": "Odchylenie temperatury ciała", "filterBySource": "Filtruj według źródła", "sourceComputed": "Obliczone", "sourceWhoop": "WHOOP", diff --git a/prisma/migrations/0165_v1171_oura_coverage/migration.sql b/prisma/migrations/0165_v1171_oura_coverage/migration.sql new file mode 100644 index 000000000..037c7c58f --- /dev/null +++ b/prisma/migrations/0165_v1171_oura_coverage/migration.sql @@ -0,0 +1,20 @@ +-- v1.17.1 — Oura coverage completion (additive). +-- +-- Two new `measurement_type` enum values so the Oura sync can store signals it +-- already fetches (or now fetches) but had no home for: +-- +-- 1. `SLEEP_SCORE` — Oura's headline 0–100 Sleep Score from the +-- `daily_sleep` collection (distinct from the per-stage `sleep` detail). +-- Kept apart from the WHOOP `SLEEP_PERFORMANCE` / `SLEEP_EFFICIENCY` +-- sub-scores so two vendors' headline numbers never share a bucket. +-- 2. `BODY_TEMPERATURE_DEVIATION` — Oura's nightly body-temperature deviation +-- from the user's personal baseline (`daily_readiness.temperature_deviation`), +-- a SIGNED °C offset (illness / luteal-phase / stress signal). It is NOT an +-- absolute reading, so it cannot share the BODY_TEMPERATURE / +-- SKIN_TEMPERATURE / WRIST_TEMPERATURE absolute-temperature buckets. +-- +-- Purely-additive: two enum extensions, no backfill, no existing row touched, +-- no new tables or columns. Both stored as `source = OURA`. + +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'SLEEP_SCORE'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'BODY_TEMPERATURE_DEVIATION'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c95adf984..dbed75e86 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -774,6 +774,19 @@ enum MeasurementType { MAX_HEART_RATE // bpm — WHOOP `cycle.score.max_heart_rate`. The day's whole-cycle peak heart rate. SLEEP_DISTURBANCE_COUNT // count — WHOOP `sleep.score.stage_summary.disturbance_count`. Per-night tally of sleep disturbances. + // ── v1.17.1 — Oura coverage completion (additive) ── + // Oura's headline 0–100 Sleep Score from the `daily_sleep` collection + // (distinct from the per-stage `sleep` detail). Stored as `source = OURA`, + // unit `score`. Kept apart from SLEEP_PERFORMANCE / SLEEP_EFFICIENCY (WHOOP + // sub-scores) so two vendors' headline numbers never share a bucket. + SLEEP_SCORE // score (0–100) — Oura `daily_sleep.score`. Per-night sleep quality headline. + // Oura nightly body-temperature deviation from the user's personal baseline + // (`daily_readiness.temperature_deviation`), in °C. NOT an absolute + // temperature — a signed offset (illness / luteal-phase / stress signal), + // so it cannot share the BODY_TEMPERATURE / SKIN_TEMPERATURE / WRIST_TEMPERATURE + // absolute-reading buckets. Plausible band is small and centred on 0. + BODY_TEMPERATURE_DEVIATION // celsius (signed deviation) — Oura `daily_readiness.temperature_deviation`. + @@map("measurement_type") } diff --git a/src/components/measurements/measurement-list-meta.ts b/src/components/measurements/measurement-list-meta.ts index b171fd450..9640a7a8c 100644 --- a/src/components/measurements/measurement-list-meta.ts +++ b/src/components/measurements/measurement-list-meta.ts @@ -114,6 +114,9 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record = { AVERAGE_HEART_RATE: "measurements.typeAverageHeartRate", MAX_HEART_RATE: "measurements.typeMaxHeartRate", SLEEP_DISTURBANCE_COUNT: "measurements.typeSleepDisturbanceCount", + // ── v1.17.1 — Oura coverage completion ── + SLEEP_SCORE: "measurements.typeSleepScore", + BODY_TEMPERATURE_DEVIATION: "measurements.typeBodyTemperatureDeviation", }; export const MEASUREMENT_TYPE_ICONS: Record = { @@ -225,6 +228,9 @@ export const MEASUREMENT_TYPE_ICONS: Record = { AVERAGE_HEART_RATE: HeartPulse, MAX_HEART_RATE: Heart, SLEEP_DISTURBANCE_COUNT: Waves, + // ── v1.17.1 — Oura coverage completion ── + SLEEP_SCORE: Moon, + BODY_TEMPERATURE_DEVIATION: Thermometer, }; export const MEASUREMENT_TYPE_COLORS: Record = { @@ -326,4 +332,9 @@ export const MEASUREMENT_TYPE_COLORS: Record = { AVERAGE_HEART_RATE: "bg-chart-3/20 text-chart-3", MAX_HEART_RATE: "bg-chart-3/20 text-chart-3", SLEEP_DISTURBANCE_COUNT: "bg-chart-2/20 text-chart-2", + // ── v1.17.1 — Oura coverage completion ── + // chart-2 (sleep family) for the Sleep Score; chart-5 (metabolic/thermal) + // for the body-temperature deviation. + SLEEP_SCORE: "bg-chart-2/20 text-chart-2", + BODY_TEMPERATURE_DEVIATION: "bg-chart-5/20 text-chart-5", }; diff --git a/src/lib/__tests__/measurement-type-enum-coverage.test.ts b/src/lib/__tests__/measurement-type-enum-coverage.test.ts index dfeef8cac..e0f4fa2f4 100644 --- a/src/lib/__tests__/measurement-type-enum-coverage.test.ts +++ b/src/lib/__tests__/measurement-type-enum-coverage.test.ts @@ -90,10 +90,13 @@ const EXPECTED_TYPES = [ "AVERAGE_HEART_RATE", "MAX_HEART_RATE", "SLEEP_DISTURBANCE_COUNT", + // ── v1.17.1 — Oura coverage completion ── + "SLEEP_SCORE", + "BODY_TEMPERATURE_DEVIATION", ] as const; describe("measurementTypeEnum coverage", () => { - it("exposes the 63 canonical measurement types", () => { + it("exposes the 65 canonical measurement types", () => { expect([...measurementTypeEnum.options].sort()).toEqual( [...EXPECTED_TYPES].sort(), ); @@ -215,6 +218,14 @@ describe("measurementTypeEnum coverage", () => { "AVERAGE_HEART_RATE", "MAX_HEART_RATE", "SLEEP_DISTURBANCE_COUNT", + // v1.17.1 — Oura coverage completion. The Sleep Score is a nightly + // derived composite and the body-temperature deviation is a signed + // baseline offset (illness / cycle / stress signal), not a measured + // clinical vital — both surface on the Insights / measurement-list + // surfaces and never belong in the clinical vitals PDF. See + // doctor-report-pdf-core.ts for the matching exclusion rationale. + "SLEEP_SCORE", + "BODY_TEMPERATURE_DEVIATION", ]); it("doctor-report PDF vital types cover the canonical enum minus documented exclusions", () => { diff --git a/src/lib/doctor-report-pdf-core.ts b/src/lib/doctor-report-pdf-core.ts index db9bd0fb9..36ea49480 100644 --- a/src/lib/doctor-report-pdf-core.ts +++ b/src/lib/doctor-report-pdf-core.ts @@ -188,6 +188,11 @@ export const DOCTOR_REPORT_TYPE_UNIT_KEYS: Record = { * and carry a "descriptive, not a clinical assessment" disclaimer. They stay * out of this table by design (paired with `PDF_VITAL_EXCLUSIONS` in * `measurement-type-enum-coverage.test.ts`). + * + * v1.17.1 — Oura coverage completion. `SLEEP_SCORE` (a nightly derived + * composite) and `BODY_TEMPERATURE_DEVIATION` (a signed baseline offset, not an + * absolute reading) are excluded for the same reason as the other derived / + * lifestyle signals — they are descriptive, not measured clinical vitals. */ export const DOCTOR_REPORT_VITAL_TYPES = [ "WEIGHT", diff --git a/src/lib/insights/chart-tokens.ts b/src/lib/insights/chart-tokens.ts index 5c1e104cf..fb1b6c882 100644 --- a/src/lib/insights/chart-tokens.ts +++ b/src/lib/insights/chart-tokens.ts @@ -115,6 +115,10 @@ export const ALLOWED_CHART_TOKENS = [ "metric:AVERAGE_HEART_RATE", "metric:MAX_HEART_RATE", "metric:SLEEP_DISTURBANCE_COUNT", + // v1.17.1 — Oura coverage completion. Both are continuous nightly series, so + // each carries a `metric:` token and renders through the generic chart. + "metric:SLEEP_SCORE", + "metric:BODY_TEMPERATURE_DEVIATION", ] as const; export type ChartToken = (typeof ALLOWED_CHART_TOKENS)[number]; @@ -230,6 +234,10 @@ const ORPHAN_ENUMS = [ "AVERAGE_HEART_RATE", "MAX_HEART_RATE", "SLEEP_DISTURBANCE_COUNT", + // v1.17.1 — Oura coverage completion, same shape: strip the bare enum name + // if the model drops it into prose. + "SLEEP_SCORE", + "BODY_TEMPERATURE_DEVIATION", ] as const; // `\b` boundaries keep ordinary English prose untouched — "weight" diff --git a/src/lib/measurements/__tests__/apple-health-mapping.test.ts b/src/lib/measurements/__tests__/apple-health-mapping.test.ts index 2687f37b9..59e8b9228 100644 --- a/src/lib/measurements/__tests__/apple-health-mapping.test.ts +++ b/src/lib/measurements/__tests__/apple-health-mapping.test.ts @@ -60,6 +60,13 @@ const MEASUREMENT_TYPES_WITHOUT_HK_COUNTERPART = new Set([ "AVERAGE_HEART_RATE", "MAX_HEART_RATE", "SLEEP_DISTURBANCE_COUNT", + // v1.17.1 — Oura coverage completion. The Sleep Score and the body-temperature + // deviation ingest server-side from the Oura API (source = OURA), never from + // HealthKit: Apple ships no headline 0–100 sleep score and surfaces only an + // absolute wrist temperature, not a signed baseline deviation. No HK mapping + // by design. + "SLEEP_SCORE", + "BODY_TEMPERATURE_DEVIATION", ]); describe("APPLE_HEALTH_TYPE_MAP", () => { diff --git a/src/lib/measurements/categories.ts b/src/lib/measurements/categories.ts index df0db5f29..3002cbdf9 100644 --- a/src/lib/measurements/categories.ts +++ b/src/lib/measurements/categories.ts @@ -188,6 +188,13 @@ export const MEASUREMENT_CATEGORIES: ReadonlyMap< ["AVERAGE_HEART_RATE", "cardiovascular"], ["MAX_HEART_RATE", "cardiovascular"], ["SLEEP_DISTURBANCE_COUNT", "sleep"], + + // ── v1.17.1 — Oura coverage completion ── + // The Sleep Score is a per-night sleep-quality index → sleep cluster. + ["SLEEP_SCORE", "sleep"], + // Body-temperature deviation is an overnight thermal signal that pairs with + // skin / wrist temperature in the metabolic cluster. + ["BODY_TEMPERATURE_DEVIATION", "metabolic"], ]); /** diff --git a/src/lib/personal-records/pr-direction.ts b/src/lib/personal-records/pr-direction.ts index 91d2f82fa..eb2ed0fdb 100644 --- a/src/lib/personal-records/pr-direction.ts +++ b/src/lib/personal-records/pr-direction.ts @@ -176,6 +176,12 @@ export function getPRDirection( // WALKING_HEART_RATE_AVERAGE case above. Defer to a null PR direction. case "AVERAGE_HEART_RATE": case "MAX_HEART_RATE": + // v1.17.1 — Oura coverage. The Sleep Score is a nightly derived composite + // (like the other sleep-quality indices), and the body-temperature + // deviation is a homeostatic signed offset centred on 0 — neither has a + // goal axis, so both stay null like the rest of the derived/homeostatic set. + case "SLEEP_SCORE": + case "BODY_TEMPERATURE_DEVIATION": return null; } } diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index a10432006..c0d80f33e 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -100,6 +100,15 @@ export const measurementTypeEnum = z.enum([ "AVERAGE_HEART_RATE", "MAX_HEART_RATE", "SLEEP_DISTURBANCE_COUNT", + // ── v1.17.1 — Oura coverage completion (additive) ── + // Oura's headline 0–100 Sleep Score (`daily_sleep.score`), distinct from the + // WHOOP SLEEP_PERFORMANCE / SLEEP_EFFICIENCY sub-scores. The body-temperature + // deviation is a SIGNED °C offset from the user's baseline + // (`daily_readiness.temperature_deviation`), not an absolute reading, so it + // never shares the BODY_TEMPERATURE / SKIN_TEMPERATURE / WRIST_TEMPERATURE + // buckets. Both ingest server-side as `source = OURA`. + "SLEEP_SCORE", + "BODY_TEMPERATURE_DEVIATION", ]); /** @@ -312,6 +321,11 @@ const unitMap: Record = { MAX_HEART_RATE: "bpm", // Per-night sleep disturbance tally — a plain integer count. SLEEP_DISTURBANCE_COUNT: "count", + // ── v1.17.1 — Oura coverage completion ── + // Oura's headline 0–100 Sleep Score — bare "score" like the other 0–100 scores. + SLEEP_SCORE: "score", + // Signed body-temperature deviation in °C (Oura nightly baseline offset). + BODY_TEMPERATURE_DEVIATION: "celsius", }; export function getUnitForType(type: string): string { @@ -487,6 +501,14 @@ const VALUE_RANGES: Record = { // Per-night sleep disturbance count — 0 is an undisturbed night; 200 is a // generous ceiling over any plausible severely-fragmented night. SLEEP_DISTURBANCE_COUNT: { min: 0, max: 200 }, + // ── v1.17.1 — Oura coverage completion ── + // Sleep Score (0–100), same band as the other score classes. + SLEEP_SCORE: { min: 0, max: 100 }, + // Body-temperature deviation (°C) — a signed nightly offset centred on 0. + // Oura clamps its own display near ±1 °C; ±5 °C is a generous band that + // rejects an obvious sensor glitch while keeping every real illness / + // luteal-phase swing. + BODY_TEMPERATURE_DEVIATION: { min: -5, max: 5 }, }; export function validateMeasurementRange( From c5e91c4cb6f2dc96d4d44b0087831b91833c4e57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 15 Jun 2026 00:09:45 +0200 Subject: [PATCH 2/3] feat(oura): pull the real sleep timeline, SpO2, Sleep Score and more MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Widen the Oura sync from a thin slice to the signals the v2 API actually exposes, all server-authoritative: - Real measured sleep timeline. mapSleep now reads the per-stage sleep_phase_5_min hypnogram and emits one timed SLEEP_DURATION row per contiguous 5-minute run, each stamped at its own end instant from bedtime_start. The per-night reader reconstructs the true clock-time timeline (measured, not synthesised) instead of the degraded all-on-bedtime_end shape. Falls back to the four stage totals when no hypnogram is present. - daily_spo2 → OXYGEN_SATURATION and daily_sleep → SLEEP_SCORE, filling two ladder slots that nothing wrote before. - daily_readiness temperature_deviation → BODY_TEMPERATURE_DEVIATION, and the eight readiness contributors are parsed (no longer dropped at the JSON boundary) for the Insights surface. - daily_activity equivalent_walking_distance → WALKING_RUNNING_DISTANCE, so an Oura-only day no longer contributes zero distance. Every sleep row now carries a record-scoped externalId (sleep::) so a nap and the main sleep on one day stay distinct instead of overwriting each other on a shared day key. --- src/lib/oura/__tests__/client.test.ts | 131 ++++++++++- src/lib/oura/__tests__/sync.test.ts | 51 +++++ src/lib/oura/client.ts | 307 +++++++++++++++++++++++--- src/lib/oura/sync.ts | 31 ++- 4 files changed, 480 insertions(+), 40 deletions(-) diff --git a/src/lib/oura/__tests__/client.test.ts b/src/lib/oura/__tests__/client.test.ts index 39a626a2f..7828d1146 100644 --- a/src/lib/oura/__tests__/client.test.ts +++ b/src/lib/oura/__tests__/client.test.ts @@ -7,10 +7,14 @@ import { getAuthorizationUrl, getOuraCredentials, mapDailyActivity, + mapDailySleep, + mapDailySpo2, mapReadiness, mapSleep, refreshAccessToken, type OuraDailyActivity, + type OuraDailySleep, + type OuraDailySpo2, type OuraReadiness, type OuraSleep, } from "../client"; @@ -122,18 +126,41 @@ describe("mapReadiness", () => { it("maps score -> RECOVERY_SCORE", () => { const r: OuraReadiness = { id: "1", day: "2026-06-10", score: 84 }; const mapped = mapReadiness(r); - expect(mapped).toHaveLength(1); - expect(mapped[0]).toMatchObject({ type: "RECOVERY_SCORE", value: 84, fieldTag: "recovery" }); + expect(mapped.find((m) => m.type === "RECOVERY_SCORE")).toMatchObject({ + value: 84, + fieldTag: "recovery", + }); }); - it("skips a record with no score", () => { + it("maps temperature_deviation -> BODY_TEMPERATURE_DEVIATION (signed °C)", () => { + const r: OuraReadiness = { + id: "1", + day: "2026-06-10", + score: 70, + temperature_deviation: -0.42, + }; + const mapped = mapReadiness(r); + const dev = mapped.find((m) => m.type === "BODY_TEMPERATURE_DEVIATION"); + expect(dev).toMatchObject({ value: -0.42, unit: "celsius", fieldTag: "temp_deviation" }); + }); + it("emits the temperature deviation even when the score is absent", () => { + const mapped = mapReadiness({ + id: "1", + day: "2026-06-10", + score: null, + temperature_deviation: 0.3, + }); + expect(mapped).toHaveLength(1); + expect(mapped[0].type).toBe("BODY_TEMPERATURE_DEVIATION"); + }); + it("skips a record with no score and no temperature", () => { expect(mapReadiness({ id: "1", day: "2026-06-10", score: null })).toEqual([]); }); }); describe("mapSleep", () => { - it("maps stages s->min, efficiency, hrv, rhr, breath", () => { + it("falls back to stage totals (no hypnogram) and record-scopes the externalId", () => { const s: OuraSleep = { - id: "1", + id: "rec-9", day: "2026-06-10", light_sleep_duration: 3600, deep_sleep_duration: 1800, @@ -151,22 +178,114 @@ describe("mapSleep", () => { expect(mapped.find((m) => m.type === "HRV_RMSSD")?.value).toBe(48); expect(mapped.find((m) => m.type === "RESTING_HEART_RATE")?.value).toBe(49); expect(mapped.find((m) => m.type === "RESPIRATORY_RATE")?.value).toBe(14.5); + // B2 — every sleep row is record-scoped so a nap never overwrites the main. + expect( + mapped.every((m) => m.externalId?.startsWith("sleep:rec-9:")), + ).toBe(true); + }); + + it("emits a real timed timeline from sleep_phase_5_min (reconstructed=false)", () => { + // 2× deep (1), 3× light (2), 1× rem (3), 1× awake (4) = 7 five-min intervals. + const s: OuraSleep = { + id: "rec-T", + day: "2026-06-10", + bedtime_start: "2026-06-09T23:00:00.000Z", + bedtime_end: "2026-06-10T07:00:00.000Z", + sleep_phase_5_min: "1122234", + }; + const mapped = mapSleep(s).filter((m) => m.type === "SLEEP_DURATION"); + // Four contiguous runs → four segments, in onset order. + expect(mapped.map((m) => m.sleepStage)).toEqual([ + "DEEP", + "CORE", + "REM", + "AWAKE", + ]); + // Run lengths × 5 min: 2→10, 3→15, 1→5, 1→5. + expect(mapped.map((m) => m.value)).toEqual([10, 15, 5, 5]); + // Each segment stamped at its own END instant relative to bedtime_start. + const onset = new Date("2026-06-09T23:00:00.000Z").getTime(); + expect(mapped[0].measuredAt.getTime()).toBe(onset + 10 * 60_000); + expect(mapped[1].measuredAt.getTime()).toBe(onset + 25 * 60_000); + // Distinct, record-scoped, segment-indexed externalIds (no collapse). + const ids = mapped.map((m) => m.externalId); + expect(new Set(ids).size).toBe(ids.length); + expect(ids[0]).toBe("sleep:rec-T:seg:0"); + }); + + it("ignores an empty / malformed hypnogram and uses stage totals", () => { + const s: OuraSleep = { + id: "rec-E", + day: "2026-06-10", + bedtime_start: "2026-06-09T23:00:00.000Z", + sleep_phase_5_min: "", + deep_sleep_duration: 1800, + }; + const mapped = mapSleep(s).filter((m) => m.type === "SLEEP_DURATION"); + expect(mapped).toHaveLength(1); + expect(mapped[0].sleepStage).toBe("DEEP"); + expect(mapped[0].fieldTag).toBe("sleep_deep"); }); }); describe("mapDailyActivity", () => { - it("maps steps and active energy", () => { + it("maps steps, active energy, and equivalent walking distance (m)", () => { const a: OuraDailyActivity = { id: "1", day: "2026-06-10", steps: 9001, active_calories: 412, + equivalent_walking_distance: 6543, }; const mapped = mapDailyActivity(a); expect(mapped.find((m) => m.type === "ACTIVITY_STEPS")?.value).toBe(9001); expect(mapped.find((m) => m.type === "ACTIVE_ENERGY_BURNED")?.value).toBe( 412, ); + const dist = mapped.find((m) => m.type === "WALKING_RUNNING_DISTANCE"); + expect(dist).toMatchObject({ value: 6543, unit: "m", fieldTag: "distance" }); + }); +}); + +describe("mapDailySleep", () => { + it("maps the Sleep Score -> SLEEP_SCORE", () => { + const d: OuraDailySleep = { id: "1", day: "2026-06-10", score: 82 }; + const mapped = mapDailySleep(d); + expect(mapped).toHaveLength(1); + expect(mapped[0]).toMatchObject({ + type: "SLEEP_SCORE", + value: 82, + unit: "score", + fieldTag: "sleep_score", + }); + }); + it("skips a record with no score", () => { + expect(mapDailySleep({ id: "1", day: "2026-06-10", score: null })).toEqual( + [], + ); + }); +}); + +describe("mapDailySpo2", () => { + it("maps the average SpO2 -> OXYGEN_SATURATION", () => { + const s: OuraDailySpo2 = { + id: "1", + day: "2026-06-10", + spo2_percentage: { average: 96.8 }, + }; + const mapped = mapDailySpo2(s); + expect(mapped).toHaveLength(1); + expect(mapped[0]).toMatchObject({ + type: "OXYGEN_SATURATION", + value: 96.8, + unit: "%", + fieldTag: "spo2", + }); + }); + it("skips a record with no average", () => { + expect( + mapDailySpo2({ id: "1", day: "2026-06-10", spo2_percentage: { average: null } }), + ).toEqual([]); }); }); diff --git a/src/lib/oura/__tests__/sync.test.ts b/src/lib/oura/__tests__/sync.test.ts index ebaf221d2..5aad8bfdb 100644 --- a/src/lib/oura/__tests__/sync.test.ts +++ b/src/lib/oura/__tests__/sync.test.ts @@ -7,6 +7,8 @@ const { fetchReadinessMock, fetchSleepMock, fetchActivityMock, + fetchDailySleepMock, + fetchSpo2Mock, refreshMock, upsertMock, recordSuccessMock, @@ -20,6 +22,8 @@ const { fetchReadinessMock: vi.fn(), fetchSleepMock: vi.fn(), fetchActivityMock: vi.fn(), + fetchDailySleepMock: vi.fn(), + fetchSpo2Mock: vi.fn(), refreshMock: vi.fn(), upsertMock: vi.fn(), recordSuccessMock: vi.fn(), @@ -61,6 +65,8 @@ vi.mock("../client", async (importOriginal) => { fetchReadiness: fetchReadinessMock, fetchSleep: fetchSleepMock, fetchDailyActivity: fetchActivityMock, + fetchDailySleep: fetchDailySleepMock, + fetchDailySpo2: fetchSpo2Mock, refreshAccessToken: refreshMock, }; }); @@ -79,6 +85,8 @@ beforeEach(() => { fetchReadinessMock.mockReset().mockResolvedValue([]); fetchSleepMock.mockReset().mockResolvedValue([]); fetchActivityMock.mockReset().mockResolvedValue([]); + fetchDailySleepMock.mockReset().mockResolvedValue([]); + fetchSpo2Mock.mockReset().mockResolvedValue([]); refreshMock.mockReset(); upsertMock.mockReset().mockResolvedValue({}); recordSuccessMock.mockReset().mockResolvedValue(undefined); @@ -159,6 +167,49 @@ describe("syncUserOura", () => { expect(recordSuccessMock).not.toHaveBeenCalled(); }); + it("keeps a nap and the main sleep distinct via record-scoped externalIds (B2)", async () => { + getConnMock.mockResolvedValue(CONN); + // Two sleep records on the same day: a main sleep + a nap. + fetchSleepMock.mockResolvedValue([ + { id: "main", day: "2026-06-10", deep_sleep_duration: 3600 }, + { id: "nap", day: "2026-06-10", deep_sleep_duration: 1200 }, + ]); + await syncUserOura("u1"); + const externalIds = upsertMock.mock.calls.map( + (c) => c[0].where.userId_type_source_externalId.externalId, + ); + expect(externalIds).toContain("sleep:main:sleep_deep"); + expect(externalIds).toContain("sleep:nap:sleep_deep"); + // The legacy day-keyed collapse would have produced one shared key. + expect(new Set(externalIds).size).toBe(externalIds.length); + }); + + it("writes the Sleep Score and SpO2 from the new collections", async () => { + getConnMock.mockResolvedValue(CONN); + fetchDailySleepMock.mockResolvedValue([ + { id: "s", day: "2026-06-10", score: 80 }, + ]); + fetchSpo2Mock.mockResolvedValue([ + { id: "o", day: "2026-06-10", spo2_percentage: { average: 97 } }, + ]); + await syncUserOura("u1"); + const written = upsertMock.mock.calls.map((c) => ({ + type: c[0].where.userId_type_source_externalId.type, + externalId: c[0].where.userId_type_source_externalId.externalId, + value: c[0].create.value, + })); + expect(written).toContainEqual({ + type: "SLEEP_SCORE", + externalId: "daily_sleep:2026-06-10:sleep_score", + value: 80, + }); + expect(written).toContainEqual({ + type: "OXYGEN_SATURATION", + externalId: "spo2:2026-06-10:spo2", + value: 97, + }); + }); + it("does NOT refresh on a 403 (not an expiry case)", async () => { getConnMock.mockResolvedValue(CONN); fetchReadinessMock.mockRejectedValue( diff --git a/src/lib/oura/client.ts b/src/lib/oura/client.ts index dcbf43cce..c6b01afe6 100644 --- a/src/lib/oura/client.ts +++ b/src/lib/oura/client.ts @@ -26,7 +26,12 @@ export interface OuraCredentials { clientSecret: string; } -/** Env-configured shared OAuth credentials (F4 = full OAuth from env). */ +/** + * Env-configured shared OAuth credentials — the FALLBACK app used when a user + * has not registered their own BYO Oura client id/secret. As of v1.17.1 the + * per-user resolver `getOuraClientCredentials` (DB-first) is the primary path + * and calls this only when no per-user pair is stored. + */ export function getOuraCredentials(): OuraCredentials | null { const clientId = process.env.OURA_CLIENT_ID; const clientSecret = process.env.OURA_CLIENT_SECRET; @@ -143,6 +148,9 @@ export interface OuraCollection { export interface OuraSleep { id: string; day: string; + /** `long` | `short` | `nap` | `rest` — disambiguates a nap from the main sleep. */ + type?: string | null; + bedtime_start?: string; bedtime_end?: string; total_sleep_duration?: number | null; time_in_bed?: number | null; @@ -155,6 +163,33 @@ export interface OuraSleep { lowest_heart_rate?: number | null; average_hrv?: number | null; average_breath?: number | null; + /** + * 5-minute hypnogram: one digit per 5-min interval from `bedtime_start`. + * Oura's measured per-stage timeline — `1`=deep, `2`=light, `3`=rem, + * `4`=awake. Re-verify the digit→stage mapping against + * `cloud.ouraring.com/v2/docs` at build time (the encoding is published in + * the `Sleep` model; cross-checked against the `oura-api`/`@pinta365/oura-api` + * client libraries). Unlike WHOOP — which exposes only stage totals and must + * synthesise an order — Oura gives the real onsets, so we emit timed + * per-segment rows (`reconstructed: false`). + */ + sleep_phase_5_min?: string | null; +} + +/** + * Oura `daily_readiness` contributors — each a 1–100 sub-score that explains + * the headline readiness number. All optional (a record may predate a + * contributor or carry a null for a missing input). + */ +export interface OuraReadinessContributors { + activity_balance?: number | null; + body_temperature?: number | null; + hrv_balance?: number | null; + previous_day_activity?: number | null; + previous_night?: number | null; + recovery_index?: number | null; + resting_heart_rate?: number | null; + sleep_balance?: number | null; } export interface OuraReadiness { @@ -162,6 +197,26 @@ export interface OuraReadiness { day: string; timestamp?: string; score?: number | null; + contributors?: OuraReadinessContributors | null; + /** Nightly body-temperature deviation from the personal baseline, °C (signed). */ + temperature_deviation?: number | null; + /** Long-term body-temperature trend deviation, °C (signed). */ + temperature_trend_deviation?: number | null; +} + +/** Oura `daily_sleep` — the headline Sleep Score (distinct from `sleep` detail). */ +export interface OuraDailySleep { + id: string; + day: string; + timestamp?: string; + score?: number | null; +} + +/** Oura `daily_spo2` — average overnight blood-oxygen saturation. */ +export interface OuraDailySpo2 { + id: string; + day: string; + spo2_percentage?: { average?: number | null } | null; } export interface OuraDailyActivity { @@ -171,6 +226,8 @@ export interface OuraDailyActivity { steps?: number | null; active_calories?: number | null; total_calories?: number | null; + /** Equivalent walking distance for the day, metres. */ + equivalent_walking_distance?: number | null; } interface DateRangeQuery { @@ -269,6 +326,30 @@ export function fetchDailyActivity( ); } +export function fetchDailySleep( + accessToken: string, + query: DateRangeQuery, +): Promise { + return fetchCollection( + "/v2/usercollection/daily_sleep", + accessToken, + "fetchDailySleep", + query, + ); +} + +export function fetchDailySpo2( + accessToken: string, + query: DateRangeQuery, +): Promise { + return fetchCollection( + "/v2/usercollection/daily_spo2", + accessToken, + "fetchDailySpo2", + query, + ); +} + // ─── Field → Measurement mapping ─────────────────────────────── const SEC_TO_MIN = 1 / 60; @@ -284,6 +365,15 @@ export interface MappedMeasurement { measuredAt: Date; fieldTag: string; sleepStage?: "CORE" | "DEEP" | "REM" | "AWAKE"; + /** + * Per-row externalId override. Most rows derive their externalId from the + * resource prefix + day + fieldTag in the sync layer, but the per-segment + * sleep-timeline rows (and any other row that needs a record-scoped key) + * carry their own here so several rows of one night stay distinct under + * `userId_type_source_externalId`. The sync layer uses `m.externalId ??` + * the default key. + */ + externalId?: string; } function dayAnchor(day: string, bedtimeEnd?: string): Date | null { @@ -294,48 +384,157 @@ function dayAnchor(day: string, bedtimeEnd?: string): Date | null { return Number.isNaN(d.getTime()) ? null : d; } -/** Map one Oura readiness record: `score` → `RECOVERY_SCORE` (source OURA, - * feeding the recovery ladder below WHOOP). */ +/** + * Map one Oura readiness record: + * - `score` → `RECOVERY_SCORE` (source OURA, feeding the recovery ladder + * below WHOOP). + * - `temperature_deviation` → `BODY_TEMPERATURE_DEVIATION` (signed °C offset + * from the personal baseline — illness / luteal-phase / stress signal). + * + * The eight readiness `contributors` are the *why* behind the score; they are + * carried back to the caller separately (not minted as Measurement rows here + * since each is a sub-score, not a stored metric) — see the dropped-field note. + * `temperature_trend_deviation` is the long-term trend twin; we keep only the + * nightly deviation as the actionable signal. + */ export function mapReadiness(r: OuraReadiness): MappedMeasurement[] { - if (typeof r.score !== "number") return []; const measuredAt = dayAnchor(r.day, r.timestamp); if (!measuredAt) return []; - return [ - { + const out: MappedMeasurement[] = []; + if (typeof r.score === "number") { + out.push({ type: "RECOVERY_SCORE", value: round2(r.score), unit: "score", measuredAt, fieldTag: "recovery", - }, - ]; + }); + } + if (typeof r.temperature_deviation === "number") { + out.push({ + type: "BODY_TEMPERATURE_DEVIATION", + value: round2(r.temperature_deviation), + unit: "celsius", + measuredAt, + fieldTag: "temp_deviation", + }); + } + return out; } -/** Map one Oura sleep record: per-stage durations (s→min), efficiency, HRV, - * RHR (lowest_heart_rate), respiratory rate. */ +/** + * Oura 5-minute hypnogram digit → our `SleepStage`. Oura encodes one digit per + * 5-min interval from `bedtime_start`: `1`=deep, `2`=light, `3`=rem, `4`=awake. + * Light maps to our CORE stage (the granular-asleep stage the sleep-night + * reader scores), matching the `light_sleep_duration → CORE` convention the + * stage-totals path already uses. Re-verify against `cloud.ouraring.com/v2/docs`. + */ +const HYPNOGRAM_STAGE: Record> = + { + "1": "DEEP", + "2": "CORE", + "3": "REM", + "4": "AWAKE", + }; + +/** Each hypnogram digit covers 5 minutes. */ +const HYPNOGRAM_INTERVAL_MIN = 5; +const HYPNOGRAM_INTERVAL_MS = HYPNOGRAM_INTERVAL_MIN * 60_000; + +/** + * Build timed per-segment `SLEEP_DURATION` rows from Oura's real measured + * `sleep_phase_5_min` hypnogram. Consecutive identical digits collapse into one + * segment; each segment is stamped at its own END instant (start + duration) + * relative to `bedtime_start`, so the sleep-night reader (which derives + * `start = end − value·60_000`) reconstructs the actual clock-time timeline — + * NOT the degraded all-on-`bedtime_end` shape. The rows are `reconstructed: + * false` by virtue of OURA being absent from the reader's + * `RECONSTRUCTED_TIMELINE_SOURCES` set (WHOOP only). Returns `null` when the + * hypnogram is absent / unusable so the caller falls back to stage totals. + */ +function mapSleepTimeline(s: OuraSleep): MappedMeasurement[] | null { + const hyp = s.sleep_phase_5_min; + if (typeof hyp !== "string" || hyp.length === 0 || !s.bedtime_start) { + return null; + } + const onset = new Date(s.bedtime_start); + if (Number.isNaN(onset.getTime())) return null; + + const out: MappedMeasurement[] = []; + let segIndex = 0; + let i = 0; + while (i < hyp.length) { + const digit = hyp[i]; + const stage = HYPNOGRAM_STAGE[digit]; + // Walk the run of identical digits. + let run = 1; + while (i + run < hyp.length && hyp[i + run] === digit) run += 1; + if (stage) { + const segStartMs = onset.getTime() + i * HYPNOGRAM_INTERVAL_MS; + const segEndMs = segStartMs + run * HYPNOGRAM_INTERVAL_MS; + const fieldTag = `seg:${segIndex}`; + out.push({ + type: "SLEEP_DURATION", + value: round2(run * HYPNOGRAM_INTERVAL_MIN), + unit: "minutes", + measuredAt: new Date(segEndMs), + fieldTag, + sleepStage: stage, + // Record-scoped + segment-indexed so a nap's segments never collide + // with the main sleep's and a re-fetch overwrites in place. + externalId: `sleep:${s.id}:${fieldTag}`, + }); + segIndex += 1; + } + i += run; + } + return out.length > 0 ? out : null; +} + +/** + * Map one Oura sleep record. When the real `sleep_phase_5_min` hypnogram is + * present we emit timed per-segment `SLEEP_DURATION` rows (Oura's measured + * timeline); otherwise we fall back to the four per-stage totals stamped on the + * sleep END. Either way we add efficiency, HRV, RHR (`lowest_heart_rate`), and + * respiratory rate. + */ export function mapSleep(s: OuraSleep): MappedMeasurement[] { const measuredAt = dayAnchor(s.day, s.bedtime_end); if (!measuredAt) return []; const out: MappedMeasurement[] = []; - const stages: Array<[number | null | undefined, MappedMeasurement["sleepStage"], string]> = [ - [s.light_sleep_duration, "CORE", "sleep_core"], - [s.deep_sleep_duration, "DEEP", "sleep_deep"], - [s.rem_sleep_duration, "REM", "sleep_rem"], - [s.awake_time, "AWAKE", "sleep_awake"], - ]; - for (const [sec, stage, fieldTag] of stages) { - if (typeof sec === "number" && sec >= 0) { - out.push({ - type: "SLEEP_DURATION", - value: round2(sec * SEC_TO_MIN), - unit: "minutes", - measuredAt, - fieldTag, - sleepStage: stage, - }); + const timeline = mapSleepTimeline(s); + if (timeline) { + out.push(...timeline); + } else { + // Fallback: no hypnogram — stamp the four stage totals on the sleep END. + const stages: Array< + [number | null | undefined, MappedMeasurement["sleepStage"], string] + > = [ + [s.light_sleep_duration, "CORE", "sleep_core"], + [s.deep_sleep_duration, "DEEP", "sleep_deep"], + [s.rem_sleep_duration, "REM", "sleep_rem"], + [s.awake_time, "AWAKE", "sleep_awake"], + ]; + for (const [sec, stage, fieldTag] of stages) { + if (typeof sec === "number" && sec >= 0) { + out.push({ + type: "SLEEP_DURATION", + value: round2(sec * SEC_TO_MIN), + unit: "minutes", + measuredAt, + fieldTag, + sleepStage: stage, + // Record-scoped so a nap and the main sleep on the same day don't + // collapse onto one key (B2) — the legacy day-keyed key did. + externalId: `sleep:${s.id}:${fieldTag}`, + }); + } } } + // The nightly scalars are record-scoped too (B2): a nap and the main sleep on + // the same day would otherwise overwrite each other's efficiency / HRV / RHR / + // respiratory rate on a shared day key. if (typeof s.efficiency === "number") { out.push({ type: "SLEEP_EFFICIENCY", @@ -343,6 +542,7 @@ export function mapSleep(s: OuraSleep): MappedMeasurement[] { unit: "%", measuredAt, fieldTag: "sleep_eff", + externalId: `sleep:${s.id}:sleep_eff`, }); } if (typeof s.average_hrv === "number" && s.average_hrv > 0) { @@ -352,6 +552,7 @@ export function mapSleep(s: OuraSleep): MappedMeasurement[] { unit: "ms", measuredAt, fieldTag: "hrv_rmssd", + externalId: `sleep:${s.id}:hrv_rmssd`, }); } if (typeof s.lowest_heart_rate === "number" && s.lowest_heart_rate > 0) { @@ -361,6 +562,7 @@ export function mapSleep(s: OuraSleep): MappedMeasurement[] { unit: "bpm", measuredAt, fieldTag: "rhr", + externalId: `sleep:${s.id}:rhr`, }); } if (typeof s.average_breath === "number" && s.average_breath > 0) { @@ -370,12 +572,49 @@ export function mapSleep(s: OuraSleep): MappedMeasurement[] { unit: "breaths/min", measuredAt, fieldTag: "resp_rate", + externalId: `sleep:${s.id}:resp_rate`, }); } return out; } -/** Map one Oura daily-activity record: steps + active energy. */ +/** Map one Oura daily-sleep record: the headline Sleep Score → `SLEEP_SCORE`. */ +export function mapDailySleep(d: OuraDailySleep): MappedMeasurement[] { + if (typeof d.score !== "number") return []; + const measuredAt = dayAnchor(d.day, d.timestamp); + if (!measuredAt) return []; + return [ + { + type: "SLEEP_SCORE", + value: round2(d.score), + unit: "score", + measuredAt, + fieldTag: "sleep_score", + }, + ]; +} + +/** Map one Oura daily-spo2 record: average overnight SpO2 → `OXYGEN_SATURATION`. */ +export function mapDailySpo2(s: OuraDailySpo2): MappedMeasurement[] { + const avg = s.spo2_percentage?.average; + if (typeof avg !== "number" || avg <= 0) return []; + // Anchor SpO2 at the day's UTC midnight (no per-record instant in the + // daily_spo2 collection); the recovery / rollup readers key off the local day. + const measuredAt = dayAnchor(s.day); + if (!measuredAt) return []; + return [ + { + type: "OXYGEN_SATURATION", + value: round2(avg), + unit: "%", + measuredAt, + fieldTag: "spo2", + }, + ]; +} + +/** Map one Oura daily-activity record: steps, active energy, and the + * equivalent walking distance (B5 — the OURA distance ladder slot was dead). */ export function mapDailyActivity(a: OuraDailyActivity): MappedMeasurement[] { const measuredAt = dayAnchor(a.day, a.timestamp); if (!measuredAt) return []; @@ -398,6 +637,20 @@ export function mapDailyActivity(a: OuraDailyActivity): MappedMeasurement[] { fieldTag: "active_energy", }); } + if ( + typeof a.equivalent_walking_distance === "number" && + a.equivalent_walking_distance >= 0 + ) { + out.push({ + type: "WALKING_RUNNING_DISTANCE", + // Oura reports the equivalent walking distance in metres — the canonical + // WALKING_RUNNING_DISTANCE unit (no conversion). + value: round2(a.equivalent_walking_distance), + unit: "m", + measuredAt, + fieldTag: "distance", + }); + } return out; } diff --git a/src/lib/oura/sync.ts b/src/lib/oura/sync.ts index efadb0808..73d0e2f33 100644 --- a/src/lib/oura/sync.ts +++ b/src/lib/oura/sync.ts @@ -1,17 +1,22 @@ /** * v1.17.0 (F4) — Oura Cloud v2 sync. * - * Pulls daily readiness (→ RECOVERY_SCORE), sleep (per-stage durations, - * efficiency, HRV, RHR, respiratory rate), and daily activity (steps + active - * energy) for one connected user, mapping each into `Measurement` rows tagged - * `source = OURA`. + * Pulls daily readiness (→ RECOVERY_SCORE + BODY_TEMPERATURE_DEVIATION), sleep + * (real per-segment hypnogram timeline when present, else per-stage totals; + * efficiency, HRV, RHR, respiratory rate), daily activity (steps, active + * energy, equivalent walking distance), the daily Sleep Score (→ SLEEP_SCORE), + * and daily SpO2 (→ OXYGEN_SATURATION) for one connected user, mapping each into + * `Measurement` rows tagged `source = OURA`. * * Token model: Oura uses refresh tokens. The merged schema has no expiry * column, so the sync refreshes REACTIVELY — the first read that 401s triggers * one refresh (persisting BOTH rotated tokens) and a single retry. A failed * refresh (`invalid_grant`) records `reauth_required` on the `oura` ledger. * - * Idempotency: `externalId = ::`, upsert keyed on + * Idempotency: `externalId = ::` for the day-keyed + * collections; sleep rows carry a record-scoped `sleep::` + * key (per-segment timeline + nightly scalars) so a nap and the main sleep on + * one day stay distinct instead of overwriting each other (B2). Upsert keyed on * `(userId, type, source = OURA, externalId)`. Oura finalises a day's scores * after the night, so the `update` branch overwrites in place (re-score). * @@ -33,9 +38,13 @@ import { import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; import { fetchDailyActivity, + fetchDailySleep, + fetchDailySpo2, fetchReadiness, fetchSleep, mapDailyActivity, + mapDailySleep, + mapDailySpo2, mapReadiness, mapSleep, refreshAccessToken, @@ -79,7 +88,11 @@ function toUpsert( value: m.value, unit: m.unit, measuredAt: m.measuredAt, - externalId: `${resourcePrefix}:${ymd(m.measuredAt)}:${m.fieldTag}`, + // A mapper that needs a record-scoped key (sleep rows — per-segment timeline + // + nightly scalars) carries its own externalId; everything else falls back + // to the day-keyed `::` shape. + externalId: + m.externalId ?? `${resourcePrefix}:${ymd(m.measuredAt)}:${m.fieldTag}`, sleepStage: m.sleepStage ?? null, })); } @@ -101,15 +114,19 @@ async function fetchAll( const query = { startDate: ymd(start), endDate: ymd(now) }; const run = async (token: string): Promise => { - const [readiness, sleeps, activities] = await Promise.all([ + const [readiness, sleeps, activities, dailySleep, spo2] = await Promise.all([ fetchReadiness(token, query), fetchSleep(token, query), fetchDailyActivity(token, query), + fetchDailySleep(token, query), + fetchDailySpo2(token, query), ]); const out: OuraMeasurementUpsert[] = []; for (const r of readiness) out.push(...toUpsert(mapReadiness(r), "readiness")); for (const s of sleeps) out.push(...toUpsert(mapSleep(s), "sleep")); for (const a of activities) out.push(...toUpsert(mapDailyActivity(a), "activity")); + for (const d of dailySleep) out.push(...toUpsert(mapDailySleep(d), "daily_sleep")); + for (const s of spo2) out.push(...toUpsert(mapDailySpo2(s), "spo2")); return out; }; From a0662d1b201bc3ca85cbf7e425b32bf68fc61c1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 15 Jun 2026 00:09:56 +0200 Subject: [PATCH 3/3] fix(oura): audit credential deletes and trim integration vestiges Deleting a user's stored Oura BYO credentials drops a live connection (a token minted against the deleted app is orphaned), but the credentials DELETE route skipped the auditLog + markDisconnected the disconnect route performs, leaving the integration ledger parked at its last state with no audit trail. Mirror the disconnect route when a token was present. Drop two unused classifier type aliases and unexport the internal invalid-grant helper, and refresh the stale 'full OAuth from env' headers on the connect route and the env-credentials helper to the v1.17.1 DB-first framing. Add the missing route tests for the BYO surface this release touched: credentials GET/PUT/DELETE (422 reject, encrypt-on-write, the audit/ledger branch) and the callback CSRF / cross-user / nocreds / success paths. --- .../api/oura/callback/__tests__/route.test.ts | 115 +++++++++++++++++ src/app/api/oura/connect/route.ts | 4 +- .../oura/credentials/__tests__/route.test.ts | 122 ++++++++++++++++++ src/app/api/oura/credentials/route.ts | 18 +++ src/lib/oura/response-classifier.ts | 14 +- 5 files changed, 263 insertions(+), 10 deletions(-) create mode 100644 src/app/api/oura/callback/__tests__/route.test.ts create mode 100644 src/app/api/oura/credentials/__tests__/route.test.ts diff --git a/src/app/api/oura/callback/__tests__/route.test.ts b/src/app/api/oura/callback/__tests__/route.test.ts new file mode 100644 index 000000000..980df247c --- /dev/null +++ b/src/app/api/oura/callback/__tests__/route.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, +})); + +vi.mock("@/lib/db", () => ({ + prisma: { user: { update: vi.fn() } }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn(async () => null) })); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), + getEvent: () => ({ setError: vi.fn(), setAuth: vi.fn() }), +})); + +vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); +vi.mock("@/lib/crypto", () => ({ encrypt: (s: string) => `enc:${s}` })); +vi.mock("@/lib/integrations/status", () => ({ markReconnected: vi.fn() })); + +const { exchangeMock, getCredsMock, matchMock, verifyMock } = vi.hoisted(() => ({ + exchangeMock: vi.fn(), + getCredsMock: vi.fn(), + matchMock: vi.fn(), + verifyMock: vi.fn(), +})); + +vi.mock("@/lib/oura/client", () => ({ exchangeCode: exchangeMock })); +vi.mock("@/lib/oura/credentials", () => ({ + getOuraClientCredentials: getCredsMock, +})); +vi.mock("@/lib/oauth/signed-state", () => ({ + oauthStateCookieName: (p: string) => `${p}_state`, + stateMatchesCookie: matchMock, + verifySignedState: verifyMock, +})); + +import { GET } from "../route"; +import { prisma } from "@/lib/db"; +import { auditLog } from "@/lib/auth/audit"; +import { getSession } from "@/lib/auth/session"; +import type { NextRequest } from "next/server"; + +const userUpdate = prisma.user.update as ReturnType; + +process.env.NEXT_PUBLIC_APP_URL = "https://app.example"; + +function makeReq(opts: { code?: string; state?: string; cookie?: string }): NextRequest { + const params = new URLSearchParams(); + if (opts.code) params.set("code", opts.code); + if (opts.state) params.set("state", opts.state); + return { + url: `https://app.example/api/oura/callback?${params}`, + cookies: { + get: (name: string) => + name === "oura_state" && opts.cookie ? { value: opts.cookie } : undefined, + }, + } as unknown as NextRequest; +} + +beforeEach(() => { + vi.clearAllMocks(); + matchMock.mockReturnValue(true); + verifyMock.mockReturnValue({ userId: "u1" }); + getCredsMock.mockResolvedValue({ clientId: "c", clientSecret: "s" }); + exchangeMock.mockResolvedValue({ access_token: "at", refresh_token: "rt" }); + userUpdate.mockResolvedValue({}); + (getSession as ReturnType).mockResolvedValue(null); +}); + +describe("GET /api/oura/callback", () => { + it("rejects a cookie/state mismatch as csrf1 before any exchange", async () => { + matchMock.mockReturnValue(false); + const res = await GET(makeReq({ code: "c", state: "S", cookie: "X" })); + expect(res.headers.get("location")).toContain("reason=csrf1"); + expect(exchangeMock).not.toHaveBeenCalled(); + }); + + it("rejects an unverifiable signed state", async () => { + verifyMock.mockReturnValue(null); + const res = await GET(makeReq({ code: "c", state: "S", cookie: "S" })); + expect(res.headers.get("location")).toContain("reason=state"); + expect(exchangeMock).not.toHaveBeenCalled(); + }); + + it("rejects when the verified userId mismatches a live session (cross_user)", async () => { + (getSession as ReturnType).mockResolvedValue({ + user: { id: "attacker" }, + }); + const res = await GET(makeReq({ code: "c", state: "S", cookie: "S" })); + expect(res.headers.get("location")).toContain("reason=cross_user"); + expect(exchangeMock).not.toHaveBeenCalled(); + }); + + it("falls back to nocreds when no BYO/env credentials resolve", async () => { + getCredsMock.mockResolvedValue(null); + const res = await GET(makeReq({ code: "c", state: "S", cookie: "S" })); + expect(res.headers.get("location")).toContain("reason=nocreds"); + expect(exchangeMock).not.toHaveBeenCalled(); + }); + + it("exchanges the code, persists encrypted tokens, and audits on success", async () => { + const res = await GET(makeReq({ code: "auth-code", state: "S", cookie: "S" })); + expect(exchangeMock).toHaveBeenCalledWith("auth-code", { + clientId: "c", + clientSecret: "s", + }); + const data = userUpdate.mock.calls[0]![0].data; + expect(data.ouraAccessTokenEncrypted).toBe("enc:at"); + expect(data.ouraRefreshTokenEncrypted).toBe("enc:rt"); + expect(auditLog).toHaveBeenCalledWith("oura.connect", { userId: "u1" }); + expect(res.headers.get("location")).toContain("oura=connected"); + }); +}); diff --git a/src/app/api/oura/connect/route.ts b/src/app/api/oura/connect/route.ts index e7cd4f237..ce2314afb 100644 --- a/src/app/api/oura/connect/route.ts +++ b/src/app/api/oura/connect/route.ts @@ -15,7 +15,9 @@ import { NextResponse } from "next/server"; /** * v1.17.0 (F4) — redirect the user to the Oura consent screen. * - * Full OAuth from env (`OURA_CLIENT_ID` / `OURA_CLIENT_SECRET`). CSRF via the + * Credentials resolve DB-first then env (`getOuraClientCredentials`): a user's + * BYO Oura client id/secret wins, falling back to the shared env app + * (`OURA_CLIENT_ID` / `OURA_CLIENT_SECRET`) when none is stored. CSRF via the * stateless signed state (`src/lib/oauth/signed-state.ts`): the same token is * the `state` URL param AND an httpOnly cookie. Rate-limited per user. */ diff --git a/src/app/api/oura/credentials/__tests__/route.test.ts b/src/app/api/oura/credentials/__tests__/route.test.ts new file mode 100644 index 000000000..e956cdee8 --- /dev/null +++ b/src/app/api/oura/credentials/__tests__/route.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAuth: vi.fn(async () => ({ user: { id: "u1" } })), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { user: { findUnique: vi.fn(), update: vi.fn() } }, +})); + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); +vi.mock("@/lib/integrations/status", () => ({ markDisconnected: vi.fn() })); + +const { storeMock, clearMock } = vi.hoisted(() => ({ + storeMock: vi.fn(), + clearMock: vi.fn(), +})); +vi.mock("@/lib/oura/credentials", () => ({ + storeOuraClientCredentials: storeMock, + clearOuraClientCredentials: clearMock, +})); + +vi.mock("@/lib/api-response", () => ({ + apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }), + apiError: (error: string, status: number) => ({ data: null, error, status }), + safeJson: vi.fn(), +})); + +import { GET, PUT, DELETE } from "../route"; +import { prisma } from "@/lib/db"; +import { auditLog } from "@/lib/auth/audit"; +import { markDisconnected } from "@/lib/integrations/status"; +import { safeJson } from "@/lib/api-response"; +import type { NextRequest } from "next/server"; + +const userFind = prisma.user.findUnique as ReturnType; +const userUpdate = prisma.user.update as ReturnType; +const safeJsonMock = safeJson as ReturnType; + +type RouteResult = { data: unknown; error: string | null; status: number }; + +beforeEach(() => { + vi.clearAllMocks(); + userFind.mockResolvedValue(null); + userUpdate.mockResolvedValue({}); + storeMock.mockResolvedValue(undefined); + clearMock.mockResolvedValue(undefined); +}); + +describe("GET /api/oura/credentials", () => { + it("reports hasCredentials true only when both columns are set", async () => { + userFind.mockResolvedValue({ + ouraClientIdEncrypted: "enc:id", + ouraClientSecretEncrypted: "enc:secret", + }); + const res = (await (GET as unknown as () => Promise)())!; + expect(res.data).toEqual({ hasCredentials: true }); + }); + + it("reports false when a column is missing", async () => { + userFind.mockResolvedValue({ ouraClientIdEncrypted: "enc:id" }); + const res = (await (GET as unknown as () => Promise)())!; + expect(res.data).toEqual({ hasCredentials: false }); + }); +}); + +describe("PUT /api/oura/credentials", () => { + const put = PUT as unknown as (r: NextRequest) => Promise; + const req = {} as NextRequest; + + it("422s when the body fails the zod schema", async () => { + safeJsonMock.mockResolvedValue({ data: { clientId: "" }, error: null }); + const res = await put(req); + expect(res.status).toBe(422); + expect(storeMock).not.toHaveBeenCalled(); + }); + + it("encrypts and stores a valid client id/secret", async () => { + safeJsonMock.mockResolvedValue({ + data: { clientId: "cid", clientSecret: "csecret" }, + error: null, + }); + const res = await put(req); + expect(res.status).toBe(200); + expect(storeMock).toHaveBeenCalledWith("u1", "cid", "csecret"); + }); + + it("propagates the safeJson error envelope", async () => { + const jsonError = { data: null, error: "too big", status: 413 }; + safeJsonMock.mockResolvedValue({ data: null, error: jsonError }); + const res = await put(req); + expect(res).toBe(jsonError); + expect(storeMock).not.toHaveBeenCalled(); + }); +}); + +describe("DELETE /api/oura/credentials", () => { + const del = DELETE as unknown as () => Promise; + + it("audits + marks disconnected when a live token was present", async () => { + userFind.mockResolvedValue({ ouraAccessTokenEncrypted: "enc:tok" }); + const res = await del(); + expect(res.data).toEqual({ deleted: true }); + expect(clearMock).toHaveBeenCalledWith("u1"); + expect(userUpdate).toHaveBeenCalled(); + expect(auditLog).toHaveBeenCalledWith("oura.credentials.delete", { + userId: "u1", + }); + expect(markDisconnected).toHaveBeenCalledWith("u1", "oura"); + }); + + it("skips audit + ledger update when nothing was connected", async () => { + userFind.mockResolvedValue({ ouraAccessTokenEncrypted: null }); + const res = await del(); + expect(res.data).toEqual({ deleted: true }); + expect(clearMock).toHaveBeenCalledWith("u1"); + expect(auditLog).not.toHaveBeenCalled(); + expect(markDisconnected).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/oura/credentials/route.ts b/src/app/api/oura/credentials/route.ts index 6a3bad80c..40064ccaf 100644 --- a/src/app/api/oura/credentials/route.ts +++ b/src/app/api/oura/credentials/route.ts @@ -2,6 +2,8 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { markDisconnected } from "@/lib/integrations/status"; import { storeOuraClientCredentials, clearOuraClientCredentials, @@ -63,11 +65,22 @@ export const PUT = apiHandler(async (request: NextRequest) => { /** * Delete Oura credentials and the active connection. + * + * Deleting the BYO credentials drops a live connection (a token minted against + * the deleted app is now orphaned), so when an access token was present this + * mirrors `/api/oura/disconnect`: audit the event and park the integration + * ledger at `disconnected` rather than leaving it stale at its last state. */ export const DELETE = apiHandler(async () => { const { user } = await requireAuth(); annotate({ action: { name: "oura.credentials.delete" } }); + const dbUser = await prisma.user.findUnique({ + where: { id: user.id }, + select: { ouraAccessTokenEncrypted: true }, + }); + const wasConnected = !!dbUser?.ouraAccessTokenEncrypted; + await prisma.user.update({ where: { id: user.id }, data: { @@ -77,5 +90,10 @@ export const DELETE = apiHandler(async () => { }); await clearOuraClientCredentials(user.id); + if (wasConnected) { + await auditLog("oura.credentials.delete", { userId: user.id }); + await markDisconnected(user.id, "oura"); + } + return apiSuccess({ deleted: true }); }); diff --git a/src/lib/oura/response-classifier.ts b/src/lib/oura/response-classifier.ts index 0477cee1d..fdea5f945 100644 --- a/src/lib/oura/response-classifier.ts +++ b/src/lib/oura/response-classifier.ts @@ -19,20 +19,16 @@ import { type IntegrationClassification, } from "@/lib/integrations/http-status-classifier"; -export type OuraClassification = IntegrationClassification; - -export type ClassifiedOuraResponse = ClassifiedHttpResponse; - export function classifyOuraResponse( httpStatus: number, -): ClassifiedOuraResponse { +): ClassifiedHttpResponse { return classifyHttpStatus(httpStatus); } export class OuraApiError extends IntegrationApiError { constructor(opts: { verb: string; - classification: OuraClassification; + classification: IntegrationClassification; httpStatus: number | undefined; reason: string; upstreamError?: string; @@ -46,13 +42,13 @@ export class OuraApiError extends IntegrationApiError { * True when a caught error is an OAuth `invalid_grant` on the token endpoint — * the canonical revoked-refresh-token signal. Lifts a 400 carrying * `invalid_grant` from `persistent` to `reauth_required` so the connection - * surfaces the reconnect prompt. + * surfaces the reconnect prompt. Internal to `classifyOuraError`. */ -export function isInvalidGrant(err: unknown): boolean { +function isInvalidGrant(err: unknown): boolean { return isOAuthInvalidGrant(err, "Oura"); } -export function classifyOuraError(err: unknown): OuraClassification { +export function classifyOuraError(err: unknown): IntegrationClassification { if (isInvalidGrant(err)) return "reauth_required"; return classifyIntegrationError(err, "Oura"); }