diff --git a/src/__tests__/measurement-write-tail-guard.test.ts b/src/__tests__/measurement-write-tail-guard.test.ts index 061f49718..4deacda2d 100644 --- a/src/__tests__/measurement-write-tail-guard.test.ts +++ b/src/__tests__/measurement-write-tail-guard.test.ts @@ -51,6 +51,8 @@ const WRITE_SITES: Record = { "lib/measurements/create-from-telegram.ts": TAIL, "lib/whoop/sync-body.ts": TAIL, "lib/whoop/webhook-handler.ts": TAIL, + // Retiring the rows of Oura sleep periods the person rejected or deleted. + "lib/oura/sync.ts": TAIL, // Integration syncs: each collects the (type, day) pairs it touched and // runs both legs itself at the end of the sync. "lib/fitbit/sync-core.ts": diff --git a/src/lib/oura/__tests__/client.test.ts b/src/lib/oura/__tests__/client.test.ts index ec294e1f9..b8e6b0893 100644 --- a/src/lib/oura/__tests__/client.test.ts +++ b/src/lib/oura/__tests__/client.test.ts @@ -330,6 +330,24 @@ describe("mapReadiness", () => { }); describe("mapSleep", () => { + it("maps a rejected (`rest`) or deleted period to nothing", () => { + const base: OuraSleep = { + id: "rec-r", + day: "2026-06-10", + bedtime_start: "2026-06-10T14:00:00.000Z", + bedtime_end: "2026-06-10T15:00:00.000Z", + sleep_phase_5_min: "222222222222", + lowest_heart_rate: 58, + average_hrv: 40, + }; + expect(mapSleep({ ...base, type: "rest" })).toEqual([]); + expect(mapSleep({ ...base, type: "deleted" })).toEqual([]); + // Periods Oura counts still map, a nap included. + for (const type of ["long_sleep", "sleep", "late_nap", undefined]) { + expect(mapSleep({ ...base, type }).length).toBeGreaterThan(0); + } + }); + it("falls back to stage totals (no hypnogram) and record-scopes the externalId", () => { const s: OuraSleep = { id: "rec-9", diff --git a/src/lib/oura/__tests__/sync.test.ts b/src/lib/oura/__tests__/sync.test.ts index 691199cee..299f6e892 100644 --- a/src/lib/oura/__tests__/sync.test.ts +++ b/src/lib/oura/__tests__/sync.test.ts @@ -415,6 +415,75 @@ describe("syncUserOura", () => { ); }); + it("imports nothing for a rejected or deleted sleep period and retires what an earlier sync stored for it", async () => { + getConnMock.mockResolvedValue(CONN); + fetchSleepMock.mockResolvedValue([ + { + id: "rec-rest", + type: "rest", + day: "2026-06-10", + bedtime_start: "2026-06-10T14:00:00.000Z", + bedtime_end: "2026-06-10T15:00:00.000Z", + sleep_phase_5_min: "222222222222", + lowest_heart_rate: 58, + }, + { + id: "rec-del", + type: "deleted", + day: "2026-06-09", + bedtime_end: "2026-06-09T16:00:00.000Z", + light_sleep_duration: 1800, + }, + ]); + const stored = [ + { + id: "m1", + type: "SLEEP_DURATION", + measuredAt: new Date("2026-06-10T15:00:00.000Z"), + }, + { + id: "m2", + type: "RESTING_HEART_RATE", + measuredAt: new Date("2026-06-10T15:00:00.000Z"), + }, + ]; + findManyMock.mockResolvedValue(stored); + updateManyMock.mockResolvedValue({ count: 2 }); + + await syncUserOura("u1"); + + // Nothing of either period reaches the write path. + expect( + createdRows().filter((r) => String(r.externalId).startsWith("sleep:")), + ).toEqual([]); + // The rows of both periods are looked up by their record prefix, any type. + const lookup = findManyMock.mock.calls + .map((c) => c[0] as { where: Record }) + .find((a) => Array.isArray(a.where.OR)); + expect(lookup).toBeDefined(); + expect(lookup!.where).toMatchObject({ + userId: "u1", + source: "OURA", + deletedAt: null, + }); + expect(lookup!.where.type).toBeUndefined(); + expect(lookup!.where.OR).toEqual([ + { externalId: { startsWith: "sleep:rec-rest:" } }, + { externalId: { startsWith: "sleep:rec-del:" } }, + ]); + // ...and soft-deleted by id, then their rollup days recomputed. + const retire = updateManyMock.mock.calls + .map((c) => c[0] as { where: { id?: { in: string[] } }; data: unknown }) + .find((a) => a.where.id); + expect(retire?.where.id?.in).toEqual(["m1", "m2"]); + expect(retire?.data).toEqual({ deletedAt: expect.any(Date) }); + expect(recomputeMock).toHaveBeenCalledWith( + "u1", + "RESTING_HEART_RATE", + stored[1]!.measuredAt, + ); + }); + it("never sweeps when the sleep collection returns no records", async () => { getConnMock.mockResolvedValue(CONN); fetchReadinessMock.mockResolvedValue([ diff --git a/src/lib/oura/client.ts b/src/lib/oura/client.ts index 44b9e9087..876b8d40b 100644 --- a/src/lib/oura/client.ts +++ b/src/lib/oura/client.ts @@ -309,7 +309,11 @@ export interface OuraCollection { export interface OuraSleep { id: string; day: string; - /** `long` | `short` | `nap` | `rest` — disambiguates a nap from the main sleep. */ + /** + * `long_sleep` | `sleep` | `late_nap` | `rest` | `deleted`. `rest` is a + * detected period the person rejected and `deleted` one they removed; see + * `isRetiredOuraSleepPeriod`. + */ type?: string | null; bedtime_start?: string; bedtime_end?: string; @@ -796,13 +800,35 @@ function mapSleepTimeline(s: OuraSleep): MappedMeasurement[] | null { } /** - * Map one Oura sleep record. When the real `sleep_phase_5_min` hypnogram is + * Oura sleep periods that are not sleep. Oura's `type` marks a detected period + * the person turned down in the app's confirm prompt as `rest` ("falsely + * detected sleep / nap, rejected by the user") and one they deleted as + * `deleted`; the sleep collection still returns both. Importing them made an + * afternoon on the sofa a second sleep session on that day, which added its + * minutes to the night and pulled the night's midpoint into the afternoon. + * `sleep`, `long_sleep` and `late_nap` are periods Oura counts, and an unknown + * or missing type stays in, as before. + */ +const RETIRED_OURA_SLEEP_TYPES: ReadonlySet = new Set([ + "rest", + "deleted", +]); + +/** True for an Oura sleep period the person rejected or deleted. */ +export function isRetiredOuraSleepPeriod(s: Pick): boolean { + return typeof s.type === "string" && RETIRED_OURA_SLEEP_TYPES.has(s.type); +} + +/** + * Map one Oura sleep record. A rejected or deleted period + * (`isRetiredOuraSleepPeriod`) maps to nothing. 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, tz?: string): MappedMeasurement[] { + if (isRetiredOuraSleepPeriod(s)) return []; const measuredAt = dayAnchor(s.day, tz, s.bedtime_end); if (!measuredAt) return []; const out: MappedMeasurement[] = []; diff --git a/src/lib/oura/sync.ts b/src/lib/oura/sync.ts index d9d764f53..51a785ef3 100644 --- a/src/lib/oura/sync.ts +++ b/src/lib/oura/sync.ts @@ -52,6 +52,7 @@ import { type InsertedMeasurementArrivalRow, } from "@/lib/arrivals/measurement-emit"; import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; +import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation"; import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; import type { SyncWriteResult } from "@/lib/outcome/written-outcome"; import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger"; @@ -72,6 +73,7 @@ import { fetchResilience, fetchSleep, fetchVo2Max, + isRetiredOuraSleepPeriod, mapCardiovascularAge, mapDailyActivity, mapDailySleep, @@ -245,6 +247,12 @@ export interface OuraFetchResult { * run-indexed `seg:` row — before the fresh set upserts. */ sleepSweeps: SleepSegmentSweep[]; + /** + * The `sleep::` prefix of every fetched sleep period Oura marks as + * rejected or deleted (`isRetiredOuraSleepPeriod`). Such a period maps to no + * rows, and whatever an earlier sync imported for it is retired. + */ + sleepRetirements: string[]; /** * Collections refused with 401 while a sibling succeeded with the same * token: the token is fine, the grant does not cover them. Not failures; the @@ -335,6 +343,7 @@ async function fetchAll( // upsert. A collection that throws pushes nothing (the fetch is its first // await), so a 401-retry cannot double-enter a record. const sleepSweeps: SleepSegmentSweep[] = []; + const sleepRetirements: string[] = []; const collections: OuraCollectionSpec[] = [ { @@ -354,6 +363,10 @@ async function fetchAll( const records = await fetchSleep(t, query); const ups: OuraMeasurementUpsert[] = []; for (const s of records) { + if (isRetiredOuraSleepPeriod(s)) { + if (s.id) sleepRetirements.push(`sleep:${s.id}:`); + continue; + } const rows = toUpsert( mapSleep(s, tz), "sleep", @@ -480,6 +493,7 @@ async function fetchAll( readings: first.readings, ...splitScopeDenied(first.failures, first.succeeded > 0), sleepSweeps, + sleepRetirements, }; } @@ -492,6 +506,7 @@ async function fetchAll( readings: first.readings, failures: first.failures, sleepSweeps, + sleepRetirements, scopeDenied: null, }; @@ -510,6 +525,7 @@ async function fetchAll( readings: first.readings, failures: first.failures, sleepSweeps, + sleepRetirements, scopeDenied: null, }; @@ -520,9 +536,55 @@ async function fetchAll( // proves the grant as much as one that succeeded after it. ...splitScopeDenied(retry.failures, first.succeeded + retry.succeeded > 0), sleepSweeps, + sleepRetirements, }; } +/** + * Retire what earlier syncs imported for sleep periods Oura now reports as + * rejected or deleted: every live OURA row keyed under the period's + * `sleep::` prefix, whatever its type, because the period's stage + * rows and its nightly scalars (lowest heart rate, HRV, breathing rate, + * efficiency) all came from a period that was not sleep. Soft delete, then the + * shared write tail refolds the touched days and re-warms the touched types. + * Best-effort like the segment sweep: a failure never fails the sync, and the + * record stays inside the lookback window, so the next run retries it. + */ +export async function retireOuraSleepRecords( + userId: string, + prefixes: readonly string[], +): Promise { + // A bare `sleep::` (no record id) would match nothing useful and must never + // widen to the whole source. + const bounded = prefixes.filter((p) => /^sleep:[^:]+:$/.test(p)); + if (bounded.length === 0) return 0; + try { + const rows = await prisma.measurement.findMany({ + where: { + userId, + source: "OURA", + deletedAt: null, + OR: bounded.map((prefix) => ({ externalId: { startsWith: prefix } })), + }, + select: { id: true, type: true, measuredAt: true }, + }); + if (rows.length === 0) return 0; + const res = await prisma.measurement.updateMany({ + where: { userId, id: { in: rows.map((r) => r.id) }, deletedAt: null }, + data: { deletedAt: new Date() }, + }); + await afterMeasurementMutation(userId, rows, "oura"); + invalidateUserMeasurements(userId); + annotate({ meta: { oura_sleep_retired_rows: res.count } }); + return res.count; + } catch (err) { + getEvent()?.addWarning( + `oura: retiring rejected sleep periods failed for ${userId}: ${err}`, + ); + return 0; + } +} + /** * Sync one user's Oura data. * @@ -589,6 +651,7 @@ export async function syncUserOura( // sync; the record stays inside the lookback window, so the next tick // retries it. await sweepStaleSleepSegments(userId, "OURA", result.sleepSweeps); + await retireOuraSleepRecords(userId, result.sleepRetirements); // Import everything the healthy collections returned regardless of whether a // sibling collection failed — one bad collection must not blank the source. diff --git a/tests/integration/oura-retired-sleep.test.ts b/tests/integration/oura-retired-sleep.test.ts new file mode 100644 index 000000000..8dfaf2e39 --- /dev/null +++ b/tests/integration/oura-retired-sleep.test.ts @@ -0,0 +1,116 @@ +/** + * Oura sleep periods the person rejected (`rest`) or deleted: against the + * real schema, retiring one soft-deletes every live OURA row under its record + * prefix, whatever the type, leaves a counted sleep period and the other + * sources alone, and recomputes the rollup day so the day stops counting the + * retired reading. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +const USER = "user-oura-retired-sleep"; +const END = new Date(Date.UTC(2026, 5, 10, 15, 0, 0)); +const NIGHT_END = new Date(Date.UTC(2026, 5, 10, 7, 0, 0)); + +beforeEach(async () => { + const prisma = getPrismaClient(); + await truncateAllTables(prisma); + await prisma.user.create({ + data: { + id: USER, + username: "oura-retired-sleep", + email: "oura-retired-sleep@example.test", + }, + }); + const row = ( + type: "SLEEP_DURATION" | "RESTING_HEART_RATE", + externalId: string, + value: number, + measuredAt: Date, + source: "OURA" | "APPLE_HEALTH" = "OURA", + ) => ({ + userId: USER, + type, + value, + unit: type === "SLEEP_DURATION" ? "minutes" : "bpm", + measuredAt, + source, + externalId, + sleepStage: type === "SLEEP_DURATION" ? ("CORE" as const) : null, + }); + await prisma.measurement.createMany({ + data: [ + // The rejected afternoon period: a stage row and its nightly scalar. + row( + "SLEEP_DURATION", + "sleep:rec-rest:seg:2026-06-10T14:00:00.000Z", + 60, + END, + ), + row("RESTING_HEART_RATE", "sleep:rec-rest:rhr", 58, END), + // The real night before it, same day. + row( + "SLEEP_DURATION", + "sleep:rec-night:seg:2026-06-09T23:00:00.000Z", + 480, + NIGHT_END, + ), + row("RESTING_HEART_RATE", "sleep:rec-night:rhr", 48, NIGHT_END), + // Another source's row whose id happens to share the prefix shape. + row("RESTING_HEART_RATE", "sleep:rec-rest:rhr", 52, END, "APPLE_HEALTH"), + ], + }); + await recomputeBucketsForMeasurement(USER, "RESTING_HEART_RATE", END); +}); + +describe("retireOuraSleepRecords", () => { + it("retires only the rejected period's OURA rows and recomputes the day", async () => { + const prisma = getPrismaClient(); + const { retireOuraSleepRecords } = await import("@/lib/oura/sync"); + + const before = await prisma.measurementRollup.findFirst({ + where: { + userId: USER, + type: "RESTING_HEART_RATE", + granularity: "DAY", + source: "OURA", + }, + }); + expect(before?.count).toBe(2); + + const removed = await retireOuraSleepRecords(USER, ["sleep:rec-rest:"]); + expect(removed).toBe(2); + + const live = await prisma.measurement.findMany({ + where: { userId: USER, deletedAt: null }, + select: { externalId: true, source: true }, + }); + const key = (r: { externalId: string | null; source: string }) => + `${r.source}|${r.externalId}`; + expect(live.sort((a, b) => key(a).localeCompare(key(b)))).toEqual([ + { externalId: "sleep:rec-rest:rhr", source: "APPLE_HEALTH" }, + { externalId: "sleep:rec-night:rhr", source: "OURA" }, + { + externalId: "sleep:rec-night:seg:2026-06-09T23:00:00.000Z", + source: "OURA", + }, + ]); + + const after = await prisma.measurementRollup.findFirst({ + where: { + userId: USER, + type: "RESTING_HEART_RATE", + granularity: "DAY", + source: "OURA", + }, + }); + expect(after?.count).toBe(1); + expect(after?.mean).toBe(48); + + // A malformed prefix never widens to the whole source. + expect(await retireOuraSleepRecords(USER, ["sleep::", "sleep:"])).toBe(0); + }); +});