mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
1 parent
7f70856e2b
commit
9c2119e48b
2 files changed
+53
-2
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 ──
|
||||
|
||||
|
||||
Reference in new issue
Block a user