From 8d2753f942cd7af83113eb89ccd2fde0405eb734 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 3 Sep 2026 02:02:04 +0200 Subject: [PATCH] fix(notifications): apply the global channel switches at dispatch `AppSettings.telegramGlobal`, `.ntfyGlobal` and `.webPushGlobal` were read in two places, and neither of them was the one that matters: the setup-card visibility on Settings -> Integrations and an echo in the preferences payload. The dispatcher never looked at them. An operator who switched a channel off instance-wide lost the setup card and kept every existing delivery on that channel, indefinitely. Gate each of the three in the dispatcher, in the shape `isApiGloballyEnabled()` already uses for the twelve API routes. The read sits above the per-user channel row and below the per-event preference, it happens at most once per dispatch, and only when a switchable channel is actually in the cascade. A skip writes a `globally_disabled` row to the push-attempt ledger so the admin diagnostics name the cause, and does not count as an attempt, so `dispatched` keeps telling the truth. The account's own channel row is left enabled: the switch is the operator's, and flipping it back has to restore delivery without every user re-enabling anything. APNs, the generic webhook and email carry no instance-wide switch and are not gated here; they are turned off at their source instead. The unit test drives the real dispatcher over a stubbed settings row; the integration test writes the actual singleton and reads the ledger back out of Postgres, which is the layer that proves the column the admin route writes is the column the dispatcher reads. --- src/lib/app-settings.ts | 50 +++++ .../dispatcher-global-channel-switch.test.ts | 201 ++++++++++++++++++ src/lib/notifications/dispatcher.ts | 56 ++++- ...notification-global-channel-switch.test.ts | 138 ++++++++++++ 4 files changed, 441 insertions(+), 4 deletions(-) create mode 100644 src/lib/notifications/__tests__/dispatcher-global-channel-switch.test.ts create mode 100644 tests/integration/notification-global-channel-switch.test.ts diff --git a/src/lib/app-settings.ts b/src/lib/app-settings.ts index b210675f8..0c8752e5a 100644 --- a/src/lib/app-settings.ts +++ b/src/lib/app-settings.ts @@ -42,6 +42,56 @@ export async function isApiGloballyEnabled(): Promise { return settings.apiGlobal; } +/** + * The notification channels an operator can switch off instance-wide, mapped + * to the `AppSettings` column that carries the switch. A channel absent from + * this map (APNs, generic webhook, email) has no instance-wide switch and is + * never gated by it — the operator turns those off at their source (the APNs + * credentials, the SMTP env) instead. + */ +const GLOBAL_CHANNEL_SWITCHES = { + TELEGRAM: "telegramGlobal", + NTFY: "ntfyGlobal", + WEB_PUSH: "webPushGlobal", +} as const; + +export type GloballySwitchableChannel = keyof typeof GLOBAL_CHANNEL_SWITCHES; + +export function hasGlobalChannelSwitch( + channelType: string, +): channelType is GloballySwitchableChannel { + return Object.hasOwn(GLOBAL_CHANNEL_SWITCHES, channelType); +} + +/** + * Resolve one channel's instance-wide switch out of an availability snapshot + * the caller already holds. Split from the async form so a dispatch that + * walks several channels reads the settings row once. + */ +export function resolveChannelGloballyEnabled( + availability: GlobalServiceAvailability, + channelType: string, +): boolean { + if (!hasGlobalChannelSwitch(channelType)) return true; + return availability[GLOBAL_CHANNEL_SWITCHES[channelType]]; +} + +/** + * Whether a notification channel may deliver on this instance. Same shape and + * same posture as `isApiGloballyEnabled()`: the switch is the operator's and + * sits above every per-user channel setting, and an unreadable settings row + * resolves to "enabled" so a storage blip never silently mutes an instance. + */ +export async function isChannelGloballyEnabled( + channelType: string, +): Promise { + if (!hasGlobalChannelSwitch(channelType)) return true; + return resolveChannelGloballyEnabled( + await getGlobalServiceAvailability(), + channelType, + ); +} + export interface ReminderThresholds { lateMinutes: number; missedMinutes: number; diff --git a/src/lib/notifications/__tests__/dispatcher-global-channel-switch.test.ts b/src/lib/notifications/__tests__/dispatcher-global-channel-switch.test.ts new file mode 100644 index 000000000..5dbf4434c --- /dev/null +++ b/src/lib/notifications/__tests__/dispatcher-global-channel-switch.test.ts @@ -0,0 +1,201 @@ +/** + * The operator's instance-wide channel switches, asserted where they have to + * hold: at dispatch. + * + * `AppSettings.telegramGlobal` / `.ntfyGlobal` / `.webPushGlobal` used to + * decide only whether the setup card rendered under Settings → Integrations. + * A channel a self-hoster had switched off kept delivering to every account + * that had already configured it. These tests drive the real dispatcher over + * a stubbed settings row and assert the user-visible outcome: nothing is + * sent, and the push-attempt ledger says why. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + appSettings: { findUnique: vi.fn() }, + notificationChannel: { findMany: vi.fn(), update: vi.fn() }, + pushAttempt: { create: vi.fn() }, + user: { findUnique: vi.fn() }, + }, +})); + +vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn(async () => undefined) })); + +vi.mock("@/lib/crypto", () => ({ + encrypt: (s: string) => s, + decrypt: (s: string) => s, +})); + +vi.mock("@/lib/logging/context", () => ({ + getEvent: () => ({ + addWarning: vi.fn(), + addMeta: vi.fn(), + addExternalCall: vi.fn(), + setError: vi.fn(), + }), +})); + +const sendViaTelegramMock = vi.fn(); +const sendViaNtfyMock = vi.fn(); +const sendViaWebPushMock = vi.fn(); +const sendViaEmailMock = vi.fn(); + +vi.mock("@/lib/notifications/senders/email", () => ({ + sendViaEmail: (...args: unknown[]) => sendViaEmailMock(...args), +})); + +vi.mock("@/lib/notifications/senders/telegram", () => ({ + sendViaTelegram: (...args: unknown[]) => sendViaTelegramMock(...args), +})); +vi.mock("@/lib/notifications/senders/ntfy", () => ({ + sendViaNtfy: (...args: unknown[]) => sendViaNtfyMock(...args), +})); +vi.mock("@/lib/notifications/senders/web-push", () => ({ + sendViaWebPush: (...args: unknown[]) => sendViaWebPushMock(...args), +})); + +import { prisma } from "@/lib/db"; +import { dispatchNotification } from "@/lib/notifications/dispatcher"; + +type ChannelKind = "TELEGRAM" | "NTFY" | "WEB_PUSH" | "EMAIL"; + +function makeChannel(type: ChannelKind) { + return { + id: `ch-${type}`, + userId: "u-1", + type, + enabled: true, + config: JSON.stringify({ serverUrl: "https://ntfy.example", topic: "t" }), + consecutiveFailures: 0, + nextRetryAt: null, + preferences: [], + }; +} + +function stubSettings(over: Record = {}) { + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ + telegramGlobal: true, + ntfyGlobal: true, + webPushGlobal: true, + apiGlobal: true, + ...over, + } as never); +} + +const SENDERS: Record> = { + TELEGRAM: sendViaTelegramMock, + NTFY: sendViaNtfyMock, + WEB_PUSH: sendViaWebPushMock, + EMAIL: sendViaEmailMock, +}; + +const SWITCH_COLUMN = { + TELEGRAM: "telegramGlobal", + NTFY: "ntfyGlobal", + WEB_PUSH: "webPushGlobal", +} as const; + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + managedProfileAt: null, + } as never); + vi.mocked(prisma.notificationChannel.update).mockResolvedValue({ + consecutiveFailures: 0, + } as never); + vi.mocked(prisma.pushAttempt.create).mockResolvedValue({} as never); + stubSettings(); +}); + +describe.each(["TELEGRAM", "NTFY", "WEB_PUSH"] as const)( + "dispatchNotification — %s instance-wide switch", + (type) => { + it("sends nothing and writes a globally_disabled ledger row when the operator switched the channel off", async () => { + stubSettings({ [SWITCH_COLUMN[type]]: false }); + vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([ + makeChannel(type), + ] as never); + + const outcome = await dispatchNotification({ + eventType: "SYSTEM_ALERT", + userId: "u-1", + title: "t", + message: "m", + }); + + expect(SENDERS[type]).not.toHaveBeenCalled(); + expect(outcome).toEqual({ + dispatched: false, + channelsAttempted: 0, + channelsSucceeded: 0, + }); + expect(prisma.pushAttempt.create).toHaveBeenCalledTimes(1); + expect( + vi.mocked(prisma.pushAttempt.create).mock.calls[0][0], + ).toMatchObject({ + data: { + channel: type, + eventType: "SYSTEM_ALERT", + result: "skipped", + reason: "globally_disabled", + }, + }); + }); + + it("sends normally when the switch is on", async () => { + stubSettings({ [SWITCH_COLUMN[type]]: true }); + vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([ + makeChannel(type), + ] as never); + SENDERS[type].mockResolvedValue({ ok: true }); + + const outcome = await dispatchNotification({ + eventType: "SYSTEM_ALERT", + userId: "u-1", + title: "t", + message: "m", + }); + + expect(SENDERS[type]).toHaveBeenCalledTimes(1); + expect(outcome.dispatched).toBe(true); + expect(outcome.channelsSucceeded).toBe(1); + }); + }, +); + +describe("dispatchNotification — switch scope", () => { + it("reads the settings row once for a cascade that carries several switchable channels", async () => { + stubSettings({ telegramGlobal: false, ntfyGlobal: false }); + vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([ + makeChannel("TELEGRAM"), + makeChannel("NTFY"), + ] as never); + + await dispatchNotification({ + eventType: "SYSTEM_ALERT", + userId: "u-1", + title: "t", + message: "m", + }); + + expect(sendViaTelegramMock).not.toHaveBeenCalled(); + expect(sendViaNtfyMock).not.toHaveBeenCalled(); + expect(prisma.appSettings.findUnique).toHaveBeenCalledTimes(1); + }); + + it("never reads the settings row for a cascade with no switchable channel", async () => { + vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([ + makeChannel("EMAIL"), + ] as never); + + await dispatchNotification({ + eventType: "SYSTEM_ALERT", + userId: "u-1", + title: "t", + message: "m", + }); + + expect(prisma.appSettings.findUnique).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/notifications/dispatcher.ts b/src/lib/notifications/dispatcher.ts index 4bc30dff2..2d8f9d793 100644 --- a/src/lib/notifications/dispatcher.ts +++ b/src/lib/notifications/dispatcher.ts @@ -25,6 +25,12 @@ import { recordChannelTransientFailure, } from "@/lib/notifications/channel-state"; import { getEvent } from "@/lib/logging/context"; +import { + getGlobalServiceAvailability, + hasGlobalChannelSwitch, + resolveChannelGloballyEnabled, + type GlobalServiceAvailability, +} from "@/lib/app-settings"; import { CLIENT_MANAGED_APNS_EVENTS, hasClientManagedApnsGate, @@ -43,11 +49,12 @@ import { * * For each channel: * 1. Skip if `enabled=false` (manually or auto-disabled). - * 2. Skip if currently in retry-cooldown (`nextRetryAt > now`). - * 3. Check the per-channel preference for this eventType (default: enabled). - * 4. Skip the APNs channel — and only that one — when the user's iOS + * 2. Check the per-channel preference for this eventType (default: enabled). + * 3. Skip if the operator switched the channel off instance-wide. + * 4. Skip if currently in retry-cooldown (`nextRetryAt > now`). + * 5. Skip the APNs channel — and only that one — when the user's iOS * client owns the local reminder for this event type. - * 5. Call the appropriate sender — which now returns a `SendOutcome` so + * 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. * @@ -231,6 +238,16 @@ export async function dispatchNotification( }); defaultEnabled = user?.moodReminderEnabled === true; } + // Operator kill switches for the three channels that carry one, read at + // most once per dispatch and only when such a channel is actually in the + // cascade — a cascade of APNs + webhook + email pays nothing for it. + let globalAvailability: GlobalServiceAvailability | null = null; + const resolveGlobalAvailability = + async (): Promise => { + globalAvailability ??= await getGlobalServiceAvailability(); + return globalAvailability; + }; + // Client-managed APNs suppression, resolved at most once per dispatch // and only when an APNs channel is actually in the cascade. `null` // means "not applicable"; the read is lazy so the common event types @@ -259,6 +276,37 @@ export async function dispatchNotification( continue; } + // The operator's instance-wide switch for this channel + // (`AppSettings.{telegram,ntfy,webPush}Global`). It sits above every + // per-user setting: a channel switched off here delivers nothing, no + // matter what the account has configured. Not counted as an attempt — + // nothing was sent — but the ledger carries a `globally_disabled` row + // so the operator reads a reason instead of inferring silence. + const globalSwitch = hasGlobalChannelSwitch(channel.type) + ? channel.type + : null; + if ( + globalSwitch && + !resolveChannelGloballyEnabled( + await resolveGlobalAvailability(), + globalSwitch, + ) + ) { + getEvent()?.addMeta( + `notification_skip_${globalSwitch.toLowerCase()}_globally_disabled`, + recipientPayload.eventType, + ); + recordPushAttempt({ + recordUserId: delivery.recordUserId, + recipientUserId: delivery.recipientUserId, + channel: globalSwitch, + eventType: recipientPayload.eventType, + result: "skipped", + reason: "globally_disabled", + }); + continue; + } + // Backoff cooldown — skip the channel until `nextRetryAt`. Without // this guard, a flapping upstream would burn API quota every // reminder-tick (every 60s by default), defeating the whole point diff --git a/tests/integration/notification-global-channel-switch.test.ts b/tests/integration/notification-global-channel-switch.test.ts new file mode 100644 index 000000000..43b01fae7 --- /dev/null +++ b/tests/integration/notification-global-channel-switch.test.ts @@ -0,0 +1,138 @@ +/** + * The operator's instance-wide channel switches, against a real settings row. + * + * The unit suite stubs `prisma.appSettings.findUnique`; this one writes the + * actual `app_settings` singleton, dispatches through the real cascade, and + * reads the `push_attempts` ledger back out of Postgres. It is the layer that + * proves the column the admin route writes is the column the dispatcher + * reads — a unit mock would agree with itself even if those two drifted apart. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +const ntfySendMock = vi.fn(); + +vi.mock("@/lib/notifications/senders/ntfy", () => ({ + sendViaNtfy: (...args: unknown[]) => ntfySendMock(...args), +})); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +import { getPrismaClient, truncateAllTables } from "./setup"; + +const TEST_USER_ID = "user-global-channel-switch"; + +async function seedNtfyChannel(): Promise { + const { encrypt } = await import("@/lib/crypto"); + await getPrismaClient().notificationChannel.create({ + data: { + userId: TEST_USER_ID, + type: "NTFY", + enabled: true, + config: encrypt( + JSON.stringify({ serverUrl: "https://ntfy.example", topic: "health" }), + ), + }, + }); +} + +async function setNtfyGlobal(ntfyGlobal: boolean): Promise { + await getPrismaClient().appSettings.upsert({ + where: { id: "singleton" }, + create: { id: "singleton", ntfyGlobal }, + update: { ntfyGlobal }, + }); +} + +async function dispatch() { + const { dispatchNotification } = + await import("@/lib/notifications/dispatcher"); + return dispatchNotification({ + userId: TEST_USER_ID, + eventType: "MEASUREMENT_REMINDER", + title: "Time to weigh in", + message: "Your morning reading is due", + }); +} + +/** The ledger write is fire-and-forget; let its microtask land. */ +async function settleLedger(): Promise { + await new Promise((resolve) => setTimeout(resolve, 50)); +} + +beforeEach(async () => { + vi.clearAllMocks(); + await truncateAllTables(getPrismaClient()); + await getPrismaClient().user.create({ + data: { + id: TEST_USER_ID, + username: "global-channel-switch", + email: "global-channel-switch@example.test", + }, + }); +}); + +describe("instance-wide notification channel switch", () => { + it("delivers nothing and records the skip when the operator switched ntfy off", async () => { + await seedNtfyChannel(); + await setNtfyGlobal(false); + + const outcome = await dispatch(); + await settleLedger(); + + expect(ntfySendMock).not.toHaveBeenCalled(); + expect(outcome).toEqual({ + dispatched: false, + channelsAttempted: 0, + channelsSucceeded: 0, + }); + + const attempts = await getPrismaClient().pushAttempt.findMany({ + where: { userId: TEST_USER_ID }, + }); + expect(attempts).toHaveLength(1); + expect(attempts[0]).toMatchObject({ + channel: "NTFY", + eventType: "MEASUREMENT_REMINDER", + result: "skipped", + reason: "globally_disabled", + }); + + // The switch is the operator's, not the user's: the account's own + // channel row is left enabled so flipping the switch back restores + // delivery without every user having to re-enable anything. + const channel = await getPrismaClient().notificationChannel.findFirst({ + where: { userId: TEST_USER_ID, type: "NTFY" }, + }); + expect(channel?.enabled).toBe(true); + }); + + it("delivers on the same channel once the switch is back on", async () => { + await seedNtfyChannel(); + await setNtfyGlobal(true); + ntfySendMock.mockResolvedValueOnce({ ok: true }); + + const outcome = await dispatch(); + + expect(ntfySendMock).toHaveBeenCalledTimes(1); + expect(outcome).toEqual({ + dispatched: true, + channelsAttempted: 1, + channelsSucceeded: 1, + }); + }); + + it("delivers when no settings row exists at all", async () => { + await seedNtfyChannel(); + ntfySendMock.mockResolvedValueOnce({ ok: true }); + + const outcome = await dispatch(); + + expect(ntfySendMock).toHaveBeenCalledTimes(1); + expect(outcome.dispatched).toBe(true); + }); +});