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); + }); +});