Add notification jobs for incident episode public notes and state timelines

- Implemented SendNotificationToSubscribers job for IncidentEpisodePublicNote to notify subscribers about new public notes added to episodes.
- Implemented SendNotificationToSubscribers job for IncidentEpisodeStateTimeline to notify subscribers about state changes of episodes.
- Both jobs include logic for fetching relevant episodes, monitors, and subscribers, and sending notifications via email, SMS, Slack, and Microsoft Teams.
- Added error handling and logging for better traceability of notification processes.
This commit is contained in:
Nawaz Dhandala
2026-02-04 19:11:44 +00:00
parent e3f8af83e5
commit 849882d868
25 changed files with 4030 additions and 0 deletions

View File

@@ -35,6 +35,7 @@ import UserWebAuthnAPI from "Common/Server/API/UserWebAuthnAPI";
import MonitorTest from "Common/Models/DatabaseModels/MonitorTest";
import IncidentInternalNoteAPI from "Common/Server/API/IncidentInternalNoteAPI";
import IncidentPublicNoteAPI from "Common/Server/API/IncidentPublicNoteAPI";
import IncidentEpisodePublicNoteAPI from "Common/Server/API/IncidentEpisodePublicNoteAPI";
import ScheduledMaintenanceInternalNoteAPI from "Common/Server/API/ScheduledMaintenanceInternalNoteAPI";
import ScheduledMaintenancePublicNoteAPI from "Common/Server/API/ScheduledMaintenancePublicNoteAPI";
import IncidentAPI from "Common/Server/API/IncidentAPI";
@@ -2140,6 +2141,11 @@ const BaseAPIFeatureSet: FeatureSet = {
new IncidentPublicNoteAPI().getRouter(),
);
app.use(
`/${APP_NAME.toLocaleLowerCase()}`,
new IncidentEpisodePublicNoteAPI().getRouter(),
);
app.use(
`/${APP_NAME.toLocaleLowerCase()}`,
new IncidentInternalNoteAPI().getRouter(),

View File

@@ -0,0 +1,36 @@
{{> Start this}}
{{> CustomLogo this}}
{{> EmailTitle title=(concat "New Incident Episode: " episodeTitle) }}
{{> InfoBlock info="A new incident episode has been declared that may affect the services you're subscribed to."}}
{{> DetailBoxStart this }}
{{> DetailBoxField title="Episode" text=episodeTitle }}
{{#if episodeSeverity}}
{{> DetailBoxField title="Severity" text=episodeSeverity }}
{{/if}}
{{> DetailBoxField title="Affected Resources" text=resourcesAffected }}
{{#if episodeDescription}}
{{> DetailBoxField title="Description" text=episodeDescription }}
{{/if}}
{{> DetailBoxEnd this }}
{{#if detailsUrl}}
{{> ButtonBlock buttonUrl=detailsUrl buttonText="View Episode Details"}}
{{else}}
{{> ButtonBlock buttonUrl=statusPageUrl buttonText="View Status Page"}}
{{/if}}
{{> VerticalSpace this}}
{{#if subscriberEmailNotificationFooterText}}
{{> InfoBlock info=subscriberEmailNotificationFooterText }}
{{/if}}
{{> UnsubscribeBlock this}}
{{> Footer this}}
{{> End this}}

View File

@@ -0,0 +1,30 @@
{{> Start this}}
{{> CustomLogo this}}
{{> EmailTitle title=(concat "Episode Update: " episodeTitle) }}
{{> InfoBlock info="A new note has been added to the episode. Here are the details: "}}
{{> DetailBoxStart this }}
{{> DetailBoxField title="Episode Title: " text=episodeTitle }}
{{> DetailBoxField title="Resources Affected: " text=resourcesAffected }}
{{#if episodeSeverity}}
{{> DetailBoxField title="Severity: " text=episodeSeverity }}
{{/if}}
{{> DetailBoxField title="Note: " text=note }}
{{> DetailBoxEnd this }}
{{> InfoBlock info=(concat subscriberEmailNotificationFooterText "") }}
{{#if detailsUrl}}
{{> InfoBlock info=(concat "Find further information here: " detailsUrl)}}
{{else}}
{{> InfoBlock info=(concat "Find further information here: " statusPageUrl)}}
{{/if}}
{{> UnsubscribeBlock this}}
{{> VerticalSpace this}}
{{> End this}}

View File

@@ -0,0 +1,34 @@
{{> Start this}}
{{> CustomLogo this}}
{{> EmailTitle title=emailTitle }}
{{> InfoBlock info="The status of an incident episode affecting services you're subscribed to has been updated."}}
{{> DetailBoxStart this }}
{{> DetailBoxField title="Episode" text=episodeTitle }}
{{> DetailBoxField title="Current State" text=episodeState }}
{{#if episodeSeverity}}
{{> DetailBoxField title="Severity" text=episodeSeverity }}
{{/if}}
{{> DetailBoxField title="Affected Resources" text=resourcesAffected }}
{{> DetailBoxEnd this }}
{{#if detailsUrl}}
{{> ButtonBlock buttonUrl=detailsUrl buttonText="View Episode Details"}}
{{else}}
{{> ButtonBlock buttonUrl=statusPageUrl buttonText="View Status Page"}}
{{/if}}
{{> VerticalSpace this}}
{{#if subscriberEmailNotificationFooterText}}
{{> InfoBlock info=subscriberEmailNotificationFooterText }}
{{/if}}
{{> UnsubscribeBlock this}}
{{> Footer this}}
{{> End this}}

View File

@@ -34,6 +34,7 @@ import {
ManyToOne,
} from "typeorm";
import NotificationRuleWorkspaceChannel from "../../Types/Workspace/NotificationRules/NotificationRuleWorkspaceChannel";
import StatusPageSubscriberNotificationStatus from "../../Types/StatusPage/StatusPageSubscriberNotificationStatus";
@EnableDocumentation()
@AccessControlColumn("labels")
@@ -1253,4 +1254,158 @@ export default class IncidentEpisode extends BaseModel {
})
public postUpdatesToWorkspaceChannels?: Array<NotificationRuleWorkspaceChannel> =
undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateIncidentEpisode,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadIncidentEpisode,
Permission.ReadAllProjectResources,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.EditIncidentEpisode,
],
})
@Index()
@TableColumn({
type: TableColumnType.Boolean,
required: true,
isDefaultValueColumn: true,
title: "Visible on Status Page",
description: "Should this episode be visible on the status page?",
defaultValue: false,
})
@Column({
type: ColumnType.Boolean,
nullable: false,
default: false,
})
public isVisibleOnStatusPage?: boolean = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateIncidentEpisode,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadIncidentEpisode,
Permission.ReadAllProjectResources,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.EditIncidentEpisode,
],
})
@Index()
@TableColumn({
type: TableColumnType.Date,
title: "Declared At",
description: "When this episode was declared",
})
@Column({
type: ColumnType.Date,
nullable: true,
unique: false,
})
public declaredAt?: Date = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateIncidentEpisode,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadIncidentEpisode,
Permission.ReadAllProjectResources,
],
update: [],
})
@TableColumn({
isDefaultValueColumn: true,
type: TableColumnType.Boolean,
title: "Should subscribers be notified on episode created?",
description:
"Should status page subscribers be notified when this episode is created?",
defaultValue: true,
})
@Column({
type: ColumnType.Boolean,
default: true,
})
public shouldStatusPageSubscribersBeNotifiedOnEpisodeCreated?: boolean =
undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadIncidentEpisode,
Permission.ReadAllProjectResources,
],
update: [],
})
@TableColumn({
isDefaultValueColumn: true,
computed: true,
hideColumnInDocumentation: true,
type: TableColumnType.ShortText,
title: "Subscriber Notification Status on Episode Created",
description:
"Status of notification sent to subscribers when this episode was created",
defaultValue: StatusPageSubscriberNotificationStatus.Pending,
})
@Column({
type: ColumnType.ShortText,
default: StatusPageSubscriberNotificationStatus.Pending,
})
public subscriberNotificationStatusOnEpisodeCreated?: StatusPageSubscriberNotificationStatus =
undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadIncidentEpisode,
Permission.ReadAllProjectResources,
],
update: [],
})
@TableColumn({
type: TableColumnType.VeryLongText,
title: "Subscriber Notification Status Message",
description:
"Status message for subscriber notifications - includes success messages, failure reasons, or skip reasons",
required: false,
})
@Column({
type: ColumnType.VeryLongText,
nullable: true,
})
public subscriberNotificationStatusMessage?: string = undefined;
}

View File

@@ -34,12 +34,14 @@ export enum IncidentEpisodeFeedEventType {
OwnerTeamRemoved = "OwnerTeamRemoved",
OwnerNotificationSent = "OwnerNotificationSent",
PrivateNote = "PrivateNote",
PublicNote = "PublicNote",
RootCause = "RootCause",
RemediationNotes = "RemediationNotes",
PostmortemNote = "PostmortemNote",
OnCallPolicy = "OnCallPolicy",
OnCallNotification = "OnCallNotification",
SeverityChanged = "SeverityChanged",
SubscriberNotificationSent = "SubscriberNotificationSent",
}
@EnableDocumentation()

View File

@@ -0,0 +1,611 @@
import IncidentEpisode from "./IncidentEpisode";
import Project from "./Project";
import User from "./User";
import File from "./File";
import BaseModel from "./DatabaseBaseModel/DatabaseBaseModel";
import Route from "../../Types/API/Route";
import ColumnAccessControl from "../../Types/Database/AccessControl/ColumnAccessControl";
import TableAccessControl from "../../Types/Database/AccessControl/TableAccessControl";
import CanAccessIfCanReadOn from "../../Types/Database/CanAccessIfCanReadOn";
import ColumnType from "../../Types/Database/ColumnType";
import CrudApiEndpoint from "../../Types/Database/CrudApiEndpoint";
import EnableDocumentation from "../../Types/Database/EnableDocumentation";
import EnableWorkflow from "../../Types/Database/EnableWorkflow";
import TableColumn from "../../Types/Database/TableColumn";
import TableColumnType from "../../Types/Database/TableColumnType";
import TableMetadata from "../../Types/Database/TableMetadata";
import TenantColumn from "../../Types/Database/TenantColumn";
import IconProp from "../../Types/Icon/IconProp";
import ObjectID from "../../Types/ObjectID";
import Permission from "../../Types/Permission";
import StatusPageSubscriberNotificationStatus from "../../Types/StatusPage/StatusPageSubscriberNotificationStatus";
import {
Column,
Entity,
Index,
JoinColumn,
JoinTable,
ManyToMany,
ManyToOne,
} from "typeorm";
@EnableDocumentation()
@CanAccessIfCanReadOn("incidentEpisode")
@TenantColumn("projectId")
@TableAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateIncidentEpisodePublicNote,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadIncidentEpisodePublicNote,
Permission.ReadAllProjectResources,
],
delete: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.DeleteIncidentEpisodePublicNote,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.EditIncidentEpisodePublicNote,
],
})
@EnableWorkflow({
create: true,
delete: true,
update: true,
read: true,
})
@CrudApiEndpoint(new Route("/incident-episode-public-note"))
@Entity({
name: "IncidentEpisodePublicNote",
})
@TableMetadata({
tableName: "IncidentEpisodePublicNote",
singularName: "Incident Episode Public Note",
pluralName: "Incident Episode Public Notes",
icon: IconProp.Team,
tableDescription: "Manage public notes for your incident episode",
})
export default class IncidentEpisodePublicNote extends BaseModel {
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateIncidentEpisodePublicNote,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadIncidentEpisodePublicNote,
Permission.ReadAllProjectResources,
],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "projectId",
type: TableColumnType.Entity,
modelType: Project,
title: "Project",
description: "Relation to Project Resource in which this object belongs",
})
@ManyToOne(
() => {
return Project;
},
{
eager: false,
nullable: true,
onDelete: "CASCADE",
orphanedRowAction: "nullify",
},
)
@JoinColumn({ name: "projectId" })
public project?: Project = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateIncidentEpisodePublicNote,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadIncidentEpisodePublicNote,
Permission.ReadAllProjectResources,
],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.ObjectID,
required: true,
canReadOnRelationQuery: true,
title: "Project ID",
description: "ID of your OneUptime Project in which this object belongs",
example: "5f8b9c0d-e1a2-4b3c-8d5e-6f7a8b9c0d1e",
})
@Column({
type: ColumnType.ObjectID,
nullable: false,
transformer: ObjectID.getDatabaseTransformer(),
})
public projectId?: ObjectID = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateIncidentEpisodePublicNote,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadIncidentEpisodePublicNote,
Permission.ReadAllProjectResources,
],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "incidentEpisodeId",
type: TableColumnType.Entity,
modelType: IncidentEpisode,
title: "Incident Episode",
description: "Relation to Incident Episode in which this resource belongs",
})
@ManyToOne(
() => {
return IncidentEpisode;
},
{
eager: false,
nullable: true,
onDelete: "CASCADE",
orphanedRowAction: "nullify",
},
)
@JoinColumn({ name: "incidentEpisodeId" })
public incidentEpisode?: IncidentEpisode = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateIncidentEpisodePublicNote,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadIncidentEpisodePublicNote,
Permission.ReadAllProjectResources,
],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.ObjectID,
required: true,
title: "Incident Episode ID",
description:
"Relation to Incident Episode ID in which this resource belongs",
example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
})
@Column({
type: ColumnType.ObjectID,
nullable: false,
transformer: ObjectID.getDatabaseTransformer(),
})
public incidentEpisodeId?: ObjectID = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateIncidentEpisodePublicNote,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadIncidentEpisodePublicNote,
Permission.ReadAllProjectResources,
],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "createdByUserId",
type: TableColumnType.Entity,
modelType: User,
title: "Created by User",
description:
"Relation to User who created this object (if this object was created by a User)",
})
@ManyToOne(
() => {
return User;
},
{
eager: false,
nullable: true,
onDelete: "SET NULL",
orphanedRowAction: "nullify",
},
)
@JoinColumn({ name: "createdByUserId" })
public createdByUser?: User = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateIncidentEpisodePublicNote,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadIncidentEpisodePublicNote,
Permission.ReadAllProjectResources,
],
update: [],
})
@TableColumn({
type: TableColumnType.ObjectID,
title: "Created by User ID",
description:
"User ID who created this object (if this object was created by a User)",
example: "7c8d9e0f-a1b2-3c4d-9e5f-8a9b0c1d2e3f",
})
@Column({
type: ColumnType.ObjectID,
nullable: true,
transformer: ObjectID.getDatabaseTransformer(),
})
public createdByUserId?: ObjectID = undefined;
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "deletedByUserId",
type: TableColumnType.Entity,
title: "Deleted by User",
modelType: User,
description:
"Relation to User who deleted this object (if this object was deleted by a User)",
})
@ManyToOne(
() => {
return User;
},
{
cascade: false,
eager: false,
nullable: true,
onDelete: "SET NULL",
orphanedRowAction: "nullify",
},
)
@JoinColumn({ name: "deletedByUserId" })
public deletedByUser?: User = undefined;
@ColumnAccessControl({
create: [],
read: [],
update: [],
})
@TableColumn({
type: TableColumnType.ObjectID,
title: "Deleted by User ID",
description:
"User ID who deleted this object (if this object was deleted by a User)",
example: "9d0e1f2a-b3c4-5d6e-af7b-8c9d0e1f2a3b",
})
@Column({
type: ColumnType.ObjectID,
nullable: true,
transformer: ObjectID.getDatabaseTransformer(),
})
public deletedByUserId?: ObjectID = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateIncidentEpisodePublicNote,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadIncidentEpisodePublicNote,
Permission.ReadAllProjectResources,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.EditIncidentEpisodePublicNote,
],
})
@TableColumn({
type: TableColumnType.Markdown,
title: "Note",
description: "Notes in markdown",
example:
"## Update - Episode Resolved\n\nWe have identified and resolved the issue. All services are now operating normally.",
})
@Column({
type: ColumnType.Markdown,
nullable: false,
unique: false,
})
public note?: string = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateIncidentEpisodePublicNote,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadIncidentEpisodePublicNote,
Permission.ReadAllProjectResources,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.EditIncidentEpisodePublicNote,
],
})
@TableColumn({
type: TableColumnType.EntityArray,
modelType: File,
title: "Attachments",
description: "Files attached to this note",
required: false,
})
@ManyToMany(
() => {
return File;
},
{
eager: false,
},
)
@JoinTable({
name: "IncidentEpisodePublicNoteFile",
joinColumn: {
name: "incidentEpisodePublicNoteId",
referencedColumnName: "_id",
},
inverseJoinColumn: {
name: "fileId",
referencedColumnName: "_id",
},
})
public attachments?: Array<File> = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateIncidentEpisodePublicNote,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadIncidentEpisodePublicNote,
Permission.ReadAllProjectResources,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.EditIncidentEpisodePublicNote,
],
})
@TableColumn({
isDefaultValueColumn: true,
computed: true,
hideColumnInDocumentation: true,
type: TableColumnType.ShortText,
title: "Subscriber Notification Status",
description: "Status of notification sent to subscribers about this note",
defaultValue: StatusPageSubscriberNotificationStatus.Pending,
})
@Column({
type: ColumnType.ShortText,
default: StatusPageSubscriberNotificationStatus.Pending,
})
public subscriberNotificationStatusOnNoteCreated?: StatusPageSubscriberNotificationStatus =
undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateIncidentEpisodePublicNote,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadIncidentEpisodePublicNote,
Permission.ReadAllProjectResources,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.EditIncidentEpisodePublicNote,
],
})
@TableColumn({
type: TableColumnType.VeryLongText,
title: "Notification Status Message",
description:
"Status message for subscriber notifications - includes success messages, failure reasons, or skip reasons",
required: false,
example: "Successfully notified 1,234 subscribers via email and SMS",
})
@Column({
type: ColumnType.VeryLongText,
nullable: true,
})
public subscriberNotificationStatusMessage?: string = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateIncidentEpisodePublicNote,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadIncidentEpisodePublicNote,
Permission.ReadAllProjectResources,
],
update: [],
})
@TableColumn({
isDefaultValueColumn: true,
type: TableColumnType.Boolean,
title: "Should subscribers be notified?",
description: "Should subscribers be notified about this note?",
defaultValue: true,
})
@Column({
type: ColumnType.Boolean,
default: true,
})
public shouldStatusPageSubscribersBeNotifiedOnNoteCreated?: boolean =
undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadIncidentEpisodePublicNote,
Permission.ReadAllProjectResources,
],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.Boolean,
computed: true,
hideColumnInDocumentation: true,
required: true,
isDefaultValueColumn: true,
title: "Are Owners Notified",
description: "Are owners notified of this resource ownership?",
defaultValue: false,
})
@Column({
type: ColumnType.Boolean,
nullable: false,
default: false,
})
public isOwnerNotified?: boolean = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateIncidentEpisodePublicNote,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadIncidentEpisodePublicNote,
Permission.ReadAllProjectResources,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.EditIncidentEpisodePublicNote,
],
})
@TableColumn({
title: "Note Posted At",
description: "Date and time when the note was posted",
type: TableColumnType.Date,
})
@Column({
type: ColumnType.Date,
nullable: true,
unique: false,
})
public postedAt?: Date = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateIncidentEpisodePublicNote,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadIncidentEpisodePublicNote,
Permission.ReadAllProjectResources,
],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.LongText,
title: "Posted from Slack Message ID",
description:
"Unique identifier for the Slack message this note was created from (channel_id:message_ts). Used to prevent duplicate notes when multiple users react to the same message.",
required: false,
example: "C1234567890:1234567890.123456",
})
@Column({
type: ColumnType.LongText,
nullable: true,
})
public postedFromSlackMessageId?: string = undefined;
}

View File

@@ -19,6 +19,7 @@ import IconProp from "../../Types/Icon/IconProp";
import { JSONObject } from "../../Types/JSON";
import ObjectID from "../../Types/ObjectID";
import Permission from "../../Types/Permission";
import StatusPageSubscriberNotificationStatus from "../../Types/StatusPage/StatusPageSubscriberNotificationStatus";
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
@EnableDocumentation()
@@ -535,4 +536,85 @@ export default class IncidentEpisodeStateTimeline extends BaseModel {
unique: false,
})
public startsAt?: Date = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateIncidentEpisodeStateTimeline,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadIncidentEpisodeStateTimeline,
Permission.ReadAllProjectResources,
],
update: [],
})
@TableColumn({
isDefaultValueColumn: true,
type: TableColumnType.Boolean,
title: "Should subscribers be notified?",
description:
"Should status page subscribers be notified about this state change?",
defaultValue: true,
})
@Column({
type: ColumnType.Boolean,
default: true,
})
public shouldStatusPageSubscribersBeNotified?: boolean = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadIncidentEpisodeStateTimeline,
Permission.ReadAllProjectResources,
],
update: [],
})
@TableColumn({
isDefaultValueColumn: true,
computed: true,
hideColumnInDocumentation: true,
type: TableColumnType.ShortText,
title: "Subscriber Notification Status",
description: "Status of notification sent to subscribers about this state change",
defaultValue: StatusPageSubscriberNotificationStatus.Pending,
})
@Column({
type: ColumnType.ShortText,
default: StatusPageSubscriberNotificationStatus.Pending,
})
public subscriberNotificationStatus?: StatusPageSubscriberNotificationStatus =
undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadIncidentEpisodeStateTimeline,
Permission.ReadAllProjectResources,
],
update: [],
})
@TableColumn({
type: TableColumnType.VeryLongText,
title: "Subscriber Notification Status Message",
description:
"Status message for subscriber notifications - includes success messages, failure reasons, or skip reasons",
required: false,
})
@Column({
type: ColumnType.VeryLongText,
nullable: true,
})
public subscriberNotificationStatusMessage?: string = undefined;
}

View File

@@ -1690,4 +1690,40 @@ export default class IncidentGroupingRule extends BaseModel {
transformer: ObjectID.getDatabaseTransformer(),
})
public deletedByUserId?: ObjectID = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.CreateIncidentGroupingRule,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadIncidentGroupingRule,
Permission.ReadAllProjectResources,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.EditIncidentGroupingRule,
],
})
@Index()
@TableColumn({
required: true,
type: TableColumnType.Boolean,
title: "Show Episodes on Status Page",
description:
"Should episodes created by this rule be shown on the status page?",
defaultValue: false,
isDefaultValueColumn: true,
})
@Column({
type: ColumnType.Boolean,
nullable: false,
default: false,
})
public showEpisodeOnStatusPage?: boolean = undefined;
}

View File

@@ -210,6 +210,7 @@ import IncidentEpisodeOwnerUser from "./IncidentEpisodeOwnerUser";
import IncidentEpisodeOwnerTeam from "./IncidentEpisodeOwnerTeam";
import IncidentEpisodeInternalNote from "./IncidentEpisodeInternalNote";
import IncidentEpisodeFeed from "./IncidentEpisodeFeed";
import IncidentEpisodePublicNote from "./IncidentEpisodePublicNote";
import IncidentGroupingRule from "./IncidentGroupingRule";
import IncidentSlaRule from "./IncidentSlaRule";
import IncidentSla from "./IncidentSla";
@@ -318,6 +319,7 @@ const AllModelTypes: Array<{
IncidentEpisodeOwnerTeam,
IncidentEpisodeInternalNote,
IncidentEpisodeFeed,
IncidentEpisodePublicNote,
IncidentGroupingRule,
IncidentSlaRule,
IncidentSla,

View File

@@ -2357,6 +2357,46 @@ export default class StatusPage extends BaseModel {
})
public showAnnouncementsOnStatusPage?: boolean = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateProjectStatusPage,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadProjectStatusPage,
Permission.ReadAllProjectResources,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.EditProjectStatusPage,
],
})
@TableColumn({
isDefaultValueColumn: true,
type: TableColumnType.Boolean,
title: "Show Incident Episodes on Status Page",
description: "Show Incident Episodes on Status Page?",
defaultValue: true,
})
@Column({
type: ColumnType.Boolean,
default: true,
nullable: false,
})
@ColumnBillingAccessControl({
read: PlanType.Free,
update: PlanType.Growth,
create: PlanType.Free,
})
public showEpisodesOnStatusPage?: boolean = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,

View File

@@ -0,0 +1,97 @@
import IncidentEpisodePublicNote from "../../Models/DatabaseModels/IncidentEpisodePublicNote";
import File from "../../Models/DatabaseModels/File";
import NotFoundException from "../../Types/Exception/NotFoundException";
import ObjectID from "../../Types/ObjectID";
import IncidentEpisodePublicNoteService, {
Service as IncidentEpisodePublicNoteServiceType,
} from "../Services/IncidentEpisodePublicNoteService";
import Response from "../Utils/Response";
import BaseAPI from "./BaseAPI";
import UserMiddleware from "../Middleware/UserAuthorization";
import {
ExpressRequest,
ExpressResponse,
NextFunction,
} from "../Utils/Express";
import CommonAPI from "./CommonAPI";
import DatabaseCommonInteractionProps from "../../Types/BaseDatabase/DatabaseCommonInteractionProps";
export default class IncidentEpisodePublicNoteAPI extends BaseAPI<
IncidentEpisodePublicNote,
IncidentEpisodePublicNoteServiceType
> {
public constructor() {
super(IncidentEpisodePublicNote, IncidentEpisodePublicNoteService);
this.router.get(
`${new this.entityType().getCrudApiPath()?.toString()}/attachment/:projectId/:noteId/:fileId`,
UserMiddleware.getUserMiddleware,
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
try {
await this.getAttachment(req, res);
} catch (err) {
next(err);
}
},
);
}
private async getAttachment(
req: ExpressRequest,
res: ExpressResponse,
): Promise<void> {
const noteIdParam: string | undefined = req.params["noteId"];
const fileIdParam: string | undefined = req.params["fileId"];
if (!noteIdParam || !fileIdParam) {
throw new NotFoundException("Attachment not found");
}
let noteId: ObjectID;
let fileId: ObjectID;
try {
noteId = new ObjectID(noteIdParam);
fileId = new ObjectID(fileIdParam);
} catch {
throw new NotFoundException("Attachment not found");
}
const props: DatabaseCommonInteractionProps =
await CommonAPI.getDatabaseCommonInteractionProps(req);
const note: IncidentEpisodePublicNote | null =
await this.service.findOneBy({
query: {
_id: noteId,
},
select: {
attachments: {
_id: true,
file: true,
fileType: true,
name: true,
},
},
props,
});
const attachment: File | undefined = note?.attachments?.find(
(file: File) => {
const attachmentId: string | null = file._id
? file._id.toString()
: file.id
? file.id.toString()
: null;
return attachmentId === fileId.toString();
},
);
if (!attachment || !attachment.file) {
throw new NotFoundException("Attachment not found");
}
Response.setNoCacheHeaders(res);
return Response.sendFileResponse(req, res, attachment);
}
}

View File

@@ -1,5 +1,9 @@
import UserMiddleware from "../Middleware/UserAuthorization";
import AcmeChallengeService from "../Services/AcmeChallengeService";
import IncidentEpisodeService from "../Services/IncidentEpisodeService";
import IncidentEpisodeMemberService from "../Services/IncidentEpisodeMemberService";
import IncidentEpisodePublicNoteService from "../Services/IncidentEpisodePublicNoteService";
import IncidentEpisodeStateTimelineService from "../Services/IncidentEpisodeStateTimelineService";
import IncidentPublicNoteService from "../Services/IncidentPublicNoteService";
import IncidentService from "../Services/IncidentService";
import IncidentStateService from "../Services/IncidentStateService";
@@ -52,6 +56,10 @@ import PositiveNumber from "../../Types/PositiveNumber";
import HashedString from "../../Types/HashedString";
import AcmeChallenge from "../../Models/DatabaseModels/AcmeChallenge";
import Incident from "../../Models/DatabaseModels/Incident";
import IncidentEpisode from "../../Models/DatabaseModels/IncidentEpisode";
import IncidentEpisodeMember from "../../Models/DatabaseModels/IncidentEpisodeMember";
import IncidentEpisodePublicNote from "../../Models/DatabaseModels/IncidentEpisodePublicNote";
import IncidentEpisodeStateTimeline from "../../Models/DatabaseModels/IncidentEpisodeStateTimeline";
import IncidentPublicNote from "../../Models/DatabaseModels/IncidentPublicNote";
import IncidentState from "../../Models/DatabaseModels/IncidentState";
import IncidentStateTimeline from "../../Models/DatabaseModels/IncidentStateTimeline";
@@ -2097,6 +2105,59 @@ export default class StatusPageAPI extends BaseAPI<
}
},
);
// Episodes endpoints
this.router.post(
`${new this.entityType()
.getCrudApiPath()
?.toString()}/episodes/:statusPageIdOrDomain`,
UserMiddleware.getUserMiddleware,
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
try {
const objectId: ObjectID = await resolveStatusPageIdOrThrow(
req.params["statusPageIdOrDomain"] as string,
);
const response: JSONObject = await this.getEpisodes(
objectId,
null,
req,
);
return Response.sendJsonObjectResponse(req, res, response);
} catch (err) {
next(err);
}
},
);
this.router.post(
`${new this.entityType()
.getCrudApiPath()
?.toString()}/episodes/:statusPageIdOrDomain/:episodeId`,
UserMiddleware.getUserMiddleware,
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
try {
const objectId: ObjectID = await resolveStatusPageIdOrThrow(
req.params["statusPageIdOrDomain"] as string,
);
const episodeId: ObjectID = new ObjectID(
req.params["episodeId"] as string,
);
const response: JSONObject = await this.getEpisodes(
objectId,
episodeId,
req,
);
return Response.sendJsonObjectResponse(req, res, response);
} catch (err) {
next(err);
}
},
);
}
@CaptureSpan()
@@ -3532,6 +3593,324 @@ export default class StatusPageAPI extends BaseAPI<
return response;
}
@CaptureSpan()
public async getEpisodes(
statusPageId: ObjectID,
episodeId: ObjectID | null,
req: ExpressRequest,
): Promise<JSONObject> {
await this.checkHasReadAccess({
statusPageId: statusPageId,
req: req,
});
const statusPage: StatusPage | null = await StatusPageService.findOneBy({
query: {
_id: statusPageId.toString(),
},
select: {
_id: true,
projectId: true,
showIncidentHistoryInDays: true,
showEpisodesOnStatusPage: true,
},
props: {
isRoot: true,
},
});
if (!statusPage) {
throw new BadDataException("Status Page not found");
}
if (!statusPage.showEpisodesOnStatusPage) {
throw new BadDataException(
"Episodes are not enabled on this status page.",
);
}
// get monitors on status page.
const statusPageResources: Array<StatusPageResource> =
await StatusPageService.getStatusPageResources({
statusPageId: statusPageId,
});
const { monitorsOnStatusPage, monitorsInGroup } =
await StatusPageService.getMonitorIdsOnStatusPage({
statusPageId: statusPageId,
});
const today: Date = OneUptimeDate.getCurrentDate();
const historyDays: Date = OneUptimeDate.getSomeDaysAgo(
statusPage.showIncidentHistoryInDays || 14,
);
// First get incidents that are visible on status page and have the required monitors
let incidentQuery: Query<Incident> = {
monitors: monitorsOnStatusPage as any,
projectId: statusPage.projectId!,
createdAt: QueryHelper.inBetween(historyDays, today),
isVisibleOnStatusPage: true,
};
let incidents: Array<Incident> = [];
if (monitorsOnStatusPage.length > 0) {
incidents = await IncidentService.findBy({
query: incidentQuery,
select: {
_id: true,
},
skip: 0,
limit: LIMIT_PER_PROJECT,
props: {
isRoot: true,
},
});
}
const incidentIds: Array<ObjectID> = incidents.map((incident: Incident) => {
return incident.id!;
});
// Get episode members that link to these incidents
let episodeMembers: Array<IncidentEpisodeMember> = [];
if (incidentIds.length > 0) {
episodeMembers = await IncidentEpisodeMemberService.findBy({
query: {
incidentId: QueryHelper.any(incidentIds),
projectId: statusPage.projectId!,
},
select: {
incidentEpisodeId: true,
incident: {
monitors: {
_id: true,
},
},
},
skip: 0,
limit: LIMIT_PER_PROJECT,
props: {
isRoot: true,
},
});
}
// Get unique episode IDs
const episodeIdsFromMembers: Set<string> = new Set();
for (const member of episodeMembers) {
if (member.incidentEpisodeId) {
episodeIdsFromMembers.add(member.incidentEpisodeId.toString());
}
}
let episodeQuery: Query<IncidentEpisode> = {
_id: QueryHelper.any(
Array.from(episodeIdsFromMembers).map((id: string) => {
return new ObjectID(id);
}),
),
projectId: statusPage.projectId!,
isVisibleOnStatusPage: true,
};
if (episodeId) {
episodeQuery = {
_id: episodeId.toString(),
projectId: statusPage.projectId!,
isVisibleOnStatusPage: true,
};
}
// Get episodes
let episodes: Array<IncidentEpisode> = [];
const selectEpisodes: Select<IncidentEpisode> = {
createdAt: true,
declaredAt: true,
updatedAt: true,
title: true,
description: true,
_id: true,
episodeNumber: true,
incidentSeverity: {
name: true,
color: true,
},
currentIncidentState: {
name: true,
color: true,
_id: true,
order: true,
},
incidentCount: true,
};
if (episodeIdsFromMembers.size > 0 || episodeId) {
episodes = await IncidentEpisodeService.findBy({
query: episodeQuery,
select: selectEpisodes,
sort: {
declaredAt: SortOrder.Descending,
createdAt: SortOrder.Descending,
},
skip: 0,
limit: LIMIT_PER_PROJECT,
props: {
isRoot: true,
},
});
}
// If no specific episode, also fetch active (unresolved) episodes
if (!episodeId && episodeIdsFromMembers.size > 0) {
const unresolvedIncidentStates: Array<IncidentState> =
await IncidentStateService.getUnresolvedIncidentStates(
statusPage.projectId!,
{
isRoot: true,
},
);
const unresolvedIncidentStateIds: Array<ObjectID> =
unresolvedIncidentStates.map((state: IncidentState) => {
return state.id!;
});
const activeEpisodes: Array<IncidentEpisode> =
await IncidentEpisodeService.findBy({
query: {
_id: QueryHelper.any(
Array.from(episodeIdsFromMembers).map((id: string) => {
return new ObjectID(id);
}),
),
isVisibleOnStatusPage: true,
currentIncidentStateId: QueryHelper.any(unresolvedIncidentStateIds),
projectId: statusPage.projectId!,
},
select: selectEpisodes,
sort: {
declaredAt: SortOrder.Descending,
createdAt: SortOrder.Descending,
},
skip: 0,
limit: LIMIT_PER_PROJECT,
props: {
isRoot: true,
},
});
episodes = [...activeEpisodes, ...episodes];
episodes = ArrayUtil.distinctByFieldName(episodes, "_id");
}
const episodesOnStatusPage: Array<ObjectID> = episodes.map(
(episode: IncidentEpisode) => {
return episode.id!;
},
);
// Get public notes for episodes
let episodePublicNotes: Array<IncidentEpisodePublicNote> = [];
if (episodesOnStatusPage.length > 0) {
episodePublicNotes = await IncidentEpisodePublicNoteService.findBy({
query: {
incidentEpisodeId: QueryHelper.any(episodesOnStatusPage),
projectId: statusPage.projectId!,
},
select: {
postedAt: true,
note: true,
incidentEpisodeId: true,
attachments: {
_id: true,
name: true,
},
},
sort: {
postedAt: SortOrder.Descending,
},
skip: 0,
limit: LIMIT_PER_PROJECT,
props: {
isRoot: true,
},
});
}
// Get state timelines for episodes
let episodeStateTimelines: Array<IncidentEpisodeStateTimeline> = [];
if (episodesOnStatusPage.length > 0) {
episodeStateTimelines = await IncidentEpisodeStateTimelineService.findBy({
query: {
incidentEpisodeId: QueryHelper.any(episodesOnStatusPage),
projectId: statusPage.projectId!,
},
select: {
_id: true,
createdAt: true,
startsAt: true,
incidentEpisodeId: true,
incidentState: {
name: true,
color: true,
},
},
sort: {
startsAt: SortOrder.Descending,
},
skip: 0,
limit: LIMIT_PER_PROJECT,
props: {
isRoot: true,
},
});
}
// Get all incident states for this project
const incidentStates: Array<IncidentState> =
await IncidentStateService.findBy({
query: {
projectId: statusPage.projectId!,
},
select: {
isResolvedState: true,
order: true,
},
limit: LIMIT_PER_PROJECT,
skip: 0,
props: {
isRoot: true,
},
});
const response: JSONObject = {
episodePublicNotes: BaseModel.toJSONArray(
episodePublicNotes,
IncidentEpisodePublicNote,
),
incidentStates: BaseModel.toJSONArray(incidentStates, IncidentState),
episodes: BaseModel.toJSONArray(episodes, IncidentEpisode),
statusPageResources: BaseModel.toJSONArray(
statusPageResources,
StatusPageResource,
),
episodeStateTimelines: BaseModel.toJSONArray(
episodeStateTimelines,
IncidentEpisodeStateTimeline,
),
monitorsInGroup: JSONFunctions.serialize(monitorsInGroup),
};
return response;
}
@CaptureSpan()
public async getStatusPageResourcesAndTimelines(data: {
statusPageId: ObjectID;

View File

@@ -0,0 +1,70 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class MigrationName1770232207959 implements MigrationInterface {
public name = 'MigrationName1770232207959'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "IncidentEpisodePublicNote" ("_id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "deletedAt" TIMESTAMP WITH TIME ZONE, "version" integer NOT NULL, "projectId" uuid NOT NULL, "incidentEpisodeId" uuid NOT NULL, "createdByUserId" uuid, "deletedByUserId" uuid, "note" text NOT NULL, "subscriberNotificationStatusOnNoteCreated" character varying NOT NULL DEFAULT 'Pending', "subscriberNotificationStatusMessage" text, "shouldStatusPageSubscribersBeNotifiedOnNoteCreated" boolean NOT NULL DEFAULT true, "isOwnerNotified" boolean NOT NULL DEFAULT false, "postedAt" TIMESTAMP WITH TIME ZONE, "postedFromSlackMessageId" character varying, CONSTRAINT "PK_7ac3241d9cf7bdf5ecac42c9c98" PRIMARY KEY ("_id"))`);
await queryRunner.query(`CREATE INDEX "IDX_46268b7b996f3b8129136d12d2" ON "IncidentEpisodePublicNote" ("projectId") `);
await queryRunner.query(`CREATE INDEX "IDX_885de505291c2f6f4f86b988ca" ON "IncidentEpisodePublicNote" ("incidentEpisodeId") `);
await queryRunner.query(`CREATE INDEX "IDX_2e0c6dd840bbc05b9a5117f882" ON "IncidentEpisodePublicNote" ("isOwnerNotified") `);
await queryRunner.query(`CREATE INDEX "IDX_784fb865a857417194a37034ed" ON "IncidentEpisodePublicNote" ("postedFromSlackMessageId") `);
await queryRunner.query(`CREATE TABLE "IncidentEpisodePublicNoteFile" ("incidentEpisodePublicNoteId" uuid NOT NULL, "fileId" uuid NOT NULL, CONSTRAINT "PK_546f0c2cf908ebf301fb164a1ed" PRIMARY KEY ("incidentEpisodePublicNoteId", "fileId"))`);
await queryRunner.query(`CREATE INDEX "IDX_8e439d24c574141d9a66d74fa5" ON "IncidentEpisodePublicNoteFile" ("incidentEpisodePublicNoteId") `);
await queryRunner.query(`CREATE INDEX "IDX_0dfcd7747b6e55239df9bbaac5" ON "IncidentEpisodePublicNoteFile" ("fileId") `);
await queryRunner.query(`ALTER TABLE "IncidentGroupingRule" ADD "showEpisodeOnStatusPage" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`ALTER TABLE "IncidentEpisode" ADD "isVisibleOnStatusPage" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`ALTER TABLE "IncidentEpisode" ADD "declaredAt" TIMESTAMP WITH TIME ZONE`);
await queryRunner.query(`ALTER TABLE "IncidentEpisode" ADD "shouldStatusPageSubscribersBeNotifiedOnEpisodeCreated" boolean NOT NULL DEFAULT true`);
await queryRunner.query(`ALTER TABLE "IncidentEpisode" ADD "subscriberNotificationStatusOnEpisodeCreated" character varying NOT NULL DEFAULT 'Pending'`);
await queryRunner.query(`ALTER TABLE "IncidentEpisode" ADD "subscriberNotificationStatusMessage" text`);
await queryRunner.query(`ALTER TABLE "StatusPage" ADD "showEpisodesOnStatusPage" boolean NOT NULL DEFAULT true`);
await queryRunner.query(`ALTER TABLE "IncidentEpisodeStateTimeline" ADD "shouldStatusPageSubscribersBeNotified" boolean NOT NULL DEFAULT true`);
await queryRunner.query(`ALTER TABLE "IncidentEpisodeStateTimeline" ADD "subscriberNotificationStatus" character varying NOT NULL DEFAULT 'Pending'`);
await queryRunner.query(`ALTER TABLE "IncidentEpisodeStateTimeline" ADD "subscriberNotificationStatusMessage" text`);
await queryRunner.query(`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type":"Recurring","value":{"intervalType":"Day","intervalCount":{"_type":"PositiveNumber","value":1}}}'`);
await queryRunner.query(`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type":"RestrictionTimes","value":{"restictionType":"None","dayRestrictionTimes":null,"weeklyRestrictionTimes":[]}}'`);
await queryRunner.query(`CREATE INDEX "IDX_9d7bfebab97dc56583df143ae8" ON "IncidentGroupingRule" ("showEpisodeOnStatusPage") `);
await queryRunner.query(`CREATE INDEX "IDX_298e3524e859ddb920c05e1072" ON "IncidentEpisode" ("isVisibleOnStatusPage") `);
await queryRunner.query(`CREATE INDEX "IDX_30d4a06d51be505b6233185906" ON "IncidentEpisode" ("declaredAt") `);
await queryRunner.query(`ALTER TABLE "IncidentEpisodePublicNote" ADD CONSTRAINT "FK_46268b7b996f3b8129136d12d21" FOREIGN KEY ("projectId") REFERENCES "Project"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "IncidentEpisodePublicNote" ADD CONSTRAINT "FK_885de505291c2f6f4f86b988ca6" FOREIGN KEY ("incidentEpisodeId") REFERENCES "IncidentEpisode"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "IncidentEpisodePublicNote" ADD CONSTRAINT "FK_7711bde5254d7c1021bbbb0f944" FOREIGN KEY ("createdByUserId") REFERENCES "User"("_id") ON DELETE SET NULL ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "IncidentEpisodePublicNote" ADD CONSTRAINT "FK_96d88a43c5479b67ddd7c70df16" FOREIGN KEY ("deletedByUserId") REFERENCES "User"("_id") ON DELETE SET NULL ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "IncidentEpisodePublicNoteFile" ADD CONSTRAINT "FK_8e439d24c574141d9a66d74fa5c" FOREIGN KEY ("incidentEpisodePublicNoteId") REFERENCES "IncidentEpisodePublicNote"("_id") ON DELETE CASCADE ON UPDATE CASCADE`);
await queryRunner.query(`ALTER TABLE "IncidentEpisodePublicNoteFile" ADD CONSTRAINT "FK_0dfcd7747b6e55239df9bbaac59" FOREIGN KEY ("fileId") REFERENCES "File"("_id") ON DELETE CASCADE ON UPDATE CASCADE`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "IncidentEpisodePublicNoteFile" DROP CONSTRAINT "FK_0dfcd7747b6e55239df9bbaac59"`);
await queryRunner.query(`ALTER TABLE "IncidentEpisodePublicNoteFile" DROP CONSTRAINT "FK_8e439d24c574141d9a66d74fa5c"`);
await queryRunner.query(`ALTER TABLE "IncidentEpisodePublicNote" DROP CONSTRAINT "FK_96d88a43c5479b67ddd7c70df16"`);
await queryRunner.query(`ALTER TABLE "IncidentEpisodePublicNote" DROP CONSTRAINT "FK_7711bde5254d7c1021bbbb0f944"`);
await queryRunner.query(`ALTER TABLE "IncidentEpisodePublicNote" DROP CONSTRAINT "FK_885de505291c2f6f4f86b988ca6"`);
await queryRunner.query(`ALTER TABLE "IncidentEpisodePublicNote" DROP CONSTRAINT "FK_46268b7b996f3b8129136d12d21"`);
await queryRunner.query(`DROP INDEX "public"."IDX_30d4a06d51be505b6233185906"`);
await queryRunner.query(`DROP INDEX "public"."IDX_298e3524e859ddb920c05e1072"`);
await queryRunner.query(`DROP INDEX "public"."IDX_9d7bfebab97dc56583df143ae8"`);
await queryRunner.query(`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type": "RestrictionTimes", "value": {"restictionType": "None", "dayRestrictionTimes": null, "weeklyRestrictionTimes": []}}'`);
await queryRunner.query(`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type": "Recurring", "value": {"intervalType": "Day", "intervalCount": {"_type": "PositiveNumber", "value": 1}}}'`);
await queryRunner.query(`ALTER TABLE "IncidentEpisodeStateTimeline" DROP COLUMN "subscriberNotificationStatusMessage"`);
await queryRunner.query(`ALTER TABLE "IncidentEpisodeStateTimeline" DROP COLUMN "subscriberNotificationStatus"`);
await queryRunner.query(`ALTER TABLE "IncidentEpisodeStateTimeline" DROP COLUMN "shouldStatusPageSubscribersBeNotified"`);
await queryRunner.query(`ALTER TABLE "StatusPage" DROP COLUMN "showEpisodesOnStatusPage"`);
await queryRunner.query(`ALTER TABLE "IncidentEpisode" DROP COLUMN "subscriberNotificationStatusMessage"`);
await queryRunner.query(`ALTER TABLE "IncidentEpisode" DROP COLUMN "subscriberNotificationStatusOnEpisodeCreated"`);
await queryRunner.query(`ALTER TABLE "IncidentEpisode" DROP COLUMN "shouldStatusPageSubscribersBeNotifiedOnEpisodeCreated"`);
await queryRunner.query(`ALTER TABLE "IncidentEpisode" DROP COLUMN "declaredAt"`);
await queryRunner.query(`ALTER TABLE "IncidentEpisode" DROP COLUMN "isVisibleOnStatusPage"`);
await queryRunner.query(`ALTER TABLE "IncidentGroupingRule" DROP COLUMN "showEpisodeOnStatusPage"`);
await queryRunner.query(`DROP INDEX "public"."IDX_0dfcd7747b6e55239df9bbaac5"`);
await queryRunner.query(`DROP INDEX "public"."IDX_8e439d24c574141d9a66d74fa5"`);
await queryRunner.query(`DROP TABLE "IncidentEpisodePublicNoteFile"`);
await queryRunner.query(`DROP INDEX "public"."IDX_784fb865a857417194a37034ed"`);
await queryRunner.query(`DROP INDEX "public"."IDX_2e0c6dd840bbc05b9a5117f882"`);
await queryRunner.query(`DROP INDEX "public"."IDX_885de505291c2f6f4f86b988ca"`);
await queryRunner.query(`DROP INDEX "public"."IDX_46268b7b996f3b8129136d12d2"`);
await queryRunner.query(`DROP TABLE "IncidentEpisodePublicNote"`);
}
}

View File

@@ -249,6 +249,7 @@ import { MigrationName1769774527481 } from "./1769774527481-MigrationName";
import { MigrationName1769780297584 } from "./1769780297584-MigrationName";
import { MigrationName1769802715014 } from "./1769802715014-MigrationName";
import { MigrationName1770054293299 } from "./1770054293299-MigrationName";
import { MigrationName1770232207959 } from "./1770232207959-MigrationName";
export default [
InitialMigration,
@@ -502,4 +503,5 @@ export default [
MigrationName1769780297584,
MigrationName1769802715014,
MigrationName1770054293299,
MigrationName1770232207959
];

View File

@@ -0,0 +1,254 @@
import CreateBy from "../Types/Database/CreateBy";
import { OnCreate, OnUpdate } from "../Types/Database/Hooks";
import DatabaseService from "./DatabaseService";
import OneUptimeDate from "../../Types/Date";
import Model from "../../Models/DatabaseModels/IncidentEpisodePublicNote";
import IncidentEpisodeFeedService from "./IncidentEpisodeFeedService";
import { IncidentEpisodeFeedEventType } from "../../Models/DatabaseModels/IncidentEpisodeFeed";
import { Blue500, Indigo500 } from "../../Types/BrandColors";
import ObjectID from "../../Types/ObjectID";
import { LIMIT_PER_PROJECT } from "../../Types/Database/LimitMax";
import IncidentEpisodeService from "./IncidentEpisodeService";
import IncidentEpisode from "../../Models/DatabaseModels/IncidentEpisode";
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
import StatusPageSubscriberNotificationStatus from "../../Types/StatusPage/StatusPageSubscriberNotificationStatus";
import File from "../../Models/DatabaseModels/File";
import FileAttachmentMarkdownUtil from "../Utils/FileAttachmentMarkdownUtil";
export class Service extends DatabaseService<Model> {
public constructor() {
super(Model);
}
@CaptureSpan()
public async addNote(data: {
userId: ObjectID;
incidentEpisodeId: ObjectID;
projectId: ObjectID;
note: string;
attachmentFileIds?: Array<ObjectID>;
postedFromSlackMessageId?: string;
}): Promise<Model> {
const publicNote: Model = new Model();
publicNote.createdByUserId = data.userId;
publicNote.incidentEpisodeId = data.incidentEpisodeId;
publicNote.projectId = data.projectId;
publicNote.note = data.note;
publicNote.postedAt = OneUptimeDate.getCurrentDate();
if (data.postedFromSlackMessageId) {
publicNote.postedFromSlackMessageId = data.postedFromSlackMessageId;
}
if (data.attachmentFileIds && data.attachmentFileIds.length > 0) {
publicNote.attachments = data.attachmentFileIds.map(
(fileId: ObjectID) => {
const file: File = new File();
file.id = fileId;
return file;
},
);
}
return this.create({
data: publicNote,
props: {
isRoot: true,
},
});
}
@CaptureSpan()
public async hasNoteFromSlackMessage(data: {
incidentEpisodeId: ObjectID;
postedFromSlackMessageId: string;
}): Promise<boolean> {
const existingNote: Model | null = await this.findOneBy({
query: {
incidentEpisodeId: data.incidentEpisodeId,
postedFromSlackMessageId: data.postedFromSlackMessageId,
},
select: {
_id: true,
},
props: {
isRoot: true,
},
});
return existingNote !== null;
}
@CaptureSpan()
protected override async onBeforeCreate(
createBy: CreateBy<Model>,
): Promise<OnCreate<Model>> {
if (!createBy.data.postedAt) {
createBy.data.postedAt = OneUptimeDate.getCurrentDate();
}
// Set notification status based on shouldStatusPageSubscribersBeNotifiedOnNoteCreated
if (
createBy.data.shouldStatusPageSubscribersBeNotifiedOnNoteCreated === false
) {
createBy.data.subscriberNotificationStatusOnNoteCreated =
StatusPageSubscriberNotificationStatus.Skipped;
createBy.data.subscriberNotificationStatusMessage =
"Notifications skipped as subscribers are not to be notified for this episode note.";
} else if (
createBy.data.shouldStatusPageSubscribersBeNotifiedOnNoteCreated === true
) {
createBy.data.subscriberNotificationStatusOnNoteCreated =
StatusPageSubscriberNotificationStatus.Pending;
}
return {
createBy: createBy,
carryForward: null,
};
}
@CaptureSpan()
public override async onCreateSuccess(
_onCreate: OnCreate<Model>,
createdItem: Model,
): Promise<Model> {
const userId: ObjectID | null | undefined =
createdItem.createdByUserId || createdItem.createdByUser?.id;
const incidentEpisodeId: ObjectID = createdItem.incidentEpisodeId!;
const projectId: ObjectID = createdItem.projectId!;
const episodeNumber: number | null =
await IncidentEpisodeService.getEpisodeNumber({
episodeId: incidentEpisodeId,
});
const attachmentsMarkdown: string = await this.getAttachmentsMarkdown(
createdItem.id!,
"/incident-episode-public-note/attachment",
);
await IncidentEpisodeFeedService.createIncidentEpisodeFeedItem({
incidentEpisodeId: createdItem.incidentEpisodeId!,
projectId: createdItem.projectId!,
incidentEpisodeFeedEventType: IncidentEpisodeFeedEventType.PublicNote,
displayColor: Indigo500,
userId: userId || undefined,
feedInfoInMarkdown: `📄 posted **public note** for this [Episode ${episodeNumber}](${(await IncidentEpisodeService.getEpisodeLinkInDashboard(projectId!, incidentEpisodeId!)).toString()}) on status page:
${(createdItem.note || "") + attachmentsMarkdown}
`,
});
return createdItem;
}
@CaptureSpan()
public override async onUpdateSuccess(
onUpdate: OnUpdate<Model>,
_updatedItemIds: Array<ObjectID>,
): Promise<OnUpdate<Model>> {
if (onUpdate.updateBy.data.note) {
const updatedItems: Array<Model> = await this.findBy({
query: onUpdate.updateBy.query,
limit: LIMIT_PER_PROJECT,
skip: 0,
props: {
isRoot: true,
},
select: {
incidentEpisodeId: true,
projectId: true,
incidentEpisode: {
_id: true,
episodeNumber: true,
projectId: true,
},
note: true,
createdByUserId: true,
createdByUser: {
_id: true,
},
},
});
const userId: ObjectID | null | undefined =
onUpdate.updateBy.props.userId;
for (const updatedItem of updatedItems) {
const episode: IncidentEpisode = updatedItem.incidentEpisode!;
const attachmentsMarkdown: string = await this.getAttachmentsMarkdown(
updatedItem.id!,
"/incident-episode-public-note/attachment",
);
await IncidentEpisodeFeedService.createIncidentEpisodeFeedItem({
incidentEpisodeId: updatedItem.incidentEpisodeId!,
projectId: updatedItem.projectId!,
incidentEpisodeFeedEventType: IncidentEpisodeFeedEventType.PublicNote,
displayColor: Blue500,
userId: userId || undefined,
feedInfoInMarkdown: `📄 updated **Public Note** for this [Episode ${episode.episodeNumber}](${(await IncidentEpisodeService.getEpisodeLinkInDashboard(episode.projectId!, episode.id!)).toString()})
${(updatedItem.note || "") + attachmentsMarkdown}
`,
});
}
}
return onUpdate;
}
private async getAttachmentsMarkdown(
modelId: ObjectID,
attachmentApiPath: string,
): Promise<string> {
if (!modelId) {
return "";
}
const noteWithAttachments: Model | null = await this.findOneById({
id: modelId,
select: {
attachments: {
_id: true,
},
},
props: {
isRoot: true,
},
});
if (!noteWithAttachments || !noteWithAttachments.attachments) {
return "";
}
const attachmentIds: Array<ObjectID> = noteWithAttachments.attachments
.map((file: File) => {
if (file.id) {
return file.id;
}
if (file._id) {
return new ObjectID(file._id);
}
return null;
})
.filter((id: ObjectID | null): id is ObjectID => {
return Boolean(id);
});
if (!attachmentIds.length) {
return "";
}
return await FileAttachmentMarkdownUtil.buildAttachmentMarkdown({
modelId,
attachmentIds,
attachmentApiPath,
});
}
}
export default new Service();

View File

@@ -38,6 +38,8 @@ import Semaphore, { SemaphoreMutex } from "../Infrastructure/Semaphore";
import OnCallDutyPolicyService from "./OnCallDutyPolicyService";
import OnCallDutyPolicy from "../../Models/DatabaseModels/OnCallDutyPolicy";
import UserNotificationEventType from "../../Types/UserNotification/UserNotificationEventType";
import IncidentGroupingRuleService from "./IncidentGroupingRuleService";
import IncidentGroupingRule from "../../Models/DatabaseModels/IncidentGroupingRule";
export class Service extends DatabaseService<Model> {
public constructor() {
@@ -135,6 +137,30 @@ export class Service extends DatabaseService<Model> {
createBy.data.lastIncidentAddedAt = OneUptimeDate.getCurrentDate();
}
// Set declaredAt if not provided
if (!createBy.data.declaredAt) {
createBy.data.declaredAt = OneUptimeDate.getCurrentDate();
}
// Copy showEpisodeOnStatusPage from grouping rule if available
if (createBy.data.incidentGroupingRuleId) {
const groupingRule: IncidentGroupingRule | null =
await IncidentGroupingRuleService.findOneById({
id: createBy.data.incidentGroupingRuleId,
select: {
showEpisodeOnStatusPage: true,
},
props: {
isRoot: true,
},
});
if (groupingRule) {
createBy.data.isVisibleOnStatusPage =
groupingRule.showEpisodeOnStatusPage ?? true;
}
}
return { createBy, carryForward: { mutex } };
} catch (error) {
// Release the mutex if it was acquired and an error occurred

View File

@@ -181,6 +181,7 @@ import IncidentEpisodeRoleMemberService from "./IncidentEpisodeRoleMemberService
import IncidentEpisodeOwnerTeamService from "./IncidentEpisodeOwnerTeamService";
import IncidentEpisodeOwnerUserService from "./IncidentEpisodeOwnerUserService";
import IncidentEpisodeStateTimelineService from "./IncidentEpisodeStateTimelineService";
import IncidentEpisodePublicNoteService from "./IncidentEpisodePublicNoteService";
import AlertGroupingRuleService from "./AlertGroupingRuleService";
import IncidentSlaRuleService from "./IncidentSlaRuleService";
import IncidentSlaService from "./IncidentSlaService";
@@ -400,6 +401,7 @@ const services: Array<BaseService> = [
IncidentEpisodeOwnerTeamService,
IncidentEpisodeOwnerUserService,
IncidentEpisodeStateTimelineService,
IncidentEpisodePublicNoteService,
AlertGroupingRuleService,
IncidentSlaRuleService,
IncidentSlaService,

View File

@@ -53,6 +53,10 @@ enum EmailTemplateType {
IncidentEpisodeOwnerNotePosted = "IncidentEpisodeOwnerNotePosted.hbs",
IncidentEpisodeOwnerResourceCreated = "IncidentEpisodeOwnerResourceCreated.hbs",
SubscriberEpisodeCreated = "SubscriberEpisodeCreated.hbs",
SubscriberEpisodeStateChanged = "SubscriberEpisodeStateChanged.hbs",
SubscriberEpisodeNoteCreated = "SubscriberEpisodeNoteCreated.hbs",
ScheduledMaintenanceOwnerNotePosted = "ScheduledMaintenanceOwnerNotePosted.hbs",
ScheduledMaintenanceOwnerAdded = "ScheduledMaintenanceOwnerAdded.hbs",
ScheduledMaintenanceOwnerStateChanged = "ScheduledMaintenanceOwnerStateChanged.hbs",

View File

@@ -810,6 +810,12 @@ enum Permission {
EditIncidentEpisodeFeed = "EditIncidentEpisodeFeed",
ReadIncidentEpisodeFeed = "ReadIncidentEpisodeFeed",
// Incident Episode Public Note Permissions
CreateIncidentEpisodePublicNote = "CreateIncidentEpisodePublicNote",
DeleteIncidentEpisodePublicNote = "DeleteIncidentEpisodePublicNote",
EditIncidentEpisodePublicNote = "EditIncidentEpisodePublicNote",
ReadIncidentEpisodePublicNote = "ReadIncidentEpisodePublicNote",
// Incident Grouping Rule Permissions
CreateIncidentGroupingRule = "CreateIncidentGroupingRule",
DeleteIncidentGroupingRule = "DeleteIncidentGroupingRule",
@@ -5769,6 +5775,40 @@ export class PermissionHelper {
isAccessControlPermission: false,
},
// Incident Episode Public Note Permissions
{
permission: Permission.CreateIncidentEpisodePublicNote,
title: "Create Incident Episode Public Note",
description:
"This permission can create Incident Episode public notes in this project.",
isAssignableToTenant: true,
isAccessControlPermission: false,
},
{
permission: Permission.DeleteIncidentEpisodePublicNote,
title: "Delete Incident Episode Public Note",
description:
"This permission can delete Incident Episode public notes of this project.",
isAssignableToTenant: true,
isAccessControlPermission: false,
},
{
permission: Permission.EditIncidentEpisodePublicNote,
title: "Edit Incident Episode Public Note",
description:
"This permission can edit Incident Episode public notes of this project.",
isAssignableToTenant: true,
isAccessControlPermission: false,
},
{
permission: Permission.ReadIncidentEpisodePublicNote,
title: "Read Incident Episode Public Note",
description:
"This permission can read Incident Episode public notes of this project.",
isAssignableToTenant: true,
isAccessControlPermission: false,
},
// Incident Grouping Rule Permissions
{
permission: Permission.CreateIncidentGroupingRule,

View File

@@ -10,6 +10,11 @@ enum StatusPageSubscriberNotificationEventType {
SubscriberIncidentNoteCreated = "Subscriber Incident Note Created",
SubscriberIncidentPostmortemPublished = "Subscriber Incident Postmortem Published",
// Incident Episode related events
SubscriberEpisodeCreated = "Subscriber Episode Created",
SubscriberEpisodeStateChanged = "Subscriber Episode State Changed",
SubscriberEpisodeNoteCreated = "Subscriber Episode Note Created",
// Announcement related events
SubscriberAnnouncementCreated = "Subscriber Announcement Created",

View File

@@ -0,0 +1,727 @@
import RunCron from "../../Utils/Cron";
import { StatusPageApiRoute } from "Common/ServiceRoute";
import Hostname from "Common/Types/API/Hostname";
import Protocol from "Common/Types/API/Protocol";
import URL from "Common/Types/API/URL";
import Dictionary from "Common/Types/Dictionary";
import EmailTemplateType from "Common/Types/Email/EmailTemplateType";
import ObjectID from "Common/Types/ObjectID";
import SMS from "Common/Types/SMS/SMS";
import { EVERY_MINUTE } from "Common/Utils/CronTime";
import DatabaseConfig from "Common/Server/DatabaseConfig";
import IncidentEpisodeService from "Common/Server/Services/IncidentEpisodeService";
import IncidentEpisodeMemberService from "Common/Server/Services/IncidentEpisodeMemberService";
import MailService from "Common/Server/Services/MailService";
import ProjectCallSMSConfigService from "Common/Server/Services/ProjectCallSMSConfigService";
import ProjectSMTPConfigService from "Common/Server/Services/ProjectSmtpConfigService";
import SmsService from "Common/Server/Services/SmsService";
import StatusPageResourceService from "Common/Server/Services/StatusPageResourceService";
import StatusPageService, {
Service as StatusPageServiceType,
} from "Common/Server/Services/StatusPageService";
import StatusPageSubscriberService from "Common/Server/Services/StatusPageSubscriberService";
import StatusPageSubscriberNotificationTemplateService, {
Service as StatusPageSubscriberNotificationTemplateServiceClass,
} from "Common/Server/Services/StatusPageSubscriberNotificationTemplateService";
import StatusPageSubscriberNotificationTemplate from "Common/Models/DatabaseModels/StatusPageSubscriberNotificationTemplate";
import StatusPageSubscriberNotificationEventType from "Common/Types/StatusPage/StatusPageSubscriberNotificationEventType";
import StatusPageSubscriberNotificationMethod from "Common/Types/StatusPage/StatusPageSubscriberNotificationMethod";
import QueryHelper from "Common/Server/Types/Database/QueryHelper";
import Markdown, { MarkdownContentType } from "Common/Server/Types/Markdown";
import logger from "Common/Server/Utils/Logger";
import IncidentEpisode from "Common/Models/DatabaseModels/IncidentEpisode";
import IncidentEpisodeMember from "Common/Models/DatabaseModels/IncidentEpisodeMember";
import Monitor from "Common/Models/DatabaseModels/Monitor";
import StatusPage from "Common/Models/DatabaseModels/StatusPage";
import StatusPageResource from "Common/Models/DatabaseModels/StatusPageResource";
import StatusPageSubscriber from "Common/Models/DatabaseModels/StatusPageSubscriber";
import StatusPageEventType from "Common/Types/StatusPage/StatusPageEventType";
import StatusPageSubscriberNotificationStatus from "Common/Types/StatusPage/StatusPageSubscriberNotificationStatus";
import IncidentEpisodeFeedService from "Common/Server/Services/IncidentEpisodeFeedService";
import { IncidentEpisodeFeedEventType } from "Common/Models/DatabaseModels/IncidentEpisodeFeed";
import { Blue500 } from "Common/Types/BrandColors";
import SlackUtil from "Common/Server/Utils/Workspace/Slack/Slack";
import MicrosoftTeamsUtil from "Common/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams";
import StatusPageResourceUtil from "Common/Server/Utils/StatusPageResource";
import { LIMIT_PER_PROJECT } from "Common/Types/Database/LimitMax";
import Incident from "Common/Models/DatabaseModels/Incident";
RunCron(
"IncidentEpisode:SendNotificationToSubscribers",
{ schedule: EVERY_MINUTE, runOnStartup: false },
async () => {
// First, mark episodes as Skipped if they should not be notified
const episodesToSkip: Array<IncidentEpisode> =
await IncidentEpisodeService.findAllBy({
query: {
subscriberNotificationStatusOnEpisodeCreated:
StatusPageSubscriberNotificationStatus.Pending,
shouldStatusPageSubscribersBeNotifiedOnEpisodeCreated: false,
},
props: {
isRoot: true,
},
skip: 0,
select: {
_id: true,
},
});
logger.debug(
`Found ${episodesToSkip.length} episodes to mark as Skipped (subscribers should not be notified).`,
);
for (const episode of episodesToSkip) {
logger.debug(
`Marking episode ${episode.id} as Skipped for subscriber notifications.`,
);
await IncidentEpisodeService.updateOneById({
id: episode.id!,
data: {
subscriberNotificationStatusOnEpisodeCreated:
StatusPageSubscriberNotificationStatus.Skipped,
subscriberNotificationStatusMessage:
"Notifications skipped as subscribers are not to be notified for this episode.",
},
props: {
isRoot: true,
ignoreHooks: true,
},
});
logger.debug(
`Episode ${episode.id} marked as Skipped for subscriber notifications.`,
);
}
// Get all episodes that need notification
const episodes: Array<IncidentEpisode> =
await IncidentEpisodeService.findAllBy({
query: {
subscriberNotificationStatusOnEpisodeCreated:
StatusPageSubscriberNotificationStatus.Pending,
shouldStatusPageSubscribersBeNotifiedOnEpisodeCreated: true,
},
props: {
isRoot: true,
},
skip: 0,
select: {
_id: true,
title: true,
description: true,
projectId: true,
isVisibleOnStatusPage: true,
incidentSeverity: {
name: true,
},
episodeNumber: true,
},
});
logger.debug(
`Found ${episodes.length} episodes to notify subscribers for.`,
);
const host: Hostname = await DatabaseConfig.getHost();
const httpProtocol: Protocol = await DatabaseConfig.getHttpProtocol();
logger.debug(
`Database host resolved as ${host.toString()} with protocol ${httpProtocol.toString()}.`,
);
for (const episode of episodes) {
try {
logger.debug(
`Processing episode ${episode.id} (project: ${episode.projectId}) for subscriber notifications.`,
);
const episodeId: ObjectID = episode.id!;
const projectId: ObjectID = episode.projectId!;
const episodeNumber: string =
episode.episodeNumber?.toString() || " - ";
const episodeFeedText: string = `📧 **Subscriber Episode Created Notification Sent for [Episode ${episodeNumber}](${(await IncidentEpisodeService.getEpisodeLinkInDashboard(projectId, episodeId)).toString()})**:
Notification sent to status page subscribers because this episode was created.`;
// Get monitors from member incidents
const episodeMembers: Array<IncidentEpisodeMember> =
await IncidentEpisodeMemberService.findBy({
query: {
incidentEpisodeId: episodeId,
},
select: {
incident: {
monitors: {
_id: true,
},
},
},
props: {
isRoot: true,
},
limit: LIMIT_PER_PROJECT,
skip: 0,
});
// Collect all unique monitors from member incidents
const monitorIds: Set<string> = new Set();
for (const member of episodeMembers) {
if (member.incident?.monitors) {
for (const monitor of member.incident.monitors) {
if (monitor._id) {
monitorIds.add(monitor._id.toString());
}
}
}
}
if (monitorIds.size === 0) {
logger.debug(
`Episode ${episode.id} has no monitors attached via member incidents; marking subscriber notifications as Skipped.`,
);
await IncidentEpisodeService.updateOneById({
id: episode.id!,
data: {
subscriberNotificationStatusOnEpisodeCreated:
StatusPageSubscriberNotificationStatus.Skipped,
subscriberNotificationStatusMessage:
"No monitors are attached to the incidents in this episode. Skipping notifications to subscribers.",
},
props: {
isRoot: true,
ignoreHooks: true,
},
});
continue;
}
await IncidentEpisodeService.updateOneById({
id: episode.id!,
data: {
subscriberNotificationStatusOnEpisodeCreated:
StatusPageSubscriberNotificationStatus.InProgress,
},
props: {
isRoot: true,
ignoreHooks: true,
},
});
logger.debug(
`Episode ${episode.id} status set to InProgress for subscriber notifications.`,
);
if (!episode.isVisibleOnStatusPage) {
logger.debug(
`Episode ${episode.id} is not visible on status page; skipping subscriber notifications.`,
);
await IncidentEpisodeService.updateOneById({
id: episode.id!,
data: {
subscriberNotificationStatusOnEpisodeCreated:
StatusPageSubscriberNotificationStatus.Skipped,
subscriberNotificationStatusMessage:
"Episode is not visible on status page. Skipping notifications.",
},
props: {
isRoot: true,
ignoreHooks: true,
},
});
continue;
}
// Get status page resources from monitors
const statusPageResources: Array<StatusPageResource> =
await StatusPageResourceService.findAllBy({
query: {
monitorId: QueryHelper.any(
Array.from(monitorIds).map((id: string) => {
return new ObjectID(id);
}),
),
},
props: {
isRoot: true,
ignoreHooks: true,
},
skip: 0,
select: {
_id: true,
displayName: true,
statusPageId: true,
statusPageGroupId: true,
statusPageGroup: {
name: true,
},
},
});
logger.debug(
`Found ${statusPageResources.length} status page resources linked to episode ${episode.id}.`,
);
const statusPageToResources: Dictionary<Array<StatusPageResource>> = {};
for (const resource of statusPageResources) {
if (!resource.statusPageId) {
continue;
}
if (!statusPageToResources[resource.statusPageId?.toString()]) {
statusPageToResources[resource.statusPageId?.toString()] = [];
}
statusPageToResources[resource.statusPageId?.toString()]?.push(
resource,
);
}
logger.debug(
`Episode ${episode.id} maps to ${Object.keys(statusPageToResources).length} status page(s) for notifications.`,
);
const statusPages: Array<StatusPage> =
await StatusPageSubscriberService.getStatusPagesToSendNotification(
Object.keys(statusPageToResources).map((i: string) => {
return new ObjectID(i);
}),
);
logger.debug(
`Loaded ${statusPages.length} status page(s) for episode ${episode.id}.`,
);
for (const statuspage of statusPages) {
try {
if (!statuspage.id) {
logger.debug(
"Encountered a status page without an id; skipping.",
);
continue;
}
if (!statuspage.showEpisodesOnStatusPage) {
logger.debug(
`Status page ${statuspage.id} is configured to hide episodes; skipping notifications.`,
);
continue;
}
const subscribers: Array<StatusPageSubscriber> =
await StatusPageSubscriberService.getSubscribersByStatusPage(
statuspage.id!,
{
isRoot: true,
ignoreHooks: true,
},
);
const statusPageURL: string =
await StatusPageService.getStatusPageURL(statuspage.id);
const statusPageName: string =
statuspage.pageTitle || statuspage.name || "Status Page";
const statusPageIdString: string | null =
statuspage.id?.toString() || statuspage._id?.toString() || null;
const episodeDetailsUrl: string =
episode.id && statusPageURL
? URL.fromString(statusPageURL)
.addRoute(`/episodes/${episode.id.toString()}`)
.toString()
: statusPageURL;
logger.debug(
`Status page ${statuspage.id} (${statusPageName}) has ${subscribers.length} subscriber(s).`,
);
// Send email to Email subscribers.
const resourcesAffectedString: string =
StatusPageResourceUtil.getResourcesGroupedByGroupName(
statusPageToResources[statuspage._id!] || [],
);
logger.debug(
`Resources affected for episode ${episode.id} on status page ${statuspage.id}: ${resourcesAffectedString}`,
);
// Fetch custom templates for this status page (if any)
const [emailTemplate, smsTemplate, slackTemplate, teamsTemplate]: [
StatusPageSubscriberNotificationTemplate | null,
StatusPageSubscriberNotificationTemplate | null,
StatusPageSubscriberNotificationTemplate | null,
StatusPageSubscriberNotificationTemplate | null,
] = await Promise.all([
StatusPageSubscriberNotificationTemplateService.getTemplateForStatusPage(
{
statusPageId: statuspage.id!,
eventType:
StatusPageSubscriberNotificationEventType.SubscriberEpisodeCreated,
notificationMethod:
StatusPageSubscriberNotificationMethod.Email,
},
),
StatusPageSubscriberNotificationTemplateService.getTemplateForStatusPage(
{
statusPageId: statuspage.id!,
eventType:
StatusPageSubscriberNotificationEventType.SubscriberEpisodeCreated,
notificationMethod: StatusPageSubscriberNotificationMethod.SMS,
},
),
StatusPageSubscriberNotificationTemplateService.getTemplateForStatusPage(
{
statusPageId: statuspage.id!,
eventType:
StatusPageSubscriberNotificationEventType.SubscriberEpisodeCreated,
notificationMethod:
StatusPageSubscriberNotificationMethod.Slack,
},
),
StatusPageSubscriberNotificationTemplateService.getTemplateForStatusPage(
{
statusPageId: statuspage.id!,
eventType:
StatusPageSubscriberNotificationEventType.SubscriberEpisodeCreated,
notificationMethod:
StatusPageSubscriberNotificationMethod.MicrosoftTeams,
},
),
]);
// Prepare template variables for custom templates
const templateVariables: Record<string, string> = {
statusPageName: statusPageName,
statusPageUrl: statusPageURL,
detailsUrl: episodeDetailsUrl,
resourcesAffected: resourcesAffectedString,
episodeSeverity: episode.incidentSeverity?.name || " - ",
episodeTitle: episode.title || "",
episodeDescription: episode.description || "",
};
// Prepare SMS-specific template variables with plain text (no HTML/Markdown)
const smsTemplateVariables: Record<string, string> = {
...templateVariables,
episodeDescription: Markdown.convertToPlainText(
episode.description || "",
),
};
for (const subscriber of subscribers) {
try {
if (!subscriber._id) {
logger.debug(
"Encountered a subscriber without an _id; skipping.",
);
continue;
}
const shouldNotifySubscriber: boolean =
StatusPageSubscriberService.shouldSendNotification({
subscriber: subscriber,
statusPageResources:
statusPageToResources[statuspage._id!] || [],
statusPage: statuspage,
eventType: StatusPageEventType.Incident, // Episodes use incident event type for subscriber filtering
});
if (!shouldNotifySubscriber) {
logger.debug(
`Skipping subscriber ${subscriber._id} based on preferences or filters.`,
);
continue;
}
const unsubscribeUrl: string =
StatusPageSubscriberService.getUnsubscribeLink(
URL.fromString(statusPageURL),
subscriber.id!,
).toString();
logger.debug(
`Prepared unsubscribe link for subscriber ${subscriber._id}.`,
);
// Add unsubscribeUrl to template variables
const subscriberTemplateVariables: Record<string, string> = {
...templateVariables,
unsubscribeUrl: unsubscribeUrl,
};
if (subscriber.subscriberEmail) {
// send email here.
logger.debug(
`Queueing email notification to subscriber ${subscriber._id} at ${subscriber.subscriberEmail}.`,
);
if (emailTemplate?.templateBody && statuspage.smtpConfig) {
// Use custom template with BlankTemplate only when custom SMTP is configured
const compiledBody: string =
StatusPageSubscriberNotificationTemplateServiceClass.compileTemplate(
emailTemplate.templateBody,
subscriberTemplateVariables,
);
const compiledSubject: string = emailTemplate.emailSubject
? StatusPageSubscriberNotificationTemplateServiceClass.compileTemplate(
emailTemplate.emailSubject,
subscriberTemplateVariables,
)
: "[Incident Episode] " + episode.title || "";
MailService.sendMail(
{
toEmail: subscriber.subscriberEmail,
templateType: EmailTemplateType.BlankTemplate,
vars: {
body: compiledBody,
},
subject: compiledSubject,
},
{
mailServer: ProjectSMTPConfigService.toEmailServer(
statuspage.smtpConfig,
),
projectId: statuspage.projectId,
statusPageId: statuspage.id!,
},
).catch((err: Error) => {
logger.error(err);
});
} else {
// Use default hard-coded template
MailService.sendMail(
{
toEmail: subscriber.subscriberEmail,
templateType:
EmailTemplateType.SubscriberEpisodeCreated,
vars: {
statusPageName: statusPageName,
statusPageUrl: statusPageURL,
detailsUrl: episodeDetailsUrl,
logoUrl:
statuspage.logoFileId && statusPageIdString
? new URL(httpProtocol, host)
.addRoute(StatusPageApiRoute)
.addRoute(`/logo/${statusPageIdString}`)
.toString()
: "",
isPublicStatusPage: statuspage.isPublicStatusPage
? "true"
: "false",
resourcesAffected: resourcesAffectedString,
episodeSeverity:
episode.incidentSeverity?.name || " - ",
episodeTitle: episode.title || "",
episodeDescription: await Markdown.convertToHTML(
episode.description || "",
MarkdownContentType.Email,
),
unsubscribeUrl: unsubscribeUrl,
subscriberEmailNotificationFooterText:
StatusPageServiceType.getSubscriberEmailFooterText(
statuspage,
),
},
subject: "[Incident Episode] " + episode.title || "",
},
{
mailServer: ProjectSMTPConfigService.toEmailServer(
statuspage.smtpConfig,
),
projectId: statuspage.projectId,
statusPageId: statuspage.id!,
},
).catch((err: Error) => {
logger.error(err);
});
}
logger.debug(
`Email notification queued for subscriber ${subscriber._id}.`,
);
}
if (subscriber.subscriberPhone) {
const phoneStr: string =
subscriber.subscriberPhone.toString();
const phoneMasked: string = `${phoneStr.slice(0, 2)}******${phoneStr.slice(-2)}`;
logger.debug(
`Queueing SMS notification to subscriber ${subscriber._id} at ${phoneMasked}.`,
);
// SMS-specific template variables with unsubscribe URL
const subscriberSmsTemplateVariables: Record<string, string> =
{
...smsTemplateVariables,
unsubscribeUrl: unsubscribeUrl,
};
let smsMessage: string;
if (smsTemplate?.templateBody && statuspage.callSmsConfig) {
// Use custom template only when custom Twilio is configured
smsMessage =
StatusPageSubscriberNotificationTemplateServiceClass.compileTemplate(
smsTemplate.templateBody,
subscriberSmsTemplateVariables,
);
} else {
// Use default hard-coded template
smsMessage = `Incident Episode ${episode.title || ""} (${episode.incidentSeverity?.name || "-"}) on ${statusPageName}. Impact: ${resourcesAffectedString}. Details: ${episodeDetailsUrl}. Unsub: ${unsubscribeUrl}`;
}
const sms: SMS = {
message: smsMessage,
to: subscriber.subscriberPhone,
};
// send sms here.
SmsService.sendSms(sms, {
projectId: statuspage.projectId,
customTwilioConfig:
ProjectCallSMSConfigService.toTwilioConfig(
statuspage.callSmsConfig,
),
statusPageId: statuspage.id!,
}).catch((err: Error) => {
logger.error(err);
});
logger.debug(
`SMS notification queued for subscriber ${subscriber._id}.`,
);
}
if (subscriber.slackIncomingWebhookUrl) {
logger.debug(
`Queueing Slack notification to subscriber ${subscriber._id} via incoming webhook.`,
);
let markdownMessage: string;
if (slackTemplate?.templateBody) {
// Use custom template
markdownMessage =
StatusPageSubscriberNotificationTemplateServiceClass.compileTemplate(
slackTemplate.templateBody,
subscriberTemplateVariables,
);
} else {
// Use default hard-coded template
markdownMessage = `## 🚨 Incident Episode - ${episode.title || ""}
**Severity:** ${episode.incidentSeverity?.name || " - "}
**Resources Affected:** ${resourcesAffectedString}
**Description:** ${episode.description || ""}
[View Status Page](${statusPageURL}) | [Unsubscribe](${unsubscribeUrl})`;
}
// send Slack notification with markdown conversion
SlackUtil.sendMessageToChannelViaIncomingWebhook({
url: subscriber.slackIncomingWebhookUrl,
text: SlackUtil.convertMarkdownToSlackRichText(
markdownMessage,
),
}).catch((err: Error) => {
logger.error(err);
});
logger.debug(
`Slack notification queued for subscriber ${subscriber._id}.`,
);
}
if (subscriber.microsoftTeamsIncomingWebhookUrl) {
logger.debug(
`Queueing Microsoft Teams notification to subscriber ${subscriber._id} via incoming webhook.`,
);
let markdownMessage: string;
if (teamsTemplate?.templateBody) {
// Use custom template
markdownMessage =
StatusPageSubscriberNotificationTemplateServiceClass.compileTemplate(
teamsTemplate.templateBody,
subscriberTemplateVariables,
);
} else {
// Use default hard-coded template
markdownMessage = `## 🚨 Incident Episode - ${episode.title || ""}
**Severity:** ${episode.incidentSeverity?.name || " - "}
**Resources Affected:** ${resourcesAffectedString}
**Description:** ${episode.description || ""}
[View Status Page](${statusPageURL}) | [Unsubscribe](${unsubscribeUrl})`;
}
// send Teams notification
MicrosoftTeamsUtil.sendMessageToChannelViaIncomingWebhook({
url: subscriber.microsoftTeamsIncomingWebhookUrl,
text: markdownMessage,
}).catch((err: Error) => {
logger.error(err);
});
logger.debug(
`Microsoft Teams notification queued for subscriber ${subscriber._id}.`,
);
}
} catch (err) {
logger.error(err);
}
}
} catch (err) {
logger.error(err);
}
}
logger.debug("Creating episode feed for subscriber notification");
await IncidentEpisodeFeedService.createIncidentEpisodeFeedItem({
incidentEpisodeId: episode.id!,
projectId: episode.projectId!,
incidentEpisodeFeedEventType:
IncidentEpisodeFeedEventType.SubscriberNotificationSent,
displayColor: Blue500,
feedInfoInMarkdown: episodeFeedText,
});
logger.debug("Episode Feed created");
// If we get here, the notification was successful
await IncidentEpisodeService.updateOneById({
id: episode.id!,
data: {
subscriberNotificationStatusOnEpisodeCreated:
StatusPageSubscriberNotificationStatus.Success,
subscriberNotificationStatusMessage:
"Notifications sent successfully to all subscribers",
},
props: {
isRoot: true,
ignoreHooks: true,
},
});
logger.debug(
`Episode ${episode.id} marked as Success for subscriber notifications.`,
);
} catch (err) {
// If there was an error, mark as failed
logger.error(err);
IncidentEpisodeService.updateOneById({
id: episode.id!,
data: {
subscriberNotificationStatusOnEpisodeCreated:
StatusPageSubscriberNotificationStatus.Failed,
subscriberNotificationStatusMessage:
err instanceof Error ? err.message : String(err),
},
props: {
isRoot: true,
ignoreHooks: true,
},
}).catch((error: Error) => {
logger.error(
`Failed to update episode ${episode.id} status after error: ${error.message}`,
);
});
}
}
},
);

View File

@@ -0,0 +1,703 @@
import RunCron from "../../Utils/Cron";
import { StatusPageApiRoute } from "Common/ServiceRoute";
import Hostname from "Common/Types/API/Hostname";
import Protocol from "Common/Types/API/Protocol";
import URL from "Common/Types/API/URL";
import LIMIT_MAX, { LIMIT_PER_PROJECT } from "Common/Types/Database/LimitMax";
import Dictionary from "Common/Types/Dictionary";
import EmailTemplateType from "Common/Types/Email/EmailTemplateType";
import ObjectID from "Common/Types/ObjectID";
import SMS from "Common/Types/SMS/SMS";
import { EVERY_MINUTE } from "Common/Utils/CronTime";
import DatabaseConfig from "Common/Server/DatabaseConfig";
import IncidentEpisodePublicNoteService from "Common/Server/Services/IncidentEpisodePublicNoteService";
import IncidentEpisodeService from "Common/Server/Services/IncidentEpisodeService";
import IncidentEpisodeMemberService from "Common/Server/Services/IncidentEpisodeMemberService";
import MailService from "Common/Server/Services/MailService";
import ProjectCallSMSConfigService from "Common/Server/Services/ProjectCallSMSConfigService";
import ProjectSmtpConfigService from "Common/Server/Services/ProjectSmtpConfigService";
import SmsService from "Common/Server/Services/SmsService";
import StatusPageResourceService from "Common/Server/Services/StatusPageResourceService";
import StatusPageService, {
Service as StatusPageServiceType,
} from "Common/Server/Services/StatusPageService";
import StatusPageSubscriberService from "Common/Server/Services/StatusPageSubscriberService";
import QueryHelper from "Common/Server/Types/Database/QueryHelper";
import Markdown, { MarkdownContentType } from "Common/Server/Types/Markdown";
import logger from "Common/Server/Utils/Logger";
import IncidentEpisode from "Common/Models/DatabaseModels/IncidentEpisode";
import IncidentEpisodeMember from "Common/Models/DatabaseModels/IncidentEpisodeMember";
import IncidentEpisodePublicNote from "Common/Models/DatabaseModels/IncidentEpisodePublicNote";
import StatusPage from "Common/Models/DatabaseModels/StatusPage";
import StatusPageResource from "Common/Models/DatabaseModels/StatusPageResource";
import StatusPageSubscriber from "Common/Models/DatabaseModels/StatusPageSubscriber";
import StatusPageEventType from "Common/Types/StatusPage/StatusPageEventType";
import StatusPageSubscriberNotificationStatus from "Common/Types/StatusPage/StatusPageSubscriberNotificationStatus";
import StatusPageSubscriberNotificationTemplateService, {
Service as StatusPageSubscriberNotificationTemplateServiceClass,
} from "Common/Server/Services/StatusPageSubscriberNotificationTemplateService";
import StatusPageSubscriberNotificationTemplate from "Common/Models/DatabaseModels/StatusPageSubscriberNotificationTemplate";
import StatusPageSubscriberNotificationEventType from "Common/Types/StatusPage/StatusPageSubscriberNotificationEventType";
import StatusPageSubscriberNotificationMethod from "Common/Types/StatusPage/StatusPageSubscriberNotificationMethod";
import IncidentEpisodeFeedService from "Common/Server/Services/IncidentEpisodeFeedService";
import { IncidentEpisodeFeedEventType } from "Common/Models/DatabaseModels/IncidentEpisodeFeed";
import { Blue500 } from "Common/Types/BrandColors";
import SlackUtil from "Common/Server/Utils/Workspace/Slack/Slack";
import MicrosoftTeamsUtil from "Common/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams";
import StatusPageResourceUtil from "Common/Server/Utils/StatusPageResource";
RunCron(
"IncidentEpisodePublicNote:SendNotificationToSubscribers",
{ schedule: EVERY_MINUTE, runOnStartup: false },
async () => {
// get all episode public notes that need notification
const host: Hostname = await DatabaseConfig.getHost();
const httpProtocol: Protocol = await DatabaseConfig.getHttpProtocol();
const episodePublicNotes: Array<IncidentEpisodePublicNote> =
await IncidentEpisodePublicNoteService.findBy({
query: {
subscriberNotificationStatusOnNoteCreated:
StatusPageSubscriberNotificationStatus.Pending,
shouldStatusPageSubscribersBeNotifiedOnNoteCreated: true,
},
props: {
isRoot: true,
},
limit: LIMIT_MAX,
skip: 0,
select: {
_id: true,
note: true,
incidentEpisodeId: true,
projectId: true,
},
});
logger.debug(
`Found ${episodePublicNotes.length} episode public note(s) to notify subscribers for.`,
);
for (const episodePublicNote of episodePublicNotes) {
try {
logger.debug(
`Processing episode public note ${episodePublicNote.id}.`,
);
if (!episodePublicNote.incidentEpisodeId) {
logger.debug(
`Episode public note ${episodePublicNote.id} has no incidentEpisodeId; skipping.`,
);
continue; // skip if incidentEpisodeId is not set
}
// get the episode
const episode: IncidentEpisode | null =
await IncidentEpisodeService.findOneById({
id: episodePublicNote.incidentEpisodeId!,
props: {
isRoot: true,
},
select: {
_id: true,
title: true,
description: true,
projectId: true,
incidentSeverity: {
name: true,
},
isVisibleOnStatusPage: true,
episodeNumber: true,
},
});
if (!episode) {
logger.debug(
`Episode ${episodePublicNote.incidentEpisodeId} not found; marking public note ${episodePublicNote.id} as Skipped.`,
);
await IncidentEpisodePublicNoteService.updateOneById({
id: episodePublicNote.id!,
data: {
subscriberNotificationStatusOnNoteCreated:
StatusPageSubscriberNotificationStatus.Skipped,
subscriberNotificationStatusMessage:
"Related episode not found. Skipping notifications to subscribers.",
},
props: {
isRoot: true,
ignoreHooks: true,
},
});
continue;
}
// Get monitors from member incidents
const episodeMembers: Array<IncidentEpisodeMember> =
await IncidentEpisodeMemberService.findBy({
query: {
incidentEpisodeId: episode.id!,
},
select: {
incident: {
monitors: {
_id: true,
},
},
},
props: {
isRoot: true,
},
limit: LIMIT_PER_PROJECT,
skip: 0,
});
// Collect all unique monitors from member incidents
const monitorIds: Set<string> = new Set();
for (const member of episodeMembers) {
if (member.incident?.monitors) {
for (const monitor of member.incident.monitors) {
if (monitor._id) {
monitorIds.add(monitor._id.toString());
}
}
}
}
if (monitorIds.size === 0) {
logger.debug(
`Episode ${episode.id} has no monitors; marking public note ${episodePublicNote.id} as Skipped.`,
);
await IncidentEpisodePublicNoteService.updateOneById({
id: episodePublicNote.id!,
data: {
subscriberNotificationStatusOnNoteCreated:
StatusPageSubscriberNotificationStatus.Skipped,
subscriberNotificationStatusMessage:
"No monitors are attached to the incidents in this episode. Skipping notifications.",
},
props: {
isRoot: true,
ignoreHooks: true,
},
});
continue;
}
// Set status to InProgress
await IncidentEpisodePublicNoteService.updateOneById({
id: episodePublicNote.id!,
data: {
subscriberNotificationStatusOnNoteCreated:
StatusPageSubscriberNotificationStatus.InProgress,
},
props: {
isRoot: true,
ignoreHooks: true,
},
});
logger.debug(
`Episode public note ${episodePublicNote.id} status set to InProgress for subscriber notifications.`,
);
if (!episode.isVisibleOnStatusPage) {
// Set status to Skipped for non-visible episodes
logger.debug(
`Episode ${episode.id} is not visible on status page; marking public note ${episodePublicNote.id} as Skipped.`,
);
await IncidentEpisodePublicNoteService.updateOneById({
id: episodePublicNote.id!,
data: {
subscriberNotificationStatusOnNoteCreated:
StatusPageSubscriberNotificationStatus.Skipped,
subscriberNotificationStatusMessage:
"Notifications skipped as episode is not visible on status page.",
},
props: {
isRoot: true,
ignoreHooks: true,
},
});
continue;
}
// get status page resources from monitors.
const statusPageResources: Array<StatusPageResource> =
await StatusPageResourceService.findBy({
query: {
monitorId: QueryHelper.any(
Array.from(monitorIds).map((id: string) => {
return new ObjectID(id);
}),
),
},
props: {
isRoot: true,
ignoreHooks: true,
},
skip: 0,
limit: LIMIT_PER_PROJECT,
select: {
_id: true,
displayName: true,
statusPageId: true,
statusPageGroupId: true,
statusPageGroup: {
name: true,
},
},
});
logger.debug(
`Found ${statusPageResources.length} status page resource(s) for episode ${episode.id}.`,
);
const statusPageToResources: Dictionary<Array<StatusPageResource>> = {};
for (const resource of statusPageResources) {
if (!resource.statusPageId) {
continue;
}
if (!statusPageToResources[resource.statusPageId?.toString()]) {
statusPageToResources[resource.statusPageId?.toString()] = [];
}
statusPageToResources[resource.statusPageId?.toString()]?.push(
resource,
);
}
logger.debug(
`Episode ${episode.id} maps to ${Object.keys(statusPageToResources).length} status page(s) for public note notifications.`,
);
const statusPages: Array<StatusPage> =
await StatusPageSubscriberService.getStatusPagesToSendNotification(
Object.keys(statusPageToResources).map((i: string) => {
return new ObjectID(i);
}),
);
for (const statuspage of statusPages) {
logger.debug("Encountered a status page without an id; skipping.");
if (!statuspage.id) {
continue;
}
logger.debug(
`Status page ${statuspage.id} hides episodes; skipping.`,
);
if (!statuspage.showEpisodesOnStatusPage) {
continue; // Do not send notification to subscribers if episodes are not visible on status page.
}
const subscribers: Array<StatusPageSubscriber> =
await StatusPageSubscriberService.getSubscribersByStatusPage(
statuspage.id!,
{
isRoot: true,
ignoreHooks: true,
},
);
const statusPageURL: string =
await StatusPageService.getStatusPageURL(statuspage.id);
const statusPageName: string =
statuspage.pageTitle || statuspage.name || "Status Page";
const statusPageIdString: string | null =
statuspage.id?.toString() || statuspage._id?.toString() || null;
const episodeDetailsUrl: string =
episode.id && statusPageURL
? URL.fromString(statusPageURL)
.addRoute(`/episodes/${episode.id.toString()}`)
.toString()
: statusPageURL;
logger.debug(
`Status page ${statuspage.id} (${statusPageName}) has ${subscribers.length} subscriber(s) for public note ${episodePublicNote.id}.`,
);
// Fetch custom templates for this status page (if any)
const [emailTemplate, smsTemplate, slackTemplate, teamsTemplate]: [
StatusPageSubscriberNotificationTemplate | null,
StatusPageSubscriberNotificationTemplate | null,
StatusPageSubscriberNotificationTemplate | null,
StatusPageSubscriberNotificationTemplate | null,
] = await Promise.all([
StatusPageSubscriberNotificationTemplateService.getTemplateForStatusPage(
{
statusPageId: statuspage.id!,
eventType:
StatusPageSubscriberNotificationEventType.SubscriberEpisodeNoteCreated,
notificationMethod:
StatusPageSubscriberNotificationMethod.Email,
},
),
StatusPageSubscriberNotificationTemplateService.getTemplateForStatusPage(
{
statusPageId: statuspage.id!,
eventType:
StatusPageSubscriberNotificationEventType.SubscriberEpisodeNoteCreated,
notificationMethod: StatusPageSubscriberNotificationMethod.SMS,
},
),
StatusPageSubscriberNotificationTemplateService.getTemplateForStatusPage(
{
statusPageId: statuspage.id!,
eventType:
StatusPageSubscriberNotificationEventType.SubscriberEpisodeNoteCreated,
notificationMethod:
StatusPageSubscriberNotificationMethod.Slack,
},
),
StatusPageSubscriberNotificationTemplateService.getTemplateForStatusPage(
{
statusPageId: statuspage.id!,
eventType:
StatusPageSubscriberNotificationEventType.SubscriberEpisodeNoteCreated,
notificationMethod:
StatusPageSubscriberNotificationMethod.MicrosoftTeams,
},
),
]);
// Prepare template variables for custom templates
const resourcesAffectedString: string =
StatusPageResourceUtil.getResourcesGroupedByGroupName(
statusPageToResources[statuspage._id!] || [],
);
const templateVariables: Record<string, string> = {
statusPageName: statusPageName,
statusPageUrl: statusPageURL,
detailsUrl: episodeDetailsUrl,
resourcesAffected: resourcesAffectedString,
episodeSeverity: episode.incidentSeverity?.name || " - ",
episodeTitle: episode.title || "",
note: episodePublicNote.note || "",
};
// Prepare SMS-specific template variables with plain text (no HTML/Markdown)
const smsTemplateVariables: Record<string, string> = {
...templateVariables,
note: Markdown.convertToPlainText(episodePublicNote.note || ""),
};
// Send email to Email subscribers.
for (const subscriber of subscribers) {
if (!subscriber._id) {
logger.debug(
"Encountered a subscriber without an _id; skipping.",
);
continue;
}
const shouldNotifySubscriber: boolean =
StatusPageSubscriberService.shouldSendNotification({
subscriber: subscriber,
statusPageResources:
statusPageToResources[statuspage._id!] || [],
statusPage: statuspage,
eventType: StatusPageEventType.Incident, // Episodes use incident event type
});
if (!shouldNotifySubscriber) {
logger.debug(
`Skipping subscriber ${subscriber._id} based on preferences for public note ${episodePublicNote.id}.`,
);
continue;
}
const unsubscribeUrl: string =
StatusPageSubscriberService.getUnsubscribeLink(
URL.fromString(statusPageURL),
subscriber.id!,
).toString();
logger.debug(
`Prepared unsubscribe link for subscriber ${subscriber._id} for public note ${episodePublicNote.id}.`,
);
// Add unsubscribeUrl to template variables
const subscriberTemplateVariables: Record<string, string> = {
...templateVariables,
unsubscribeUrl: unsubscribeUrl,
};
if (subscriber.subscriberPhone) {
const phoneStr: string = subscriber.subscriberPhone.toString();
const phoneMasked: string = `${phoneStr.slice(0, 2)}******${phoneStr.slice(-2)}`;
logger.debug(
`Queueing SMS notification to subscriber ${subscriber._id} at ${phoneMasked} for public note ${episodePublicNote.id}.`,
);
// SMS-specific template variables with unsubscribe URL
const subscriberSmsTemplateVariables: Record<string, string> = {
...smsTemplateVariables,
unsubscribeUrl: unsubscribeUrl,
};
let smsMessage: string;
if (smsTemplate?.templateBody && statuspage.callSmsConfig) {
// Use custom template only when custom Twilio is configured
smsMessage =
StatusPageSubscriberNotificationTemplateServiceClass.compileTemplate(
smsTemplate.templateBody,
subscriberSmsTemplateVariables,
);
} else {
// Use default hard-coded template
smsMessage = `Episode update: ${episode.title || "-"} on ${statusPageName}. A new note is posted. Details: ${episodeDetailsUrl}. Unsub: ${unsubscribeUrl}`;
}
const sms: SMS = {
message: smsMessage,
to: subscriber.subscriberPhone,
};
// send sms here.
SmsService.sendSms(sms, {
projectId: statuspage.projectId,
customTwilioConfig: ProjectCallSMSConfigService.toTwilioConfig(
statuspage.callSmsConfig,
),
statusPageId: statuspage.id!,
}).catch((err: Error) => {
logger.error(err);
});
}
if (subscriber.subscriberEmail) {
// send email here.
logger.debug(
`Queueing email notification to subscriber ${subscriber._id} at ${subscriber.subscriberEmail} for public note ${episodePublicNote.id}.`,
);
if (emailTemplate?.templateBody && statuspage.smtpConfig) {
// Use custom template with BlankTemplate only when custom SMTP is configured
const compiledBody: string =
StatusPageSubscriberNotificationTemplateServiceClass.compileTemplate(
emailTemplate.templateBody,
subscriberTemplateVariables,
);
const compiledSubject: string = emailTemplate.emailSubject
? StatusPageSubscriberNotificationTemplateServiceClass.compileTemplate(
emailTemplate.emailSubject,
subscriberTemplateVariables,
)
: "[Episode Update] " + episode.title || "";
MailService.sendMail(
{
toEmail: subscriber.subscriberEmail,
templateType: EmailTemplateType.BlankTemplate,
vars: {
body: compiledBody,
},
subject: compiledSubject,
},
{
mailServer: ProjectSmtpConfigService.toEmailServer(
statuspage.smtpConfig,
),
projectId: statuspage.projectId,
statusPageId: statuspage.id!,
},
).catch((err: Error) => {
logger.error(err);
});
} else {
// Use default hard-coded template
MailService.sendMail(
{
toEmail: subscriber.subscriberEmail,
templateType:
EmailTemplateType.SubscriberEpisodeNoteCreated,
vars: {
note: await Markdown.convertToHTML(
episodePublicNote.note!,
MarkdownContentType.Email,
),
statusPageName: statusPageName,
statusPageUrl: statusPageURL,
detailsUrl: episodeDetailsUrl,
logoUrl:
statuspage.logoFileId && statusPageIdString
? new URL(httpProtocol, host)
.addRoute(StatusPageApiRoute)
.addRoute(`/logo/${statusPageIdString}`)
.toString()
: "",
isPublicStatusPage: statuspage.isPublicStatusPage
? "true"
: "false",
resourcesAffected: resourcesAffectedString,
episodeSeverity:
episode.incidentSeverity?.name || " - ",
episodeTitle: episode.title || "",
episodeDescription: episode.description || "",
unsubscribeUrl: unsubscribeUrl,
subscriberEmailNotificationFooterText:
StatusPageServiceType.getSubscriberEmailFooterText(
statuspage,
),
},
subject: "[Episode Update] " + episode.title,
},
{
mailServer: ProjectSmtpConfigService.toEmailServer(
statuspage.smtpConfig,
),
projectId: statuspage.projectId,
statusPageId: statuspage.id!,
},
).catch((err: Error) => {
logger.error(err);
});
}
logger.debug(
`Email notification queued for subscriber ${subscriber._id} for public note ${episodePublicNote.id}.`,
);
}
if (subscriber.slackIncomingWebhookUrl) {
// send slack message here.
logger.debug(
`Queueing Slack notification to subscriber ${subscriber._id} via incoming webhook for public note ${episodePublicNote.id}.`,
);
let markdownMessage: string;
if (slackTemplate?.templateBody) {
// Use custom template
markdownMessage =
StatusPageSubscriberNotificationTemplateServiceClass.compileTemplate(
slackTemplate.templateBody,
subscriberTemplateVariables,
);
} else {
// Use default hard-coded template
markdownMessage = `## Episode - ${episode.title || ""}
**New note has been added to an episode**
**Resources Affected:** ${resourcesAffectedString}
**Severity:** ${episode.incidentSeverity?.name || " - "}
**Note:**
${episodePublicNote.note || ""}
[View Status Page](${statusPageURL}) | [Unsubscribe](${unsubscribeUrl})`;
}
SlackUtil.sendMessageToChannelViaIncomingWebhook({
url: subscriber.slackIncomingWebhookUrl,
text: SlackUtil.convertMarkdownToSlackRichText(markdownMessage),
}).catch((err: Error) => {
logger.error(err);
});
logger.debug(
`Slack notification queued for subscriber ${subscriber._id} for public note ${episodePublicNote.id}.`,
);
}
if (subscriber.microsoftTeamsIncomingWebhookUrl) {
// send Teams message here.
logger.debug(
`Queueing Microsoft Teams notification to subscriber ${subscriber._id} via incoming webhook for public note ${episodePublicNote.id}.`,
);
let markdownMessage: string;
if (teamsTemplate?.templateBody) {
// Use custom template
markdownMessage =
StatusPageSubscriberNotificationTemplateServiceClass.compileTemplate(
teamsTemplate.templateBody,
subscriberTemplateVariables,
);
} else {
// Use default hard-coded template
markdownMessage = `## Episode - ${episode.title || ""}
**New note has been added to an episode**
**Resources Affected:** ${resourcesAffectedString}
**Severity:** ${episode.incidentSeverity?.name || " - "}
**Note:**
${episodePublicNote.note || ""}
[View Status Page](${statusPageURL}) | [Unsubscribe](${unsubscribeUrl})`;
}
MicrosoftTeamsUtil.sendMessageToChannelViaIncomingWebhook({
url: subscriber.microsoftTeamsIncomingWebhookUrl,
text: markdownMessage,
}).catch((err: Error) => {
logger.error(err);
});
logger.debug(
`Microsoft Teams notification queued for subscriber ${subscriber._id} for public note ${episodePublicNote.id}.`,
);
}
}
}
logger.debug(
`Notification sent to subscribers for public note added to episode: ${episode.id}`,
);
await IncidentEpisodeFeedService.createIncidentEpisodeFeedItem({
incidentEpisodeId: episode.id!,
projectId: episode.projectId!,
incidentEpisodeFeedEventType:
IncidentEpisodeFeedEventType.SubscriberNotificationSent,
displayColor: Blue500,
feedInfoInMarkdown: `📧 **Notification sent to subscribers** because a public note is added to this [Episode ${episode.episodeNumber}](${(await IncidentEpisodeService.getEpisodeLinkInDashboard(episode.projectId!, episode.id!)).toString()}).`,
moreInformationInMarkdown: `**Public Note:**
${episodePublicNote.note}`,
});
logger.debug("Episode Feed created");
// Set status to Success after successful notification
await IncidentEpisodePublicNoteService.updateOneById({
id: episodePublicNote.id!,
data: {
subscriberNotificationStatusOnNoteCreated:
StatusPageSubscriberNotificationStatus.Success,
subscriberNotificationStatusMessage:
"Notifications sent successfully to all subscribers",
},
props: {
isRoot: true,
ignoreHooks: true,
},
});
logger.debug(
`Episode public note ${episodePublicNote.id} marked as Success for subscriber notifications.`,
);
} catch (err) {
logger.error(
`Error sending notification for episode public note ${episodePublicNote.id}: ${err}`,
);
// Set status to Failed with error reason
await IncidentEpisodePublicNoteService.updateOneById({
id: episodePublicNote.id!,
data: {
subscriberNotificationStatusOnNoteCreated:
StatusPageSubscriberNotificationStatus.Failed,
subscriberNotificationStatusMessage: (err as Error).message,
},
props: {
isRoot: true,
ignoreHooks: true,
},
});
}
}
},
);

View File

@@ -0,0 +1,680 @@
import RunCron from "../../Utils/Cron";
import { StatusPageApiRoute } from "Common/ServiceRoute";
import Hostname from "Common/Types/API/Hostname";
import Protocol from "Common/Types/API/Protocol";
import URL from "Common/Types/API/URL";
import LIMIT_MAX, { LIMIT_PER_PROJECT } from "Common/Types/Database/LimitMax";
import Dictionary from "Common/Types/Dictionary";
import EmailTemplateType from "Common/Types/Email/EmailTemplateType";
import ObjectID from "Common/Types/ObjectID";
import SMS from "Common/Types/SMS/SMS";
import Text from "Common/Types/Text";
import { EVERY_MINUTE } from "Common/Utils/CronTime";
import DatabaseConfig from "Common/Server/DatabaseConfig";
import IncidentEpisodeService from "Common/Server/Services/IncidentEpisodeService";
import IncidentEpisodeMemberService from "Common/Server/Services/IncidentEpisodeMemberService";
import IncidentEpisodeStateTimelineService from "Common/Server/Services/IncidentEpisodeStateTimelineService";
import MailService from "Common/Server/Services/MailService";
import ProjectCallSMSConfigService from "Common/Server/Services/ProjectCallSMSConfigService";
import ProjectSMTPConfigService from "Common/Server/Services/ProjectSmtpConfigService";
import SmsService from "Common/Server/Services/SmsService";
import StatusPageResourceService from "Common/Server/Services/StatusPageResourceService";
import StatusPageService, {
Service as StatusPageServiceType,
} from "Common/Server/Services/StatusPageService";
import StatusPageSubscriberService from "Common/Server/Services/StatusPageSubscriberService";
import StatusPageSubscriberNotificationTemplateService, {
Service as StatusPageSubscriberNotificationTemplateServiceClass,
} from "Common/Server/Services/StatusPageSubscriberNotificationTemplateService";
import StatusPageSubscriberNotificationTemplate from "Common/Models/DatabaseModels/StatusPageSubscriberNotificationTemplate";
import StatusPageSubscriberNotificationEventType from "Common/Types/StatusPage/StatusPageSubscriberNotificationEventType";
import StatusPageSubscriberNotificationMethod from "Common/Types/StatusPage/StatusPageSubscriberNotificationMethod";
import QueryHelper from "Common/Server/Types/Database/QueryHelper";
import logger from "Common/Server/Utils/Logger";
import IncidentEpisode from "Common/Models/DatabaseModels/IncidentEpisode";
import IncidentEpisodeMember from "Common/Models/DatabaseModels/IncidentEpisodeMember";
import IncidentEpisodeStateTimeline from "Common/Models/DatabaseModels/IncidentEpisodeStateTimeline";
import StatusPage from "Common/Models/DatabaseModels/StatusPage";
import StatusPageResource from "Common/Models/DatabaseModels/StatusPageResource";
import StatusPageSubscriber from "Common/Models/DatabaseModels/StatusPageSubscriber";
import StatusPageEventType from "Common/Types/StatusPage/StatusPageEventType";
import StatusPageSubscriberNotificationStatus from "Common/Types/StatusPage/StatusPageSubscriberNotificationStatus";
import IncidentEpisodeFeedService from "Common/Server/Services/IncidentEpisodeFeedService";
import { IncidentEpisodeFeedEventType } from "Common/Models/DatabaseModels/IncidentEpisodeFeed";
import { Blue500 } from "Common/Types/BrandColors";
import SlackUtil from "Common/Server/Utils/Workspace/Slack/Slack";
import MicrosoftTeamsUtil from "Common/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams";
import StatusPageResourceUtil from "Common/Server/Utils/StatusPageResource";
RunCron(
"IncidentEpisodeStateTimeline:SendNotificationToSubscribers",
{ schedule: EVERY_MINUTE, runOnStartup: false },
async () => {
const episodeStateTimelines: Array<IncidentEpisodeStateTimeline> =
await IncidentEpisodeStateTimelineService.findBy({
query: {
subscriberNotificationStatus:
StatusPageSubscriberNotificationStatus.Pending,
shouldStatusPageSubscribersBeNotified: true,
},
props: {
isRoot: true,
},
limit: LIMIT_MAX,
skip: 0,
select: {
_id: true,
projectId: true,
incidentEpisodeId: true,
incidentStateId: true,
incidentState: {
name: true,
isCreatedState: true,
},
},
});
logger.debug(
`Found ${episodeStateTimelines.length} episode state timeline(s) to notify subscribers for.`,
);
const host: Hostname = await DatabaseConfig.getHost();
const httpProtocol: Protocol = await DatabaseConfig.getHttpProtocol();
for (const episodeStateTimeline of episodeStateTimelines) {
logger.debug(
`Processing episode state timeline ${episodeStateTimeline.id}.`,
);
// Set to InProgress at the start of processing
await IncidentEpisodeStateTimelineService.updateOneById({
id: episodeStateTimeline.id!,
data: {
subscriberNotificationStatus:
StatusPageSubscriberNotificationStatus.InProgress,
},
props: {
isRoot: true,
ignoreHooks: true,
},
});
if (
!episodeStateTimeline.incidentEpisodeId ||
!episodeStateTimeline.incidentStateId
) {
await IncidentEpisodeStateTimelineService.updateOneById({
id: episodeStateTimeline.id!,
data: {
subscriberNotificationStatus:
StatusPageSubscriberNotificationStatus.Skipped,
subscriberNotificationStatusMessage:
"Missing episode or incident state reference. Skipping notifications.",
},
props: { isRoot: true, ignoreHooks: true },
});
continue;
}
if (!episodeStateTimeline.incidentState?.name) {
await IncidentEpisodeStateTimelineService.updateOneById({
id: episodeStateTimeline.id!,
data: {
subscriberNotificationStatus:
StatusPageSubscriberNotificationStatus.Skipped,
subscriberNotificationStatusMessage:
"Incident state has no name. Skipping notifications.",
},
props: { isRoot: true, ignoreHooks: true },
});
continue;
}
if (episodeStateTimeline.incidentState.isCreatedState) {
await IncidentEpisodeStateTimelineService.updateOneById({
id: episodeStateTimeline.id!,
data: {
subscriberNotificationStatus:
StatusPageSubscriberNotificationStatus.Skipped,
subscriberNotificationStatusMessage:
"Notification already sent when the episode was created. So, episode state change notification is skipped.",
},
props: { isRoot: true, ignoreHooks: true },
});
continue;
}
// Get the episode
const episode: IncidentEpisode | null =
await IncidentEpisodeService.findOneById({
id: episodeStateTimeline.incidentEpisodeId!,
props: {
isRoot: true,
},
select: {
_id: true,
title: true,
projectId: true,
incidentSeverity: {
name: true,
},
isVisibleOnStatusPage: true,
episodeNumber: true,
},
});
if (!episode) {
logger.debug(
`Episode ${episodeStateTimeline.incidentEpisodeId} not found; marking as Skipped.`,
);
await IncidentEpisodeStateTimelineService.updateOneById({
id: episodeStateTimeline.id!,
data: {
subscriberNotificationStatus:
StatusPageSubscriberNotificationStatus.Skipped,
subscriberNotificationStatusMessage:
"Related episode not found. Skipping notifications.",
},
props: { isRoot: true, ignoreHooks: true },
});
continue;
}
// Get monitors from member incidents
const episodeMembers: Array<IncidentEpisodeMember> =
await IncidentEpisodeMemberService.findBy({
query: {
incidentEpisodeId: episode.id!,
},
select: {
incident: {
monitors: {
_id: true,
},
},
},
props: {
isRoot: true,
},
limit: LIMIT_PER_PROJECT,
skip: 0,
});
// Collect all unique monitors from member incidents
const monitorIds: Set<string> = new Set();
for (const member of episodeMembers) {
if (member.incident?.monitors) {
for (const monitor of member.incident.monitors) {
if (monitor._id) {
monitorIds.add(monitor._id.toString());
}
}
}
}
if (monitorIds.size === 0) {
logger.debug(
`Episode ${episode.id} has no monitors; marking timeline ${episodeStateTimeline.id} as Skipped.`,
);
await IncidentEpisodeStateTimelineService.updateOneById({
id: episodeStateTimeline.id!,
data: {
subscriberNotificationStatus:
StatusPageSubscriberNotificationStatus.Skipped,
subscriberNotificationStatusMessage:
"No monitors are attached to the incidents in this episode. Skipping notifications.",
},
props: { isRoot: true, ignoreHooks: true },
});
continue;
}
if (!episode.isVisibleOnStatusPage) {
logger.debug(
`Episode ${episode.id} not visible on status page; marking as Skipped.`,
);
await IncidentEpisodeStateTimelineService.updateOneById({
id: episodeStateTimeline.id!,
data: {
subscriberNotificationStatus:
StatusPageSubscriberNotificationStatus.Skipped,
subscriberNotificationStatusMessage:
"Episode is not visible on status page. Skipping notifications.",
},
props: { isRoot: true, ignoreHooks: true },
});
continue;
}
// Get status page resources from monitors
const statusPageResources: Array<StatusPageResource> =
await StatusPageResourceService.findBy({
query: {
monitorId: QueryHelper.any(
Array.from(monitorIds).map((id: string) => {
return new ObjectID(id);
}),
),
},
props: {
isRoot: true,
ignoreHooks: true,
},
skip: 0,
limit: LIMIT_PER_PROJECT,
select: {
_id: true,
displayName: true,
statusPageId: true,
statusPageGroupId: true,
statusPageGroup: {
name: true,
},
},
});
logger.debug(
`Found ${statusPageResources.length} status page resource(s) for episode ${episode.id}.`,
);
const statusPageToResources: Dictionary<Array<StatusPageResource>> = {};
for (const resource of statusPageResources) {
if (!resource.statusPageId) {
continue;
}
if (!statusPageToResources[resource.statusPageId?.toString()]) {
statusPageToResources[resource.statusPageId?.toString()] = [];
}
statusPageToResources[resource.statusPageId?.toString()]?.push(
resource,
);
}
logger.debug(
`Episode ${episode.id} maps to ${Object.keys(statusPageToResources).length} status page(s) for state timeline notification.`,
);
const statusPages: Array<StatusPage> =
await StatusPageSubscriberService.getStatusPagesToSendNotification(
Object.keys(statusPageToResources).map((i: string) => {
return new ObjectID(i);
}),
);
for (const statuspage of statusPages) {
if (!statuspage.id) {
logger.debug("Encountered a status page without an id; skipping.");
continue;
}
if (!statuspage.showEpisodesOnStatusPage) {
logger.debug(
`Status page ${statuspage.id} hides episodes; skipping.`,
);
continue;
}
const subscribers: Array<StatusPageSubscriber> =
await StatusPageSubscriberService.getSubscribersByStatusPage(
statuspage.id!,
{
isRoot: true,
ignoreHooks: true,
},
);
const statusPageURL: string = await StatusPageService.getStatusPageURL(
statuspage.id,
);
const statusPageName: string =
statuspage.pageTitle || statuspage.name || "Status Page";
const statusPageIdString: string | null =
statuspage.id?.toString() || statuspage._id?.toString() || null;
const episodeDetailsUrl: string =
episode.id && statusPageURL
? URL.fromString(statusPageURL)
.addRoute(`/episodes/${episode.id.toString()}`)
.toString()
: statusPageURL;
logger.debug(
`Status page ${statuspage.id} (${statusPageName}) has ${subscribers.length} subscriber(s) for episode state timeline ${episodeStateTimeline.id}.`,
);
// Fetch custom templates for this status page (if any)
const [emailTemplate, smsTemplate, slackTemplate, teamsTemplate]: [
StatusPageSubscriberNotificationTemplate | null,
StatusPageSubscriberNotificationTemplate | null,
StatusPageSubscriberNotificationTemplate | null,
StatusPageSubscriberNotificationTemplate | null,
] = await Promise.all([
StatusPageSubscriberNotificationTemplateService.getTemplateForStatusPage(
{
statusPageId: statuspage.id!,
eventType:
StatusPageSubscriberNotificationEventType.SubscriberEpisodeStateChanged,
notificationMethod: StatusPageSubscriberNotificationMethod.Email,
},
),
StatusPageSubscriberNotificationTemplateService.getTemplateForStatusPage(
{
statusPageId: statuspage.id!,
eventType:
StatusPageSubscriberNotificationEventType.SubscriberEpisodeStateChanged,
notificationMethod: StatusPageSubscriberNotificationMethod.SMS,
},
),
StatusPageSubscriberNotificationTemplateService.getTemplateForStatusPage(
{
statusPageId: statuspage.id!,
eventType:
StatusPageSubscriberNotificationEventType.SubscriberEpisodeStateChanged,
notificationMethod: StatusPageSubscriberNotificationMethod.Slack,
},
),
StatusPageSubscriberNotificationTemplateService.getTemplateForStatusPage(
{
statusPageId: statuspage.id!,
eventType:
StatusPageSubscriberNotificationEventType.SubscriberEpisodeStateChanged,
notificationMethod:
StatusPageSubscriberNotificationMethod.MicrosoftTeams,
},
),
]);
// Send email to Email subscribers.
for (const subscriber of subscribers) {
if (!subscriber._id) {
logger.debug("Encountered a subscriber without an _id; skipping.");
continue;
}
const shouldNotifySubscriber: boolean =
StatusPageSubscriberService.shouldSendNotification({
subscriber: subscriber,
statusPageResources: statusPageToResources[statuspage._id!] || [],
statusPage: statuspage,
eventType: StatusPageEventType.Incident, // Episodes use incident event type
});
if (!shouldNotifySubscriber) {
logger.debug(
`Skipping subscriber ${subscriber._id} based on preferences for state timeline ${episodeStateTimeline.id}.`,
);
continue;
}
const unsubscribeUrl: string =
StatusPageSubscriberService.getUnsubscribeLink(
URL.fromString(statusPageURL),
subscriber.id!,
).toString();
const resourcesAffected: string =
StatusPageResourceUtil.getResourcesGroupedByGroupName(
statusPageToResources[statuspage._id!] || [],
"", // Use empty string as default for backward compatibility
);
// Prepare template variables for custom templates
const templateVariables: Record<string, string> = {
statusPageName: statusPageName,
statusPageUrl: statusPageURL,
detailsUrl: episodeDetailsUrl,
resourcesAffected: resourcesAffected || "None",
episodeSeverity: episode.incidentSeverity?.name || " - ",
episodeTitle: episode.title || "",
episodeState: episodeStateTimeline.incidentState.name,
unsubscribeUrl: unsubscribeUrl,
};
if (subscriber.subscriberPhone) {
const phoneStr: string = subscriber.subscriberPhone.toString();
const phoneMasked: string = `${phoneStr.slice(0, 2)}******${phoneStr.slice(-2)}`;
logger.debug(
`Queueing SMS notification to subscriber ${subscriber._id} at ${phoneMasked} for episode state timeline ${episodeStateTimeline.id}.`,
);
let smsMessage: string;
if (smsTemplate?.templateBody && statuspage.callSmsConfig) {
// Use custom template only when custom Twilio is configured
smsMessage =
StatusPageSubscriberNotificationTemplateServiceClass.compileTemplate(
smsTemplate.templateBody,
templateVariables,
);
} else {
// Use default hard-coded template
smsMessage = `Episode ${episode.title || ""} on ${statusPageName} is ${Text.uppercaseFirstLetter(episodeStateTimeline.incidentState.name)}. Details: ${episodeDetailsUrl}. Unsub: ${unsubscribeUrl}`;
}
const sms: SMS = {
message: smsMessage,
to: subscriber.subscriberPhone,
};
// send sms here.
SmsService.sendSms(sms, {
projectId: statuspage.projectId,
customTwilioConfig: ProjectCallSMSConfigService.toTwilioConfig(
statuspage.callSmsConfig,
),
statusPageId: statuspage.id!,
}).catch((err: Error) => {
logger.error(err);
});
}
let emailTitle: string = `Episode `;
if (resourcesAffected) {
emailTitle += `on ${resourcesAffected} `;
}
emailTitle += `is ${episodeStateTimeline.incidentState.name}`;
if (subscriber.subscriberEmail) {
// send email here.
logger.debug(
`Queueing email notification to subscriber ${subscriber._id} at ${subscriber.subscriberEmail} for episode state timeline ${episodeStateTimeline.id}.`,
);
if (emailTemplate?.templateBody && statuspage.smtpConfig) {
// Use custom template with BlankTemplate only when custom SMTP is configured
const compiledBody: string =
StatusPageSubscriberNotificationTemplateServiceClass.compileTemplate(
emailTemplate.templateBody,
templateVariables,
);
const compiledSubject: string = emailTemplate.emailSubject
? StatusPageSubscriberNotificationTemplateServiceClass.compileTemplate(
emailTemplate.emailSubject,
templateVariables,
)
: `[Episode ${Text.uppercaseFirstLetter(episodeStateTimeline.incidentState.name)}] ${episode.title}`;
MailService.sendMail(
{
toEmail: subscriber.subscriberEmail,
templateType: EmailTemplateType.BlankTemplate,
vars: {
body: compiledBody,
},
subject: compiledSubject,
},
{
mailServer: ProjectSMTPConfigService.toEmailServer(
statuspage.smtpConfig,
),
projectId: statuspage.projectId,
statusPageId: statuspage.id!,
},
).catch((err: Error) => {
logger.error(err);
});
} else {
// Use default hard-coded template
MailService.sendMail(
{
toEmail: subscriber.subscriberEmail,
templateType:
EmailTemplateType.SubscriberEpisodeStateChanged,
vars: {
emailTitle: emailTitle,
statusPageName: statusPageName,
statusPageUrl: statusPageURL,
detailsUrl: episodeDetailsUrl,
logoUrl:
statuspage.logoFileId && statusPageIdString
? new URL(httpProtocol, host)
.addRoute(StatusPageApiRoute)
.addRoute(`/logo/${statusPageIdString}`)
.toString()
: "",
isPublicStatusPage: statuspage.isPublicStatusPage
? "true"
: "false",
resourcesAffected: resourcesAffected || "None",
episodeSeverity: episode.incidentSeverity?.name || " - ",
episodeTitle: episode.title || "",
episodeState: episodeStateTimeline.incidentState.name,
unsubscribeUrl: unsubscribeUrl,
subscriberEmailNotificationFooterText:
StatusPageServiceType.getSubscriberEmailFooterText(
statuspage,
),
},
subject: `[Episode ${Text.uppercaseFirstLetter(
episodeStateTimeline.incidentState.name,
)}] ${episode.title}`,
},
{
mailServer: ProjectSMTPConfigService.toEmailServer(
statuspage.smtpConfig,
),
projectId: statuspage.projectId,
statusPageId: statuspage.id!,
},
).catch((err: Error) => {
logger.error(err);
});
}
}
if (subscriber.slackIncomingWebhookUrl) {
let slackTitle: string;
if (slackTemplate?.templateBody) {
// Use custom template
slackTitle =
StatusPageSubscriberNotificationTemplateServiceClass.compileTemplate(
slackTemplate.templateBody,
templateVariables,
);
} else {
// Use default hard-coded template
slackTitle = `🚨 ## Episode - ${episode.title || " - "}
`;
if (resourcesAffected) {
slackTitle += `
**Resources Affected:** ${resourcesAffected}`;
}
slackTitle += `
**Severity:** ${episode.incidentSeverity?.name || " - "}
**Status:** ${episodeStateTimeline.incidentState.name}
[View Status Page](${statusPageURL}) | [Unsubscribe](${unsubscribeUrl})`;
}
SlackUtil.sendMessageToChannelViaIncomingWebhook({
url: subscriber.slackIncomingWebhookUrl,
text: SlackUtil.convertMarkdownToSlackRichText(slackTitle),
}).catch((err: Error) => {
logger.error(err);
});
logger.debug(
`Slack notification queued for subscriber ${subscriber._id} for episode state timeline ${episodeStateTimeline.id}.`,
);
}
if (subscriber.microsoftTeamsIncomingWebhookUrl) {
let teamsTitle: string;
if (teamsTemplate?.templateBody) {
// Use custom template
teamsTitle =
StatusPageSubscriberNotificationTemplateServiceClass.compileTemplate(
teamsTemplate.templateBody,
templateVariables,
);
} else {
// Use default hard-coded template
teamsTitle = `🚨 ## Episode - ${episode.title || " - "}
`;
if (resourcesAffected) {
teamsTitle += `
**Resources Affected:** ${resourcesAffected}`;
}
teamsTitle += `
**Severity:** ${episode.incidentSeverity?.name || " - "}
**Status:** ${episodeStateTimeline.incidentState.name}
[View Status Page](${statusPageURL}) | [Unsubscribe](${unsubscribeUrl})`;
}
MicrosoftTeamsUtil.sendMessageToChannelViaIncomingWebhook({
url: subscriber.microsoftTeamsIncomingWebhookUrl,
text: teamsTitle,
}).catch((err: Error) => {
logger.error(err);
});
logger.debug(
`Microsoft Teams notification queued for subscriber ${subscriber._id} for episode state timeline ${episodeStateTimeline.id}.`,
);
}
}
}
logger.debug(
"Notification sent to subscribers for episode state change",
);
const episodeNumber: string =
episode.episodeNumber?.toString() || " - ";
const projectId: ObjectID = episode.projectId!;
const episodeId: ObjectID = episode.id!;
await IncidentEpisodeFeedService.createIncidentEpisodeFeedItem({
incidentEpisodeId: episode.id!,
projectId: episode.projectId!,
incidentEpisodeFeedEventType:
IncidentEpisodeFeedEventType.SubscriberNotificationSent,
displayColor: Blue500,
feedInfoInMarkdown: `📧 **Status Page Subscribers have been notified** about the state change of the [Episode ${episodeNumber}](${(await IncidentEpisodeService.getEpisodeLinkInDashboard(projectId, episodeId)).toString()}) to **${episodeStateTimeline.incidentState.name}**`,
});
logger.debug("Episode Feed created");
// Mark Success at the end
await IncidentEpisodeStateTimelineService.updateOneById({
id: episodeStateTimeline.id!,
data: {
subscriberNotificationStatus:
StatusPageSubscriberNotificationStatus.Success,
subscriberNotificationStatusMessage:
"Notifications sent successfully to all subscribers",
},
props: { isRoot: true, ignoreHooks: true },
});
}
},
);

View File

@@ -43,6 +43,13 @@ import "./Jobs/AlertEpisodeOwners/SendStateChangeNotification";
// Incident Episodes
import "./Jobs/IncidentEpisode/AutoResolve";
import "./Jobs/IncidentEpisode/ResolveInactiveEpisodes";
import "./Jobs/IncidentEpisode/SendNotificationToSubscribers";
// Incident Episode State Timeline
import "./Jobs/IncidentEpisodeStateTimeline/SendNotificationToSubscribers";
// Incident Episode Public Notes
import "./Jobs/IncidentEpisodePublicNote/SendNotificationToSubscribers";
// Incident Episode Owners
import "./Jobs/IncidentEpisodeOwners/SendCreatedResourceNotification";