From 1bb2afd8290f7021e9a0430064a3fab06c200d29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 17 May 2026 23:43:50 +0200 Subject: [PATCH] feat(notifications): bubble dispatch outcome and whitelist iOS metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two structural changes on the notification platform that unblock the mood-reminder ledger-after-delivery semantics (separate commit) and close the v0.5.4 PR #190 review's Security-H1 finding. - `dispatchNotification` now returns `DispatchOutcome` with `dispatched`, `channelsAttempted`, and `channelsSucceeded`. The caller can decide whether the send is worth marking as committed in its own ledger. Existing callers that ignore the return value keep working — the function is still best-effort and never throws. - `sendViaApns` now whitelists the metadata keys it forwards into the APNs custom payload. The dispatcher's `metadata` is shared across every channel and previously surfaced channel-specific payload structures (Telegram's `replyMarkup` keyboard, ad-hoc extras) into the APNs userInfo Apple sees and the iOS app's notification handler. The allowlist covers the keys iOS actually reads (`scheduledAt`, `localDate`, `medicationId`, `scheduleId`, `phase`, `date`) and drops the rest. --- src/lib/notifications/dispatcher.ts | 23 +++++++++++++++++- src/lib/notifications/senders/apns.ts | 35 ++++++++++++++++++++++++++- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/src/lib/notifications/dispatcher.ts b/src/lib/notifications/dispatcher.ts index 07d07198f..f76f5f59d 100644 --- a/src/lib/notifications/dispatcher.ts +++ b/src/lib/notifications/dispatcher.ts @@ -36,9 +36,21 @@ import { getEvent } from "@/lib/logging/context"; * Also handles legacy Telegram config stored on the User model by * auto-migrating it to a NotificationChannel record on first dispatch. */ +export interface DispatchOutcome { + /** True when at least one channel reported a successful delivery. */ + dispatched: boolean; + /** Channels considered after enable / cooldown / preference filtering. */ + channelsAttempted: number; + /** Subset of attempted channels that returned ok. */ + channelsSucceeded: number; +} + export async function dispatchNotification( payload: NotificationPayload, -): Promise { +): Promise { + let channelsAttempted = 0; + let channelsSucceeded = 0; + try { const channels = await prisma.notificationChannel.findMany({ where: { userId: payload.userId, enabled: true }, @@ -143,6 +155,8 @@ export async function dispatchNotification( continue; } + channelsAttempted += 1; + try { const outcome = await sendToChannel( channel.type as ChannelType, @@ -156,6 +170,7 @@ export async function dispatchNotification( userId: channel.userId, type: channel.type as ChannelType, }); + channelsSucceeded += 1; continue; } @@ -209,6 +224,12 @@ export async function dispatchNotification( } catch (err) { getEvent()?.addWarning(`Notification dispatcher error: ${err}`); } + + return { + dispatched: channelsSucceeded > 0, + channelsAttempted, + channelsSucceeded, + }; } async function sendToChannel( diff --git a/src/lib/notifications/senders/apns.ts b/src/lib/notifications/senders/apns.ts index cce0a0d20..d23941bef 100644 --- a/src/lib/notifications/senders/apns.ts +++ b/src/lib/notifications/senders/apns.ts @@ -393,7 +393,7 @@ export async function sendViaApns( alert: { title: payload.title, body: stripHtml(payload.message) }, data: { eventType: payload.eventType, - ...(payload.metadata ?? {}), + ...pickIosMetadata(payload.metadata), }, threadId: payload.eventType, // The iOS app registers a `UNNotificationCategory` per event-type @@ -455,3 +455,36 @@ export async function sendViaApns( function stripHtml(s: string): string { return s.replace(/<[^>]*>/g, ""); } + +/** + * Whitelist the metadata keys we send through to iOS. The dispatcher + * payload's `metadata` field is shared across every channel — Telegram + * stuffs an `replyMarkup` keyboard in there, future channels will add + * their own extras — and forwarding the lot to APNs leaks + * channel-specific payload structures into the APNs userInfo Apple + * sees plus the iOS app's notification handler. + * + * Allowed keys are the ones the iOS app actually reads. Add a new key + * here as iOS gains support for it. + */ +const IOS_METADATA_ALLOWLIST = new Set([ + "scheduledAt", + "localDate", + "medicationId", + "scheduleId", + "phase", + "date", +]); + +function pickIosMetadata( + metadata: Record | undefined, +): Record { + if (!metadata) return {}; + const out: Record = {}; + for (const key of Object.keys(metadata)) { + if (IOS_METADATA_ALLOWLIST.has(key)) { + out[key] = metadata[key]; + } + } + return out; +}