diff --git a/prisma/migrations/0298_notification_delivery_identity/migration.sql b/prisma/migrations/0298_notification_delivery_identity/migration.sql new file mode 100644 index 000000000..27691f2e4 --- /dev/null +++ b/prisma/migrations/0298_notification_delivery_identity/migration.sql @@ -0,0 +1,121 @@ +-- v1.37.0 — a notification names the health record it concerns and the +-- account whose channels receive it. The former remains the source of health +-- calculations and content; the latter owns delivery preferences and reach. +-- +-- `user_id` remains as a legacy recipient mirror so established self-delivery +-- writers and bounded historical readers stay valid while every row gains the +-- explicit pair. The trigger maps an omitted pair to self-delivery, rejects a +-- partial or inconsistent pair, and refuses a managed profile as recipient. + +ALTER TABLE "push_attempts" + ADD COLUMN "record_user_id" TEXT, + ADD COLUMN "recipient_user_id" TEXT; + +UPDATE "push_attempts" +SET + "record_user_id" = "user_id", + "recipient_user_id" = "user_id"; + +ALTER TABLE "push_attempts" + ALTER COLUMN "record_user_id" SET NOT NULL, + ALTER COLUMN "recipient_user_id" SET NOT NULL; + +ALTER TABLE "push_attempts" + ADD CONSTRAINT "push_attempts_record_user_id_fkey" + FOREIGN KEY ("record_user_id") REFERENCES "users"("id") + ON DELETE CASCADE ON UPDATE CASCADE, + ADD CONSTRAINT "push_attempts_recipient_user_id_fkey" + FOREIGN KEY ("recipient_user_id") REFERENCES "users"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +CREATE INDEX "push_attempts_recipient_user_id_created_at_desc_idx" + ON "push_attempts" ("recipient_user_id", "created_at" DESC); + +CREATE INDEX "push_attempts_record_user_id_created_at_desc_idx" + ON "push_attempts" ("record_user_id", "created_at" DESC); + +CREATE TABLE "notification_events" ( + "id" TEXT NOT NULL, + "record_user_id" TEXT NOT NULL, + "event_type" TEXT NOT NULL, + "dedup_key" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "notification_events_pkey" PRIMARY KEY ("id"), + CONSTRAINT "notification_events_record_user_id_fkey" + FOREIGN KEY ("record_user_id") REFERENCES "users"("id") + ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE INDEX "notification_events_record_user_id_event_type_dedup_key_created_at_desc_idx" + ON "notification_events" ("record_user_id", "event_type", "dedup_key", "created_at" DESC); + +-- Tracking belongs to the account that received the Telegram message. The +-- prior subject-only key collapsed multiple delegated recipients into one row. +ALTER TABLE "telegram_reminder_messages" + ADD COLUMN "recipient_user_id" TEXT; + +UPDATE "telegram_reminder_messages" AS "message" +SET "recipient_user_id" = "medication"."user_id" +FROM "medications" AS "medication" +WHERE "medication"."id" = "message"."medication_id"; + +ALTER TABLE "telegram_reminder_messages" + ALTER COLUMN "recipient_user_id" SET NOT NULL, + ADD CONSTRAINT "telegram_reminder_messages_recipient_user_id_fkey" + FOREIGN KEY ("recipient_user_id") REFERENCES "users"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +DROP INDEX "telegram_reminder_messages_med_sched_date_phase_tod_key"; + +CREATE UNIQUE INDEX "telegram_reminder_messages_recipient_slot_key" + ON "telegram_reminder_messages" ( + "recipient_user_id", + "medication_id", + "schedule_id", + "date", + "phase", + "time_of_day" + ); + +CREATE FUNCTION enforce_push_attempt_attribution() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + IF NEW.record_user_id IS NULL AND NEW.recipient_user_id IS NULL THEN + NEW.record_user_id := NEW.user_id; + NEW.recipient_user_id := NEW.user_id; + ELSIF NEW.record_user_id IS NULL OR NEW.recipient_user_id IS NULL THEN + RAISE EXCEPTION 'push attempt attribution requires both principals'; + ELSIF NEW.user_id <> NEW.recipient_user_id THEN + RAISE EXCEPTION 'push attempt recipient must match user_id'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM "users" + WHERE "id" = NEW.recipient_user_id + AND "managed_profile_at" IS NOT NULL + ) THEN + RAISE EXCEPTION 'managed profile cannot receive notification delivery'; + END IF; + + IF NEW.record_user_id <> NEW.recipient_user_id + AND NOT EXISTS ( + SELECT 1 + FROM "users" + WHERE "id" = NEW.record_user_id + AND "managed_profile_at" IS NOT NULL + ) THEN + RAISE EXCEPTION 'cross-principal delivery requires a managed record'; + END IF; + + RETURN NEW; +END; +$$; + +CREATE TRIGGER push_attempt_attribution_guard +BEFORE INSERT OR UPDATE ON "push_attempts" +FOR EACH ROW +EXECUTE FUNCTION enforce_push_attempt_attribution(); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 457e3a151..153febee0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -860,7 +860,10 @@ model User { // 20 attempts ordered by recency. Daily prune at 03:35 Europe/Berlin // retires rows older than 90 days (see `handlePushAttemptCleanup` in // `reminder-worker.ts`). - pushAttempts PushAttempt[] + pushAttemptsForRecord PushAttempt[] @relation("PushAttemptRecordUser") + pushAttemptsReceived PushAttempt[] @relation("PushAttemptRecipientUser") + notificationEvents NotificationEvent[] + telegramReminderMessages TelegramReminderMessage[] // v1.10.3 — per-(user, day) Strain personal-anchor cache (day-total // TRIMP + running EWMA reference) so the nightly Strain engine anchors // the 0–100 map to the user's own training load without re-integrating @@ -3362,24 +3365,26 @@ model ReminderPhaseConfig { } model TelegramReminderMessage { - id String @id @default(cuid()) - medicationId String @map("medication_id") - scheduleId String @map("schedule_id") - chatId String @map("chat_id") - messageId Int @map("message_id") - phase ReminderPhase - sentAt DateTime @default(now()) @map("sent_at") - date String // YYYY-MM-DD for day grouping + id String @id @default(cuid()) + recipientUserId String @map("recipient_user_id") + medicationId String @map("medication_id") + scheduleId String @map("schedule_id") + chatId String @map("chat_id") + messageId Int @map("message_id") + phase ReminderPhase + sentAt DateTime @default(now()) @map("sent_at") + date String // YYYY-MM-DD for day grouping /// v1.7.0 — time-of-day this reminder belongs to (HH:mm, user-local). /// Empty string = the single legacy window (pre-v1.7 rows + single-time /// schedules). Part of the dedup key so a multi-time schedule dispatches /// once per time-of-day per phase per day instead of once per day. - timeOfDay String @default("") @map("time_of_day") + timeOfDay String @default("") @map("time_of_day") - medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade) - schedule MedicationSchedule @relation(fields: [scheduleId], references: [id], onDelete: Cascade) + recipientUser User @relation(fields: [recipientUserId], references: [id], onDelete: Cascade) + medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade) + schedule MedicationSchedule @relation(fields: [scheduleId], references: [id], onDelete: Cascade) - @@unique([medicationId, scheduleId, date, phase, timeOfDay], map: "telegram_reminder_messages_med_sched_date_phase_tod_key") + @@unique([recipientUserId, medicationId, scheduleId, date, phase, timeOfDay], map: "telegram_reminder_messages_recipient_slot_key") @@index([medicationId, date]) @@map("telegram_reminder_messages") } @@ -5803,6 +5808,22 @@ model ConsentReceipt { // `channel` is the dispatcher `ChannelType` string (`APNS`, // `WEB_PUSH`, `TELEGRAM`, `NTFY`). // +// Record-scoped notification events are durable event anchors rather than +// channel delivery records. They intentionally name no recipient, which keeps +// scheduler deduplication independent from future Guardian fan-out. +model NotificationEvent { + id String @id @default(cuid()) + recordUserId String @map("record_user_id") + eventType String @map("event_type") + dedupKey String @map("dedup_key") + createdAt DateTime @default(now()) @map("created_at") + + recordUser User @relation(fields: [recordUserId], references: [id], onDelete: Cascade) + + @@index([recordUserId, eventType, dedupKey, createdAt(sort: Desc)]) + @@map("notification_events") +} + // `result` is one of `"ok"` | `"error"` | `"skipped"` — `skipped` // covers the "no recipient" / "config missing" soft cases. // @@ -5812,17 +5833,26 @@ model ConsentReceipt { // classified `web_push_*` reason; Telegram / NTFY likewise mirror // their sender-side `SendOutcome.reason`. model PushAttempt { - id String @id @default(cuid()) - userId String @map("user_id") - channel String // "APNS" | "WEB_PUSH" | "TELEGRAM" | "NTFY" - eventType String @map("event_type") // dispatcher event-type string - result String // "ok" | "error" | "skipped" - reason String? // sender-side reason on error/skip; null on ok - createdAt DateTime @default(now()) @map("created_at") + id String @id @default(cuid()) + /// Legacy recipient mirror. Migration 0298 preserves established writers + /// while the database trigger requires it to equal `recipientUserId`. + userId String @map("user_id") + /// The health-record subject. This supplies calculation context and content. + recordUserId String? @map("record_user_id") + /// The account whose channel, preferences, and locale receive the delivery. + recipientUserId String? @map("recipient_user_id") + channel String // "APNS" | "WEB_PUSH" | "TELEGRAM" | "NTFY" + eventType String @map("event_type") // dispatcher event-type string + result String // "ok" | "error" | "skipped" + reason String? // sender-side reason on error/skip; null on ok + createdAt DateTime @default(now()) @map("created_at") - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + recordUser User? @relation("PushAttemptRecordUser", fields: [recordUserId], references: [id], onDelete: Cascade) + recipientUser User? @relation("PushAttemptRecipientUser", fields: [recipientUserId], references: [id], onDelete: Cascade) @@index([userId, createdAt(sort: Desc)]) + @@index([recordUserId, createdAt(sort: Desc)]) + @@index([recipientUserId, createdAt(sort: Desc)]) @@map("push_attempts") } diff --git a/src/__tests__/notification-delivery-identity.test.ts b/src/__tests__/notification-delivery-identity.test.ts new file mode 100644 index 000000000..7cd591d7d --- /dev/null +++ b/src/__tests__/notification-delivery-identity.test.ts @@ -0,0 +1,183 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const transactionMock = vi.fn(); + +vi.mock("@/lib/db", () => ({ + prisma: { + notificationChannel: { + findMany: vi.fn(), + update: vi.fn(), + upsert: vi.fn(), + }, + user: { findUnique: vi.fn() }, + auditLog: { create: vi.fn() }, + $transaction: (...args: unknown[]) => transactionMock(...args), + }, +})); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn(async () => undefined), +})); + +vi.mock("@/lib/crypto", () => ({ + decrypt: (value: string) => value, + encrypt: (value: string) => value, +})); + +vi.mock("@/lib/logging/context", () => ({ + getEvent: () => ({ + addWarning: vi.fn(), + addMeta: vi.fn(), + }), +})); + +const sendViaWebPushMock = vi.fn(); + +vi.mock("@/lib/notifications/senders/apns", () => ({ + sendViaApns: vi.fn(), +})); +vi.mock("@/lib/notifications/senders/email", () => ({ + sendViaEmail: vi.fn(), +})); +vi.mock("@/lib/notifications/senders/ntfy", () => ({ + sendViaNtfy: vi.fn(), +})); +vi.mock("@/lib/notifications/senders/telegram", () => ({ + sendViaTelegram: vi.fn(), +})); +vi.mock("@/lib/notifications/senders/web-push", () => ({ + sendViaWebPush: (...args: unknown[]) => sendViaWebPushMock(...args), +})); +vi.mock("@/lib/notifications/senders/webhook", () => ({ + sendViaWebhook: vi.fn(), +})); +vi.mock("@/lib/notifications/senders/push-attempt-record", () => ({ + recordPushAttempt: vi.fn(), +})); + +import { prisma } from "@/lib/db"; +import { dispatchNotification } from "@/lib/notifications/dispatcher"; + +const recordUserId = "managed-record"; +const recipientUserId = "guardian-recipient"; + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + managedProfileAt: new Date(), + } as never); + vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([ + { + id: "recipient-channel", + userId: recipientUserId, + type: "WEB_PUSH", + config: "{}", + nextRetryAt: null, + preferences: [], + }, + ] as never); + vi.mocked(prisma.notificationChannel.update).mockResolvedValue({ + consecutiveFailures: 0, + } as never); + transactionMock.mockImplementation( + async (operation: (tx: unknown) => Promise) => + operation({ + $queryRaw: vi.fn(), + user: { + findUnique: vi.fn(async ({ where }: { where: { id: string } }) => + where.id === recordUserId + ? { managedProfileAt: new Date() } + : { managedProfileAt: null }, + ), + }, + accountGrant: { findFirst: vi.fn(async () => ({ id: "grant" })) }, + }), + ); + sendViaWebPushMock.mockResolvedValue({ ok: true }); +}); + +describe("notification delivery identity", () => { + it("principal resolution uses the recipient channel for a managed record", async () => { + const outcome = await dispatchNotification({ + eventType: "MEDICATION_REMINDER", + userId: recordUserId, + recordUserId, + recipientUserId, + title: "Record content", + message: "Record schedule", + }); + + expect(outcome).toEqual({ + dispatched: true, + channelsAttempted: 1, + channelsSucceeded: 1, + }); + expect(prisma.notificationChannel.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId: recipientUserId, enabled: true }, + }), + ); + expect(sendViaWebPushMock).toHaveBeenCalledWith( + recipientUserId, + expect.objectContaining({ + userId: recordUserId, + recordUserId, + recipientUserId, + title: "Record content", + message: "Record schedule", + }), + ); + expect(transactionMock).toHaveBeenCalledTimes(1); + }); + + it("refuses a managed record without an explicit recipient", async () => { + const outcome = await dispatchNotification({ + eventType: "MEDICATION_REMINDER", + userId: recordUserId, + title: "Record content", + message: "Record schedule", + }); + + expect(outcome).toEqual({ + dispatched: false, + channelsAttempted: 0, + channelsSucceeded: 0, + }); + expect(prisma.notificationChannel.findMany).not.toHaveBeenCalled(); + expect(sendViaWebPushMock).not.toHaveBeenCalled(); + }); + + it("uses the recipient preference without changing record content", async () => { + vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([ + { + id: "recipient-channel", + userId: recipientUserId, + type: "WEB_PUSH", + config: "{}", + nextRetryAt: null, + preferences: [{ eventType: "MEDICATION_REMINDER", enabled: false }], + }, + ] as never); + + const outcome = await dispatchNotification({ + eventType: "MEDICATION_REMINDER", + userId: recordUserId, + recordUserId, + recipientUserId, + title: "Record content", + message: "Record schedule", + }); + + expect(outcome).toEqual({ + dispatched: false, + channelsAttempted: 0, + channelsSucceeded: 0, + }); + expect(prisma.notificationChannel.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId: recipientUserId, enabled: true }, + }), + ); + expect(sendViaWebPushMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/admin/notifications/diagnostic/__tests__/route.test.ts b/src/app/api/admin/notifications/diagnostic/__tests__/route.test.ts index 8581ebece..bd873039c 100644 --- a/src/app/api/admin/notifications/diagnostic/__tests__/route.test.ts +++ b/src/app/api/admin/notifications/diagnostic/__tests__/route.test.ts @@ -68,7 +68,6 @@ vi.mock("@/lib/crypto", () => ({ import { GET } from "../route"; import { prisma } from "@/lib/db"; import { requireAdmin, HttpError } from "@/lib/api-handler"; -import { REMINDER_DEDUP_CHANNEL } from "@/lib/notifications/reminder-dedup"; const ADMIN_USER_ID = "admin-1"; const ADMIN_CTX = { @@ -97,6 +96,8 @@ interface DiagnosticEnvelope { configPresent: boolean; }>; recentPushAttempts: Array<{ + recordUserId: string; + recipientUserId: string; eventType: string; channel: string; result: string; @@ -341,6 +342,8 @@ describe("GET /api/admin/notifications/diagnostic — recentPushAttempts", () => // mapping step normalises to an ISO string under `at`. vi.mocked(prisma.pushAttempt.findMany).mockResolvedValue([ { + recordUserId: "managed-record", + recipientUserId: ADMIN_USER_ID, eventType: "MEDICATION_REMINDER", channel: "APNS", result: "ok", @@ -348,6 +351,8 @@ describe("GET /api/admin/notifications/diagnostic — recentPushAttempts", () => createdAt: new Date("2026-05-22T09:00:00.000Z"), }, { + recordUserId: ADMIN_USER_ID, + recipientUserId: ADMIN_USER_ID, eventType: "MOOD_REMINDER", channel: "TELEGRAM", result: "error", @@ -362,6 +367,8 @@ describe("GET /api/admin/notifications/diagnostic — recentPushAttempts", () => expect(res.status).toBe(200); expect(body.data.recentPushAttempts).toEqual([ { + recordUserId: "managed-record", + recipientUserId: ADMIN_USER_ID, eventType: "MEDICATION_REMINDER", channel: "APNS", result: "ok", @@ -369,6 +376,8 @@ describe("GET /api/admin/notifications/diagnostic — recentPushAttempts", () => at: "2026-05-22T09:00:00.000Z", }, { + recordUserId: ADMIN_USER_ID, + recipientUserId: ADMIN_USER_ID, eventType: "MOOD_REMINDER", channel: "TELEGRAM", result: "error", @@ -386,28 +395,24 @@ describe("GET /api/admin/notifications/diagnostic — recentPushAttempts", () => expect(vi.mocked(prisma.pushAttempt.findMany)).toHaveBeenCalledTimes(1); const args = vi.mocked(prisma.pushAttempt.findMany).mock.calls[0]?.[0] as { - where: { userId: string }; + where: { recipientUserId: string }; orderBy: { createdAt: "desc" }; take: number; }; - expect(args.where.userId).toBe(ADMIN_USER_ID); + expect(args.where.recipientUserId).toBe(ADMIN_USER_ID); expect(args.orderBy).toEqual({ createdAt: "desc" }); expect(args.take).toBe(20); }); - it("excludes reminder-dedup bookkeeping rows so the last 20 are real sends", async () => { - // The medication tick anchors its per-slot dedup in this table under a - // sentinel channel. Letting those rows into the trailing window would - // push the actual delivery attempts — the thing an operator opens this - // endpoint for — off the list. + it("reads recipient-scoped delivery attempts without bookkeeping sentinels", async () => { vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX); vi.mocked(prisma.pushAttempt.findMany).mockResolvedValue([] as never); await GET(); const args = vi.mocked(prisma.pushAttempt.findMany).mock.calls[0]?.[0] as { - where: { channel?: { not?: string } }; + where: { recipientUserId?: string }; }; - expect(args.where.channel).toEqual({ not: REMINDER_DEDUP_CHANNEL }); + expect(args.where).toEqual({ recipientUserId: ADMIN_USER_ID }); }); }); diff --git a/src/app/api/admin/notifications/diagnostic/route.ts b/src/app/api/admin/notifications/diagnostic/route.ts index 9c80e4df9..9b977842e 100644 --- a/src/app/api/admin/notifications/diagnostic/route.ts +++ b/src/app/api/admin/notifications/diagnostic/route.ts @@ -4,7 +4,6 @@ import { annotate } from "@/lib/logging/context"; import { apiSuccess } from "@/lib/api-response"; import { decrypt } from "@/lib/crypto"; import type { ChannelType } from "@/lib/notifications/types"; -import { REMINDER_DEDUP_CHANNEL } from "@/lib/notifications/reminder-dedup"; export const dynamic = "force-dynamic"; @@ -59,6 +58,8 @@ interface DiagnosticChannel { } interface DiagnosticPushAttempt { + recordUserId: string; + recipientUserId: string; eventType: string; channel: string; result: string; @@ -170,18 +171,19 @@ export const GET = apiHandler(async () => { where: { userId: user.id }, select: { type: true, enabled: true, config: true }, }), - // v1.4.49 — trailing 20 push attempts for the calling admin's own - // account. The `(user_id, created_at DESC)` index on `push_attempts` - // turns the orderBy + take into a single index scan, so the cost is - // bounded regardless of the user's lifetime push volume. + // Trailing delivery attempts for the calling account as recipient. The + // recipient index keeps the bounded diagnostic read independent of the + // account's lifetime notification volume. prisma.pushAttempt.findMany({ - // The reminder tick's per-slot dedup anchors live in this table under - // a sentinel channel. They are bookkeeping, not delivery attempts, - // and would push the real sends out of the trailing window. - where: { userId: user.id, channel: { not: REMINDER_DEDUP_CHANNEL } }, + where: { + recipientUserId: user.id, + }, orderBy: { createdAt: "desc" }, take: 20, select: { + userId: true, + recordUserId: true, + recipientUserId: true, eventType: true, channel: true, result: true, @@ -215,6 +217,8 @@ export const GET = apiHandler(async () => { // `DiagnosticPushAttempt` interface above (the existing consumer // contract uses `at`, not `createdAt`). const recentPushAttempts: DiagnosticPushAttempt[] = pushAttempts.map((a) => ({ + recordUserId: a.recordUserId ?? a.userId, + recipientUserId: a.recipientUserId ?? a.userId, eventType: a.eventType, channel: a.channel, result: a.result, diff --git a/src/app/api/admin/notifications/health/__tests__/route.test.ts b/src/app/api/admin/notifications/health/__tests__/route.test.ts index a431abc2d..1cd02c79c 100644 --- a/src/app/api/admin/notifications/health/__tests__/route.test.ts +++ b/src/app/api/admin/notifications/health/__tests__/route.test.ts @@ -37,7 +37,6 @@ vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); import { GET } from "../route"; import { prisma } from "@/lib/db"; import { requireAdmin, HttpError } from "@/lib/api-handler"; -import { REMINDER_DEDUP_CHANNEL } from "@/lib/notifications/reminder-dedup"; import { NextRequest } from "next/server"; const ADMIN_CTX = { @@ -140,15 +139,12 @@ describe("GET /api/admin/notifications/health — aggregate", () => { ]); }); - it("excludes reminder-dedup bookkeeping rows from the channel picture", async () => { - // The medication tick anchors its per-slot dedup in the same table - // under a sentinel channel. Those rows are not delivery attempts, so - // counting them would misreport what the channels are doing. + it("groups every delivery attempt in the selected window", async () => { await GET(req()); const args = vi.mocked(prisma.pushAttempt.groupBy).mock.calls[0][0] as { - where: { channel?: { not?: string } }; + where: { createdAt?: { gte?: Date } }; }; - expect(args.where.channel).toEqual({ not: REMINDER_DEDUP_CHANNEL }); + expect(args.where.createdAt?.gte).toBeInstanceOf(Date); }); it("defaults the window to 24h and clamps out-of-range values", async () => { diff --git a/src/app/api/admin/notifications/health/route.ts b/src/app/api/admin/notifications/health/route.ts index 47460a9f5..ab7228c39 100644 --- a/src/app/api/admin/notifications/health/route.ts +++ b/src/app/api/admin/notifications/health/route.ts @@ -2,7 +2,6 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAdmin } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { apiSuccess } from "@/lib/api-response"; -import { REMINDER_DEDUP_CHANNEL } from "@/lib/notifications/reminder-dedup"; export const dynamic = "force-dynamic"; @@ -59,12 +58,8 @@ export const GET = apiHandler(async (request) => { const [grouped, disabled] = await Promise.all([ prisma.pushAttempt.groupBy({ by: ["channel", "result"], - // The reminder tick anchors its per-slot dedup in the same table - // under a sentinel channel. Those rows record a decision, not a - // delivery, so they stay out of the per-channel health picture. where: { createdAt: { gte: since }, - channel: { not: REMINDER_DEDUP_CHANNEL }, }, _count: { _all: true }, }), diff --git a/src/app/api/telegram/webhook/__tests__/route.test.ts b/src/app/api/telegram/webhook/__tests__/route.test.ts index 634857667..05431bd59 100644 --- a/src/app/api/telegram/webhook/__tests__/route.test.ts +++ b/src/app/api/telegram/webhook/__tests__/route.test.ts @@ -291,6 +291,14 @@ describe("Telegram webhook — callback dispatch", () => { const res = await POST(tgRequest(callbackUpdate("taken:med-1"))); expect(res.status).toBe(200); + expect(prisma.telegramReminderMessage.deleteMany).toHaveBeenCalledWith({ + where: { + recipientUserId: "user-1", + medicationId: "med-1", + medication: { userId: "user-1" }, + }, + }); + // No schedules wired → the take is ad-hoc and inserts standalone. expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1); const intakeArgs = vi.mocked(prisma.medicationIntakeEvent.create).mock @@ -779,6 +787,16 @@ describe("Telegram webhook — slot convergence (v1.16.9)", () => { const res = await POST(tgRequest(callbackUpdate("taken:med-1"))); expect(res.status).toBe(200); + expect(prisma.telegramReminderMessage.findFirst).toHaveBeenCalledWith({ + where: { + recipientUserId: "user-1", + medicationId: "med-1", + chatId: "7777", + messageId: 555, + }, + select: { date: true, timeOfDay: true }, + }); + // The pending row was updated in place — never a duplicate insert. expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled(); expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledTimes(1); diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index b30dbb111..ae0ddc698 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -161,6 +161,7 @@ export const WIPE_MODELS = [ "NotificationChannel", "PushSubscription", "PushAttempt", + "NotificationEvent", "Device", "TelegramScheduledDeletion", "TelegramPromptContext", @@ -421,6 +422,7 @@ export const WIPE_OWNER_FIELDS: Readonly> = { // attribution and must not let deleting a manager erase another record's // import history. MedicationIntakeImportJob: ["recordUserId"], + NotificationEvent: ["recordUserId"], // Both sides. Wiping only the grantor side would leave the account still // holding read access to other people's records after it asked for // everything of its own to be deleted. diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index be2bd5208..ef5dee16d 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -405,7 +405,9 @@ export const NOT_IN_BACKUP_MODELS: Readonly> = { // Ledgers whose meaning is local to the deployment. PushAttempt: - "A 90-day delivery ledger for this host's sends, and since v1.33.1 also the anchor rows the reminder dedup writes under a sentinel channel. Both describe what this instance did; restoring either onto another host would assert sends it never made, and the dedup anchors would suppress reminders that host still owes.", + "A 90-day delivery ledger for this host's sends. Restoring it onto another host would assert sends it never made.", + NotificationEvent: + "A local notification dedup anchor. Restoring it onto another host would suppress reminders and safety notices that host still owes.", AuditLog: "An append-only record of actions taken on this instance. Merging one instance's audit trail into another's would make the trail untrue.", CoachUsage: diff --git a/src/lib/illness/__tests__/red-flag-notify.test.ts b/src/lib/illness/__tests__/red-flag-notify.test.ts index 9a852ca9c..9cdaf2399 100644 --- a/src/lib/illness/__tests__/red-flag-notify.test.ts +++ b/src/lib/illness/__tests__/red-flag-notify.test.ts @@ -10,10 +10,15 @@ 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(); +const claimMock = 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), }, @@ -28,6 +33,10 @@ vi.mock("@/lib/notifications/dispatch-localised", () => ({ dispatchLocalisedNotification: (...a: unknown[]) => dispatchMock(...a), })); +vi.mock("@/lib/notifications/reminder-dedup", () => ({ + claimNotificationEvent: (...a: unknown[]) => claimMock(...a), +})); + import { notifyIllnessRedFlag } from "../red-flag-notify"; import type { IllnessRedFlag } from "../correlation"; @@ -45,9 +54,11 @@ const feverFlag: IllnessRedFlag = { }; beforeEach(() => { + userFindUniqueMock.mockReset().mockResolvedValue({ managedProfileAt: null }); findFirstMock.mockReset().mockResolvedValue(null); createMock.mockReset().mockResolvedValue({}); dispatchMock.mockReset().mockResolvedValue(undefined); + claimMock.mockReset().mockResolvedValue(true); }); afterEach(() => vi.clearAllMocks()); @@ -85,25 +96,46 @@ describe("notifyIllnessRedFlag", () => { ); }); - it("de-dupes when a recent ledger row exists for the episode", async () => { - findFirstMock.mockResolvedValueOnce({ id: "prior" }); + it("does not dispatch when a concurrent claimant already owns the episode", async () => { + claimMock.mockResolvedValueOnce(false); await notifyIllnessRedFlag({ userId: "u1", episodeId: "e1", redFlags: [feverFlag], }); expect(dispatchMock).not.toHaveBeenCalled(); - expect(createMock).not.toHaveBeenCalled(); + expect(claimMock).toHaveBeenCalledTimes(1); }); - it("stamps the ledger anchor before dispatching", async () => { + it("claims 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(claimMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + 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(claimMock).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..0eef5e6a3 100644 --- a/src/lib/illness/__tests__/safety-floor-notify.test.ts +++ b/src/lib/illness/__tests__/safety-floor-notify.test.ts @@ -11,10 +11,15 @@ 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(); +const claimMock = 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), }, @@ -29,6 +34,10 @@ vi.mock("@/lib/notifications/dispatch-localised", () => ({ dispatchLocalisedNotification: (...a: unknown[]) => dispatchMock(...a), })); +vi.mock("@/lib/notifications/reminder-dedup", () => ({ + claimNotificationEvent: (...a: unknown[]) => claimMock(...a), +})); + import { notifySafetyFloor } from "../safety-floor-notify"; import type { SafetyFloorDecision } from "../safety-floors"; @@ -50,9 +59,11 @@ const glucoseLowSymptomatic: SafetyFloorDecision = { }; beforeEach(() => { + userFindUniqueMock.mockReset().mockResolvedValue({ managedProfileAt: null }); findFirstMock.mockReset().mockResolvedValue(null); createMock.mockReset().mockResolvedValue({}); dispatchMock.mockReset().mockResolvedValue(undefined); + claimMock.mockReset().mockResolvedValue(true); }); afterEach(() => vi.clearAllMocks()); @@ -94,20 +105,34 @@ describe("notifySafetyFloor", () => { expect(opts.params).toEqual({ value: 2.7, unit: "mmol/L", threshold: 3 }); }); - it("de-dupes by reason when a recent ledger row exists", async () => { - findFirstMock.mockResolvedValueOnce({ id: "prior" }); + it("does not dispatch when a concurrent claimant owns the safety event", async () => { + claimMock.mockResolvedValueOnce(false); await notifySafetyFloor({ userId: "u1", decision: bpHigh }); expect(dispatchMock).not.toHaveBeenCalled(); - expect(createMock).not.toHaveBeenCalled(); + expect(claimMock).toHaveBeenCalledTimes(1); }); - it("stamps the ledger anchor (keyed by reason) before dispatching", async () => { + it("claims 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(claimMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + recordUserId: "u9", + eventType: "SYSTEM_ALERT", + dedupKey: expect.stringContaining("bp_hypertensive"), + }), ); - expect(createMock.mock.calls[0][0].data.eventType).toBe("SYSTEM_ALERT"); + }); + + 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(claimMock).not.toHaveBeenCalled(); + expect(dispatchMock).not.toHaveBeenCalled(); }); it("never throws when dispatch fails", async () => { diff --git a/src/lib/illness/__tests__/safety-floor-sustained.test.ts b/src/lib/illness/__tests__/safety-floor-sustained.test.ts index bc874e075..70f0f4915 100644 --- a/src/lib/illness/__tests__/safety-floor-sustained.test.ts +++ b/src/lib/illness/__tests__/safety-floor-sustained.test.ts @@ -6,14 +6,19 @@ * opened the episode's correlation tab. These tests pin the on-write seam: * a sustained run escalates on INGEST through the SAME illness red-flag push, * with the SAME run length (RED_FLAG_RUN_DAYS) and absolute floors the detector - * uses, and the 24h ledger dedupe stops a 4th reading re-firing the alarm. + * uses, and the 24h record-event dedupe stops a 4th reading re-firing the alarm. * * Calendar-day bucketing runs against TZ=UTC (the gate's contract). */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -/** In-memory pushAttempt ledger so the 24h dedupe behaves across calls. */ -const ledger: Array<{ userId: string; reason: string }> = []; +/** In-memory event claims so the 24h dedupe behaves across calls. */ +const ledger: Array<{ + recordUserId: string; + eventType: string; + dedupKey: string; + createdAt: Date; +}> = []; const measurementFindMany = vi.fn(); const dispatchMock = vi.fn(); @@ -22,17 +27,8 @@ vi.mock("@/lib/db", () => ({ measurement: { findMany: (...a: unknown[]) => measurementFindMany(...a), }, - pushAttempt: { - findFirst: ({ where }: { where: { userId: string; reason: string } }) => { - const hit = ledger.find( - (r) => r.userId === where.userId && r.reason === where.reason, - ); - return Promise.resolve(hit ? { id: "prior" } : null); - }, - create: ({ data }: { data: { userId: string; reason: string } }) => { - ledger.push({ userId: data.userId, reason: data.reason }); - return Promise.resolve({}); - }, + user: { + findUnique: vi.fn().mockResolvedValue({ managedProfileAt: null }), }, }, })); @@ -49,6 +45,29 @@ vi.mock("@/lib/notifications/dispatch-localised", () => ({ dispatchLocalisedNotification: (...a: unknown[]) => dispatchMock(...a), })); +vi.mock("@/lib/notifications/reminder-dedup", () => ({ + claimNotificationEvent: ( + _prisma: unknown, + input: { + recordUserId: string; + eventType: string; + dedupKey: string; + since: Date; + }, + ) => { + const existing = ledger.find( + (event) => + event.recordUserId === input.recordUserId && + event.eventType === input.eventType && + event.dedupKey === input.dedupKey && + event.createdAt >= input.since, + ); + if (existing) return Promise.resolve(false); + ledger.push({ ...input, createdAt: new Date() }); + return Promise.resolve(true); + }, +})); + import { runSafetyFloorCheck } from "../safety-floor-check"; /** A measured-at on a given UTC calendar day, mid-morning. */ diff --git a/src/lib/illness/red-flag-notify.ts b/src/lib/illness/red-flag-notify.ts index d5cbf8916..2ac3c23a6 100644 --- a/src/lib/illness/red-flag-notify.ts +++ b/src/lib/illness/red-flag-notify.ts @@ -15,19 +15,20 @@ * 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"; import { getEvent } from "@/lib/logging/context"; import { dispatchLocalisedNotification } from "@/lib/notifications/dispatch-localised"; +import { claimNotificationEvent } from "@/lib/notifications/reminder-dedup"; 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,16 +45,26 @@ export async function notifyIllnessRedFlag(input: { if (redFlags.length === 0) return; try { - // 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. + // 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; + + // Claim this episode's record event before provider egress. The claim + // serializes concurrent readers without holding a transaction open for + // delivery itself. 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 } }, - select: { id: true }, + const claimed = await claimNotificationEvent(prisma, { + recordUserId: userId, + eventType: "SYSTEM_ALERT", + dedupKey: reason, + since, }); - if (prior) return; + if (!claimed) return; // Prefer the most clinically actionable reason for the body. Both copy // keys already exist in every locale bundle (i18n integrity guarantee). @@ -62,19 +73,6 @@ 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({ - data: { - userId, - channel: "WEB_PUSH", - eventType: "SYSTEM_ALERT", - result: "skipped", - reason, - }, - }); - await dispatchLocalisedNotification({ userId, titleKey: "illness.correlation.redFlagTitle", diff --git a/src/lib/illness/safety-floor-notify.ts b/src/lib/illness/safety-floor-notify.ts index 23df53951..9065b8fa9 100644 --- a/src/lib/illness/safety-floor-notify.ts +++ b/src/lib/illness/safety-floor-notify.ts @@ -13,13 +13,14 @@ * - 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). */ import { prisma } from "@/lib/db"; import { getEvent } from "@/lib/logging/context"; import { dispatchLocalisedNotification } from "@/lib/notifications/dispatch-localised"; +import { claimNotificationEvent } from "@/lib/notifications/reminder-dedup"; import { convertGlucose, type GlucoseUnit } from "@/lib/glucose"; import { GLUCOSE_HYPO, @@ -31,7 +32,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,26 +114,23 @@ 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 } }, - 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({ - data: { - userId, - channel: "WEB_PUSH", - eventType: "SYSTEM_ALERT", - result: "skipped", - reason, - }, + const claimed = await claimNotificationEvent(prisma, { + recordUserId: userId, + eventType: "SYSTEM_ALERT", + dedupKey: reason, + since, }); + if (!claimed) return; const { titleKey, messageKey } = copyKeysFor(decision); await dispatchLocalisedNotification({ diff --git a/src/lib/jobs/__tests__/medication-reminder-check.test.ts b/src/lib/jobs/__tests__/medication-reminder-check.test.ts index 2db333d96..55ab1bfcd 100644 --- a/src/lib/jobs/__tests__/medication-reminder-check.test.ts +++ b/src/lib/jobs/__tests__/medication-reminder-check.test.ts @@ -103,9 +103,9 @@ vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined), })); vi.mock("@/lib/notifications/reminder-dedup", () => ({ - hasReminderDedupAnchor: vi.fn().mockResolvedValue(false), + claimNotificationEvent: vi.fn().mockResolvedValue(true), medicationReminderDedupKey: vi.fn(() => "dedup-key"), - writeReminderDedupAnchor: vi.fn().mockResolvedValue(undefined), + REMINDER_DEDUP_LOOKBACK_MS: 48 * 60 * 60 * 1000, })); import { handleReminderCheck } from "@/lib/jobs/reminder/medication-reminder-check"; diff --git a/src/lib/jobs/__tests__/medication-reminder-dedup.test.ts b/src/lib/jobs/__tests__/medication-reminder-dedup.test.ts index e11b6912f..dfa47aadd 100644 --- a/src/lib/jobs/__tests__/medication-reminder-dedup.test.ts +++ b/src/lib/jobs/__tests__/medication-reminder-dedup.test.ts @@ -9,7 +9,7 @@ * only when a Telegram send succeeds. A user on email / APNs / ntfy * therefore never had a ledger row, so the 15-minute tick re-fired the * same overdue notice for the rest of the day — roughly fifty mails per - * missed dose. The anchor now lives in `push_attempts` and is written + * missed dose. The anchor now lives in `notification_events` and is written * for every dispatched slot regardless of channel outcome. * * 2. **Coarser must not mean quieter.** The dedup is per phase and per @@ -24,18 +24,18 @@ */ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; -interface FakePushAttempt { - userId: string; - channel: string; +interface FakeNotificationEvent { + recordUserId: string; eventType: string; - result: string; - reason: string | null; + dedupKey: string; createdAt: Date; } -const pushAttempts: FakePushAttempt[] = []; +const notificationEvents: FakeNotificationEvent[] = []; const prismaMock = { + $queryRaw: vi.fn().mockResolvedValue([{ locked: 1 }]), + $transaction: vi.fn(), telegramScheduledDeletion: { findMany: vi.fn().mockResolvedValue([]), deleteMany: vi.fn().mockResolvedValue({ count: 0 }), @@ -56,11 +56,11 @@ const prismaMock = { telegramReminderMessage: { findUnique: vi.fn().mockResolvedValue(null), }, - pushAttempt: { + notificationEvent: { create: vi.fn( - async ({ data }: { data: Omit }) => { - pushAttempts.push({ ...data, createdAt: new Date() }); - return { id: `pa-${pushAttempts.length}` }; + async ({ data }: { data: Omit }) => { + notificationEvents.push({ ...data, createdAt: new Date() }); + return { id: `ne-${notificationEvents.length}` }; }, ), findFirst: vi.fn( @@ -68,17 +68,17 @@ const prismaMock = { where, }: { where: { - userId: string; + recordUserId: string; eventType: string; - reason: string; + dedupKey: string; createdAt?: { gte: Date }; }; }) => - pushAttempts.find( + notificationEvents.find( (row) => - row.userId === where.userId && + row.recordUserId === where.recordUserId && row.eventType === where.eventType && - row.reason === where.reason && + row.dedupKey === where.dedupKey && (where.createdAt?.gte === undefined || row.createdAt >= where.createdAt.gte), ) ?? null, @@ -189,7 +189,8 @@ async function tickAt(iso: string): Promise { beforeEach(() => { vi.clearAllMocks(); - pushAttempts.length = 0; + notificationEvents.length = 0; + prismaMock.$transaction.mockImplementation(async (run) => run(prismaMock)); vi.useFakeTimers(); prismaMock.telegramScheduledDeletion.findMany.mockResolvedValue([]); prismaMock.telegramPromptContext.deleteMany.mockResolvedValue({ count: 0 }); @@ -331,8 +332,8 @@ describe("issue #664 — dedup follows the scheduled occurrence across midnight" await tickAt("2026-07-28T22:30:00.000Z"); expect(dispatchNotification).toHaveBeenCalledTimes(1); - expect(pushAttempts).toHaveLength(1); - expect(pushAttempts[0]?.reason).toBe( + expect(notificationEvents).toHaveLength(1); + expect(notificationEvents[0]?.dedupKey).toBe( "med:med-1:sched-1:23:45:YELLOW:2026-07-28", ); }); diff --git a/src/lib/jobs/reminder/medication-reminder-check.ts b/src/lib/jobs/reminder/medication-reminder-check.ts index 7cd887cac..1643f69ce 100644 --- a/src/lib/jobs/reminder/medication-reminder-check.ts +++ b/src/lib/jobs/reminder/medication-reminder-check.ts @@ -31,9 +31,9 @@ import { getPhaseKeyboard, } from "@/lib/jobs/reminder-phases"; import { - hasReminderDedupAnchor, + claimNotificationEvent, medicationReminderDedupKey, - writeReminderDedupAnchor, + REMINDER_DEDUP_LOOKBACK_MS, } from "@/lib/notifications/reminder-dedup"; import { getUserTodayBounds as getUserTodayBoundsUtil } from "@/lib/tz/local-day"; import { getWorkerPrisma, parseTimeToMinutes } from "./shared"; @@ -544,13 +544,9 @@ export async function handleReminderCheck( continue; } - // Already notified for this slot, phase and local day? The - // anchor lives in `push_attempts` and is stamped for every - // dispatched slot regardless of which channels delivered. It - // used to be the Telegram message ledger, which is written only - // when a Telegram send succeeds — so a user on email / APNs / - // ntfy had no row and the tick re-sent the same overdue notice - // every 15 minutes for the rest of the day. + // Claim this slot/phase/local-day record event before mutable RED + // handling or provider egress. The short locked transaction keeps + // concurrent reminder ticks from both minting and delivering it. const dedupReason = medicationReminderDedupKey({ medicationId: med.id, scheduleId: schedule.id, @@ -561,15 +557,14 @@ export async function handleReminderCheck( }), }); - if ( - await hasReminderDedupAnchor(prisma, { - userId: med.user.id, + if (med.notificationsEnabled) { + const claimed = await claimNotificationEvent(prisma, { + recordUserId: med.user.id, eventType: "MEDICATION_REMINDER", - reason: dedupReason, - now, - }) - ) { - continue; + dedupKey: dedupReason, + since: new Date(now.getTime() - REMINDER_DEDUP_LOOKBACK_MS), + }); + if (!claimed) continue; } const doseInfo = schedule.dose ?? med.dose; @@ -651,15 +646,6 @@ export async function handleReminderCheck( `${currentPhase}:${med.name}:${slotTime}`, ); - // Stamp the dedup anchor BEFORE dispatching: a crash between - // the two costs this phase's notice, a repeat costs the user - // a notification every 15 minutes until the day rolls. - await writeReminderDedupAnchor(prisma, { - userId: med.user.id, - eventType: "MEDICATION_REMINDER", - reason: dedupReason, - }); - try { await dispatchNotification({ eventType: "MEDICATION_REMINDER", diff --git a/src/lib/notifications/__tests__/client-managed-apns.test.ts b/src/lib/notifications/__tests__/client-managed-apns.test.ts index 9121c89a5..75a7a7b8c 100644 --- a/src/lib/notifications/__tests__/client-managed-apns.test.ts +++ b/src/lib/notifications/__tests__/client-managed-apns.test.ts @@ -137,7 +137,8 @@ describe("dispatchNotification — client-managed APNs suppression", () => { }, ); expect(recordPushAttemptMock).toHaveBeenCalledWith({ - userId: "u-1", + recordUserId: "u-1", + recipientUserId: "u-1", channel: "APNS", eventType: "MEDICATION_REMINDER", result: "skipped", diff --git a/src/lib/notifications/__tests__/dispatcher.test.ts b/src/lib/notifications/__tests__/dispatcher.test.ts index b8b543938..3e2deb809 100644 --- a/src/lib/notifications/__tests__/dispatcher.test.ts +++ b/src/lib/notifications/__tests__/dispatcher.test.ts @@ -86,7 +86,9 @@ function makeChannel(over: Partial = {}): MockChannel { beforeEach(() => { vi.resetAllMocks(); - vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + managedProfileAt: null, + } as never); // Default: every update returns a counter of 1 — individual tests // override the resolved value when they need to drive a specific // failure count (e.g. the "5th in a row" give-up test). diff --git a/src/lib/notifications/__tests__/reminder-dedup.test.ts b/src/lib/notifications/__tests__/reminder-dedup.test.ts new file mode 100644 index 000000000..8c84a4305 --- /dev/null +++ b/src/lib/notifications/__tests__/reminder-dedup.test.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { prisma } from "@/lib/db"; + +const findFirstMock = vi.fn(); +const createMock = vi.fn(); +const queryRawMock = vi.fn(); +const transactionMock = vi.fn(); + +vi.mock("@/lib/db", () => ({ + prisma: { + $queryRaw: (...args: unknown[]) => queryRawMock(...args), + $transaction: (...args: unknown[]) => transactionMock(...args), + notificationEvent: { + findFirst: (...args: unknown[]) => findFirstMock(...args), + create: (...args: unknown[]) => createMock(...args), + }, + }, +})); + +import { claimNotificationEvent } from "../reminder-dedup"; + +beforeEach(() => { + findFirstMock.mockReset().mockResolvedValue(null); + createMock.mockReset().mockResolvedValue({ id: "event" }); + queryRawMock.mockReset().mockResolvedValue([{ locked: 1 }]); + transactionMock.mockReset().mockImplementation(async (run) => run(prisma)); +}); + +describe("reminder dedup anchors", () => { + it("claims one record-scoped event under a short transaction lock", async () => { + const now = new Date("2026-08-06T08:00:00.000Z"); + + await expect( + claimNotificationEvent(prisma as never, { + recordUserId: "managed-record", + eventType: "MEDICATION_REMINDER", + dedupKey: "med:slot", + since: now, + }), + ).resolves.toBe(true); + + expect(queryRawMock).toHaveBeenCalledTimes(1); + expect(findFirstMock).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + recordUserId: "managed-record", + eventType: "MEDICATION_REMINDER", + dedupKey: "med:slot", + }), + }), + ); + expect(createMock).toHaveBeenCalledWith({ + data: { + recordUserId: "managed-record", + eventType: "MEDICATION_REMINDER", + dedupKey: "med:slot", + }, + }); + }); + + it("does not claim when the locked window already contains the event", async () => { + findFirstMock.mockResolvedValueOnce({ id: "prior" }); + + await expect( + claimNotificationEvent(prisma as never, { + recordUserId: "managed-record", + eventType: "MEDICATION_REMINDER", + dedupKey: "med:slot", + since: new Date("2026-08-06T08:00:00.000Z"), + }), + ).resolves.toBe(false); + + expect(createMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/notifications/__tests__/web-push-dose-clear.test.ts b/src/lib/notifications/__tests__/web-push-dose-clear.test.ts index 5fd17e260..684a85cea 100644 --- a/src/lib/notifications/__tests__/web-push-dose-clear.test.ts +++ b/src/lib/notifications/__tests__/web-push-dose-clear.test.ts @@ -45,6 +45,7 @@ vi.mock("@/lib/logging/context", () => ({ vi.mock("@/lib/notifications/senders/push-attempt-record", () => ({ recordPushAttempt: vi.fn(), + recordPushAttemptForPayload: vi.fn(), })); vi.mock("@/lib/db", () => ({ diff --git a/src/lib/notifications/__tests__/web-push-egress-pin.test.ts b/src/lib/notifications/__tests__/web-push-egress-pin.test.ts index 00885c993..488d4574b 100644 --- a/src/lib/notifications/__tests__/web-push-egress-pin.test.ts +++ b/src/lib/notifications/__tests__/web-push-egress-pin.test.ts @@ -38,6 +38,7 @@ vi.mock("@/lib/logging/context", () => ({ vi.mock("@/lib/notifications/senders/push-attempt-record", () => ({ recordPushAttempt: vi.fn(), + recordPushAttemptForPayload: vi.fn(), })); vi.mock("@/lib/db", () => ({ diff --git a/src/lib/notifications/delivery-identity.ts b/src/lib/notifications/delivery-identity.ts new file mode 100644 index 000000000..3283dddb1 --- /dev/null +++ b/src/lib/notifications/delivery-identity.ts @@ -0,0 +1,76 @@ +import { prisma } from "@/lib/db"; +import { + activeGuardianWhere, + withManagedProfileLock, +} from "@/lib/managed-profiles/lifecycle"; +import type { NotificationPayload } from "@/lib/notifications/types"; + +export interface NotificationDeliveryIdentity { + recordUserId: string; + recipientUserId: string; + managed: boolean; +} + +/** + * Resolve notification identities without treating a managed record as its + * own destination. Legacy payloads remain valid only for ordinary self + * delivery; a managed record must name an active Guardian explicitly. + * + * Cross-principal authorization linearizes at the locked grant read below, + * immediately before the dispatcher selects a channel. A revocation that + * commits before that read prevents delivery; one that commits afterwards + * affects later dispatches but cannot recall provider work already in flight. + * The provider call deliberately runs outside the short database transaction. + */ +export async function resolveNotificationDeliveryIdentity( + payload: NotificationPayload, +): Promise { + const hasRecordUserId = payload.recordUserId !== undefined; + const hasRecipientUserId = payload.recipientUserId !== undefined; + + if (hasRecordUserId !== hasRecipientUserId) return null; + + const recordUserId = payload.recordUserId ?? payload.userId; + const recipientUserId = payload.recipientUserId ?? payload.userId; + if (!recordUserId || !recipientUserId || payload.userId !== recordUserId) { + return null; + } + + const record = await prisma.user.findUnique({ + where: { id: recordUserId }, + select: { managedProfileAt: true }, + }); + if (!record) return null; + + if (record.managedProfileAt == null) { + return recipientUserId === recordUserId + ? { recordUserId, recipientUserId, managed: false } + : null; + } + + if (recipientUserId === recordUserId) return null; + + return prisma.$transaction(async (tx) => + withManagedProfileLock(tx, recordUserId, async (lockedRecord) => { + if (lockedRecord?.managedProfileAt == null) return null; + + const recipient = await tx.user.findUnique({ + where: { id: recipientUserId }, + select: { managedProfileAt: true }, + }); + if (!recipient || recipient.managedProfileAt != null) return null; + + const guardian = await tx.accountGrant.findFirst({ + where: { + grantorId: recordUserId, + granteeId: recipientUserId, + ...activeGuardianWhere(new Date()), + }, + select: { id: true }, + }); + if (!guardian) return null; + + return { recordUserId, recipientUserId, managed: true }; + }), + ); +} diff --git a/src/lib/notifications/dispatch-localised.ts b/src/lib/notifications/dispatch-localised.ts index 6278febbb..2311286b3 100644 --- a/src/lib/notifications/dispatch-localised.ts +++ b/src/lib/notifications/dispatch-localised.ts @@ -45,6 +45,8 @@ import type { EventType } from "@/lib/notifications/types"; interface DispatchLocalisedOptions { userId: string; + recordUserId?: string; + recipientUserId?: string; titleKey: string; messageKey: string; params?: Record; @@ -147,7 +149,9 @@ async function resolveRecipientLocale(userId: string): Promise { export async function dispatchLocalisedNotification( opts: DispatchLocalisedOptions, ): Promise { - const locale = await resolveRecipientLocale(opts.userId); + const locale = await resolveRecipientLocale( + opts.recipientUserId ?? opts.userId, + ); const t = getServerTranslator(locale).t; const title = t(opts.titleKey, opts.params); @@ -173,6 +177,8 @@ export async function dispatchLocalisedNotification( await dispatchNotification({ eventType: opts.eventType ?? "SYSTEM_ALERT", userId: opts.userId, + recordUserId: opts.recordUserId, + recipientUserId: opts.recipientUserId, title, message, metadata, diff --git a/src/lib/notifications/dispatcher.ts b/src/lib/notifications/dispatcher.ts index 48336ef85..77fed2b7d 100644 --- a/src/lib/notifications/dispatcher.ts +++ b/src/lib/notifications/dispatcher.ts @@ -29,6 +29,7 @@ import { hasClientManagedApnsGate, } from "@/lib/notifications/client-managed-apns"; import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record"; +import { resolveNotificationDeliveryIdentity } from "@/lib/notifications/delivery-identity"; /** * Dispatch a notification to all enabled channels for a user. @@ -63,11 +64,21 @@ export async function dispatchNotification( let channelsSucceeded = 0; try { + const delivery = await resolveNotificationDeliveryIdentity(payload); + if (!delivery) { + getEvent()?.addWarning("Notification delivery identity was invalid"); + return { dispatched: false, channelsAttempted, channelsSucceeded }; + } + const deliveryPayload: NotificationPayload = { + ...payload, + recordUserId: delivery.recordUserId, + recipientUserId: delivery.recipientUserId, + }; const channels = await prisma.notificationChannel.findMany({ - where: { userId: payload.userId, enabled: true }, + where: { userId: delivery.recipientUserId, enabled: true }, include: { preferences: { - where: { eventType: payload.eventType }, + where: { eventType: deliveryPayload.eventType }, }, }, }); @@ -79,7 +90,7 @@ export async function dispatchNotification( if (!hasTelegramChannel) { try { const user = await prisma.user.findUnique({ - where: { id: payload.userId }, + where: { id: delivery.recipientUserId }, select: { telegramBotToken: true, telegramChatId: true, @@ -101,12 +112,12 @@ export async function dispatchNotification( const created = await prisma.notificationChannel.upsert({ where: { userId_type: { - userId: payload.userId, + userId: delivery.recipientUserId, type: "TELEGRAM", }, }, create: { - userId: payload.userId, + userId: delivery.recipientUserId, type: "TELEGRAM", enabled: true, config: channelConfig, @@ -117,13 +128,16 @@ export async function dispatchNotification( }, include: { preferences: { - where: { eventType: payload.eventType }, + where: { eventType: deliveryPayload.eventType }, }, }, }); channels.push(created); - getEvent()?.addMeta("telegram_legacy_migration", payload.userId); + getEvent()?.addMeta( + "telegram_legacy_migration", + delivery.recipientUserId, + ); } } catch (err) { getEvent()?.addWarning(`Legacy Telegram migration failed: ${err}`); @@ -145,7 +159,7 @@ export async function dispatchNotification( // from /settings/notifications once they've seen the badge a few // times and decided they want push. let defaultEnabled = - EVENT_DEFAULT_ENABLED[payload.eventType as EventType] ?? true; + EVENT_DEFAULT_ENABLED[deliveryPayload.eventType as EventType] ?? true; // v1.7.0 — MOOD_REMINDER single source of truth = the visible card. // The event used to default OFF, layered on top of the per-user @@ -156,9 +170,9 @@ export async function dispatchNotification( // opt-out (a `NotificationPreference` row with `enabled = false`) is // still honoured below via `pref.enabled` — only the no-row default // is derived from the card. - if (payload.eventType === "MOOD_REMINDER") { + if (deliveryPayload.eventType === "MOOD_REMINDER") { const user = await prisma.user.findUnique({ - where: { id: payload.userId }, + where: { id: delivery.recipientUserId }, select: { moodReminderEnabled: true }, }); defaultEnabled = user?.moodReminderEnabled === true; @@ -170,13 +184,13 @@ export async function dispatchNotification( let clientManagedApns: boolean | null = null; const resolveClientManagedApns = async (): Promise => { if (clientManagedApns !== null) return clientManagedApns; - const gate = CLIENT_MANAGED_APNS_EVENTS[payload.eventType]; + const gate = CLIENT_MANAGED_APNS_EVENTS[deliveryPayload.eventType]; if (!gate) { clientManagedApns = false; return false; } const row = await prisma.user.findUnique({ - where: { id: payload.userId }, + where: { id: delivery.recipientUserId }, select: { notificationPrefs: true }, }); clientManagedApns = gate.isClientManaged(row?.notificationPrefs); @@ -209,21 +223,23 @@ export async function dispatchNotification( // about whether anything actually reached the user. if ( channel.type === "APNS" && - hasClientManagedApnsGate(payload.eventType) && + !delivery.managed && + hasClientManagedApnsGate(deliveryPayload.eventType) && (await resolveClientManagedApns()) ) { - const gate = CLIENT_MANAGED_APNS_EVENTS[payload.eventType]; - getEvent()?.addMeta(gate.metaKey, gate.tag(payload.metadata)); + const gate = CLIENT_MANAGED_APNS_EVENTS[deliveryPayload.eventType]; + getEvent()?.addMeta(gate.metaKey, gate.tag(deliveryPayload.metadata)); if (gate.detail && gate.detailKey) { getEvent()?.addMeta( gate.detailKey, - gate.detail(payload.userId, payload.metadata), + gate.detail(deliveryPayload.userId, deliveryPayload.metadata), ); } recordPushAttempt({ - userId: payload.userId, + recordUserId: delivery.recordUserId, + recipientUserId: delivery.recipientUserId, channel: "APNS", - eventType: payload.eventType, + eventType: deliveryPayload.eventType, result: "skipped", reason: "client_managed", }); @@ -236,7 +252,8 @@ export async function dispatchNotification( const outcome = await sendToChannel( channel.type as ChannelType, channel.config, - payload, + delivery.recipientUserId, + deliveryPayload, ); if (outcome.ok) { @@ -310,6 +327,7 @@ export async function dispatchNotification( async function sendToChannel( type: ChannelType, encryptedConfig: string, + recipientUserId: string, payload: NotificationPayload, ): Promise { let decrypted: string; @@ -393,7 +411,7 @@ async function sendToChannel( return sendViaEmail(config, payload); } case "WEB_PUSH": { - return sendViaWebPush(payload.userId, payload); + return sendViaWebPush(recipientUserId, payload); } case "APNS": { // APNs config lives on the Device row, not in the channel config. @@ -401,7 +419,7 @@ async function sendToChannel( // we just keep the symmetric path so future per-user overrides // (e.g. a custom APNs topic) can hang off the channel without // changing the dispatcher shape. - return sendViaApns(payload.userId, payload); + return sendViaApns(recipientUserId, payload); } default: getEvent()?.addWarning(`Unknown notification channel type: ${type}`); diff --git a/src/lib/notifications/reminder-dedup.ts b/src/lib/notifications/reminder-dedup.ts index 014ee3512..16f5ee741 100644 --- a/src/lib/notifications/reminder-dedup.ts +++ b/src/lib/notifications/reminder-dedup.ts @@ -8,9 +8,9 @@ * ntfy had no row, so the tick re-sent the same overdue notice on every * pass for the rest of the local day. * - * The anchor now lives in `push_attempts`, the ledger the daily briefing - * and the illness escalation already use for the same purpose. Two - * properties matter and both are deliberate: + * The anchor now lives in `notification_events`, a record-scoped event ledger + * rather than a channel delivery record. Three properties matter and all are + * deliberate: * * - **Written per dispatched slot, not per successful send.** Anchoring * on a delivered push would re-open the flood the moment a channel @@ -18,27 +18,20 @@ * - **Written before the dispatch.** A crash mid-dispatch burns the slot * for that phase rather than risking a repeat; the next phase still * escalates, and the next local day starts clean. + * - **Claimed atomically.** A transaction-scoped advisory lock serializes + * same-key workers through the window check and append, while provider + * egress remains outside that short transaction. * - * The rows are bookkeeping, not delivery attempts, so they carry the - * `DEDUP` channel sentinel and the operator-facing notification surfaces - * filter them out. */ import type { PrismaClient } from "@/generated/prisma/client"; -/** - * Channel sentinel for a bookkeeping row. Never a real delivery target — - * `/api/admin/notifications/{health,diagnostic}` exclude it so the - * per-channel health picture stays a picture of actual sends. - */ -export const REMINDER_DEDUP_CHANNEL = "DEDUP"; - /** * How far back the anchor lookup reads. The key already carries the local * date, so anything older than this cannot match; the bound exists to keep - * the read on the `(user_id, created_at DESC)` index. Two days covers every - * timezone offset plus a DST shift. + * the read on the record/event/key index. Two days covers every timezone + * offset plus a DST shift. */ -const ANCHOR_LOOKBACK_MS = 48 * 60 * 60 * 1000; +export const REMINDER_DEDUP_LOOKBACK_MS = 48 * 60 * 60 * 1000; /** * Dedup key for one medication dose slot in one phase on one local day. @@ -69,51 +62,59 @@ export function medicationReminderDedupKey(input: { } /** - * Has this exact slot+phase+day already been dispatched? Best-effort: a DB - * error biases toward "not yet", so a transient blip delays nothing — the - * cost of a rare duplicate is far below the cost of a silently dropped - * dose reminder. + * Atomically claim a record-scoped notification event within one rolling + * window. A `true` result is the sole permission to continue to provider + * egress; `false` means another worker already claimed it or the database + * could not establish the claim. The advisory lock, lookup, and append stay + * inside the short transaction. Callers always perform delivery afterward. */ -export async function hasReminderDedupAnchor( +export async function claimNotificationEvent( prisma: PrismaClient, - input: { userId: string; eventType: string; reason: string; now: Date }, + input: { + recordUserId: string; + eventType: string; + dedupKey: string; + since: Date; + }, ): Promise { try { - const row = await prisma.pushAttempt.findFirst({ - where: { - userId: input.userId, - eventType: input.eventType, - reason: input.reason, - createdAt: { gte: new Date(input.now.getTime() - ANCHOR_LOOKBACK_MS) }, - }, - select: { id: true }, + return await prisma.$transaction(async (tx) => { + const lockKey = [ + "notification-event", + input.recordUserId, + input.eventType, + input.dedupKey, + ].join(":"); + await tx.$queryRaw` + SELECT 1 AS locked + FROM pg_advisory_xact_lock(hashtextextended(${lockKey}, 0)) + `; + + const existing = await tx.notificationEvent.findFirst({ + where: { + recordUserId: input.recordUserId, + eventType: input.eventType, + dedupKey: input.dedupKey, + createdAt: { gte: input.since }, + }, + orderBy: { createdAt: "desc" }, + select: { id: true }, + }); + if (existing) return false; + + await tx.notificationEvent.create({ + data: { + recordUserId: input.recordUserId, + eventType: input.eventType, + dedupKey: input.dedupKey, + }, + }); + return true; }); - return row !== null; } catch { + // Fail closed: a worker that cannot durably claim the event must not + // contact a provider. A later tick may retry after the transaction rolls + // back, so an outage delays rather than duplicates a notification. return false; } } - -/** - * Stamp the anchor. Never throws — a failed stamp costs a duplicate - * notification at the next tick, while a thrown error would cost the - * dispatch itself. - */ -export async function writeReminderDedupAnchor( - prisma: PrismaClient, - input: { userId: string; eventType: string; reason: string }, -): Promise { - try { - await prisma.pushAttempt.create({ - data: { - userId: input.userId, - channel: REMINDER_DEDUP_CHANNEL, - eventType: input.eventType, - result: "skipped", - reason: input.reason, - }, - }); - } catch { - // Best-effort by contract; the next tick re-derives the same slot. - } -} diff --git a/src/lib/notifications/senders/__tests__/email.test.ts b/src/lib/notifications/senders/__tests__/email.test.ts index 5abbb7cd9..998ec15f6 100644 --- a/src/lib/notifications/senders/__tests__/email.test.ts +++ b/src/lib/notifications/senders/__tests__/email.test.ts @@ -31,6 +31,11 @@ vi.mock("@/lib/logging/context", () => ({ vi.mock("@/lib/notifications/senders/push-attempt-record", () => ({ recordPushAttempt: (...args: unknown[]) => recordPushAttemptMock(...args), + recordPushAttemptForPayload: ( + _payload: unknown, + _recipientUserId: string, + attempt: unknown, + ) => recordPushAttemptMock(attempt), })); import { sendViaEmail, resetEmailTransporterForTesting } from "../email"; diff --git a/src/lib/notifications/senders/__tests__/push-attempt-record.test.ts b/src/lib/notifications/senders/__tests__/push-attempt-record.test.ts index eda7a445d..85d048094 100644 --- a/src/lib/notifications/senders/__tests__/push-attempt-record.test.ts +++ b/src/lib/notifications/senders/__tests__/push-attempt-record.test.ts @@ -41,6 +41,7 @@ const hoisted = vi.hoisted(() => ({ webPushGenerateRequestDetailsMock: vi.fn(), sendTelegramMessageMock: vi.fn(), deleteTelegramMessageMock: vi.fn(), + addWarningMock: vi.fn(), })); vi.mock("@parse/node-apn", () => { @@ -98,7 +99,7 @@ vi.mock("@/lib/crypto", () => ({ vi.mock("@/lib/logging/context", () => ({ getEvent: () => ({ addExternalCall: vi.fn(), - addWarning: vi.fn(), + addWarning: hoisted.addWarningMock, setError: vi.fn(), }), })); @@ -142,6 +143,7 @@ import { import { sendViaWebPush } from "@/lib/notifications/senders/web-push"; import { sendViaTelegram } from "@/lib/notifications/senders/telegram"; import { sendViaNtfy } from "@/lib/notifications/senders/ntfy"; +import { recordPushAttemptForPayload } from "@/lib/notifications/senders/push-attempt-record"; import { prisma } from "@/lib/db"; const TEST_EC_PEM_LINES = [ @@ -218,6 +220,31 @@ async function flushMicrotasks(): Promise { await new Promise((resolve) => setImmediate(resolve)); } +describe("recordPushAttemptForPayload — attribution boundary", () => { + it("rejects a partial delivery identity without writing a ledger row", async () => { + recordPushAttemptForPayload({ recordUserId: "record-1" }, "recipient-1", { + channel: "TELEGRAM", + eventType: "MEDICATION_REMINDER", + result: "ok", + }); + recordPushAttemptForPayload( + { recipientUserId: "recipient-1" }, + "recipient-1", + { + channel: "TELEGRAM", + eventType: "MEDICATION_REMINDER", + result: "ok", + }, + ); + await flushMicrotasks(); + + expect(prisma.pushAttempt.create).not.toHaveBeenCalled(); + expect(hoisted.addWarningMock).toHaveBeenCalledWith( + "push_attempt_ledger_write_failed invalid_attribution", + ); + }); +}); + describe("recordPushAttempt — APNS sender", () => { it("writes one ok row when the dispatch succeeds", async () => { vi.mocked(prisma.device.findMany).mockResolvedValue([ diff --git a/src/lib/notifications/senders/__tests__/telegram.test.ts b/src/lib/notifications/senders/__tests__/telegram.test.ts index 9439062dd..292900143 100644 --- a/src/lib/notifications/senders/__tests__/telegram.test.ts +++ b/src/lib/notifications/senders/__tests__/telegram.test.ts @@ -2,8 +2,9 @@ * Telegram sender unit tests. * * v1.7.0 SB-SCHED-4 / code-correctness H2 — the pre-send delete must be - * scoped to the single dose slot `{ medicationId, scheduleId, date, - * phase, timeOfDay }`, NOT the whole medication. A multi-time-of-day + * scoped to the recipient and single dose slot `{ recipientUserId, + * medicationId, scheduleId, date, phase, timeOfDay }`, NOT the whole + * medication. A multi-time-of-day * schedule keeps a distinct ledger row per slot; the legacy whole- * medication wipe deleted the morning row when the evening slot fired, * which made the worker's dedup `findUnique` miss and re-send the @@ -14,7 +15,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const sendTelegramMessageMock = vi.fn(); const deleteMessageMock = vi.fn(); const findUniqueMock = vi.fn(); -const deleteMock = vi.fn(); const upsertMock = vi.fn(); const userFindUniqueMock = vi.fn(); @@ -30,7 +30,6 @@ vi.mock("@/lib/db", () => ({ }, telegramReminderMessage: { findUnique: (...args: unknown[]) => findUniqueMock(...args), - delete: (...args: unknown[]) => deleteMock(...args), upsert: (...args: unknown[]) => upsertMock(...args), }, }, @@ -42,6 +41,7 @@ vi.mock("@/lib/logging/context", () => ({ vi.mock("@/lib/notifications/senders/push-attempt-record", () => ({ recordPushAttempt: vi.fn(), + recordPushAttemptForPayload: vi.fn(), })); const scheduleTelegramAutoDeleteMock = vi.fn(); @@ -77,7 +77,6 @@ describe("sendViaTelegram — per-slot delete scope (H2)", () => { sendTelegramMessageMock.mockResolvedValue({ ok: true, messageId: 999 }); deleteMessageMock.mockResolvedValue(undefined); findUniqueMock.mockResolvedValue(null); - deleteMock.mockResolvedValue(undefined); upsertMock.mockResolvedValue(undefined); userFindUniqueMock.mockResolvedValue({ locale: "en" }); scheduleTelegramAutoDeleteMock.mockResolvedValue(undefined); @@ -93,7 +92,8 @@ describe("sendViaTelegram — per-slot delete scope (H2)", () => { expect(findUniqueMock).toHaveBeenCalledTimes(1); expect(findUniqueMock).toHaveBeenCalledWith({ where: { - medicationId_scheduleId_date_phase_timeOfDay: { + recipientUserId_medicationId_scheduleId_date_phase_timeOfDay: { + recipientUserId: "user-1", medicationId: "med-1", scheduleId: "sched-1", date: "2025-06-10", @@ -104,7 +104,7 @@ describe("sendViaTelegram — per-slot delete scope (H2)", () => { }); }); - it("deletes only the matching slot's ledger row, leaving sibling slots untouched", async () => { + it("replaces only the matching slot's remote message, leaving its tracking row for update", async () => { // The 20:00 slot has a prior row; the lookup returns it. findUniqueMock.mockResolvedValue({ chatId: "chat-1", @@ -115,19 +115,22 @@ describe("sendViaTelegram — per-slot delete scope (H2)", () => { // The Telegram message for the old 20:00 row is deleted... expect(deleteMessageMock).toHaveBeenCalledWith("bot-token", "chat-1", 123); - // ...and exactly that single ledger row is removed (scoped delete, not deleteMany). - expect(deleteMock).toHaveBeenCalledTimes(1); - expect(deleteMock).toHaveBeenCalledWith({ - where: { - medicationId_scheduleId_date_phase_timeOfDay: { - medicationId: "med-1", - scheduleId: "sched-1", - date: "2025-06-10", - phase: "YELLOW", - timeOfDay: "20:00", + // The tracking row stays in place so the recipient-scoped upsert updates + // it rather than creating a second row when the chat binding changes. + expect(upsertMock).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + recipientUserId_medicationId_scheduleId_date_phase_timeOfDay: { + recipientUserId: "user-1", + medicationId: "med-1", + scheduleId: "sched-1", + date: "2025-06-10", + phase: "YELLOW", + timeOfDay: "20:00", + }, }, - }, - }); + }), + ); }); it("does not delete anything when no prior row exists for the slot", async () => { @@ -136,7 +139,6 @@ describe("sendViaTelegram — per-slot delete scope (H2)", () => { await sendViaTelegram(config, reminderPayload()); expect(deleteMessageMock).not.toHaveBeenCalled(); - expect(deleteMock).not.toHaveBeenCalled(); // It still sends + tracks the new message. expect(sendTelegramMessageMock).toHaveBeenCalledTimes(1); expect(upsertMock).toHaveBeenCalledTimes(1); @@ -151,7 +153,81 @@ describe("sendViaTelegram — per-slot delete scope (H2)", () => { ); expect(findUniqueMock).not.toHaveBeenCalled(); - expect(deleteMock).not.toHaveBeenCalled(); + }); + + it("keeps two recipients' identical subject slots independent", async () => { + await sendViaTelegram( + { botToken: "bot-token", chatId: "guardian-chat-a" }, + { + ...reminderPayload(), + userId: "managed-record", + recordUserId: "managed-record", + recipientUserId: "guardian-a", + }, + ); + await sendViaTelegram( + { botToken: "bot-token", chatId: "guardian-chat-b" }, + { + ...reminderPayload(), + userId: "managed-record", + recordUserId: "managed-record", + recipientUserId: "guardian-b", + }, + ); + + expect(upsertMock).toHaveBeenCalledTimes(2); + expect(upsertMock.mock.calls.map((call) => call[0].where)).toEqual([ + { + recipientUserId_medicationId_scheduleId_date_phase_timeOfDay: { + recipientUserId: "guardian-a", + medicationId: "med-1", + scheduleId: "sched-1", + date: "2025-06-10", + phase: "YELLOW", + timeOfDay: "20:00", + }, + }, + { + recipientUserId_medicationId_scheduleId_date_phase_timeOfDay: { + recipientUserId: "guardian-b", + medicationId: "med-1", + scheduleId: "sched-1", + date: "2025-06-10", + phase: "YELLOW", + timeOfDay: "20:00", + }, + }, + ]); + }); + + it("updates one recipient slot when its Telegram chat changes", async () => { + findUniqueMock.mockResolvedValue({ chatId: "old-chat", messageId: 123 }); + + await sendViaTelegram( + { botToken: "bot-token", chatId: "new-chat" }, + reminderPayload(), + ); + + expect(deleteMessageMock).toHaveBeenCalledWith( + "bot-token", + "old-chat", + 123, + ); + expect(upsertMock).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + recipientUserId_medicationId_scheduleId_date_phase_timeOfDay: { + recipientUserId: "user-1", + medicationId: "med-1", + scheduleId: "sched-1", + date: "2025-06-10", + phase: "YELLOW", + timeOfDay: "20:00", + }, + }, + update: { chatId: "new-chat", messageId: 999 }, + }), + ); }); }); diff --git a/src/lib/notifications/senders/__tests__/urgency.test.ts b/src/lib/notifications/senders/__tests__/urgency.test.ts index e23a15f44..bf94783ac 100644 --- a/src/lib/notifications/senders/__tests__/urgency.test.ts +++ b/src/lib/notifications/senders/__tests__/urgency.test.ts @@ -35,6 +35,11 @@ vi.mock("@/lib/logging/context", () => ({ vi.mock("@/lib/notifications/senders/push-attempt-record", () => ({ recordPushAttempt: (...args: unknown[]) => recordPushAttemptMock(...args), + recordPushAttemptForPayload: ( + _payload: unknown, + _recipientUserId: string, + attempt: unknown, + ) => recordPushAttemptMock(attempt), })); vi.mock("@/lib/db", () => ({ diff --git a/src/lib/notifications/senders/__tests__/webhook.test.ts b/src/lib/notifications/senders/__tests__/webhook.test.ts index eaddccc2b..aab98366d 100644 --- a/src/lib/notifications/senders/__tests__/webhook.test.ts +++ b/src/lib/notifications/senders/__tests__/webhook.test.ts @@ -27,6 +27,11 @@ vi.mock("@/lib/logging/context", () => ({ vi.mock("@/lib/notifications/senders/push-attempt-record", () => ({ recordPushAttempt: (...args: unknown[]) => recordPushAttemptMock(...args), + recordPushAttemptForPayload: ( + _payload: unknown, + _recipientUserId: string, + attempt: unknown, + ) => recordPushAttemptMock(attempt), })); import { sendViaWebhook } from "../webhook"; diff --git a/src/lib/notifications/senders/apns.ts b/src/lib/notifications/senders/apns.ts index 07fe39a75..a614004bd 100644 --- a/src/lib/notifications/senders/apns.ts +++ b/src/lib/notifications/senders/apns.ts @@ -29,7 +29,7 @@ import apn from "@parse/node-apn"; import { prisma } from "@/lib/db"; import { getEvent } from "@/lib/logging/context"; import type { SendOutcome } from "@/lib/notifications/retry-policy"; -import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record"; +import { recordPushAttemptForPayload } from "@/lib/notifications/senders/push-attempt-record"; import { plainPushText } from "@/lib/notifications/strip-emoji"; export interface ApnsPayload { @@ -532,11 +532,13 @@ export async function sendViaApns( metadata?: Record; discreet?: boolean; urgent?: boolean; + recordUserId?: string; + recipientUserId?: string; }, ): Promise { const config = loadApnsConfig(); if (!config) { - recordPushAttempt({ + recordPushAttemptForPayload(payload, userId, { userId, channel: "APNS", eventType: payload.eventType, @@ -565,7 +567,7 @@ export async function sendViaApns( if (devices.length === 0) { // Same rationale as web-push: a user who hasn't paired an iPhone yet // shouldn't see the channel auto-disabled. Treat as soft "no recipient". - recordPushAttempt({ + recordPushAttemptForPayload(payload, userId, { userId, channel: "APNS", eventType: payload.eventType, @@ -626,6 +628,10 @@ export async function sendViaApns( process.env.APNS_CRITICAL_ENTITLEMENT === "true"; let result: Awaited> | null = null; + const managedDelivery = + payload.recordUserId !== undefined && + payload.recipientUserId !== undefined && + payload.recordUserId !== payload.recipientUserId; // Discreet mode (cycle privacy): the lock-screen-visible routing // metadata must not carry the event name. Collapse the category / // threadId / collapseId and the data eventType to a generic value so @@ -677,7 +683,7 @@ export async function sendViaApns( // ignores categories it doesn't know about, so adding the key // here is safe for event-types that don't have an actionable // category registered yet — they fall back to a plain alert. - category: routingEvent, + category: managedDelivery ? undefined : routingEvent, // Future-proof for an iOS Notification Service Extension. iOS // ignores the flag when no extension is registered, so it's a // no-op on today's binary but unblocks NSE work without a @@ -742,7 +748,7 @@ export async function sendViaApns( } if (anySuccess) { - recordPushAttempt({ + recordPushAttemptForPayload(payload, userId, { userId, channel: "APNS", eventType: payload.eventType, @@ -755,7 +761,7 @@ export async function sendViaApns( // until the user re-registers from a fresh iOS install. if (deadDeviceIds.length === devices.length) { const reason = lastFailureReason ?? "apns_all_devices_unregistered"; - recordPushAttempt({ + recordPushAttemptForPayload(payload, userId, { userId, channel: "APNS", eventType: payload.eventType, @@ -771,7 +777,7 @@ export async function sendViaApns( } const reason = lastFailureReason ?? "apns_send_failed"; - recordPushAttempt({ + recordPushAttemptForPayload(payload, userId, { userId, channel: "APNS", eventType: payload.eventType, diff --git a/src/lib/notifications/senders/email.ts b/src/lib/notifications/senders/email.ts index b072cb90b..813a1660a 100644 --- a/src/lib/notifications/senders/email.ts +++ b/src/lib/notifications/senders/email.ts @@ -7,7 +7,7 @@ import type { } from "@/lib/notifications/types"; import type { SendOutcome } from "@/lib/notifications/retry-policy"; import { getEvent } from "@/lib/logging/context"; -import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record"; +import { recordPushAttemptForPayload } from "@/lib/notifications/senders/push-attempt-record"; import { loadEmailConfig } from "@/lib/notifications/senders/email-config"; import { plainPushText } from "@/lib/notifications/strip-emoji"; @@ -97,12 +97,13 @@ export async function sendViaEmail( config: EmailChannelConfig, payload: NotificationPayload, ): Promise { + const userId = payload.recipientUserId ?? payload.userId; const start = performance.now(); const transport = getTransporter(); if (!transport) { // Operator hasn't configured SMTP — soft skip, no channel burn. - recordPushAttempt({ + recordPushAttemptForPayload(payload, userId, { userId: payload.userId, channel: "EMAIL", eventType: payload.eventType, @@ -113,7 +114,7 @@ export async function sendViaEmail( } if (!config.recipient) { - recordPushAttempt({ + recordPushAttemptForPayload(payload, userId, { userId: payload.userId, channel: "EMAIL", eventType: payload.eventType, @@ -143,7 +144,7 @@ export async function sendViaEmail( method: "sendMail", duration_ms: Math.round(performance.now() - start), }); - recordPushAttempt({ + recordPushAttemptForPayload(payload, userId, { userId: payload.userId, channel: "EMAIL", eventType: payload.eventType, @@ -158,7 +159,7 @@ export async function sendViaEmail( duration_ms: Math.round(performance.now() - start), error: classified.message, }); - recordPushAttempt({ + recordPushAttemptForPayload(payload, userId, { userId: payload.userId, channel: "EMAIL", eventType: payload.eventType, diff --git a/src/lib/notifications/senders/ntfy.ts b/src/lib/notifications/senders/ntfy.ts index 7a673f974..ade424ac6 100644 --- a/src/lib/notifications/senders/ntfy.ts +++ b/src/lib/notifications/senders/ntfy.ts @@ -5,7 +5,7 @@ import type { import type { SendOutcome } from "@/lib/notifications/retry-policy"; import { classifyHttpStatus } from "@/lib/notifications/retry-policy"; import { getEvent } from "@/lib/logging/context"; -import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record"; +import { recordPushAttemptForPayload } from "@/lib/notifications/senders/push-attempt-record"; import { safeFetch } from "@/lib/safe-fetch"; import { plainPushText } from "@/lib/notifications/strip-emoji"; import { isUrgentPayload } from "@/lib/notifications/types"; @@ -23,6 +23,7 @@ export async function sendViaNtfy( config: NtfyChannelConfig, payload: NotificationPayload, ): Promise { + const userId = payload.recipientUserId ?? payload.userId; const start = performance.now(); try { const url = `${config.serverUrl.replace(/\/$/, "")}/${encodeURIComponent(config.topic)}`; @@ -84,7 +85,7 @@ export async function sendViaNtfy( }); if (res.ok) { - recordPushAttempt({ + recordPushAttemptForPayload(payload, userId, { userId: payload.userId, channel: "NTFY", eventType: payload.eventType, @@ -93,7 +94,7 @@ export async function sendViaNtfy( return { ok: true, statusCode: res.status }; } const classified = classifyHttpStatus(res.status, "ntfy"); - recordPushAttempt({ + recordPushAttemptForPayload(payload, userId, { userId: payload.userId, channel: "NTFY", eventType: payload.eventType, @@ -114,7 +115,7 @@ export async function sendViaNtfy( duration_ms: Math.round(performance.now() - start), error: message, }); - recordPushAttempt({ + recordPushAttemptForPayload(payload, userId, { userId: payload.userId, channel: "NTFY", eventType: payload.eventType, diff --git a/src/lib/notifications/senders/push-attempt-record.ts b/src/lib/notifications/senders/push-attempt-record.ts index 78b9bfcac..e4a2ba070 100644 --- a/src/lib/notifications/senders/push-attempt-record.ts +++ b/src/lib/notifications/senders/push-attempt-record.ts @@ -1,5 +1,6 @@ import { prisma } from "@/lib/db"; import { getEvent } from "@/lib/logging/context"; +import type { NotificationPayload } from "@/lib/notifications/types"; /** * v1.4.49 — fire-and-forget push-attempt ledger write. @@ -31,22 +32,93 @@ import { getEvent } from "@/lib/logging/context"; * the sender contracts surface without contacting * the upstream provider. */ -export interface PushAttemptRecord { - userId: string; +interface PushAttemptRecordBase { channel: "APNS" | "WEB_PUSH" | "TELEGRAM" | "NTFY" | "WEBHOOK" | "EMAIL"; eventType: string; result: "ok" | "error" | "skipped"; reason?: string | null; } +/** Existing self-delivery callers map the record and recipient to one account. */ +interface SelfPushAttemptRecord extends PushAttemptRecordBase { + userId: string; + recordUserId?: never; + recipientUserId?: never; +} + +/** Managed delivery always supplies both principals without inference. */ +interface AttributedPushAttemptRecord extends PushAttemptRecordBase { + userId?: never; + recordUserId: string; + recipientUserId: string; +} + +export type PushAttemptRecord = + SelfPushAttemptRecord | AttributedPushAttemptRecord; + +/** + * Preserve the supplied record subject while a sender records delivery for + * the recipient selected by the dispatcher. The legacy `userId` property in + * sender call sites is deliberately ignored here: 0298 stores it as a + * recipient mirror and the explicit pair is authoritative. + */ +export function recordPushAttemptForPayload( + payload: Pick, + legacyUserId: string, + record: PushAttemptRecordBase & { userId?: string }, +): void { + const hasRecordUserId = payload.recordUserId !== undefined; + const hasRecipientUserId = payload.recipientUserId !== undefined; + + if (hasRecordUserId !== hasRecipientUserId) { + getEvent()?.addWarning( + "push_attempt_ledger_write_failed invalid_attribution", + ); + return; + } + + if (!hasRecordUserId) { + recordPushAttempt({ + userId: legacyUserId, + channel: record.channel, + eventType: record.eventType, + result: record.result, + reason: record.reason, + }); + return; + } + + recordPushAttempt({ + recordUserId: payload.recordUserId!, + recipientUserId: payload.recipientUserId!, + channel: record.channel, + eventType: record.eventType, + result: record.result, + reason: record.reason, + }); +} + export function recordPushAttempt(record: PushAttemptRecord): void { + const recordUserId = "userId" in record ? record.userId : record.recordUserId; + const recipientUserId = + "userId" in record ? record.userId : record.recipientUserId; + + if (!recordUserId || !recipientUserId) { + getEvent()?.addWarning( + "push_attempt_ledger_write_failed invalid_attribution", + ); + return; + } + // The `void` here is the doubly-explicit form: we want neither the // caller nor a linter to wait on this promise. The `.catch` traps // a rejected promise without surfacing it to the caller. void prisma.pushAttempt .create({ data: { - userId: record.userId, + userId: recipientUserId, + recordUserId, + recipientUserId, channel: record.channel, eventType: record.eventType, result: record.result, diff --git a/src/lib/notifications/senders/telegram.ts b/src/lib/notifications/senders/telegram.ts index b31f9f6de..8a2bf2479 100644 --- a/src/lib/notifications/senders/telegram.ts +++ b/src/lib/notifications/senders/telegram.ts @@ -8,7 +8,7 @@ import { classifyTelegramError } from "@/lib/notifications/retry-policy"; import { prisma } from "@/lib/db"; import type { ReminderPhase } from "@/generated/prisma/client"; import { getEvent } from "@/lib/logging/context"; -import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record"; +import { recordPushAttemptForPayload } from "@/lib/notifications/senders/push-attempt-record"; import { scheduleTelegramAutoDelete } from "@/lib/telegram-cleanup"; import { buildMoodKeyboard, @@ -18,8 +18,8 @@ import { defaultLocale, locales, type Locale } from "@/lib/i18n/config"; /** * v1.19.0 — resolve the bot locale for an interactive reminder. The - * sender knows the user via `payload.userId`; the keyboard labels need - * the user's locale. Defaults to "de" (the historical bot default) when + * sender knows the recipient through the delivery identity; keyboard labels + * need that account's locale. Defaults to "de" when * unset, matching the webhook's `resolveBotLocale`. */ async function resolveSenderLocale(userId: string): Promise { @@ -52,7 +52,7 @@ export interface TelegramSendResult extends SendOutcome { /** * Delete the existing Telegram reminder message for a single dose slot * before re-sending it. v1.7.0 SB-SCHED-4 — scoped to the exact slot - * `{ medicationId, scheduleId, date, phase, timeOfDay }` (the same + * `{ recipientUserId, medicationId, scheduleId, date, phase, timeOfDay }` (the same * composite the tracking upsert keys on), NOT the whole medication. A * medication with `timesOfDay = ["08:00","20:00"]` has two distinct * ledger rows per day; the pre-v1.7 whole-medication wipe deleted the @@ -62,6 +62,7 @@ export interface TelegramSendResult extends SendOutcome { */ async function deleteExistingReminders( botToken: string, + recipientUserId: string, medicationId: string, scheduleId: string, date: string, @@ -71,7 +72,8 @@ async function deleteExistingReminders( try { const existing = await prisma.telegramReminderMessage.findUnique({ where: { - medicationId_scheduleId_date_phase_timeOfDay: { + recipientUserId_medicationId_scheduleId_date_phase_timeOfDay: { + recipientUserId, medicationId, scheduleId, date, @@ -88,18 +90,6 @@ async function deleteExistingReminders( } catch { // Best-effort: message may already be deleted } - - await prisma.telegramReminderMessage.delete({ - where: { - medicationId_scheduleId_date_phase_timeOfDay: { - medicationId, - scheduleId, - date, - phase, - timeOfDay, - }, - }, - }); } catch (err) { getEvent()?.addWarning(`Failed to delete existing reminders: ${err}`); } @@ -118,6 +108,7 @@ export async function sendViaTelegram( config: TelegramChannelConfig, payload: NotificationPayload, ): Promise { + const userId = payload.recipientUserId ?? payload.userId; const medicationId = payload.metadata?.medicationId as string | undefined; const scheduleId = payload.metadata?.scheduleId as string | undefined; const phase = payload.metadata?.phase as string | undefined; @@ -135,6 +126,7 @@ export async function sendViaTelegram( if (medicationId && scheduleId && phase && date) { await deleteExistingReminders( config.botToken, + userId, medicationId, scheduleId, date, @@ -152,24 +144,25 @@ export async function sendViaTelegram( let interactiveKeyboard: | { inline_keyboard: { text: string; callback_data: string }[][] } | undefined; - if (!replyMarkup) { + const selfDelivery = payload.recordUserId === payload.recipientUserId; + if (selfDelivery && !replyMarkup) { if (payload.eventType === "MOOD_REMINDER") { interactiveKeyboard = buildMoodKeyboard( - await resolveSenderLocale(payload.userId), + await resolveSenderLocale(userId), ); } else if (payload.eventType === "MEASUREMENT_REMINDER" && reminderId) { interactiveKeyboard = buildMeasurementKeyboard( reminderId, - await resolveSenderLocale(payload.userId), + await resolveSenderLocale(userId), ); } } // Build reply markup const keyboard = - replyMarkup ?? + (selfDelivery ? replyMarkup : undefined) ?? interactiveKeyboard ?? - (payload.eventType === "MEDICATION_REMINDER" && medicationId + (selfDelivery && payload.eventType === "MEDICATION_REMINDER" && medicationId ? { inline_keyboard: [ [ @@ -218,7 +211,8 @@ export async function sendViaTelegram( try { await prisma.telegramReminderMessage.upsert({ where: { - medicationId_scheduleId_date_phase_timeOfDay: { + recipientUserId_medicationId_scheduleId_date_phase_timeOfDay: { + recipientUserId: userId, medicationId, scheduleId, date, @@ -227,6 +221,7 @@ export async function sendViaTelegram( }, }, create: { + recipientUserId: userId, medicationId, scheduleId, chatId: config.chatId, @@ -251,13 +246,11 @@ export async function sendViaTelegram( // tapped prompt is deleted immediately by the webhook callback handler; // this row is the fallback and is harmless once the message is gone. if (result.ok && result.messageId && interactiveKeyboard) { - await scheduleTelegramAutoDelete(payload.userId, config.chatId, [ - result.messageId, - ]); + await scheduleTelegramAutoDelete(userId, config.chatId, [result.messageId]); } if (result.ok) { - recordPushAttempt({ + recordPushAttemptForPayload(payload, userId, { userId: payload.userId, channel: "TELEGRAM", eventType: payload.eventType, @@ -266,7 +259,7 @@ export async function sendViaTelegram( return { ok: true, messageId: result.messageId }; } const classified = classifyTelegramError(result.errorDescription); - recordPushAttempt({ + recordPushAttemptForPayload(payload, userId, { userId: payload.userId, channel: "TELEGRAM", eventType: payload.eventType, diff --git a/src/lib/notifications/senders/web-push.ts b/src/lib/notifications/senders/web-push.ts index 84b6a25fa..f0e080dac 100644 --- a/src/lib/notifications/senders/web-push.ts +++ b/src/lib/notifications/senders/web-push.ts @@ -5,7 +5,7 @@ import type { SendOutcome } from "@/lib/notifications/retry-policy"; import { classifyHttpStatus } from "@/lib/notifications/retry-policy"; import { getVapidConfig } from "@/lib/notifications/vapid-config"; import { getEvent } from "@/lib/logging/context"; -import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record"; +import { recordPushAttemptForPayload } from "@/lib/notifications/senders/push-attempt-record"; import { isPublicUrl } from "@/lib/validations/notifications"; import { safeFetch } from "@/lib/safe-fetch"; import { plainPushText } from "@/lib/notifications/strip-emoji"; @@ -36,7 +36,7 @@ export async function sendViaWebPush( const config = await getVapidConfig(); if (!config) { getEvent()?.addWarning("Web Push: VAPID keys not configured"); - recordPushAttempt({ + recordPushAttemptForPayload(payload, userId, { userId, channel: "WEB_PUSH", eventType: payload.eventType, @@ -65,7 +65,7 @@ export async function sendViaWebPush( // may simply not have hit "Subscribe" on this browser. Counting // this as a give-up signal would lock the user out of the channel // they just configured. Treat it as a soft "no recipient" instead. - recordPushAttempt({ + recordPushAttemptForPayload(payload, userId, { userId, channel: "WEB_PUSH", eventType: payload.eventType, @@ -231,7 +231,7 @@ export async function sendViaWebPush( } if (anySuccess) { - recordPushAttempt({ + recordPushAttemptForPayload(payload, userId, { userId, channel: "WEB_PUSH", eventType: payload.eventType, @@ -243,7 +243,7 @@ export async function sendViaWebPush( // Every sub returned a permanent 410/404 → channel is dead until // the user re-pairs a device. This is a hard reject. if (allPermanentReject && expiredIds.length === subscriptions.length) { - recordPushAttempt({ + recordPushAttemptForPayload(payload, userId, { userId, channel: "WEB_PUSH", eventType: payload.eventType, @@ -260,7 +260,7 @@ export async function sendViaWebPush( // Soft failure (5xx / 429 / fetch error) on at least one sub. const classified = classifyHttpStatus(lastTransientStatus, "web-push"); - recordPushAttempt({ + recordPushAttemptForPayload(payload, userId, { userId, channel: "WEB_PUSH", eventType: payload.eventType, @@ -279,7 +279,7 @@ export async function sendViaWebPush( ? err : new Error("[web-push] sendViaWebPush failed"), ); - recordPushAttempt({ + recordPushAttemptForPayload(payload, userId, { userId, channel: "WEB_PUSH", eventType: payload.eventType, diff --git a/src/lib/notifications/senders/webhook.ts b/src/lib/notifications/senders/webhook.ts index e7a890aee..fd01eeac8 100644 --- a/src/lib/notifications/senders/webhook.ts +++ b/src/lib/notifications/senders/webhook.ts @@ -5,7 +5,7 @@ import type { import type { SendOutcome } from "@/lib/notifications/retry-policy"; import { classifyHttpStatus } from "@/lib/notifications/retry-policy"; import { getEvent } from "@/lib/logging/context"; -import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record"; +import { recordPushAttemptForPayload } from "@/lib/notifications/senders/push-attempt-record"; import { safeFetch } from "@/lib/safe-fetch"; import { plainPushText } from "@/lib/notifications/strip-emoji"; @@ -32,6 +32,7 @@ export async function sendViaWebhook( config: WebhookChannelConfig, payload: NotificationPayload, ): Promise { + const userId = payload.recipientUserId ?? payload.userId; const start = performance.now(); try { const headers: Record = { @@ -82,7 +83,7 @@ export async function sendViaWebhook( }); if (res.ok) { - recordPushAttempt({ + recordPushAttemptForPayload(payload, userId, { userId: payload.userId, channel: "WEBHOOK", eventType: payload.eventType, @@ -91,7 +92,7 @@ export async function sendViaWebhook( return { ok: true, statusCode: res.status }; } const classified = classifyHttpStatus(res.status, "webhook"); - recordPushAttempt({ + recordPushAttemptForPayload(payload, userId, { userId: payload.userId, channel: "WEBHOOK", eventType: payload.eventType, @@ -112,7 +113,7 @@ export async function sendViaWebhook( duration_ms: Math.round(performance.now() - start), error: message, }); - recordPushAttempt({ + recordPushAttemptForPayload(payload, userId, { userId: payload.userId, channel: "WEBHOOK", eventType: payload.eventType, diff --git a/src/lib/notifications/types.ts b/src/lib/notifications/types.ts index 25b494515..7b7d7bbd8 100644 --- a/src/lib/notifications/types.ts +++ b/src/lib/notifications/types.ts @@ -252,7 +252,15 @@ export interface EmailChannelConfig { export interface NotificationPayload { eventType: EventType; + /** + * Health-record subject retained for established self-delivery callers. + * Managed delivery must also declare `recordUserId` and `recipientUserId`. + */ userId: string; + /** The health record that supplies notification content and calculations. */ + recordUserId?: string; + /** The account whose channel, locale, and preferences receive the delivery. */ + recipientUserId?: string; title: string; message: string; /** Channel-specific extras (e.g. medicationId for Telegram inline buttons) */ diff --git a/src/lib/telegram-webhook-handlers.ts b/src/lib/telegram-webhook-handlers.ts index 6bd22fec3..7bd4568e2 100644 --- a/src/lib/telegram-webhook-handlers.ts +++ b/src/lib/telegram-webhook-handlers.ts @@ -90,10 +90,17 @@ function resolveBotLocale(value: string | null | undefined): Locale { return "de"; } -async function cleanupReminderTracking(medicationId: string): Promise { +async function cleanupReminderTracking( + recipientUserId: string, + medicationId: string, +): Promise { try { await prisma.telegramReminderMessage.deleteMany({ - where: { medicationId }, + where: { + recipientUserId, + medicationId, + medication: { userId: recipientUserId }, + }, }); } catch { // Best-effort cleanup @@ -153,6 +160,7 @@ async function resolveReminderSlot(input: { }): Promise { const reminder = await prisma.telegramReminderMessage.findFirst({ where: { + recipientUserId: input.userId, medicationId: input.medicationId, chatId: input.chatId, messageId: input.messageId, @@ -398,7 +406,7 @@ export async function handleCallback(update: TelegramUpdate) { if (messageId) { await deleteMessage(botToken, chatId, messageId); } - await cleanupReminderTracking(medicationId); + await cleanupReminderTracking(user.id, medicationId); } else if (data.startsWith("snooze:")) { // Format: "snooze:{medicationId}:{minutes}" const parts = data.split(":"); @@ -443,7 +451,7 @@ export async function handleCallback(update: TelegramUpdate) { if (messageId) { await deleteMessage(botToken, chatId, messageId); } - await cleanupReminderTracking(medicationId); + await cleanupReminderTracking(user.id, medicationId); } else if (data.startsWith("skip:")) { const medicationId = data.slice("skip:".length).trim(); if (!medicationId) { @@ -586,7 +594,7 @@ export async function handleCallback(update: TelegramUpdate) { if (messageId) { await deleteMessage(botToken, chatId, messageId); } - await cleanupReminderTracking(medicationId); + await cleanupReminderTracking(user.id, medicationId); } else if (data.startsWith("ack:")) { const medicationId = data.slice("ack:".length).trim(); if (!medicationId) { @@ -613,7 +621,7 @@ export async function handleCallback(update: TelegramUpdate) { if (messageId) { await deleteMessage(botToken, chatId, messageId); } - await cleanupReminderTracking(medicationId); + await cleanupReminderTracking(user.id, medicationId); } else if (data.startsWith("add:")) { // Format: "add:{medicationId}" or "add:{medicationId}:umid:{userMsgId}" const withoutPrefix = data.slice("add:".length); diff --git a/tests/integration/notification-attribution.test.ts b/tests/integration/notification-attribution.test.ts new file mode 100644 index 000000000..7f9e411b5 --- /dev/null +++ b/tests/integration/notification-attribution.test.ts @@ -0,0 +1,526 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; +import { resolveNotificationDeliveryIdentity } from "@/lib/notifications/delivery-identity"; +import { + claimNotificationEvent, +} from "@/lib/notifications/reminder-dedup"; +import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record"; + +let sequence = 0; + +async function createUser(label: string, managed = false) { + const suffix = sequence++; + return getPrismaClient().user.create({ + data: { + username: `${label}-${suffix}`, + email: `${label}-${suffix}@example.test`, + managedProfileAt: managed ? new Date() : null, + }, + }); +} + +async function createGuardianGrant(input: { + recordUserId: string; + recipientUserId: string; + access?: "READ" | "WRITE" | "MANAGE"; + acceptedAt?: Date | null; + revokedAt?: Date | null; + expiresAt?: Date | null; +}) { + return getPrismaClient().accountGrant.create({ + data: { + grantorId: input.recordUserId, + granteeId: input.recipientUserId, + access: input.access ?? "MANAGE", + acceptedAt: + input.acceptedAt === undefined ? new Date() : input.acceptedAt, + revokedAt: input.revokedAt ?? null, + revokedBy: input.revokedAt ? "GRANTOR" : null, + expiresAt: input.expiresAt ?? null, + }, + }); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); +}); + +describe("notification attribution (real Postgres)", () => { + it("persistence retains distinct record and recipient principals", async () => { + const recordUser = await createUser("record", true); + const recipientUser = await createUser("recipient"); + + recordPushAttempt({ + recordUserId: recordUser.id, + recipientUserId: recipientUser.id, + channel: "NTFY", + eventType: "MEDICATION_REMINDER", + result: "ok", + }); + + await vi.waitFor(async () => { + const attempt = await getPrismaClient().pushAttempt.findFirstOrThrow({ + where: { + recordUserId: recordUser.id, + recipientUserId: recipientUser.id, + }, + select: { + recordUserId: true, + recipientUserId: true, + channel: true, + eventType: true, + }, + }); + + expect(attempt).toEqual({ + recordUserId: recordUser.id, + recipientUserId: recipientUser.id, + channel: "NTFY", + eventType: "MEDICATION_REMINDER", + }); + }); + }); + + it("persistence leaves explicit self attribution unchanged", async () => { + const self = await createUser("self"); + + const attempt = await getPrismaClient().pushAttempt.create({ + data: { + userId: self.id, + recordUserId: self.id, + recipientUserId: self.id, + channel: "NTFY", + eventType: "MEDICATION_REMINDER", + result: "ok", + }, + select: { + userId: true, + recordUserId: true, + recipientUserId: true, + }, + }); + + expect(attempt).toEqual({ + userId: self.id, + recordUserId: self.id, + recipientUserId: self.id, + }); + }); + + it("persists a managed reminder anchor as a record event without a delivery attempt", async () => { + const recordUser = await createUser("managed-record", true); + const client = getPrismaClient(); + const now = new Date(); + + await expect( + claimNotificationEvent(client, { + recordUserId: recordUser.id, + eventType: "MEDICATION_REMINDER", + dedupKey: "med:slot", + since: new Date(now.getTime() - 48 * 60 * 60 * 1000), + }), + ).resolves.toBe(true); + await expect( + claimNotificationEvent(client, { + recordUserId: recordUser.id, + eventType: "MEDICATION_REMINDER", + dedupKey: "med:slot", + since: new Date(now.getTime() - 48 * 60 * 60 * 1000), + }), + ).resolves.toBe(false); + + const event = await client.notificationEvent.findFirstOrThrow({ + where: { recordUserId: recordUser.id, dedupKey: "med:slot" }, + select: { id: true }, + }); + expect(await client.pushAttempt.count()).toBe(0); + await client.user.delete({ where: { id: recordUser.id } }); + expect( + await client.notificationEvent.findUnique({ where: { id: event.id } }), + ).toBeNull(); + }); + + it("allows exactly one concurrent claim for a notification event", async () => { + const recordUser = await createUser("claim-record", true); + const client = getPrismaClient(); + const since = new Date(Date.now() - 60_000); + const input = { + recordUserId: recordUser.id, + eventType: "MEDICATION_REMINDER", + dedupKey: "med:race", + since, + }; + + const results = await Promise.all([ + claimNotificationEvent(client, input), + claimNotificationEvent(client, input), + ]); + + expect(results.filter(Boolean)).toHaveLength(1); + expect( + await client.notificationEvent.count({ + where: { + recordUserId: recordUser.id, + eventType: "MEDICATION_REMINDER", + dedupKey: "med:race", + }, + }), + ).toBe(1); + }); + + it("uses the rolling window and allows a retry after a failed claim", async () => { + const client = getPrismaClient(); + const missingRecordId = `missing-claim-${sequence++}`; + const missingInput = { + recordUserId: missingRecordId, + eventType: "SYSTEM_ALERT", + dedupKey: "safety:retry", + since: new Date(Date.now() - 24 * 60 * 60 * 1000), + }; + + await expect(claimNotificationEvent(client, missingInput)).resolves.toBe( + false, + ); + await client.user.create({ + data: { + id: missingRecordId, + username: `claim-retry-${sequence++}`, + email: `claim-retry-${sequence++}@example.test`, + }, + }); + await expect(claimNotificationEvent(client, missingInput)).resolves.toBe( + true, + ); + + const recordUser = await createUser("expired-claim", true); + const now = new Date(); + await client.notificationEvent.create({ + data: { + recordUserId: recordUser.id, + eventType: "SYSTEM_ALERT", + dedupKey: "safety:window", + createdAt: new Date(now.getTime() - 25 * 60 * 60 * 1000), + }, + }); + + await expect( + claimNotificationEvent(client, { + recordUserId: recordUser.id, + eventType: "SYSTEM_ALERT", + dedupKey: "safety:window", + since: new Date(now.getTime() - 24 * 60 * 60 * 1000), + }), + ).resolves.toBe(true); + }); + + it("keeps Telegram reminder tracking independent for each recipient slot", async () => { + const recordUser = await createUser("telegram-record", true); + const firstRecipient = await createUser("telegram-recipient-a"); + const secondRecipient = await createUser("telegram-recipient-b"); + const client = getPrismaClient(); + const medication = await client.medication.create({ + data: { + userId: recordUser.id, + name: "Tracking medication", + dose: "1 tablet", + schedules: { + create: { windowStart: "08:00", windowEnd: "08:00" }, + }, + }, + include: { schedules: true }, + }); + const scheduleId = medication.schedules[0]!.id; + const slot = { + medicationId: medication.id, + scheduleId, + date: "2026-08-06", + phase: "YELLOW" as const, + timeOfDay: "08:00", + }; + + await client.telegramReminderMessage.createMany({ + data: [ + { + recipientUserId: firstRecipient.id, + ...slot, + chatId: "first-chat", + messageId: 101, + }, + { + recipientUserId: secondRecipient.id, + ...slot, + chatId: "second-chat", + messageId: 202, + }, + ], + }); + + await client.telegramReminderMessage.upsert({ + where: { + recipientUserId_medicationId_scheduleId_date_phase_timeOfDay: { + recipientUserId: firstRecipient.id, + ...slot, + }, + }, + create: { + recipientUserId: firstRecipient.id, + ...slot, + chatId: "unused-create-chat", + messageId: 303, + }, + update: { chatId: "first-chat-new", messageId: 303 }, + }); + + expect( + await client.telegramReminderMessage.findMany({ + where: { medicationId: medication.id }, + orderBy: { recipientUserId: "asc" }, + select: { recipientUserId: true, chatId: true, messageId: true }, + }), + ).toEqual([ + { + recipientUserId: firstRecipient.id, + chatId: "first-chat-new", + messageId: 303, + }, + { + recipientUserId: secondRecipient.id, + chatId: "second-chat", + messageId: 202, + }, + ]); + + await client.user.delete({ where: { id: firstRecipient.id } }); + expect( + await client.telegramReminderMessage.count({ + where: { medicationId: medication.id }, + }), + ).toBe(1); + }); + + it("persistence maps an omitted pair from the legacy recipient", async () => { + const self = await createUser("legacy"); + + const attempt = await getPrismaClient().pushAttempt.create({ + data: { + userId: self.id, + channel: "NTFY", + eventType: "MEDICATION_REMINDER", + result: "ok", + }, + select: { + recordUserId: true, + recipientUserId: true, + }, + }); + + expect(attempt).toEqual({ + recordUserId: self.id, + recipientUserId: self.id, + }); + }); + + it("persistence rejects a partial or contradictory principal pair atomically", async () => { + const recordUser = await createUser("record", true); + const recipientUser = await createUser("recipient"); + const otherUser = await createUser("other"); + + await expect( + getPrismaClient().pushAttempt.create({ + data: { + userId: recipientUser.id, + recordUserId: recordUser.id, + channel: "NTFY", + eventType: "MEDICATION_REMINDER", + result: "ok", + }, + }), + ).rejects.toThrow("push attempt attribution requires both principals"); + + await expect( + getPrismaClient().pushAttempt.create({ + data: { + userId: otherUser.id, + recordUserId: recordUser.id, + recipientUserId: recipientUser.id, + channel: "NTFY", + eventType: "MEDICATION_REMINDER", + result: "ok", + }, + }), + ).rejects.toThrow("push attempt recipient must match user_id"); + + expect(await getPrismaClient().pushAttempt.count()).toBe(0); + }); + + it("persistence refuses a managed recipient and cascades an attributed recipient deletion", async () => { + const recordUser = await createUser("record", true); + const recipientUser = await createUser("recipient"); + const managedRecipient = await createUser("managed-recipient", true); + + await expect( + getPrismaClient().pushAttempt.create({ + data: { + userId: managedRecipient.id, + recordUserId: recordUser.id, + recipientUserId: managedRecipient.id, + channel: "NTFY", + eventType: "MEDICATION_REMINDER", + result: "ok", + }, + }), + ).rejects.toThrow("managed profile cannot receive notification delivery"); + + const attempt = await getPrismaClient().pushAttempt.create({ + data: { + userId: recipientUser.id, + recordUserId: recordUser.id, + recipientUserId: recipientUser.id, + channel: "NTFY", + eventType: "MEDICATION_REMINDER", + result: "ok", + }, + }); + await getPrismaClient().user.delete({ where: { id: recipientUser.id } }); + + expect( + await getPrismaClient().pushAttempt.findUnique({ + where: { id: attempt.id }, + }), + ).toBeNull(); + }); + + it("principal resolution requires an active MANAGE grant for managed delivery", async () => { + const recordUser = await createUser("record", true); + const recipientUser = await createUser("recipient"); + const ordinaryRecord = await createUser("ordinary"); + + await expect( + resolveNotificationDeliveryIdentity({ + eventType: "MEDICATION_REMINDER", + userId: recordUser.id, + recordUserId: recordUser.id, + recipientUserId: recipientUser.id, + title: "Record content", + message: "Record schedule", + }), + ).resolves.toBeNull(); + + await createGuardianGrant({ + recordUserId: recordUser.id, + recipientUserId: recipientUser.id, + }); + + await expect( + resolveNotificationDeliveryIdentity({ + eventType: "MEDICATION_REMINDER", + userId: recordUser.id, + recordUserId: recordUser.id, + recipientUserId: recipientUser.id, + title: "Record content", + message: "Record schedule", + }), + ).resolves.toEqual({ + recordUserId: recordUser.id, + recipientUserId: recipientUser.id, + managed: true, + }); + + await expect( + resolveNotificationDeliveryIdentity({ + eventType: "MEDICATION_REMINDER", + userId: recordUser.id, + title: "Record content", + message: "Record schedule", + }), + ).resolves.toBeNull(); + + await expect( + resolveNotificationDeliveryIdentity({ + eventType: "MEDICATION_REMINDER", + userId: ordinaryRecord.id, + recordUserId: ordinaryRecord.id, + recipientUserId: recipientUser.id, + title: "Record content", + message: "Record schedule", + }), + ).resolves.toBeNull(); + }); + + it("principal resolution rejects inactive, insufficient, wrong-record, and managed-recipient grants", async () => { + const recordUser = await createUser("record", true); + const otherRecord = await createUser("other-record", true); + const pendingRecipient = await createUser("pending-recipient"); + const revokedRecipient = await createUser("revoked-recipient"); + const expiredRecipient = await createUser("expired-recipient"); + const writeRecipient = await createUser("write-recipient"); + const wrongRecordRecipient = await createUser("wrong-record-recipient"); + const managedRecipient = await createUser("managed-recipient", true); + + await Promise.all([ + createGuardianGrant({ + recordUserId: recordUser.id, + recipientUserId: pendingRecipient.id, + acceptedAt: null, + }), + createGuardianGrant({ + recordUserId: recordUser.id, + recipientUserId: revokedRecipient.id, + revokedAt: new Date(), + }), + createGuardianGrant({ + recordUserId: recordUser.id, + recipientUserId: expiredRecipient.id, + expiresAt: new Date(Date.now() - 1_000), + }), + createGuardianGrant({ + recordUserId: recordUser.id, + recipientUserId: writeRecipient.id, + access: "WRITE", + }), + createGuardianGrant({ + recordUserId: otherRecord.id, + recipientUserId: wrongRecordRecipient.id, + }), + createGuardianGrant({ + recordUserId: recordUser.id, + recipientUserId: managedRecipient.id, + }), + ]); + + for (const recipientUserId of [ + pendingRecipient.id, + revokedRecipient.id, + expiredRecipient.id, + writeRecipient.id, + wrongRecordRecipient.id, + managedRecipient.id, + ]) { + await expect( + resolveNotificationDeliveryIdentity({ + eventType: "MEDICATION_REMINDER", + userId: recordUser.id, + recordUserId: recordUser.id, + recipientUserId, + title: "Record content", + message: "Record schedule", + }), + ).resolves.toBeNull(); + } + + const ordinaryUser = await createUser("ordinary-self"); + await expect( + resolveNotificationDeliveryIdentity({ + eventType: "MEDICATION_REMINDER", + userId: ordinaryUser.id, + title: "Personal content", + message: "Personal schedule", + }), + ).resolves.toEqual({ + recordUserId: ordinaryUser.id, + recipientUserId: ordinaryUser.id, + managed: false, + }); + }); +});