From 70295f45cb0fbb556f69b7ee3aaa899ccbc0b2f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 20:27:23 +0100 Subject: [PATCH 1/7] fix(push): bring Web Push back after turning it on again A channel switched off because the last browser's endpoint answered 410 (app removed from the Home Screen, site data cleared) stayed off when push was turned on again: the subscription was stored and the test push arrived, but no reminder ever did. Subscribing now re-enables a disabled channel through reEnableChannel, the same reset the status card's button runs. Turning push off on the last browser deleted the subscription and left the channel on, and each later reminder was booked as a failed send (web_push_no_subscriptions) until the channel gave up after five. The dispatcher now leaves channel state alone when a sender had nobody to send to; APNs without a paired iPhone and email without an address or SMTP had the same problem and are covered by the same rule. --- .../web-push/__tests__/route.test.ts | 93 ++++++++++++++++++- src/app/api/notifications/web-push/route.ts | 11 ++- .../__tests__/dispatcher.test.ts | 41 ++++++++ src/lib/notifications/dispatcher.ts | 65 +++++++------ src/lib/notifications/senders/web-push.ts | 13 ++- 5 files changed, 186 insertions(+), 37 deletions(-) diff --git a/src/app/api/notifications/web-push/__tests__/route.test.ts b/src/app/api/notifications/web-push/__tests__/route.test.ts index 237e76856..c93f702f4 100644 --- a/src/app/api/notifications/web-push/__tests__/route.test.ts +++ b/src/app/api/notifications/web-push/__tests__/route.test.ts @@ -31,10 +31,16 @@ vi.mock("@/lib/db", () => ({ // asserting the validation refusals they were written for. appSettings: { findUnique: vi.fn() }, pushSubscription: { upsert: vi.fn(), deleteMany: vi.fn() }, - notificationChannel: { findFirst: vi.fn(), create: vi.fn() }, + notificationChannel: { + findFirst: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }, }, })); +vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); + vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn(), getEvent: vi.fn(() => null), @@ -42,12 +48,45 @@ vi.mock("@/lib/logging/context", () => ({ vi.mock("@/lib/crypto", () => ({ encrypt: (v: string) => `enc(${v})` })); +import type { + NotificationChannel, + PushSubscription, +} from "@/generated/prisma/client"; import { POST, DELETE } from "../route"; import { requireAuth } from "@/lib/api-handler"; import { prisma } from "@/lib/db"; const USER = { id: "user-1", username: "u" }; +const STORED_SUBSCRIPTION: PushSubscription = { + id: "sub-1", + userId: USER.id, + endpoint: "https://fcm.googleapis.com/fcm/send/abcdef-routing-secret", + p256dh: "enc(BPkey)", + auth: "enc(authsecret)", + userAgent: null, + createdAt: new Date(0), +}; + +function channelRow(over: Partial): NotificationChannel { + return { + id: "ch-1", + userId: USER.id, + type: "WEB_PUSH", + enabled: true, + config: "enc({})", + createdAt: new Date(0), + updatedAt: new Date(0), + disabledReason: null, + consecutiveFailures: 0, + lastSuccessAt: null, + lastFailureAt: null, + lastFailureReason: null, + nextRetryAt: null, + ...over, + }; +} + /** A well-formed subscription, for the cases that should succeed. */ const GOOD = { endpoint: "https://fcm.googleapis.com/fcm/send/abcdef-routing-secret", @@ -139,6 +178,58 @@ describe("POST /api/notifications/web-push — the subscribe refusal", () => { }); }); +describe("POST /api/notifications/web-push — the channel it delivers through", () => { + // The channel is switched off when the last browser's endpoint answers 410 + // (the app was removed from the Home Screen, site data was cleared) or after + // five failed sends. Subscribing again must switch it back on: before, the + // subscription was stored, the test push arrived and no reminder ever did. + it("switches a disabled channel back on", async () => { + const disabled = channelRow({ + enabled: false, + disabledReason: "web_push_410_gone", + consecutiveFailures: 1, + }); + vi.mocked(prisma.pushSubscription.upsert).mockResolvedValue( + STORED_SUBSCRIPTION, + ); + vi.mocked(prisma.notificationChannel.findFirst).mockResolvedValue(disabled); + vi.mocked(prisma.notificationChannel.update).mockResolvedValue({ + ...disabled, + enabled: true, + disabledReason: null, + consecutiveFailures: 0, + }); + + const res = await (POST as unknown as Handler)(req(GOOD)); + + expect(res.status).toBe(200); + expect(prisma.notificationChannel.update).toHaveBeenCalledWith({ + where: { id: "ch-1" }, + data: expect.objectContaining({ + enabled: true, + disabledReason: null, + consecutiveFailures: 0, + }), + }); + expect(prisma.notificationChannel.create).not.toHaveBeenCalled(); + }); + + it("leaves an enabled channel as it is", async () => { + vi.mocked(prisma.pushSubscription.upsert).mockResolvedValue( + STORED_SUBSCRIPTION, + ); + vi.mocked(prisma.notificationChannel.findFirst).mockResolvedValue( + channelRow({}), + ); + + const res = await (POST as unknown as Handler)(req(GOOD)); + + expect(res.status).toBe(200); + expect(prisma.notificationChannel.update).not.toHaveBeenCalled(); + expect(prisma.notificationChannel.create).not.toHaveBeenCalled(); + }); +}); + describe("DELETE /api/notifications/web-push — the unsubscribe refusal", () => { it("names the offending field rather than answering flat", async () => { const res = await (DELETE as unknown as Handler)( diff --git a/src/app/api/notifications/web-push/route.ts b/src/app/api/notifications/web-push/route.ts index 50b0e0c20..7b25c92f2 100644 --- a/src/app/api/notifications/web-push/route.ts +++ b/src/app/api/notifications/web-push/route.ts @@ -5,6 +5,7 @@ import { isChannelGloballyEnabled } from "@/lib/app-settings"; import { annotate } from "@/lib/logging/context"; import { apiSuccess, apiError, returnAllZodIssues } from "@/lib/api-response"; import { encrypt } from "@/lib/crypto"; +import { reEnableChannel } from "@/lib/notifications/channel-state"; import { webPushSubscriptionSchema } from "@/lib/validations/notifications"; import { z } from "zod/v4"; @@ -97,7 +98,9 @@ export const POST = apiHandler(async (request: NextRequest) => { }, }); - // Ensure a WEB_PUSH notification channel exists for this user + // Ensure an enabled WEB_PUSH channel. A channel switched off because the + // last browser's endpoint died (410) or failed five times stays off until + // something turns it back on, and a new subscription is exactly that. const existingChannel = await prisma.notificationChannel.findFirst({ where: { userId: user.id, type: "WEB_PUSH" }, }); @@ -111,6 +114,12 @@ export const POST = apiHandler(async (request: NextRequest) => { config: encrypt(JSON.stringify({})), }, }); + } else if (!existingChannel.enabled) { + await reEnableChannel({ + id: existingChannel.id, + userId: existingChannel.userId, + type: "WEB_PUSH", + }); } return apiSuccess({ subscribed: true }); diff --git a/src/lib/notifications/__tests__/dispatcher.test.ts b/src/lib/notifications/__tests__/dispatcher.test.ts index 7aa7046e9..e5b80df3c 100644 --- a/src/lib/notifications/__tests__/dispatcher.test.ts +++ b/src/lib/notifications/__tests__/dispatcher.test.ts @@ -207,6 +207,47 @@ describe("dispatchNotification — soft reject (web-push 429)", () => { }); }); +describe("dispatchNotification — nobody to send to", () => { + // Turning push off on the last browser deletes its subscription and leaves + // the channel on. Counted as failures, the next five reminders switched the + // channel off for good, and subscribing again later did not bring it back. + it.each([ + ["WEB_PUSH", "web_push_no_subscriptions"], + ["WEB_PUSH", "web_push_vapid_not_configured"], + ["APNS", "apns_no_devices"], + ["APNS", "apns_not_configured"], + ] as const)( + "%s answering %s leaves the channel state alone", + async (type, reason) => { + vi.mocked(prisma.notificationChannel.findMany).mockResolvedValueOnce([ + { + ...makeChannel({ type, consecutiveFailures: 4 }), + createdAt: new Date(0), + updatedAt: new Date(0), + disabledReason: null, + lastSuccessAt: null, + lastFailureAt: null, + lastFailureReason: null, + }, + ]); + const sender = type === "APNS" ? sendViaApnsMock : sendViaWebPushMock; + sender.mockResolvedValueOnce({ ok: false, hardReject: false, reason }); + + const outcome = await dispatchNotification({ + eventType: "MEDICATION_REMINDER", + userId: "u-1", + title: "t", + message: "m", + }); + + expect(sender).toHaveBeenCalledTimes(1); + expect(outcome.dispatched).toBe(false); + expect(prisma.notificationChannel.update).not.toHaveBeenCalled(); + expect(auditLog).not.toHaveBeenCalled(); + }, + ); +}); + describe("dispatchNotification — bounded APNs fallback", () => { it("continues to the next channel after an APNs timeout", async () => { vi.mocked(prisma.notificationChannel.findMany).mockResolvedValueOnce([ diff --git a/src/lib/notifications/dispatcher.ts b/src/lib/notifications/dispatcher.ts index 153217a17..4b102c7c2 100644 --- a/src/lib/notifications/dispatcher.ts +++ b/src/lib/notifications/dispatcher.ts @@ -43,6 +43,23 @@ import { resolveNotificationDeliveryIdentity, } from "@/lib/notifications/delivery-identity"; +/** + * Sender outcomes that mean there was nobody to send to: no browser + * subscribed, no iPhone paired, no address, or a transport the operator has + * not set up. The sender writes a `skipped` ledger row. The channel has not + * failed, so these leave its failure counter alone; counted as failures, + * turning push off on the last device would switch the channel off for good + * after five reminders. + */ +const NO_RECIPIENT_REASONS: ReadonlySet = new Set([ + "web_push_no_subscriptions", + "web_push_vapid_not_configured", + "apns_no_devices", + "apns_not_configured", + "email_no_recipient", + "email_not_configured", +]); + /** * Dispatch a notification to all enabled channels for a user. * Best-effort: logs errors but never throws. @@ -57,6 +74,8 @@ import { * 6. Call the appropriate sender — which now returns a `SendOutcome` so * the dispatcher can classify hard rejects (410, blocked-by-user) vs * soft errors (5xx, 429, network) and update channel state accordingly. + * A send that had nobody to go to (`NO_RECIPIENT_REASONS`) changes no + * channel state. * * Also handles legacy Telegram config stored on the User model by * auto-migrating it to a NotificationChannel record on first dispatch. @@ -352,9 +371,14 @@ export async function dispatchNotification( continue; } + const ref = { + id: channel.id, + userId: channel.userId, + type: channel.type as ChannelType, + }; try { const prepared = prepareChannelSend( - channel.type as ChannelType, + ref.type, channel.config, delivery.recipientUserId, recipientPayload, @@ -402,39 +426,28 @@ export async function dispatchNotification( } } if (outcome.ok) { - await recordChannelSuccess({ - id: channel.id, - userId: channel.userId, - type: channel.type as ChannelType, - }); + await recordChannelSuccess(ref); channelsSucceeded += 1; continue; } - if (outcome.hardReject) { - await recordChannelHardReject( - { - id: channel.id, - userId: channel.userId, - type: channel.type as ChannelType, - }, - outcome, + if (outcome.reason && NO_RECIPIENT_REASONS.has(outcome.reason)) { + getEvent()?.addMeta( + `notification_skip_${channel.type.toLowerCase()}_no_recipient`, + outcome.reason, ); + continue; + } + + if (outcome.hardReject) { + await recordChannelHardReject(ref, outcome); getEvent()?.addWarning( `Notification channel ${channel.type} auto-disabled: ${outcome.reason}`, ); continue; } - const result = await recordChannelTransientFailure( - { - id: channel.id, - userId: channel.userId, - type: channel.type as ChannelType, - }, - outcome, - now, - ); + const result = await recordChannelTransientFailure(ref, outcome, now); getEvent()?.addWarning( `Notification dispatch failed for channel ${channel.type}: ${outcome.reason}` + (result.autoDisabled ? " (auto-disabled after 5 failures)" : ""), @@ -445,11 +458,7 @@ export async function dispatchNotification( // be no-throw, this is defence-in-depth.) const message = err instanceof Error ? err.message : String(err); await recordChannelTransientFailure( - { - id: channel.id, - userId: channel.userId, - type: channel.type as ChannelType, - }, + ref, { reason: "sender_threw", message }, now, ); diff --git a/src/lib/notifications/senders/web-push.ts b/src/lib/notifications/senders/web-push.ts index 22bc9528e..42808fcc4 100644 --- a/src/lib/notifications/senders/web-push.ts +++ b/src/lib/notifications/senders/web-push.ts @@ -24,9 +24,10 @@ import { isSafeAppPath } from "@/lib/notifications/tap-target"; * permanent reject (410/404) and the channel can no longer succeed. * The 410 subscriptions themselves are deleted from the DB regardless, * so a hard-channel-reject means the user has zero devices left. - * - `hardReject: false` for any 5xx, 429, fetch failure, or - * "no subscriptions at all" (treated as soft so the user can re-pair - * a device without the channel staying auto-disabled forever). + * - `hardReject: false` for any 5xx, 429 or fetch failure. + * - `reason: "web_push_no_subscriptions"` (or `_vapid_not_configured`) when + * there is nothing to send to; the dispatcher leaves the channel state + * alone for those. */ export async function sendViaWebPush( userId: string, @@ -65,10 +66,8 @@ export async function sendViaWebPush( }); if (!subscriptions.length) { - // No devices registered yet — not a hard channel reject. The user - // may simply not have hit "Subscribe" on this browser. Counting - // this as a give-up signal would lock the user out of the channel - // they just configured. Treat it as a soft "no recipient" instead. + // No browser subscribed (never, or push was turned off on the last + // one). Nothing failed; the dispatcher books this as a skip. recordPushAttemptForPayload(payload, userId, { userId, channel: "WEB_PUSH", 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 2/7] 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< From 6d8f533deb1d835aa32e18fcad0aa23e0d17e2b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 20:42:25 +0100 Subject: [PATCH 3/7] fix(auth): keep the page when sign-out is refused The sign-out mutation took every HTTP answer as success. A refused logout (403 from the demo guard, a 429, a 5xx) wiped the client caches and showed the sign-in page while the session cookie stayed valid, so the record pages opened again on the next visit. The call now goes through apiFetch, which throws on a non-2xx answer, and the error toast says the person is still signed in instead of naming a network error. --- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/ko.json | 1 + messages/nl.json | 1 + messages/pl.json | 1 + messages/pt-BR.json | 1 + messages/ru.json | 1 + .../baselines/version-stamp-comments.json | 4 +- src/hooks/__tests__/use-logout.test.ts | 106 ++++++++++++++++++ src/hooks/use-auth.ts | 10 +- 13 files changed, 123 insertions(+), 7 deletions(-) create mode 100644 src/hooks/__tests__/use-logout.test.ts diff --git a/messages/de.json b/messages/de.json index 4dcc47c51..737f6dce7 100644 --- a/messages/de.json +++ b/messages/de.json @@ -529,6 +529,7 @@ "noAccount": "Noch kein Konto?", "hasAccount": "Bereits ein Konto?", "loginFailed": "Anmeldung fehlgeschlagen", + "signOutFailed": "Abmelden hat nicht geklappt. Du bist weiterhin angemeldet.", "registerFailed": "Registrierung fehlgeschlagen", "invitedBanner": "Du wurdest eingeladen. Erstelle dein Konto, auch wenn die Registrierung sonst geschlossen ist.", "alreadySignedIn": { diff --git a/messages/en.json b/messages/en.json index 6a5c05ac9..8877c35d5 100644 --- a/messages/en.json +++ b/messages/en.json @@ -529,6 +529,7 @@ "noAccount": "Don't have an account?", "hasAccount": "Already have an account?", "loginFailed": "Login failed", + "signOutFailed": "Could not sign out. You are still signed in.", "registerFailed": "Registration failed", "invitedBanner": "You have been invited. Create your account even while registration is otherwise closed.", "alreadySignedIn": { diff --git a/messages/es.json b/messages/es.json index a2b612bad..ad30b5020 100644 --- a/messages/es.json +++ b/messages/es.json @@ -529,6 +529,7 @@ "noAccount": "¿Aún no tienes cuenta?", "hasAccount": "¿Ya tienes cuenta?", "loginFailed": "No se ha podido iniciar sesión", + "signOutFailed": "No se pudo cerrar la sesión. Tu sesión sigue abierta.", "registerFailed": "No se ha podido completar el registro", "invitedBanner": "Te han invitado: crea tu cuenta aunque el registro esté cerrado.", "alreadySignedIn": { diff --git a/messages/fr.json b/messages/fr.json index af97df036..ed15c1527 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -529,6 +529,7 @@ "noAccount": "Pas encore de compte ?", "hasAccount": "Déjà un compte ?", "loginFailed": "Échec de la connexion", + "signOutFailed": "La déconnexion a échoué. Votre session est toujours ouverte.", "registerFailed": "Échec de l’inscription", "invitedBanner": "Vous avez été invité. Créez votre compte même si les inscriptions sont fermées.", "alreadySignedIn": { diff --git a/messages/it.json b/messages/it.json index fed51449c..bb602f60a 100644 --- a/messages/it.json +++ b/messages/it.json @@ -529,6 +529,7 @@ "noAccount": "Non hai ancora un account?", "hasAccount": "Hai già un account?", "loginFailed": "Accesso non riuscito", + "signOutFailed": "Non è stato possibile uscire. La sessione è ancora attiva.", "registerFailed": "Registrazione non riuscita", "invitedBanner": "Sei stato invitato: crea il tuo account anche se la registrazione è chiusa.", "alreadySignedIn": { diff --git a/messages/ko.json b/messages/ko.json index 77eee65e5..a7baf05aa 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -529,6 +529,7 @@ "noAccount": "계정이 없나요?", "hasAccount": "이미 계정이 있나요?", "loginFailed": "로그인 실패", + "signOutFailed": "로그아웃하지 못했어요. 아직 로그인되어 있어요.", "registerFailed": "회원가입 실패", "invitedBanner": "초대를 받았어요. 회원가입이 닫혀 있어도 계정을 만들 수 있어요.", "alreadySignedIn": { diff --git a/messages/nl.json b/messages/nl.json index 1df8e98bf..67807173c 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -529,6 +529,7 @@ "noAccount": "Nog geen account?", "hasAccount": "Heb je al een account?", "loginFailed": "Inloggen mislukt", + "signOutFailed": "Uitloggen is mislukt. Je bent nog steeds ingelogd.", "registerFailed": "Registratie mislukt", "invitedBanner": "Je bent uitgenodigd. Maak je account aan, ook al is registratie verder gesloten.", "alreadySignedIn": { diff --git a/messages/pl.json b/messages/pl.json index fa9457d77..6bc8109e9 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -529,6 +529,7 @@ "noAccount": "Nie masz jeszcze konta?", "hasAccount": "Masz już konto?", "loginFailed": "Logowanie nie powiodło się", + "signOutFailed": "Nie udało się wylogować. Sesja jest nadal aktywna.", "registerFailed": "Rejestracja nie powiodła się", "invitedBanner": "Otrzymano zaproszenie. Utwórz konto, nawet jeśli rejestracja jest zamknięta.", "alreadySignedIn": { diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 6abf9a204..5efe0f404 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -529,6 +529,7 @@ "noAccount": "Não tem uma conta?", "hasAccount": "Já tem uma conta?", "loginFailed": "Falha no login", + "signOutFailed": "Não foi possível sair. Sua sessão continua ativa.", "registerFailed": "Falha no cadastro", "invitedBanner": "Você foi convidado. Crie sua conta mesmo que o cadastro esteja fechado.", "alreadySignedIn": { diff --git a/messages/ru.json b/messages/ru.json index 159cf4fab..412c87fa9 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -529,6 +529,7 @@ "noAccount": "Нет аккаунта?", "hasAccount": "Уже есть аккаунт?", "loginFailed": "Не удалось войти", + "signOutFailed": "Не удалось выйти. Вы по-прежнему в системе.", "registerFailed": "Не удалось зарегистрироваться", "invitedBanner": "Вас пригласили. Создайте аккаунт, даже если регистрация в остальном закрыта.", "alreadySignedIn": { diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index 58eb7415a..601533123 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": 2890, - "total": 9216, + "total": 9215, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -1474,7 +1474,7 @@ "hooks/use-account-switch.ts": 2, "hooks/use-ai-capability.ts": 3, "hooks/use-ai-run.ts": 1, - "hooks/use-auth.ts": 34, + "hooks/use-auth.ts": 33, "hooks/use-chart-domain-stats.ts": 1, "hooks/use-chart-overlay-prefs.ts": 2, "hooks/use-coach-message-results.ts": 1, diff --git a/src/hooks/__tests__/use-logout.test.ts b/src/hooks/__tests__/use-logout.test.ts new file mode 100644 index 000000000..ed0ba5e6e --- /dev/null +++ b/src/hooks/__tests__/use-logout.test.ts @@ -0,0 +1,106 @@ +/** + * Signing out: what the browser is left with when the server answers. + * + * `useMutation` is replaced by a stand-in that hands back the options, so the + * test drives `mutationFn`, `onSuccess` and `onError` the way React Query + * does, against the real fetch wrapper and a stubbed `fetch`. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +interface LogoutOptions { + mutationFn: () => Promise; + onSuccess: () => void; + onError: (failure: unknown) => void; +} + +const captured: { options: LogoutOptions | null } = { options: null }; +const push = vi.fn(); +const clear = vi.fn(); +const showErrorToast = vi.fn(); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: vi.fn(), + useQueryClient: () => ({ clear }), + useMutation: (options: LogoutOptions) => { + captured.options = options; + return { mutate: vi.fn(), isPending: false }; + }, +})); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push, replace: vi.fn() }), +})); +vi.mock("@/lib/i18n/context", () => ({ + useTranslations: () => ({ t: (key: string) => key }), +})); +vi.mock("@/lib/errors/error-toast", () => ({ + showErrorToast: (...args: unknown[]) => showErrorToast(...args), +})); +vi.mock("@/lib/pwa/query-persister", () => ({ + clearOfflineCachesForSessionEnd: vi.fn().mockResolvedValue(undefined), +})); + +import { useLogout } from "../use-auth"; + +const fetchMock = + vi.fn<(input: string, init?: RequestInit) => Promise>(); + +function json(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +/** What React Query does with the options: run, then settle one way. */ +async function settle(options: LogoutOptions): Promise { + try { + await options.mutationFn(); + } catch (failure) { + options.onError(failure); + return; + } + options.onSuccess(); +} + +beforeEach(() => { + vi.stubGlobal("fetch", fetchMock); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + captured.options = null; +}); + +describe("useLogout", () => { + it("clears the caches and opens the sign-in page once the server signed out", async () => { + fetchMock.mockResolvedValue(json(200, { data: { loggedOut: true } })); + + useLogout(); + await settle(captured.options!); + + expect(fetchMock).toHaveBeenCalledWith( + "/api/auth/logout", + expect.objectContaining({ method: "POST" }), + ); + expect(clear).toHaveBeenCalledTimes(1); + expect(push).toHaveBeenCalledWith("/auth/login"); + expect(showErrorToast).not.toHaveBeenCalled(); + }); + + it("stays on the page and says so when the server refuses", async () => { + // The demo instance answered 403 here; the page showed the sign-in form + // while the session cookie still opened every record page. + fetchMock.mockResolvedValue(json(403, { data: null, error: "Forbidden" })); + + useLogout(); + await settle(captured.options!); + + expect(push).not.toHaveBeenCalled(); + expect(clear).not.toHaveBeenCalled(); + expect(showErrorToast).toHaveBeenCalledWith( + "auth.signOutFailed", + expect.objectContaining({ cause: expect.anything() }), + ); + }); +}); diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index f5b9e4218..aa8344cdb 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -9,7 +9,7 @@ import { import { useRouter } from "next/navigation"; import { showErrorToast } from "@/lib/errors/error-toast"; import { queryKeys } from "@/lib/query-keys"; -import { apiGet, apiFetchRaw, ApiError } from "@/lib/api/api-fetch"; +import { apiGet, apiFetch, ApiError } from "@/lib/api/api-fetch"; import { adoptRecordFenceState } from "@/lib/api/record-fence"; import { retryOnceOnTransientError } from "@/lib/queries/retry-transient"; import { useMounted } from "@/hooks/use-mounted"; @@ -649,15 +649,15 @@ export function useLogout() { return useMutation({ mutationFn: async () => { - await apiFetchRaw("/api/auth/logout", { method: "POST" }); + // Throws on any non-2xx answer: a refused sign-out leaves the session + // alive, and showing the sign-in page then would claim otherwise. + await apiFetch("/api/auth/logout", { method: "POST" }); }, onSuccess: () => { clearCachesForSessionEnd(queryClient); router.push("/auth/login"); }, - // v1.16.4 — a network-failed logout used to do nothing at all (the - // menu closed, the session stayed); a toast names the failure. onError: (failure) => - showErrorToast(t("common.networkError"), { cause: failure }), + showErrorToast(t("auth.signOutFailed"), { cause: failure }), }); } From 32911fea3739055064403a172045914ba803370f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 20:44:24 +0100 Subject: [PATCH 4/7] fix(push): remove this browser's subscription on sign-out Signing out ended the session and wiped the caches but left the Web Push subscription in place, in the browser and on the server. The browser kept showing the signed-out account's medication reminders, also after someone else signed in on it, and the next account's Settings showed push as already on. Sign-out now runs the same steps as "Disable push" before the logout call: DELETE the server row while the session is still valid, then unsubscribe in the browser. Both callers share one helper. A failure there does not block signing out. With the dispatcher no longer counting "no subscription" as a failed send, signing out does not start the five-failure countdown on the account's channel. --- .../baselines/source-function-lines.json | 4 +- src/components/settings/web-push-card.tsx | 17 +----- src/hooks/__tests__/use-logout.test.ts | 61 +++++++++++++++++++ src/hooks/use-auth.ts | 4 ++ src/lib/notifications/web-push-unsubscribe.ts | 23 +++++++ 5 files changed, 92 insertions(+), 17 deletions(-) create mode 100644 src/lib/notifications/web-push-unsubscribe.ts diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index 5dea39622..ad2d1a644 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": 111507, + "total": 111493, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275, @@ -224,7 +224,7 @@ "components/settings/sharing-section.tsx :: ShareLinksCard": 260, "components/settings/sources-section.tsx :: SourcesSection": 601, "components/settings/thresholds-editor-section.tsx :: MetricRow": 241, - "components/settings/web-push-card.tsx :: WebPushCard": 215, + "components/settings/web-push-card.tsx :: WebPushCard": 201, "components/symptoms/symptom-entry-form.tsx :: SymptomEntryForm": 317, "components/symptoms/symptoms-section.tsx :: SymptomsSection": 205, "components/targets/range-bar.tsx :: RangeBar": 220, diff --git a/src/components/settings/web-push-card.tsx b/src/components/settings/web-push-card.tsx index 53fed1acf..c9515fc88 100644 --- a/src/components/settings/web-push-card.tsx +++ b/src/components/settings/web-push-card.tsx @@ -10,6 +10,7 @@ import { SettingsCardActions } from "@/components/settings/_card-actions"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; import { apiFetchRaw } from "@/lib/api/api-fetch"; +import { unsubscribeWebPush } from "@/lib/notifications/web-push-unsubscribe"; /** Convert a URL-safe base64 string to an ArrayBuffer (for VAPID key). */ function urlBase64ToArrayBuffer(base64String: string): ArrayBuffer { @@ -136,21 +137,7 @@ export function WebPushCard() { setMsgType(null); try { - const registration = - await navigator.serviceWorker.getRegistration("/sw.js"); - if (registration) { - const subscription = await registration.pushManager.getSubscription(); - if (subscription) { - await apiFetchRaw("/api/notifications/web-push", { - method: "DELETE", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ endpoint: subscription.endpoint }), - }); - - await subscription.unsubscribe(); - } - } - + await unsubscribeWebPush(); setIsSubscribed(false); setMsg(t("settings.webPushUnsubscribed")); setMsgType("success"); diff --git a/src/hooks/__tests__/use-logout.test.ts b/src/hooks/__tests__/use-logout.test.ts index ed0ba5e6e..592b28e79 100644 --- a/src/hooks/__tests__/use-logout.test.ts +++ b/src/hooks/__tests__/use-logout.test.ts @@ -88,6 +88,67 @@ describe("useLogout", () => { expect(showErrorToast).not.toHaveBeenCalled(); }); + it("removes this browser's push subscription before the session ends", async () => { + // Left in place, the browser kept showing the signed-out account's + // medication reminders, also after someone else signed in. + const order: string[] = []; + const unsubscribe = vi.fn(async () => { + order.push("browser unsubscribe"); + return true; + }); + const subscription = { + endpoint: "https://push.example.com/send/abc", + unsubscribe, + }; + vi.stubGlobal("window", { PushManager: class {} }); + vi.stubGlobal("navigator", { + serviceWorker: { + getRegistration: async () => ({ + pushManager: { getSubscription: async () => subscription }, + }), + }, + }); + fetchMock.mockImplementation(async (url, init) => { + order.push(`${init?.method} ${url}`); + return json(200, { data: {} }); + }); + + useLogout(); + await settle(captured.options!); + + expect(order).toEqual([ + "DELETE /api/notifications/web-push", + "browser unsubscribe", + "POST /api/auth/logout", + ]); + const deleteCall = fetchMock.mock.calls[0]; + expect(JSON.parse(String(deleteCall[1]?.body))).toEqual({ + endpoint: subscription.endpoint, + }); + expect(push).toHaveBeenCalledWith("/auth/login"); + }); + + it("still signs out when removing the subscription fails", async () => { + vi.stubGlobal("window", { PushManager: class {} }); + vi.stubGlobal("navigator", { + serviceWorker: { + getRegistration: async () => { + throw new Error("no worker"); + }, + }, + }); + fetchMock.mockResolvedValue(json(200, { data: { loggedOut: true } })); + + useLogout(); + await settle(captured.options!); + + expect(fetchMock).toHaveBeenCalledWith( + "/api/auth/logout", + expect.objectContaining({ method: "POST" }), + ); + expect(push).toHaveBeenCalledWith("/auth/login"); + }); + it("stays on the page and says so when the server refuses", async () => { // The demo instance answered 403 here; the page showed the sign-in form // while the session cookie still opened every record page. diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index aa8344cdb..63f8b056a 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -30,6 +30,7 @@ import type { TourProgress } from "@/lib/onboarding/tour-progress"; import { clearLastDay } from "@/lib/day/last-day"; import { clearCoachDeleteJournal } from "@/lib/ai/coach/delete-journal"; import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister"; +import { unsubscribeWebPush } from "@/lib/notifications/web-push-unsubscribe"; import { setRecordScope, setRefusedRecordScope, @@ -649,6 +650,9 @@ export function useLogout() { return useMutation({ mutationFn: async () => { + // Before the session ends: the server row can only be removed while + // it is still valid. A failure here does not block signing out. + await unsubscribeWebPush().catch(() => undefined); // Throws on any non-2xx answer: a refused sign-out leaves the session // alive, and showing the sign-in page then would claim otherwise. await apiFetch("/api/auth/logout", { method: "POST" }); diff --git a/src/lib/notifications/web-push-unsubscribe.ts b/src/lib/notifications/web-push-unsubscribe.ts new file mode 100644 index 000000000..ca2ba162a --- /dev/null +++ b/src/lib/notifications/web-push-unsubscribe.ts @@ -0,0 +1,23 @@ +import { apiFetchRaw } from "@/lib/api/api-fetch"; + +/** + * Remove this browser's Web Push subscription: the server row first, while + * the session can still name it, then the browser's own. Does nothing when + * the browser has none. + * + * Used by "Disable push" and by sign-out. A subscription left behind at + * sign-out keeps showing that account's reminders on this browser, to + * whoever uses it next. + */ +export async function unsubscribeWebPush(): Promise { + if (!("serviceWorker" in navigator) || !("PushManager" in window)) return; + const registration = await navigator.serviceWorker.getRegistration("/sw.js"); + const subscription = await registration?.pushManager.getSubscription(); + if (!subscription) return; + await apiFetchRaw("/api/notifications/web-push", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ endpoint: subscription.endpoint }), + }); + await subscription.unsubscribe(); +} From 7f8ba0ae98ecf19d78b8b4523168ae2d7d3fb2d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 20:46:58 +0100 Subject: [PATCH 5/7] fix(push): tell iPhone Safari users to open the Home Screen app iOS offers Web Push only to a web app opened from the Home Screen. In a Safari tab the push card said the browser does not support push notifications, and the step that fixes it was only in the docs. On an iPhone or iPad outside the installed app the card now says to add HealthLog to the Home Screen, open it from there and turn push on. WebPushCard grows by two lines for the second sentence; its baseline goes from 201 to 203, still below the 215 it had before the previous change. --- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/ko.json | 1 + messages/nl.json | 1 + messages/pl.json | 1 + messages/pt-BR.json | 1 + messages/ru.json | 1 + .../baselines/source-function-lines.json | 4 +- .../web-push-card-home-screen.test.ts | 62 +++++++++++++++++++ src/components/settings/web-push-card.tsx | 29 +++++++-- 13 files changed, 97 insertions(+), 8 deletions(-) create mode 100644 src/components/settings/__tests__/web-push-card-home-screen.test.ts diff --git a/messages/de.json b/messages/de.json index 737f6dce7..146f8a994 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6377,6 +6377,7 @@ "webPush": "Browser-Push", "webPushDescription": "Erhalte Benachrichtigungen direkt im Browser, auch wenn HealthLog nicht geöffnet ist.", "webPushNotSupported": "Dein Browser unterstützt keine Push-Benachrichtigungen.", + "webPushInstallFirst": "Auf iPhone und iPad funktionieren Push-Benachrichtigungen nur in der installierten App. Tippe in Safari auf Teilen und dann auf „Zum Home-Bildschirm“, öffne HealthLog vom Home-Bildschirm aus und schalte Push hier ein.", "webPushDenied": "Push-Benachrichtigungen wurden blockiert. Erlaube sie in den Browser-Einstellungen.", "webPushNotConfigured": "Web Push ist serverseitig nicht konfiguriert.", "webPushSubscribe": "Push aktivieren", diff --git a/messages/en.json b/messages/en.json index 8877c35d5..7341a1e66 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6377,6 +6377,7 @@ "webPush": "Browser Push", "webPushDescription": "Receive notifications directly in your browser, even when HealthLog is not open.", "webPushNotSupported": "Your browser does not support push notifications.", + "webPushInstallFirst": "On iPhone and iPad, push notifications only work in the installed app. In Safari, tap Share, then Add to Home Screen, open HealthLog from the Home Screen and turn on push here.", "webPushDenied": "Push notifications are blocked. Allow them in your browser settings.", "webPushNotConfigured": "Web Push is not configured on the server.", "webPushSubscribe": "Enable push", diff --git a/messages/es.json b/messages/es.json index ad30b5020..b3f5203f9 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6377,6 +6377,7 @@ "webPush": "Push del navegador", "webPushDescription": "Recibe notificaciones directamente en tu navegador, incluso cuando HealthLog no está abierto.", "webPushNotSupported": "Tu navegador no admite notificaciones push.", + "webPushInstallFirst": "En iPhone y iPad, las notificaciones push solo funcionan en la app instalada. En Safari, toca Compartir y luego «Añadir a pantalla de inicio», abre HealthLog desde la pantalla de inicio y activa las notificaciones push aquí.", "webPushDenied": "Las notificaciones push están bloqueadas. Permítelas en los ajustes de tu navegador.", "webPushNotConfigured": "Web Push no está configurado en el servidor.", "webPushSubscribe": "Activar push", diff --git a/messages/fr.json b/messages/fr.json index ed15c1527..ef5ea9d9c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6377,6 +6377,7 @@ "webPush": "Push navigateur", "webPushDescription": "Recevez des notifications directement dans votre navigateur, même lorsque HealthLog n’est pas ouvert.", "webPushNotSupported": "Votre navigateur ne prend pas en charge les notifications push.", + "webPushInstallFirst": "Sur iPhone et iPad, les notifications push ne fonctionnent que dans l'app installée. Dans Safari, touchez Partager puis « Sur l'écran d'accueil », ouvrez HealthLog depuis l'écran d'accueil et activez les notifications push ici.", "webPushDenied": "Les notifications push sont bloquées. Autorisez-les dans les réglages de votre navigateur.", "webPushNotConfigured": "Web Push n’est pas configuré sur le serveur.", "webPushSubscribe": "Activer le push", diff --git a/messages/it.json b/messages/it.json index bb602f60a..9c695a0e4 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6377,6 +6377,7 @@ "webPush": "Push del browser", "webPushDescription": "Ricevi notifiche direttamente nel browser, anche quando HealthLog non è aperto.", "webPushNotSupported": "Il tuo browser non supporta le notifiche push.", + "webPushInstallFirst": "Su iPhone e iPad le notifiche push funzionano solo nell'app installata. In Safari tocca Condividi, poi «Aggiungi alla schermata Home», apri HealthLog dalla schermata Home e attiva qui le notifiche push.", "webPushDenied": "Le notifiche push sono bloccate. Consentile nelle impostazioni del browser.", "webPushNotConfigured": "Web Push non è configurato sul server.", "webPushSubscribe": "Attiva push", diff --git a/messages/ko.json b/messages/ko.json index a7baf05aa..c5314d117 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -6377,6 +6377,7 @@ "webPush": "브라우저 푸시", "webPushDescription": "HealthLog를 열어 두지 않아도 브라우저로 바로 알림을 받아요.", "webPushNotSupported": "이 브라우저는 푸시 알림을 지원하지 않아요.", + "webPushInstallFirst": "iPhone과 iPad에서는 설치한 앱에서만 푸시 알림을 받을 수 있어요. Safari에서 공유를 누른 다음 '홈 화면에 추가'를 선택하고, 홈 화면에서 HealthLog를 연 뒤 여기서 푸시 알림을 켜 주세요.", "webPushDenied": "푸시 알림이 차단돼 있어요. 브라우저 설정에서 허용해 주세요.", "webPushNotConfigured": "서버에 Web Push가 설정돼 있지 않아요.", "webPushSubscribe": "푸시 켜기", diff --git a/messages/nl.json b/messages/nl.json index 67807173c..9398345ba 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -6377,6 +6377,7 @@ "webPush": "Browserpush", "webPushDescription": "Ontvang meldingen rechtstreeks in je browser, ook als HealthLog niet geopend is.", "webPushNotSupported": "Je browser ondersteunt geen pushmeldingen.", + "webPushInstallFirst": "Op iPhone en iPad werken pushmeldingen alleen in de geïnstalleerde app. Tik in Safari op Deel en daarna op 'Zet op beginscherm', open HealthLog vanaf het beginscherm en zet pushmeldingen hier aan.", "webPushDenied": "Pushmeldingen zijn geblokkeerd. Sta ze toe in de instellingen van je browser.", "webPushNotConfigured": "Web Push is niet geconfigureerd op de server.", "webPushSubscribe": "Push inschakelen", diff --git a/messages/pl.json b/messages/pl.json index 6bc8109e9..655e74b46 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6377,6 +6377,7 @@ "webPush": "Push przeglądarki", "webPushDescription": "Otrzymuj powiadomienia bezpośrednio w przeglądarce, nawet gdy HealthLog nie jest otwarty.", "webPushNotSupported": "Twoja przeglądarka nie obsługuje powiadomień push.", + "webPushInstallFirst": "Na iPhonie i iPadzie powiadomienia push działają tylko w zainstalowanej aplikacji. W Safari stuknij Udostępnij, potem „Do ekranu początkowego”, otwórz HealthLog z ekranu początkowego i włącz tutaj powiadomienia push.", "webPushDenied": "Powiadomienia push są zablokowane. Zezwól na nie w ustawieniach przeglądarki.", "webPushNotConfigured": "Web Push nie jest skonfigurowany na serwerze.", "webPushSubscribe": "Włącz push", diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 5efe0f404..941c24df1 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -6377,6 +6377,7 @@ "webPush": "Push do navegador", "webPushDescription": "Receba notificações diretamente no navegador, mesmo quando o HealthLog não estiver aberto.", "webPushNotSupported": "Seu navegador não oferece suporte a notificações push.", + "webPushInstallFirst": "No iPhone e no iPad, as notificações push só funcionam no app instalado. No Safari, toque em Compartilhar e depois em “Adicionar à Tela de Início”, abra o HealthLog pela Tela de Início e ative as notificações push aqui.", "webPushDenied": "As notificações push estão bloqueadas. Permita-as nas configurações do navegador.", "webPushNotConfigured": "O Web Push não está configurado no servidor.", "webPushSubscribe": "Ativar push", diff --git a/messages/ru.json b/messages/ru.json index 412c87fa9..ce87f9f0d 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -6377,6 +6377,7 @@ "webPush": "Push в браузере", "webPushDescription": "Получайте уведомления прямо в браузере, даже когда HealthLog не открыт.", "webPushNotSupported": "Ваш браузер не поддерживает push-уведомления.", + "webPushInstallFirst": "На iPhone и iPad push-уведомления работают только в установленном приложении. В Safari нажмите «Поделиться», затем «На экран „Домой“», откройте HealthLog с экрана «Домой» и включите push-уведомления здесь.", "webPushDenied": "Push-уведомления заблокированы. Разрешите их в настройках браузера.", "webPushNotConfigured": "Web Push не настроен на сервере.", "webPushSubscribe": "Включить push", diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index ad2d1a644..e9e3df96f 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": 111493, + "total": 111495, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275, @@ -224,7 +224,7 @@ "components/settings/sharing-section.tsx :: ShareLinksCard": 260, "components/settings/sources-section.tsx :: SourcesSection": 601, "components/settings/thresholds-editor-section.tsx :: MetricRow": 241, - "components/settings/web-push-card.tsx :: WebPushCard": 201, + "components/settings/web-push-card.tsx :: WebPushCard": 203, "components/symptoms/symptom-entry-form.tsx :: SymptomEntryForm": 317, "components/symptoms/symptoms-section.tsx :: SymptomsSection": 205, "components/targets/range-bar.tsx :: RangeBar": 220, diff --git a/src/components/settings/__tests__/web-push-card-home-screen.test.ts b/src/components/settings/__tests__/web-push-card-home-screen.test.ts new file mode 100644 index 000000000..fefe8e260 --- /dev/null +++ b/src/components/settings/__tests__/web-push-card-home-screen.test.ts @@ -0,0 +1,62 @@ +/** + * Which "push is unavailable" sentence the Web Push card shows. + * + * An iPhone in a Safari tab has no PushManager, the same as a browser that + * cannot do push at all, and the card used to tell both that push is not + * supported. On iOS the fix is to open HealthLog from the Home Screen, so + * that case gets its own sentence. + */ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { needsHomeScreen } from "../web-push-card"; + +const IPHONE = + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1"; +const IPAD_AS_MAC = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Safari/605.1.15"; +const ANDROID_FIREFOX = + "Mozilla/5.0 (Android 14; Mobile; rv:132.0) Gecko/132.0 Firefox/132.0"; + +function device( + userAgent: string, + maxTouchPoints: number, + standalone: boolean, +) { + vi.stubGlobal("navigator", { userAgent, maxTouchPoints }); + vi.stubGlobal("window", { + matchMedia: (query: string) => ({ + matches: standalone && query === "(display-mode: standalone)", + }), + }); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("needsHomeScreen", () => { + it("asks an iPhone in a Safari tab to open the Home Screen app", () => { + device(IPHONE, 5, false); + expect(needsHomeScreen()).toBe(true); + }); + + it("asks an iPad, which reports itself as a Mac, the same", () => { + device(IPAD_AS_MAC, 5, false); + expect(needsHomeScreen()).toBe(true); + }); + + it("does not ask the installed app, where push is missing for real", () => { + device(IPHONE, 5, true); + expect(needsHomeScreen()).toBe(false); + }); + + it("does not ask a Mac without a touch screen", () => { + device(IPAD_AS_MAC, 0, false); + expect(needsHomeScreen()).toBe(false); + }); + + it("does not ask a browser that has no push at all", () => { + device(ANDROID_FIREFOX, 5, false); + expect(needsHomeScreen()).toBe(false); + }); +}); diff --git a/src/components/settings/web-push-card.tsx b/src/components/settings/web-push-card.tsx index c9515fc88..f25ae2795 100644 --- a/src/components/settings/web-push-card.tsx +++ b/src/components/settings/web-push-card.tsx @@ -25,10 +25,25 @@ function urlBase64ToArrayBuffer(base64String: string): ArrayBuffer { return buffer; } +/** + * iPhone and iPad offer Web Push only to HealthLog opened from the Home + * Screen; in a Safari tab the API is missing. iPadOS Safari reports itself + * as a Mac, so a Mac with a touch screen counts too. + */ +export function needsHomeScreen(): boolean { + const ua = navigator.userAgent; + const appleTouch = + /iPhone|iPad|iPod/.test(ua) || + (/Macintosh/.test(ua) && navigator.maxTouchPoints > 1); + return appleTouch && !window.matchMedia("(display-mode: standalone)").matches; +} + +type Unsupported = "browser" | "install" | null; + export function WebPushCard() { const { t } = useTranslations(); const [isSubscribed, setIsSubscribed] = useState(false); - const [isSupported, setIsSupported] = useState(true); + const [unsupported, setUnsupported] = useState(null); const [isDenied, setIsDenied] = useState(false); const [loading, setLoading] = useState(true); const [actionLoading, setActionLoading] = useState(false); @@ -41,7 +56,7 @@ export function WebPushCard() { // the effect body is rejected. async function checkSubscription() { if (!("serviceWorker" in navigator) || !("PushManager" in window)) { - setIsSupported(false); + setUnsupported(needsHomeScreen() ? "install" : "browser"); setLoading(false); return; } @@ -156,7 +171,7 @@ export function WebPushCard() { // carries a status badge. const cardAction = loading ? ( - ) : !isSupported || isDenied ? null : ( + ) : unsupported || isDenied ? null : ( {isSubscribed && ( @@ -199,11 +214,13 @@ export function WebPushCard() { description={t("settings.webPushDescription")} /> - {(!loading && !isSupported) || (!loading && isDenied) || msg ? ( + {(!loading && unsupported) || (!loading && isDenied) || msg ? (
- {!loading && !isSupported ? ( + {!loading && unsupported ? (

- {t("settings.webPushNotSupported")} + {unsupported === "install" + ? t("settings.webPushInstallFirst") + : t("settings.webPushNotSupported")}

) : !loading && isDenied ? (

From af623d12db4733bab3c21eab62622b9a6a7e71d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 20:52:36 +0100 Subject: [PATCH 6/7] fix(push): send the test push to every device, in the user's language The Web Push test went to the newest subscription only. With a phone and a laptop subscribed, Test pressed on the laptop buzzed the phone and the laptop looked broken. The text was English in every locale, and the push was dialled through the library's own transport, past the pinned safeFetch that real sends use. The route now calls sendViaWebPush, so the test reaches every subscription, reaps endpoints the push service reports gone, and goes out through the same dial as a reminder. Title and body come from the account's locale. The response keeps ok and latencyMs and gains reason; sent and perEndpoint are gone, nothing read them. --- messages/de.json | 3 +- messages/en.json | 3 +- messages/es.json | 3 +- messages/fr.json | 3 +- messages/it.json | 3 +- messages/ko.json | 3 +- messages/nl.json | 3 +- messages/pl.json | 3 +- messages/pt-BR.json | 3 +- messages/ru.json | 3 +- src/__tests__/baselines/test-as-never.json | 5 +- .../web-push/test/__tests__/route.test.ts | 282 +++++++++--------- .../api/notifications/web-push/test/route.ts | 98 ++---- .../openapi/routes/notifications-transport.ts | 42 +-- 14 files changed, 210 insertions(+), 247 deletions(-) diff --git a/messages/de.json b/messages/de.json index 146f8a994..721e72a42 100644 --- a/messages/de.json +++ b/messages/de.json @@ -9255,7 +9255,8 @@ "accountErasureFailedBody": "Die Löschfrist von {username} ist abgelaufen, aber das Löschen des Kontos ist fehlgeschlagen. Es bleibt gesperrt und wird jede Stunde erneut versucht, nach den anderen fälligen Konten. Den Fehler zeigt die Karte der fehlgeschlagenen Jobs unter Administration." }, "user": { - "telegramTestBody": "HealthLog: Verbindung hergestellt. Telegram-Benachrichtigungen sind aktiv." + "telegramTestBody": "HealthLog: Verbindung hergestellt. Telegram-Benachrichtigungen sind aktiv.", + "webPushTestBody": "Wenn du das siehst, kommen Push-Benachrichtigungen auf diesem Gerät an." }, "moodReminder": { "title": "Tägliche Stimmungs-Erinnerung", diff --git a/messages/en.json b/messages/en.json index 7341a1e66..5212ca494 100644 --- a/messages/en.json +++ b/messages/en.json @@ -9255,7 +9255,8 @@ "accountErasureFailedBody": "The deletion window of {username} has ended, but erasing the account failed. It stays locked and is tried again every hour, after the other accounts due. The failing jobs card in Administration has the error." }, "user": { - "telegramTestBody": "HealthLog: connection successful. Telegram notifications are active." + "telegramTestBody": "HealthLog: connection successful. Telegram notifications are active.", + "webPushTestBody": "If you can see this, push notifications reach this device." }, "moodReminder": { "title": "Daily mood reminder", diff --git a/messages/es.json b/messages/es.json index b3f5203f9..61217c9e8 100644 --- a/messages/es.json +++ b/messages/es.json @@ -9255,7 +9255,8 @@ "accountErasureFailedBody": "El plazo de eliminación de {username} ha terminado, pero no se pudo borrar la cuenta. Sigue bloqueada y se vuelve a intentar cada hora, después de las demás cuentas pendientes. El error aparece en la tarjeta de tareas fallidas de Administration." }, "user": { - "telegramTestBody": "HealthLog: conexión correcta. Las notificaciones de Telegram están activas." + "telegramTestBody": "HealthLog: conexión correcta. Las notificaciones de Telegram están activas.", + "webPushTestBody": "Si ves esto, las notificaciones push llegan a este dispositivo." }, "moodReminder": { "title": "Recordatorio diario de ánimo", diff --git a/messages/fr.json b/messages/fr.json index ef5ea9d9c..3f728eda2 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -9255,7 +9255,8 @@ "accountErasureFailedBody": "Le délai de suppression de {username} est écoulé, mais l’effacement du compte a échoué. Il reste verrouillé et une nouvelle tentative a lieu chaque heure, après les autres comptes en attente. L’erreur figure dans la carte des tâches en échec, dans Administration." }, "user": { - "telegramTestBody": "HealthLog: connexion réussie. Les notifications Telegram sont actives." + "telegramTestBody": "HealthLog: connexion réussie. Les notifications Telegram sont actives.", + "webPushTestBody": "Si vous voyez ceci, les notifications push arrivent sur cet appareil." }, "moodReminder": { "title": "Rappel quotidien d’humeur", diff --git a/messages/it.json b/messages/it.json index 9c695a0e4..bfe254835 100644 --- a/messages/it.json +++ b/messages/it.json @@ -9255,7 +9255,8 @@ "accountErasureFailedBody": "Il periodo di eliminazione di {username} è terminato, ma la cancellazione dell’account non è riuscita. Resta bloccato e viene ritentata ogni ora, dopo gli altri account in scadenza. L’errore è nella scheda dei job non riusciti in Administration." }, "user": { - "telegramTestBody": "HealthLog: connessione riuscita. Le notifiche Telegram sono attive." + "telegramTestBody": "HealthLog: connessione riuscita. Le notifiche Telegram sono attive.", + "webPushTestBody": "Se vedi questo messaggio, le notifiche push arrivano su questo dispositivo." }, "moodReminder": { "title": "Promemoria quotidiano dell’umore", diff --git a/messages/ko.json b/messages/ko.json index c5314d117..501d360a3 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -9255,7 +9255,8 @@ "accountErasureFailedBody": "{username} 계정의 삭제 유예 기간이 끝났지만 계정을 지우지 못했어요. 계정은 계속 잠겨 있고, 삭제할 다른 계정들 다음으로 매시간 다시 시도해요. 오류는 관리자 화면의 실패한 작업 카드에서 볼 수 있어요." }, "user": { - "telegramTestBody": "HealthLog: 연결에 성공했어요. Telegram 알림이 켜져 있어요." + "telegramTestBody": "HealthLog: 연결에 성공했어요. Telegram 알림이 켜져 있어요.", + "webPushTestBody": "이 알림이 보이면 이 기기에서 푸시 알림을 받을 수 있어요." }, "moodReminder": { "title": "매일 기분 알림", diff --git a/messages/nl.json b/messages/nl.json index 9398345ba..09fb725ea 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -9255,7 +9255,8 @@ "accountErasureFailedBody": "De verwijdertermijn van {username} is afgelopen, maar het wissen van het account is mislukt. Het blijft vergrendeld en wordt elk uur opnieuw geprobeerd, na de andere accounts die aan de beurt zijn. De fout staat op de kaart met mislukte taken in Beheer." }, "user": { - "telegramTestBody": "HealthLog: verbinding geslaagd. Telegram-meldingen zijn actief." + "telegramTestBody": "HealthLog: verbinding geslaagd. Telegram-meldingen zijn actief.", + "webPushTestBody": "Zie je dit, dan komen pushmeldingen aan op dit apparaat." }, "moodReminder": { "title": "Dagelijkse stemmingsherinnering", diff --git a/messages/pl.json b/messages/pl.json index 655e74b46..699081867 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -9255,7 +9255,8 @@ "accountErasureFailedBody": "Okres usuwania konta {username} minął, ale usunięcie konta się nie powiodło. Konto pozostaje zablokowane, a próba jest ponawiana co godzinę, po innych kontach do usunięcia. Błąd widać na karcie nieudanych zadań w Administracja." }, "user": { - "telegramTestBody": "HealthLog: połączenie udane. Powiadomienia Telegram są aktywne." + "telegramTestBody": "HealthLog: połączenie udane. Powiadomienia Telegram są aktywne.", + "webPushTestBody": "Jeśli to widzisz, powiadomienia push docierają na to urządzenie." }, "moodReminder": { "title": "Codzienne przypomnienie o nastroju", diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 941c24df1..3da35552e 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -9255,7 +9255,8 @@ "accountErasureFailedBody": "O prazo de exclusão de {username} terminou, mas apagar a conta falhou. Ela continua bloqueada e uma nova tentativa é feita a cada hora, depois das outras contas pendentes. O erro aparece no cartão de tarefas com falha em Administração." }, "user": { - "telegramTestBody": "HealthLog: conexão bem-sucedida. As notificações do Telegram estão ativas." + "telegramTestBody": "HealthLog: conexão bem-sucedida. As notificações do Telegram estão ativas.", + "webPushTestBody": "Se você está vendo isto, as notificações push chegam a este dispositivo." }, "moodReminder": { "title": "Lembrete diário de humor", diff --git a/messages/ru.json b/messages/ru.json index ce87f9f0d..1703b52cb 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -9255,7 +9255,8 @@ "accountErasureFailedBody": "Срок удаления {username} истёк, но удалить аккаунт не удалось. Он остаётся заблокированным, и попытка повторяется каждый час после остальных аккаунтов с наступившим сроком. Ошибка указана в карточке сбоящих заданий в разделе Администрирование." }, "user": { - "telegramTestBody": "HealthLog: подключение успешно. Уведомления в Telegram активны." + "telegramTestBody": "HealthLog: подключение успешно. Уведомления в Telegram активны.", + "webPushTestBody": "Если вы видите это сообщение, push-уведомления приходят на это устройство." }, "moodReminder": { "title": "Ежедневное напоминание о настроении", diff --git a/src/__tests__/baselines/test-as-never.json b/src/__tests__/baselines/test-as-never.json index 20b560254..8806cc7df 100644 --- a/src/__tests__/baselines/test-as-never.json +++ b/src/__tests__/baselines/test-as-never.json @@ -1,7 +1,7 @@ { "$comment": "as never casts in tests, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.", - "count": 523, - "total": 5475, + "count": 522, + "total": 5466, "entries": { "__tests__/api/auth/login/mfa-branch.test.ts": 10, "__tests__/api/auth/mfa/verify.test.ts": 9, @@ -219,7 +219,6 @@ "app/api/notifications/action/[token]/__tests__/route.test.ts": 5, "app/api/notifications/status/__tests__/route.test.ts": 4, "app/api/notifications/web-push/__tests__/route.test.ts": 5, - "app/api/notifications/web-push/test/__tests__/route.test.ts": 9, "app/api/nutrients/__tests__/route.test.ts": 5, "app/api/nutrients/batch/__tests__/route.test.ts": 11, "app/api/nutrients/daily/__tests__/route.test.ts": 7, diff --git a/src/app/api/notifications/web-push/test/__tests__/route.test.ts b/src/app/api/notifications/web-push/test/__tests__/route.test.ts index ba8532fe8..980830013 100644 --- a/src/app/api/notifications/web-push/test/__tests__/route.test.ts +++ b/src/app/api/notifications/web-push/test/__tests__/route.test.ts @@ -1,176 +1,186 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +/** + * The Web Push test reaches every subscribed browser of the account, in the + * account's language, through the sender real reminders use. + * + * It used to send to the newest subscription only: with a phone and a laptop + * subscribed, Test pressed on the laptop buzzed the phone and the laptop + * looked broken. The text was English in every locale, and the push went out + * through the library's own transport instead of the pinned `safeFetch` dial. + * + * The real sender runs here; the push library's signing, the dial and the + * database are stubbed. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { PushSubscription } from "@/generated/prisma/client"; -vi.mock("@/lib/api-handler", () => ({ - apiHandler: unknown>(fn: T) => fn, - requireAuth: vi.fn(async () => ({ - user: { id: "u-1" }, - session: { id: "s-1" }, - })), +const mocks = vi.hoisted(() => ({ + requireAuth: vi.fn(), + findMany: vi.fn(), + deleteMany: vi.fn(), + getVapidConfig: vi.fn(), + safeFetch: vi.fn(), + generateRequestDetails: vi.fn(), + checkRateLimit: vi.fn(), })); +vi.mock("@/lib/api-handler", () => ({ + apiHandler: (fn: T) => fn, + requireAuth: mocks.requireAuth, +})); vi.mock("@/lib/db", () => ({ prisma: { pushSubscription: { - findFirst: vi.fn(), + findMany: mocks.findMany, + deleteMany: mocks.deleteMany, }, }, })); - -vi.mock("@/lib/rate-limit", () => ({ - checkRateLimit: vi.fn(async () => ({ allowed: true })), -})); - +vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: mocks.checkRateLimit })); vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn(), + getEvent: () => null, })); - -vi.mock("@/lib/crypto", () => ({ - decrypt: vi.fn((x: string) => x), -})); - +vi.mock("@/lib/crypto", () => ({ decrypt: (v: string) => v })); vi.mock("@/lib/notifications/vapid-config", () => ({ - getVapidConfig: vi.fn(async () => ({ - subject: "mailto:test@example.com", - publicKey: - "BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4YfYCA_0QTpQtUbVlUls0VJXg7A8u-Ts1XbjhazAkj7I99e8QcYP7DkM", - privateKey: "private-key-stub", - })), + getVapidConfig: mocks.getVapidConfig, })); - -const sendNotification = vi.fn(); +vi.mock("@/lib/notifications/senders/push-attempt-record", () => ({ + recordPushAttemptForPayload: vi.fn(), +})); +vi.mock("@/lib/safe-fetch", () => ({ safeFetch: mocks.safeFetch })); vi.mock("web-push", () => ({ - default: { - setVapidDetails: vi.fn(), - sendNotification: (...args: unknown[]) => sendNotification(...args), - }, setVapidDetails: vi.fn(), - sendNotification: (...args: unknown[]) => sendNotification(...args), + generateRequestDetails: mocks.generateRequestDetails, })); import { POST } from "../route"; -import { prisma } from "@/lib/db"; -import { checkRateLimit } from "@/lib/rate-limit"; -interface ApiErrorEnvelope { - data: null; - error: string; -} -interface ApiSuccessEnvelope { - data: T; - error: null; +type Handler = (request: Request) => Promise; +const post = () => + (POST as unknown as Handler)( + new Request("http://localhost/api/notifications/web-push/test", { + method: "POST", + }), + ); + +function subscription(id: string, endpoint: string): PushSubscription { + return { + id, + userId: "u-1", + endpoint, + p256dh: "p256", + auth: "auth", + userAgent: null, + createdAt: new Date(0), + }; } -const originalFetch = global.fetch; +const PHONE = subscription( + "s-phone", + "https://fcm.googleapis.com/fcm/send/phone-secret", +); +const LAPTOP = subscription( + "s-laptop", + "https://updates.push.services.mozilla.com/wpush/v2/laptop-secret", +); + +/** The JSON payload the sender encrypted for each subscription. */ +function payloadsSent(): Array<{ title: string; body: string; tag: string }> { + return mocks.generateRequestDetails.mock.calls.map(([, payload]) => + JSON.parse(String(payload)), + ); +} beforeEach(() => { - vi.mocked(checkRateLimit).mockResolvedValue({ - allowed: true, - remaining: 5, - resetAt: Date.now() + 60_000, - } as never); - vi.mocked(prisma.pushSubscription.findFirst).mockReset(); - sendNotification.mockReset(); - global.fetch = vi.fn() as never; -}); - -afterEach(() => { - global.fetch = originalFetch; -}); - -function emptyRequest(): Request { - return new Request("http://localhost/api/notifications/web-push/test", { - method: "POST", - headers: { "content-length": "0" }, + vi.clearAllMocks(); + mocks.requireAuth.mockResolvedValue({ user: { id: "u-1", locale: "de" } }); + mocks.checkRateLimit.mockResolvedValue({ allowed: true }); + mocks.getVapidConfig.mockResolvedValue({ + subject: "mailto:ops@example.com", + publicKey: "pub", + privateKey: "priv", }); -} + mocks.findMany.mockResolvedValue([PHONE, LAPTOP]); + mocks.deleteMany.mockResolvedValue({ count: 0 }); + mocks.generateRequestDetails.mockImplementation( + (sub: { endpoint: string }) => ({ + endpoint: sub.endpoint, + method: "POST", + headers: { TTL: "60" }, + body: null, + }), + ); + mocks.safeFetch.mockResolvedValue(new Response(null, { status: 201 })); +}); describe("POST /api/notifications/web-push/test", () => { - it("happy path returns ok and sends to one subscription", async () => { - vi.mocked(prisma.pushSubscription.findFirst).mockResolvedValueOnce({ - id: "p-1", - userId: "u-1", - endpoint: "https://fcm.googleapis.com/fcm/send/abc123token", - p256dh: "p256", - auth: "auth", - } as never); - sendNotification.mockResolvedValueOnce({ statusCode: 201 }); + it("sends to every subscribed browser, in the account's language", async () => { + const res = await post(); - const response = await POST(emptyRequest() as never); - expect(response.status).toBe(200); - const body = (await response.json()) as ApiSuccessEnvelope<{ - ok: boolean; - sent: number; - latencyMs: number; - perEndpoint: Array<{ host: string; status: number | null }>; - }>; + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { ok: boolean; latencyMs: number }; + }; expect(body.data.ok).toBe(true); - expect(body.data.sent).toBe(1); - expect(body.data.perEndpoint).toHaveLength(1); - expect(body.data.perEndpoint[0].host).toBe("fcm.googleapis.com"); - // never the full endpoint URL (the routing token is the only thing - // protecting that subscription from anyone who learns it). - const text = JSON.stringify(body); - expect(text).not.toMatch(/abc123token/); + expect(typeof body.data.latencyMs).toBe("number"); - // Spec: only the most-recent subscription gets the test push, not all. - expect(prisma.pushSubscription.findFirst).toHaveBeenCalledWith( + expect(mocks.findMany).toHaveBeenCalledWith({ where: { userId: "u-1" } }); + expect(mocks.safeFetch.mock.calls.map(([url]) => url)).toEqual([ + PHONE.endpoint, + LAPTOP.endpoint, + ]); + for (const call of mocks.safeFetch.mock.calls) { + expect(call[2]).toEqual({ requirePublicHost: true }); + } + expect(payloadsSent()).toEqual([ expect.objectContaining({ - where: { userId: "u-1" }, - orderBy: { createdAt: "desc" }, + title: "HealthLog Test-Benachrichtigung", + body: "Wenn du das siehst, kommen Push-Benachrichtigungen auf diesem Gerät an.", + tag: "self-test", }), - ); - expect(sendNotification).toHaveBeenCalledTimes(1); + expect.objectContaining({ tag: "self-test" }), + ]); }); - it("rate-limit denial returns 429 with no upstream call", async () => { - vi.mocked(checkRateLimit).mockResolvedValueOnce({ - allowed: false, - remaining: 0, - resetAt: Date.now() + 60_000, - } as never); - const response = await POST(emptyRequest() as never); - expect(response.status).toBe(429); - expect(sendNotification).not.toHaveBeenCalled(); + it("answers 422 not_configured when no browser is subscribed", async () => { + mocks.findMany.mockResolvedValue([]); + + const res = await post(); + + expect(res.status).toBe(422); + const body = (await res.json()) as { meta?: { errorCode?: string } }; + expect(body.meta?.errorCode).toBe("not_configured"); + expect(mocks.safeFetch).not.toHaveBeenCalled(); }); - it("returns 422 when no subscriptions registered", async () => { - vi.mocked(prisma.pushSubscription.findFirst).mockResolvedValueOnce(null); - const response = await POST(emptyRequest() as never); - expect(response.status).toBe(422); - const body = (await response.json()) as ApiErrorEnvelope; - expect(body.error).toMatch(/no push subscriptions/i); - expect(sendNotification).not.toHaveBeenCalled(); + it("answers 422 vapid_not_configured without VAPID keys", async () => { + mocks.getVapidConfig.mockResolvedValue(null); + + const res = await post(); + + expect(res.status).toBe(422); + const body = (await res.json()) as { meta?: { errorCode?: string } }; + expect(body.meta?.errorCode).toBe("vapid_not_configured"); + expect(mocks.safeFetch).not.toHaveBeenCalled(); }); - it("does not leak Bearer/sk- or full endpoint URL when upstream rejects", async () => { - vi.mocked(prisma.pushSubscription.findFirst).mockResolvedValueOnce({ - id: "p-1", - userId: "u-1", - endpoint: "https://fcm.googleapis.com/fcm/send/abc123token", - p256dh: "p256", - auth: "auth", - } as never); - sendNotification.mockRejectedValueOnce( - Object.assign( - new Error( - "401 Unauthorized: Authorization: Bearer sk-secret from https://fcm.googleapis.com/fcm/send/abc123token", - ), - { statusCode: 401 }, - ), - ); - const response = await POST(emptyRequest() as never); - const text = await response.text(); - expect(response.status).toBe(200); - expect(text).not.toMatch(/sk-/); - expect(text).not.toMatch(/abc123token/); - const parsed = JSON.parse(text) as ApiSuccessEnvelope<{ - ok: boolean; - sent: number; - perEndpoint: Array<{ host: string; status: number | null }>; - }>; - expect(parsed.data.ok).toBe(false); - expect(parsed.data.sent).toBe(0); - expect(parsed.data.perEndpoint[0].host).toBe("fcm.googleapis.com"); - expect(parsed.data.perEndpoint[0].status).toBe(401); + it("refuses over the rate limit without sending", async () => { + mocks.checkRateLimit.mockResolvedValue({ allowed: false }); + + const res = await post(); + + expect(res.status).toBe(429); + expect(mocks.safeFetch).not.toHaveBeenCalled(); + }); + + it("reports a rejected push without echoing an endpoint", async () => { + mocks.safeFetch.mockResolvedValue(new Response(null, { status: 401 })); + + const res = await post(); + const text = await res.text(); + + expect(res.status).toBe(200); + expect(JSON.parse(text).data.ok).toBe(false); + expect(text).not.toMatch(/phone-secret|laptop-secret/); }); }); diff --git a/src/app/api/notifications/web-push/test/route.ts b/src/app/api/notifications/web-push/test/route.ts index 94e4c92f5..54e45d09f 100644 --- a/src/app/api/notifications/web-push/test/route.ts +++ b/src/app/api/notifications/web-push/test/route.ts @@ -3,28 +3,20 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiSuccess, apiError } from "@/lib/api-response"; import { checkRateLimit } from "@/lib/rate-limit"; import { annotate } from "@/lib/logging/context"; -import { prisma } from "@/lib/db"; -import { decrypt } from "@/lib/crypto"; -import { getVapidConfig } from "@/lib/notifications/vapid-config"; +import { coerceLocale } from "@/lib/i18n/config"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; +import { sendViaWebPush } from "@/lib/notifications/senders/web-push"; export const dynamic = "force-dynamic"; -function hostFromEndpoint(endpoint: string): string { - try { - return new URL(endpoint).host; - } catch { - return "unknown"; - } -} - -// Strip any URL from a stringified web-push library error. Push providers (FCM, -// Mozilla autopush, etc.) embed the full subscription endpoint into 410/404 -// error messages — that endpoint token is the routing secret, so it must never -// land in our Wide Events. -function redactPushError(text: string): string { - return text.replace(/https?:\/\/\S+/gi, "[endpoint]"); -} - +/** + * POST /api/notifications/web-push/test + * + * Sends a test push to every browser subscribed for the account, through the + * sender real reminders use, in the account's language. Pressing Test on one + * device must reach that device; the newest subscription alone did not when + * a person had more than one. + */ export const POST = apiHandler(async (request: NextRequest) => { void request; const { user } = await requireAuth(); @@ -37,68 +29,32 @@ export const POST = apiHandler(async (request: NextRequest) => { }); } - // Spec: only the most-recent subscription receives the test push. A user - // with phone + tablet + desktop should not get three buzzes per click. - const subscription = await prisma.pushSubscription.findFirst({ - where: { userId: user.id }, - orderBy: { createdAt: "desc" }, + const { t } = getServerTranslator(coerceLocale(user.locale)); + const start = performance.now(); + const result = await sendViaWebPush(user.id, { + eventType: "SYSTEM_ALERT", + userId: user.id, + title: t("notifications.admin.testNotificationTitle"), + message: t("notifications.user.webPushTestBody"), + metadata: { webPushTag: "self-test" }, }); + const latencyMs = Math.round(performance.now() - start); - if (!subscription) { + if (result.reason === "web_push_no_subscriptions") { return apiError("No push subscriptions registered", 422, { errorCode: "not_configured", }); } - - const config = await getVapidConfig(); - if (!config) { + if (result.reason === "web_push_vapid_not_configured") { return apiError("VAPID keys not configured", 422, { errorCode: "vapid_not_configured", }); } - const webpush = await import("web-push"); - webpush.setVapidDetails(config.subject, config.publicKey, config.privateKey); - - const pushPayload = JSON.stringify({ - title: "HealthLog", - body: "This is a test notification from HealthLog", - tag: "self-test", + annotate({ + meta: { web_push_test_ok: result.ok, web_push_test_reason: result.reason }, }); - - const host = hostFromEndpoint(subscription.endpoint); - const start = performance.now(); - - try { - const p256dh = decrypt(subscription.p256dh); - const auth = decrypt(subscription.auth); - - const result = await webpush.sendNotification( - { endpoint: subscription.endpoint, keys: { p256dh, auth } }, - pushPayload, - ); - const latencyMs = Math.round(performance.now() - start); - - return apiSuccess({ - ok: true, - sent: 1, - latencyMs, - perEndpoint: [{ host, status: result.statusCode ?? 201 }], - }); - } catch (err: unknown) { - const status = (err as { statusCode?: number }).statusCode ?? null; - const message = redactPushError((err as Error).message ?? "").slice(0, 200); - annotate({ - meta: { - web_push_test_status: status, - web_push_test_host: host, - web_push_test_error: message, - }, - }); - return apiSuccess({ - ok: false, - sent: 0, - perEndpoint: [{ host, status }], - }); - } + return apiSuccess( + result.ok ? { ok: true, latencyMs } : { ok: false, reason: result.reason }, + ); }); diff --git a/src/lib/openapi/routes/notifications-transport.ts b/src/lib/openapi/routes/notifications-transport.ts index 9eb27b5b4..1839477d9 100644 --- a/src/lib/openapi/routes/notifications-transport.ts +++ b/src/lib/openapi/routes/notifications-transport.ts @@ -66,34 +66,22 @@ const apnsTestResponse = z const webPushTestResponse = z .object({ - ok: z.boolean(), - sent: z - .number() - .int() - .describe("1 on success, 0 on failure. Never more than one."), + ok: z + .boolean() + .describe( + "Whether at least one of the account's subscriptions accepted the push.", + ), latencyMs: z .number() .int() .optional() - .describe("Round-trip to the push service. Present on success only."), - perEndpoint: z - .array( - z.object({ - host: z - .string() - .describe( - "Host of the push service the test went to — the endpoint path is a routing secret and is never returned.", - ), - status: z - .number() - .int() - .nullable() - .describe( - "The push service's HTTP status, or null when the attempt threw before one existed.", - ), - }), - ) - .describe("One entry: only the newest subscription is tested."), + .describe("Time to send to every subscription. Present on success only."), + reason: z + .string() + .optional() + .describe( + "Why it was not, when `ok` is false. Absent on success. The sender's own classification, the same code a failed reminder records (`web_push_410_gone` when every endpoint is gone, a status-derived code otherwise).", + ), }) .meta({ id: "WebPushTestResponse" }); @@ -200,10 +188,10 @@ export const notificationTransportPaths: NonNullable< "/api/notifications/web-push/test": { post: { tags: ["Notifications"], - summary: "Send a test push to the newest Web Push subscription", + summary: "Send a test push to every Web Push subscription", description: - "Fires one notification to the caller's MOST RECENT subscription only — a person with a phone, a tablet and a desktop registered should not get three buzzes per click. Takes no body. Rate-limited 5 per minute per user.\n\n" + - "Read `ok`, not the status. A push that the service rejects still answers 200, with `ok: false` and the service's own status in `perEndpoint`. Only the endpoint's HOST is reported: the full endpoint is the routing secret for that subscription, and push services embed it in their own error messages, so it is stripped before anything is logged or returned.", + "Fires one notification to each of the caller's subscriptions, through the same sender real reminders use, with the text in the account's language. The device the test was pressed on gets it whichever subscription is newest. Endpoints the push service reports gone (404/410) are removed, as on a real send. Takes no body. Rate-limited 5 per minute per user.\n\n" + + "Read `ok`, not the status. A push that every service rejects still answers 200, with `ok: false` and a `reason`. No endpoint is returned: the full endpoint is the routing secret for that subscription.", responses: { "200": { description: From ee04c5bcf45d9eb57e6e931f3a3536d3d03671c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 20:55:08 +0100 Subject: [PATCH 7/7] test: register the push unsubscribe DELETE by hand The DELETE behind "Disable push" moved into the shared unsubscribeWebPush helper under lib/, which the destructive-control detectors do not scan. The card's entry stays and says where the request goes out now. --- src/__tests__/destructive-control-guard.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts index 1dd48b750..b9f599b07 100644 --- a/src/__tests__/destructive-control-guard.test.ts +++ b/src/__tests__/destructive-control-guard.test.ts @@ -347,6 +347,8 @@ const REGISTRY: DestructiveEntry[] = [ "This is the off half of a notification channel toggle. Re-subscribing from the same control recreates the row, nothing the person authored is stored in it, and the browser permission survives. Gating it would put a dialog on a switch.", }, ], + undetected: + "No detector sees it. The DELETE goes out from `unsubscribeWebPush` in `lib/notifications/web-push-unsubscribe.ts`, which sign-out calls too, and `lib/` is an excluded root. Registered by hand.", }, // ── Measurements, mood, labs, illness — the undo-and-restore surfaces ────