diff --git a/src/app/api/illness/episodes/[id]/correlation/route.ts b/src/app/api/illness/episodes/[id]/correlation/route.ts index a315f69ae..c0f647364 100644 --- a/src/app/api/illness/episodes/[id]/correlation/route.ts +++ b/src/app/api/illness/episodes/[id]/correlation/route.ts @@ -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, diff --git a/src/lib/illness/__tests__/red-flag-notify.test.ts b/src/lib/illness/__tests__/red-flag-notify.test.ts new file mode 100644 index 000000000..9a852ca9c --- /dev/null +++ b/src/lib/illness/__tests__/red-flag-notify.test.ts @@ -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(); + }); +}); diff --git a/src/lib/illness/red-flag-notify.ts b/src/lib/illness/red-flag-notify.ts new file mode 100644 index 000000000..d5cbf8916 --- /dev/null +++ b/src/lib/illness/red-flag-notify.ts @@ -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 { + 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) + }`, + ); + } +} diff --git a/src/lib/notifications/dispatch-localised.ts b/src/lib/notifications/dispatch-localised.ts index 5d20d5f39..e633a91d0 100644 --- a/src/lib/notifications/dispatch-localised.ts +++ b/src/lib/notifications/dispatch-localised.ts @@ -59,6 +59,13 @@ interface DispatchLocalisedOptions { eventType?: EventType; /** Channel-specific extras forwarded to the dispatcher metadata. */ metadata?: Record; + /** + * 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, }); } diff --git a/src/lib/notifications/senders/__tests__/apns.test.ts b/src/lib/notifications/senders/__tests__/apns.test.ts index f41fda74d..72b3072ed 100644 --- a/src/lib/notifications/senders/__tests__/apns.test.ts +++ b/src/lib/notifications/senders/__tests__/apns.test.ts @@ -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", diff --git a/src/lib/notifications/senders/__tests__/urgency.test.ts b/src/lib/notifications/senders/__tests__/urgency.test.ts new file mode 100644 index 000000000..5edb185ec --- /dev/null +++ b/src/lib/notifications/senders/__tests__/urgency.test.ts @@ -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) { + 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 }).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; + }).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; + }).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 }) + .headers.Priority, + ).toBe("5"); + expect( + (sendNotificationMock.mock.calls[0][2] as { urgency?: string }).urgency, + ).toBe("high"); + }); +}); diff --git a/src/lib/notifications/senders/apns.ts b/src/lib/notifications/senders/apns.ts index 511989863..1e50797b9 100644 --- a/src/lib/notifications/senders/apns.ts +++ b/src/lib/notifications/senders/apns.ts @@ -525,6 +525,7 @@ export async function sendViaApns( eventType: string; metadata?: Record; discreet?: boolean; + urgent?: boolean; }, ): Promise { 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> | 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, } : {}), diff --git a/src/lib/notifications/senders/ntfy.ts b/src/lib/notifications/senders/ntfy.ts index 7878c8c92..7a673f974 100644 --- a/src/lib/notifications/senders/ntfy.ts +++ b/src/lib/notifications/senders/ntfy.ts @@ -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 = { 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) { diff --git a/src/lib/notifications/senders/web-push.ts b/src/lib/notifications/senders/web-push.ts index 2ff484147..0d4900d47 100644 --- a/src/lib/notifications/senders/web-push.ts +++ b/src/lib/notifications/senders/web-push.ts @@ -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; diff --git a/src/lib/notifications/senders/webhook.ts b/src/lib/notifications/senders/webhook.ts index 68add9be9..e7a890aee 100644 --- a/src/lib/notifications/senders/webhook.ts +++ b/src/lib/notifications/senders/webhook.ts @@ -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( diff --git a/src/lib/notifications/types.ts b/src/lib/notifications/types.ts index ea23e1ce9..b338b566c 100644 --- a/src/lib/notifications/types.ts +++ b/src/lib/notifications/types.ts @@ -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"; }