Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions client/src/Types/Notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export const NotificationChannels = [
"webhook",
"pager_duty",
"matrix",
"teams",
] as const;
export type NotificationChannel = (typeof NotificationChannels)[number];

Expand Down
6 changes: 6 additions & 0 deletions client/src/Validation/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,19 @@ 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,
discordSchema,
webhookSchema,
pagerDutySchema,
matrixSchema,
teamsSchema,
]);

export type NotificationFormData = z.infer<typeof notificationSchema>;
3 changes: 3 additions & 0 deletions server/src/config/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
DiscordProvider,
PagerDutyProvider,
MatrixProvider,
TeamsProvider,
// Interfaces
INetworkService,
IEmailService,
Expand Down Expand Up @@ -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,
Expand All @@ -238,6 +240,7 @@ export const initializeServices = async ({
discordProvider,
pagerDutyProvider,
matrixProvider,
teamsProvider,
settingsService,
logger,
notificationMessageBuilder
Expand Down
2 changes: 1 addition & 1 deletion server/src/db/models/Notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
4 changes: 2 additions & 2 deletions server/src/service/business/userService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions server/src/service/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
274 changes: 274 additions & 0 deletions server/src/service/infrastructure/notificationProviders/teams.ts
Original file line number Diff line number Diff line change
@@ -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 } : {}),
};
}
}
7 changes: 7 additions & 0 deletions server/src/service/infrastructure/notificationsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -45,6 +46,7 @@ export class NotificationsService implements INotificationsService {
discordProvider: INotificationProvider,
pagerDutyProvider: INotificationProvider,
matrixProvider: INotificationProvider,
teamsProvider: INotificationProvider,
settingsService: ISettingsService,
logger: ILogger,
notificationMessageBuilder: INotificationMessageBuilder
Expand All @@ -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;
Expand Down Expand Up @@ -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}`,
Expand Down Expand Up @@ -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;
}
Expand Down
Loading