From f20be2918958410d97f206c782f1ee055a683d27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 18 Jun 2026 20:12:11 +0200 Subject: [PATCH] feat(illness): escalate confirmed clinical safety floors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add absolute, guideline-backed safety floors that escalate a freshly logged reading — but only after a second, confirming reading inside a one-hour re-test window, so a single oscillometric spike or a one-off finger-stick can never raise a false alarm: - blood pressure 180/120 or higher (re-check, contact your doctor; symptom- coupled lifts to emergency copy) and a symmetric low-systolic-under-90 cautionary tier; - glucose under 70 (low) and under 54 (very low), and a sustained very-high 250-or-higher tier. The 200 DKA criterion deliberately never shows an all-clear and never urgently escalates on its own, since a value alone cannot rule euglycemic DKA in or out. The escalation reuses the urgent SYSTEM_ALERT path with a 24h per-reason dedup, never names a condition, and gates glucose behind the glucose module. An optional transient `symptomsPresent` flag on the measurement write lifts a confirmed breach from the contact-your-doctor copy to the emergency copy. The measurement POST runs the check fire-and-forget so a notification failure never fails the user's write. --- src/app/api/measurements/route.ts | 51 +++- .../__tests__/safety-floor-notify.test.ts | 107 ++++++++ .../illness/__tests__/safety-floors.test.ts | 227 ++++++++++++++++ src/lib/illness/safety-floor-check.ts | 173 +++++++++++++ src/lib/illness/safety-floor-notify.ts | 123 +++++++++ src/lib/illness/safety-floors.ts | 242 ++++++++++++++++++ src/lib/validations/measurement.ts | 6 + 7 files changed, 927 insertions(+), 2 deletions(-) create mode 100644 src/lib/illness/__tests__/safety-floor-notify.test.ts create mode 100644 src/lib/illness/__tests__/safety-floors.test.ts create mode 100644 src/lib/illness/safety-floor-check.ts create mode 100644 src/lib/illness/safety-floor-notify.ts create mode 100644 src/lib/illness/safety-floors.ts diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index 77c23833a..0aac4a53c 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -30,6 +30,7 @@ import { withIdempotency } from "@/lib/idempotency"; import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy"; +import { runSafetyFloorCheck } from "@/lib/illness/safety-floor-check"; import { recomputeBucketsForMeasurement, collapseToTypeDayKeys, @@ -776,6 +777,25 @@ async function postMeasurement(request: NextRequest) { // reminders against the just-landed readings now. Fire-and-forget. void enqueueReminderSatisfy(user.id).catch(() => {}); + // v1.18.6 — absolute clinical safety-floor check on the just-written + // readings (confirm-gated, module-gated, never diagnoses). The combined + // BP form lands both arms here, so the BP floor sees a whole reading. + // The transient per-entry `symptomsPresent` flag (ORed across the batch) + // lifts a confirmed breach to the symptom-coupled emergency copy. + // Fire-and-forget — a notification failure never fails the write. + void runSafetyFloorCheck({ + userId: user.id, + written: results.map((r) => ({ + type: r.type, + value: r.value, + measuredAt: r.measuredAt, + glucoseContext: r.glucoseContext, + })), + symptomsPresent: parsed.data.measurements.some( + (m) => m.symptomsPresent === true, + ), + }).catch(() => {}); + // v1.5.0 — refresh the persistent rollup table for every distinct // (type, day) the batch touched so the next analytics / coach read // hits the cache rather than falling through to live aggregation. @@ -836,8 +856,16 @@ async function postMeasurement(request: NextRequest) { return returnAllZodIssues(parsed.error, 422); } - const { type, value, measuredAt, notes, source, glucoseContext, deviceType } = - parsed.data; + const { + type, + value, + measuredAt, + notes, + source, + glucoseContext, + deviceType, + symptomsPresent, + } = parsed.data; // Handle unique constraint violation let measurement; @@ -893,6 +921,25 @@ async function postMeasurement(request: NextRequest) { // against the just-landed reading now. Fire-and-forget. void enqueueReminderSatisfy(user.id).catch(() => {}); + // v1.18.6 — absolute clinical safety-floor check (confirm-gated, + // module-gated, never diagnoses). A single-entry POST carries only one arm + // of a BP reading, so the BP floor only fires through the combined-form / + // batch path; a lone glucose reading is evaluated here. The transient + // `symptomsPresent` flag lifts a confirmed breach to the emergency copy. + // Fire-and-forget — a notification failure never fails the write. + void runSafetyFloorCheck({ + userId: user.id, + written: [ + { + type: measurement.type, + value: measurement.value, + measuredAt: measurement.measuredAt, + glucoseContext: measurement.glucoseContext, + }, + ], + symptomsPresent, + }).catch(() => {}); + // v1.5.0 — refresh the persistent rollup row for the affected // (type, day) tuple. Runs inline so the next read of the // comprehensive / analytics surface is correct without waiting on diff --git a/src/lib/illness/__tests__/safety-floor-notify.test.ts b/src/lib/illness/__tests__/safety-floor-notify.test.ts new file mode 100644 index 000000000..b16aaa123 --- /dev/null +++ b/src/lib/illness/__tests__/safety-floor-notify.test.ts @@ -0,0 +1,107 @@ +/** + * v1.18.6 — safety-floor escalation push. + * + * Pins: null decision = no-op; a confirmed breach dispatches an URGENT + * SYSTEM_ALERT; asymptomatic → Doctor copy, symptom-coupled → Emergency copy; + * a recent ledger row de-dupes by reason; failures never throw; the ledger + * anchor is stamped before dispatch. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const findFirstMock = vi.fn(); +const createMock = vi.fn(); +const dispatchMock = vi.fn(); + +vi.mock("@/lib/db", () => ({ + prisma: { + pushAttempt: { + findFirst: (...a: unknown[]) => findFirstMock(...a), + create: (...a: unknown[]) => createMock(...a), + }, + }, +})); + +vi.mock("@/lib/logging/context", () => ({ + getEvent: () => ({ addMeta: vi.fn(), addWarning: vi.fn() }), +})); + +vi.mock("@/lib/notifications/dispatch-localised", () => ({ + dispatchLocalisedNotification: (...a: unknown[]) => dispatchMock(...a), +})); + +import { notifySafetyFloor } from "../safety-floor-notify"; +import type { SafetyFloorDecision } from "../safety-floors"; + +const bpHigh: SafetyFloorDecision = { + kind: "BLOOD_PRESSURE", + reason: "bp_hypertensive", + tier: "severe", + symptomCoupled: false, + value: 188, + diastolic: 122, +}; +const glucoseLowSymptomatic: SafetyFloorDecision = { + kind: "GLUCOSE", + reason: "glucose_hypo_severe", + tier: "severe", + symptomCoupled: true, + value: 48, + diastolic: null, +}; + +beforeEach(() => { + findFirstMock.mockReset().mockResolvedValue(null); + createMock.mockReset().mockResolvedValue({}); + dispatchMock.mockReset().mockResolvedValue(undefined); +}); + +afterEach(() => vi.clearAllMocks()); + +describe("notifySafetyFloor", () => { + it("no-op when the decision is null", async () => { + await notifySafetyFloor({ userId: "u1", decision: null }); + expect(dispatchMock).not.toHaveBeenCalled(); + expect(findFirstMock).not.toHaveBeenCalled(); + }); + + it("dispatches an URGENT SYSTEM_ALERT with the asymptomatic Doctor copy", async () => { + await notifySafetyFloor({ userId: "u1", decision: bpHigh }); + expect(dispatchMock).toHaveBeenCalledTimes(1); + const opts = dispatchMock.mock.calls[0][0]; + expect(opts.urgent).toBe(true); + expect(opts.eventType).toBe("SYSTEM_ALERT"); + expect(opts.userId).toBe("u1"); + expect(opts.titleKey).toBe("safety.floor.bpHighTitle"); + expect(opts.messageKey).toBe("safety.floor.bpHighDoctor"); + expect(opts.params).toEqual({ systolic: 188, diastolic: 122 }); + }); + + it("uses the Emergency copy variant when symptom-coupled", async () => { + await notifySafetyFloor({ userId: "u1", decision: glucoseLowSymptomatic }); + const opts = dispatchMock.mock.calls[0][0]; + expect(opts.messageKey).toBe("safety.floor.glucoseLowSevereEmergency"); + expect(opts.titleKey).toBe("safety.floor.glucoseLowSevereTitle"); + expect(opts.params).toEqual({ value: 48 }); + }); + + it("de-dupes by reason when a recent ledger row exists", async () => { + findFirstMock.mockResolvedValueOnce({ id: "prior" }); + await notifySafetyFloor({ userId: "u1", decision: bpHigh }); + expect(dispatchMock).not.toHaveBeenCalled(); + expect(createMock).not.toHaveBeenCalled(); + }); + + it("stamps the ledger anchor (keyed by reason) before dispatching", async () => { + await notifySafetyFloor({ userId: "u9", decision: bpHigh }); + expect(createMock).toHaveBeenCalledTimes(1); + expect(createMock.mock.calls[0][0].data.reason).toContain("bp_hypertensive"); + expect(createMock.mock.calls[0][0].data.eventType).toBe("SYSTEM_ALERT"); + }); + + it("never throws when dispatch fails", async () => { + dispatchMock.mockRejectedValueOnce(new Error("boom")); + await expect( + notifySafetyFloor({ userId: "u1", decision: bpHigh }), + ).resolves.toBeUndefined(); + }); +}); diff --git a/src/lib/illness/__tests__/safety-floors.test.ts b/src/lib/illness/__tests__/safety-floors.test.ts new file mode 100644 index 000000000..4af788946 --- /dev/null +++ b/src/lib/illness/__tests__/safety-floors.test.ts @@ -0,0 +1,227 @@ +/** + * v1.18.6 — absolute clinical safety-floor engine. + * + * Pins each threshold + the confirm-before-alarm gate. The engine must NEVER + * escalate on a single breach reading — a prior same-floor reading inside the + * confirm window is required. Severe tiers keep the confirm gate; symptom + * coupling only lifts the copy tier, never the gate. + */ +import { describe, expect, it } from "vitest"; +import { + evaluateBloodPressure, + evaluateGlucose, + CONFIRM_WINDOW_MS, + BP_SYS_HYPERTENSIVE, + BP_DIA_HYPERTENSIVE, + BP_SYS_HYPOTENSIVE, + GLUCOSE_HYPO, + GLUCOSE_HYPO_SEVERE, + GLUCOSE_HYPER, + type BpSample, + type GlucoseSample, +} from "../safety-floors"; + +const T0 = new Date("2026-06-18T09:00:00Z"); +/** A confirming prior reading 10 minutes before the candidate (in window). */ +const T_PRIOR = new Date(T0.getTime() - 10 * 60 * 1000); +/** A prior reading just outside the confirm window. */ +const T_STALE = new Date(T0.getTime() - CONFIRM_WINDOW_MS - 60 * 1000); + +function bp(measuredAt: Date, systolic: number, diastolic: number): BpSample { + return { measuredAt, systolic, diastolic }; +} +function gl(measuredAt: Date, mgdl: number): GlucoseSample { + return { measuredAt, mgdl }; +} + +describe("safety-floors — blood pressure", () => { + it("does not escalate on a single hypertensive reading (no confirm)", () => { + const d = evaluateBloodPressure({ + candidate: bp(T0, 185, 110), + recent: [], + symptomCoupled: false, + }); + expect(d).toBeNull(); + }); + + it("escalates a confirmed hypertensive breach (>=180 systolic) at severe tier", () => { + const d = evaluateBloodPressure({ + candidate: bp(T0, BP_SYS_HYPERTENSIVE, 100), + recent: [bp(T_PRIOR, 182, 98)], + symptomCoupled: false, + }); + expect(d).not.toBeNull(); + expect(d!.reason).toBe("bp_hypertensive"); + expect(d!.tier).toBe("severe"); + expect(d!.symptomCoupled).toBe(false); + expect(d!.value).toBe(BP_SYS_HYPERTENSIVE); + expect(d!.diastolic).toBe(100); + }); + + it("escalates when only the diastolic crosses the floor (>=120)", () => { + const d = evaluateBloodPressure({ + candidate: bp(T0, 150, BP_DIA_HYPERTENSIVE), + recent: [bp(T_PRIOR, 150, 122)], + symptomCoupled: false, + }); + expect(d?.reason).toBe("bp_hypertensive"); + }); + + it("does not confirm with a reading outside the window", () => { + const d = evaluateBloodPressure({ + candidate: bp(T0, 190, 115), + recent: [bp(T_STALE, 190, 115)], + symptomCoupled: false, + }); + expect(d).toBeNull(); + }); + + it("does not confirm with a prior reading of a DIFFERENT floor family", () => { + // candidate hypertensive, prior hypotensive — not the same floor. + const d = evaluateBloodPressure({ + candidate: bp(T0, 185, 110), + recent: [bp(T_PRIOR, 85, 55)], + symptomCoupled: false, + }); + expect(d).toBeNull(); + }); + + it("treats low BP (<90 systolic) as caution when asymptomatic", () => { + const d = evaluateBloodPressure({ + candidate: bp(T0, BP_SYS_HYPOTENSIVE - 1, 55), + recent: [bp(T_PRIOR, 86, 54)], + symptomCoupled: false, + }); + expect(d?.reason).toBe("bp_hypotensive"); + expect(d?.tier).toBe("caution"); + }); + + it("lifts low BP to severe when symptom-coupled", () => { + const d = evaluateBloodPressure({ + candidate: bp(T0, 85, 55), + recent: [bp(T_PRIOR, 86, 54)], + symptomCoupled: true, + }); + expect(d?.reason).toBe("bp_hypotensive"); + expect(d?.tier).toBe("severe"); + expect(d?.symptomCoupled).toBe(true); + }); + + it("does not escalate a normal reading", () => { + const d = evaluateBloodPressure({ + candidate: bp(T0, 120, 78), + recent: [bp(T_PRIOR, 122, 80)], + symptomCoupled: false, + }); + expect(d).toBeNull(); + }); + + it("ignores a future-dated prior reading (after the candidate)", () => { + const future = new Date(T0.getTime() + 5 * 60 * 1000); + const d = evaluateBloodPressure({ + candidate: bp(T0, 185, 110), + recent: [bp(future, 185, 110)], + symptomCoupled: false, + }); + expect(d).toBeNull(); + }); +}); + +describe("safety-floors — glucose", () => { + it("does not escalate a single low reading (no confirm)", () => { + const d = evaluateGlucose({ + candidate: gl(T0, 65), + recent: [], + symptomCoupled: false, + }); + expect(d).toBeNull(); + }); + + it("escalates a confirmed hypo (<70) at caution tier", () => { + const d = evaluateGlucose({ + candidate: gl(T0, GLUCOSE_HYPO - 1), + recent: [gl(T_PRIOR, 66)], + symptomCoupled: false, + }); + expect(d?.reason).toBe("glucose_hypo"); + expect(d?.tier).toBe("caution"); + expect(d?.value).toBe(GLUCOSE_HYPO - 1); + }); + + it("escalates a confirmed severe-hypo (<54) at severe tier", () => { + const d = evaluateGlucose({ + candidate: gl(T0, GLUCOSE_HYPO_SEVERE - 4), + recent: [gl(T_PRIOR, 52)], + symptomCoupled: false, + }); + expect(d?.reason).toBe("glucose_hypo_severe"); + expect(d?.tier).toBe("severe"); + }); + + it("confirms a severe-hypo candidate with any low (<70) re-test (same floor family)", () => { + const d = evaluateGlucose({ + candidate: gl(T0, 50), + recent: [gl(T_PRIOR, 68)], // 68 is hypo but not severe — still confirms LOW family + symptomCoupled: false, + }); + expect(d?.reason).toBe("glucose_hypo_severe"); + expect(d?.tier).toBe("severe"); + }); + + it("escalates a confirmed hyper (>=250) at severe tier", () => { + const d = evaluateGlucose({ + candidate: gl(T0, GLUCOSE_HYPER), + recent: [gl(T_PRIOR, 270)], + symptomCoupled: false, + }); + expect(d?.reason).toBe("glucose_hyper"); + expect(d?.tier).toBe("severe"); + }); + + it("does NOT escalate the 200 DKA criterion (euglycemic-DKA conservatism)", () => { + // ADA-2026 lowered the DKA criterion to >=200, but a value alone cannot + // rule DKA in or out, so we never URGENTLY escalate at 200 — only at 250. + const d = evaluateGlucose({ + candidate: gl(T0, 210), + recent: [gl(T_PRIOR, 220)], + symptomCoupled: false, + }); + expect(d).toBeNull(); + }); + + it("does not confirm a low candidate with a high re-test (different family)", () => { + const d = evaluateGlucose({ + candidate: gl(T0, 60), + recent: [gl(T_PRIOR, 260)], + symptomCoupled: false, + }); + expect(d).toBeNull(); + }); + + it("does not confirm outside the window", () => { + const d = evaluateGlucose({ + candidate: gl(T0, 50), + recent: [gl(T_STALE, 50)], + symptomCoupled: false, + }); + expect(d).toBeNull(); + }); + + it("carries the symptom flag through to the decision", () => { + const d = evaluateGlucose({ + candidate: gl(T0, 300), + recent: [gl(T_PRIOR, 280)], + symptomCoupled: true, + }); + expect(d?.symptomCoupled).toBe(true); + }); + + it("does not escalate an in-range reading", () => { + const d = evaluateGlucose({ + candidate: gl(T0, 110), + recent: [gl(T_PRIOR, 105)], + symptomCoupled: false, + }); + expect(d).toBeNull(); + }); +}); diff --git a/src/lib/illness/safety-floor-check.ts b/src/lib/illness/safety-floor-check.ts new file mode 100644 index 000000000..1b25e1520 --- /dev/null +++ b/src/lib/illness/safety-floor-check.ts @@ -0,0 +1,173 @@ +/** + * Safety-floor write-hook orchestrator (v1.18.6). + * + * Runs AFTER a measurement write lands. Resolves the recent same-kind readings + * the confirm gate needs, runs the absolute-floor engine (`safety-floors.ts`), + * and dispatches an urgent escalation (`safety-floor-notify.ts`) when — and + * only when — a fresh reading breaches a clinical floor AND a prior reading + * inside the confirm window held the same floor. + * + * Module-gated: glucose checks only run when the `glucose` module is enabled; + * BP is a CORE domain (always on) but the BP check is still cheap and only + * fires on an extreme reading. Owner-scoped, fire-and-forget, never throws — + * a notification failure must never fail the user's measurement write. + * + * The caller (the measurement POST route) passes the just-written rows plus + * the transient `symptomsPresent` flag; this helper does the bounded reads. + */ +import { prisma } from "@/lib/db"; +import { getEvent } from "@/lib/logging/context"; +import { isModuleEnabled } from "@/lib/modules/gate"; +import { + evaluateBloodPressure, + evaluateGlucose, + CONFIRM_WINDOW_MS, + type BpSample, + type GlucoseSample, +} from "@/lib/illness/safety-floors"; +import { notifySafetyFloor } from "@/lib/illness/safety-floor-notify"; + +/** A row the write hook just persisted (the subset the check needs). */ +export interface WrittenReading { + type: string; + value: number; + measuredAt: Date; + glucoseContext?: string | null; +} + +/** + * Evaluate the just-written readings against the absolute safety floors and + * dispatch any confirmed escalation. Best-effort. + * + * `symptomsPresent` is the transient per-write flag — true lifts a confirmed + * breach to the symptom-coupled emergency copy. Defaults to false. + */ +export async function runSafetyFloorCheck(input: { + userId: string; + written: WrittenReading[]; + symptomsPresent?: boolean; +}): Promise { + const { userId, written } = input; + const symptomCoupled = input.symptomsPresent === true; + + try { + await Promise.all([ + checkBloodPressure(userId, written, symptomCoupled), + checkGlucose(userId, written, symptomCoupled), + ]); + } catch (err) { + getEvent()?.addWarning( + `safety-floor check failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } +} + +/* ── blood pressure ───────────────────────────────────────────────────── */ + +async function checkBloodPressure( + userId: string, + written: WrittenReading[], + symptomCoupled: boolean, +): Promise { + // BP is stored as two rows (SYS + DIA). We only evaluate when BOTH arms of + // the candidate reading are present in this write (the normal combined-form + // and iOS batch path) so we never reason about half a reading. + const sys = written.find((r) => r.type === "BLOOD_PRESSURE_SYS"); + const dia = written.find((r) => r.type === "BLOOD_PRESSURE_DIA"); + if (!sys || !dia) return; + + const candidate: BpSample = { + measuredAt: sys.measuredAt, + systolic: sys.value, + diastolic: dia.value, + }; + + // Pull the recent SYS + DIA rows inside the confirm window and pair them by + // timestamp so the engine sees prior whole readings, not loose arms. The + // candidate's own rows are excluded by the strict `lt: measuredAt` bound. + const windowStart = new Date( + candidate.measuredAt.getTime() - CONFIRM_WINDOW_MS, + ); + const rows = await prisma.measurement.findMany({ + where: { + userId, + type: { in: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"] }, + deletedAt: null, + measuredAt: { gte: windowStart, lt: candidate.measuredAt }, + }, + orderBy: { measuredAt: "desc" }, + take: 50, + select: { type: true, value: true, measuredAt: true }, + }); + + const recent = pairBp(rows); + const decision = evaluateBloodPressure({ candidate, recent, symptomCoupled }); + await notifySafetyFloor({ userId, decision }); +} + +/** + * Pair loose SYS/DIA rows into whole BP samples by exact timestamp. A combined + * BP form writes both arms at the same instant, so an exact-instant join is + * the right grain here (the confirm gate only needs prior WHOLE readings). + */ +function pairBp( + rows: ReadonlyArray<{ type: string; value: number; measuredAt: Date }>, +): BpSample[] { + const sysByTs = new Map(); + const diaByTs = new Map(); + for (const r of rows) { + const ts = r.measuredAt.getTime(); + if (r.type === "BLOOD_PRESSURE_SYS") sysByTs.set(ts, r.value); + else if (r.type === "BLOOD_PRESSURE_DIA") diaByTs.set(ts, r.value); + } + const out: BpSample[] = []; + for (const [ts, systolic] of sysByTs) { + const diastolic = diaByTs.get(ts); + if (diastolic === undefined) continue; + out.push({ measuredAt: new Date(ts), systolic, diastolic }); + } + return out; +} + +/* ── glucose ──────────────────────────────────────────────────────────── */ + +async function checkGlucose( + userId: string, + written: WrittenReading[], + symptomCoupled: boolean, +): Promise { + const glucose = written.find((r) => r.type === "BLOOD_GLUCOSE"); + if (!glucose) return; + + // Module gate: only run when the glucose module is enabled for this account. + if (!(await isModuleEnabled(userId, "glucose"))) return; + + const candidate: GlucoseSample = { + measuredAt: glucose.measuredAt, + mgdl: glucose.value, + }; + + const windowStart = new Date( + candidate.measuredAt.getTime() - CONFIRM_WINDOW_MS, + ); + const rows = await prisma.measurement.findMany({ + where: { + userId, + type: "BLOOD_GLUCOSE", + deletedAt: null, + measuredAt: { gte: windowStart, lt: candidate.measuredAt }, + }, + orderBy: { measuredAt: "desc" }, + take: 50, + select: { value: true, measuredAt: true }, + }); + + const recent: GlucoseSample[] = rows.map((r) => ({ + measuredAt: r.measuredAt, + mgdl: r.value, + })); + const decision = evaluateGlucose({ candidate, recent, symptomCoupled }); + await notifySafetyFloor({ userId, decision }); +} diff --git a/src/lib/illness/safety-floor-notify.ts b/src/lib/illness/safety-floor-notify.ts new file mode 100644 index 000000000..5e206088b --- /dev/null +++ b/src/lib/illness/safety-floor-notify.ts @@ -0,0 +1,123 @@ +/** + * Absolute safety-floor escalation push (v1.18.6). + * + * Bridges a {@link SafetyFloorDecision} (from `safety-floors.ts`, already + * confirm-gated) to the notification dispatcher at the URGENT level, reusing + * the same urgent SYSTEM_ALERT + 24h-dedup mechanics as the illness red-flag + * push (`red-flag-notify.ts`). + * + * Copy framing (firm, safety-reviewed): + * - NEVER diagnoses (no "hypertensive emergency", no "DKA"). + * - ASYMPTOMATIC breach → "re-check and contact your doctor" copy. + * - SYMPTOM-COUPLED breach → emergency copy ("call emergency services"). + * - The body always echoes the (already re-confirmed) reading value. + * + * Dedup: at most one push per (user, reason) per rolling 24h window, anchored + * on a synthetic `pushAttempt` row keyed by reason — so a user re-checking a + * stubbornly-high reading repeatedly doesn't get spammed. Owner-scoped, never + * throws (a notification failure must not break the measurement write). + */ +import { prisma } from "@/lib/db"; +import { getEvent } from "@/lib/logging/context"; +import { dispatchLocalisedNotification } from "@/lib/notifications/dispatch-localised"; +import type { + SafetyFloorDecision, + SafetyFloorReason, +} from "@/lib/illness/safety-floors"; + +/** One escalation per (user, reason) per this window. */ +const DEDUPE_WINDOW_MS = 24 * 60 * 60 * 1000; + +/** Stable ledger reason prefix so the dedupe lookup is exact-matchable. */ +const LEDGER_REASON_PREFIX = "safety_floor:"; + +/** + * The i18n title/body keys for a decision. Two body variants per reason: the + * asymptomatic "contact your doctor" copy and the symptom-coupled emergency + * copy. Every key below resolves in all six locale bundles (i18n integrity + * guarantee enforced by the locale-integrity test). + */ +function copyKeysFor(decision: SafetyFloorDecision): { + titleKey: string; + messageKey: string; +} { + const variant = decision.symptomCoupled ? "Emergency" : "Doctor"; + const reasonKey: Record = { + bp_hypertensive: "bpHigh", + bp_hypotensive: "bpLow", + glucose_hypo: "glucoseLow", + glucose_hypo_severe: "glucoseLowSevere", + glucose_hyper: "glucoseHigh", + }; + return { + titleKey: `safety.floor.${reasonKey[decision.reason]}Title`, + messageKey: `safety.floor.${reasonKey[decision.reason]}${variant}`, + }; +} + +/** Body params — the re-confirmed reading value(s) echoed into the copy. */ +function paramsFor( + decision: SafetyFloorDecision, +): Record { + if (decision.kind === "BLOOD_PRESSURE") { + return { + systolic: decision.value, + diastolic: decision.diastolic ?? 0, + }; + } + return { value: decision.value }; +} + +/** + * Emit an urgent escalation push for a confirmed safety-floor breach, at most + * once per (user, reason) per `DEDUPE_WINDOW_MS`. No-op when `decision` is + * null. Never throws. + */ +export async function notifySafetyFloor(input: { + userId: string; + decision: SafetyFloorDecision | null; +}): Promise { + const { userId, decision } = input; + if (!decision) return; + + try { + const reason = `${LEDGER_REASON_PREFIX}${decision.reason}`; + const since = new Date(Date.now() - DEDUPE_WINDOW_MS); + const prior = await prisma.pushAttempt.findFirst({ + where: { userId, reason, createdAt: { gte: since } }, + select: { id: true }, + }); + if (prior) return; + + // Stamp the ledger BEFORE dispatching so a concurrent confirm can't + // double-fire. The senders write their own per-channel rows; this + // synthetic row is purely the dedupe anchor. + await prisma.pushAttempt.create({ + data: { + userId, + channel: "WEB_PUSH", + eventType: "SYSTEM_ALERT", + result: "skipped", + reason, + }, + }); + + const { titleKey, messageKey } = copyKeysFor(decision); + await dispatchLocalisedNotification({ + userId, + titleKey, + messageKey, + params: paramsFor(decision), + eventType: "SYSTEM_ALERT", + urgent: true, + }); + + getEvent()?.addMeta("safety_floor_notified", decision.reason); + } catch (err) { + getEvent()?.addWarning( + `safety-floor notify failed (${decision.reason}): ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } +} diff --git a/src/lib/illness/safety-floors.ts b/src/lib/illness/safety-floors.ts new file mode 100644 index 000000000..609e9c38e --- /dev/null +++ b/src/lib/illness/safety-floors.ts @@ -0,0 +1,242 @@ +/** + * Absolute clinical safety floors (v1.18.6) — confirm-before-alarm engine. + * + * The illness *correlation* engine (`correlation.ts`) is retrospective: it + * flags a SUSTAINED multi-day adverse run against a user's OWN baseline. This + * module is its safety-critical complement: it evaluates a SINGLE freshly + * logged reading against ABSOLUTE, guideline-backed clinical floors and, when + * a reading breaches one, decides whether to escalate — but ONLY after a + * second, confirming reading (the AHA "wait and re-measure" discipline), so a + * single oscillometric spike or a one-off finger-stick can never raise a + * false alarm. + * + * Hard design rules (this is health-safety code — conservative by construction): + * + * 1. CONFIRM BEFORE ALARM. A breach reading alone is NEVER enough. We + * require a PRIOR reading of the same kind inside a short re-test window + * that ALSO breached the same floor. One reading → "watch", two → alarm. + * (Severe-tier floors keep the same confirm gate — the differentiator + * between tiers is the copy, not whether we wait.) + * 2. NEVER DIAGNOSE. The copy says "this reading is very high/low — re-check + * and, if it holds, contact your doctor / call emergency services". It + * never names a condition (no "hypertensive emergency", no "DKA"). + * 3. SYMPTOM-COUPLED → emergency copy; ASYMPTOMATIC → contact-doctor copy. + * The caller passes whether the user flagged symptoms with the reading. + * 4. MODULE-GATED. The caller only runs the relevant check when the module + * is enabled (glucose module for glucose, the always-on BP core for BP). + * + * Pure given its inputs — no Prisma, no I/O. The caller resolves the recent + * same-kind readings + the symptom flag and passes them in; the dispatch + + * 24h dedup lives in `safety-floor-notify.ts`. mg/dL + mmHg throughout + * (HealthLog canonical store units). + * + * Citations (general guidance, not medical advice; wide individual variation): + * - BP ≥180/120: ACC/AHA 2017; AHA "Management of Elevated BP in the Acute + * Care Setting" 2024. The number is identical for emergency vs non- + * emergency — symptoms are the differentiator, not the value. Protocol: + * re-measure before acting. + * - Low BP (SBP < 90): NHLBI low-blood-pressure guidance; Hypotension — + * StatPearls/NIH 2024. Cautionary by default; emergency only with self- + * reported shock-type symptoms. + * - Hypoglycemia: ADA Standards of Care §6 — Level 1 alert < 70 mg/dL, + * Level 2 (clinically significant) < 54 mg/dL. Stable 2024–2026. + * - Hyperglycemia / DKA: ADA Standards of Care 2026 — DKA hyperglycemia + * criterion lowered to ≥ 200 mg/dL; ~10% of DKA is euglycemic, so a + * glucose value alone cannot rule it out — we NEVER show "all clear" below + * 200 and reserve the urgent escalation for a sustained very-high band + * (≥ 250 mg/dL) per the practical seek-care trigger. + */ + +/** Reading kinds the absolute-floor engine evaluates. */ +export type SafetyFloorKind = "BLOOD_PRESSURE" | "GLUCOSE"; + +/** Why a reading breached a floor — a stable key the i18n layer renders. */ +export type SafetyFloorReason = + | "bp_hypertensive" // SBP ≥ 180 OR DBP ≥ 120 + | "bp_hypotensive" // SBP < 90 + | "glucose_hypo" // < 70 mg/dL + | "glucose_hypo_severe" // < 54 mg/dL + | "glucose_hyper"; // ≥ 250 mg/dL (sustained-very-high seek-care trigger) + +/** Severity tier — drives copy, never the confirm gate. */ +export type SafetyFloorTier = "caution" | "severe"; + +/* ── absolute floors (documented clinical constants) ─────────────────── */ + +/** Hypertensive-urgency floor: systolic ≥ this OR diastolic ≥ DIA floor. */ +export const BP_SYS_HYPERTENSIVE = 180; +export const BP_DIA_HYPERTENSIVE = 120; +/** Symmetric low-BP cautionary floor: systolic < this. */ +export const BP_SYS_HYPOTENSIVE = 90; + +/** Hypoglycemia Level-1 alert: glucose < this (mg/dL). */ +export const GLUCOSE_HYPO = 70; +/** Hypoglycemia Level-2 (clinically significant): glucose < this (mg/dL). */ +export const GLUCOSE_HYPO_SEVERE = 54; +/** + * Hyperglycemia urgent escalation floor (mg/dL). ADA 2026 lowered the DKA + * hyperglycemia CRITERION to ≥ 200, but a value alone cannot rule DKA in OR + * out (euglycemic DKA exists), so we do NOT urgently escalate at 200. We + * reserve the escalation for the practical "sustained very-high" seek-care + * trigger (≥ 250) and the copy NEVER reads "all clear" below 200. + */ +export const GLUCOSE_HYPER = 250; + +/** + * Confirm window: a breach reading only escalates when a PRIOR same-kind + * reading inside this window ALSO breached the SAME floor. Long enough that a + * deliberate "wait a minute and re-measure" is captured, short enough that two + * unrelated readings days apart don't spuriously confirm each other. + */ +export const CONFIRM_WINDOW_MS = 60 * 60 * 1000; // 1 hour + +/* ── inputs (the caller resolves these and passes them in) ───────────── */ + +/** A blood-pressure reading (systolic + diastolic, mmHg) at an instant. */ +export interface BpSample { + measuredAt: Date; + systolic: number; + diastolic: number; +} + +/** A spot glucose reading (mg/dL canonical) at an instant. */ +export interface GlucoseSample { + measuredAt: Date; + mgdl: number; +} + +/** The escalation decision the engine returns (or null = nothing to do). */ +export interface SafetyFloorDecision { + kind: SafetyFloorKind; + reason: SafetyFloorReason; + tier: SafetyFloorTier; + /** True when the user flagged symptoms → emergency-tier copy. */ + symptomCoupled: boolean; + /** The breaching value(s), for the localised body params. */ + value: number; + /** The diastolic value when `kind === "BLOOD_PRESSURE"` (else null). */ + diastolic: number | null; +} + +/* ── BP evaluation ────────────────────────────────────────────────────── */ + +/** Classify a single BP reading against the absolute floors. */ +function classifyBp( + sys: number, + dia: number, +): { reason: SafetyFloorReason; tier: SafetyFloorTier } | null { + if (sys >= BP_SYS_HYPERTENSIVE || dia >= BP_DIA_HYPERTENSIVE) { + return { reason: "bp_hypertensive", tier: "severe" }; + } + if (sys < BP_SYS_HYPOTENSIVE) { + // Symmetric low-BP is cautionary by default — most hypotension is benign; + // it only becomes urgent when symptom-coupled (handled by the caller's + // symptom flag, which lifts the copy to emergency tier). + return { reason: "bp_hypotensive", tier: "caution" }; + } + return null; +} + +/** + * Evaluate a fresh BP reading against the absolute floors with the confirm + * gate. Returns a decision ONLY when the candidate breaches AND a prior + * reading inside the confirm window breached the SAME reason. `recent` is the + * user's other same-kind readings (excluding the candidate); order-independent. + */ +export function evaluateBloodPressure(input: { + candidate: BpSample; + recent: BpSample[]; + symptomCoupled: boolean; +}): SafetyFloorDecision | null { + const { candidate, recent, symptomCoupled } = input; + const candClass = classifyBp(candidate.systolic, candidate.diastolic); + if (!candClass) return null; + + const windowStart = candidate.measuredAt.getTime() - CONFIRM_WINDOW_MS; + const confirmed = recent.some((r) => { + const t = r.measuredAt.getTime(); + if (t < windowStart || t > candidate.measuredAt.getTime()) return false; + const c = classifyBp(r.systolic, r.diastolic); + return c?.reason === candClass.reason; + }); + if (!confirmed) return null; + + return { + kind: "BLOOD_PRESSURE", + reason: candClass.reason, + // A symptom-coupled hypotensive breach escalates to severe-tier copy + // (shock signs → emergency); asymptomatic stays cautionary. + tier: + candClass.reason === "bp_hypotensive" && symptomCoupled + ? "severe" + : candClass.tier, + symptomCoupled, + value: candidate.systolic, + diastolic: candidate.diastolic, + }; +} + +/* ── glucose evaluation ───────────────────────────────────────────────── */ + +/** Classify a single glucose reading (mg/dL) against the absolute floors. */ +function classifyGlucose( + mgdl: number, +): { reason: SafetyFloorReason; tier: SafetyFloorTier } | null { + if (mgdl < GLUCOSE_HYPO_SEVERE) { + return { reason: "glucose_hypo_severe", tier: "severe" }; + } + if (mgdl < GLUCOSE_HYPO) { + return { reason: "glucose_hypo", tier: "caution" }; + } + if (mgdl >= GLUCOSE_HYPER) { + return { reason: "glucose_hyper", tier: "severe" }; + } + return null; +} + +/** + * Evaluate a fresh glucose reading against the absolute floors with the + * confirm gate. The confirm match is by FLOOR FAMILY, not exact reason: a + * follow-up reading that is hypo (< 70) confirms a candidate that is + * severe-hypo (< 54) and vice versa — both are "the low floor held on + * re-test". The same applies to the high floor. Hypoglycemia is acutely + * dangerous, so a severe-hypo candidate confirmed by ANY low re-test still + * escalates at the severe tier (the candidate's own tier wins). + */ +export function evaluateGlucose(input: { + candidate: GlucoseSample; + recent: GlucoseSample[]; + symptomCoupled: boolean; +}): SafetyFloorDecision | null { + const { candidate, recent, symptomCoupled } = input; + const candClass = classifyGlucose(candidate.mgdl); + if (!candClass) return null; + + const family = floorFamily(candClass.reason); + const windowStart = candidate.measuredAt.getTime() - CONFIRM_WINDOW_MS; + const confirmed = recent.some((r) => { + const t = r.measuredAt.getTime(); + if (t < windowStart || t > candidate.measuredAt.getTime()) return false; + const c = classifyGlucose(r.mgdl); + return c != null && floorFamily(c.reason) === family; + }); + if (!confirmed) return null; + + return { + kind: "GLUCOSE", + reason: candClass.reason, + tier: candClass.tier, + symptomCoupled, + value: Math.round(candidate.mgdl), + diastolic: null, + }; +} + +/** Group a glucose reason into its directional floor family for confirm-matching. */ +function floorFamily(reason: SafetyFloorReason): "low" | "high" | "other" { + if (reason === "glucose_hypo" || reason === "glucose_hypo_severe") { + return "low"; + } + if (reason === "glucose_hyper") return "high"; + return "other"; +} diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index a2b48acbf..1e39e7cd5 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -575,6 +575,12 @@ export const createMeasurementSchema = z // metadata) now persist the tag instead of seeing it disappear. // Accepts `null` so a client can explicitly clear the column. deviceType: z.string().min(1).max(32).nullable().optional(), + // v1.18.6 — optional "I feel unwell with this reading" flag. Transient + // (NOT persisted on the measurement row): it only lifts a confirmed + // safety-floor escalation from the asymptomatic "contact your doctor" + // copy to the symptom-coupled emergency copy. Defaults to false/absent — + // every existing client keeps the asymptomatic posture. Never a diagnosis. + symptomsPresent: z.boolean().optional(), }) .refine((data) => validateMeasurementRange(data.type, data.value) === null, { message: "Value out of plausible range",