diff --git a/Common/Models/DatabaseModels/UserOnCallLog.ts b/Common/Models/DatabaseModels/UserOnCallLog.ts index d417712580..33b3ebd53f 100644 --- a/Common/Models/DatabaseModels/UserOnCallLog.ts +++ b/Common/Models/DatabaseModels/UserOnCallLog.ts @@ -28,6 +28,7 @@ import Permission from "../../Types/Permission"; import UserNotificationEventType from "../../Types/UserNotification/UserNotificationEventType"; import UserNotificationExecutionStatus from "../../Types/UserNotification/UserNotificationExecutionStatus"; import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm"; +import AlertEpisode from "./AlertEpisode"; import Alert from "./Alert"; @EnableDocumentation() @@ -433,6 +434,53 @@ export default class UserOnCallLog extends BaseModel { }) public triggeredByAlertId?: ObjectID = undefined; + @ColumnAccessControl({ + create: [], + read: [Permission.CurrentUser], + update: [], + }) + @TableColumn({ + manyToOneRelationColumn: "triggeredByAlertEpisodeId", + type: TableColumnType.Entity, + modelType: AlertEpisode, + title: "Triggered By Alert Episode", + description: + "Relation to Alert Episode which triggered this on-call duty policy.", + }) + @ManyToOne( + () => { + return AlertEpisode; + }, + { + eager: false, + nullable: true, + onDelete: "CASCADE", + orphanedRowAction: "nullify", + }, + ) + @JoinColumn({ name: "triggeredByAlertEpisodeId" }) + public triggeredByAlertEpisode?: AlertEpisode = undefined; + + @ColumnAccessControl({ + create: [], + read: [Permission.CurrentUser], + update: [], + }) + @TableColumn({ + type: TableColumnType.ObjectID, + title: "Triggered By Alert Episode ID", + required: false, + description: + "ID of the Alert Episode which triggered this on-call escalation policy.", + example: "3c4d5e6f-7a8b-9c0d-1e2f-3a4b5c6d7e8f", + }) + @Column({ + type: ColumnType.ObjectID, + nullable: true, + transformer: ObjectID.getDatabaseTransformer(), + }) + public triggeredByAlertEpisodeId?: ObjectID = undefined; + @ColumnAccessControl({ create: [], read: [Permission.CurrentUser], diff --git a/Common/Models/DatabaseModels/UserOnCallLogTimeline.ts b/Common/Models/DatabaseModels/UserOnCallLogTimeline.ts index 15246c6bd0..10734c0a3b 100644 --- a/Common/Models/DatabaseModels/UserOnCallLogTimeline.ts +++ b/Common/Models/DatabaseModels/UserOnCallLogTimeline.ts @@ -1,5 +1,6 @@ import Incident from "./Incident"; import Alert from "./Alert"; +import AlertEpisode from "./AlertEpisode"; import OnCallDutyPolicy from "./OnCallDutyPolicy"; import OnCallDutyPolicyEscalationRule from "./OnCallDutyPolicyEscalationRule"; import OnCallDutyPolicyExecutionLog from "./OnCallDutyPolicyExecutionLog"; @@ -389,6 +390,52 @@ export default class UserOnCallLogTimeline extends BaseModel { }) public triggeredByAlertId?: ObjectID = undefined; + @ColumnAccessControl({ + create: [], + read: [Permission.CurrentUser], + update: [], + }) + @TableColumn({ + manyToOneRelationColumn: "triggeredByAlertEpisodeId", + type: TableColumnType.Entity, + modelType: AlertEpisode, + title: "Alert Episode", + description: "Relation to Alert Episode Resource in which this object belongs", + }) + @ManyToOne( + () => { + return AlertEpisode; + }, + { + eager: false, + nullable: true, + onDelete: "CASCADE", + orphanedRowAction: "nullify", + }, + ) + @JoinColumn({ name: "triggeredByAlertEpisodeId" }) + public triggeredByAlertEpisode?: AlertEpisode = undefined; + + @ColumnAccessControl({ + create: [], + read: [Permission.CurrentUser], + update: [], + }) + @Index() + @TableColumn({ + type: TableColumnType.ObjectID, + required: false, + canReadOnRelationQuery: true, + title: "Alert Episode ID", + description: "ID of your OneUptime Alert Episode in which this object belongs", + }) + @Column({ + type: ColumnType.ObjectID, + nullable: true, + transformer: ObjectID.getDatabaseTransformer(), + }) + public triggeredByAlertEpisodeId?: ObjectID = undefined; + @ColumnAccessControl({ create: [], read: [Permission.CurrentUser], diff --git a/Common/Server/Services/OnCallDutyPolicyEscalationRuleService.ts b/Common/Server/Services/OnCallDutyPolicyEscalationRuleService.ts index 5c8550f5c5..c9c48a83c7 100644 --- a/Common/Server/Services/OnCallDutyPolicyEscalationRuleService.ts +++ b/Common/Server/Services/OnCallDutyPolicyEscalationRuleService.ts @@ -322,6 +322,8 @@ export class Service extends DatabaseService { userNotificationEventType: options.userNotificationEventType!, triggeredByIncidentId: options.triggeredByIncidentId || undefined, triggeredByAlertId: options.triggeredByAlertId || undefined, + triggeredByAlertEpisodeId: + options.triggeredByAlertEpisodeId || undefined, onCallPolicyExecutionLogId: options.onCallPolicyExecutionLogId, onCallPolicyId: options.onCallPolicyId, onCallPolicyEscalationRuleId: ruleId, diff --git a/Common/Server/Services/PushNotificationService.ts b/Common/Server/Services/PushNotificationService.ts index e1b832fa3d..95d67d0a93 100644 --- a/Common/Server/Services/PushNotificationService.ts +++ b/Common/Server/Services/PushNotificationService.ts @@ -25,6 +25,7 @@ export interface PushNotificationOptions { // Optional relations for richer logging incidentId?: ObjectID | undefined; alertId?: ObjectID | undefined; + alertEpisodeId?: ObjectID | undefined; scheduledMaintenanceId?: ObjectID | undefined; statusPageId?: ObjectID | undefined; statusPageAnnouncementId?: ObjectID | undefined; diff --git a/Common/Server/Services/UserNotificationRuleService.ts b/Common/Server/Services/UserNotificationRuleService.ts index 03f5ccf586..92445038d2 100644 --- a/Common/Server/Services/UserNotificationRuleService.ts +++ b/Common/Server/Services/UserNotificationRuleService.ts @@ -51,6 +51,8 @@ import Alert from "../../Models/DatabaseModels/Alert"; import AlertService from "./AlertService"; import AlertSeverity from "../../Models/DatabaseModels/AlertSeverity"; import AlertSeverityService from "./AlertSeverityService"; +import AlertEpisode from "../../Models/DatabaseModels/AlertEpisode"; +import AlertEpisodeService from "./AlertEpisodeService"; import WorkspaceNotificationRule from "../../Models/DatabaseModels/WorkspaceNotificationRule"; import WorkspaceNotificationRuleService from "./WorkspaceNotificationRuleService"; import PushNotificationService from "./PushNotificationService"; @@ -73,6 +75,7 @@ export class Service extends DatabaseService { projectId: ObjectID; triggeredByIncidentId?: ObjectID | undefined; triggeredByAlertId?: ObjectID | undefined; + triggeredByAlertEpisodeId?: ObjectID | undefined; userNotificationEventType: UserNotificationEventType; onCallPolicyExecutionLogId?: ObjectID | undefined; onCallPolicyId: ObjectID | undefined; @@ -201,6 +204,10 @@ export class Service extends DatabaseService { logTimelineItem.triggeredByAlertId = options.triggeredByAlertId; } + if (options.triggeredByAlertEpisodeId) { + logTimelineItem.triggeredByAlertEpisodeId = options.triggeredByAlertEpisodeId; + } + if (options.onCallDutyPolicyExecutionLogTimelineId) { logTimelineItem.onCallDutyPolicyExecutionLogTimelineId = options.onCallDutyPolicyExecutionLogTimelineId; @@ -210,6 +217,7 @@ export class Service extends DatabaseService { let incident: Incident | null = null; let alert: Alert | null = null; + let alertEpisode: AlertEpisode | null = null; if ( options.userNotificationEventType === @@ -270,8 +278,37 @@ export class Service extends DatabaseService { }); } - if (!incident && !alert) { - throw new BadDataException("Incident or Alert not found."); + if ( + options.userNotificationEventType === + UserNotificationEventType.AlertEpisodeCreated && + options.triggeredByAlertEpisodeId + ) { + alertEpisode = await AlertEpisodeService.findOneById({ + id: options.triggeredByAlertEpisodeId!, + props: { + isRoot: true, + }, + select: { + _id: true, + title: true, + description: true, + projectId: true, + project: { + name: true, + }, + currentAlertState: { + name: true, + }, + alertSeverity: { + name: true, + }, + episodeNumber: true, + }, + }); + } + + if (!incident && !alert && !alertEpisode) { + throw new BadDataException("Incident, Alert, or Alert Episode not found."); } if ( @@ -384,6 +421,56 @@ export class Service extends DatabaseService { }); }); } + + // send email for alert episode + if ( + options.userNotificationEventType === + UserNotificationEventType.AlertEpisodeCreated && + alertEpisode + ) { + logTimelineItem.status = UserNotificationStatus.Sending; + logTimelineItem.statusMessage = `Sending email to ${notificationRuleItem.userEmail?.email.toString()}`; + logTimelineItem.userEmailId = notificationRuleItem.userEmail.id!; + + const updatedLog: UserOnCallLogTimeline = + await UserOnCallLogTimelineService.create({ + data: logTimelineItem, + props: { + isRoot: true, + }, + }); + + const emailMessage: EmailMessage = + await this.generateEmailTemplateForAlertEpisodeCreated( + notificationRuleItem.userEmail?.email, + alertEpisode, + updatedLog.id!, + ); + + MailService.sendMail(emailMessage, { + userOnCallLogTimelineId: updatedLog.id!, + projectId: options.projectId, + alertEpisodeId: alertEpisode.id!, + userId: notificationRuleItem.userId!, + onCallPolicyId: options.onCallPolicyId, + onCallPolicyEscalationRuleId: options.onCallPolicyEscalationRuleId, + teamId: options.userBelongsToTeamId, + onCallDutyPolicyExecutionLogTimelineId: + options.onCallDutyPolicyExecutionLogTimelineId, + onCallScheduleId: options.onCallScheduleId, + }).catch(async (err: Error) => { + await UserOnCallLogTimelineService.updateOneById({ + id: updatedLog.id!, + data: { + status: UserNotificationStatus.Error, + statusMessage: err.message || "Error sending email.", + }, + props: { + isRoot: true, + }, + }); + }); + } } // if you have an email but is not verified, then create a log. @@ -512,6 +599,56 @@ export class Service extends DatabaseService { }); }); } + + // send sms for alert episode + if ( + options.userNotificationEventType === + UserNotificationEventType.AlertEpisodeCreated && + alertEpisode + ) { + logTimelineItem.status = UserNotificationStatus.Sending; + logTimelineItem.statusMessage = `Sending SMS to ${notificationRuleItem.userSms?.phone.toString()}.`; + logTimelineItem.userSmsId = notificationRuleItem.userSms.id!; + + const updatedLog: UserOnCallLogTimeline = + await UserOnCallLogTimelineService.create({ + data: logTimelineItem, + props: { + isRoot: true, + }, + }); + + const smsMessage: SMS = + await this.generateSmsTemplateForAlertEpisodeCreated( + notificationRuleItem.userSms.phone, + alertEpisode, + updatedLog.id!, + ); + + SmsService.sendSms(smsMessage, { + projectId: alertEpisode.projectId, + userOnCallLogTimelineId: updatedLog.id!, + alertEpisodeId: alertEpisode.id!, + userId: notificationRuleItem.userId!, + onCallPolicyId: options.onCallPolicyId, + onCallPolicyEscalationRuleId: options.onCallPolicyEscalationRuleId, + teamId: options.userBelongsToTeamId, + onCallDutyPolicyExecutionLogTimelineId: + options.onCallDutyPolicyExecutionLogTimelineId, + onCallScheduleId: options.onCallScheduleId, + }).catch(async (err: Error) => { + await UserOnCallLogTimelineService.updateOneById({ + id: updatedLog.id!, + data: { + status: UserNotificationStatus.Error, + statusMessage: err.message || "Error sending SMS.", + }, + props: { + isRoot: true, + }, + }); + }); + } } if ( @@ -631,6 +768,56 @@ export class Service extends DatabaseService { }); }); } + + // send WhatsApp for alert episode + if ( + options.userNotificationEventType === + UserNotificationEventType.AlertEpisodeCreated && + alertEpisode + ) { + logTimelineItem.status = UserNotificationStatus.Sending; + logTimelineItem.statusMessage = `Sending WhatsApp message to ${notificationRuleItem.userWhatsApp?.phone.toString()}.`; + logTimelineItem.userWhatsAppId = notificationRuleItem.userWhatsApp.id!; + + const updatedLog: UserOnCallLogTimeline = + await UserOnCallLogTimelineService.create({ + data: logTimelineItem, + props: { + isRoot: true, + }, + }); + + const whatsAppMessage: WhatsAppMessage = + await this.generateWhatsAppTemplateForAlertEpisodeCreated( + notificationRuleItem.userWhatsApp.phone, + alertEpisode, + updatedLog.id!, + ); + + WhatsAppService.sendWhatsAppMessage(whatsAppMessage, { + projectId: alertEpisode.projectId, + alertEpisodeId: alertEpisode.id!, + userOnCallLogTimelineId: updatedLog.id!, + userId: notificationRuleItem.userId!, + onCallPolicyId: options.onCallPolicyId, + onCallPolicyEscalationRuleId: options.onCallPolicyEscalationRuleId, + teamId: options.userBelongsToTeamId, + onCallDutyPolicyExecutionLogTimelineId: + options.onCallDutyPolicyExecutionLogTimelineId, + onCallScheduleId: options.onCallScheduleId, + }).catch(async (err: Error) => { + await UserOnCallLogTimelineService.updateOneById({ + id: updatedLog.id!, + data: { + status: UserNotificationStatus.Error, + statusMessage: err.message || "Error sending WhatsApp message.", + }, + props: { + isRoot: true, + }, + }); + }); + } } if ( @@ -758,6 +945,56 @@ export class Service extends DatabaseService { }); }); } + + // send call for alert episode + if ( + options.userNotificationEventType === + UserNotificationEventType.AlertEpisodeCreated && + alertEpisode + ) { + logTimelineItem.status = UserNotificationStatus.Sending; + logTimelineItem.statusMessage = `Making a call to ${notificationRuleItem.userCall?.phone.toString()}.`; + logTimelineItem.userCallId = notificationRuleItem.userCall.id!; + + const updatedLog: UserOnCallLogTimeline = + await UserOnCallLogTimelineService.create({ + data: logTimelineItem, + props: { + isRoot: true, + }, + }); + + const callRequest: CallRequest = + await this.generateCallTemplateForAlertEpisodeCreated( + notificationRuleItem.userCall?.phone, + alertEpisode, + updatedLog.id!, + ); + + CallService.makeCall(callRequest, { + projectId: alertEpisode.projectId, + userOnCallLogTimelineId: updatedLog.id!, + alertEpisodeId: alertEpisode.id!, + userId: notificationRuleItem.userId!, + onCallPolicyId: options.onCallPolicyId, + onCallPolicyEscalationRuleId: options.onCallPolicyEscalationRuleId, + teamId: options.userBelongsToTeamId, + onCallDutyPolicyExecutionLogTimelineId: + options.onCallDutyPolicyExecutionLogTimelineId, + onCallScheduleId: options.onCallScheduleId, + }).catch(async (err: Error) => { + await UserOnCallLogTimelineService.updateOneById({ + id: updatedLog.id!, + data: { + status: UserNotificationStatus.Error, + statusMessage: err.message || "Error making call.", + }, + props: { + isRoot: true, + }, + }); + }); + } } if ( @@ -922,6 +1159,75 @@ export class Service extends DatabaseService { }); }); } + + // send push notification for alert episode + if ( + options.userNotificationEventType === + UserNotificationEventType.AlertEpisodeCreated && + alertEpisode + ) { + logTimelineItem.status = UserNotificationStatus.Sending; + logTimelineItem.statusMessage = `Sending push notification to device.`; + logTimelineItem.userPushId = notificationRuleItem.userPush.id!; + + const updatedLog: UserOnCallLogTimeline = + await UserOnCallLogTimelineService.create({ + data: logTimelineItem, + props: { + isRoot: true, + }, + }); + + const pushMessage: PushNotificationMessage = + PushNotificationUtil.createAlertEpisodeCreatedNotification({ + alertEpisodeTitle: alertEpisode.title!, + projectName: alertEpisode.project?.name || "OneUptime", + alertEpisodeViewLink: ( + await AlertEpisodeService.getEpisodeLinkInDashboard( + alertEpisode.projectId!, + alertEpisode.id!, + ) + ).toString(), + }); + + PushNotificationService.sendPushNotification( + { + devices: [ + { + token: notificationRuleItem.userPush.deviceToken!, + ...(notificationRuleItem.userPush.deviceName && { + name: notificationRuleItem.userPush.deviceName, + }), + }, + ], + message: pushMessage, + deviceType: notificationRuleItem.userPush.deviceType!, + }, + { + projectId: options.projectId, + userOnCallLogTimelineId: updatedLog.id!, + alertEpisodeId: alertEpisode.id!, + userId: notificationRuleItem.userId!, + onCallPolicyId: options.onCallPolicyId, + onCallPolicyEscalationRuleId: options.onCallPolicyEscalationRuleId, + teamId: options.userBelongsToTeamId, + onCallDutyPolicyExecutionLogTimelineId: + options.onCallDutyPolicyExecutionLogTimelineId, + onCallScheduleId: options.onCallScheduleId, + }, + ).catch(async (err: Error) => { + await UserOnCallLogTimelineService.updateOneById({ + id: updatedLog.id!, + data: { + status: UserNotificationStatus.Error, + statusMessage: err.message || "Error sending push notification.", + }, + props: { + isRoot: true, + }, + }); + }); + } } if ( @@ -1043,6 +1349,57 @@ export class Service extends DatabaseService { return callRequest; } + @CaptureSpan() + public async generateCallTemplateForAlertEpisodeCreated( + to: Phone, + alertEpisode: AlertEpisode, + userOnCallLogTimelineId: ObjectID, + ): Promise { + const host: Hostname = await DatabaseConfig.getHost(); + + const httpProtocol: Protocol = await DatabaseConfig.getHttpProtocol(); + + const callRequest: CallRequest = { + to: to, + data: [ + { + sayMessage: "This is a call from OneUptime", + }, + { + sayMessage: "A new alert episode has been created", + }, + { + sayMessage: alertEpisode.title!, + }, + { + introMessage: "To acknowledge this alert episode press 1", + numDigits: 1, + timeoutInSeconds: 10, + noInputMessage: "You have not entered any input. Good bye", + onInputCallRequest: { + "1": { + sayMessage: "You have acknowledged this alert episode. Good bye", + }, + default: { + sayMessage: "Invalid input. Good bye", + }, + }, + responseUrl: new URL( + httpProtocol, + host, + new Route(AppApiRoute.toString()) + .addRoute(new UserOnCallLogTimeline().crudApiPath!) + .addRoute( + "/call/gather-input/" + userOnCallLogTimelineId.toString(), + ), + ), + }, + ], + }; + + return callRequest; + } + @CaptureSpan() public async generateSmsTemplateForAlertCreated( to: Phone, @@ -1109,6 +1466,39 @@ export class Service extends DatabaseService { return sms; } + @CaptureSpan() + public async generateSmsTemplateForAlertEpisodeCreated( + to: Phone, + alertEpisode: AlertEpisode, + userOnCallLogTimelineId: ObjectID, + ): Promise { + const host: Hostname = await DatabaseConfig.getHost(); + const httpProtocol: Protocol = await DatabaseConfig.getHttpProtocol(); + + const shortUrl: ShortLink = await ShortLinkService.saveShortLinkFor( + new URL( + httpProtocol, + host, + new Route(AppApiRoute.toString()) + .addRoute(new UserOnCallLogTimeline().crudApiPath!) + .addRoute("/acknowledge-page/" + userOnCallLogTimelineId.toString()), + ), + ); + const url: URL = await ShortLinkService.getShortenedUrl(shortUrl); + + const episodeIdentifier: string = + alertEpisode.episodeNumber !== undefined + ? `#${alertEpisode.episodeNumber} (${alertEpisode.title || "Alert Episode"})` + : alertEpisode.title || "Alert Episode"; + + const sms: SMS = { + to, + message: `This is a message from OneUptime. A new alert episode has been created: ${episodeIdentifier}. To acknowledge this alert episode, please click on the following link ${url.toString()}`, + }; + + return sms; + } + @CaptureSpan() public async generateWhatsAppTemplateForAlertCreated( to: Phone, @@ -1223,6 +1613,65 @@ export class Service extends DatabaseService { }; } + @CaptureSpan() + public async generateWhatsAppTemplateForAlertEpisodeCreated( + to: Phone, + alertEpisode: AlertEpisode, + userOnCallLogTimelineId: ObjectID, + ): Promise { + const host: Hostname = await DatabaseConfig.getHost(); + const httpProtocol: Protocol = await DatabaseConfig.getHttpProtocol(); + + const acknowledgeShortLink: ShortLink = + await ShortLinkService.saveShortLinkFor( + new URL( + httpProtocol, + host, + new Route(AppApiRoute.toString()) + .addRoute(new UserOnCallLogTimeline().crudApiPath!) + .addRoute( + "/acknowledge-page/" + userOnCallLogTimelineId.toString(), + ), + ), + ); + + const acknowledgeUrl: URL = + await ShortLinkService.getShortenedUrl(acknowledgeShortLink); + + const episodeLinkOnDashboard: string = + alertEpisode.projectId && alertEpisode.id + ? ( + await AlertEpisodeService.getEpisodeLinkInDashboard( + alertEpisode.projectId, + alertEpisode.id, + ) + ).toString() + : acknowledgeUrl.toString(); + + // Use AlertCreated template as fallback since alert episode is similar to alert + const templateKey: WhatsAppTemplateId = WhatsAppTemplateIds.AlertCreated; + const templateVariables: Record = { + project_name: alertEpisode.project?.name || "OneUptime", + alert_title: alertEpisode.title || "", + acknowledge_url: acknowledgeUrl.toString(), + alert_number: + alertEpisode.episodeNumber !== undefined + ? alertEpisode.episodeNumber.toString() + : "", + alert_link: episodeLinkOnDashboard, + }; + + const body: string = renderWhatsAppTemplate(templateKey, templateVariables); + + return { + to, + body, + templateKey, + templateVariables, + templateLanguageCode: WhatsAppTemplateLanguage[templateKey], + }; + } + @CaptureSpan() public async generateEmailTemplateForAlertCreated( to: Email, @@ -1308,6 +1757,49 @@ export class Service extends DatabaseService { return emailMessage; } + @CaptureSpan() + public async generateEmailTemplateForAlertEpisodeCreated( + to: Email, + alertEpisode: AlertEpisode, + userOnCallLogTimelineId: ObjectID, + ): Promise { + const host: Hostname = await DatabaseConfig.getHost(); + const httpProtocol: Protocol = await DatabaseConfig.getHttpProtocol(); + + const vars: Dictionary = { + alertTitle: alertEpisode.title!, + projectName: alertEpisode.project!.name!, + currentState: alertEpisode.currentAlertState!.name!, + alertDescription: await Markdown.convertToHTML( + alertEpisode.description! || "", + MarkdownContentType.Email, + ), + alertSeverity: alertEpisode.alertSeverity!.name!, + alertViewLink: ( + await AlertEpisodeService.getEpisodeLinkInDashboard( + alertEpisode.projectId!, + alertEpisode.id!, + ) + ).toString(), + acknowledgeAlertLink: new URL( + httpProtocol, + host, + new Route(AppApiRoute.toString()) + .addRoute(new UserOnCallLogTimeline().crudApiPath!) + .addRoute("/acknowledge-page/" + userOnCallLogTimelineId.toString()), + ).toString(), + }; + + const emailMessage: EmailMessage = { + toEmail: to!, + templateType: EmailTemplateType.AcknowledgeAlert, + vars: vars, + subject: "ACTION REQUIRED: Alert Episode created - " + alertEpisode.title!, + }; + + return emailMessage; + } + @CaptureSpan() public async startUserNotificationRulesExecution( userId: ObjectID, @@ -1315,6 +1807,7 @@ export class Service extends DatabaseService { projectId: ObjectID; triggeredByIncidentId?: ObjectID | undefined; triggeredByAlertId?: ObjectID | undefined; + triggeredByAlertEpisodeId?: ObjectID | undefined; userNotificationEventType: UserNotificationEventType; onCallPolicyExecutionLogId?: ObjectID | undefined; onCallPolicyId: ObjectID | undefined; @@ -1339,6 +1832,10 @@ export class Service extends DatabaseService { userOnCallLog.triggeredByAlertId = options.triggeredByAlertId; } + if (options.triggeredByAlertEpisodeId) { + userOnCallLog.triggeredByAlertEpisodeId = options.triggeredByAlertEpisodeId; + } + userOnCallLog.userNotificationEventType = options.userNotificationEventType; if (options.onCallPolicyExecutionLogId) { diff --git a/Common/Server/Utils/PushNotificationUtil.ts b/Common/Server/Utils/PushNotificationUtil.ts index 6a30235dcd..832ed22d22 100644 --- a/Common/Server/Utils/PushNotificationUtil.ts +++ b/Common/Server/Utils/PushNotificationUtil.ts @@ -120,6 +120,28 @@ export default class PushNotificationUtil { }); } + public static createAlertEpisodeCreatedNotification(params: { + alertEpisodeTitle: string; + projectName: string; + alertEpisodeViewLink: string; + }): PushNotificationMessage { + const { alertEpisodeTitle, projectName, alertEpisodeViewLink } = params; + return PushNotificationUtil.applyDefaults({ + title: `New Alert Episode: ${alertEpisodeTitle}`, + body: `A new alert episode has been created in ${projectName}. Click to view details.`, + clickAction: alertEpisodeViewLink, + url: alertEpisodeViewLink, + tag: "alert-episode-created", + requireInteraction: true, + data: { + type: "alert-episode-created", + alertEpisodeTitle: alertEpisodeTitle, + projectName: projectName, + url: alertEpisodeViewLink, + }, + }); + } + public static createMonitorStatusChangedNotification(params: { monitorName: string; projectName: string; diff --git a/Worker/Jobs/UserOnCallLog/ExecutePendingExecutions.ts b/Worker/Jobs/UserOnCallLog/ExecutePendingExecutions.ts index 447d4cc1bf..358223d05b 100644 --- a/Worker/Jobs/UserOnCallLog/ExecutePendingExecutions.ts +++ b/Worker/Jobs/UserOnCallLog/ExecutePendingExecutions.ts @@ -13,6 +13,8 @@ import UserNotificationRule from "Common/Models/DatabaseModels/UserNotificationR import UserOnCallLog from "Common/Models/DatabaseModels/UserOnCallLog"; import Alert from "Common/Models/DatabaseModels/Alert"; import AlertService from "Common/Server/Services/AlertService"; +import AlertEpisode from "Common/Models/DatabaseModels/AlertEpisode"; +import AlertEpisodeService from "Common/Server/Services/AlertEpisodeService"; import { LIMIT_PER_PROJECT } from "Common/Types/Database/LimitMax"; RunCron( @@ -36,6 +38,7 @@ RunCron( userNotificationEventType: true, triggeredByIncidentId: true, triggeredByAlertId: true, + triggeredByAlertEpisodeId: true, onCallDutyPolicyEscalationRuleId: true, onCallDutyPolicyExecutionLogTimelineId: true, onCallDutyPolicyExecutionLogId: true, @@ -72,6 +75,7 @@ const executePendingNotificationLog: ExecutePendingNotificationLogFunction = let incident: Incident | null = null; let alert: Alert | null = null; + let alertEpisode: AlertEpisode | null = null; if (pendingNotificationLog.triggeredByIncidentId) { incident = await IncidentService.findOneById({ @@ -97,8 +101,20 @@ const executePendingNotificationLog: ExecutePendingNotificationLogFunction = }); } - if (!incident && !alert) { - throw new Error("Incident or Alert not found."); + if (pendingNotificationLog.triggeredByAlertEpisodeId) { + alertEpisode = await AlertEpisodeService.findOneById({ + id: pendingNotificationLog.triggeredByAlertEpisodeId!, + props: { + isRoot: true, + }, + select: { + alertSeverityId: true, + }, + }); + } + + if (!incident && !alert && !alertEpisode) { + throw new Error("Incident, Alert, or Alert Episode not found."); } if (incident) { @@ -147,6 +163,30 @@ const executePendingNotificationLog: ExecutePendingNotificationLogFunction = } } + if (alertEpisode) { + // check if the alert episode is acknowledged. + const isAcknowledged: boolean = + await AlertEpisodeService.isEpisodeAcknowledged({ + episodeId: pendingNotificationLog.triggeredByAlertEpisodeId!, + }); + + if (isAcknowledged) { + // then mark this policy as executed. + await UserOnCallLogService.updateOneById({ + id: pendingNotificationLog.id!, + data: { + status: UserNotificationExecutionStatus.Completed, + statusMessage: + "Execution completed because alert episode is acknowledged.", + }, + props: { + isRoot: true, + }, + }); + return; + } + } + const notificationRules: Array = await UserNotificationRuleService.findBy({ query: { @@ -154,7 +194,10 @@ const executePendingNotificationLog: ExecutePendingNotificationLogFunction = userId: pendingNotificationLog.userId!, ruleType: ruleType, incidentSeverityId: incident?.incidentSeverityId || undefined, - alertSeverityId: alert?.alertSeverityId || undefined, + alertSeverityId: + alert?.alertSeverityId || + alertEpisode?.alertSeverityId || + undefined, }, select: { _id: true, @@ -202,6 +245,8 @@ const executePendingNotificationLog: ExecutePendingNotificationLogFunction = projectId: pendingNotificationLog.projectId!, triggeredByIncidentId: pendingNotificationLog.triggeredByIncidentId, triggeredByAlertId: pendingNotificationLog.triggeredByAlertId, + triggeredByAlertEpisodeId: + pendingNotificationLog.triggeredByAlertEpisodeId, userNotificationEventType: pendingNotificationLog.userNotificationEventType!, onCallPolicyExecutionLogId: