feat(notifications): APNs joins the dispatcher cascade

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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-11 00:39:13 +02:00
1 parent 7f70856e2b
commit 9c2119e48b
2 files changed
+53 -2

No files matched your search

+41
View File
@@ -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;
}
}
+12 -2
View File
@@ -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<ChannelType, string> = {
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<string, never>;
/**
* 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<string, never>;
export type ChannelConfig =
| TelegramChannelConfig
| NtfyChannelConfig
| WebPushChannelConfig;
| WebPushChannelConfig
| ApnsChannelConfig;
// ── Notification payload ──