diff --git a/src/__tests__/notification-delivery-identity.test.ts b/src/__tests__/notification-delivery-identity.test.ts index 2cf3a38c9..c143bcbc5 100644 --- a/src/__tests__/notification-delivery-identity.test.ts +++ b/src/__tests__/notification-delivery-identity.test.ts @@ -114,4 +114,55 @@ describe("notification delivery identity", () => { }), ); }); + + 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/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/delivery-identity.ts b/src/lib/notifications/delivery-identity.ts new file mode 100644 index 000000000..5d4174ec8 --- /dev/null +++ b/src/lib/notifications/delivery-identity.ts @@ -0,0 +1,45 @@ +import { prisma } from "@/lib/db"; +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 its recipient explicitly. + */ +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; + + const managed = record.managedProfileAt != null; + if (managed) { + return recipientUserId === recordUserId + ? null + : { recordUserId, recipientUserId, managed }; + } + + return recipientUserId === recordUserId + ? { recordUserId, recipientUserId, managed } + : null; +} 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/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 a0aac6acf..e811342b0 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. @@ -55,6 +56,27 @@ interface AttributedPushAttemptRecord extends PushAttemptRecordBase { 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 { + recordPushAttempt({ + recordUserId: payload.recordUserId ?? legacyUserId, + recipientUserId: payload.recipientUserId ?? legacyUserId, + 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 = diff --git a/src/lib/notifications/senders/telegram.ts b/src/lib/notifications/senders/telegram.ts index b31f9f6de..f5d35b497 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 { @@ -118,6 +118,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; @@ -152,24 +153,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: [ [ @@ -251,13 +253,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 +266,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/tests/integration/notification-attribution.test.ts b/tests/integration/notification-attribution.test.ts index 31af6edee..cc6f7a26e 100644 --- a/tests/integration/notification-attribution.test.ts +++ b/tests/integration/notification-attribution.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { getPrismaClient, truncateAllTables } from "./setup"; +import { resolveNotificationDeliveryIdentity } from "@/lib/notifications/delivery-identity"; import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record"; let sequence = 0; @@ -173,4 +174,45 @@ describe("notification attribution (real Postgres)", () => { }), ).toBeNull(); }); + + it("principal resolution accepts only an explicit managed subject and human recipient", 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.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(); + }); });