mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
fix: suppress premature managed safety delivery
- keep direct safety notifications to ordinary self-delivery\n- move safety dedup anchors out of delivery diagnostics
This commit is contained in:
1 parent
ce94f4773f
commit
027f2b4355
4 files changed
+98
-36
No files matched your search
@@ -10,10 +10,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
const findFirstMock = vi.fn();
|
||||
const createMock = vi.fn();
|
||||
const dispatchMock = vi.fn();
|
||||
const userFindUniqueMock = vi.fn();
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
pushAttempt: {
|
||||
user: {
|
||||
findUnique: (...a: unknown[]) => userFindUniqueMock(...a),
|
||||
},
|
||||
notificationEvent: {
|
||||
findFirst: (...a: unknown[]) => findFirstMock(...a),
|
||||
create: (...a: unknown[]) => createMock(...a),
|
||||
},
|
||||
@@ -45,6 +49,7 @@ const feverFlag: IllnessRedFlag = {
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
userFindUniqueMock.mockReset().mockResolvedValue({ managedProfileAt: null });
|
||||
findFirstMock.mockReset().mockResolvedValue(null);
|
||||
createMock.mockReset().mockResolvedValue({});
|
||||
dispatchMock.mockReset().mockResolvedValue(undefined);
|
||||
@@ -96,14 +101,34 @@ describe("notifyIllnessRedFlag", () => {
|
||||
expect(createMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stamps the ledger anchor before dispatching", async () => {
|
||||
it("stamps the record event anchor before dispatching", async () => {
|
||||
await notifyIllnessRedFlag({
|
||||
userId: "u1",
|
||||
episodeId: "e9",
|
||||
redFlags: [feverFlag],
|
||||
});
|
||||
expect(createMock).toHaveBeenCalledTimes(1);
|
||||
expect(createMock.mock.calls[0][0].data.reason).toContain("e9");
|
||||
expect(createMock.mock.calls[0][0].data).toMatchObject({
|
||||
recordUserId: "u1",
|
||||
eventType: "SYSTEM_ALERT",
|
||||
dedupKey: expect.stringContaining("e9"),
|
||||
});
|
||||
});
|
||||
|
||||
it("does not dispatch or write an anchor for a managed profile", async () => {
|
||||
userFindUniqueMock.mockResolvedValueOnce({
|
||||
managedProfileAt: new Date("2026-08-06T00:00:00.000Z"),
|
||||
});
|
||||
|
||||
await notifyIllnessRedFlag({
|
||||
userId: "managed-record",
|
||||
episodeId: "e1",
|
||||
redFlags: [feverFlag],
|
||||
});
|
||||
|
||||
expect(findFirstMock).not.toHaveBeenCalled();
|
||||
expect(createMock).not.toHaveBeenCalled();
|
||||
expect(dispatchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never throws when dispatch fails", async () => {
|
||||
|
||||
@@ -11,10 +11,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
const findFirstMock = vi.fn();
|
||||
const createMock = vi.fn();
|
||||
const dispatchMock = vi.fn();
|
||||
const userFindUniqueMock = vi.fn();
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
pushAttempt: {
|
||||
user: {
|
||||
findUnique: (...a: unknown[]) => userFindUniqueMock(...a),
|
||||
},
|
||||
notificationEvent: {
|
||||
findFirst: (...a: unknown[]) => findFirstMock(...a),
|
||||
create: (...a: unknown[]) => createMock(...a),
|
||||
},
|
||||
@@ -50,6 +54,7 @@ const glucoseLowSymptomatic: SafetyFloorDecision = {
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
userFindUniqueMock.mockReset().mockResolvedValue({ managedProfileAt: null });
|
||||
findFirstMock.mockReset().mockResolvedValue(null);
|
||||
createMock.mockReset().mockResolvedValue({});
|
||||
dispatchMock.mockReset().mockResolvedValue(undefined);
|
||||
@@ -101,13 +106,26 @@ describe("notifySafetyFloor", () => {
|
||||
expect(createMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stamps the ledger anchor (keyed by reason) before dispatching", async () => {
|
||||
it("stamps the record event 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");
|
||||
expect(createMock.mock.calls[0][0].data).toMatchObject({
|
||||
recordUserId: "u9",
|
||||
eventType: "SYSTEM_ALERT",
|
||||
dedupKey: expect.stringContaining("bp_hypertensive"),
|
||||
});
|
||||
});
|
||||
|
||||
it("does not dispatch or write an anchor for a managed profile", async () => {
|
||||
userFindUniqueMock.mockResolvedValueOnce({
|
||||
managedProfileAt: new Date("2026-08-06T00:00:00.000Z"),
|
||||
});
|
||||
|
||||
await notifySafetyFloor({ userId: "managed-record", decision: bpHigh });
|
||||
|
||||
expect(findFirstMock).not.toHaveBeenCalled();
|
||||
expect(createMock).not.toHaveBeenCalled();
|
||||
expect(dispatchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never throws when dispatch fails", async () => {
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
* the strongest tier those channels support.
|
||||
*
|
||||
* Owner-scoped (the authenticated episode owner) and module-gated by the
|
||||
* caller (`requireIllnessEnabled`). De-duplicated through the `pushAttempt`
|
||||
* ledger: at most one escalation per episode per rolling window, so a
|
||||
* caller (`requireIllnessEnabled`). De-duplicated through a record event:
|
||||
* at most one escalation per episode per rolling window, so a
|
||||
* correlation surface that is re-read repeatedly never re-fires the alarm.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
@@ -27,7 +27,7 @@ import type { IllnessRedFlag } from "@/lib/illness/correlation";
|
||||
/** One escalation per episode per this window. */
|
||||
const DEDUPE_WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Stable ledger reason prefix so the dedupe lookup is exact-matchable. */
|
||||
/** Stable event key prefix so the dedupe lookup is exact-matchable. */
|
||||
const LEDGER_REASON_PREFIX = "illness_red_flag:";
|
||||
|
||||
/**
|
||||
@@ -44,13 +44,25 @@ export async function notifyIllnessRedFlag(input: {
|
||||
if (redFlags.length === 0) return;
|
||||
|
||||
try {
|
||||
// Plan 11 adds approved guardian fanout for this safety signal. Until
|
||||
// then, managed profiles must not produce a direct delivery or anchor.
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { managedProfileAt: true },
|
||||
});
|
||||
if (user?.managedProfileAt) return;
|
||||
|
||||
// Dedupe: skip when an escalation for THIS episode already landed in the
|
||||
// ledger within the window. The episode id rides the `reason` column so
|
||||
// the lookup is exact without a schema change.
|
||||
// record event stream within the window.
|
||||
const reason = `${LEDGER_REASON_PREFIX}${episodeId}`;
|
||||
const since = new Date(Date.now() - DEDUPE_WINDOW_MS);
|
||||
const prior = await prisma.pushAttempt.findFirst({
|
||||
where: { userId, reason, createdAt: { gte: since } },
|
||||
const prior = await prisma.notificationEvent.findFirst({
|
||||
where: {
|
||||
recordUserId: userId,
|
||||
eventType: "SYSTEM_ALERT",
|
||||
dedupKey: reason,
|
||||
createdAt: { gte: since },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (prior) return;
|
||||
@@ -62,16 +74,13 @@ export async function notifyIllnessRedFlag(input: {
|
||||
? "illness.correlation.redFlagFever"
|
||||
: "illness.correlation.redFlagSpo2";
|
||||
|
||||
// Stamp the ledger BEFORE dispatching so a slow/duplicate concurrent read
|
||||
// can't double-fire. The senders write their own per-channel rows too;
|
||||
// this synthetic row is purely the dedupe anchor.
|
||||
await prisma.pushAttempt.create({
|
||||
// Stamp the record event before dispatching so a slow/duplicate concurrent
|
||||
// read cannot double-fire. Delivery attempts remain channel diagnostics.
|
||||
await prisma.notificationEvent.create({
|
||||
data: {
|
||||
userId,
|
||||
channel: "WEB_PUSH",
|
||||
recordUserId: userId,
|
||||
eventType: "SYSTEM_ALERT",
|
||||
result: "skipped",
|
||||
reason,
|
||||
dedupKey: reason,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* - 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
|
||||
* on a record event 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).
|
||||
*/
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
/** 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. */
|
||||
/** Stable event key prefix so the dedupe lookup is exact-matchable. */
|
||||
const LEDGER_REASON_PREFIX = "safety_floor:";
|
||||
|
||||
/**
|
||||
@@ -113,24 +113,34 @@ export async function notifySafetyFloor(input: {
|
||||
if (!decision) return;
|
||||
|
||||
try {
|
||||
// Plan 11 adds approved guardian fanout for this safety signal. Until
|
||||
// then, managed profiles must not produce a direct delivery or anchor.
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { managedProfileAt: true },
|
||||
});
|
||||
if (user?.managedProfileAt) return;
|
||||
|
||||
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 } },
|
||||
const prior = await prisma.notificationEvent.findFirst({
|
||||
where: {
|
||||
recordUserId: userId,
|
||||
eventType: "SYSTEM_ALERT",
|
||||
dedupKey: 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({
|
||||
// Stamp the record event before dispatching so a concurrent confirm
|
||||
// cannot double-fire. Delivery attempts remain channel diagnostics.
|
||||
await prisma.notificationEvent.create({
|
||||
data: {
|
||||
userId,
|
||||
channel: "WEB_PUSH",
|
||||
recordUserId: userId,
|
||||
eventType: "SYSTEM_ALERT",
|
||||
result: "skipped",
|
||||
reason,
|
||||
dedupKey: reason,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user