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 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/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 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", 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/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/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/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/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/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/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/__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..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 @@ -99,8 +119,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 +159,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"; } 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, + }); + } +}