mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat(notifications): per-channel urgent escalation (no APNs dependency) + illness red-flag push
This commit is contained in:
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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}
|
||||
: {}),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
Reference in new issue
Block a user