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.
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 20:38:39 +01:00
1 parent aae029d94e
commit 70295f45cb
5 files changed
+186 -37

No files matched your search

@@ -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([
+37 -28
View File
@@ -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<string> = 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,
);
+6 -7
View File
@@ -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",