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",