mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
1 parent
cf620de315
commit
f20be29189
7 files changed
+927
-2
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user