feat(notifications): bubble dispatch outcome and whitelist iOS metadata

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.
This commit is contained in:
Marc-André Bombeck committed 2026-05-17 23:43:50 +02:00
1 parent 9cf10d5cf0
commit 1bb2afd829
2 files changed
+56 -2

No files matched your search

+22 -1
View File
@@ -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<void> {
): Promise<DispatchOutcome> {
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(
+34 -1
View File
@@ -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<string, unknown> | undefined,
): Record<string, unknown> {
if (!metadata) return {};
const out: Record<string, unknown> = {};
for (const key of Object.keys(metadata)) {
if (IOS_METADATA_ALLOWLIST.has(key)) {
out[key] = metadata[key];
}
}
return out;
}