From 31f296a03e5c769a98e611a042f61c625fc37287 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 20:39:07 +0100 Subject: [PATCH] fix(push): stop the invisible push sent for every recorded dose Each taken or skipped dose sent a `clear` push to every subscription of the account. The service worker closed the pending reminder and showed nothing. A subscription made with userVisibleOnly may not receive pushes that show no notification: Safari revokes it after a few, the next reminder meets a 410 and the channel is switched off, with nothing in the app saying why. The clear push, its three call sites and the badge count only it fed are removed. A reminder already on screen now stays until it is tapped, dismissed or replaced by the next reminder for the same dose. The sender's badge field went too: nothing else ever set it. The parity snapshots lose the two recorded effects. --- .../baselines/source-function-lines.json | 4 +- .../baselines/version-stamp-comments.json | 8 +- .../__tests__/source-provenance.test.ts | 58 ++-- src/lib/medications/intake-bulk/ingest.ts | 19 -- .../record-intake/record-intake.ts | 26 +- .../update-intake-status.ts | 32 +- .../__tests__/web-push-dose-clear.test.ts | 298 ------------------ .../__tests__/web-push-egress-pin.test.ts | 17 +- src/lib/notifications/senders/web-push.ts | 19 +- src/lib/notifications/web-push-clear.ts | 157 --------- .../medication-intake-bulk.01-mixed.json.snap | 72 +---- ...ation-intake-bulk.02-mixed-again.json.snap | 70 +--- ...ation-intake-bulk.03a-background.json.snap | 22 -- ...ication-intake-bulk.03b-backfill.json.snap | 22 -- ...edication-intake-bulk.05a-native.json.snap | 22 -- ...n-intake-status.11-take-in-place.json.snap | 18 -- ...-intake-status.12-take-slot-move.json.snap | 18 -- ...medication-intake-status.13a-pin.json.snap | 18 -- ...medication-intake-status.14-skip.json.snap | 18 -- ...tatus.17b-record-only-correction.json.snap | 18 -- ...edication-intake-status.18b-site.json.snap | 18 -- ...on-intake-status.20a-writer-take.json.snap | 18 -- ...edication-intake.01-take-in-band.json.snap | 18 -- ...ation-intake.02a-take-standalone.json.snap | 18 -- ...ation-intake.03a-skip-named-slot.json.snap | 18 -- ...ication-intake.03b-skip-slotless.json.snap | 18 -- ...intake.03d-owner-skip-standalone.json.snap | 18 -- .../medication-intake.04a-pin.json.snap | 18 -- ...ication-intake.05-named-backfill.json.snap | 18 -- .../medication-intake.06a-body-key.json.snap | 18 -- ...medication-intake.07a-header-key.json.snap | 18 -- .../medication-intake.08-native.json.snap | 18 -- ...ation-intake.09b-owner-skip-flip.json.snap | 18 -- ...edication-intake.10a-writer-take.json.snap | 18 -- ...ication-intake.11b-site-and-dose.json.snap | 18 -- .../medication-intake.12-one-shot.json.snap | 18 -- .../medication-intake-bulk.parity.test.ts | 30 +- .../medication-intake-status.parity.test.ts | 29 +- .../parity/medication-intake.parity.test.ts | 29 +- 39 files changed, 79 insertions(+), 1233 deletions(-) delete mode 100644 src/lib/notifications/__tests__/web-push-dose-clear.test.ts delete mode 100644 src/lib/notifications/web-push-clear.ts diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index dd83b4bdc..5dea39622 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 344, - "total": 111520, + "total": 111507, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275, @@ -331,7 +331,7 @@ "lib/multipart/stream-to-disk.ts :: streamMultipartToDisk": 272, "lib/notifications/dispatcher.ts :: dispatchNotification": 378, "lib/notifications/senders/apns.ts :: sendViaApns": 299, - "lib/notifications/senders/web-push.ts :: sendViaWebPush": 268, + "lib/notifications/senders/web-push.ts :: sendViaWebPush": 255, "lib/personal-records/pr-detection-worker.ts :: detectPersonalRecordsForUser": 239, "lib/personal-records/pr-direction.ts :: getPRDirection": 208, "lib/record-activity/activity-verb.ts :: recordActivityVerbLine": 261, diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index fc64b4a07..58eb7415a 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", - "count": 2892, - "total": 9220, + "count": 2890, + "total": 9216, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -2590,7 +2590,6 @@ "lib/notifications/__tests__/dispatch-localised-cache.test.ts": 1, "lib/notifications/__tests__/dispatcher.test.ts": 1, "lib/notifications/__tests__/medication-intake-sync.test.ts": 1, - "lib/notifications/__tests__/web-push-dose-clear.test.ts": 1, "lib/notifications/delegated-intake.ts": 2, "lib/notifications/dispatch-localised.ts": 3, "lib/notifications/dispatcher.ts": 3, @@ -2604,10 +2603,9 @@ "lib/notifications/senders/push-attempt-record.ts": 1, "lib/notifications/senders/telegram-keyboards.ts": 1, "lib/notifications/senders/telegram.ts": 4, - "lib/notifications/senders/web-push.ts": 5, + "lib/notifications/senders/web-push.ts": 3, "lib/notifications/senders/webhook.ts": 1, "lib/notifications/types.ts": 21, - "lib/notifications/web-push-clear.ts": 1, "lib/nutrients/__tests__/catalog.test.ts": 1, "lib/nutrients/catalog.ts": 8, "lib/oauth/signed-state.ts": 1, diff --git a/src/app/api/medications/[id]/intake/__tests__/source-provenance.test.ts b/src/app/api/medications/[id]/intake/__tests__/source-provenance.test.ts index 2a24675ad..123500767 100644 --- a/src/app/api/medications/[id]/intake/__tests__/source-provenance.test.ts +++ b/src/app/api/medications/[id]/intake/__tests__/source-provenance.test.ts @@ -45,10 +45,15 @@ vi.mock("@/lib/db", () => ({ update: vi.fn(), }, auditLog: { create: vi.fn() }, + pushSubscription: { findMany: vi.fn() }, $transaction: vi.fn(), }, })); +vi.mock("@/lib/notifications/vapid-config", () => ({ + getVapidConfig: vi.fn(), +})); + vi.mock("@/lib/idempotency", () => ({ withIdempotency: (fn: (...args: Args) => Promise) => @@ -94,15 +99,6 @@ vi.mock("@/lib/notifications/medication-intake-sync", () => ({ queueMedicationIntakeSync: vi.fn(), })); -// The route dispatches the PWA clear fire-and-forget after a recorded dose; -// both halves are mocked so the test asserts the dispatch, not web-push. -vi.mock("@/lib/notifications/web-push-clear", () => ({ - dispatchMedicationIntakeWebClear: vi.fn().mockResolvedValue(undefined), -})); -vi.mock("@/lib/medications/outstanding-doses", () => ({ - countOutstandingDosesToday: vi.fn(), -})); - vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); vi.mock("@/lib/auth/bearer", () => ({ resolveBearerToken: vi.fn() })); vi.mock("@/lib/auth/audit", () => ({ @@ -127,8 +123,7 @@ import { POST } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { resolveBearerToken } from "@/lib/auth/bearer"; -import { dispatchMedicationIntakeWebClear } from "@/lib/notifications/web-push-clear"; -import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses"; +import { getVapidConfig } from "@/lib/notifications/vapid-config"; const USER = { id: "user-1", @@ -210,30 +205,33 @@ describe("POST intake — transport-derived source (iOS #64)", () => { expect(createdSource()).toBe("API"); }); - it("closes the pending dose-due web reminder after a recorded intake", async () => { - // Before this route dispatched the clear, an iOS offline drain (which - // replays free intakes through exactly this endpoint) left the web - // dose-due reminder and the app badge standing after the dose landed. + it("sends no Web Push of its own for a recorded dose", async () => { + // A push that shows no notification gets the subscription revoked by + // Safari after a few. The reminder already on screen stays until it is + // tapped or replaced by the next one for the same slot. vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(countOutstandingDosesToday).mockResolvedValue(3); + vi.mocked(getVapidConfig).mockResolvedValue({ + subject: "mailto:ops@example.com", + publicKey: "pub", + privateKey: "priv", + }); + vi.mocked(prisma.pushSubscription.findMany).mockResolvedValue([ + { + id: "s1", + userId: USER.id, + endpoint: "https://push.example.com/x", + p256dh: "a", + auth: "b", + userAgent: null, + createdAt: new Date(0), + }, + ]); const res = await POST(postReq({}), ROUTE_PARAMS); expect(res.status).toBe(201); - // Fire-and-forget: the dispatch runs detached from the response. - await expect - .poll( - () => vi.mocked(dispatchMedicationIntakeWebClear).mock.calls.length, - { timeout: 2_000, interval: 25 }, - ) - .toBe(1); - expect( - vi.mocked(dispatchMedicationIntakeWebClear).mock.calls[0][0], - ).toMatchObject({ - userId: "user-1", - medicationId: "med-1", - badgeCount: 3, - }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(prisma.pushSubscription.findMany).not.toHaveBeenCalled(); }); it("ignores a client-supplied `source` in the body (no mass assignment)", async () => { diff --git a/src/lib/medications/intake-bulk/ingest.ts b/src/lib/medications/intake-bulk/ingest.ts index 30c2d4e27..ac66d2019 100644 --- a/src/lib/medications/intake-bulk/ingest.ts +++ b/src/lib/medications/intake-bulk/ingest.ts @@ -23,8 +23,6 @@ import { refreshDoseDay } from "@/lib/medications/dose-day-refresh"; import { unstableExternalIdMeta } from "@/lib/validations/external-id"; import type { InjectionSiteValue } from "@/lib/validations/medication"; import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; -import { dispatchMedicationIntakeWebClearBulk } from "@/lib/notifications/web-push-clear"; -import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses"; import { createBatchState, type BatchState, @@ -198,23 +196,6 @@ async function settleLandedRows(input: BatchInput, state: BatchState) { userId: user.id, originDeviceToken: input.originDeviceToken, }); - - // 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(state.syncSlots.values()); - void (async () => { - const badgeCount = await countOutstandingDosesToday( - user.id, - user.timezone, - ); - await dispatchMedicationIntakeWebClearBulk({ - userId: user.id, - slots: affectedSlots, - badgeCount, - }); - })(); } } diff --git a/src/lib/medications/record-intake/record-intake.ts b/src/lib/medications/record-intake/record-intake.ts index b87562e93..c5a7679d7 100644 --- a/src/lib/medications/record-intake/record-intake.ts +++ b/src/lib/medications/record-intake/record-intake.ts @@ -23,8 +23,6 @@ import { import { reconcileOneShotState } from "@/lib/medications/lifecycle"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; -import { dispatchMedicationIntakeWebClear } from "@/lib/notifications/web-push-clear"; -import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses"; import { notifyDelegatedIntake } from "@/lib/notifications/delegated-intake"; import { refreshDoseSlot } from "@/lib/medications/dose-day-refresh"; import { @@ -203,11 +201,7 @@ async function recordAfterWrite( } /** The tails that never affect the response. */ -function startTails( - ctx: RecordIntakeContext, - skipped: boolean, - event: WrittenIntake["event"], -): void { +function startTails(ctx: RecordIntakeContext, skipped: boolean): void { const { user, medicationId: id } = ctx; // (#22) — silent cross-device intake sync: wake the user's OTHER iOS // devices so a running Live Activity / widget reconciles this dose. @@ -217,22 +211,6 @@ function startTails( originDeviceToken: ctx.originDeviceToken, }); - // PWA counterpart of the sync wake above: a dose resolved here (taken or - // skipped) closes the still-pending dose-due Web Push reminder for the - // slot and refreshes the app badge. The other intake routes have carried - // this since v1.18.4; this route is the replay target of the iOS offline - // queue, so a drained dose must clear the web reminder the same way. - // Best-effort, fire-and-forget — the canonical row is already persisted. - void (async () => { - const badgeCount = await countOutstandingDosesToday(user.id, user.timezone); - await dispatchMedicationIntakeWebClear({ - userId: user.id, - medicationId: id, - scheduledFor: event.scheduledFor.toISOString(), - badgeCount, - }); - })(); - // "Somebody else marked your dose". This route has no snooze arm, // so the state is whichever of the two markings the payload carried. The // helper refuses on self, so a person marking their own dose is unaffected. @@ -304,7 +282,7 @@ export async function recordIntake( await settleInventory(ctx, skipped, written); await recordAfterWrite(ctx, skipped, written.event); - startTails(ctx, skipped, written.event); + startTails(ctx, skipped); return { kind: "recorded", event: written.event }; } diff --git a/src/lib/medications/update-intake-status/update-intake-status.ts b/src/lib/medications/update-intake-status/update-intake-status.ts index 4263ae952..0c841df5d 100644 --- a/src/lib/medications/update-intake-status/update-intake-status.ts +++ b/src/lib/medications/update-intake-status/update-intake-status.ts @@ -19,9 +19,7 @@ import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { refreshDoseSlot } from "@/lib/medications/dose-day-refresh"; import { restoreForIntake } from "@/lib/medications/inventory/consumption"; import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; -import { dispatchMedicationIntakeWebClear } from "@/lib/notifications/web-push-clear"; import { notifyDelegatedIntake } from "@/lib/notifications/delegated-intake"; -import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses"; import { refuseDelegatedChange, refusesUntrackedIntake, @@ -95,7 +93,7 @@ async function recordAfterUpdate( data: IntakeStatusUpdate, updated: UpdatedRow | null, userTzForHook: string, -): Promise { +): Promise { const { user, existing } = ctx; const { intakeId, status } = data; await auditLog("medications.intake.update", { @@ -135,15 +133,12 @@ async function recordAfterUpdate( tz: userTzForHook, }); } - return movedTo; } /** The tails that never affect the response. */ function startTails( ctx: StatusContext, status: IntakeStatusUpdate["status"], - movedTo: Date | undefined, - userTzForHook: string, ): void { const { user, existing } = ctx; // (#22) — silent cross-device intake sync. Wake the user's OTHER @@ -157,27 +152,6 @@ function startTails( originDeviceToken: ctx.originDeviceToken, }); - // 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, - }); - })(); - } - // "Somebody else marked your dose". The helper refuses on self and // on a snooze, resolves both names itself, and never throws. // @@ -243,8 +217,8 @@ export async function updateIntakeStatus( updated = await applySkipOrSnooze(ctx, data); } - const movedTo = await recordAfterUpdate(ctx, data, updated, userTzForHook); - startTails(ctx, status, movedTo, userTzForHook); + await recordAfterUpdate(ctx, data, updated, userTzForHook); + startTails(ctx, status); return { kind: "updated", row: updated }; } diff --git a/src/lib/notifications/__tests__/web-push-dose-clear.test.ts b/src/lib/notifications/__tests__/web-push-dose-clear.test.ts deleted file mode 100644 index cbdb11ffe..000000000 --- a/src/lib/notifications/__tests__/web-push-dose-clear.test.ts +++ /dev/null @@ -1,298 +0,0 @@ -/** - * 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"; - -// safeFetch's requirePublicHost path drives undici's OWN fetch (version-locked -// with its dispatcher). Delegate it to the global fetch these tests stub so the -// existing interception still applies. -vi.mock("undici", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - fetch: (input: unknown, init?: unknown) => - (globalThis.fetch as unknown as (i: unknown, n?: unknown) => unknown)( - input, - init, - ), - }; -}); - -const { generateRequestDetailsMock, findManyMock, deleteManyMock } = vi.hoisted( - () => ({ - generateRequestDetailsMock: 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(), - recordPushAttemptForPayload: 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(), - // The sender signs + encrypts here and dials through `safeFetch`; the - // payload assertions below read the same second argument as before. - generateRequestDetails: (...args: unknown[]) => { - generateRequestDetailsMock(...args); - return { - endpoint: "https://push.example.com/x", - method: "POST", - headers: {}, - body: null, - }; - }, -})); - -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(() => { - generateRequestDetailsMock.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]); - vi.spyOn(global, "fetch").mockResolvedValue( - new Response(null, { status: 201 }), - ); - - 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( - generateRequestDetailsMock.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]); - vi.spyOn(global, "fetch").mockResolvedValue( - new Response(null, { status: 201 }), - ); - - 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( - generateRequestDetailsMock.mock.calls[0][1] as string, - ); - expect(body.tag).toBe("REMINDER"); - }); - - it("uses an ordinary landing route without actions for a managed Guardian", async () => { - findManyMock.mockResolvedValue([SUB]); - vi.spyOn(global, "fetch").mockResolvedValue( - new Response(null, { status: 201 }), - ); - - await sendViaWebPush("guardian-1", { - eventType: "MEDICATION_REMINDER", - userId: "record-1", - recordUserId: "record-1", - recipientUserId: "guardian-1", - title: "Time for your dose", - message: "Ramipril 5mg", - urgent: true, - metadata: { url: "/medications/med-1" }, - }); - - const body = JSON.parse( - generateRequestDetailsMock.mock.calls[0][1] as string, - ); - expect(body.url).toBe("/"); - expect(body.actions).toBeUndefined(); - }); -}); - -describe("web-push tap target", () => { - async function sentBody(payload: Parameters[1]) { - findManyMock.mockResolvedValue([SUB]); - vi.spyOn(global, "fetch").mockResolvedValue( - new Response(null, { status: 201 }), - ); - await sendViaWebPush("user-1", payload); - return JSON.parse(generateRequestDetailsMock.mock.calls[0][1] as string); - } - - it("carries the dispatcher's resolved tap path as data url", async () => { - const body = await sentBody({ - eventType: "MOOD_REMINDER", - userId: "user-1", - title: "Log your mood", - message: "How are you feeling today?", - tap: { path: "/mood?add=1", url: null, label: "Open" }, - }); - expect(body.url).toBe("/mood?add=1"); - }); - - it("refuses a protocol-relative metadata url when no tap was resolved", async () => { - const body = await sentBody({ - eventType: "SYSTEM_ALERT", - userId: "user-1", - title: "t", - message: "m", - metadata: { url: "//evil.example/x" }, - }); - expect(body.url).toBe("/"); - }); -}); - -describe("clear-on-taken push", () => { - it("emits type:clear with the SAME slot tag as the reminder + badge", async () => { - findManyMock.mockResolvedValue([SUB]); - vi.spyOn(global, "fetch").mockResolvedValue( - new Response(null, { status: 201 }), - ); - - await dispatchMedicationIntakeWebClear({ - userId: "user-1", - medicationId: "med-1", - scheduledFor: "2026-06-18T07:00:00.000Z", - badgeCount: 1, - }); - - expect(generateRequestDetailsMock).toHaveBeenCalledTimes(1); - const body = JSON.parse( - generateRequestDetailsMock.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]); - vi.spyOn(global, "fetch").mockResolvedValue( - new Response(null, { status: 201 }), - ); - - await dispatchMedicationIntakeWebClear({ - userId: "user-1", - medicationId: "med-1", - scheduledFor: "2026-06-18T07:00:00.000Z", - badgeCount: 0, - }); - - const body = JSON.parse( - generateRequestDetailsMock.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(generateRequestDetailsMock).not.toHaveBeenCalled(); - }); - - it("reaps expired (410) subscriptions", async () => { - findManyMock.mockResolvedValue([SUB]); - vi.spyOn(global, "fetch").mockResolvedValue( - new Response(null, { status: 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/__tests__/web-push-egress-pin.test.ts b/src/lib/notifications/__tests__/web-push-egress-pin.test.ts index 488d4574b..5154ff6b6 100644 --- a/src/lib/notifications/__tests__/web-push-egress-pin.test.ts +++ b/src/lib/notifications/__tests__/web-push-egress-pin.test.ts @@ -11,8 +11,8 @@ * * `web-push`'s `sendNotification` dials with its own internal `https.request`, * which bypasses `safeFetch` entirely and therefore bypasses the pinned - * dispatcher. Both senders now sign and encrypt via `generateRequestDetails` - * — identical VAPID + payload handling — and dial through `safeFetch` with + * dispatcher. The sender signs and encrypts via `generateRequestDetails` + * — identical VAPID + payload handling — and dials through `safeFetch` with * `requirePublicHost: true`. */ import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -76,7 +76,6 @@ vi.mock("web-push", () => ({ })); import { sendViaWebPush } from "../senders/web-push"; -import { dispatchMedicationIntakeWebClear } from "../web-push-clear"; const SUB = { id: "s1", @@ -134,15 +133,3 @@ describe("sendViaWebPush egress", () => { }); }); }); - -describe("dispatchMedicationIntakeWebClear egress", () => { - it("dials through safeFetch with requirePublicHost too", async () => { - await dispatchMedicationIntakeWebClear({ - userId: "user-1", - medicationId: "med-1", - scheduledFor: "2026-06-18T07:00:00.000Z", - }); - expect(safeFetchMock).toHaveBeenCalledTimes(1); - expect(optsOfFirstCall()?.requirePublicHost).toBe(true); - }); -}); diff --git a/src/lib/notifications/senders/web-push.ts b/src/lib/notifications/senders/web-push.ts index 42808fcc4..18f93034b 100644 --- a/src/lib/notifications/senders/web-push.ts +++ b/src/lib/notifications/senders/web-push.ts @@ -82,24 +82,14 @@ 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. + // A payload may carry a stable per-slot tag in `metadata.webPushTag` + // (a medication dose slot, see `medicationDoseTag`), so a repeated + // reminder for the same dose replaces the one on screen instead of + // stacking. Discreet mode still wins so no event name leaks. 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), @@ -109,7 +99,6 @@ export async function sendViaWebPush( // 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" : (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 deleted file mode 100644 index 5936cc8ce..000000000 --- a/src/lib/notifications/web-push-clear.ts +++ /dev/null @@ -1,157 +0,0 @@ -/** - * 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 (`queueMedicationIntakeSync`) 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 { safeFetch } from "@/lib/safe-fetch"; -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); - // Same transport swap as the main sender: sign + encrypt via - // `generateRequestDetails`, then dial through `safeFetch` with the - // pinned dispatcher so a DNS rebind between the literal - // `isPublicUrl` check above and the connect cannot reach a private - // address (issue #217). - const details = webpush.generateRequestDetails( - { endpoint: sub.endpoint, keys: { p256dh, auth } }, - clearPayload, - ) as { - endpoint: string; - method: string; - headers: Record; - body: Buffer | string | null; - }; - const clearRes = await safeFetch( - details.endpoint, - { - method: details.method, - headers: details.headers, - body: (details.body ?? undefined) as BodyInit | undefined, - }, - { requirePublicHost: true }, - ); - if (clearRes.status < 200 || clearRes.status >= 300) { - throw Object.assign( - new Error(`Web Push clear responded ${clearRes.status}`), - { statusCode: clearRes.status }, - ); - } - } 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, - }); - } -} diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.01-mixed.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.01-mixed.json.snap index 6a11c1d74..420396b2c 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.01-mixed.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.01-mixed.json.snap @@ -167,52 +167,6 @@ "originDeviceToken": null } ] - }, - { - "effect": "countOutstandingDosesToday", - "args": [ - "", - "UTC" - ] - }, - { - "effect": "dispatchMedicationIntakeWebClearBulk", - "args": [ - { - "userId": "", - "slots": [ - { - "medicationId": "", - "scheduledFor": "2026-10-15T08:00:00.000Z" - }, - { - "medicationId": "", - "scheduledFor": "2026-10-13T08:00:00.000Z" - }, - { - "medicationId": "", - "scheduledFor": "2026-10-15T20:00:00.000Z" - }, - { - "medicationId": "", - "scheduledFor": "2026-10-16T08:00:00.000Z" - }, - { - "medicationId": "", - "scheduledFor": "2026-10-15T10:00:00.000Z" - }, - { - "medicationId": "", - "scheduledFor": "2026-10-15T09:00:00.000Z" - }, - { - "medicationId": "", - "scheduledFor": "2026-10-14T09:30:00.000Z" - } - ], - "badgeCount": 2 - } - ] } ], "stored": { @@ -253,7 +207,7 @@ "updatedAt": "" }, { - "id": "", + "id": "", "userId": "", "name": "As-needed pen", "dose": "10mg", @@ -291,7 +245,7 @@ "updatedAt": "" }, { - "id": "", + "id": "", "userId": "", "name": "Record only", "dose": "10mg", @@ -326,7 +280,7 @@ "updatedAt": "" }, { - "id": "", + "id": "", "userId": "", "name": "Twice daily", "dose": "10mg", @@ -364,7 +318,7 @@ "schedules": [ { "id": "", - "medicationId": "", + "medicationId": "", "windowStart": "07:00", "windowEnd": "08:00", "label": null, @@ -384,7 +338,7 @@ }, { "id": "", - "medicationId": "", + "medicationId": "", "windowStart": "08:00", "windowEnd": "10:00", "label": null, @@ -424,7 +378,7 @@ }, { "id": "", - "medicationId": "", + "medicationId": "", "windowStart": "20:00", "windowEnd": "22:00", "label": null, @@ -450,7 +404,7 @@ { "id": "", "userId": "", - "medicationId": "", + "medicationId": "", "scheduledFor": "2026-10-13T08:00:00.000Z", "takenAt": "2026-10-13T11:00:00.000Z", "skipped": false, @@ -518,7 +472,7 @@ { "id": "", "userId": "", - "medicationId": "", + "medicationId": "", "scheduledFor": "2026-10-14T20:00:00.000Z", "takenAt": "2026-10-14T20:05:00.000Z", "skipped": false, @@ -539,7 +493,7 @@ { "id": "", "userId": "", - "medicationId": "", + "medicationId": "", "scheduledFor": "2026-10-15T08:00:00.000Z", "takenAt": "2026-10-15T08:30:00.000Z", "skipped": false, @@ -586,7 +540,7 @@ { "id": "", "userId": "", - "medicationId": "", + "medicationId": "", "scheduledFor": "2026-10-15T10:00:00.000Z", "takenAt": null, "skipped": true, @@ -607,7 +561,7 @@ { "id": "", "userId": "", - "medicationId": "", + "medicationId": "", "scheduledFor": "2026-10-15T20:00:00.000Z", "takenAt": null, "skipped": true, @@ -628,7 +582,7 @@ { "id": "", "userId": "", - "medicationId": "", + "medicationId": "", "scheduledFor": "2026-10-16T08:00:00.000Z", "takenAt": null, "skipped": false, @@ -652,7 +606,7 @@ { "id": "", "userId": "", - "medicationId": "", + "medicationId": "", "state": "IN_USE", "containerType": "OTHER", "unitsTotal": "30", diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.02-mixed-again.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.02-mixed-again.json.snap index d75efdf72..d06212ac5 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.02-mixed-again.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.02-mixed-again.json.snap @@ -163,50 +163,12 @@ "originDeviceToken": null } ] - }, - { - "effect": "countOutstandingDosesToday", - "args": [ - "", - "UTC" - ] - }, - { - "effect": "dispatchMedicationIntakeWebClearBulk", - "args": [ - { - "userId": "", - "slots": [ - { - "medicationId": "", - "scheduledFor": "2026-10-15T08:00:00.000Z" - }, - { - "medicationId": "", - "scheduledFor": "2026-10-13T08:00:00.000Z" - }, - { - "medicationId": "", - "scheduledFor": "2026-10-15T20:00:00.000Z" - }, - { - "medicationId": "", - "scheduledFor": "2026-10-16T08:00:00.000Z" - }, - { - "medicationId": "", - "scheduledFor": "2026-10-15T10:00:00.000Z" - } - ], - "badgeCount": 2 - } - ] } ], "stored": { "medications": [ { - "id": "", + "id": "", "userId": "", "name": "Apple mirror", "dose": "10mg", @@ -279,7 +241,7 @@ "updatedAt": "" }, { - "id": "", + "id": "", "userId": "", "name": "Record only", "dose": "10mg", @@ -314,7 +276,7 @@ "updatedAt": "" }, { - "id": "", + "id": "", "userId": "", "name": "Twice daily", "dose": "10mg", @@ -352,7 +314,7 @@ "schedules": [ { "id": "", - "medicationId": "", + "medicationId": "", "windowStart": "07:00", "windowEnd": "08:00", "label": null, @@ -372,7 +334,7 @@ }, { "id": "", - "medicationId": "", + "medicationId": "", "windowStart": "08:00", "windowEnd": "10:00", "label": null, @@ -392,7 +354,7 @@ }, { "id": "", - "medicationId": "", + "medicationId": "", "windowStart": "09:00", "windowEnd": "11:00", "label": null, @@ -412,7 +374,7 @@ }, { "id": "", - "medicationId": "", + "medicationId": "", "windowStart": "20:00", "windowEnd": "22:00", "label": null, @@ -438,7 +400,7 @@ { "id": "", "userId": "", - "medicationId": "", + "medicationId": "", "scheduledFor": "2026-10-13T08:00:00.000Z", "takenAt": "2026-10-13T11:00:00.000Z", "skipped": false, @@ -464,7 +426,7 @@ { "id": "", "userId": "", - "medicationId": "", + "medicationId": "", "scheduledFor": "2026-10-14T09:00:00.000Z", "takenAt": "2026-10-14T09:05:00.000Z", "skipped": false, @@ -485,7 +447,7 @@ { "id": "", "userId": "", - "medicationId": "", + "medicationId": "", "scheduledFor": "2026-10-14T09:30:00.000Z", "takenAt": "2026-10-14T09:30:00.000Z", "skipped": false, @@ -506,7 +468,7 @@ { "id": "", "userId": "", - "medicationId": "", + "medicationId": "", "scheduledFor": "2026-10-14T20:00:00.000Z", "takenAt": "2026-10-14T20:05:00.000Z", "skipped": false, @@ -527,7 +489,7 @@ { "id": "", "userId": "", - "medicationId": "", + "medicationId": "", "scheduledFor": "2026-10-15T08:00:00.000Z", "takenAt": "2026-10-15T08:30:00.000Z", "skipped": false, @@ -553,7 +515,7 @@ { "id": "", "userId": "", - "medicationId": "", + "medicationId": "", "scheduledFor": "2026-10-15T09:00:00.000Z", "takenAt": "2026-10-15T09:20:00.000Z", "skipped": false, @@ -595,7 +557,7 @@ { "id": "", "userId": "", - "medicationId": "", + "medicationId": "", "scheduledFor": "2026-10-15T20:00:00.000Z", "takenAt": null, "skipped": true, @@ -616,7 +578,7 @@ { "id": "", "userId": "", - "medicationId": "", + "medicationId": "", "scheduledFor": "2026-10-16T08:00:00.000Z", "takenAt": null, "skipped": false, @@ -640,7 +602,7 @@ { "id": "", "userId": "", - "medicationId": "", + "medicationId": "", "state": "IN_USE", "containerType": "OTHER", "unitsTotal": "30", diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.03a-background.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.03a-background.json.snap index 20fed75d5..5fafea126 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.03a-background.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.03a-background.json.snap @@ -50,28 +50,6 @@ "originDeviceToken": null } ] - }, - { - "effect": "countOutstandingDosesToday", - "args": [ - "", - "UTC" - ] - }, - { - "effect": "dispatchMedicationIntakeWebClearBulk", - "args": [ - { - "userId": "", - "slots": [ - { - "medicationId": "", - "scheduledFor": "2026-10-13T09:00:00.000Z" - } - ], - "badgeCount": 2 - } - ] } ], "stored": { diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.03b-backfill.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.03b-backfill.json.snap index 0eabbaeb1..159425a3c 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.03b-backfill.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.03b-backfill.json.snap @@ -49,28 +49,6 @@ "originDeviceToken": null } ] - }, - { - "effect": "countOutstandingDosesToday", - "args": [ - "", - "UTC" - ] - }, - { - "effect": "dispatchMedicationIntakeWebClearBulk", - "args": [ - { - "userId": "", - "slots": [ - { - "medicationId": "", - "scheduledFor": "2026-10-12T09:00:00.000Z" - } - ], - "badgeCount": 2 - } - ] } ], "stored": { diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.05a-native.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.05a-native.json.snap index 0f38ac32f..73aca5032 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.05a-native.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.05a-native.json.snap @@ -52,28 +52,6 @@ "originDeviceToken": null } ] - }, - { - "effect": "countOutstandingDosesToday", - "args": [ - "", - "UTC" - ] - }, - { - "effect": "dispatchMedicationIntakeWebClearBulk", - "args": [ - { - "userId": "", - "slots": [ - { - "medicationId": "", - "scheduledFor": "2026-10-12T08:00:00.000Z" - } - ], - "badgeCount": 2 - } - ] } ], "stored": { diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake-status.11-take-in-place.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake-status.11-take-in-place.json.snap index 99adee5e6..ebe8db65d 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake-status.11-take-in-place.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake-status.11-take-in-place.json.snap @@ -54,13 +54,6 @@ } ] }, - { - "effect": "countOutstandingDosesToday", - "args": [ - "", - "UTC" - ] - }, { "effect": "notifyDelegatedIntake", "args": [ @@ -71,17 +64,6 @@ "status": "taken" } ] - }, - { - "effect": "dispatchMedicationIntakeWebClear", - "args": [ - { - "userId": "", - "medicationId": "", - "scheduledFor": "2026-10-15T08:00:00.000Z", - "badgeCount": 2 - } - ] } ], "stored": { diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake-status.12-take-slot-move.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake-status.12-take-slot-move.json.snap index ed5baac0a..47e1ccd1a 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake-status.12-take-slot-move.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake-status.12-take-slot-move.json.snap @@ -54,13 +54,6 @@ } ] }, - { - "effect": "countOutstandingDosesToday", - "args": [ - "", - "UTC" - ] - }, { "effect": "notifyDelegatedIntake", "args": [ @@ -71,17 +64,6 @@ "status": "taken" } ] - }, - { - "effect": "dispatchMedicationIntakeWebClear", - "args": [ - { - "userId": "", - "medicationId": "", - "scheduledFor": "2026-10-14T20:00:00.000Z", - "badgeCount": 2 - } - ] } ], "stored": { diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake-status.13a-pin.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake-status.13a-pin.json.snap index 054ee2099..481661ade 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake-status.13a-pin.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake-status.13a-pin.json.snap @@ -54,13 +54,6 @@ } ] }, - { - "effect": "countOutstandingDosesToday", - "args": [ - "", - "UTC" - ] - }, { "effect": "notifyDelegatedIntake", "args": [ @@ -71,17 +64,6 @@ "status": "taken" } ] - }, - { - "effect": "dispatchMedicationIntakeWebClear", - "args": [ - { - "userId": "", - "medicationId": "", - "scheduledFor": "2026-10-13T08:00:00.000Z", - "badgeCount": 2 - } - ] } ], "stored": { diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake-status.14-skip.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake-status.14-skip.json.snap index 657ce643a..8b1d04e49 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake-status.14-skip.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake-status.14-skip.json.snap @@ -48,13 +48,6 @@ } ] }, - { - "effect": "countOutstandingDosesToday", - "args": [ - "", - "UTC" - ] - }, { "effect": "notifyDelegatedIntake", "args": [ @@ -65,17 +58,6 @@ "status": "skipped" } ] - }, - { - "effect": "dispatchMedicationIntakeWebClear", - "args": [ - { - "userId": "", - "medicationId": "", - "scheduledFor": "2026-10-12T08:00:00.000Z", - "badgeCount": 2 - } - ] } ], "stored": { diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake-status.17b-record-only-correction.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake-status.17b-record-only-correction.json.snap index 934a769af..92693858f 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake-status.17b-record-only-correction.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake-status.17b-record-only-correction.json.snap @@ -48,13 +48,6 @@ } ] }, - { - "effect": "countOutstandingDosesToday", - "args": [ - "", - "UTC" - ] - }, { "effect": "notifyDelegatedIntake", "args": [ @@ -65,17 +58,6 @@ "status": "taken" } ] - }, - { - "effect": "dispatchMedicationIntakeWebClear", - "args": [ - { - "userId": "", - "medicationId": "", - "scheduledFor": "2026-10-13T07:00:00.000Z", - "badgeCount": 2 - } - ] } ], "stored": { diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake-status.18b-site.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake-status.18b-site.json.snap index ced1d3c2c..860b351cb 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake-status.18b-site.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake-status.18b-site.json.snap @@ -52,13 +52,6 @@ } ] }, - { - "effect": "countOutstandingDosesToday", - "args": [ - "", - "UTC" - ] - }, { "effect": "notifyDelegatedIntake", "args": [ @@ -69,17 +62,6 @@ "status": "taken" } ] - }, - { - "effect": "dispatchMedicationIntakeWebClear", - "args": [ - { - "userId": "", - "medicationId": "", - "scheduledFor": "2026-10-15T09:00:00.000Z", - "badgeCount": 2 - } - ] } ], "stored": { diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake-status.20a-writer-take.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake-status.20a-writer-take.json.snap index 7aa4d03c3..ef64f2835 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake-status.20a-writer-take.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake-status.20a-writer-take.json.snap @@ -53,13 +53,6 @@ } ] }, - { - "effect": "countOutstandingDosesToday", - "args": [ - "", - "UTC" - ] - }, { "effect": "notifyDelegatedIntake", "args": [ @@ -70,17 +63,6 @@ "status": "taken" } ] - }, - { - "effect": "dispatchMedicationIntakeWebClear", - "args": [ - { - "userId": "", - "medicationId": "", - "scheduledFor": "2026-10-15T08:00:00.000Z", - "badgeCount": 2 - } - ] } ], "stored": { diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake.01-take-in-band.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake.01-take-in-band.json.snap index 5bfe65d1a..3ad332de5 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake.01-take-in-band.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake.01-take-in-band.json.snap @@ -55,13 +55,6 @@ } ] }, - { - "effect": "countOutstandingDosesToday", - "args": [ - "", - "UTC" - ] - }, { "effect": "notifyDelegatedIntake", "args": [ @@ -72,17 +65,6 @@ "status": "taken" } ] - }, - { - "effect": "dispatchMedicationIntakeWebClear", - "args": [ - { - "userId": "", - "medicationId": "", - "scheduledFor": "2026-10-15T08:00:00.000Z", - "badgeCount": 2 - } - ] } ], "stored": { diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake.02a-take-standalone.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake.02a-take-standalone.json.snap index 87fd35d61..4edce6678 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake.02a-take-standalone.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake.02a-take-standalone.json.snap @@ -53,13 +53,6 @@ } ] }, - { - "effect": "countOutstandingDosesToday", - "args": [ - "", - "UTC" - ] - }, { "effect": "notifyDelegatedIntake", "args": [ @@ -70,17 +63,6 @@ "status": "taken" } ] - }, - { - "effect": "dispatchMedicationIntakeWebClear", - "args": [ - { - "userId": "", - "medicationId": "", - "scheduledFor": "2026-10-15T11:00:00.000Z", - "badgeCount": 2 - } - ] } ], "stored": { diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake.03a-skip-named-slot.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake.03a-skip-named-slot.json.snap index df5287a0f..473181c7e 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake.03a-skip-named-slot.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake.03a-skip-named-slot.json.snap @@ -50,13 +50,6 @@ } ] }, - { - "effect": "countOutstandingDosesToday", - "args": [ - "", - "UTC" - ] - }, { "effect": "notifyDelegatedIntake", "args": [ @@ -67,17 +60,6 @@ "status": "skipped" } ] - }, - { - "effect": "dispatchMedicationIntakeWebClear", - "args": [ - { - "userId": "", - "medicationId": "", - "scheduledFor": "2026-10-15T20:00:00.000Z", - "badgeCount": 2 - } - ] } ], "stored": { diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake.03b-skip-slotless.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake.03b-skip-slotless.json.snap index 121c49ebe..e9df1aaf8 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake.03b-skip-slotless.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake.03b-skip-slotless.json.snap @@ -59,13 +59,6 @@ } ] }, - { - "effect": "countOutstandingDosesToday", - "args": [ - "", - "UTC" - ] - }, { "effect": "notifyDelegatedIntake", "args": [ @@ -76,17 +69,6 @@ "status": "skipped" } ] - }, - { - "effect": "dispatchMedicationIntakeWebClear", - "args": [ - { - "userId": "", - "medicationId": "", - "scheduledFor": "2026-10-15T08:00:00.000Z", - "badgeCount": 2 - } - ] } ], "stored": { diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake.03d-owner-skip-standalone.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake.03d-owner-skip-standalone.json.snap index 70490b749..bfbfad31e 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake.03d-owner-skip-standalone.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake.03d-owner-skip-standalone.json.snap @@ -50,13 +50,6 @@ } ] }, - { - "effect": "countOutstandingDosesToday", - "args": [ - "", - "UTC" - ] - }, { "effect": "notifyDelegatedIntake", "args": [ @@ -67,17 +60,6 @@ "status": "skipped" } ] - }, - { - "effect": "dispatchMedicationIntakeWebClear", - "args": [ - { - "userId": "", - "medicationId": "", - "scheduledFor": "2026-10-15T11:00:00.000Z", - "badgeCount": 2 - } - ] } ], "stored": { diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake.04a-pin.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake.04a-pin.json.snap index d2cc36112..c9306045e 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake.04a-pin.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake.04a-pin.json.snap @@ -55,13 +55,6 @@ } ] }, - { - "effect": "countOutstandingDosesToday", - "args": [ - "", - "UTC" - ] - }, { "effect": "notifyDelegatedIntake", "args": [ @@ -72,17 +65,6 @@ "status": "taken" } ] - }, - { - "effect": "dispatchMedicationIntakeWebClear", - "args": [ - { - "userId": "", - "medicationId": "", - "scheduledFor": "2026-10-14T08:00:00.000Z", - "badgeCount": 2 - } - ] } ], "stored": { diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake.05-named-backfill.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake.05-named-backfill.json.snap index 0a3817108..40716b4b7 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake.05-named-backfill.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake.05-named-backfill.json.snap @@ -55,13 +55,6 @@ } ] }, - { - "effect": "countOutstandingDosesToday", - "args": [ - "", - "UTC" - ] - }, { "effect": "notifyDelegatedIntake", "args": [ @@ -72,17 +65,6 @@ "status": "taken" } ] - }, - { - "effect": "dispatchMedicationIntakeWebClear", - "args": [ - { - "userId": "", - "medicationId": "", - "scheduledFor": "2026-10-13T08:00:00.000Z", - "badgeCount": 2 - } - ] } ], "stored": { diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake.06a-body-key.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake.06a-body-key.json.snap index 3374e4a7a..741923b7a 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake.06a-body-key.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake.06a-body-key.json.snap @@ -55,13 +55,6 @@ } ] }, - { - "effect": "countOutstandingDosesToday", - "args": [ - "", - "UTC" - ] - }, { "effect": "notifyDelegatedIntake", "args": [ @@ -72,17 +65,6 @@ "status": "taken" } ] - }, - { - "effect": "dispatchMedicationIntakeWebClear", - "args": [ - { - "userId": "", - "medicationId": "", - "scheduledFor": "2026-10-13T20:00:00.000Z", - "badgeCount": 2 - } - ] } ], "stored": { diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake.07a-header-key.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake.07a-header-key.json.snap index f2645fa53..e26555af0 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake.07a-header-key.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake.07a-header-key.json.snap @@ -55,13 +55,6 @@ } ] }, - { - "effect": "countOutstandingDosesToday", - "args": [ - "", - "UTC" - ] - }, { "effect": "notifyDelegatedIntake", "args": [ @@ -72,17 +65,6 @@ "status": "taken" } ] - }, - { - "effect": "dispatchMedicationIntakeWebClear", - "args": [ - { - "userId": "", - "medicationId": "", - "scheduledFor": "2026-10-12T08:00:00.000Z", - "badgeCount": 2 - } - ] } ], "stored": { diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake.08-native.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake.08-native.json.snap index decbbe0d3..52155be7f 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake.08-native.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake.08-native.json.snap @@ -55,13 +55,6 @@ } ] }, - { - "effect": "countOutstandingDosesToday", - "args": [ - "", - "UTC" - ] - }, { "effect": "notifyDelegatedIntake", "args": [ @@ -72,17 +65,6 @@ "status": "taken" } ] - }, - { - "effect": "dispatchMedicationIntakeWebClear", - "args": [ - { - "userId": "", - "medicationId": "", - "scheduledFor": "2026-10-11T20:00:00.000Z", - "badgeCount": 2 - } - ] } ], "stored": { diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake.09b-owner-skip-flip.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake.09b-owner-skip-flip.json.snap index 0bdb15dc7..7f5acc55d 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake.09b-owner-skip-flip.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake.09b-owner-skip-flip.json.snap @@ -50,13 +50,6 @@ } ] }, - { - "effect": "countOutstandingDosesToday", - "args": [ - "", - "UTC" - ] - }, { "effect": "notifyDelegatedIntake", "args": [ @@ -67,17 +60,6 @@ "status": "skipped" } ] - }, - { - "effect": "dispatchMedicationIntakeWebClear", - "args": [ - { - "userId": "", - "medicationId": "", - "scheduledFor": "2026-10-14T20:00:00.000Z", - "badgeCount": 2 - } - ] } ], "stored": { diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake.10a-writer-take.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake.10a-writer-take.json.snap index ca6facc1d..f8cc66ae0 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake.10a-writer-take.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake.10a-writer-take.json.snap @@ -55,13 +55,6 @@ } ] }, - { - "effect": "countOutstandingDosesToday", - "args": [ - "", - "UTC" - ] - }, { "effect": "notifyDelegatedIntake", "args": [ @@ -72,17 +65,6 @@ "status": "taken" } ] - }, - { - "effect": "dispatchMedicationIntakeWebClear", - "args": [ - { - "userId": "", - "medicationId": "", - "scheduledFor": "2026-10-11T08:00:00.000Z", - "badgeCount": 2 - } - ] } ], "stored": { diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake.11b-site-and-dose.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake.11b-site-and-dose.json.snap index fdc78e6fd..5363474a9 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake.11b-site-and-dose.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake.11b-site-and-dose.json.snap @@ -53,13 +53,6 @@ } ] }, - { - "effect": "countOutstandingDosesToday", - "args": [ - "", - "UTC" - ] - }, { "effect": "notifyDelegatedIntake", "args": [ @@ -70,17 +63,6 @@ "status": "taken" } ] - }, - { - "effect": "dispatchMedicationIntakeWebClear", - "args": [ - { - "userId": "", - "medicationId": "", - "scheduledFor": "2026-10-15T09:00:00.000Z", - "badgeCount": 2 - } - ] } ], "stored": { diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake.12-one-shot.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake.12-one-shot.json.snap index 2cb5cf28f..406b3ee7d 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake.12-one-shot.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake.12-one-shot.json.snap @@ -52,13 +52,6 @@ } ] }, - { - "effect": "countOutstandingDosesToday", - "args": [ - "", - "UTC" - ] - }, { "effect": "notifyDelegatedIntake", "args": [ @@ -69,17 +62,6 @@ "status": "taken" } ] - }, - { - "effect": "dispatchMedicationIntakeWebClear", - "args": [ - { - "userId": "", - "medicationId": "", - "scheduledFor": "2026-10-15T09:00:00.000Z", - "badgeCount": 2 - } - ] } ], "stored": { diff --git a/tests/integration/routes/parity/medication-intake-bulk.parity.test.ts b/tests/integration/routes/parity/medication-intake-bulk.parity.test.ts index 4175929d7..bdb82802f 100644 --- a/tests/integration/routes/parity/medication-intake-bulk.parity.test.ts +++ b/tests/integration/routes/parity/medication-intake-bulk.parity.test.ts @@ -46,9 +46,8 @@ vi.mock("@/lib/logging/transports", async (importOriginal) => { }; }); -// The tails the route starts without awaiting them (the cross-device push, -// the web-push dose clear with its badge count), recorded instead of run -// (see `effectRecorder`). +// The tail the route starts without awaiting it (the cross-device push), +// recorded instead of run (see `effectRecorder`). vi.mock( "@/lib/notifications/medication-intake-sync", async (importOriginal) => { @@ -65,31 +64,6 @@ vi.mock( }; }, ); -vi.mock("@/lib/notifications/web-push-clear", async (importOriginal) => { - const actual = - await importOriginal(); - const { effectRecorder } = await import("../../../kit/parity"); - return { - ...actual, - dispatchMedicationIntakeWebClearBulk: async (...args: unknown[]) => { - effectRecorder.record("dispatchMedicationIntakeWebClearBulk", args); - }, - }; -}); -vi.mock("@/lib/medications/outstanding-doses", async (importOriginal) => { - const actual = - await importOriginal< - typeof import("@/lib/medications/outstanding-doses") - >(); - const { effectRecorder } = await import("../../../kit/parity"); - return { - ...actual, - countOutstandingDosesToday: async (...args: unknown[]) => { - effectRecorder.record("countOutstandingDosesToday", args); - return 2; - }, - }; -}); const prisma = getPrismaClient(); const NOW = new Date("2026-10-15T12:00:00.000Z"); diff --git a/tests/integration/routes/parity/medication-intake-status.parity.test.ts b/tests/integration/routes/parity/medication-intake-status.parity.test.ts index 1c4859108..bb52646c6 100644 --- a/tests/integration/routes/parity/medication-intake-status.parity.test.ts +++ b/tests/integration/routes/parity/medication-intake-status.parity.test.ts @@ -42,8 +42,8 @@ vi.mock("@/lib/logging/transports", async (importOriginal) => { }); // The tails the route starts without awaiting them (the cross-device push, -// the web-push dose clear with its badge count, the owner's notification of a -// delegated dose), recorded instead of run (see `effectRecorder`). +// the owner's notification of a delegated dose), recorded instead of run (see +// `effectRecorder`). vi.mock( "@/lib/notifications/medication-intake-sync", async (importOriginal) => { @@ -60,31 +60,6 @@ vi.mock( }; }, ); -vi.mock("@/lib/notifications/web-push-clear", async (importOriginal) => { - const actual = - await importOriginal(); - const { effectRecorder } = await import("../../../kit/parity"); - return { - ...actual, - dispatchMedicationIntakeWebClear: async (...args: unknown[]) => { - effectRecorder.record("dispatchMedicationIntakeWebClear", args); - }, - }; -}); -vi.mock("@/lib/medications/outstanding-doses", async (importOriginal) => { - const actual = - await importOriginal< - typeof import("@/lib/medications/outstanding-doses") - >(); - const { effectRecorder } = await import("../../../kit/parity"); - return { - ...actual, - countOutstandingDosesToday: async (...args: unknown[]) => { - effectRecorder.record("countOutstandingDosesToday", args); - return 2; - }, - }; -}); vi.mock("@/lib/notifications/delegated-intake", async (importOriginal) => { const actual = await importOriginal< diff --git a/tests/integration/routes/parity/medication-intake.parity.test.ts b/tests/integration/routes/parity/medication-intake.parity.test.ts index e5ac70486..7ce13c706 100644 --- a/tests/integration/routes/parity/medication-intake.parity.test.ts +++ b/tests/integration/routes/parity/medication-intake.parity.test.ts @@ -48,8 +48,8 @@ vi.mock("@/lib/logging/transports", async (importOriginal) => { }); // The tails the route starts without awaiting them (the cross-device push, -// the web-push dose clear with its badge count, the owner's notification of a -// delegated dose), recorded instead of run (see `effectRecorder`). +// the owner's notification of a delegated dose), recorded instead of run (see +// `effectRecorder`). vi.mock( "@/lib/notifications/medication-intake-sync", async (importOriginal) => { @@ -66,31 +66,6 @@ vi.mock( }; }, ); -vi.mock("@/lib/notifications/web-push-clear", async (importOriginal) => { - const actual = - await importOriginal(); - const { effectRecorder } = await import("../../../kit/parity"); - return { - ...actual, - dispatchMedicationIntakeWebClear: async (...args: unknown[]) => { - effectRecorder.record("dispatchMedicationIntakeWebClear", args); - }, - }; -}); -vi.mock("@/lib/medications/outstanding-doses", async (importOriginal) => { - const actual = - await importOriginal< - typeof import("@/lib/medications/outstanding-doses") - >(); - const { effectRecorder } = await import("../../../kit/parity"); - return { - ...actual, - countOutstandingDosesToday: async (...args: unknown[]) => { - effectRecorder.record("countOutstandingDosesToday", args); - return 2; - }, - }; -}); vi.mock("@/lib/notifications/delegated-intake", async (importOriginal) => { const actual = await importOriginal<