From 94ea48d723a26eda32e1b2e58f36ca9b985650f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 12 Nov 2026 13:57:36 +0100 Subject: [PATCH] fix(push): make a new subscription deliver for its account at once Subscribing re-enabled a disabled channel but left the failure counter and cooldown on one that was still enabled. Failures counted before the new subscription (no browser left, a push service outage) kept every reminder skipped until the cooldown ran out, and one more transient failure switched the channel off. Subscribing now resets them too. A browser also kept the row of an account whose session ended without a sign-out, so the next person on it saw that account's reminders. An endpoint is one browser: subscribing now removes other accounts' rows for it, and the settings card, which posts its subscription on open, claims the browser for whoever uses it now. --- .../web-push/__tests__/route.test.ts | 51 +++++++++++++++++++ src/app/api/notifications/web-push/route.ts | 21 ++++++-- .../routes/settings-channels.test.ts | 21 ++++++++ 3 files changed, 89 insertions(+), 4 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 c93f702f4..65681cf16 100644 --- a/src/app/api/notifications/web-push/__tests__/route.test.ts +++ b/src/app/api/notifications/web-push/__tests__/route.test.ts @@ -214,6 +214,37 @@ describe("POST /api/notifications/web-push — the channel it delivers through", expect(prisma.notificationChannel.create).not.toHaveBeenCalled(); }); + it("clears failures and a cooldown left on an enabled channel", async () => { + // Four failed sends before the new subscription: one more transient + // failure would have switched the channel off, and the cooldown skipped + // every reminder until it ran out. + const strained = channelRow({ + consecutiveFailures: 4, + nextRetryAt: new Date(Date.now() + 2 * 3_600_000), + lastFailureReason: "web_push_no_subscriptions", + }); + vi.mocked(prisma.pushSubscription.upsert).mockResolvedValue( + STORED_SUBSCRIPTION, + ); + vi.mocked(prisma.notificationChannel.findFirst).mockResolvedValue(strained); + vi.mocked(prisma.notificationChannel.update).mockResolvedValue( + channelRow({}), + ); + + 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, + consecutiveFailures: 0, + nextRetryAt: null, + lastFailureReason: null, + }), + }); + }); + it("leaves an enabled channel as it is", async () => { vi.mocked(prisma.pushSubscription.upsert).mockResolvedValue( STORED_SUBSCRIPTION, @@ -230,6 +261,26 @@ describe("POST /api/notifications/web-push — the channel it delivers through", }); }); +describe("POST /api/notifications/web-push — one account per browser", () => { + it("removes another account's row for the same browser", async () => { + // A session that ended without a sign-out left the previous account's + // row on this endpoint; its reminders kept arriving for the next person. + 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.pushSubscription.deleteMany).toHaveBeenCalledWith({ + where: { endpoint: GOOD.endpoint, userId: { not: USER.id } }, + }); + }); +}); + 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 7b25c92f2..f8884f9e0 100644 --- a/src/app/api/notifications/web-push/route.ts +++ b/src/app/api/notifications/web-push/route.ts @@ -98,9 +98,18 @@ export const POST = apiHandler(async (request: NextRequest) => { }, }); - // 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. + // An endpoint is one browser, and the account that subscribed it last owns + // it. A row another account left (a session that ended without a sign-out) + // would keep showing that account's reminders to whoever uses it now. + await prisma.pushSubscription.deleteMany({ + where: { endpoint, userId: { not: user.id } }, + }); + + // Ensure an enabled WEB_PUSH channel that is not waiting out a cooldown. 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. Failures counted before it would switch it + // off again after a single later one. const existingChannel = await prisma.notificationChannel.findFirst({ where: { userId: user.id, type: "WEB_PUSH" }, }); @@ -114,7 +123,11 @@ export const POST = apiHandler(async (request: NextRequest) => { config: encrypt(JSON.stringify({})), }, }); - } else if (!existingChannel.enabled) { + } else if ( + !existingChannel.enabled || + existingChannel.consecutiveFailures > 0 || + existingChannel.nextRetryAt !== null + ) { await reEnableChannel({ id: existingChannel.id, userId: existingChannel.userId, diff --git a/tests/integration/routes/settings-channels.test.ts b/tests/integration/routes/settings-channels.test.ts index 81254cf79..e8c38ef1d 100644 --- a/tests/integration/routes/settings-channels.test.ts +++ b/tests/integration/routes/settings-channels.test.ts @@ -1060,4 +1060,25 @@ describe("the operator's instance-wide channel switches", () => { expect(decrypt(subscriptions[0].auth)).toBe(SUBSCRIPTION.keys.auth); expect((await channel(user.id, "WEB_PUSH"))?.enabled).toBe(true); }); + + it("hands a browser's subscription to the account that subscribed it last", async () => { + await switchGlobal({ webPushGlobal: true }); + const before = await makeUser(); + const after = await makeUser(); + for (const user of [before, after]) { + const res = await callRoute(subscribeWebPush, { + method: "POST", + route: "/api/notifications/web-push", + body: SUBSCRIPTION, + auth: await makeSession(user), + }); + expect(res.status).toBe(200); + } + + const rows = await prisma.pushSubscription.findMany({ + where: { endpoint: SUBSCRIPTION.endpoint }, + select: { userId: true }, + }); + expect(rows).toEqual([{ userId: after.id }]); + }); });