From 027f2b4355d90e37468d72a6bb1f2e1c70a71307 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 6 Aug 2026 04:41:40 +0200 Subject: [PATCH] fix: suppress premature managed safety delivery - keep direct safety notifications to ordinary self-delivery\n- move safety dedup anchors out of delivery diagnostics --- .../illness/__tests__/red-flag-notify.test.ts | 31 +++++++++++++-- .../__tests__/safety-floor-notify.test.ts | 30 +++++++++++--- src/lib/illness/red-flag-notify.ts | 39 ++++++++++++------- src/lib/illness/safety-floor-notify.ts | 34 ++++++++++------ 4 files changed, 98 insertions(+), 36 deletions(-) diff --git a/src/lib/illness/__tests__/red-flag-notify.test.ts b/src/lib/illness/__tests__/red-flag-notify.test.ts index 9a852ca9c..bd3bee473 100644 --- a/src/lib/illness/__tests__/red-flag-notify.test.ts +++ b/src/lib/illness/__tests__/red-flag-notify.test.ts @@ -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 () => { diff --git a/src/lib/illness/__tests__/safety-floor-notify.test.ts b/src/lib/illness/__tests__/safety-floor-notify.test.ts index cd2a5a6b1..87babef6d 100644 --- a/src/lib/illness/__tests__/safety-floor-notify.test.ts +++ b/src/lib/illness/__tests__/safety-floor-notify.test.ts @@ -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 () => { diff --git a/src/lib/illness/red-flag-notify.ts b/src/lib/illness/red-flag-notify.ts index d5cbf8916..6e9a2be5c 100644 --- a/src/lib/illness/red-flag-notify.ts +++ b/src/lib/illness/red-flag-notify.ts @@ -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, }, }); diff --git a/src/lib/illness/safety-floor-notify.ts b/src/lib/illness/safety-floor-notify.ts index 23df53951..d11cc70d7 100644 --- a/src/lib/illness/safety-floor-notify.ts +++ b/src/lib/illness/safety-floor-notify.ts @@ -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, }, });