mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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:
1 parent
9cf10d5cf0
commit
1bb2afd829
2 files changed
+56
-2
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user