From d5c0e759b8a757ff3f76b8c4999ddd29b5c809b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 12 Nov 2026 13:48:51 +0100 Subject: [PATCH] fix(push): turn Web Push back on for accounts it was switched off for Before this release, a send with no browser subscription counted as a failure, and five of them, or one 410, switched the WEB_PUSH channel off. Subscribing again stored the browser but left the channel off, so the card read "enabled", a test push arrived, and no reminder was ever sent. Subscribing now re-enables the channel, but nothing prompts the people already caught to subscribe again. The migration re-enables every WEB_PUSH channel the dispatcher switched off (a disabled_reason is set; a channel switched off by hand has none) whose account still has a subscription, and clears the counter, the cooldown and the last failure the same way reEnableChannel does. --- .../migration.sql | 38 +++++ ...b-push-channel-re-enable-migration.test.ts | 151 ++++++++++++++++++ 2 files changed, 189 insertions(+) create mode 100644 prisma/migrations/0497_reenable_subscribed_web_push_channels/migration.sql create mode 100644 tests/integration/web-push-channel-re-enable-migration.test.ts diff --git a/prisma/migrations/0497_reenable_subscribed_web_push_channels/migration.sql b/prisma/migrations/0497_reenable_subscribed_web_push_channels/migration.sql new file mode 100644 index 000000000..5eb18c6c7 --- /dev/null +++ b/prisma/migrations/0497_reenable_subscribed_web_push_channels/migration.sql @@ -0,0 +1,38 @@ +-- Turn Web Push back on for every account that has a browser subscription +-- but whose WEB_PUSH channel was switched off by a delivery failure. +-- +-- Until this release a send with no subscription left (push turned off on the +-- last device, or the Home Screen app removed) counted as a failure, and five +-- of them, or one 410, switched the channel off. Subscribing again stored the +-- new browser but left the channel off, so the settings card read "enabled", +-- a test push arrived, and no reminder was ever sent. Subscribing now turns +-- the channel back on; this does the same for the accounts already caught. +-- +-- Only an automatic switch-off is touched: the dispatcher always writes a +-- `disabled_reason`, a channel switched off by hand has none. The fields +-- reset are the ones a re-enable resets (`reEnableChannel`). A channel +-- without any subscription stays off. Running this again finds nothing left +-- to change. +-- @retry-on-lock-timeout + +DO $migration$ +BEGIN + PERFORM set_config('lock_timeout', '5s', true); + + UPDATE "notification_channels" AS c + SET "enabled" = true, + "disabled_reason" = NULL, + "consecutive_failures" = 0, + "next_retry_at" = NULL, + "last_failure_reason" = NULL, + "updated_at" = CURRENT_TIMESTAMP + WHERE c."type" = 'WEB_PUSH' + AND c."enabled" = false + AND c."disabled_reason" IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM "push_subscriptions" AS s + WHERE s."user_id" = c."user_id" + ); +END +$migration$; diff --git a/tests/integration/web-push-channel-re-enable-migration.test.ts b/tests/integration/web-push-channel-re-enable-migration.test.ts new file mode 100644 index 000000000..4f72f0ec7 --- /dev/null +++ b/tests/integration/web-push-channel-re-enable-migration.test.ts @@ -0,0 +1,151 @@ +/** + * Integration coverage for `0497_reenable_subscribed_web_push_channels`. + * + * The test container starts empty, so the migration has nothing to touch when + * it runs at boot. This seeds the states a live instance holds, applies the + * exact migration SQL from disk, and checks which channels came back, which + * stayed off, and that a second run changes nothing. + */ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +const MIGRATION_SQL = readFileSync( + resolve( + process.cwd(), + "prisma/migrations/0497_reenable_subscribed_web_push_channels/migration.sql", + ), + "utf8", +); + +async function seedUser(id: string, subscribed: boolean): Promise { + const prisma = getPrismaClient(); + await prisma.user.create({ + data: { id, username: id, email: `${id}@example.test` }, + }); + if (subscribed) { + await prisma.pushSubscription.create({ + data: { + userId: id, + endpoint: `https://push.example.test/${id}`, + p256dh: "p256dh", + auth: "auth", + }, + }); + } +} + +async function seedChannel( + userId: string, + type: string, + state: { enabled: boolean; disabledReason: string | null }, +): Promise { + const row = await getPrismaClient().notificationChannel.create({ + data: { + userId, + type, + config: "{}", + enabled: state.enabled, + disabledReason: state.disabledReason, + consecutiveFailures: 5, + lastFailureReason: "web_push_no_subscriptions", + nextRetryAt: state.enabled ? new Date(Date.now() + 3_600_000) : null, + }, + select: { id: true }, + }); + return row.id; +} + +function readChannel(id: string) { + return getPrismaClient().notificationChannel.findUniqueOrThrow({ + where: { id }, + select: { + enabled: true, + disabledReason: true, + consecutiveFailures: true, + lastFailureReason: true, + nextRetryAt: true, + updatedAt: true, + }, + }); +} + +describe("0497 re-enable subscribed Web Push channels", () => { + beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + }); + + it("turns an automatically disabled channel with a subscription back on", async () => { + await seedUser("gone-410", true); + await seedUser("gave-up", true); + const gone = await seedChannel("gone-410", "WEB_PUSH", { + enabled: false, + disabledReason: "web_push_410_gone", + }); + const gaveUp = await seedChannel("gave-up", "WEB_PUSH", { + enabled: false, + disabledReason: "give_up_after_5_failures", + }); + + await getPrismaClient().$executeRawUnsafe(MIGRATION_SQL); + + for (const id of [gone, gaveUp]) { + expect(await readChannel(id)).toMatchObject({ + enabled: true, + disabledReason: null, + consecutiveFailures: 0, + lastFailureReason: null, + nextRetryAt: null, + }); + } + }); + + it("leaves a channel switched off by hand, one without a subscription, and other channel types alone", async () => { + await seedUser("by-hand", true); + await seedUser("no-browser", false); + await seedUser("telegram", true); + await seedUser("healthy", true); + const byHand = await seedChannel("by-hand", "WEB_PUSH", { + enabled: false, + disabledReason: null, + }); + const noBrowser = await seedChannel("no-browser", "WEB_PUSH", { + enabled: false, + disabledReason: "web_push_410_gone", + }); + const telegram = await seedChannel("telegram", "TELEGRAM", { + enabled: false, + disabledReason: "telegram_blocked_by_user", + }); + const healthy = await seedChannel("healthy", "WEB_PUSH", { + enabled: true, + disabledReason: null, + }); + const before = await Promise.all( + [byHand, noBrowser, telegram, healthy].map(readChannel), + ); + + await getPrismaClient().$executeRawUnsafe(MIGRATION_SQL); + + const after = await Promise.all( + [byHand, noBrowser, telegram, healthy].map(readChannel), + ); + expect(after).toEqual(before); + }); + + it("changes nothing on a second run", async () => { + await seedUser("gone-410", true); + const id = await seedChannel("gone-410", "WEB_PUSH", { + enabled: false, + disabledReason: "web_push_410_gone", + }); + await getPrismaClient().$executeRawUnsafe(MIGRATION_SQL); + const first = await readChannel(id); + + await getPrismaClient().$executeRawUnsafe(MIGRATION_SQL); + + expect(await readChannel(id)).toEqual(first); + }); +});