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] 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, + }); + } +}