feat(illness): escalate confirmed clinical safety floors

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-18 20:18:37 +02:00
1 parent cf620de315
commit f20be29189
7 files changed
+927 -2

No files matched your search

+49 -2
View File
@@ -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
@@ -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();
});
});
@@ -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();
});
});
+173
View File
@@ -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<void> {
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<void> {
// 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<number, number>();
const diaByTs = new Map<number, number>();
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<void> {
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 });
}
+123
View File
@@ -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<SafetyFloorReason, string> = {
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<string, string | number> {
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<void> {
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)
}`,
);
}
}
+242
View File
@@ -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";
}
+6
View File
@@ -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",