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.
This commit is contained in:
Marc-André Bombeck committed 2026-11-12 13:57:36 +01:00
1 parent 38299f7c6e
commit 94ea48d723
3 files changed
+89 -4

No files matched your search

@@ -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)(
+17 -4
View File
@@ -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,