feat: add guardian notification fanout

This commit is contained in:
Marc-André Bombeck committed 2026-08-06 05:23:38 +02:00
1 parent 1c0a5d7777
commit a78189b7f6
10 files changed
+229 -35

No files matched your search

@@ -32,7 +32,9 @@ function payload(over: Record<string, unknown> = {}) {
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"]);
});
});
@@ -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 () => {
+3 -2
View File
@@ -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 },
+1 -8
View File
@@ -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,
});
@@ -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<string, unknown>,
}));
vi.mock("../shared", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
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<unknown>,
) =>
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);
});
});
+15 -7
View File
@@ -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);
+69 -1
View File
@@ -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<NotificationPayload, "eventType" | "managedFanoutEvent">,
): 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<string[] | null> {
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;
+4 -1
View File
@@ -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<string, unknown>;
/** 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,
});
}
+28 -1
View File
@@ -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<DispatchOutcome>(
(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");
+20
View File
@@ -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<string, unknown>;
/**
* 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`