From 9e6a58b1866d73ec699a26d3f880d651673124a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 03:28:11 +0200 Subject: [PATCH] fix(illness): escalate sustained fever and low SpO2 on write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A sustained fever (three calendar-consecutive days at or above the fever red-flag floor) or sustained low SpO2 was already classified "seek care", but the only escalation path ran inside a GET on the episode correlation route — no push fired unless the user opened that tab, and no cron drove it. The proactive on-write safety check covered only blood pressure and glucose; the two signals with the strongest safety case had the weakest delivery. Add sustained-run temperature and SpO2 checks to the on-write `runSafetyFloorCheck`, wired from both measurement-ingest seams. The run length and clinical floors are shared with the retrospective detector (no duplicated thresholds), day bucketing honours the user's timezone, and escalation reuses the existing red-flag notification with its push-attempt dedup so a febrile user logging repeatedly is alerted at most once per reason per day. --- src/app/api/measurements/route.ts | 2 + .../__tests__/safety-floor-sustained.test.ts | 290 ++++++++++++++++++ src/lib/illness/correlation.ts | 10 +- src/lib/illness/safety-floor-check.ts | 230 ++++++++++++++ 4 files changed, 530 insertions(+), 2 deletions(-) create mode 100644 src/lib/illness/__tests__/safety-floor-sustained.test.ts diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index 989c5fba4..50ec6ebd8 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -802,6 +802,7 @@ async function postMeasurement(request: NextRequest) { symptomsPresent: parsed.data.measurements.some( (m) => m.symptomsPresent === true, ), + timezone: user.timezone ?? undefined, }).catch(() => {}); // v1.5.0 — refresh the persistent rollup table for every distinct @@ -948,6 +949,7 @@ async function postMeasurement(request: NextRequest) { }, ], symptomsPresent, + timezone: user.timezone ?? undefined, }).catch(() => {}); // v1.5.0 — refresh the persistent rollup row for the affected diff --git a/src/lib/illness/__tests__/safety-floor-sustained.test.ts b/src/lib/illness/__tests__/safety-floor-sustained.test.ts new file mode 100644 index 000000000..bc874e075 --- /dev/null +++ b/src/lib/illness/__tests__/safety-floor-sustained.test.ts @@ -0,0 +1,290 @@ +/** + * v1.25 — on-write sustained fever / low-SpO2 escalation (finding H-CS1). + * + * The retrospective correlation detector classifies a 3-day fever or sustained + * low-SpO2 run as "seek care", but that escalation only fired when the user + * opened the episode's correlation tab. These tests pin the on-write seam: + * a sustained run escalates on INGEST through the SAME illness red-flag push, + * with the SAME run length (RED_FLAG_RUN_DAYS) and absolute floors the detector + * uses, and the 24h ledger dedupe stops a 4th reading re-firing the alarm. + * + * Calendar-day bucketing runs against TZ=UTC (the gate's contract). + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +/** In-memory pushAttempt ledger so the 24h dedupe behaves across calls. */ +const ledger: Array<{ userId: string; reason: string }> = []; +const measurementFindMany = vi.fn(); +const dispatchMock = vi.fn(); + +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { + findMany: (...a: unknown[]) => measurementFindMany(...a), + }, + pushAttempt: { + findFirst: ({ where }: { where: { userId: string; reason: string } }) => { + const hit = ledger.find( + (r) => r.userId === where.userId && r.reason === where.reason, + ); + return Promise.resolve(hit ? { id: "prior" } : null); + }, + create: ({ data }: { data: { userId: string; reason: string } }) => { + ledger.push({ userId: data.userId, reason: data.reason }); + return Promise.resolve({}); + }, + }, + }, +})); + +vi.mock("@/lib/logging/context", () => ({ + getEvent: () => ({ addMeta: vi.fn(), addWarning: vi.fn() }), +})); + +vi.mock("@/lib/modules/gate", () => ({ + isModuleEnabled: vi.fn().mockResolvedValue(false), +})); + +vi.mock("@/lib/notifications/dispatch-localised", () => ({ + dispatchLocalisedNotification: (...a: unknown[]) => dispatchMock(...a), +})); + +import { runSafetyFloorCheck } from "../safety-floor-check"; + +/** A measured-at on a given UTC calendar day, mid-morning. */ +function utcDay(day: string): Date { + return new Date(`${day}T08:00:00Z`); +} + +beforeEach(() => { + ledger.length = 0; + measurementFindMany.mockReset(); + dispatchMock.mockReset().mockResolvedValue(undefined); +}); + +afterEach(() => vi.clearAllMocks()); + +describe("runSafetyFloorCheck — sustained fever", () => { + it("escalates once on a 3-day calendar-consecutive fever run", async () => { + // °C ≥ 38.5 on three consecutive UTC days. + measurementFindMany.mockResolvedValue([ + { value: 39.0, measuredAt: utcDay("2026-01-03") }, + { value: 39.1, measuredAt: utcDay("2026-01-02") }, + { value: 38.8, measuredAt: utcDay("2026-01-01") }, + ]); + + await runSafetyFloorCheck({ + userId: "u1", + written: [ + { + type: "BODY_TEMPERATURE", + value: 39.0, + measuredAt: utcDay("2026-01-03"), + }, + ], + timezone: "UTC", + }); + + expect(dispatchMock).toHaveBeenCalledTimes(1); + const opts = dispatchMock.mock.calls[0][0]; + expect(opts.eventType).toBe("SYSTEM_ALERT"); + expect(opts.urgent).toBe(true); + expect(opts.titleKey).toBe("illness.correlation.redFlagTitle"); + expect(opts.messageKey).toBe("illness.correlation.redFlagFever"); + }); + + it("does not re-fire on a 4th reading inside the dedupe window", async () => { + measurementFindMany.mockResolvedValue([ + { value: 39.0, measuredAt: utcDay("2026-01-03") }, + { value: 39.1, measuredAt: utcDay("2026-01-02") }, + { value: 38.8, measuredAt: utcDay("2026-01-01") }, + ]); + + // First ingest — fires. + await runSafetyFloorCheck({ + userId: "u1", + written: [ + { + type: "BODY_TEMPERATURE", + value: 39.0, + measuredAt: utcDay("2026-01-03"), + }, + ], + timezone: "UTC", + }); + // A later same-day reading — run still holds, but the ledger anchor dedupes. + await runSafetyFloorCheck({ + userId: "u1", + written: [ + { + type: "BODY_TEMPERATURE", + value: 39.2, + measuredAt: new Date("2026-01-03T20:00:00Z"), + }, + ], + timezone: "UTC", + }); + + expect(dispatchMock).toHaveBeenCalledTimes(1); + }); + + it("does NOT escalate on a 2-day fever run (below RED_FLAG_RUN_DAYS)", async () => { + measurementFindMany.mockResolvedValue([ + { value: 39.0, measuredAt: utcDay("2026-01-02") }, + { value: 38.9, measuredAt: utcDay("2026-01-01") }, + ]); + + await runSafetyFloorCheck({ + userId: "u1", + written: [ + { + type: "BODY_TEMPERATURE", + value: 39.0, + measuredAt: utcDay("2026-01-02"), + }, + ], + timezone: "UTC", + }); + + expect(dispatchMock).not.toHaveBeenCalled(); + }); + + it("does NOT escalate on three NON-consecutive febrile days", async () => { + measurementFindMany.mockResolvedValue([ + { value: 39.0, measuredAt: utcDay("2026-01-05") }, + { value: 39.0, measuredAt: utcDay("2026-01-03") }, + { value: 39.0, measuredAt: utcDay("2026-01-01") }, + ]); + + await runSafetyFloorCheck({ + userId: "u1", + written: [ + { + type: "BODY_TEMPERATURE", + value: 39.0, + measuredAt: utcDay("2026-01-05"), + }, + ], + timezone: "UTC", + }); + + expect(dispatchMock).not.toHaveBeenCalled(); + }); + + it("does NOT escalate when the run sits below the fever floor", async () => { + // 38.0 °C < 38.5 °C floor — elevated but not the escalation band. + measurementFindMany.mockResolvedValue([ + { value: 38.0, measuredAt: utcDay("2026-01-03") }, + { value: 38.0, measuredAt: utcDay("2026-01-02") }, + { value: 38.0, measuredAt: utcDay("2026-01-01") }, + ]); + + await runSafetyFloorCheck({ + userId: "u1", + written: [ + { + type: "BODY_TEMPERATURE", + value: 38.0, + measuredAt: utcDay("2026-01-03"), + }, + ], + timezone: "UTC", + }); + + expect(dispatchMock).not.toHaveBeenCalled(); + }); +}); + +describe("runSafetyFloorCheck — sustained low SpO2", () => { + it("escalates once on a 3-day calendar-consecutive low-SpO2 run", async () => { + // ≤ 92% on three consecutive UTC days (per-day MIN is the worst). + measurementFindMany.mockResolvedValue([ + { value: 89, measuredAt: utcDay("2026-01-03") }, + { value: 91, measuredAt: utcDay("2026-01-02") }, + { value: 92, measuredAt: utcDay("2026-01-01") }, + ]); + + await runSafetyFloorCheck({ + userId: "u1", + written: [ + { + type: "OXYGEN_SATURATION", + value: 89, + measuredAt: utcDay("2026-01-03"), + }, + ], + timezone: "UTC", + }); + + expect(dispatchMock).toHaveBeenCalledTimes(1); + const opts = dispatchMock.mock.calls[0][0]; + expect(opts.titleKey).toBe("illness.correlation.redFlagTitle"); + expect(opts.messageKey).toBe("illness.correlation.redFlagSpo2"); + }); + + it("does NOT escalate when a single day recovers above the floor", async () => { + measurementFindMany.mockResolvedValue([ + { value: 89, measuredAt: utcDay("2026-01-03") }, + { value: 96, measuredAt: utcDay("2026-01-02") }, // breaks the run + { value: 90, measuredAt: utcDay("2026-01-01") }, + ]); + + await runSafetyFloorCheck({ + userId: "u1", + written: [ + { + type: "OXYGEN_SATURATION", + value: 89, + measuredAt: utcDay("2026-01-03"), + }, + ], + timezone: "UTC", + }); + + expect(dispatchMock).not.toHaveBeenCalled(); + }); + + it("a fever escalation never suppresses a concurrent SpO2 one (distinct dedupe keys)", async () => { + // Two independent runs landing on the same write: both should fire once. + measurementFindMany.mockImplementation( + ({ where }: { where: { type: string } }) => { + if (where.type === "BODY_TEMPERATURE") { + return Promise.resolve([ + { value: 39.0, measuredAt: utcDay("2026-01-03") }, + { value: 39.0, measuredAt: utcDay("2026-01-02") }, + { value: 39.0, measuredAt: utcDay("2026-01-01") }, + ]); + } + return Promise.resolve([ + { value: 89, measuredAt: utcDay("2026-01-03") }, + { value: 90, measuredAt: utcDay("2026-01-02") }, + { value: 91, measuredAt: utcDay("2026-01-01") }, + ]); + }, + ); + + await runSafetyFloorCheck({ + userId: "u1", + written: [ + { + type: "BODY_TEMPERATURE", + value: 39.0, + measuredAt: utcDay("2026-01-03"), + }, + { + type: "OXYGEN_SATURATION", + value: 89, + measuredAt: utcDay("2026-01-03"), + }, + ], + timezone: "UTC", + }); + + expect(dispatchMock).toHaveBeenCalledTimes(2); + const keys = dispatchMock.mock.calls.map((c) => c[0].messageKey).sort(); + expect(keys).toEqual([ + "illness.correlation.redFlagFever", + "illness.correlation.redFlagSpo2", + ]); + }); +}); diff --git a/src/lib/illness/correlation.ts b/src/lib/illness/correlation.ts index 4cb561d16..e9edbe04c 100644 --- a/src/lib/illness/correlation.ts +++ b/src/lib/illness/correlation.ts @@ -408,8 +408,14 @@ export interface IllnessRedFlag { const SPO2_RED_FLAG = SPO2_RED_FLAG_PCT; /** Body temperature at/above this (°C) for ≥ RED_FLAG_RUN_DAYS escalates. */ const FEVER_RED_FLAG = FEVER_RED_FLAG_C; -/** Consecutive days an adverse clinical threshold must hold to escalate. */ -const RED_FLAG_RUN_DAYS = 3; +/** + * Consecutive days an adverse clinical threshold must hold to escalate. + * Exported so the on-write safety seam (`safety-floor-check.ts`) escalates a + * sustained fever / low-SpO2 run on ingest using the SAME run length as this + * retrospective detector — the two paths can never disagree on what "sustained" + * means. + */ +export const RED_FLAG_RUN_DAYS = 3; /* ── pure helpers ─────────────────────────────────────────────────────── */ diff --git a/src/lib/illness/safety-floor-check.ts b/src/lib/illness/safety-floor-check.ts index 1b25e1520..908c0cd4f 100644 --- a/src/lib/illness/safety-floor-check.ts +++ b/src/lib/illness/safety-floor-check.ts @@ -15,9 +15,11 @@ * The caller (the measurement POST route) passes the just-written rows plus * the transient `symptomsPresent` flag; this helper does the bounded reads. */ +import type { MeasurementType } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { getEvent } from "@/lib/logging/context"; import { isModuleEnabled } from "@/lib/modules/gate"; +import { FEVER_RED_FLAG_C, SPO2_RED_FLAG_PCT } from "@/lib/clinical-floors"; import { evaluateBloodPressure, evaluateGlucose, @@ -26,6 +28,13 @@ import { type GlucoseSample, } from "@/lib/illness/safety-floors"; import { notifySafetyFloor } from "@/lib/illness/safety-floor-notify"; +import { + RED_FLAG_RUN_DAYS, + dayDiff, + type IllnessRedFlag, +} from "@/lib/illness/correlation"; +import { notifyIllnessRedFlag } from "@/lib/illness/red-flag-notify"; +import { userDayKey } from "@/lib/tz/format"; /** A row the write hook just persisted (the subset the check needs). */ export interface WrittenReading { @@ -46,14 +55,30 @@ export async function runSafetyFloorCheck(input: { userId: string; written: WrittenReading[]; symptomsPresent?: boolean; + /** + * The user's IANA timezone, used to bucket the sustained fever / SpO2 run by + * CALENDAR day. Defaults to UTC when absent — a missing timezone must never + * silence a safety escalation, only shift the day boundary. + */ + timezone?: string; }): Promise { const { userId, written } = input; const symptomCoupled = input.symptomsPresent === true; + const tz = + input.timezone && input.timezone.length > 0 ? input.timezone : "UTC"; try { await Promise.all([ checkBloodPressure(userId, written, symptomCoupled), checkGlucose(userId, written, symptomCoupled), + // Sustained multi-day runs against ABSOLUTE clinical floors. Unlike the + // confirm-gated BP / glucose checks above (single breach + a confirming + // re-test inside an hour), a fever / low-SpO2 escalation only fires when + // the floor held for RED_FLAG_RUN_DAYS calendar-consecutive days — the + // SAME run length and floors the retrospective correlation detector uses, + // so the on-write seam and the correlation surface can never disagree. + checkSustainedFever(userId, written, tz), + checkSustainedLowSpo2(userId, written, tz), ]); } catch (err) { getEvent()?.addWarning( @@ -171,3 +196,208 @@ async function checkGlucose( const decision = evaluateGlucose({ candidate, recent, symptomCoupled }); await notifySafetyFloor({ userId, decision }); } + +/* ── sustained fever / low-SpO2 (multi-day calendar runs) ─────────────── */ + +/** + * Lookback span (calendar days, inclusive of the candidate day) the sustained + * run scan reads. RED_FLAG_RUN_DAYS days are needed to form a run that ends on + * the candidate day; one extra day absorbs UTC-vs-local boundary skew so a run + * that ends "today" in the user's timezone is never clipped. We only react to + * RECENT runs by design — an old run is the correlation surface's job, not the + * write hook's. + */ +const SUSTAINED_LOOKBACK_DAYS = RED_FLAG_RUN_DAYS + 1; + +/** + * Sustained fever: BODY_TEMPERATURE ≥ FEVER_RED_FLAG_C (°C) for + * RED_FLAG_RUN_DAYS calendar-consecutive days. Mirrors the correlation + * detector's fever path — per-day MAX is the worst (an evening spike must not + * be masked by a daily mean) and the floor + run length are the shared + * constants. Fires the SAME illness red-flag escalation the retrospective path + * fires, so the notification class matches exactly. + */ +async function checkSustainedFever( + userId: string, + written: WrittenReading[], + tz: string, +): Promise { + const fresh = written.find((r) => r.type === "BODY_TEMPERATURE"); + if (!fresh) return; + + const days = await loadDailyWorst( + userId, + "BODY_TEMPERATURE", + fresh.measuredAt, + tz, + "max", + ); + const flag = sustainedRunFlag( + days, + (v) => v >= FEVER_RED_FLAG_C, + "sustained_fever", + "BODY_TEMPERATURE", + "max", + ); + if (!flag) return; + await notifyIllnessRedFlag({ + userId, + episodeId: SUSTAINED_DEDUPE_KEY.sustained_fever, + redFlags: [flag], + }); +} + +/** + * Sustained low SpO2: OXYGEN_SATURATION ≤ SPO2_RED_FLAG_PCT for + * RED_FLAG_RUN_DAYS calendar-consecutive days. SpO2's worst is the per-day MIN, + * matching the correlation detector. Fires the SAME illness red-flag escalation. + */ +async function checkSustainedLowSpo2( + userId: string, + written: WrittenReading[], + tz: string, +): Promise { + const fresh = written.find((r) => r.type === "OXYGEN_SATURATION"); + if (!fresh) return; + + const days = await loadDailyWorst( + userId, + "OXYGEN_SATURATION", + fresh.measuredAt, + tz, + "min", + ); + const flag = sustainedRunFlag( + days, + (v) => v <= SPO2_RED_FLAG_PCT, + "sustained_low_spo2", + "OXYGEN_SATURATION", + "min", + ); + if (!flag) return; + await notifyIllnessRedFlag({ + userId, + episodeId: SUSTAINED_DEDUPE_KEY.sustained_low_spo2, + redFlags: [flag], + }); +} + +/** + * Synthetic, stable per-reason dedupe anchors. The on-write sustained checks + * may run with no active illness episode, so they cannot key the red-flag + * dedupe ledger on a real episode id. These per-reason keys ride the SAME + * `pushAttempt` ledger + 24h window `notifyIllnessRedFlag` already enforces, so + * a stubbornly febrile user logging a 4th reading inside the day re-runs the + * detector but never re-fires the alarm. Distinct keys per reason so a fever + * escalation never suppresses a concurrent SpO2 one. + */ +const SUSTAINED_DEDUPE_KEY: Record = { + sustained_fever: "onwrite:sustained_fever", + sustained_low_spo2: "onwrite:sustained_low_spo2", +}; + +/** A per-day worst value, keyed by the user-timezone calendar day. */ +interface DailyWorst { + day: string; + worst: number; +} + +/** + * Read the recent same-type readings in the lookback window and collapse them + * to one worst value per user-timezone calendar day (MAX for fever, MIN for + * SpO2). Sorted chronologically so the run scan sees calendar order. Bounded + * read (window + row cap); the candidate's own freshly-written rows are + * INCLUDED so a run that completes on this very write escalates. + */ +async function loadDailyWorst( + userId: string, + type: MeasurementType, + candidateAt: Date, + tz: string, + worst: "min" | "max", +): Promise { + const windowStart = new Date( + candidateAt.getTime() - SUSTAINED_LOOKBACK_DAYS * 24 * 60 * 60 * 1000, + ); + const rows = await prisma.measurement.findMany({ + where: { + userId, + type, + deletedAt: null, + measuredAt: { gte: windowStart, lte: candidateAt }, + }, + orderBy: { measuredAt: "desc" }, + take: 500, + select: { value: true, measuredAt: true }, + }); + + const byDay = new Map(); + for (const r of rows) { + const day = userDayKey(r.measuredAt, tz); + const prev = byDay.get(day); + if (prev === undefined) { + byDay.set(day, r.value); + } else { + byDay.set( + day, + worst === "min" ? Math.min(prev, r.value) : Math.max(prev, r.value), + ); + } + } + + return [...byDay.entries()] + .map(([day, w]) => ({ day, worst: w })) + .sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0)); +} + +/** + * Longest CALENDAR-consecutive run matching `predicate`; returns an + * {@link IllnessRedFlag} when the run reaches RED_FLAG_RUN_DAYS, else null. + * Mirrors the correlation engine's `runFlag`: present-days-only points (never + * zero-filled), so a sparse febrile series (days 01/10/15) must NOT count as + * one long run — a point only extends the run when it is the calendar day + * immediately after the previous matching one (`dayDiff === 1`). + */ +function sustainedRunFlag( + sorted: DailyWorst[], + predicate: (v: number) => boolean, + reason: IllnessRedFlag["reason"], + type: MeasurementType, + worst: "min" | "max", +): IllnessRedFlag | null { + let bestRun = 0; + let bestWorst: number | null = null; + let run = 0; + let runWorst: number | null = null; + let prevDay: string | null = null; + for (const p of sorted) { + if (predicate(p.worst)) { + const adjacent = prevDay !== null && dayDiff(prevDay, p.day) === 1; + if (adjacent) { + run++; + runWorst = + runWorst === null + ? p.worst + : worst === "min" + ? Math.min(runWorst, p.worst) + : Math.max(runWorst, p.worst); + } else { + run = 1; + runWorst = p.worst; + } + if (run > bestRun) { + bestRun = run; + bestWorst = runWorst; + } + prevDay = p.day; + } else { + run = 0; + runWorst = null; + prevDay = null; + } + } + if (bestRun >= RED_FLAG_RUN_DAYS && bestWorst !== null) { + return { type, reason, worstValue: bestWorst, days: bestRun }; + } + return null; +}