Merge pull request #3375 from tb102122/feature/teams-notification-channel

add teams
This commit is contained in:
Alexander Holliday authored and GitHub committed 2026-03-09 09:39:56 -07:00
commit bbc2e4073b
11 files changed
+573 -8

No files matched your search

+1
View File
@@ -5,6 +5,7 @@ export const NotificationChannels = [
"webhook",
"pager_duty",
"matrix",
"teams",
] as const;
export type NotificationChannel = (typeof NotificationChannels)[number];
+6
View File
@@ -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<typeof notificationSchema>;
+3
View File
@@ -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
+1 -1
View File
@@ -25,7 +25,7 @@ const NotificationSchema = new Schema<NotificationDocument>(
},
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: {
+2 -2
View File
@@ -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,
+1
View File
@@ -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
@@ -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<boolean> {
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<boolean> {
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<string, string> = {
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 } : {}),
};
}
}
@@ -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;
}
+1 -1
View File
@@ -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 {
@@ -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({
+267
View File
@@ -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>): 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>): 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<typeof createLogger>;
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",
})
);
});
});
});