From 9210259511f92d6000b8c8ab414da32ede15e6e4 Mon Sep 17 00:00:00 2001 From: egeoztass Date: Thu, 16 Apr 2026 21:46:40 +0300 Subject: [PATCH 1/3] feat: add Twilio SMS notification channel Adds Twilio SMS as a new notification channel, allowing users to receive SMS alerts via the Twilio API. Backend: provider using HTTP Basic Auth, type registration, validation, service wiring. Uses existing fields: address (Account SID), accessToken (Auth Token), homeserverUrl (From number), phone (To number). Frontend: form UI with 4 fields, validation, i18n keys. Tests: 18 provider tests + updated service test constructor. Closes #3095 Co-Authored-By: Claude Opus 4.6 (1M context) --- client/src/Hooks/useNotificationForm.ts | 10 ++ .../src/Pages/Notifications/create/index.tsx | 83 ++++++++++- client/src/Types/Notification.ts | 1 + client/src/Validation/notifications.ts | 9 ++ client/src/locales/en.json | 12 ++ server/src/config/services.ts | 3 + server/src/db/models/Notification.ts | 2 +- server/src/service/index.ts | 1 + .../notificationProviders/twilio.ts | 96 +++++++++++++ .../infrastructure/notificationsService.ts | 7 + server/src/types/notification.ts | 13 +- .../src/validation/notificationValidation.ts | 9 ++ .../notifications/twilioProvider.test.ts | 135 ++++++++++++++++++ .../services/notificationsService.test.ts | 3 + 14 files changed, 381 insertions(+), 3 deletions(-) create mode 100644 server/src/service/infrastructure/notificationProviders/twilio.ts create mode 100644 server/test/unit/providers/notifications/twilioProvider.test.ts diff --git a/client/src/Hooks/useNotificationForm.ts b/client/src/Hooks/useNotificationForm.ts index 0b42b6d40d..4a1e05f2c5 100644 --- a/client/src/Hooks/useNotificationForm.ts +++ b/client/src/Hooks/useNotificationForm.ts @@ -60,6 +60,16 @@ function buildDefaults(data: Notification | null): NotificationFormData { address: data.address || "", }; } + if (data?.type === "twilio") { + return { + type: "twilio", + notificationName: data.notificationName || "", + address: data.address || "", + accessToken: data.accessToken || "", + phone: data.phone || "", + homeserverUrl: data.homeserverUrl || "", + }; + } if (data?.type === "pushover") { return { type: "pushover", diff --git a/client/src/Pages/Notifications/create/index.tsx b/client/src/Pages/Notifications/create/index.tsx index 3da9e6f757..3d7f85acde 100644 --- a/client/src/Pages/Notifications/create/index.tsx +++ b/client/src/Pages/Notifications/create/index.tsx @@ -149,7 +149,8 @@ const NotificationsCreatePage = () => { /> {watchedType !== "matrix" && watchedType !== "telegram" && - watchedType !== "pushover" && ( + watchedType !== "pushover" && + watchedType !== "twilio" && ( { } /> )} + {watchedType === "twilio" && ( + + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + + } + /> + )} {watchedType === "matrix" && ( ; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index c2e9ac854e..7fbd7dcf2a 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -967,6 +967,18 @@ "placeholderAppToken": "azGDORePK8gMaC0QOYAMyEEuzJnyUi", "optionUserKey": "User key", "placeholderUserKey": "uQiRzpo4DXghDmr9QzzfQu27cmVRsG" + }, + "twilio": { + "title": "Twilio SMS configuration", + "description": "Configure Twilio to send SMS notifications to a phone number.", + "optionAccountSid": "Account SID", + "placeholderAccountSid": "ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "optionAuthToken": "Auth token", + "placeholderAuthToken": "your_auth_token", + "optionFromNumber": "From number (Twilio)", + "placeholderFromNumber": "+15551234567", + "optionToNumber": "To number (recipient)", + "placeholderToNumber": "+15559876543" } }, "table": { diff --git a/server/src/config/services.ts b/server/src/config/services.ts index ed9f02a9f5..8a5db6b494 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -30,6 +30,7 @@ import { TeamsProvider, TelegramProvider, PushoverProvider, + TwilioProvider, // Interfaces INetworkService, IEmailService, @@ -300,6 +301,7 @@ export const initializeServices = async ({ const teamsProvider = new TeamsProvider(logger); const telegramProvider = new TelegramProvider(logger); const pushoverProvider = new PushoverProvider(logger); + const twilioProvider = new TwilioProvider(logger); const notificationsService = new NotificationsService( notificationsRepository, @@ -313,6 +315,7 @@ export const initializeServices = async ({ teamsProvider, telegramProvider, pushoverProvider, + twilioProvider, settingsService, logger, notificationMessageBuilder diff --git a/server/src/db/models/Notification.ts b/server/src/db/models/Notification.ts index 3b424bddeb..973f7fe3ef 100755 --- a/server/src/db/models/Notification.ts +++ b/server/src/db/models/Notification.ts @@ -25,7 +25,7 @@ const NotificationSchema = new Schema( }, type: { type: String, - enum: ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams", "telegram", "pushover"] as NotificationChannel[], + enum: ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams", "telegram", "pushover", "twilio"] as NotificationChannel[], required: true, }, notificationName: { diff --git a/server/src/service/index.ts b/server/src/service/index.ts index da529ed2bc..5db8afc4fc 100644 --- a/server/src/service/index.ts +++ b/server/src/service/index.ts @@ -31,6 +31,7 @@ export * from "@/service/infrastructure/notificationProviders/teams.js"; export * from "@/service/infrastructure/notificationProviders/webhook.js"; export * from "@/service/infrastructure/notificationProviders/telegram.js"; export * from "@/service/infrastructure/notificationProviders/pushover.js"; +export * from "@/service/infrastructure/notificationProviders/twilio.js"; // System services export * from "@/service/system/settingsService.js"; diff --git a/server/src/service/infrastructure/notificationProviders/twilio.ts b/server/src/service/infrastructure/notificationProviders/twilio.ts new file mode 100644 index 0000000000..6d84f80f38 --- /dev/null +++ b/server/src/service/infrastructure/notificationProviders/twilio.ts @@ -0,0 +1,96 @@ +const SERVICE_NAME = "TwilioProvider"; +import type { Notification } from "@/types/index.js"; +import { NotificationProvider } from "@/service/infrastructure/notificationProviders/INotificationProvider.js"; +import type { NotificationMessage } from "@/types/notificationMessage.js"; +import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js"; +import got from "got"; + +export class TwilioProvider extends NotificationProvider { + async sendTestAlert(notification: Partial): Promise { + if (!notification.address || !notification.accessToken || !notification.phone || !notification.homeserverUrl) { + return false; + } + + try { + await got.post(`https://api.twilio.com/2010-04-01/Accounts/${notification.address}/Messages.json`, { + form: { + To: notification.phone, + From: notification.homeserverUrl, + Body: getTestMessage(), + }, + username: notification.address, + password: notification.accessToken, + ...this.gotRequestOptions(), + }); + return true; + } catch (error) { + const errMsg = error instanceof Error ? error.message : "unknown error"; + const errStack = error instanceof Error ? error.stack : undefined; + this.logger.warn({ + message: "Twilio test alert failed", + service: SERVICE_NAME, + method: "sendTestAlert", + stack: errStack, + details: { error: errMsg }, + }); + return false; + } + } + + async sendMessage(notification: Notification, message: NotificationMessage): Promise { + if (!notification.address || !notification.accessToken || !notification.phone || !notification.homeserverUrl) { + return false; + } + + const text = this.buildSmsText(message); + + try { + await got.post(`https://api.twilio.com/2010-04-01/Accounts/${notification.address}/Messages.json`, { + form: { + To: notification.phone, + From: notification.homeserverUrl, + Body: text, + }, + username: notification.address, + password: notification.accessToken, + ...this.gotRequestOptions(), + }); + + this.logger.info({ + message: "Twilio SMS notification sent", + service: SERVICE_NAME, + method: "sendMessage", + }); + return true; + } catch (error) { + const errMsg = error instanceof Error ? error.message : "unknown error"; + const errStack = error instanceof Error ? error.stack : undefined; + this.logger.warn({ + message: "Twilio SMS alert failed", + service: SERVICE_NAME, + method: "sendMessage", + stack: errStack, + details: { error: errMsg }, + }); + return false; + } + } + + private buildSmsText(message: NotificationMessage): string { + const lines: string[] = []; + + lines.push(message.content.title); + lines.push(message.content.summary); + lines.push(""); + lines.push(`URL: ${message.monitor.url}`); + lines.push(`Status: ${message.monitor.status}`); + + if (message.content.thresholds && message.content.thresholds.length > 0) { + message.content.thresholds.forEach((breach) => { + lines.push(`${breach.metric.toUpperCase()}: ${breach.formattedValue}`); + }); + } + + return lines.join("\n"); + } +} diff --git a/server/src/service/infrastructure/notificationsService.ts b/server/src/service/infrastructure/notificationsService.ts index 0344ab30cb..90e9d637ab 100644 --- a/server/src/service/infrastructure/notificationsService.ts +++ b/server/src/service/infrastructure/notificationsService.ts @@ -35,6 +35,7 @@ export class NotificationsService implements INotificationsService { private teamsProvider: INotificationProvider; private telegramProvider: INotificationProvider; private pushoverProvider: INotificationProvider; + private twilioProvider: INotificationProvider; private logger: ILogger; private settingsService: ISettingsService; private notificationMessageBuilder: INotificationMessageBuilder; @@ -51,6 +52,7 @@ export class NotificationsService implements INotificationsService { teamsProvider: INotificationProvider, telegramProvider: INotificationProvider, pushoverProvider: INotificationProvider, + twilioProvider: INotificationProvider, settingsService: ISettingsService, logger: ILogger, notificationMessageBuilder: INotificationMessageBuilder @@ -66,6 +68,7 @@ export class NotificationsService implements INotificationsService { this.teamsProvider = teamsProvider; this.telegramProvider = telegramProvider; this.pushoverProvider = pushoverProvider; + this.twilioProvider = twilioProvider; this.settingsService = settingsService; this.logger = logger; this.notificationMessageBuilder = notificationMessageBuilder; @@ -107,6 +110,8 @@ export class NotificationsService implements INotificationsService { return await this.telegramProvider.sendMessage!(notification, notificationMessage); case "pushover": return await this.pushoverProvider.sendMessage!(notification, notificationMessage); + case "twilio": + return await this.twilioProvider.sendMessage!(notification, notificationMessage); default: this.logger.warn({ message: `Unknown notification type: ${notification.type}`, @@ -171,6 +176,8 @@ export class NotificationsService implements INotificationsService { return await this.telegramProvider.sendTestAlert(notification); case "pushover": return await this.pushoverProvider.sendTestAlert(notification); + case "twilio": + return await this.twilioProvider.sendTestAlert(notification); default: return false; } diff --git a/server/src/types/notification.ts b/server/src/types/notification.ts index f095be7702..f306e25926 100644 --- a/server/src/types/notification.ts +++ b/server/src/types/notification.ts @@ -1,4 +1,15 @@ -export const NotificationChannels = ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams", "telegram", "pushover"] as const; +export const NotificationChannels = [ + "email", + "slack", + "discord", + "webhook", + "pager_duty", + "matrix", + "teams", + "telegram", + "pushover", + "twilio", +] as const; export type NotificationChannel = (typeof NotificationChannels)[number]; export interface Notification { diff --git a/server/src/validation/notificationValidation.ts b/server/src/validation/notificationValidation.ts index 5cdb44e95b..545335dad0 100644 --- a/server/src/validation/notificationValidation.ts +++ b/server/src/validation/notificationValidation.ts @@ -79,6 +79,15 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [ address: z.string().min(1, "User key is required"), accessToken: z.string().min(1, "App token is required"), }), + // Twilio SMS notification + z.object({ + notificationName: z.string().min(1, "Notification name is required"), + type: z.literal("twilio"), + address: z.string().min(1, "Account SID is required"), + accessToken: z.string().min(1, "Auth token is required"), + phone: z.string().min(1, "Recipient phone number is required"), + homeserverUrl: z.string().min(1, "Twilio phone number is required"), + }), ]); export const testNotificationBodyValidation = createNotificationBodyValidation; diff --git a/server/test/unit/providers/notifications/twilioProvider.test.ts b/server/test/unit/providers/notifications/twilioProvider.test.ts new file mode 100644 index 0000000000..6156b648f5 --- /dev/null +++ b/server/test/unit/providers/notifications/twilioProvider.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it, jest, beforeEach } from "@jest/globals"; +import { createMockLogger } from "../../../helpers/createMockLogger.ts"; +import { makeNotification, makeMessage, makeMessageWithThresholds, makeMessageWithIncident } from "../../../helpers/notificationMessage.ts"; +import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts"; + +const mockGotPost = jest.fn().mockResolvedValue({}); +jest.unstable_mockModule("got", () => ({ default: { post: mockGotPost } })); + +const { TwilioProvider } = await import("../../../../src/service/infrastructure/notificationProviders/twilio.ts"); + +const createProvider = () => { + const logger = createMockLogger(); + return { provider: new TwilioProvider(logger as any), logger }; +}; + +const makeTwilioNotification = (overrides?: Record) => + makeNotification({ + phone: "+15559876543", + homeserverUrl: "+15551234567", + ...overrides, + }); + +testNotificationProviderContract("TwilioProvider", { + create: () => { + mockGotPost.mockResolvedValue({}); + return createProvider().provider; + }, + makeNotification: () => makeTwilioNotification(), +}); + +describe("TwilioProvider", () => { + beforeEach(() => mockGotPost.mockReset().mockResolvedValue({})); + + describe("sendTestAlert", () => { + it("sends to Twilio API and returns true", async () => { + expect(await createProvider().provider.sendTestAlert(makeTwilioNotification())).toBe(true); + expect(mockGotPost).toHaveBeenCalledWith( + expect.stringContaining("api.twilio.com/2010-04-01/Accounts/"), + expect.objectContaining({ + form: expect.objectContaining({ To: "+15559876543", From: "+15551234567" }), + username: expect.any(String), + password: expect.any(String), + }) + ); + }); + + it("returns false when address is missing", async () => { + expect(await createProvider().provider.sendTestAlert(makeTwilioNotification({ address: "" }))).toBe(false); + }); + + it("returns false when accessToken is missing", async () => { + expect(await createProvider().provider.sendTestAlert(makeTwilioNotification({ accessToken: undefined }))).toBe(false); + }); + + it("returns false when phone is missing", async () => { + expect(await createProvider().provider.sendTestAlert(makeTwilioNotification({ phone: "" }))).toBe(false); + }); + + it("returns false when homeserverUrl (from number) is missing", async () => { + expect(await createProvider().provider.sendTestAlert(makeTwilioNotification({ homeserverUrl: "" }))).toBe(false); + }); + + it("returns false and logs on error", async () => { + mockGotPost.mockRejectedValue(new Error("fail")); + const { provider, logger } = createProvider(); + expect(await provider.sendTestAlert(makeTwilioNotification())).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ method: "sendTestAlert", details: { error: "fail" } })); + }); + + it("handles non-Error thrown values in sendTestAlert", async () => { + mockGotPost.mockRejectedValue("string error"); + const { provider, logger } = createProvider(); + expect(await provider.sendTestAlert(makeTwilioNotification())).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined, details: { error: "unknown error" } })); + }); + }); + + describe("sendMessage", () => { + it("sends SMS and returns true", async () => { + const { provider } = createProvider(); + expect(await provider.sendMessage(makeTwilioNotification() as any, makeMessage())).toBe(true); + const form = mockGotPost.mock.calls[0][1].form; + expect(form.Body).toContain("Monitor Down"); + expect(form.To).toBe("+15559876543"); + expect(form.From).toBe("+15551234567"); + }); + + it("returns false when address is missing", async () => { + expect(await createProvider().provider.sendMessage(makeTwilioNotification({ address: "" }) as any, makeMessage())).toBe(false); + }); + + it("returns false when accessToken is missing", async () => { + expect(await createProvider().provider.sendMessage(makeTwilioNotification({ accessToken: undefined }) as any, makeMessage())).toBe(false); + }); + + it("returns false when phone is missing", async () => { + expect(await createProvider().provider.sendMessage(makeTwilioNotification({ phone: "" }) as any, makeMessage())).toBe(false); + }); + + it("returns false and logs on error", async () => { + mockGotPost.mockRejectedValue(new Error("fail")); + const { provider, logger } = createProvider(); + expect(await provider.sendMessage(makeTwilioNotification() as any, makeMessage())).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ method: "sendMessage" })); + }); + + it("handles non-Error thrown values in sendMessage", async () => { + mockGotPost.mockRejectedValue(42); + const { provider, logger } = createProvider(); + expect(await provider.sendMessage(makeTwilioNotification() as any, makeMessage())).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ details: { error: "unknown error" } })); + }); + + it("includes thresholds in text", async () => { + const { provider } = createProvider(); + await provider.sendMessage(makeTwilioNotification() as any, makeMessageWithThresholds()); + expect(mockGotPost.mock.calls[0][1].form.Body).toContain("CPU"); + }); + + it("includes monitor URL in text", async () => { + const { provider } = createProvider(); + await provider.sendMessage(makeTwilioNotification() as any, makeMessage()); + expect(mockGotPost.mock.calls[0][1].form.Body).toContain("https://example.com"); + }); + + it("omits thresholds when not present", async () => { + const { provider } = createProvider(); + const msg = makeMessage(); + msg.content.thresholds = undefined; + await provider.sendMessage(makeTwilioNotification() as any, msg); + const text = mockGotPost.mock.calls[0][1].form.Body; + expect(text).not.toContain("CPU"); + }); + }); +}); diff --git a/server/test/unit/services/notificationsService.test.ts b/server/test/unit/services/notificationsService.test.ts index 00dd644f83..d8ee4b05ca 100644 --- a/server/test/unit/services/notificationsService.test.ts +++ b/server/test/unit/services/notificationsService.test.ts @@ -46,6 +46,7 @@ const createService = (overrides?: Record) => { const teamsProvider = createProvider(); const telegramProvider = createProvider(); const pushoverProvider = createProvider(); + const twilioProvider = createProvider(); const settingsService = createSettingsService(); const notificationMessageBuilder = createMessageBuilder(); @@ -62,6 +63,7 @@ const createService = (overrides?: Record) => { teamsProvider, telegramProvider, pushoverProvider, + twilioProvider, settingsService, notificationMessageBuilder, ...overrides, @@ -79,6 +81,7 @@ const createService = (overrides?: Record) => { defaults.teamsProvider as any, defaults.telegramProvider as any, defaults.pushoverProvider as any, + defaults.twilioProvider as any, defaults.settingsService as any, defaults.logger as any, defaults.notificationMessageBuilder as any From d5d6797f91e6c0c2543b1ee45fd6b5f3ca9ada46 Mon Sep 17 00:00:00 2001 From: egeoztass Date: Fri, 17 Apr 2026 23:11:11 +0300 Subject: [PATCH 2/3] fix: use dedicated Twilio fields and add service switch case tests - Added `accountSid` and `twilioPhoneNumber` fields to Notification type, MongoDB model, and frontend type instead of reusing `address` and `homeserverUrl` - Updated provider, validation, form hook, and form UI to use new fields - Added Pushover and Twilio to notificationsService routing tests (both handleNotifications and sendTestNotification) - All 59 tests passing Co-Authored-By: Claude Opus 4.6 (1M context) --- client/src/Hooks/useNotificationForm.ts | 4 ++-- client/src/Pages/Notifications/create/index.tsx | 10 ++++++---- client/src/Types/Notification.ts | 2 ++ client/src/Validation/notifications.ts | 4 ++-- server/src/db/models/Notification.ts | 2 ++ .../notificationProviders/twilio.ts | 16 ++++++++-------- server/src/types/notification.ts | 2 ++ server/src/validation/notificationValidation.ts | 4 ++-- .../notifications/twilioProvider.test.ts | 15 ++++++++------- .../unit/services/notificationsService.test.ts | 8 ++++++-- 10 files changed, 40 insertions(+), 27 deletions(-) diff --git a/client/src/Hooks/useNotificationForm.ts b/client/src/Hooks/useNotificationForm.ts index 4a1e05f2c5..d08f175481 100644 --- a/client/src/Hooks/useNotificationForm.ts +++ b/client/src/Hooks/useNotificationForm.ts @@ -64,10 +64,10 @@ function buildDefaults(data: Notification | null): NotificationFormData { return { type: "twilio", notificationName: data.notificationName || "", - address: data.address || "", + accountSid: data.accountSid || "", accessToken: data.accessToken || "", phone: data.phone || "", - homeserverUrl: data.homeserverUrl || "", + twilioPhoneNumber: data.twilioPhoneNumber || "", }; } if (data?.type === "pushover") { diff --git a/client/src/Pages/Notifications/create/index.tsx b/client/src/Pages/Notifications/create/index.tsx index 3d7f85acde..0f6d9b6525 100644 --- a/client/src/Pages/Notifications/create/index.tsx +++ b/client/src/Pages/Notifications/create/index.tsx @@ -270,9 +270,9 @@ const NotificationsCreatePage = () => { rightContent={ ( { )} /> ( ( homeserverUrl: { type: String }, roomId: { type: String }, accessToken: { type: String }, + accountSid: { type: String }, + twilioPhoneNumber: { type: String }, }, { timestamps: true, diff --git a/server/src/service/infrastructure/notificationProviders/twilio.ts b/server/src/service/infrastructure/notificationProviders/twilio.ts index 6d84f80f38..a871fe5cb7 100644 --- a/server/src/service/infrastructure/notificationProviders/twilio.ts +++ b/server/src/service/infrastructure/notificationProviders/twilio.ts @@ -7,18 +7,18 @@ import got from "got"; export class TwilioProvider extends NotificationProvider { async sendTestAlert(notification: Partial): Promise { - if (!notification.address || !notification.accessToken || !notification.phone || !notification.homeserverUrl) { + if (!notification.accountSid || !notification.accessToken || !notification.phone || !notification.twilioPhoneNumber) { return false; } try { - await got.post(`https://api.twilio.com/2010-04-01/Accounts/${notification.address}/Messages.json`, { + await got.post(`https://api.twilio.com/2010-04-01/Accounts/${notification.accountSid}/Messages.json`, { form: { To: notification.phone, - From: notification.homeserverUrl, + From: notification.twilioPhoneNumber, Body: getTestMessage(), }, - username: notification.address, + username: notification.accountSid, password: notification.accessToken, ...this.gotRequestOptions(), }); @@ -38,20 +38,20 @@ export class TwilioProvider extends NotificationProvider { } async sendMessage(notification: Notification, message: NotificationMessage): Promise { - if (!notification.address || !notification.accessToken || !notification.phone || !notification.homeserverUrl) { + if (!notification.accountSid || !notification.accessToken || !notification.phone || !notification.twilioPhoneNumber) { return false; } const text = this.buildSmsText(message); try { - await got.post(`https://api.twilio.com/2010-04-01/Accounts/${notification.address}/Messages.json`, { + await got.post(`https://api.twilio.com/2010-04-01/Accounts/${notification.accountSid}/Messages.json`, { form: { To: notification.phone, - From: notification.homeserverUrl, + From: notification.twilioPhoneNumber, Body: text, }, - username: notification.address, + username: notification.accountSid, password: notification.accessToken, ...this.gotRequestOptions(), }); diff --git a/server/src/types/notification.ts b/server/src/types/notification.ts index f306e25926..9d5b914c02 100644 --- a/server/src/types/notification.ts +++ b/server/src/types/notification.ts @@ -23,6 +23,8 @@ export interface Notification { homeserverUrl?: string; roomId?: string; accessToken?: string; + accountSid?: string; + twilioPhoneNumber?: string; createdAt: string; updatedAt: string; } diff --git a/server/src/validation/notificationValidation.ts b/server/src/validation/notificationValidation.ts index 545335dad0..cf74786101 100644 --- a/server/src/validation/notificationValidation.ts +++ b/server/src/validation/notificationValidation.ts @@ -83,10 +83,10 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [ z.object({ notificationName: z.string().min(1, "Notification name is required"), type: z.literal("twilio"), - address: z.string().min(1, "Account SID is required"), + accountSid: z.string().min(1, "Account SID is required"), accessToken: z.string().min(1, "Auth token is required"), phone: z.string().min(1, "Recipient phone number is required"), - homeserverUrl: z.string().min(1, "Twilio phone number is required"), + twilioPhoneNumber: z.string().min(1, "Twilio phone number is required"), }), ]); diff --git a/server/test/unit/providers/notifications/twilioProvider.test.ts b/server/test/unit/providers/notifications/twilioProvider.test.ts index 6156b648f5..88bfcdf7e8 100644 --- a/server/test/unit/providers/notifications/twilioProvider.test.ts +++ b/server/test/unit/providers/notifications/twilioProvider.test.ts @@ -16,7 +16,8 @@ const createProvider = () => { const makeTwilioNotification = (overrides?: Record) => makeNotification({ phone: "+15559876543", - homeserverUrl: "+15551234567", + twilioPhoneNumber: "+15551234567", + accountSid: "ACtest123", ...overrides, }); @@ -44,8 +45,8 @@ describe("TwilioProvider", () => { ); }); - it("returns false when address is missing", async () => { - expect(await createProvider().provider.sendTestAlert(makeTwilioNotification({ address: "" }))).toBe(false); + it("returns false when accountSid is missing", async () => { + expect(await createProvider().provider.sendTestAlert(makeTwilioNotification({ accountSid: "" }))).toBe(false); }); it("returns false when accessToken is missing", async () => { @@ -56,8 +57,8 @@ describe("TwilioProvider", () => { expect(await createProvider().provider.sendTestAlert(makeTwilioNotification({ phone: "" }))).toBe(false); }); - it("returns false when homeserverUrl (from number) is missing", async () => { - expect(await createProvider().provider.sendTestAlert(makeTwilioNotification({ homeserverUrl: "" }))).toBe(false); + it("returns false when twilioPhoneNumber (from number) is missing", async () => { + expect(await createProvider().provider.sendTestAlert(makeTwilioNotification({ twilioPhoneNumber: "" }))).toBe(false); }); it("returns false and logs on error", async () => { @@ -85,8 +86,8 @@ describe("TwilioProvider", () => { expect(form.From).toBe("+15551234567"); }); - it("returns false when address is missing", async () => { - expect(await createProvider().provider.sendMessage(makeTwilioNotification({ address: "" }) as any, makeMessage())).toBe(false); + it("returns false when accountSid is missing", async () => { + expect(await createProvider().provider.sendMessage(makeTwilioNotification({ accountSid: "" }) as any, makeMessage())).toBe(false); }); it("returns false when accessToken is missing", async () => { diff --git a/server/test/unit/services/notificationsService.test.ts b/server/test/unit/services/notificationsService.test.ts index d8ee4b05ca..cdc1ad3207 100644 --- a/server/test/unit/services/notificationsService.test.ts +++ b/server/test/unit/services/notificationsService.test.ts @@ -147,7 +147,7 @@ describe("NotificationsService", () => { }); it("routes to correct provider for each notification type", async () => { - const types = ["webhook", "slack", "matrix", "pager_duty", "discord", "email", "teams", "telegram"] as const; + const types = ["webhook", "slack", "matrix", "pager_duty", "discord", "email", "teams", "telegram", "pushover", "twilio"] as const; for (const type of types) { const deps = createService(); (deps.notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([makeNotification({ type })]); @@ -163,6 +163,8 @@ describe("NotificationsService", () => { email: deps.emailProvider, teams: deps.teamsProvider, telegram: deps.telegramProvider, + pushover: deps.pushoverProvider, + twilio: deps.twilioProvider, }; expect(providerMap[type].sendMessage).toHaveBeenCalledTimes(1); } @@ -236,7 +238,7 @@ describe("NotificationsService", () => { // ── sendTestNotification ───────────────────────────────────────────────── describe("sendTestNotification", () => { - it.each([["email"], ["slack"], ["discord"], ["pager_duty"], ["matrix"], ["webhook"], ["teams"], ["telegram"]] as const)( + it.each([["email"], ["slack"], ["discord"], ["pager_duty"], ["matrix"], ["webhook"], ["teams"], ["telegram"], ["pushover"], ["twilio"]] as const)( "routes %s to the correct provider", async (type) => { const deps = createService(); @@ -254,6 +256,8 @@ describe("NotificationsService", () => { email: deps.emailProvider, teams: deps.teamsProvider, telegram: deps.telegramProvider, + pushover: deps.pushoverProvider, + twilio: deps.twilioProvider, }; expect(providerMap[type].sendTestAlert).toHaveBeenCalledWith(notification); } From c57614e8df8f536ddb0b551dad221679bdc88f4b Mon Sep 17 00:00:00 2001 From: egeoztass Date: Fri, 17 Apr 2026 23:14:16 +0300 Subject: [PATCH 3/3] fix: add accountSid and twilioPhoneNumber to notification repositories Updated both MongoDB and TimescaleDB repositories to map the new Twilio-specific fields (accountSid, twilioPhoneNumber) in create, update, and toEntity methods. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../notifications/MongoNotificationsRepository.ts | 2 ++ .../TimescaleNotificationsRepository.ts | 14 +++++++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/server/src/repositories/notifications/MongoNotificationsRepository.ts b/server/src/repositories/notifications/MongoNotificationsRepository.ts index 3c4225b50e..5303aa6ab2 100644 --- a/server/src/repositories/notifications/MongoNotificationsRepository.ts +++ b/server/src/repositories/notifications/MongoNotificationsRepository.ts @@ -32,6 +32,8 @@ class MongoNotificationsRepository implements INotificationsRepository { homeserverUrl: doc.homeserverUrl ?? undefined, roomId: doc.roomId ?? undefined, accessToken: doc.accessToken ?? undefined, + accountSid: doc.accountSid ?? undefined, + twilioPhoneNumber: doc.twilioPhoneNumber ?? undefined, createdAt: toDateString(doc.createdAt), updatedAt: toDateString(doc.updatedAt), }; diff --git a/server/src/repositories/notifications/TimescaleNotificationsRepository.ts b/server/src/repositories/notifications/TimescaleNotificationsRepository.ts index b2c369c863..b266f33f0f 100644 --- a/server/src/repositories/notifications/TimescaleNotificationsRepository.ts +++ b/server/src/repositories/notifications/TimescaleNotificationsRepository.ts @@ -14,20 +14,22 @@ interface NotificationRow { homeserver_url: string | null; room_id: string | null; access_token: string | null; + account_sid: string | null; + twilio_phone_number: string | null; created_at: Date; updated_at: Date; } const COLUMNS = `id, user_id, team_id, type, notification_name, address, phone, - homeserver_url, room_id, access_token, created_at, updated_at`; + homeserver_url, room_id, access_token, account_sid, twilio_phone_number, created_at, updated_at`; export class TimescaleNotificationsRepository implements INotificationsRepository { constructor(private pool: Pool) {} create = async (data: Partial): Promise => { const result = await this.pool.query( - `INSERT INTO notifications (user_id, team_id, type, notification_name, address, phone, homeserver_url, room_id, access_token) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + `INSERT INTO notifications (user_id, team_id, type, notification_name, address, phone, homeserver_url, room_id, access_token, account_sid, twilio_phone_number) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING ${COLUMNS}`, [ data.userId, @@ -39,6 +41,8 @@ export class TimescaleNotificationsRepository implements INotificationsRepositor data.homeserverUrl ?? null, data.roomId ?? null, data.accessToken ?? null, + data.accountSid ?? null, + data.twilioPhoneNumber ?? null, ] ); const row = result.rows[0]; @@ -83,6 +87,8 @@ export class TimescaleNotificationsRepository implements INotificationsRepositor ["homeserverUrl", "homeserver_url"], ["roomId", "room_id"], ["accessToken", "access_token"], + ["accountSid", "account_sid"], + ["twilioPhoneNumber", "twilio_phone_number"], ]; for (const [key, column] of fieldMap) { @@ -134,6 +140,8 @@ export class TimescaleNotificationsRepository implements INotificationsRepositor homeserverUrl: row.homeserver_url ?? undefined, roomId: row.room_id ?? undefined, accessToken: row.access_token ?? undefined, + accountSid: row.account_sid ?? undefined, + twilioPhoneNumber: row.twilio_phone_number ?? undefined, createdAt: row.created_at.toISOString(), updatedAt: row.updated_at.toISOString(), });