From 6c070abfdc948d3641a7be4c7e69667ed12788c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 18 Jun 2026 11:28:18 +0200 Subject: [PATCH 1/5] feat(notifications): escalate urgent events to each channel's highest tier Add an urgent dimension to dispatched notifications so a self-hosted instance reaches every configured channel at its strongest level without assuming APNs is present: - APNs -> interruption-level time-sensitive + priority 10 (critical only when APNS_CRITICAL_ENTITLEMENT=true; time-sensitive needs no Apple approval) - ntfy -> Priority 5 (max) + warning tag (suppressed in discreet mode) - Web Push-> Urgency: high send option + requireInteraction - Webhook -> priority "urgent" in the JSON body - Telegram-> normal delivery (no urgency tier) Wire the illness red-flag escalation (sustained low SpO2 / sustained fever) to emit an urgent SYSTEM_ALERT through the dispatcher, owner-scoped and module-gated, de-duplicated per episode via the push-attempt ledger. Normal events are unchanged. --- .../episodes/[id]/correlation/route.ts | 13 ++ .../illness/__tests__/red-flag-notify.test.ts | 119 ++++++++++ src/lib/illness/red-flag-notify.ts | 94 ++++++++ src/lib/notifications/dispatch-localised.ts | 8 + .../senders/__tests__/apns.test.ts | 47 ++++ .../senders/__tests__/urgency.test.ts | 203 ++++++++++++++++++ src/lib/notifications/senders/apns.ts | 32 ++- src/lib/notifications/senders/ntfy.ts | 25 ++- src/lib/notifications/senders/web-push.ts | 12 ++ src/lib/notifications/senders/webhook.ts | 9 +- src/lib/notifications/types.ts | 29 +++ 11 files changed, 577 insertions(+), 14 deletions(-) create mode 100644 src/lib/illness/__tests__/red-flag-notify.test.ts create mode 100644 src/lib/illness/red-flag-notify.ts create mode 100644 src/lib/notifications/senders/__tests__/urgency.test.ts 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"; } From 604aec631fa12e93ff2a07a0eb07c99064c1cc62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 18 Jun 2026 11:39:55 +0200 Subject: [PATCH 2/5] feat(notifications): strengthen the Web Push path for PWA-only self-hosters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-hosters without an Apple Developer account rely on Web Push to the installed PWA — there is no Live Activity or lock-screen widget to drive. Make that path carry the dose lifecycle on its own: - Stable per-slot tag. The medication dose-due reminder now sets a `med::` Web Push tag (medicationDoseTag), so a re-fired reminder for the same slot coalesces instead of stacking, and a later clear can target it. - Clear-on-taken. When a dose is marked taken or skipped, push a `type:"clear"` notification reusing that slot tag. The service worker closes the matching pending reminder and refreshes the badge — the PWA equivalent of ending a Live Activity. A snooze leaves the dose outstanding and clears nothing. Wired into the single + bulk intake routes alongside the existing APNs cross-device sync. - App badge. The dispatcher carries a server-authoritative outstanding- dose count; the SW reflects it via the Badging API (setAppBadge/clearAppBadge), feature-detected and a no-op where unsupported. - The SW push handler honours requireInteraction for urgent events and renotify so a re-fired reminder re-alerts. Dose-due, preventive-care, and low-stock reminders already cascade to Web Push when no APNs channel exists; tests pin that an APNs-less instance still delivers. --- public/sw.js | 70 ++++++- src/__tests__/service-worker.test.ts | 108 +++++++++- src/app/api/medications/intake/bulk/route.ts | 19 ++ src/app/api/medications/intake/route.ts | 20 ++ .../reminder/medication-reminder-check.ts | 10 + src/lib/medications/outstanding-doses.ts | 39 ++++ .../__tests__/dispatcher.test.ts | 42 ++++ .../__tests__/web-push-dose-clear.test.ts | 195 ++++++++++++++++++ src/lib/notifications/dose-tag.ts | 24 +++ src/lib/notifications/senders/web-push.ts | 22 +- src/lib/notifications/web-push-clear.ts | 127 ++++++++++++ 11 files changed, 666 insertions(+), 10 deletions(-) create mode 100644 src/lib/medications/outstanding-doses.ts create mode 100644 src/lib/notifications/__tests__/web-push-dose-clear.test.ts create mode 100644 src/lib/notifications/dose-tag.ts create mode 100644 src/lib/notifications/web-push-clear.ts diff --git a/public/sw.js b/public/sw.js index 3a83a3e39..32d3f1aa6 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.18.2"; + /* @sw-version-fallback */ "v1.18.4"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; const MAX_STATIC_ENTRIES = 150; @@ -216,6 +216,27 @@ async function trimCache(cacheName, maxEntries) { } } +// ── App badge helper (PWA Badging API) ─────────────────────────────────────── +// v1.18.4 — reflect the server-authoritative outstanding-dose count on the +// installed PWA icon. Feature-detected + best-effort: unsupported engines +// (most desktop Firefox, older Safari) silently no-op. A count of 0 clears the +// badge; a positive count sets it. `undefined`/non-number leaves it untouched. +function applyAppBadge(count) { + if (typeof count !== "number" || !isFinite(count)) return; + try { + if (count > 0) { + if (typeof self.navigator?.setAppBadge === "function") { + self.navigator.setAppBadge(count); + } + } else if (typeof self.navigator?.clearAppBadge === "function") { + self.navigator.clearAppBadge(); + } + } catch { + // Badging API can reject (permission / unsupported) — never let it + // break the push handler. + } +} + // ── Push Notifications ─────────────────────────────────────────────────────── self.addEventListener("push", (event) => { if (!event.data) return; @@ -227,21 +248,54 @@ self.addEventListener("push", (event) => { payload = { title: "HealthLog", body: event.data.text() }; } + // v1.18.4 — `type:"clear"` is the PWA equivalent of ending an iOS Live + // Activity: the server sends it when a dose is logged so the still-pending + // dose-due reminder for that slot is closed here (matched on its stable + // `tag`), and the app badge re-reflects the outstanding-dose count. No new + // notification is shown. + if (payload && payload.type === "clear") { + event.waitUntil( + (async () => { + const tagToClear = payload.tag; + if (tagToClear) { + const matches = await self.registration.getNotifications({ + tag: tagToClear, + }); + for (const n of matches) n.close(); + } + applyAppBadge(payload.badge); + })(), + ); + return; + } + const { title = "HealthLog", body = "", tag = "default", url = "/", + badge, + requireInteraction = false, } = payload; event.waitUntil( - self.registration.showNotification(title, { - body, - tag, - icon: "/logo-192.png", - badge: "/logo-192.png", - data: { url }, - }), + Promise.all([ + self.registration.showNotification(title, { + body, + tag, + // `renotify` lets a re-fired reminder for the same `tag` re-alert the + // user instead of silently replacing the existing notification. + renotify: true, + // v1.18.4 — urgent events (the web-push sender sets this) keep the + // notification on screen until the user acts, where the browser + // honours it. + requireInteraction: requireInteraction === true, + icon: "/logo-192.png", + badge: "/logo-192.png", + data: { url }, + }), + Promise.resolve(applyAppBadge(badge)), + ]), ); }); diff --git a/src/__tests__/service-worker.test.ts b/src/__tests__/service-worker.test.ts index cde471c89..7b0919563 100644 --- a/src/__tests__/service-worker.test.ts +++ b/src/__tests__/service-worker.test.ts @@ -114,15 +114,47 @@ function bootServiceWorker(): SwHarness { navPreload.enabled = true; }, }; + // v1.18.4 — push-handler fakes: a notification store the `getNotifications` + // / `showNotification` calls read/write, and a Badging-API spy. + const shown: Array<{ title: string; tag?: string; closed: boolean }> = []; + const badge = { value: undefined as number | undefined, cleared: false }; const selfObj = { addEventListener: (type: string, fn: (event: unknown) => void) => { listeners.set(type, fn); }, skipWaiting: async () => {}, clients: { claim: async () => {} }, - registration: { navigationPreload: navPreload }, + registration: { + navigationPreload: navPreload, + showNotification: async (title: string, opts: { tag?: string }) => { + shown.push({ title, tag: opts?.tag, closed: false }); + }, + getNotifications: async ({ tag }: { tag?: string }) => + shown + .filter((n) => !n.closed && (tag === undefined || n.tag === tag)) + .map((n) => ({ + close: () => { + n.closed = true; + }, + })), + }, + navigator: { + setAppBadge: (n: number) => { + badge.value = n; + }, + clearAppBadge: () => { + badge.cleared = true; + badge.value = undefined; + }, + }, location: { origin: ORIGIN }, }; + ( + selfObj as unknown as { __shown: typeof shown; __badge: typeof badge } + ).__shown = shown; + ( + selfObj as unknown as { __shown: typeof shown; __badge: typeof badge } + ).__badge = badge; const context: Record = { self: selfObj, caches: cacheStorage, @@ -306,3 +338,77 @@ describe("sw.js — trimCache", () => { expect(store.map.has(`${ORIGIN}/page-7`)).toBe(true); }); }); + +// ── push handler (v1.18.4: clear-on-taken tag close + app badge) ───────────── +async function dispatchPush( + harness: SwHarness, + data: unknown, +): Promise { + let settled: Promise = Promise.resolve(); + harness.listeners.get("push")!({ + data: { json: () => data, text: () => JSON.stringify(data) }, + waitUntil: (p: Promise) => { + settled = p; + }, + }); + await settled; +} + +function swFakes(harness: SwHarness) { + const selfObj = harness.context.self as unknown as { + __shown: Array<{ title: string; tag?: string; closed: boolean }>; + __badge: { value: number | undefined; cleared: boolean }; + }; + return { shown: selfObj.__shown, badge: selfObj.__badge }; +} + +describe("sw.js — push handler", () => { + it("shows a reminder with its stable tag and sets the app badge", async () => { + const harness = bootServiceWorker(); + const { shown, badge } = swFakes(harness); + const tag = "med:med-1:2026-06-18T07:00:00.000Z"; + + await dispatchPush(harness, { + title: "Time for your dose", + body: "Ramipril 5mg", + tag, + badge: 3, + }); + + expect(shown).toHaveLength(1); + expect(shown[0].tag).toBe(tag); + expect(badge.value).toBe(3); + }); + + it("a type:clear push closes the matching-tag notification (no new one) and updates the badge", async () => { + const harness = bootServiceWorker(); + const { shown, badge } = swFakes(harness); + const tag = "med:med-1:2026-06-18T07:00:00.000Z"; + + await dispatchPush(harness, { title: "dose", tag, badge: 1 }); + expect(shown.filter((n) => !n.closed)).toHaveLength(1); + + await dispatchPush(harness, { type: "clear", tag, badge: 0 }); + + // The pending reminder for that slot is closed; no new notification shown. + expect(shown).toHaveLength(1); + expect(shown[0].closed).toBe(true); + // badge: 0 clears the app badge. + expect(badge.cleared).toBe(true); + }); + + it("a type:clear push only closes its own slot's tag", async () => { + const harness = bootServiceWorker(); + const { shown } = swFakes(harness); + + await dispatchPush(harness, { title: "a", tag: "med:a:t1" }); + await dispatchPush(harness, { title: "b", tag: "med:b:t2" }); + + await dispatchPush(harness, { type: "clear", tag: "med:a:t1" }); + + const a = shown.find((n) => n.tag === "med:a:t1")!; + const b = shown.find((n) => n.tag === "med:b:t2")!; + expect(a.closed).toBe(true); + expect(b.closed).toBe(false); + }); +}); diff --git a/src/app/api/medications/intake/bulk/route.ts b/src/app/api/medications/intake/bulk/route.ts index 2c05515e1..316ae38d5 100644 --- a/src/app/api/medications/intake/bulk/route.ts +++ b/src/app/api/medications/intake/bulk/route.ts @@ -69,6 +69,8 @@ import { } from "@/lib/validations/medication"; import type { InjectionSiteKey } from "@/lib/medications/injection-sites"; import { dispatchMedicationIntakeSyncBulk } from "@/lib/notifications/medication-intake-sync"; +import { dispatchMedicationIntakeWebClearBulk } from "@/lib/notifications/web-push-clear"; +import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses"; const MAX_ENTRIES_PER_BATCH = 500; const BATCH_RATE_LIMIT_MAX = 60; @@ -711,6 +713,23 @@ async function postBulk(request: NextRequest): Promise { slots: Array.from(syncSlots.values()), originDeviceToken: request.headers.get("x-device-id"), }); + + // v1.18.4 — PWA-only counterpart: close the still-pending dose-due + // reminders for every affected slot over Web Push and refresh the app + // badge once with the post-batch outstanding-dose count. Best-effort, + // fire-and-forget — a clear miss never affects the batch response. + const affectedSlots = Array.from(syncSlots.values()); + void (async () => { + const badgeCount = await countOutstandingDosesToday( + user.id, + user.timezone, + ); + await dispatchMedicationIntakeWebClearBulk({ + userId: user.id, + slots: affectedSlots, + badgeCount, + }); + })(); } return apiSuccess({ diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts index 506fb6607..14a53b392 100644 --- a/src/app/api/medications/intake/route.ts +++ b/src/app/api/medications/intake/route.ts @@ -51,6 +51,8 @@ import { type InjectionSiteValue, } from "@/lib/validations/medication"; import { dispatchMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; +import { dispatchMedicationIntakeWebClear } from "@/lib/notifications/web-push-clear"; +import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses"; const querySchema = z.object({ scope: z.enum(["today", "compliance"]), @@ -698,5 +700,23 @@ export const POST = apiHandler(async (request: NextRequest) => { originDeviceToken: request.headers.get("x-device-id"), }); + // v1.18.4 — PWA-only equivalent of the Live Activity end: when a dose is + // resolved (taken / skipped), push a `type:"clear"` to the user's Web Push + // subscriptions so the still-pending dose-due reminder for this slot is + // closed by the service worker (matched on the stable slot tag) and the app + // badge re-reflects the outstanding-dose count. A snooze leaves the dose + // outstanding, so it neither clears the reminder nor changes the badge. + if (status === "taken" || status === "skipped") { + void (async () => { + const badgeCount = await countOutstandingDosesToday(user.id, userTzForHook); + await dispatchMedicationIntakeWebClear({ + userId: user.id, + medicationId: existing.medicationId, + scheduledFor: (movedTo ?? existing.scheduledFor).toISOString(), + badgeCount, + }); + })(); + } + return apiSuccess(updated); }); diff --git a/src/lib/jobs/reminder/medication-reminder-check.ts b/src/lib/jobs/reminder/medication-reminder-check.ts index 6500e0d35..70b683828 100644 --- a/src/lib/jobs/reminder/medication-reminder-check.ts +++ b/src/lib/jobs/reminder/medication-reminder-check.ts @@ -10,6 +10,7 @@ import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication- import { decrypt } from "@/lib/crypto"; import { withBackgroundEvent } from "@/lib/logging/background"; import { dispatchNotification } from "@/lib/notifications/dispatcher"; +import { medicationDoseTag } from "@/lib/notifications/dose-tag"; import { buildCanonicalSchedule, buildRecurrenceContext, @@ -494,6 +495,15 @@ export async function handleReminderCheck(jobs: Job[]) { timeOfDay: dedupTimeOfDay, scheduledAt: scheduledAtIso, replyMarkup: keyboard, + // v1.18.4 — stable per-slot Web Push tag. The + // clear-on-taken push reuses the SAME tag so the SW + // replaces/closes this reminder when the dose is logged + // (the PWA equivalent of ending a Live Activity), and a + // re-fired reminder for the same slot coalesces instead + // of stacking on the lock screen. Deep-link clicks land + // on the dose's medication page. + webPushTag: medicationDoseTag(med.id, scheduledAtIso), + url: `/medications/${med.id}`, }, }); } catch (notifErr) { diff --git a/src/lib/medications/outstanding-doses.ts b/src/lib/medications/outstanding-doses.ts new file mode 100644 index 000000000..1647bc427 --- /dev/null +++ b/src/lib/medications/outstanding-doses.ts @@ -0,0 +1,39 @@ +/** + * v1.18.4 — server-authoritative count of a user's still-outstanding doses + * for the current local day. Drives the PWA app badge (`navigator.setAppBadge`) + * so the installed icon mirrors how many doses still need logging — the + * free-tier equivalent of the iOS widget's pending count. + * + * Outstanding = a today-scheduled `MedicationIntakeEvent` that is neither + * taken (`takenAt == null`) nor skipped (`skipped == false`) nor swept by the + * miss cutoff (`autoMissed == false`). Pending rows are minted by + * `projectTodayIntakesAndRecompute`; this is a read-only count over the same + * window, so callers that have just projected today's rows get an accurate + * total. + */ +import { prisma } from "@/lib/db"; +import { getUserTodayBounds } from "@/lib/tz/local-day"; + +/** + * Count the user's outstanding (pending) doses for today in their timezone. + * Best-effort: any failure returns 0 so a badge update never blocks a caller. + */ +export async function countOutstandingDosesToday( + userId: string, + tz: string, +): Promise { + try { + const { start, end } = getUserTodayBounds(new Date(), tz); + return await prisma.medicationIntakeEvent.count({ + where: { + userId, + scheduledFor: { gte: start, lte: end }, + takenAt: null, + skipped: false, + autoMissed: false, + }, + }); + } catch { + return 0; + } +} diff --git a/src/lib/notifications/__tests__/dispatcher.test.ts b/src/lib/notifications/__tests__/dispatcher.test.ts index e60993218..1795de825 100644 --- a/src/lib/notifications/__tests__/dispatcher.test.ts +++ b/src/lib/notifications/__tests__/dispatcher.test.ts @@ -501,3 +501,45 @@ describe("dispatchNotification — Telegram hard reject", () => { ); }); }); + +describe("dispatchNotification — reminders reach Web Push without APNs", () => { + it("a PWA-only self-hoster (WEB_PUSH channel, no APNS) still gets MEDICATION_REMINDER", async () => { + const channel = makeChannel({ type: "WEB_PUSH" }); + vi.mocked(prisma.notificationChannel.findMany).mockResolvedValueOnce([ + channel, + ] as never); + sendViaWebPushMock.mockResolvedValueOnce({ ok: true }); + + const outcome = await dispatchNotification({ + eventType: "MEDICATION_REMINDER", + userId: "u-1", + title: "Time for your dose", + message: "Ramipril 5mg", + metadata: { webPushTag: "med:med-1:2026-06-18T07:00:00.000Z" }, + }); + + expect(sendViaWebPushMock).toHaveBeenCalledTimes(1); + expect(outcome.dispatched).toBe(true); + expect(outcome.channelsSucceeded).toBe(1); + }); + + it("MEASUREMENT_REMINDER and MEDICATION_LOW_STOCK also reach Web Push", async () => { + for (const eventType of [ + "MEASUREMENT_REMINDER", + "MEDICATION_LOW_STOCK", + ] as const) { + vi.mocked(prisma.notificationChannel.findMany).mockResolvedValueOnce([ + makeChannel({ type: "WEB_PUSH" }), + ] as never); + sendViaWebPushMock.mockResolvedValueOnce({ ok: true }); + + const outcome = await dispatchNotification({ + eventType, + userId: "u-1", + title: "t", + message: "m", + }); + expect(outcome.dispatched).toBe(true); + } + }); +}); diff --git a/src/lib/notifications/__tests__/web-push-dose-clear.test.ts b/src/lib/notifications/__tests__/web-push-dose-clear.test.ts new file mode 100644 index 000000000..9f56b80d7 --- /dev/null +++ b/src/lib/notifications/__tests__/web-push-dose-clear.test.ts @@ -0,0 +1,195 @@ +/** + * v1.18.4 — PWA-only dose lifecycle over Web Push. + * + * Verifies the three pieces a self-hoster without an Apple Developer account + * relies on: + * 1. The dose-due reminder push carries a STABLE per-slot tag + badge count. + * 2. The clear-on-taken push reuses the SAME tag (so the SW closes the + * pending reminder — the equivalent of ending a Live Activity) and + * refreshes the badge. + * 3. A Web Push channel still delivers when APNs is absent. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { sendNotificationMock, findManyMock, deleteManyMock } = vi.hoisted( + () => ({ + sendNotificationMock: vi.fn(), + findManyMock: vi.fn(), + deleteManyMock: vi.fn(), + }), +); + +vi.mock("@/lib/logging/context", () => ({ + getEvent: () => ({ + addWarning: vi.fn(), + addExternalCall: vi.fn(), + setError: vi.fn(), + addMeta: vi.fn(), + }), +})); + +vi.mock("@/lib/notifications/senders/push-attempt-record", () => ({ + recordPushAttempt: vi.fn(), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + pushSubscription: { + findMany: findManyMock, + deleteMany: deleteManyMock, + }, + }, +})); + +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 { sendViaWebPush } from "../senders/web-push"; +import { dispatchMedicationIntakeWebClear } from "../web-push-clear"; +import { medicationDoseTag } from "../dose-tag"; + +const SUB = { + id: "s1", + endpoint: "https://push.example.com/x", + p256dh: "a", + auth: "b", +}; + +beforeEach(() => { + sendNotificationMock.mockReset(); + findManyMock.mockReset(); + deleteManyMock.mockReset(); + deleteManyMock.mockResolvedValue({ count: 0 }); +}); + +afterEach(() => vi.clearAllMocks()); + +describe("medicationDoseTag", () => { + it("is stable across ISO instants that differ only in milliseconds", () => { + const a = medicationDoseTag("med-1", "2026-06-18T07:00:00.000Z"); + const b = medicationDoseTag("med-1", "2026-06-18T07:00:00Z"); + expect(a).toBe(b); + expect(a).toBe("med:med-1:2026-06-18T07:00:00.000Z"); + }); +}); + +describe("web-push reminder push — stable tag + badge", () => { + it("uses metadata.webPushTag as the notification tag and carries the badge", async () => { + findManyMock.mockResolvedValue([SUB]); + sendNotificationMock.mockResolvedValue(undefined); + + const tag = medicationDoseTag("med-1", "2026-06-18T07:00:00.000Z"); + const res = await sendViaWebPush("user-1", { + eventType: "MEDICATION_REMINDER", + userId: "user-1", + title: "Time for your dose", + message: "Ramipril 5mg", + metadata: { webPushTag: tag, badgeCount: 2, url: "/medications/med-1" }, + }); + + expect(res.ok).toBe(true); + const body = JSON.parse(sendNotificationMock.mock.calls[0][1] as string); + expect(body.tag).toBe(tag); + expect(body.badge).toBe(2); + expect(body.url).toBe("/medications/med-1"); + }); + + it("discreet mode still wins over a stable tag (no event name leak)", async () => { + findManyMock.mockResolvedValue([SUB]); + sendNotificationMock.mockResolvedValue(undefined); + + await sendViaWebPush("user-1", { + eventType: "CYCLE_PERIOD_SOON", + userId: "user-1", + title: "HealthLog reminder", + message: "HealthLog reminder", + discreet: true, + metadata: { webPushTag: "med:should-not-leak" }, + }); + + const body = JSON.parse(sendNotificationMock.mock.calls[0][1] as string); + expect(body.tag).toBe("REMINDER"); + }); +}); + +describe("clear-on-taken push", () => { + it("emits type:clear with the SAME slot tag as the reminder + badge", async () => { + findManyMock.mockResolvedValue([SUB]); + sendNotificationMock.mockResolvedValue(undefined); + + await dispatchMedicationIntakeWebClear({ + userId: "user-1", + medicationId: "med-1", + scheduledFor: "2026-06-18T07:00:00.000Z", + badgeCount: 1, + }); + + expect(sendNotificationMock).toHaveBeenCalledTimes(1); + const body = JSON.parse(sendNotificationMock.mock.calls[0][1] as string); + expect(body.type).toBe("clear"); + expect(body.tag).toBe(medicationDoseTag("med-1", "2026-06-18T07:00:00.000Z")); + expect(body.badge).toBe(1); + }); + + it("a count of 0 clears the badge (badge:0 on the wire)", async () => { + findManyMock.mockResolvedValue([SUB]); + sendNotificationMock.mockResolvedValue(undefined); + + await dispatchMedicationIntakeWebClear({ + userId: "user-1", + medicationId: "med-1", + scheduledFor: "2026-06-18T07:00:00.000Z", + badgeCount: 0, + }); + + const body = JSON.parse(sendNotificationMock.mock.calls[0][1] as string); + expect(body.badge).toBe(0); + }); + + it("no subscriptions → no-op, no throw", async () => { + findManyMock.mockResolvedValue([]); + await expect( + dispatchMedicationIntakeWebClear({ + userId: "user-1", + medicationId: "med-1", + scheduledFor: "2026-06-18T07:00:00.000Z", + }), + ).resolves.toBeUndefined(); + expect(sendNotificationMock).not.toHaveBeenCalled(); + }); + + it("reaps expired (410) subscriptions", async () => { + findManyMock.mockResolvedValue([SUB]); + sendNotificationMock.mockRejectedValue({ statusCode: 410 }); + + await dispatchMedicationIntakeWebClear({ + userId: "user-1", + medicationId: "med-1", + scheduledFor: "2026-06-18T07:00:00.000Z", + }); + + expect(deleteManyMock).toHaveBeenCalledWith({ + where: { id: { in: ["s1"] } }, + }); + }); +}); diff --git a/src/lib/notifications/dose-tag.ts b/src/lib/notifications/dose-tag.ts new file mode 100644 index 000000000..8b1af83fa --- /dev/null +++ b/src/lib/notifications/dose-tag.ts @@ -0,0 +1,24 @@ +/** + * v1.18.4 — stable Web Push notification tag for a medication dose slot. + * + * A PWA self-hoster with no Apple Developer account has no Live Activity to + * end when a dose is logged. Instead the server pushes a `type: "clear"` + * web-push that the service worker turns into `getNotifications({ tag })` + + * `notification.close()`. For that to work the REMINDER push and the CLEAR + * push must agree on a single, stable tag per dose slot. + * + * The slot is uniquely identified by `(medicationId, scheduledFor-instant)`: + * - the dose-due reminder fires with `scheduledAt` = the slot's ISO instant, + * - the clear-on-taken fires with `scheduledFor` = the same ISO instant. + * + * `scheduledFor` is normalised through `Date` so a reminder ISO carrying + * milliseconds and a clear ISO without them still collapse to one tag. + */ +export function medicationDoseTag( + medicationId: string, + scheduledForIso: string, +): string { + const t = Date.parse(scheduledForIso); + const instant = Number.isNaN(t) ? scheduledForIso : new Date(t).toISOString(); + return `med:${medicationId}:${instant}`; +} diff --git a/src/lib/notifications/senders/web-push.ts b/src/lib/notifications/senders/web-push.ts index 0d4900d47..00d08e384 100644 --- a/src/lib/notifications/senders/web-push.ts +++ b/src/lib/notifications/senders/web-push.ts @@ -78,6 +78,25 @@ export async function sendViaWebPush( }; } + // v1.18.4 — a dispatcher payload may carry a stable per-slot tag in + // `metadata.webPushTag` (e.g. a medication dose slot, see + // `medicationDoseTag`). The matching `type:"clear"` push reuses the SAME + // tag so the SW can replace/close the pending reminder when the dose is + // logged — the PWA-only equivalent of ending a Live Activity. Discreet + // mode still wins so no event name leaks even when a stable tag exists. + const stableTag = + typeof payload.metadata?.webPushTag === "string" + ? payload.metadata.webPushTag + : undefined; + // v1.18.4 — server-authoritative app-badge count. The dispatcher puts the + // user's current outstanding-dose count in `metadata.badgeCount`; the SW + // calls `navigator.setAppBadge(count)` (feature-detected, no-op elsewhere). + const badgeCount = + typeof payload.metadata?.badgeCount === "number" && + Number.isFinite(payload.metadata.badgeCount) + ? Math.max(0, Math.trunc(payload.metadata.badgeCount)) + : undefined; + const pushPayload = JSON.stringify({ title: plainPushText(payload.title, payload.eventType), body: plainPushText( @@ -88,7 +107,8 @@ export async function sendViaWebPush( // event. Web-Push payloads are VAPID-encrypted (only the SW sees this), // but the discreet contract still requires no event name on the wire — // mirror the APNs GENERIC_EVENT collapse (QA M-sec3). - tag: payload.discreet ? "REMINDER" : payload.eventType, + tag: payload.discreet ? "REMINDER" : (stableTag ?? payload.eventType), + ...(badgeCount !== undefined ? { badge: badgeCount } : {}), // v1.15.20 — opt-in deep link: a dispatcher payload may carry a // same-origin path in `metadata.url` (e.g. the Coach nudge's // `/coach`); everything else keeps the legacy "/" click diff --git a/src/lib/notifications/web-push-clear.ts b/src/lib/notifications/web-push-clear.ts new file mode 100644 index 000000000..e4a5690ba --- /dev/null +++ b/src/lib/notifications/web-push-clear.ts @@ -0,0 +1,127 @@ +/** + * v1.18.4 — Web Push "clear" for a logged medication dose. + * + * A self-hoster on the installed PWA with no Apple Developer account has no + * Live Activity / Home-Screen widget to reconcile when a dose is logged on + * another tab/device. The native path (`dispatchMedicationIntakeSync`) ends + * the Live Activity over APNs; this module is the PWA-only equivalent: it + * pushes a `{ type: "clear", tag }` payload to the user's Web Push + * subscriptions so the service worker closes the still-pending dose-due + * reminder for that exact slot (same stable tag the reminder used) and + * refreshes the app badge. + * + * Targeted + best-effort: it talks straight to the user's PushSubscription + * rows (not the full dispatcher cascade) because a clear has no Telegram / + * ntfy / email analogue — those channels can't retract a sent message. Every + * failure is swallowed + annotated; a clear miss never fails the intake + * mutation that triggered it. Expired (410/404) subscriptions are reaped, the + * same as the alert sender. + */ +import { prisma } from "@/lib/db"; +import { decrypt } from "@/lib/crypto"; +import { getVapidConfig } from "@/lib/notifications/vapid-config"; +import { getEvent } from "@/lib/logging/context"; +import { isPublicUrl } from "@/lib/validations/notifications"; +import { medicationDoseTag } from "@/lib/notifications/dose-tag"; + +export interface WebClearInput { + userId: string; + medicationId: string; + /** ISO-8601 scheduled-for instant of the affected dose slot. */ + scheduledFor: string; + /** + * Server-authoritative count of the user's still-outstanding doses after + * this mutation. The SW reflects it via `navigator.setAppBadge`. Omit to + * leave the badge untouched. + */ + badgeCount?: number; +} + +/** + * Send a `type:"clear"` web push that closes the pending reminder for one + * dose slot and updates the app badge. Best-effort, fire-and-forget safe. + */ +export async function dispatchMedicationIntakeWebClear( + input: WebClearInput, +): Promise { + try { + const config = await getVapidConfig(); + if (!config) return; // VAPID not configured — nothing to clear. + + const subscriptions = await prisma.pushSubscription.findMany({ + where: { userId: input.userId }, + }); + if (!subscriptions.length) return; + + const webpush = await import("web-push"); + webpush.setVapidDetails(config.subject, config.publicKey, config.privateKey); + + const badgeCount = + typeof input.badgeCount === "number" && Number.isFinite(input.badgeCount) + ? Math.max(0, Math.trunc(input.badgeCount)) + : undefined; + + const clearPayload = JSON.stringify({ + type: "clear", + tag: medicationDoseTag(input.medicationId, input.scheduledFor), + ...(badgeCount !== undefined ? { badge: badgeCount } : {}), + }); + + const expiredIds: string[] = []; + for (const sub of subscriptions) { + try { + if (!isPublicUrl(sub.endpoint)) { + expiredIds.push(sub.id); + continue; + } + const p256dh = decrypt(sub.p256dh); + const auth = decrypt(sub.auth); + await webpush.sendNotification( + { endpoint: sub.endpoint, keys: { p256dh, auth } }, + clearPayload, + ); + } catch (err: unknown) { + const status = (err as { statusCode?: number }).statusCode; + if (status === 410 || status === 404) expiredIds.push(sub.id); + // Soft errors on a best-effort clear are ignored — the next reminder + // tick or badge refresh self-corrects. + } + } + + if (expiredIds.length > 0) { + await prisma.pushSubscription + .deleteMany({ where: { id: { in: expiredIds } } }) + .catch(() => { + /* best-effort cleanup */ + }); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + getEvent()?.addWarning(`medication_intake_web_clear_failed: ${message}`); + } +} + +/** + * Bulk entry point — de-duplicates affected slots so one clear fires per + * distinct `(medicationId, scheduledFor)` slot a batch touched. The badge + * count is the same post-mutation outstanding total for every slot, so it is + * passed through unchanged. + */ +export async function dispatchMedicationIntakeWebClearBulk(args: { + userId: string; + slots: Array<{ medicationId: string; scheduledFor: string }>; + badgeCount?: number; +}): Promise { + const seen = new Set(); + for (const slot of args.slots) { + const key = `${slot.medicationId}|${slot.scheduledFor}`; + if (seen.has(key)) continue; + seen.add(key); + await dispatchMedicationIntakeWebClear({ + userId: args.userId, + medicationId: slot.medicationId, + scheduledFor: slot.scheduledFor, + badgeCount: args.badgeCount, + }); + } +} From f39b5541f5ba06a90bfb2c8f851f136c198c1b4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 18 Jun 2026 11:40:00 +0200 Subject: [PATCH 3/5] chore: bump to 1.18.4 --- docs/api/openapi.yaml | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index c9ca05c2e..1b3578916 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.18.3 + version: 1.18.4 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index 29042abc3..8e0636429 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.18.3", + "version": "1.18.4", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", From 9226a97912782a06c3f81ff582ee0d50033e9485 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 18 Jun 2026 11:44:15 +0200 Subject: [PATCH 4/5] docs(self-hosting): expand notifications guide for all channels Cover the full channel set self-hosters can run without an Apple account: Web Push, Telegram, ntfy, the generic webhook, and email over SMTP, plus the dispatcher cascade and graceful degradation when APNs is absent (ntfy max priority, Web Push high urgency, webhook urgency flag). Document the PWA path as the recommended default: install to the Home Screen, enable Web Push, and get reminders, the taken-clears-the-reminder behaviour, and the outstanding-dose app badge. Note the inherent limit that the lock-screen Live Activity needs a native build. Explain why native iOS push binds to the publisher's Apple team and walk through running your own signed build under your own bundle ID, team, and .p8 key, with the APNS_* env vars including APNS_CRITICAL_ENTITLEMENT. --- docs/self-hosting/getting-started.md | 2 +- docs/self-hosting/notifications.md | 187 +++++++++++++++++++++------ 2 files changed, 151 insertions(+), 38 deletions(-) diff --git a/docs/self-hosting/getting-started.md b/docs/self-hosting/getting-started.md index 5bee1db7d..996bd107d 100644 --- a/docs/self-hosting/getting-started.md +++ b/docs/self-hosting/getting-started.md @@ -147,7 +147,7 @@ NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false pnpm build | Next step | File | | --------- | ---- | | TLS + reverse-proxy configuration | `docs/self-hosting/reverse-proxy.md` | -| Push notifications — Web Push, Telegram, ntfy, APNs | `docs/self-hosting/notifications.md` | +| Push notifications — Web Push, Telegram, ntfy, webhook, email, APNs | `docs/self-hosting/notifications.md` | | Web/worker process split for horizontal scale | `docs/self-hosting/scaling.md` | | Off-host encrypted backups to S3/R2/B2 | `docs/ops/backup-restore.md` | | Encryption-key rotation procedure | `docs/ops/encryption-key-rotation.md` | diff --git a/docs/self-hosting/notifications.md b/docs/self-hosting/notifications.md index 7abf16df0..19e6878f4 100644 --- a/docs/self-hosting/notifications.md +++ b/docs/self-hosting/notifications.md @@ -3,17 +3,24 @@ HealthLog delivers reminders (medication doses, mood check-ins, personal records) and a few operational alerts through a small cascade of push channels. The single message to take away: **you do not need an Apple -Developer account or APNs to get push.** Three of the four channels are -free, work in the browser or in a messaging app you already have, and -cover every platform a self-hoster runs. APNs is only for the optional -native iOS app. +Developer account or APNs to get push.** Five of the six channels are +free, work in the browser or in an app you already have, and cover every +platform a self-hoster runs. APNs is only for the native iOS app, and +even that you can build yourself (see the last section). When an event fires, the dispatcher fans it out to every channel a user has enabled, in a fixed order — APNs first, then Telegram, then ntfy, -then Web Push as the universal fallback -(`src/lib/notifications/dispatcher.ts`). Each channel is best-effort and -independent: a user can enable one, two, or all four, and a failure on -one never blocks the others. +then the generic webhook, then email, then Web Push as the universal +fallback (`src/lib/notifications/dispatcher.ts`). Each channel is +best-effort and independent: a user can enable one or several, and a +failure on one never blocks the others. + +An APNs-less instance degrades gracefully. Nothing about push depends on +APNs being configured. Urgent events still surface everywhere it matters: +ntfy escalates them to max priority (`5`), Web Push raises urgency and +keeps the notification on screen until the user acts, and the webhook +carries the urgent flag for your own routing. APNs only adds the +Apple-platform exclusives the web cannot offer. ## TL;DR — which channel needs what @@ -22,7 +29,9 @@ one never blocks the others. | **Web Push** | A VAPID keypair (one command, or paste into the admin panel) | Free | Any modern desktop/Android browser; the installed PWA on iPhone (iOS 16.4+, after *Add to Home Screen*) | | **Telegram** | A bot token from @BotFather + each user's chat ID | Free | Anywhere the Telegram app runs | | **ntfy** | A topic on `ntfy.sh` or your own ntfy server | Free | The ntfy app (iOS/Android) or any browser | -| **APNs** | An Apple Developer account + a `.p8` push key | Apple Developer Program (paid) | The native iOS app only (TestFlight / App Store) | +| **Webhook** | A URL you own (and optionally one custom header) | Free | Anywhere — you route the POST yourself | +| **Email** | An SMTP transport you operate or rent | Free–cheap | Any mailbox | +| **APNs** | An Apple Developer account + a `.p8` push key + your own signed iOS build | Apple Developer Program (paid) | A native iOS app you build and sign | Web Push is the recommended default — it needs no third-party account, just a keypair you generate yourself once. @@ -73,9 +82,8 @@ That prints a **public key** and a **private key** (both Base64URL). ### 2. Store the keys via environment variables (alternative) -If you would rather keep -secrets out of the database, set them in `.env` / the compose -`environment:` block instead: +If you would rather keep secrets out of the database, set them in `.env` / +the compose `environment:` block instead: ```env VAPID_PUBLIC_KEY="BElx...(public key)" @@ -96,7 +104,28 @@ button, and a Test button to fire a one-off push once subscribed. The browser prompts for notification permission on first subscribe; once granted, that browser receives every enabled event. -### iPhone caveat — Add to Home Screen first +## The PWA path — install to the Home Screen + +Installing HealthLog as a PWA and enabling Web Push is the recommended +default for phones. It needs nothing from Apple and gives you, in +v1.18.4: + +- **Reminders.** Medication doses, mood check-ins, and any other event + the user has enabled, delivered to the installed app even when it is + closed. +- **Taken → clears the reminder.** When you log a dose, the server sends + a silent `type:"clear"` push that closes the still-pending dose-due + reminder for that slot (matched on its stable tag) without showing a + new notification (`public/sw.js`). This is the PWA equivalent of ending + an iOS Live Activity. +- **An app badge for outstanding doses.** The icon on the Home Screen + carries a server-authoritative count of doses still due; logging a dose + decrements it, and a count of zero clears the badge. The count is + computed on the server and reflected by the service worker via the + Badging API (`navigator.setAppBadge`), feature-detected — engines + without it (most desktop Firefox, older Safari) silently no-op. + +### iPhone — Add to Home Screen first Safari on iPhone supports Web Push only from **iOS 16.4 or newer**, and only for a site that has been **installed to the Home Screen as a PWA**. @@ -112,6 +141,14 @@ A regular Safari tab cannot subscribe. So on iPhone: Android and desktop browsers have no such step — Subscribe and grant the permission, and push works. +### The inherent limit + +The installed PWA gets reminders, the taken-clears behaviour, and the app +badge — but it **cannot** show a native iOS lock-screen Live Activity +widget. That single feature rides Apple Push Notification service and is +only available through a native iOS build with APNs. Reminders themselves +arrive on both paths; only the Live Activity is APNs-exclusive. + ## Telegram Telegram delivers reminders as bot messages, with inline action buttons @@ -146,38 +183,112 @@ HealthLog publishes to that topic. If your ntfy server requires auth, an access token can be attached; the server URL is validated as a public host and dialled through the egress -guard (`src/lib/notifications/senders/ntfy.ts`). +guard (`src/lib/notifications/senders/ntfy.ts`). Urgent events are sent at +ntfy's top priority (`5` / max) so they bypass batching and surface on the +lock screen. -## APNs — native iOS app only +## Webhook -Apple Push Notification service is used by **only** the native SwiftUI -iOS app (TestFlight / App Store build). PWA and browser users never touch -it. Setting it up requires a paid **Apple Developer Program** membership. +The generic webhook POSTs every enabled event as JSON to a URL you own — +wire it into your own automation (Home Assistant, n8n, a Discord/Slack +relay, whatever routes the payload). You supply a public URL and, +optionally, one custom header (e.g. an auth token). -You do **not** need APNs to give iPhone users push: the installed PWA -gets every reminder over Web Push (see the iPhone caveat above). APNs adds -one Apple-platform exclusive the PWA cannot offer — the lock-screen Live -Activity — plus the tighter native delivery path. Reminders themselves -arrive on both. +1. In HealthLog open `/settings/notifications`, fill the Webhook card's + **URL** and, if your endpoint expects one, a single **header name** + + **header value**. +2. Save. A Test button fires a one-off POST so you can confirm routing. -If you do run the native app and want server-driven push, set the APNs -manifest (`src/lib/notifications/senders/apns.ts`) — three identifiers -plus exactly one key source: +The URL is validated as a public host and dialled through +`safeFetch({ requirePublicHost: true })` — the SSRF guard blocks private +and loopback ranges because the target is user-supplied +(`src/lib/notifications/senders/webhook.ts`). The webhook carries the +event's urgency flag so you can route urgent events differently. + +## Email (SMTP) + +The email channel sends reminders to a mailbox over an SMTP transport you +operate or rent. It is configured **instance-wide by the operator** (one +transport serves every user); each user then enables the channel and sets +their destination address from `/settings/notifications`. + +Set the SMTP transport in `.env` / the compose `environment:` block: + +```env +SMTP_HOST="smtp.example.com" +SMTP_PORT="587" +SMTP_FROM="HealthLog " +SMTP_USER="" # optional — omit for an unauthenticated relay +SMTP_PASS="" # optional +SMTP_SECURE="false" # true = implicit TLS (port 465); false = STARTTLS +``` + +`SMTP_HOST` + `SMTP_PORT` + `SMTP_FROM` together enable the channel; +`SMTP_USER` / `SMTP_PASS` are optional (omit them for an unauthenticated +relay). `SMTP_SECURE=true` uses implicit TLS on port 465; leave it `false` +for STARTTLS on 587. As with every var in `docker-compose.yml`, these must +be on the compose `environment:` whitelist to reach the container. + +## APNs — native iOS, and why it needs your own Apple account + +Apple Push Notification service is used by **only** a native SwiftUI iOS +app. PWA, browser, Telegram, ntfy, webhook, and email users never touch +it. It is the one channel a self-hoster cannot simply switch on, and the +reason is worth stating plainly. + +### Why your server can't push to the published app + +APNs binds a push to a specific **Apple Developer team + app bundle ID + +signing key**. The HealthLog iOS app published on TestFlight / the App +Store is signed under **the maintainer's Apple team and bundle ID**. Your +self-hosted server has neither that team's `.p8` key nor the authority to +mint one, so it **cannot** send APNs pushes to the published app. There is +no way around this — it is how Apple's trust model works, by design. + +To get native iOS push **and** the lock-screen Live Activity widget on +**your own** infrastructure, you have to publish your own copy of the app +under your own Apple identity. Everything below is that path. + +### Step by step — your own native iOS build + +1. **Get an Apple Developer account** (~$99/yr). Required to sign an iOS + app for a real device and to create a push key. +2. **Build and sign your own copy of the iOS app.** The client is open in + a separate repository (`healthlog-iOS`). Open it in Xcode and set your + **own bundle identifier** and **your team** as the signing team, then + build to your device (or distribute via your own TestFlight). +3. **Create your own APNs `.p8` auth key** in the Apple Developer Portal, + under **Keys → Create a Key → Apple Push Notifications service (APNs)**. + Note the **Key ID**, and your **Team ID** (top-right of the portal). + Scope the key **Sandbox & Production** (see the gotcha below). +4. **Point your bundle ID at your server.** Build the iOS app against your + instance so the device registers its APNs token with your server. +5. **Set the APNs manifest on the server.** Add the env vars below to + `.env` / the compose `environment:` block. + +The APNs env vars (`src/lib/notifications/senders/apns.ts`): | Variable | Purpose | | --- | --- | | `APNS_KEY_ID` | The `.p8` key's Key ID | | `APNS_TEAM_ID` | Your Apple Developer Team ID | -| `APNS_BUNDLE_ID` | The app's bundle identifier | +| `APNS_BUNDLE_ID` | Your app's bundle identifier | | `APNS_KEY_B64` | The `.p8` PEM, base64-encoded (recommended key source) | | `APNS_KEY` | OR the raw PEM body inline | | `APNS_KEY_FILE` | OR a path to the `.p8` file | +| `APNS_CRITICAL_ENTITLEMENT` | Optional — set `true` only if Apple grants your app the Critical Alerts entitlement | -APNs is **all-or-none**: either set the three IDs plus one key source, or -leave the whole block unset. A partial config is rejected so the channel -never silently half-works (`scripts/check-env.ts`). When more than one key -source is present, the precedence is `APNS_KEY_B64` > `APNS_KEY` > -`APNS_KEY_FILE`. +APNs is **all-or-none**: either set the three IDs plus exactly one key +source, or leave the whole block unset. A partial config is rejected so +the channel never silently half-works (`scripts/check-env.ts`). When more +than one key source is present, the precedence is `APNS_KEY_B64` > +`APNS_KEY` > `APNS_KEY_FILE`. + +`APNS_CRITICAL_ENTITLEMENT=true` is honoured only if Apple has actually +granted your app the Critical Alerts entitlement (a separate request to +Apple). Without that entitlement, leave it unset — the server keeps urgent +events at normal priority rather than minting a `critical` payload your +app cannot send (`src/lib/notifications/types.ts`). Two gotchas worth knowing before you blame a stale token: @@ -190,9 +301,9 @@ Two gotchas worth knowing before you blame a stale token: `BadEnvironmentKeyInToken`. Confirm the key shows **Sandbox & Production**; a wrongly scoped key cannot be reconfigured — re-issue it. -> **`clientManaged` is iOS-app-only.** The native iOS app can opt to run -> its own local medication reminders and ask the server to stop sending -> the duplicate `MEDICATION_REMINDER` push (the `clientManaged` flag on +> **`clientManaged` is iOS-app-only.** A native iOS app can opt to run its +> own local medication reminders and ask the server to stop sending the +> duplicate `MEDICATION_REMINDER` push (the `clientManaged` flag on > `PATCH /api/auth/me/notification-prefs`). This is a native-app contract: > a web/PWA-only self-hoster never needs it and there is no toggle for it > in the web UI. If you only run the PWA, ignore it entirely — your @@ -203,9 +314,11 @@ Two gotchas worth knowing before you blame a stale token: | Your setup | Recommended channel | | --- | --- | | PWA on Android or desktop | **Web Push** — generate a VAPID keypair, subscribe, done. | -| PWA on an iPhone | **Web Push**, but install the PWA via *Add to Home Screen* first (iOS 16.4+). Reminders work; the lock-screen Live Activity needs the native app. | -| You want the native iOS app's full experience | **APNs** (paid Apple Developer account) *plus* Web Push as a fallback for any non-iOS device. | +| PWA on an iPhone | **Web Push**, but install the PWA via *Add to Home Screen* first (iOS 16.4+). Reminders, taken-clears, and the app badge all work; the lock-screen Live Activity needs a native build. | +| You want the native iOS lock-screen Live Activity | Build and sign **your own** iOS app under your own Apple account and wire up **APNs** (paid), with Web Push as a fallback for any non-iOS device. | | Headless / no browser, or you live in chat | **Telegram** or **ntfy** — neither needs a browser, both are free. ntfy is the most self-host-native; Telegram adds inline reminder action buttons. | +| You already run an automation hub | **Webhook** — POST every event to your own URL and route it however you like. | +| You want reminders in your inbox | **Email** — set the SMTP transport once as the operator; users add their address. | Mixing channels is fine and common — e.g. Web Push on the laptop and ntfy on the phone. Each user configures their own set from From 37a361197a45b4cdf7ad9398943776df379bb4e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 18 Jun 2026 11:46:19 +0200 Subject: [PATCH 5/5] chore(release): v1.18.4 --- .env.production.example | 1 + CHANGELOG.md | 21 +++++++++++++++++++++ docker-compose.yml | 3 +++ 3 files changed, 25 insertions(+) diff --git a/.env.production.example b/.env.production.example index 3b94d3963..f2a0396d5 100644 --- a/.env.production.example +++ b/.env.production.example @@ -104,6 +104,7 @@ API_TOKEN_HMAC_KEY="" # APNS_KEY_B64="" # PEM body base64-encoded -- recommended (escapes newlines) # APNS_KEY="" # OR APNS_KEY (raw PEM) OR APNS_KEY_FILE -- one satisfies the manifest # APNS_KEY_FILE="" +# APNS_CRITICAL_ENTITLEMENT="" # "true" only if your build has Apple's Critical Alerts entitlement; else urgent = time-sensitive # ----------------------------------------------------------------------------- diff --git a/CHANGELOG.md b/CHANGELOG.md index e7317c1f8..e89e0740b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,27 @@ ## [Unreleased] +## [1.18.4] — 2026-06-18 — urgent alerts that reach you on whatever you've set up, and a stronger free notification path + +This release makes notifications work well for self-hosters who don't run Apple push. Genuinely urgent health signals — a sustained low-oxygen or fever pattern flagged by the condition journal — now go out at the strongest level each notification channel you've configured supports, and reach you even with no Apple Developer account: max-priority ntfy, high-urgency Web Push, a webhook, or a time-sensitive iOS alert if you do run push. The installed web app gains real reminder handling: a dose reminder clears itself once you mark it taken, and an app badge counts what's still due. No breaking changes; no migration. + +### Added + +- **Urgent alerts, on every channel you have.** A condition red-flag (sustained low SpO₂ or fever — "seek care", never reassurance) now sends an urgent alert that escalates per channel: time-sensitive on iOS (critical only if you've been granted and enabled Apple's entitlement), max priority on ntfy, high urgency on Web Push, flagged on the webhook. No Apple account required — an instance with only ntfy or Web Push still gets the urgent treatment. De-duplicated per condition. +- **A real reminder experience in the installed web app.** Dose, preventive-care and low-stock reminders reach the home-screen web app over Web Push; marking a dose taken clears its reminder (no leftover notification), and an app badge shows how many doses are still due today — the closest the no-Apple-account path gets to the native lock-screen behaviour. + +### Changed + +- **Notifications degrade gracefully without Apple push.** The channel cascade and every urgent path are now explicit that an instance without APNs still delivers through its other channels at their top tier. + +### Docs + +- A self-hosting notifications guide: what each free channel (Web Push, Telegram, ntfy, webhook, email) can and can't do, the recommended PWA + Web Push setup, and a step-by-step path for running your own signed iOS build with your own Apple Developer account and APNs key for native push and the lock-screen widget. + +### Operator note + +- UI/API-only; no migration. Optional new env var `APNS_CRITICAL_ENTITLEMENT` (default off) — set it only if your own iOS build carries Apple's Critical Alerts entitlement; otherwise urgent iOS alerts use the time-sensitive level, which needs no Apple approval. + ## [1.18.3] — 2026-06-17 — the condition journal you can scroll back through, and a clearer view of what's tracked A small follow-up that finishes a few rough edges from the previous releases. The condition journal's day-by-day timeline now scrolls through the whole illness, not just today; deleting a condition can be undone; the condition journal is on by default like every other area; and medication inventory that was never set now reads as "unknown" instead of a misleading zero. No breaking changes; no migration. diff --git a/docker-compose.yml b/docker-compose.yml index ac73397f7..f3c518c9c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -136,6 +136,9 @@ services: APNS_KEY_ID: "${APNS_KEY_ID:-}" APNS_TEAM_ID: "${APNS_TEAM_ID:-}" APNS_BUNDLE_ID: "${APNS_BUNDLE_ID:-}" + # Set "true" only if your own iOS build carries Apple's Critical Alerts + # entitlement; otherwise urgent alerts use the time-sensitive level. + APNS_CRITICAL_ENTITLEMENT: "${APNS_CRITICAL_ENTITLEMENT:-}" depends_on: db: condition: service_healthy