mirror of
https://github.com/OneUptime/oneuptime.git
synced 2026-04-06 00:32:12 +02:00
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:
@@ -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(),
|
||||
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
611
Common/Models/DatabaseModels/IncidentEpisodePublicNote.ts
Normal file
611
Common/Models/DatabaseModels/IncidentEpisodePublicNote.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
97
Common/Server/API/IncidentEpisodePublicNoteAPI.ts
Normal file
97
Common/Server/API/IncidentEpisodePublicNoteAPI.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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"`);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
];
|
||||
|
||||
254
Common/Server/Services/IncidentEpisodePublicNoteService.ts
Normal file
254
Common/Server/Services/IncidentEpisodePublicNoteService.ts
Normal 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();
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
||||
727
Worker/Jobs/IncidentEpisode/SendNotificationToSubscribers.ts
Normal file
727
Worker/Jobs/IncidentEpisode/SendNotificationToSubscribers.ts
Normal 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}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user