diff --git a/client/src/Types/Notification.ts b/client/src/Types/Notification.ts index 5b9645c9da..610f73547d 100644 --- a/client/src/Types/Notification.ts +++ b/client/src/Types/Notification.ts @@ -5,6 +5,7 @@ export const NotificationChannels = [ "webhook", "pager_duty", "matrix", + "teams", ] as const; export type NotificationChannel = (typeof NotificationChannels)[number]; diff --git a/client/src/Validation/notifications.ts b/client/src/Validation/notifications.ts index e505c695fd..2bbb871c94 100644 --- a/client/src/Validation/notifications.ts +++ b/client/src/Validation/notifications.ts @@ -45,6 +45,11 @@ const matrixSchema = baseSchema.extend({ accessToken: z.string().min(1, "Access token is required"), }); +const teamsSchema = baseSchema.extend({ + type: z.literal("teams"), + address: z.string().min(1, "Webhook URL is required").url("Please enter a valid URL"), +}); + export const notificationSchema = z.discriminatedUnion("type", [ emailSchema, slackSchema, @@ -52,6 +57,7 @@ export const notificationSchema = z.discriminatedUnion("type", [ webhookSchema, pagerDutySchema, matrixSchema, + teamsSchema, ]); export type NotificationFormData = z.infer; diff --git a/server/src/config/services.ts b/server/src/config/services.ts index 65377eeb4b..9434b3ae55 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -27,6 +27,7 @@ import { DiscordProvider, PagerDutyProvider, MatrixProvider, + TeamsProvider, // Interfaces INetworkService, IEmailService, @@ -228,6 +229,7 @@ export const initializeServices = async ({ const discordProvider = new DiscordProvider(logger); const pagerDutyProvider = new PagerDutyProvider(logger); const matrixProvider = new MatrixProvider(logger); + const teamsProvider = new TeamsProvider(logger); const notificationsService = new NotificationsService( notificationsRepository, @@ -238,6 +240,7 @@ export const initializeServices = async ({ discordProvider, pagerDutyProvider, matrixProvider, + teamsProvider, settingsService, logger, notificationMessageBuilder diff --git a/server/src/db/models/Notification.ts b/server/src/db/models/Notification.ts index 97e2d43fb0..9ea3f17bdd 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"] as NotificationChannel[], + enum: ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams"] as NotificationChannel[], required: true, }, notificationName: { diff --git a/server/src/service/business/userService.ts b/server/src/service/business/userService.ts index 48f3a51cf4..f224e9ba4b 100644 --- a/server/src/service/business/userService.ts +++ b/server/src/service/business/userService.ts @@ -273,8 +273,8 @@ export class UserService implements IUserService { await this.recoveryTokensRepository.deleteManyByEmail(email); const recoveryToken = await this.recoveryTokensRepository.create(email); const name = user.firstName; - const { clientHost } = this.settingsService.getSettings(); - const url = `${clientHost}/set-new-password/${recoveryToken.token}`; + const settings = this.settingsService.getSettings(); + const url = `${settings.clientHost!}/set-new-password/${recoveryToken.token}`; const html = await this.emailService.buildEmail("passwordResetTemplate", { name, diff --git a/server/src/service/index.ts b/server/src/service/index.ts index 464866e091..9686d2a135 100644 --- a/server/src/service/index.ts +++ b/server/src/service/index.ts @@ -27,6 +27,7 @@ export * from "@/service/infrastructure/notificationProviders/INotificationProvi export * from "@/service/infrastructure/notificationProviders/matrix.js"; export * from "@/service/infrastructure/notificationProviders/pagerduty.js"; export * from "@/service/infrastructure/notificationProviders/slack.js"; +export * from "@/service/infrastructure/notificationProviders/teams.js"; export * from "@/service/infrastructure/notificationProviders/webhook.js"; // System services diff --git a/server/src/service/infrastructure/notificationProviders/teams.ts b/server/src/service/infrastructure/notificationProviders/teams.ts new file mode 100644 index 0000000000..2a44768075 --- /dev/null +++ b/server/src/service/infrastructure/notificationProviders/teams.ts @@ -0,0 +1,274 @@ +const SERVICE_NAME = "TeamsProvider"; +import type { Notification } from "@/types/index.js"; +import { INotificationProvider } from "@/service/index.js"; +import type { NotificationMessage } from "@/types/notificationMessage.js"; +import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js"; +import type { ILogger } from "@/utils/logger.js"; +import got, { HTTPError } from "got"; + +// Types for Adaptive Card elements +type TextBlock = { + type: "TextBlock"; + text: string; + weight?: "Bolder" | "Normal" | "Lighter"; + size?: "Small" | "Medium" | "Large" | "ExtraLarge"; + color?: "Default" | "Dark" | "Light" | "Accent" | "Good" | "Warning" | "Attention"; + wrap?: boolean; + spacing?: "None" | "Small" | "Medium" | "Large" | "ExtraLarge" | "Auto"; + isSubtle?: boolean; +}; + +type ColumnSet = { + type: "ColumnSet"; + separator?: boolean; + spacing?: "None" | "Small" | "Medium" | "Large" | "ExtraLarge" | "Auto"; + columns: unknown[]; +}; + +type Fact = { + title: string; + value: string; +}; + +type FactSet = { + type: "FactSet"; + facts: Fact[]; +}; + +type Action = { + type: string; + title: string; + url?: string; +}; + +type AdaptiveCard = { + type: "AdaptiveCard"; + $schema: "http://adaptivecards.io/schemas/adaptive-card.json"; + version: "1.4"; + body: (TextBlock | ColumnSet | FactSet)[]; + actions?: Action[]; +}; + +type TeamsMessage = { + type: "message"; + attachments: Array<{ + contentType: "application/vnd.microsoft.card.adaptive"; + contentUrl: null; + content: AdaptiveCard; + }>; +}; + +export class TeamsProvider implements INotificationProvider { + private logger: ILogger; + + constructor(logger: ILogger) { + this.logger = logger; + } + + async sendTestAlert(notification: Notification): Promise { + if (!notification.address) { + return false; + } + + try { + const payload = this.wrapAdaptiveCard({ + type: "AdaptiveCard", + $schema: "http://adaptivecards.io/schemas/adaptive-card.json", + version: "1.4", + body: [ + { + type: "TextBlock", + text: getTestMessage(), + weight: "Bolder", + size: "Medium", + }, + ], + }); + + await got.post(notification.address, { + json: payload, + headers: { + "Content-Type": "application/json", + }, + }); + return true; + } catch (error) { + const err = error as HTTPError; + this.logger.warn({ + message: "Teams test alert failed", + service: SERVICE_NAME, + method: "sendTestAlert", + stack: err?.stack, + }); + return false; + } + } + + async sendMessage(notification: Notification, message: NotificationMessage): Promise { + if (!notification.address) { + return false; + } + + const payload = this.wrapAdaptiveCard(this.buildAdaptiveCard(message)); + + try { + await got.post(notification.address, { + json: payload, + headers: { + "Content-Type": "application/json", + }, + }); + this.logger.info({ + message: "Teams notification sent via sendMessage", + service: SERVICE_NAME, + method: "sendMessage", + }); + return true; + } catch (error) { + const err = error as HTTPError; + this.logger.warn({ + message: "Teams alert failed via sendMessage", + service: SERVICE_NAME, + method: "sendMessage", + stack: err?.stack, + }); + return false; + } + } + + /** + * Wrap an Adaptive Card in the Teams webhook envelope format + */ + private wrapAdaptiveCard(card: AdaptiveCard): TeamsMessage { + return { + type: "message", + attachments: [ + { + contentType: "application/vnd.microsoft.card.adaptive", + contentUrl: null, + content: card, + }, + ], + }; + } + + /** + * Build an Adaptive Card from NotificationMessage + */ + private buildAdaptiveCard(message: NotificationMessage): AdaptiveCard { + const colorMap: Record = { + critical: "attention", + warning: "warning", + success: "good", + info: "accent", + }; + const color = (colorMap[message.severity] || "default") as "Default" | "Dark" | "Light" | "Accent" | "Good" | "Warning" | "Attention" | undefined; + + const body: (TextBlock | ColumnSet | FactSet)[] = []; + + // Header with colored status indicator + body.push({ + type: "TextBlock", + text: message.content.title, + weight: "Bolder", + size: "Large", + color, + wrap: true, + }); + + // Summary + body.push({ + type: "TextBlock", + text: message.content.summary, + wrap: true, + spacing: "Small", + }); + + // Separator + body.push({ + type: "ColumnSet", + separator: true, + spacing: "Medium", + columns: [], + }); + + // Monitor details as a FactSet + body.push({ + type: "FactSet", + facts: [ + { title: "Name", value: message.monitor.name }, + { title: "Type", value: message.monitor.type }, + { title: "Status", value: message.monitor.status }, + { title: "URL", value: message.monitor.url }, + ], + }); + + // Threshold breaches + if (message.content.thresholds && message.content.thresholds.length > 0) { + body.push({ + type: "TextBlock", + text: "**Threshold Breaches**", + weight: "Bolder", + spacing: "Medium", + wrap: true, + }); + + for (const breach of message.content.thresholds) { + body.push({ + type: "TextBlock", + text: `• **${breach.metric.toUpperCase()}**: ${breach.formattedValue} (threshold: ${breach.threshold}${breach.unit})`, + wrap: true, + spacing: "Small", + }); + } + } + + // Additional details + if (message.content.details && message.content.details.length > 0) { + body.push({ + type: "TextBlock", + text: "**Additional Information**", + weight: "Bolder", + spacing: "Medium", + wrap: true, + }); + + for (const detail of message.content.details) { + body.push({ + type: "TextBlock", + text: `• ${detail}`, + wrap: true, + spacing: "Small", + }); + } + } + + // Timestamp footer + body.push({ + type: "TextBlock", + text: `Checkmate | ${new Date(message.content.timestamp).toUTCString()}`, + size: "Small", + isSubtle: true, + spacing: "Medium", + wrap: true, + }); + + // Actions (incident link) + const actions: Action[] = []; + if (message.content.incident) { + actions.push({ + type: "Action.OpenUrl", + title: "View Incident", + url: `${message.clientHost}/incidents/${message.content.incident.id}`, + }); + } + + return { + type: "AdaptiveCard", + $schema: "http://adaptivecards.io/schemas/adaptive-card.json", + version: "1.4", + body, + ...(actions.length > 0 ? { actions } : {}), + }; + } +} diff --git a/server/src/service/infrastructure/notificationsService.ts b/server/src/service/infrastructure/notificationsService.ts index 1da308678b..ea7b78a4ff 100644 --- a/server/src/service/infrastructure/notificationsService.ts +++ b/server/src/service/infrastructure/notificationsService.ts @@ -32,6 +32,7 @@ export class NotificationsService implements INotificationsService { private discordProvider: INotificationProvider; private pagerDutyProvider: INotificationProvider; private matrixProvider: INotificationProvider; + private teamsProvider: INotificationProvider; private logger: ILogger; private settingsService: ISettingsService; private notificationMessageBuilder: INotificationMessageBuilder; @@ -45,6 +46,7 @@ export class NotificationsService implements INotificationsService { discordProvider: INotificationProvider, pagerDutyProvider: INotificationProvider, matrixProvider: INotificationProvider, + teamsProvider: INotificationProvider, settingsService: ISettingsService, logger: ILogger, notificationMessageBuilder: INotificationMessageBuilder @@ -57,6 +59,7 @@ export class NotificationsService implements INotificationsService { this.discordProvider = discordProvider; this.pagerDutyProvider = pagerDutyProvider; this.matrixProvider = matrixProvider; + this.teamsProvider = teamsProvider; this.settingsService = settingsService; this.logger = logger; this.notificationMessageBuilder = notificationMessageBuilder; @@ -92,6 +95,8 @@ export class NotificationsService implements INotificationsService { return await this.discordProvider.sendMessage!(notification, notificationMessage); case "email": return await this.emailProvider.sendMessage!(notification, notificationMessage); + case "teams": + return await this.teamsProvider.sendMessage!(notification, notificationMessage); default: this.logger.warn({ message: `Unknown notification type: ${notification.type}`, @@ -150,6 +155,8 @@ export class NotificationsService implements INotificationsService { return await this.matrixProvider.sendTestAlert(notification); case "webhook": return await this.webhookProvider.sendTestAlert(notification); + case "teams": + return await this.teamsProvider.sendTestAlert(notification); default: return false; } diff --git a/server/src/types/notification.ts b/server/src/types/notification.ts index 669c840ca1..a121cd8e0c 100644 --- a/server/src/types/notification.ts +++ b/server/src/types/notification.ts @@ -1,4 +1,4 @@ -export const NotificationChannels = ["email", "slack", "discord", "webhook", "pager_duty", "matrix"] as const; +export const NotificationChannels = ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams"] 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 d974d873ff..5aa0b976eb 100644 --- a/server/src/validation/notificationValidation.ts +++ b/server/src/validation/notificationValidation.ts @@ -18,7 +18,7 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [ z.object({ notificationName: z.string().min(1, "Notification name is required"), type: z.literal("webhook"), - address: z.string().url("Please enter a valid Webhook URL"), + address: z.url({ message: "Please enter a valid Webhook URL" }), homeserverUrl: z.union([z.string(), z.literal("")]).optional(), roomId: z.union([z.string(), z.literal("")]).optional(), accessToken: z.union([z.string(), z.literal("")]).optional(), @@ -27,7 +27,7 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [ z.object({ notificationName: z.string().min(1, "Notification name is required"), type: z.literal("slack"), - address: z.string().url("Please enter a valid Webhook URL"), + address: z.url({ message: "Please enter a valid Webhook URL" }), homeserverUrl: z.union([z.string(), z.literal("")]).optional(), roomId: z.union([z.string(), z.literal("")]).optional(), accessToken: z.union([z.string(), z.literal("")]).optional(), @@ -36,7 +36,7 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [ z.object({ notificationName: z.string().min(1, "Notification name is required"), type: z.literal("discord"), - address: z.string().url("Please enter a valid Webhook URL"), + address: z.url({ message: "Please enter a valid Webhook URL" }), homeserverUrl: z.union([z.string(), z.literal("")]).optional(), roomId: z.union([z.string(), z.literal("")]).optional(), accessToken: z.union([z.string(), z.literal("")]).optional(), @@ -55,10 +55,16 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [ notificationName: z.string().min(1, "Notification name is required"), type: z.literal("matrix"), address: z.union([z.string(), z.literal("")]).optional(), - homeserverUrl: z.string().url("Please enter a valid Homeserver URL"), + homeserverUrl: z.url({ message: "Please enter a valid Homeserver URL" }), roomId: z.string().min(1, "Room ID is required"), accessToken: z.string().min(1, "Access Token is required"), }), + // Teams notification + z.object({ + notificationName: z.string().min(1, "Notification name is required"), + type: z.literal("teams"), + address: z.url({ message: "Please enter a valid Webhook URL" }), + }), ]); export const sendTestEmailBodyValidation = z.object({ diff --git a/server/test/teamsProvider.test.ts b/server/test/teamsProvider.test.ts new file mode 100644 index 0000000000..e6104f5687 --- /dev/null +++ b/server/test/teamsProvider.test.ts @@ -0,0 +1,267 @@ +import { describe, expect, it, jest, beforeEach } from "@jest/globals"; +import { TeamsProvider } from "../src/service/infrastructure/notificationProviders/teams.ts"; +import type { Notification } from "../src/types/notification.ts"; +import type { NotificationMessage } from "../src/types/notificationMessage.ts"; + +// Mock got +jest.unstable_mockModule("got", () => ({ + default: { post: jest.fn() }, +})); + +const createLogger = () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), +}); + +const createNotification = (overrides?: Partial): Notification => ({ + id: "notif-1", + userId: "user-1", + teamId: "team-1", + type: "teams", + notificationName: "Teams Alert", + address: "https://xxxxx.webhook.office.com/webhookb2/test", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + ...overrides, +}); + +const createMessage = (overrides?: Partial): NotificationMessage => ({ + type: "monitor_down", + severity: "critical", + monitor: { + id: "mon-1", + name: "API Server", + url: "https://api.example.com", + type: "http", + status: "down", + }, + content: { + title: "🔴 Monitor Down", + summary: "API Server is not responding", + details: ["Response timeout after 30s"], + thresholds: [], + incident: { + id: "inc-1", + url: "https://app.example.com/incidents/inc-1", + createdAt: new Date(), + }, + timestamp: new Date("2026-01-15T12:00:00Z"), + }, + clientHost: "https://app.example.com", + metadata: { + teamId: "team-1", + notificationReason: "monitor_down", + }, + ...overrides, +}); + +describe("TeamsProvider", () => { + let provider: TeamsProvider; + let logger: ReturnType; + let gotPost: jest.Mock; + + beforeEach(async () => { + logger = createLogger(); + provider = new TeamsProvider(logger); + const got = await import("got"); + gotPost = got.default.post as jest.Mock; + gotPost.mockReset(); + gotPost.mockResolvedValue({}); + }); + + describe("sendTestAlert", () => { + it("returns false when address is missing", async () => { + const notification = createNotification({ address: undefined }); + const result = await provider.sendTestAlert(notification); + expect(result).toBe(false); + expect(gotPost).not.toHaveBeenCalled(); + }); + + it("sends an Adaptive Card test message and returns true on success", async () => { + const notification = createNotification(); + const result = await provider.sendTestAlert(notification); + expect(result).toBe(true); + expect(gotPost).toHaveBeenCalledTimes(1); + + const [url, options] = gotPost.mock.calls[0] as [string, { json: any; headers: any }]; + expect(url).toBe(notification.address); + expect(options.headers["Content-Type"]).toBe("application/json"); + + // Verify Teams webhook envelope + const payload = options.json; + expect(payload.type).toBe("message"); + expect(payload.attachments).toHaveLength(1); + expect(payload.attachments[0].contentType).toBe("application/vnd.microsoft.card.adaptive"); + + // Verify Adaptive Card structure + const card = payload.attachments[0].content; + expect(card.type).toBe("AdaptiveCard"); + expect(card.version).toBe("1.4"); + expect(card.body[0].type).toBe("TextBlock"); + expect(card.body[0].text).toContain("test notification"); + }); + + it("returns false and logs warning on HTTP error", async () => { + gotPost.mockRejectedValue(new Error("Network error")); + const notification = createNotification(); + const result = await provider.sendTestAlert(notification); + expect(result).toBe(false); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Teams test alert failed", + service: "TeamsProvider", + }) + ); + }); + }); + + describe("sendMessage", () => { + it("returns false when address is missing", async () => { + const notification = createNotification({ address: undefined }); + const result = await provider.sendMessage(notification, createMessage()); + expect(result).toBe(false); + expect(gotPost).not.toHaveBeenCalled(); + }); + + it("sends a well-formed Adaptive Card and returns true", async () => { + const notification = createNotification(); + const message = createMessage(); + const result = await provider.sendMessage(notification, message); + + expect(result).toBe(true); + expect(gotPost).toHaveBeenCalledTimes(1); + + const [url, options] = gotPost.mock.calls[0] as [string, { json: any }]; + expect(url).toBe(notification.address); + + const payload = options.json; + expect(payload.type).toBe("message"); + + const card = payload.attachments[0].content; + expect(card.type).toBe("AdaptiveCard"); + expect(card.version).toBe("1.4"); + + // Verify body contains expected elements + const textBlocks = card.body.filter((b: any) => b.type === "TextBlock"); + const factSets = card.body.filter((b: any) => b.type === "FactSet"); + + // Title block + expect(textBlocks[0].text).toBe(message.content.title); + expect(textBlocks[0].color).toBe("attention"); // critical -> attention + + // Summary block + expect(textBlocks[1].text).toBe(message.content.summary); + + // FactSet with monitor details + expect(factSets).toHaveLength(1); + const facts = factSets[0].facts; + expect(facts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ title: "Name", value: "API Server" }), + expect.objectContaining({ title: "URL", value: "https://api.example.com" }), + expect.objectContaining({ title: "Status", value: "down" }), + ]) + ); + + // Incident action button + expect(card.actions).toHaveLength(1); + expect(card.actions[0].type).toBe("Action.OpenUrl"); + expect(card.actions[0].title).toBe("View Incident"); + expect(card.actions[0].url).toContain("/infrastructure/mon-1"); + }); + + it("omits actions when no incident is present", async () => { + const notification = createNotification(); + const message = createMessage({ + content: { + title: "Test", + summary: "Summary", + timestamp: new Date(), + }, + }); + const result = await provider.sendMessage(notification, message); + expect(result).toBe(true); + + const [, options] = gotPost.mock.calls[0] as [string, { json: any }]; + const card = options.json.attachments[0].content; + expect(card.actions).toBeUndefined(); + }); + + it("includes threshold breaches when present", async () => { + const notification = createNotification(); + const message = createMessage({ + type: "threshold_breach", + severity: "warning", + content: { + title: "Threshold Breach", + summary: "CPU usage exceeded", + thresholds: [ + { + metric: "cpu", + currentValue: 95, + threshold: 80, + unit: "%", + formattedValue: "95%", + }, + ], + timestamp: new Date(), + }, + }); + + await provider.sendMessage(notification, message); + + const [, options] = gotPost.mock.calls[0] as [string, { json: any }]; + const card = options.json.attachments[0].content; + + // Title should use "warning" color + expect(card.body[0].color).toBe("warning"); + + // Find threshold text blocks + const thresholdHeader = card.body.find((b: any) => b.type === "TextBlock" && b.text === "**Threshold Breaches**"); + expect(thresholdHeader).toBeDefined(); + + const cpuBlock = card.body.find((b: any) => b.type === "TextBlock" && b.text?.includes("CPU")); + expect(cpuBlock).toBeDefined(); + expect(cpuBlock.text).toContain("95%"); + expect(cpuBlock.text).toContain("threshold: 80%"); + }); + + it("maps severity to correct Adaptive Card colors", async () => { + const notification = createNotification(); + + const severityMap = [ + { severity: "critical", expected: "attention" }, + { severity: "warning", expected: "warning" }, + { severity: "success", expected: "good" }, + { severity: "info", expected: "accent" }, + ] as const; + + for (const { severity, expected } of severityMap) { + gotPost.mockReset(); + gotPost.mockResolvedValue({}); + + const message = createMessage({ severity }); + await provider.sendMessage(notification, message); + + const [, options] = gotPost.mock.calls[0] as [string, { json: any }]; + const card = options.json.attachments[0].content; + expect(card.body[0].color).toBe(expected); + } + }); + + it("returns false and logs warning on HTTP error", async () => { + gotPost.mockRejectedValue(new Error("502 Bad Gateway")); + const notification = createNotification(); + const result = await provider.sendMessage(notification, createMessage()); + expect(result).toBe(false); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Teams alert failed via sendMessage", + service: "TeamsProvider", + }) + ); + }); + }); +});