feat(notifications): per-channel urgent escalation (no APNs dependency) + illness red-flag push

This commit is contained in:
Marc-André Bombeck committed 2026-06-18 11:29:01 +02:00
commit 0163f159d5
11 files changed
+577 -14

No files matched your search

@@ -21,6 +21,7 @@ import { annotate } from "@/lib/logging/context";
import { apiSuccess, apiError } from "@/lib/api-response";
import { requireIllnessEnabled } from "@/lib/illness/gate";
import { computeEpisodeCorrelation } from "@/lib/illness/correlation-read";
import { notifyIllnessRedFlag } from "@/lib/illness/red-flag-notify";
import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
type RouteParams = { params: Promise<{ id: string }> };
@@ -74,6 +75,18 @@ export const GET = apiHandler(
},
});
// v1.18.4 — bridge the red-flag escalation to an urgent push. Owner-scoped
// (the episode is already confirmed to belong to `user.id`) + module-gated
// (the illness gate above). Awaited but internally de-duped + no-throw, so a
// re-read never re-fires and a notification hiccup can't fail the read.
if (derived.status === "ok" && derived.value.redFlags.length > 0) {
await notifyIllnessRedFlag({
userId: user.id,
episodeId: id,
redFlags: derived.value.redFlags,
});
}
return apiSuccess({
episodeId: id,
status: derived.status,
@@ -0,0 +1,119 @@
/**
* v1.18.4 — illness red-flag escalation push.
*
* Pins: empty red-flags is a no-op; a fresh red flag dispatches an URGENT
* localised SYSTEM_ALERT; a recent ledger row de-dupes (no re-fire); the
* fever reason is preferred for the body; failures never throw.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const findFirstMock = vi.fn();
const createMock = vi.fn();
const dispatchMock = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
pushAttempt: {
findFirst: (...a: unknown[]) => findFirstMock(...a),
create: (...a: unknown[]) => createMock(...a),
},
},
}));
vi.mock("@/lib/logging/context", () => ({
getEvent: () => ({ addMeta: vi.fn(), addWarning: vi.fn() }),
}));
vi.mock("@/lib/notifications/dispatch-localised", () => ({
dispatchLocalisedNotification: (...a: unknown[]) => dispatchMock(...a),
}));
import { notifyIllnessRedFlag } from "../red-flag-notify";
import type { IllnessRedFlag } from "../correlation";
const spo2Flag: IllnessRedFlag = {
type: "SPO2" as never,
reason: "sustained_low_spo2",
worstValue: 89,
days: 3,
};
const feverFlag: IllnessRedFlag = {
type: "BODY_TEMPERATURE" as never,
reason: "sustained_fever",
worstValue: 39.1,
days: 3,
};
beforeEach(() => {
findFirstMock.mockReset().mockResolvedValue(null);
createMock.mockReset().mockResolvedValue({});
dispatchMock.mockReset().mockResolvedValue(undefined);
});
afterEach(() => vi.clearAllMocks());
describe("notifyIllnessRedFlag", () => {
it("no-op when there are no red flags", async () => {
await notifyIllnessRedFlag({ userId: "u1", episodeId: "e1", redFlags: [] });
expect(dispatchMock).not.toHaveBeenCalled();
expect(findFirstMock).not.toHaveBeenCalled();
});
it("dispatches an URGENT SYSTEM_ALERT for a fresh red flag", async () => {
await notifyIllnessRedFlag({
userId: "u1",
episodeId: "e1",
redFlags: [spo2Flag],
});
expect(dispatchMock).toHaveBeenCalledTimes(1);
const opts = dispatchMock.mock.calls[0][0];
expect(opts.urgent).toBe(true);
expect(opts.eventType).toBe("SYSTEM_ALERT");
expect(opts.userId).toBe("u1");
expect(opts.titleKey).toBe("illness.correlation.redFlagTitle");
expect(opts.messageKey).toBe("illness.correlation.redFlagSpo2");
});
it("prefers the fever reason for the body when both flags fire", async () => {
await notifyIllnessRedFlag({
userId: "u1",
episodeId: "e1",
redFlags: [spo2Flag, feverFlag],
});
expect(dispatchMock.mock.calls[0][0].messageKey).toBe(
"illness.correlation.redFlagFever",
);
});
it("de-dupes when a recent ledger row exists for the episode", async () => {
findFirstMock.mockResolvedValueOnce({ id: "prior" });
await notifyIllnessRedFlag({
userId: "u1",
episodeId: "e1",
redFlags: [feverFlag],
});
expect(dispatchMock).not.toHaveBeenCalled();
expect(createMock).not.toHaveBeenCalled();
});
it("stamps the ledger anchor before dispatching", async () => {
await notifyIllnessRedFlag({
userId: "u1",
episodeId: "e9",
redFlags: [feverFlag],
});
expect(createMock).toHaveBeenCalledTimes(1);
expect(createMock.mock.calls[0][0].data.reason).toContain("e9");
});
it("never throws when dispatch fails", async () => {
dispatchMock.mockRejectedValueOnce(new Error("boom"));
await expect(
notifyIllnessRedFlag({
userId: "u1",
episodeId: "e1",
redFlags: [feverFlag],
}),
).resolves.toBeUndefined();
});
});
+94
View File
@@ -0,0 +1,94 @@
/**
* v1.18.4 — illness red-flag escalation push.
*
* The illness correlation engine (`computeIllnessCorrelation`) surfaces a
* retrospective "seek care" red flag when a vital held an absolute clinical
* floor for a sustained run (sustained low SpO2, sustained fever). Until now
* that escalation only rendered in-app on the correlation surface; it never
* left a push. This helper bridges it to the notification dispatcher so the
* escalation reaches the user wherever they configured a channel.
*
* It dispatches at the URGENT level: every configured channel delivers at its
* highest urgency (APNs time-sensitive, ntfy max, Web Push `Urgency: high`,
* webhook `urgent`). There is NO dependency on APNs — an instance with only
* ntfy / Web Push / Telegram / webhook / email still gets the escalation at
* the strongest tier those channels support.
*
* Owner-scoped (the authenticated episode owner) and module-gated by the
* caller (`requireIllnessEnabled`). De-duplicated through the `pushAttempt`
* ledger: at most one escalation per episode per rolling window, so a
* correlation surface that is re-read repeatedly never re-fires the alarm.
*/
import { prisma } from "@/lib/db";
import { getEvent } from "@/lib/logging/context";
import { dispatchLocalisedNotification } from "@/lib/notifications/dispatch-localised";
import type { IllnessRedFlag } from "@/lib/illness/correlation";
/** One escalation per episode per this window. */
const DEDUPE_WINDOW_MS = 24 * 60 * 60 * 1000;
/** Stable ledger reason prefix so the dedupe lookup is exact-matchable. */
const LEDGER_REASON_PREFIX = "illness_red_flag:";
/**
* Emit an urgent escalation push for an episode's red flags, at most once
* per `DEDUPE_WINDOW_MS`. No-op when `redFlags` is empty. Never throws —
* a notification failure must not break the correlation read.
*/
export async function notifyIllnessRedFlag(input: {
userId: string;
episodeId: string;
redFlags: IllnessRedFlag[];
}): Promise<void> {
const { userId, episodeId, redFlags } = input;
if (redFlags.length === 0) return;
try {
// Dedupe: skip when an escalation for THIS episode already landed in the
// ledger within the window. The episode id rides the `reason` column so
// the lookup is exact without a schema change.
const reason = `${LEDGER_REASON_PREFIX}${episodeId}`;
const since = new Date(Date.now() - DEDUPE_WINDOW_MS);
const prior = await prisma.pushAttempt.findFirst({
where: { userId, reason, createdAt: { gte: since } },
select: { id: true },
});
if (prior) return;
// Prefer the most clinically actionable reason for the body. Both copy
// keys already exist in every locale bundle (i18n integrity guarantee).
const fever = redFlags.find((f) => f.reason === "sustained_fever");
const messageKey = fever
? "illness.correlation.redFlagFever"
: "illness.correlation.redFlagSpo2";
// Stamp the ledger BEFORE dispatching so a slow/duplicate concurrent read
// can't double-fire. The senders write their own per-channel rows too;
// this synthetic row is purely the dedupe anchor.
await prisma.pushAttempt.create({
data: {
userId,
channel: "WEB_PUSH",
eventType: "SYSTEM_ALERT",
result: "skipped",
reason,
},
});
await dispatchLocalisedNotification({
userId,
titleKey: "illness.correlation.redFlagTitle",
messageKey,
eventType: "SYSTEM_ALERT",
urgent: true,
});
getEvent()?.addMeta("illness_red_flag_notified", episodeId);
} catch (err) {
getEvent()?.addWarning(
`illness red-flag notify failed for episode ${episodeId}: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
}
@@ -59,6 +59,13 @@ interface DispatchLocalisedOptions {
eventType?: EventType;
/** Channel-specific extras forwarded to the dispatcher metadata. */
metadata?: Record<string, unknown>;
/**
* Escalate to every channel's highest urgency (v1.18.4). Forwarded to
* the dispatcher so APNs goes time-sensitive, ntfy max, Web Push
* `Urgency: high`, webhook `urgent` — APNs-less instances degrade
* gracefully to whatever channels are configured.
*/
urgent?: boolean;
}
function isLocale(value: string | null | undefined): value is Locale {
@@ -163,5 +170,6 @@ export async function dispatchLocalisedNotification(
title,
message,
metadata,
urgent: opts.urgent,
});
}
@@ -630,6 +630,53 @@ describe("sendViaApns — dispatcher fan-out", () => {
expect(note.priority).toBe(10);
});
it("urgent payload sets time-sensitive + priority=10 on a non-medication event", async () => {
// v1.18.4 — an explicitly urgent event (e.g. an illness red-flag
// escalation) reaches APNs's strongest no-entitlement tier:
// time-sensitive + priority 10, regardless of eventType.
vi.mocked(prisma.device.findMany).mockResolvedValueOnce([
{ id: "d1", apnsToken: "tok-a", apnsEnvironment: "sandbox" },
] as never);
sendMock.mockResolvedValueOnce({ sent: [{ device: "tok-a" }], failed: [] });
await sendViaApns("u-1", {
title: "Seek care",
message: "Sustained fever",
eventType: "SYSTEM_ALERT",
urgent: true,
});
const note = sendMock.mock.calls[0][0];
expect(note.interruptionLevel).toBe("time-sensitive");
expect(note.priority).toBe(10);
});
it("urgent payload escalates to critical ONLY with the entitlement env set", async () => {
// v1.18.4 — the `critical` level ignores DND/Focus but needs an
// Apple-approved entitlement; gate it behind APNS_CRITICAL_ENTITLEMENT.
const prev = process.env.APNS_CRITICAL_ENTITLEMENT;
process.env.APNS_CRITICAL_ENTITLEMENT = "true";
try {
vi.mocked(prisma.device.findMany).mockResolvedValueOnce([
{ id: "d1", apnsToken: "tok-a", apnsEnvironment: "sandbox" },
] as never);
sendMock.mockResolvedValueOnce({
sent: [{ device: "tok-a" }],
failed: [],
});
await sendViaApns("u-1", {
title: "Seek care",
message: "Sustained fever",
eventType: "SYSTEM_ALERT",
urgent: true,
});
const note = sendMock.mock.calls[0][0];
expect(note.interruptionLevel).toBe("critical");
expect(note.priority).toBe(10);
} finally {
if (prev === undefined) delete process.env.APNS_CRITICAL_ENTITLEMENT;
else process.env.APNS_CRITICAL_ENTITLEMENT = prev;
}
});
it.each([
"MOOD_REMINDER",
"MEASUREMENT_ANOMALY",
@@ -0,0 +1,203 @@
/**
* v1.18.4 — per-channel urgency mapping.
*
* An urgent payload must reach EACH configured channel at its highest level,
* with NO dependency on APNs (most self-hosters have no Apple Developer
* account). These tests pin the mapping per sender and confirm an APNs-less
* instance degrades gracefully (ntfy / Web Push / webhook still escalate).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const {
safeFetchMock,
recordPushAttemptMock,
sendNotificationMock,
findManyMock,
} = vi.hoisted(() => ({
safeFetchMock: vi.fn(),
recordPushAttemptMock: vi.fn(),
sendNotificationMock: vi.fn(),
findManyMock: vi.fn(),
}));
vi.mock("@/lib/safe-fetch", () => ({
safeFetch: (...args: unknown[]) => safeFetchMock(...args),
SafeFetchError: class SafeFetchError extends Error {},
}));
vi.mock("@/lib/logging/context", () => ({
getEvent: () => ({
addWarning: vi.fn(),
addExternalCall: vi.fn(),
setError: vi.fn(),
}),
}));
vi.mock("@/lib/notifications/senders/push-attempt-record", () => ({
recordPushAttempt: (...args: unknown[]) => recordPushAttemptMock(...args),
}));
vi.mock("@/lib/db", () => ({
prisma: { pushSubscription: { findMany: findManyMock, deleteMany: vi.fn() } },
}));
vi.mock("@/lib/crypto", () => ({
decrypt: (v: string) => v,
encrypt: (v: string) => v,
}));
vi.mock("@/lib/notifications/vapid-config", () => ({
getVapidConfig: () =>
Promise.resolve({
subject: "mailto:a@b.c",
publicKey: "pub",
privateKey: "priv",
}),
}));
vi.mock("@/lib/validations/notifications", () => ({
isPublicUrl: () => true,
}));
vi.mock("web-push", () => ({
setVapidDetails: vi.fn(),
sendNotification: (...args: unknown[]) => sendNotificationMock(...args),
}));
import { sendViaNtfy } from "../ntfy";
import { sendViaWebhook } from "../webhook";
import { sendViaWebPush } from "../web-push";
function payload(over?: Record<string, unknown>) {
return {
eventType: "SYSTEM_ALERT" as const,
userId: "user-1",
title: "Seek care",
message: "Sustained fever",
...over,
};
}
beforeEach(() => {
safeFetchMock.mockReset();
recordPushAttemptMock.mockReset();
sendNotificationMock.mockReset();
findManyMock.mockReset();
});
afterEach(() => vi.clearAllMocks());
describe("ntfy urgent mapping", () => {
it("urgent → Priority 5 (max) + warning tag", async () => {
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
await sendViaNtfy(
{ serverUrl: "https://ntfy.example.com", topic: "t" },
payload({ urgent: true }),
);
const [, init] = safeFetchMock.mock.calls[0];
const headers = (init as { headers: Record<string, string> }).headers;
expect(headers.Priority).toBe("5");
expect(headers.Tags).toContain("warning");
});
it("non-urgent SYSTEM_ALERT → default priority, no warning tag", async () => {
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
await sendViaNtfy(
{ serverUrl: "https://ntfy.example.com", topic: "t" },
payload(),
);
const headers = (safeFetchMock.mock.calls[0][1] as {
headers: Record<string, string>;
}).headers;
expect(headers.Priority).toBe("default");
expect(headers.Tags).not.toContain("warning");
});
it("discreet urgent keeps the generic tag (no warning leak)", async () => {
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
await sendViaNtfy(
{ serverUrl: "https://ntfy.example.com", topic: "t" },
payload({ urgent: true, discreet: true }),
);
const headers = (safeFetchMock.mock.calls[0][1] as {
headers: Record<string, string>;
}).headers;
expect(headers.Priority).toBe("5");
expect(headers.Tags).toBe("reminder");
});
});
describe("webhook urgent mapping", () => {
it("urgent → priority 'urgent' in the JSON body", async () => {
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
await sendViaWebhook(
{ url: "https://relay.example.com" },
payload({ urgent: true }),
);
const body = JSON.parse(
(safeFetchMock.mock.calls[0][1] as { body: string }).body,
);
expect(body.priority).toBe("urgent");
});
it("non-urgent → priority 'default'", async () => {
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
await sendViaWebhook({ url: "https://relay.example.com" }, payload());
const body = JSON.parse(
(safeFetchMock.mock.calls[0][1] as { body: string }).body,
);
expect(body.priority).toBe("default");
});
});
describe("web-push urgent mapping", () => {
it("urgent → Urgency:high option + requireInteraction in payload", async () => {
findManyMock.mockResolvedValue([
{ id: "s1", endpoint: "https://push.example.com/x", p256dh: "a", auth: "b" },
]);
sendNotificationMock.mockResolvedValue(undefined);
await sendViaWebPush("user-1", payload({ urgent: true }));
const [, body, options] = sendNotificationMock.mock.calls[0];
expect((options as { urgency?: string }).urgency).toBe("high");
expect(JSON.parse(body as string).requireInteraction).toBe(true);
});
it("non-urgent → no urgency option, requireInteraction false", async () => {
findManyMock.mockResolvedValue([
{ id: "s1", endpoint: "https://push.example.com/x", p256dh: "a", auth: "b" },
]);
sendNotificationMock.mockResolvedValue(undefined);
await sendViaWebPush("user-1", payload());
const [, body, options] = sendNotificationMock.mock.calls[0];
expect(options).toBeUndefined();
expect(JSON.parse(body as string).requireInteraction).toBe(false);
});
});
describe("APNs-less instance degrades gracefully", () => {
it("an urgent event still escalates ntfy + Web Push with no APNs configured", async () => {
// No APNs env, no APNs channel — the urgent event reaches the channels
// that ARE present at their top tier.
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
findManyMock.mockResolvedValue([
{ id: "s1", endpoint: "https://push.example.com/x", p256dh: "a", auth: "b" },
]);
sendNotificationMock.mockResolvedValue(undefined);
const ntfy = await sendViaNtfy(
{ serverUrl: "https://ntfy.example.com", topic: "t" },
payload({ urgent: true }),
);
const web = await sendViaWebPush("user-1", payload({ urgent: true }));
expect(ntfy.ok).toBe(true);
expect(web.ok).toBe(true);
expect(
(safeFetchMock.mock.calls[0][1] as { headers: Record<string, string> })
.headers.Priority,
).toBe("5");
expect(
(sendNotificationMock.mock.calls[0][2] as { urgency?: string }).urgency,
).toBe("high");
});
});
+25 -7
View File
@@ -525,6 +525,7 @@ export async function sendViaApns(
eventType: string;
metadata?: Record<string, unknown>;
discreet?: boolean;
urgent?: boolean;
},
): Promise<SendOutcome> {
const config = loadApnsConfig();
@@ -595,13 +596,28 @@ export async function sendViaApns(
? (["production", "sandbox"] as const)
: (["sandbox", "production"] as const);
// SB-5 v1.4.40 — only MEDICATION_REMINDER opts in to Focus-bypass.
// The user explicitly asked the server to nudge them about a dose;
// a missed dose is the textbook "time-sensitive" use case Apple
// SB-5 v1.4.40 — only MEDICATION_REMINDER opts in to Focus-bypass by
// default. The user explicitly asked the server to nudge them about a
// dose; a missed dose is the textbook "time-sensitive" use case Apple
// sanctions. Every other event-type (mood nudges, anomaly alerts,
// PRs, system messages) must stay on the default `active` level so
// Focus modes — including Sleep — silence them as the user expects.
const isTimeSensitive = payload.eventType === "MEDICATION_REMINDER";
// PRs, system messages) stays on the default `active` level so Focus
// modes — including Sleep — silence them as the user expects.
//
// v1.18.4 — an explicitly urgent payload (e.g. an illness red-flag
// "seek care" escalation) ALSO opts in to time-sensitive: it is the
// strongest level the APNs sender can reach without an Apple-approved
// entitlement, and the channel-agnostic urgency model wants every
// channel at its highest tier for an urgent event.
const isTimeSensitive =
payload.eventType === "MEDICATION_REMINDER" || payload.urgent === true;
// The `critical` interruption-level ignores DND/Focus completely but
// is gated behind an Apple-approved entitlement (`com.apple.developer.
// usernotifications.critical-alerts`). Only emit it when the operator
// has confirmed the build carries the entitlement — otherwise APNs
// rejects the push. Default off; time-sensitive is the safe floor.
const useCritical =
payload.urgent === true &&
process.env.APNS_CRITICAL_ENTITLEMENT === "true";
let result: Awaited<ReturnType<typeof sendApnsPush>> | null = null;
// Discreet mode (cycle privacy): the lock-screen-visible routing
@@ -645,7 +661,9 @@ export async function sendViaApns(
mutableContent: true,
...(isTimeSensitive
? {
interruptionLevel: "time-sensitive" as const,
interruptionLevel: useCritical
? ("critical" as const)
: ("time-sensitive" as const),
priority: 10 as const,
}
: {}),
+19 -6
View File
@@ -8,6 +8,7 @@ import { getEvent } from "@/lib/logging/context";
import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record";
import { safeFetch } from "@/lib/safe-fetch";
import { plainPushText } from "@/lib/notifications/strip-emoji";
import { isUrgentPayload } from "@/lib/notifications/types";
/**
* Send notification via ntfy (simple HTTP POST).
@@ -26,16 +27,28 @@ export async function sendViaNtfy(
try {
const url = `${config.serverUrl.replace(/\/$/, "")}/${encodeURIComponent(config.topic)}`;
// v1.18.4 — an explicitly urgent payload escalates to ntfy's top
// priority (`5` / max) so it bypasses the relay's batching and surfaces
// loudest; MEDICATION_REMINDER keeps its long-standing `high` (4); the
// rest stay `default` (3). `urgent` also adds a tag so the lock-screen
// entry reads as an alert (unless discreet privacy collapses tags).
const urgent = isUrgentPayload(payload) && payload.urgent === true;
const priority = urgent
? "5"
: payload.eventType === "MEDICATION_REMINDER"
? "high"
: "default";
const eventTag = payload.discreet
? "reminder"
: payload.eventType.toLowerCase().replace(/_/g, "-");
const headers: Record<string, string> = {
Title: plainPushText(payload.title, payload.eventType),
Priority:
payload.eventType === "MEDICATION_REMINDER" ? "high" : "default",
Priority: priority,
// Discreet mode (cycle privacy): the X-Tags header is visible on the
// lock screen, so collapse it to a generic tag instead of leaking the
// cycle event name.
Tags: payload.discreet
? "reminder"
: payload.eventType.toLowerCase().replace(/_/g, "-"),
// cycle event name. An urgent (non-discreet) event also carries a
// `warning` tag so the entry renders as an alert.
Tags: urgent && !payload.discreet ? `warning,${eventTag}` : eventTag,
};
if (config.authToken) {
+12
View File
@@ -99,8 +99,19 @@ export async function sendViaWebPush(
payload.metadata.url.startsWith("/")
? payload.metadata.url
: "/",
// v1.18.4 — urgent events ask the service worker to keep the
// notification on screen until the user acts on it (where the
// browser honours `requireInteraction`).
requireInteraction: payload.urgent === true,
});
// v1.18.4 — the Web Push `Urgency` header (RFC 8030) tells the push
// service to deliver immediately rather than batching for power. An
// urgent event uses `high`; everything else keeps the library default.
const sendOptions = payload.urgent === true
? ({ urgency: "high" as const } as const)
: undefined;
let anySuccess = false;
const expiredIds: string[] = [];
let lastTransientStatus: number | undefined;
@@ -128,6 +139,7 @@ export async function sendViaWebPush(
keys: { p256dh, auth },
},
pushPayload,
sendOptions,
);
anySuccess = true;
allPermanentReject = false;
+8 -1
View File
@@ -53,8 +53,15 @@ export async function sendViaWebhook(
payload.eventType,
),
eventType: payload.discreet ? "reminder" : payload.eventType,
// v1.18.4 — an explicitly urgent event maps to `urgent` so a relay
// rule (Gotify priority, Discord mention, Home Assistant automation)
// can escalate; MEDICATION_REMINDER keeps `high`; the rest `default`.
priority:
payload.eventType === "MEDICATION_REMINDER" ? "high" : "default",
payload.urgent === true
? "urgent"
: payload.eventType === "MEDICATION_REMINDER"
? "high"
: "default",
});
const res = await safeFetch(
+29
View File
@@ -219,4 +219,33 @@ export interface NotificationPayload {
* — the title/body are already masked upstream.
*/
discreet?: boolean;
/**
* Urgent / escalation dimension (v1.18.4). When true, EVERY configured
* channel delivers at the HIGHEST urgency it supports — never assuming
* APNs is one of them (most self-hosters have no Apple Developer account):
* - APNs → interruption-level `time-sensitive` + priority 10 (and
* `critical` only when `APNS_CRITICAL_ENTITLEMENT=true`, since the
* critical flag needs an Apple-approved entitlement; time-sensitive
* does not).
* - ntfy → `Priority: 5` (max) + an `urgent` tag.
* - WebPush → `Urgency: high` header + `requireInteraction`.
* - Webhook → `priority: "urgent"` in the JSON body.
* - Telegram → normal delivery (no urgency tier exists; never silent).
* Normal events leave `urgent` unset and behave exactly as before.
*/
urgent?: boolean;
}
/**
* Whether a payload should escalate to each channel's highest urgency.
* Explicit `urgent: true` is the modern signal. `MEDICATION_REMINDER`
* stays escalated for backward compatibility (it has shipped as APNs
* time-sensitive / ntfy high since v1.4.40), so callers that don't set
* the flag keep their existing behaviour.
*/
export function isUrgentPayload(payload: {
urgent?: boolean;
eventType: string;
}): boolean {
return payload.urgent === true || payload.eventType === "MEDICATION_REMINDER";
}