feat: Add incident member notification system with email and WhatsApp templates

This commit is contained in:
Nawaz Dhandala
2026-01-29 21:09:44 +00:00
parent 01f7d7cc78
commit 4b30274915
10 changed files with 524 additions and 0 deletions

View File

@@ -0,0 +1,32 @@
{{> Start this}}
{{> Logo this}}
{{> EmailTitle title=(concat "Incident " incidentNumber ": " incidentTitle) }}
{{> InfoBlock info=(concat "You have been assigned as " incidentRole " to this incident.")}}
{{> InfoBlock info="Here are the details: "}}
{{> DetailBoxStart this }}
{{> DetailBoxField title="Incident Title:" text=incidentTitle }}
{{> DetailBoxField title="Your Role: " text=incidentRole }}
{{> DetailBoxField title="Current State: " text=currentState }}
{{> DetailBoxField title="Resources Affected: " text=resourcesAffected }}
{{> DetailBoxField title="Severity: " text=incidentSeverity }}
{{> DetailBoxField title="Description: " text=incidentDescription }}
{{> DetailBoxEnd this }}
{{> InfoBlock info="You can view this incident by clicking on the button below - "}}
{{> ButtonBlock buttonUrl=incidentViewLink buttonText="View on Dashboard"}}
{{> InfoBlock info="You can also copy and paste this link:"}}
{{> InfoBlock info=incidentViewLink}}
{{> InfoBlock info="You will be notified when the status of this incident changes."}}
{{> Footer this }}
{{> End this}}

View File

@@ -38,6 +38,8 @@ export enum IncidentFeedEventType {
OwnerTeamRemoved = "OwnerTeamRemoved",
OnCallPolicy = "OnCallPolicy",
OnCallNotification = "OnCallNotification",
IncidentMemberAdded = "IncidentMemberAdded",
IncidentMemberRemoved = "IncidentMemberRemoved",
}
@EnableDocumentation()

View File

@@ -521,4 +521,24 @@ export default class IncidentMember extends BaseModel {
transformer: ObjectID.getDatabaseTransformer(),
})
public deletedByUserId?: ObjectID = undefined;
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@TableColumn({
isDefaultValueColumn: true,
type: TableColumnType.Boolean,
title: "Is Member Notified",
description: "Has the member been notified of this role assignment?",
defaultValue: false,
example: false,
})
@Column({
type: ColumnType.Boolean,
nullable: false,
default: false,
})
public isMemberNotified?: boolean = undefined;
}

View File

@@ -1,9 +1,209 @@
import ObjectID from "../../Types/ObjectID";
import DatabaseService from "./DatabaseService";
import Model from "../../Models/DatabaseModels/IncidentMember";
import IncidentFeedService from "./IncidentFeedService";
import { IncidentFeedEventType } from "../../Models/DatabaseModels/IncidentFeed";
import { Gray500, Red500 } from "../../Types/BrandColors";
import User from "../../Models/DatabaseModels/User";
import UserService from "./UserService";
import { OnCreate, OnDelete } from "../Types/Database/Hooks";
import DeleteBy from "../Types/Database/DeleteBy";
import IncidentService from "./IncidentService";
import WorkspaceNotificationRuleService from "./WorkspaceNotificationRuleService";
import NotificationRuleEventType from "../../Types/Workspace/NotificationRules/EventType";
import WorkspaceNotificationRule from "../../Models/DatabaseModels/WorkspaceNotificationRule";
import logger from "../Utils/Logger";
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
import IncidentRole from "../../Models/DatabaseModels/IncidentRole";
import IncidentRoleService from "./IncidentRoleService";
export class Service extends DatabaseService<Model> {
public constructor() {
super(Model);
}
@CaptureSpan()
protected override async onBeforeDelete(
deleteBy: DeleteBy<Model>,
): Promise<OnDelete<Model>> {
const itemsToDelete: Model[] = await this.findBy({
query: deleteBy.query,
limit: deleteBy.limit,
skip: deleteBy.skip,
props: {
isRoot: true,
},
select: {
incidentId: true,
projectId: true,
userId: true,
incidentRoleId: true,
},
});
return {
carryForward: {
itemsToDelete: itemsToDelete,
},
deleteBy: deleteBy,
};
}
@CaptureSpan()
protected override async onDeleteSuccess(
onDelete: OnDelete<Model>,
_itemIdsBeforeDelete: Array<ObjectID>,
): Promise<OnDelete<Model>> {
const deleteByUserId: ObjectID | undefined =
onDelete.deleteBy.deletedByUser?.id || onDelete.deleteBy.props.userId;
const itemsToDelete: Model[] = onDelete.carryForward.itemsToDelete;
for (const item of itemsToDelete) {
const incidentId: ObjectID | undefined = item.incidentId;
const projectId: ObjectID | undefined = item.projectId;
const userId: ObjectID | undefined = item.userId;
const incidentRoleId: ObjectID | undefined = item.incidentRoleId;
if (incidentId && userId && projectId) {
const user: User | null = await UserService.findOneById({
id: userId,
select: {
name: true,
email: true,
},
props: {
isRoot: true,
},
});
let roleName: string = "Member";
if (incidentRoleId) {
const role: IncidentRole | null =
await IncidentRoleService.findOneById({
id: incidentRoleId,
select: {
name: true,
},
props: {
isRoot: true,
},
});
if (role && role.name) {
roleName = role.name;
}
}
const incidentNumber: number | null =
await IncidentService.getIncidentNumber({
incidentId: incidentId,
});
if (user && user.name) {
await IncidentFeedService.createIncidentFeedItem({
incidentId: incidentId,
projectId: projectId,
incidentFeedEventType: IncidentFeedEventType.IncidentMemberRemoved,
displayColor: Red500,
feedInfoInMarkdown: `👤 Removed **${user.name.toString()}** (${user.email?.toString()}) as **${roleName}** from [Incident ${incidentNumber}](${(await IncidentService.getIncidentLinkInDashboard(projectId!, incidentId!)).toString()}).`,
userId: deleteByUserId || undefined,
workspaceNotification: {
sendWorkspaceNotification: true,
notifyUserId: userId || undefined,
},
});
}
}
}
return onDelete;
}
@CaptureSpan()
public override async onCreateSuccess(
onCreate: OnCreate<Model>,
createdItem: Model,
): Promise<Model> {
const incidentId: ObjectID | undefined = createdItem.incidentId;
const projectId: ObjectID | undefined = createdItem.projectId;
const userId: ObjectID | undefined = createdItem.userId;
const incidentRoleId: ObjectID | undefined = createdItem.incidentRoleId;
const createdByUserId: ObjectID | undefined =
createdItem.createdByUserId || onCreate.createBy.props.userId;
if (incidentId && userId && projectId) {
let roleName: string = "Member";
if (incidentRoleId) {
const role: IncidentRole | null = await IncidentRoleService.findOneById(
{
id: incidentRoleId,
select: {
name: true,
},
props: {
isRoot: true,
},
},
);
if (role && role.name) {
roleName = role.name;
}
}
const incidentNumber: number | null =
await IncidentService.getIncidentNumber({
incidentId: incidentId,
});
if (userId) {
await IncidentFeedService.createIncidentFeedItem({
incidentId: incidentId,
projectId: projectId,
incidentFeedEventType: IncidentFeedEventType.IncidentMemberAdded,
displayColor: Gray500,
feedInfoInMarkdown: `👤 Added **${await UserService.getUserMarkdownString(
{
userId: userId,
projectId: projectId,
},
)}** as **${roleName}** to [Incident ${incidentNumber}](${(await IncidentService.getIncidentLinkInDashboard(projectId!, incidentId!)).toString()}).`,
userId: createdByUserId || undefined,
workspaceNotification: {
sendWorkspaceNotification: true,
notifyUserId: userId || undefined,
},
});
}
}
// get notification rule where inviteOwners is true.
const notificationRules: Array<WorkspaceNotificationRule> =
await WorkspaceNotificationRuleService.getNotificationRulesWhereInviteOwnersIsTrue(
{
projectId: projectId!,
notificationFor: {
incidentId: incidentId,
},
notificationRuleEventType: NotificationRuleEventType.Incident,
},
);
WorkspaceNotificationRuleService.inviteUsersBasedOnRulesAndWorkspaceChannels(
{
notificationRules: notificationRules,
projectId: projectId!,
workspaceChannels: await IncidentService.getWorkspaceChannelForIncident(
{
incidentId: incidentId!,
},
),
userIds: [userId!],
},
).catch((error: Error) => {
logger.error(error);
});
return createdItem;
}
}
export default new Service();

View File

@@ -34,6 +34,7 @@ const templateDashboardLinkVariableMap: Partial<
[WhatsAppTemplateIds.IncidentNotePostedOwnerNotification]: "incident_link",
[WhatsAppTemplateIds.IncidentStateChangedOwnerNotification]: "incident_link",
[WhatsAppTemplateIds.IncidentOwnerAddedNotification]: "incident_link",
[WhatsAppTemplateIds.IncidentMemberAddedNotification]: "incident_link",
[WhatsAppTemplateIds.MonitorOwnerAddedNotification]: "monitor_link",
[WhatsAppTemplateIds.MonitorCreatedOwnerNotification]: "monitor_link",
[WhatsAppTemplateIds.MonitorStatusChangedOwnerNotification]: "monitor_link",
@@ -75,6 +76,8 @@ const templateIdByEventType: Record<
WhatsAppTemplateIds.IncidentStateChangedOwnerNotification,
[NotificationSettingEventType.SEND_INCIDENT_OWNER_ADDED_NOTIFICATION]:
WhatsAppTemplateIds.IncidentOwnerAddedNotification,
[NotificationSettingEventType.SEND_INCIDENT_MEMBER_ADDED_NOTIFICATION]:
WhatsAppTemplateIds.IncidentMemberAddedNotification,
[NotificationSettingEventType.SEND_ALERT_CREATED_OWNER_NOTIFICATION]:
WhatsAppTemplateIds.AlertCreatedOwnerNotification,
[NotificationSettingEventType.SEND_ALERT_NOTE_POSTED_OWNER_NOTIFICATION]:

View File

@@ -36,6 +36,7 @@ enum EmailTemplateType {
IncidentOwnerStateChanged = "IncidentOwnerStateChanged.hbs",
IncidentOwnerNotePosted = "IncidentOwnerNotePosted.hbs",
IncidentOwnerResourceCreated = "IncidentOwnerResourceCreated.hbs",
IncidentMemberAdded = "IncidentMemberAdded.hbs",
AlertOwnerAdded = "AlertOwnerAdded.hbs",
AlertOwnerStateChanged = "AlertOwnerStateChanged.hbs",

View File

@@ -4,6 +4,7 @@ enum NotificationSettingEventType {
SEND_INCIDENT_NOTE_POSTED_OWNER_NOTIFICATION = "Send incident note posted notification when I am the owner of the incident",
SEND_INCIDENT_STATE_CHANGED_OWNER_NOTIFICATION = "Send incident state changed notification when I am the owner of the incident",
SEND_INCIDENT_OWNER_ADDED_NOTIFICATION = "Send notification when I am added as a owner to the incident",
SEND_INCIDENT_MEMBER_ADDED_NOTIFICATION = "Send notification when I am assigned to an incident as a member",
// Alerts

View File

@@ -9,6 +9,7 @@ type TemplateIdsMap = {
readonly IncidentNotePostedOwnerNotification: "oneuptime_incident_note_posted_owner_notification";
readonly IncidentStateChangedOwnerNotification: "oneuptime_incident_state_change_owner_notification";
readonly IncidentOwnerAddedNotification: "oneuptime_incident_owner_added_notification";
readonly IncidentMemberAddedNotification: "oneuptime_incident_member_added_notification";
readonly AlertCreatedOwnerNotification: "oneuptime_alert_created_owner_notification";
readonly AlertNotePostedOwnerNotification: "oneuptime_alert_note_posted_owner_notification";
readonly AlertStateChangedOwnerNotification: "oneuptime_alert_state_changed_owner_notification";
@@ -58,6 +59,8 @@ const templateIds: TemplateIdsMap = {
IncidentStateChangedOwnerNotification:
"oneuptime_incident_state_change_owner_notification",
IncidentOwnerAddedNotification: "oneuptime_incident_owner_added_notification",
IncidentMemberAddedNotification:
"oneuptime_incident_member_added_notification",
AlertCreatedOwnerNotification: "oneuptime_alert_created_owner_notification",
AlertNotePostedOwnerNotification:
"oneuptime_alert_note_posted_owner_notification",
@@ -144,6 +147,7 @@ export const WhatsAppTemplateMessages: WhatsAppTemplateMessagesDefinition = {
[WhatsAppTemplateIds.IncidentNotePostedOwnerNotification]: `A new note was posted on incident #{{incident_number}} ({{incident_title}}). Review the incident using {{incident_link}} on the OneUptime dashboard for more context.`,
[WhatsAppTemplateIds.IncidentStateChangedOwnerNotification]: `Incident #{{incident_number}} ({{incident_title}}) state changed to {{incident_state}}. Track the incident status using {{incident_link}} on the OneUptime dashboard for more context.`,
[WhatsAppTemplateIds.IncidentOwnerAddedNotification]: `You have been added as an owner of incident #{{incident_number}} ({{incident_title}}). Manage the incident using {{incident_link}} on the OneUptime dashboard.`,
[WhatsAppTemplateIds.IncidentMemberAddedNotification]: `You have been assigned as {{incident_role}} to incident #{{incident_number}} ({{incident_title}}). Manage the incident using {{incident_link}} on the OneUptime dashboard.`,
[WhatsAppTemplateIds.AlertCreatedOwnerNotification]: `Alert #{{alert_number}} ({{alert_title}}) has been created for project {{project_name}}. View alert details using {{alert_link}} on the OneUptime dashboard `,
[WhatsAppTemplateIds.AlertNotePostedOwnerNotification]: `A new note was posted on alert #{{alert_number}} ({{alert_title}}). Review the alert using {{alert_link}} on the OneUptime dashboard for updates.`,
[WhatsAppTemplateIds.AlertStateChangedOwnerNotification]: `Alert #{{alert_number}} ({{alert_title}}) state changed to {{alert_state}}. Track the alert status using {{alert_link}} on the OneUptime dashboard to stay informed.`,
@@ -190,6 +194,7 @@ export const WhatsAppTemplateLanguage: Record<WhatsAppTemplateId, string> = {
[WhatsAppTemplateIds.IncidentNotePostedOwnerNotification]: "en",
[WhatsAppTemplateIds.IncidentStateChangedOwnerNotification]: "en",
[WhatsAppTemplateIds.IncidentOwnerAddedNotification]: "en",
[WhatsAppTemplateIds.IncidentMemberAddedNotification]: "en",
[WhatsAppTemplateIds.AlertCreatedOwnerNotification]: "en",
[WhatsAppTemplateIds.AlertNotePostedOwnerNotification]: "en",
[WhatsAppTemplateIds.AlertStateChangedOwnerNotification]: "en",

View File

@@ -0,0 +1,257 @@
import RunCron from "../../Utils/Cron";
import { CallRequestMessage } from "Common/Types/Call/CallRequest";
import Dictionary from "Common/Types/Dictionary";
import { EmailEnvelope } from "Common/Types/Email/EmailMessage";
import EmailTemplateType from "Common/Types/Email/EmailTemplateType";
import NotificationSettingEventType from "Common/Types/NotificationSetting/NotificationSettingEventType";
import ObjectID from "Common/Types/ObjectID";
import { SMSMessage } from "Common/Types/SMS/SMS";
import PushNotificationMessage from "Common/Types/PushNotification/PushNotificationMessage";
import { EVERY_MINUTE } from "Common/Utils/CronTime";
import IncidentMemberService from "Common/Server/Services/IncidentMemberService";
import IncidentService from "Common/Server/Services/IncidentService";
import IncidentRoleService from "Common/Server/Services/IncidentRoleService";
import UserNotificationSettingService from "Common/Server/Services/UserNotificationSettingService";
import PushNotificationUtil from "Common/Server/Utils/PushNotificationUtil";
import { createWhatsAppMessageFromTemplate } from "Common/Server/Utils/WhatsAppTemplateUtil";
import Markdown, { MarkdownContentType } from "Common/Server/Types/Markdown";
import Incident from "Common/Models/DatabaseModels/Incident";
import IncidentMember from "Common/Models/DatabaseModels/IncidentMember";
import IncidentRole from "Common/Models/DatabaseModels/IncidentRole";
import Monitor from "Common/Models/DatabaseModels/Monitor";
import User from "Common/Models/DatabaseModels/User";
import { WhatsAppMessagePayload } from "Common/Types/WhatsApp/WhatsAppMessage";
RunCron(
"IncidentMember:SendMemberAddedEmail",
{ schedule: EVERY_MINUTE, runOnStartup: false },
async () => {
const incidentMembers: Array<IncidentMember> =
await IncidentMemberService.findAllBy({
query: {
isMemberNotified: false,
},
props: {
isRoot: true,
},
skip: 0,
select: {
_id: true,
incidentId: true,
userId: true,
incidentRoleId: true,
user: {
email: true,
name: true,
},
},
});
const incidentMembersMap: Dictionary<
Array<{ user: User; roleName: string }>
> = {};
for (const incidentMember of incidentMembers) {
const incidentId: ObjectID = incidentMember.incidentId!;
const user: User = incidentMember.user!;
const incidentRoleId: ObjectID | undefined = incidentMember.incidentRoleId;
let roleName: string = "Member";
if (incidentRoleId) {
const role: IncidentRole | null = await IncidentRoleService.findOneById(
{
id: incidentRoleId,
select: {
name: true,
},
props: {
isRoot: true,
},
},
);
if (role && role.name) {
roleName = role.name;
}
}
if (incidentMembersMap[incidentId.toString()] === undefined) {
incidentMembersMap[incidentId.toString()] = [];
}
(
incidentMembersMap[incidentId.toString()] as Array<{
user: User;
roleName: string;
}>
).push({ user, roleName });
// mark this as notified.
await IncidentMemberService.updateOneById({
id: incidentMember.id!,
data: {
isMemberNotified: true,
},
props: {
isRoot: true,
},
});
}
// send email to all of these users.
for (const incidentId in incidentMembersMap) {
if (!incidentMembersMap[incidentId]) {
continue;
}
if (
(
incidentMembersMap[incidentId] as Array<{
user: User;
roleName: string;
}>
).length === 0
) {
continue;
}
const members: Array<{ user: User; roleName: string }> =
incidentMembersMap[incidentId] as Array<{
user: User;
roleName: string;
}>;
// get incident details
const incident: Incident | null = await IncidentService.findOneById({
id: new ObjectID(incidentId),
props: {
isRoot: true,
},
select: {
_id: true,
title: true,
description: true,
projectId: true,
project: {
name: true,
},
currentIncidentState: {
name: true,
},
incidentSeverity: {
name: true,
},
monitors: {
name: true,
},
incidentNumber: true,
},
});
if (!incident) {
continue;
}
const incidentNumber: string = incident.incidentNumber
? `#${incident.incidentNumber}`
: "";
for (const member of members) {
const user: User = member.user;
const roleName: string = member.roleName;
const vars: Dictionary<string> = {
incidentTitle: incident.title!,
incidentNumber: incidentNumber,
projectName: incident.project!.name!,
currentState: incident.currentIncidentState!.name!,
incidentRole: roleName,
incidentDescription: await Markdown.convertToHTML(
incident.description! || "",
MarkdownContentType.Email,
),
resourcesAffected:
incident
.monitors!.map((monitor: Monitor) => {
return monitor.name!;
})
.join(", ") || "None",
incidentSeverity: incident.incidentSeverity!.name!,
incidentViewLink: (
await IncidentService.getIncidentLinkInDashboard(
incident.projectId!,
incident.id!,
)
).toString(),
};
const incidentIdentifier: string =
incident.incidentNumber !== undefined
? `#${incident.incidentNumber} (${incident.title})`
: incident.title!;
const emailMessage: EmailEnvelope = {
templateType: EmailTemplateType.IncidentMemberAdded,
vars: vars,
subject: `You have been assigned as ${roleName} to Incident ${incidentNumber} - ${incident.title}`,
};
const sms: SMSMessage = {
message: `This is a message from OneUptime. You have been assigned as ${roleName} to the incident ${incidentIdentifier}. To unsubscribe from this notification go to User Settings in OneUptime Dashboard.`,
};
const callMessage: CallRequestMessage = {
data: [
{
sayMessage: `This is a message from OneUptime. You have been assigned as ${roleName} to the incident ${incidentIdentifier}. To unsubscribe from this notification go to User Settings in OneUptime Dashboard. Good bye.`,
},
],
};
const pushMessage: PushNotificationMessage =
PushNotificationUtil.createGenericNotification({
title: `Assigned as ${roleName} to Incident ${incidentNumber}`,
body: `You have been assigned as ${roleName} to the incident ${incidentIdentifier}. Click to view details.`,
clickAction: (
await IncidentService.getIncidentLinkInDashboard(
incident.projectId!,
incident.id!,
)
).toString(),
tag: "incident-member-added",
requireInteraction: false,
});
const eventType: NotificationSettingEventType =
NotificationSettingEventType.SEND_INCIDENT_MEMBER_ADDED_NOTIFICATION;
const whatsAppMessage: WhatsAppMessagePayload =
createWhatsAppMessageFromTemplate({
eventType,
templateVariables: {
incident_title: incident.title!,
incident_number:
incident.incidentNumber !== undefined
? incident.incidentNumber.toString()
: "",
incident_role: roleName,
incident_link: vars["incidentViewLink"] || "",
},
});
await UserNotificationSettingService.sendUserNotification({
userId: user.id!,
projectId: incident.projectId!,
emailEnvelope: emailMessage,
smsMessage: sms,
callRequestMessage: callMessage,
pushNotificationMessage: pushMessage,
whatsAppMessage,
incidentId: incident.id!,
eventType,
});
}
}
},
);

View File

@@ -13,6 +13,9 @@ import "./Jobs/IncidentOwners/SendNotePostedNotification";
import "./Jobs/IncidentOwners/SendOwnerAddedNotification";
import "./Jobs/IncidentOwners/SendStateChangeNotification";
// Incident Members
import "./Jobs/IncidentMembers/SendMemberAddedNotification";
// Monitor Jobs.
import "./Jobs/Monitor/KeepCurrentStateConsistent";