From 9c2119e48b082a920c813d0d22a5c77356a53ffe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 11 May 2026 00:39:13 +0200 Subject: [PATCH] feat(notifications): APNs joins the dispatcher cascade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires `sendViaApns()` into `dispatcher.sendToChannel()` as the fourth channel-type alongside Telegram, ntfy, and Web Push. Per-user channel selection works exactly as before — a user with `APNS` enabled in `NotificationChannel` and no preference override receives APNs alerts; a user who pairs an iPhone but disables APNs in Settings doesn't. Cascade order is now deterministic: APNs → Telegram → ntfy → Web Push. The order matters because each channel runs best-effort and the user sees notifications fan out in that sequence; sorting in `dispatchNotification` keeps the delivery sequence stable across dispatches even when Postgres returns the channel rows in physical order. Unknown channel types sort last so a stale enum value can't preempt a real channel. The `CHANNEL_TYPES` tuple, `CHANNEL_TYPE_LABELS` map, and `ChannelConfig` union pick up `APNS` / `Apple Push (APNs)` / `ApnsChannelConfig` (an empty record by design — the per-device APNs token + environment live on the `Device` row, not on the channel config). On `shouldDisable: true` from the sender, the dispatcher's existing channel-state machine flips `disabledReason` and bumps `consecutiveFailures` through `recordChannelHardReject()` — no new state-machine code needed. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/notifications/dispatcher.ts | 41 +++++++++++++++++++++++++++++ src/lib/notifications/types.ts | 14 ++++++++-- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/src/lib/notifications/dispatcher.ts b/src/lib/notifications/dispatcher.ts index a294f2d49..08691916d 100644 --- a/src/lib/notifications/dispatcher.ts +++ b/src/lib/notifications/dispatcher.ts @@ -9,6 +9,7 @@ import type { import { sendViaTelegram } from "@/lib/notifications/senders/telegram"; import { sendViaNtfy } from "@/lib/notifications/senders/ntfy"; import { sendViaWebPush } from "@/lib/notifications/senders/web-push"; +import { sendViaApns } from "@/lib/notifications/senders/apns"; import type { SendOutcome } from "@/lib/notifications/retry-policy"; import { isChannelInCooldown, @@ -104,6 +105,13 @@ export async function dispatchNotification( } } + // Cascade order: APNs first (if the user paired an iPhone), then + // Telegram, then ntfy, then Web Push as the universal fallback. + // The order matters because each channel is best-effort — sorting + // here keeps the payload-delivery sequence deterministic across + // dispatches even when Postgres returns rows in physical order. + channels.sort((a, b) => channelPriority(a.type) - channelPriority(b.type)); + const now = new Date(); for (const channel of channels) { // Opt-out model: if no preference row exists, default to enabled @@ -239,6 +247,14 @@ async function sendToChannel( case "WEB_PUSH": { return sendViaWebPush(payload.userId, payload); } + case "APNS": { + // APNs config lives on the Device row, not in the channel config. + // The decrypt above is a no-op (config is the empty object `{}`), + // 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); + } default: getEvent()?.addWarning(`Unknown notification channel type: ${type}`); return { @@ -248,3 +264,28 @@ async function sendToChannel( }; } } + +/** + * Static cascade order: APNs first, Web Push last. Lower number = earlier. + * The order is deliberate: + * - APNs — native iOS path; best UX when the user has an iPhone. + * - Telegram — explicit user-set chat; high deliverability. + * - ntfy — self-hosted-friendly; user controls the relay. + * - WebPush — broadest reach, slowest UX (browser must be alive). + * + * Unknown channel types sort last so they never preempt a real channel. + */ +function channelPriority(type: string): number { + switch (type) { + case "APNS": + return 0; + case "TELEGRAM": + return 1; + case "NTFY": + return 2; + case "WEB_PUSH": + return 3; + default: + return 99; + } +} diff --git a/src/lib/notifications/types.ts b/src/lib/notifications/types.ts index 9849b2710..a44522ee6 100644 --- a/src/lib/notifications/types.ts +++ b/src/lib/notifications/types.ts @@ -4,7 +4,7 @@ * Events: medication reminders, anomalies, compliance, etc. */ -export const CHANNEL_TYPES = ["TELEGRAM", "NTFY", "WEB_PUSH"] as const; +export const CHANNEL_TYPES = ["TELEGRAM", "NTFY", "WEB_PUSH", "APNS"] as const; export type ChannelType = (typeof CHANNEL_TYPES)[number]; export const EVENT_TYPES = [ @@ -28,6 +28,7 @@ export const CHANNEL_TYPE_LABELS: Record = { TELEGRAM: "Telegram", NTFY: "ntfy", WEB_PUSH: "Web Push", + APNS: "Apple Push (APNs)", }; // ── Channel config shapes (stored as encrypted JSON) ── @@ -45,10 +46,19 @@ export interface NtfyChannelConfig { export type WebPushChannelConfig = Record; +/** + * APNs channel config is empty by design — the per-device APNs token + + * environment live on the `Device` model, not on the channel row. The + * channel row exists so per-event preference toggles in + * `NotificationPreference` apply uniformly across all four channels. + */ +export type ApnsChannelConfig = Record; + export type ChannelConfig = | TelegramChannelConfig | NtfyChannelConfig - | WebPushChannelConfig; + | WebPushChannelConfig + | ApnsChannelConfig; // ── Notification payload ──