From a78189b7f6edad4418371d98cf9fdaf003b23f1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 6 Aug 2026 05:23:38 +0200 Subject: [PATCH] feat: add guardian notification fanout --- .../guardian-notification-policy.test.ts | 34 +++++---- .../__tests__/safety-floor-notify.test.ts | 15 +++- src/lib/illness/red-flag-notify.ts | 5 +- src/lib/illness/safety-floor-notify.ts | 9 +-- .../__tests__/notification-retention.test.ts | 55 +++++++++++++++ src/lib/jobs/reminder/cleanup-handlers.ts | 22 ++++-- src/lib/notifications/delivery-identity.ts | 70 ++++++++++++++++++- src/lib/notifications/dispatch-localised.ts | 5 +- src/lib/notifications/dispatcher.ts | 29 +++++++- src/lib/notifications/types.ts | 20 ++++++ 10 files changed, 229 insertions(+), 35 deletions(-) create mode 100644 src/lib/jobs/reminder/__tests__/notification-retention.test.ts diff --git a/src/__tests__/guardian-notification-policy.test.ts b/src/__tests__/guardian-notification-policy.test.ts index 23fae3050..086ad5de0 100644 --- a/src/__tests__/guardian-notification-policy.test.ts +++ b/src/__tests__/guardian-notification-policy.test.ts @@ -32,7 +32,9 @@ function payload(over: Record = {}) { beforeEach(() => { vi.resetAllMocks(); findUnique.mockResolvedValue({ managedProfileAt: new Date() }); - findMany.mockResolvedValue([{ granteeId: "guardian-one" }]); + findMany.mockResolvedValue([ + { granteeId: "guardian-one", grantee: { managedProfileAt: null } }, + ]); }); describe("guardian notification policy — allowlist and eligibility", () => { @@ -76,19 +78,24 @@ describe("guardian notification policy — allowlist and eligibility", () => { }); it("enumerates only active Guardians of the marked record", async () => { - await expect(resolveManagedGuardianRecipientIds(payload())).resolves.toEqual( - ["guardian-one"], - ); + await expect( + resolveManagedGuardianRecipientIds(payload()), + ).resolves.toEqual(["guardian-one"]); expect(findMany).toHaveBeenCalledWith({ where: expect.objectContaining({ grantorId: recordUserId }), - select: { granteeId: true }, + select: { + granteeId: true, + grantee: { select: { managedProfileAt: true } }, + }, }); }); it("does not fan out unmarked records, disallowed events, self delivery, or invalid Guardians", async () => { findUnique.mockResolvedValueOnce({ managedProfileAt: null }); - await expect(resolveManagedGuardianRecipientIds(payload())).resolves.toBeNull(); + await expect( + resolveManagedGuardianRecipientIds(payload()), + ).resolves.toBeNull(); await expect( resolveManagedGuardianRecipientIds( @@ -97,12 +104,15 @@ describe("guardian notification policy — allowlist and eligibility", () => { ).resolves.toBeNull(); findMany.mockResolvedValueOnce([ - { granteeId: recordUserId }, - { granteeId: "managed-guardian", managedProfileAt: new Date() }, - { granteeId: "guardian-two" }, - ]); - await expect(resolveManagedGuardianRecipientIds(payload())).resolves.toEqual([ - "guardian-two", + { granteeId: recordUserId, grantee: { managedProfileAt: null } }, + { + granteeId: "managed-guardian", + grantee: { managedProfileAt: new Date() }, + }, + { granteeId: "guardian-two", grantee: { managedProfileAt: null } }, ]); + await expect( + resolveManagedGuardianRecipientIds(payload()), + ).resolves.toEqual(["guardian-two"]); }); }); diff --git a/src/lib/illness/__tests__/safety-floor-notify.test.ts b/src/lib/illness/__tests__/safety-floor-notify.test.ts index 0eef5e6a3..a126a6c3c 100644 --- a/src/lib/illness/__tests__/safety-floor-notify.test.ts +++ b/src/lib/illness/__tests__/safety-floor-notify.test.ts @@ -124,15 +124,24 @@ describe("notifySafetyFloor", () => { ); }); - it("does not dispatch or write an anchor for a managed profile", async () => { + it("claims then admits an anchored Guardian fan-out for a managed profile", async () => { userFindUniqueMock.mockResolvedValueOnce({ managedProfileAt: new Date("2026-08-06T00:00:00.000Z"), }); await notifySafetyFloor({ userId: "managed-record", decision: bpHigh }); - expect(claimMock).not.toHaveBeenCalled(); - expect(dispatchMock).not.toHaveBeenCalled(); + expect(claimMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ recordUserId: "managed-record" }), + ); + expect(dispatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "managed-record", + eventType: "SYSTEM_ALERT", + managedFanoutEvent: "SAFETY_FLOOR_ALERT", + }), + ); }); 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 2ac3c23a6..19ff68069 100644 --- a/src/lib/illness/red-flag-notify.ts +++ b/src/lib/illness/red-flag-notify.ts @@ -45,8 +45,9 @@ 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. + // Red-flag alerts are deliberately outside the closed Guardian fan-out + // allowlist. Managed records therefore retain the existing no-delivery, + // no-anchor behaviour for this distinct SYSTEM_ALERT producer. const user = await prisma.user.findUnique({ where: { id: userId }, select: { managedProfileAt: true }, diff --git a/src/lib/illness/safety-floor-notify.ts b/src/lib/illness/safety-floor-notify.ts index 9065b8fa9..532d1bafd 100644 --- a/src/lib/illness/safety-floor-notify.ts +++ b/src/lib/illness/safety-floor-notify.ts @@ -114,14 +114,6 @@ 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 claimed = await claimNotificationEvent(prisma, { @@ -139,6 +131,7 @@ export async function notifySafetyFloor(input: { messageKey, params: paramsFor(decision, input.glucoseUnit ?? "mg/dL"), eventType: "SYSTEM_ALERT", + managedFanoutEvent: "SAFETY_FLOOR_ALERT", urgent: true, }); diff --git a/src/lib/jobs/reminder/__tests__/notification-retention.test.ts b/src/lib/jobs/reminder/__tests__/notification-retention.test.ts new file mode 100644 index 000000000..db4ba1443 --- /dev/null +++ b/src/lib/jobs/reminder/__tests__/notification-retention.test.ts @@ -0,0 +1,55 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const h = vi.hoisted(() => ({ + pushDeleteMany: vi.fn(), + eventDeleteMany: vi.fn(), + meta: {} as Record, +})); + +vi.mock("../shared", async (importOriginal) => ({ + ...(await importOriginal>()), + getWorkerPrisma: () => ({ + pushAttempt: { deleteMany: h.pushDeleteMany }, + notificationEvent: { deleteMany: h.eventDeleteMany }, + }), +})); + +vi.mock("@/lib/logging/background", () => ({ + withBackgroundEvent: async ( + _name: string, + fn: (evt: { + addMeta: (key: string, value: unknown) => void; + addWarning: (warning: string) => void; + }) => Promise, + ) => + fn({ + addMeta: (key, value) => { + h.meta[key] = value; + }, + addWarning: vi.fn(), + }), +})); + +import { handlePushAttemptCleanup } from "../cleanup-handlers"; + +beforeEach(() => { + vi.clearAllMocks(); + for (const key of Object.keys(h.meta)) delete h.meta[key]; + h.pushDeleteMany.mockResolvedValue({ count: 3 }); + h.eventDeleteMany.mockResolvedValue({ count: 5 }); +}); + +describe("notification retention", () => { + it("prunes record event anchors with the canonical bounded attempt retention", async () => { + await handlePushAttemptCleanup([]); + + expect(h.pushDeleteMany).toHaveBeenCalledWith({ + where: { createdAt: { lt: expect.any(Date) } }, + }); + expect(h.eventDeleteMany).toHaveBeenCalledWith({ + where: { createdAt: { lt: expect.any(Date) } }, + }); + expect(h.meta.push_attempt_cleanup_deleted).toBe(3); + expect(h.meta.notification_event_cleanup_deleted).toBe(5); + }); +}); diff --git a/src/lib/jobs/reminder/cleanup-handlers.ts b/src/lib/jobs/reminder/cleanup-handlers.ts index 145ac3e30..5216507e2 100644 --- a/src/lib/jobs/reminder/cleanup-handlers.ts +++ b/src/lib/jobs/reminder/cleanup-handlers.ts @@ -41,8 +41,9 @@ import { import { getWorkerPrisma } from "./shared"; const MOOD_REMINDER_RETENTION_DAYS = 90; -// v1.4.49 — daily prune for the push-attempt ledger. Same 90-day -// retention as the mood-reminder dispatch ledger; both surfaces are +// v1.4.49 — daily prune for the notification delivery ledger. The attempt +// diagnostics and record-only event anchors share the same 90-day horizon; +// both surfaces are // behavioural footprints we keep long enough to debug a duplicate-push // report (~one billing cycle) but no longer. Slots at 03:35 between // mood-reminder cleanup (03:25) and drain-cumulative (03:45) so the @@ -116,13 +117,15 @@ export async function handleMoodReminderCleanup( } /** - * v1.4.49 — daily prune for the per-attempt push-delivery ledger. + * Daily prune for the bounded notification-delivery ledger. * * Every sender (APNS, WEB_PUSH, TELEGRAM, NTFY) writes one * fire-and-forget row to `push_attempts` per dispatch. The admin * diagnostic endpoint only ever reads the trailing 20 rows per user, * so anything older than the 90-day retention window is dead weight - * inflating the table and the `(user_id, created_at DESC)` index. + * inflating the table and the `(user_id, created_at DESC)` index. Record-only + * notification events are claims rather than delivery diagnostics, but after + * the same horizon their dedup windows have elapsed and they have no reader. * * The DELETE is unbounded by user — the index covers `created_at` * directly, so a `WHERE created_at < cutoff` scan is bounded by the @@ -144,11 +147,16 @@ export async function handlePushAttemptCleanup( try { const cutoff = new Date(); cutoff.setUTCDate(cutoff.getUTCDate() - PUSH_ATTEMPT_RETENTION_DAYS); - const deleted = await p.pushAttempt.deleteMany({ + const deletedAttempts = await p.pushAttempt.deleteMany({ where: { createdAt: { lt: cutoff } }, }); - evt.addMeta("push_attempt_cleanup_deleted", deleted.count); - return jobDone({ deleted: deleted.count }); + const deletedEvents = await p.notificationEvent.deleteMany({ + where: { createdAt: { lt: cutoff } }, + }); + const deleted = deletedAttempts.count + deletedEvents.count; + evt.addMeta("push_attempt_cleanup_deleted", deletedAttempts.count); + evt.addMeta("notification_event_cleanup_deleted", deletedEvents.count); + return jobDone({ deleted }); } catch (err) { evt.addWarning(`push-attempt-cleanup failed: ${err}`); return jobFailed("push-attempt cleanup failed", err); diff --git a/src/lib/notifications/delivery-identity.ts b/src/lib/notifications/delivery-identity.ts index 3283dddb1..cd82a8aac 100644 --- a/src/lib/notifications/delivery-identity.ts +++ b/src/lib/notifications/delivery-identity.ts @@ -3,7 +3,10 @@ import { activeGuardianWhere, withManagedProfileLock, } from "@/lib/managed-profiles/lifecycle"; -import type { NotificationPayload } from "@/lib/notifications/types"; +import type { + ManagedGuardianFanoutEvent, + NotificationPayload, +} from "@/lib/notifications/types"; export interface NotificationDeliveryIdentity { recordUserId: string; @@ -11,6 +14,69 @@ export interface NotificationDeliveryIdentity { managed: boolean; } +/** + * The policy is deliberately closed. `SYSTEM_ALERT` by itself is never a + * Guardian event: the safety-floor producer must name its narrow admission + * marker, while illness red flags continue to remain record-local. + */ +export function isManagedGuardianFanoutAllowed( + payload: Pick, +): boolean { + if (payload.managedFanoutEvent === "SAFETY_FLOOR_ALERT") { + return payload.eventType === "SYSTEM_ALERT"; + } + + return ( + payload.managedFanoutEvent === undefined && + (payload.eventType === "MEDICATION_REMINDER" || + payload.eventType === "MEASUREMENT_REMINDER" || + payload.eventType === "MEDICATION_LOW_STOCK") + ); +} + +/** + * Enumerate candidate Guardians only for a legacy record-addressed delivery. + * Each candidate is still re-authorized under the managed-profile advisory + * lock immediately before its own channel selection and provider egress. + */ +export async function resolveManagedGuardianRecipientIds( + payload: NotificationPayload, +): Promise { + if ( + payload.recordUserId !== undefined || + payload.recipientUserId !== undefined || + !isManagedGuardianFanoutAllowed(payload) + ) { + return null; + } + + const record = await prisma.user.findUnique({ + where: { id: payload.userId }, + select: { managedProfileAt: true }, + }); + if (record?.managedProfileAt == null) return null; + + const grants = await prisma.accountGrant.findMany({ + where: { + grantorId: payload.userId, + ...activeGuardianWhere(new Date()), + grantee: { managedProfileAt: null }, + }, + select: { + granteeId: true, + grantee: { select: { managedProfileAt: true } }, + }, + }); + + return grants + .filter( + (grant) => + grant.granteeId !== payload.userId && + grant.grantee.managedProfileAt == null, + ) + .map((grant) => grant.granteeId); +} + /** * Resolve notification identities without treating a managed record as its * own destination. Legacy payloads remain valid only for ordinary self @@ -50,6 +116,8 @@ export async function resolveNotificationDeliveryIdentity( if (recipientUserId === recordUserId) return null; + if (!isManagedGuardianFanoutAllowed(payload)) return null; + return prisma.$transaction(async (tx) => withManagedProfileLock(tx, recordUserId, async (lockedRecord) => { if (lockedRecord?.managedProfileAt == null) return null; diff --git a/src/lib/notifications/dispatch-localised.ts b/src/lib/notifications/dispatch-localised.ts index 2311286b3..d24b9828b 100644 --- a/src/lib/notifications/dispatch-localised.ts +++ b/src/lib/notifications/dispatch-localised.ts @@ -41,7 +41,7 @@ import { getEvent } from "@/lib/logging/context"; import { defaultLocale, type Locale } from "@/lib/i18n/config"; import { getServerTranslator } from "@/lib/i18n/server-translator"; import { dispatchNotification } from "@/lib/notifications/dispatcher"; -import type { EventType } from "@/lib/notifications/types"; +import type { EventType, NotificationPayload } from "@/lib/notifications/types"; interface DispatchLocalisedOptions { userId: string; @@ -61,6 +61,8 @@ interface DispatchLocalisedOptions { eventType?: EventType; /** Channel-specific extras forwarded to the dispatcher metadata. */ metadata?: Record; + /** Closed internal marker for an admitted managed-record event. */ + managedFanoutEvent?: NotificationPayload["managedFanoutEvent"]; /** * Escalate to every channel's highest urgency (v1.18.4). Forwarded to * the dispatcher so APNs goes time-sensitive, ntfy max, Web Push @@ -182,6 +184,7 @@ export async function dispatchLocalisedNotification( title, message, metadata, + managedFanoutEvent: opts.managedFanoutEvent, urgent: opts.urgent, }); } diff --git a/src/lib/notifications/dispatcher.ts b/src/lib/notifications/dispatcher.ts index 77fed2b7d..a718acb4c 100644 --- a/src/lib/notifications/dispatcher.ts +++ b/src/lib/notifications/dispatcher.ts @@ -29,7 +29,10 @@ import { hasClientManagedApnsGate, } from "@/lib/notifications/client-managed-apns"; import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record"; -import { resolveNotificationDeliveryIdentity } from "@/lib/notifications/delivery-identity"; +import { + resolveManagedGuardianRecipientIds, + resolveNotificationDeliveryIdentity, +} from "@/lib/notifications/delivery-identity"; /** * Dispatch a notification to all enabled channels for a user. @@ -64,6 +67,30 @@ export async function dispatchNotification( let channelsSucceeded = 0; try { + const guardianRecipientIds = + await resolveManagedGuardianRecipientIds(payload); + if (guardianRecipientIds !== null) { + const outcomes = await Promise.all( + guardianRecipientIds.map((recipientUserId) => + dispatchNotification({ + ...payload, + recordUserId: payload.userId, + recipientUserId, + }), + ), + ); + return outcomes.reduce( + (aggregate, outcome) => ({ + dispatched: aggregate.dispatched || outcome.dispatched, + channelsAttempted: + aggregate.channelsAttempted + outcome.channelsAttempted, + channelsSucceeded: + aggregate.channelsSucceeded + outcome.channelsSucceeded, + }), + { dispatched: false, channelsAttempted: 0, channelsSucceeded: 0 }, + ); + } + const delivery = await resolveNotificationDeliveryIdentity(payload); if (!delivery) { getEvent()?.addWarning("Notification delivery identity was invalid"); diff --git a/src/lib/notifications/types.ts b/src/lib/notifications/types.ts index 7b7d7bbd8..419400ebf 100644 --- a/src/lib/notifications/types.ts +++ b/src/lib/notifications/types.ts @@ -125,6 +125,20 @@ export const EVENT_TYPES = [ ] as const; export type EventType = (typeof EVENT_TYPES)[number]; +/** + * The closed set of managed-record events that may reach Guardians. A + * `SYSTEM_ALERT` needs the explicit safety-floor discriminator because + * illness red flags use the same broad delivery event but do not fan out. + */ +export const MANAGED_GUARDIAN_FANOUT_EVENTS = [ + "MEDICATION_REMINDER", + "MEASUREMENT_REMINDER", + "MEDICATION_LOW_STOCK", + "SAFETY_FLOOR_ALERT", +] as const; +export type ManagedGuardianFanoutEvent = + (typeof MANAGED_GUARDIAN_FANOUT_EVENTS)[number]; + /** * Per-event default policy when no `NotificationPreference` row * exists. The dispatcher reads this to decide whether to fire on a @@ -265,6 +279,12 @@ export interface NotificationPayload { message: string; /** Channel-specific extras (e.g. medicationId for Telegram inline buttons) */ metadata?: Record; + /** + * Internal admission marker for managed-record delivery. It is deliberately + * absent from public request contracts: only the four allowlisted producer + * paths may set it, and safety-floor is the only SYSTEM_ALERT admission. + */ + managedFanoutEvent?: ManagedGuardianFanoutEvent; /** * Discreet mode (cycle privacy). When true, the lock-screen-visible * routing metadata the senders would otherwise derive from `eventType`