mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
feat: add guardian notification fanout
This commit is contained in:
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 () => {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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`
|
||||
|
||||
Reference in new issue
Block a user