Merge pull request #2238 from OneUptime/alert-episode

Alert episode
This commit is contained in:
Simon Larsen
2026-01-26 19:24:18 +00:00
committed by GitHub
151 changed files with 23762 additions and 283 deletions

View File

@@ -15,7 +15,7 @@ on:
jobs:
terraform-e2e-tests:
runs-on: ubuntu-latest
timeout-minutes: 60
timeout-minutes: 120
env:
CI_PIPELINE_ID: ${{ github.run_number }}
APP_TAG: latest
@@ -34,16 +34,84 @@ jobs:
- name: Additional Disk Cleanup
run: |
echo "=== Initial disk space ==="
df -h
echo "=== Removing unnecessary tools and libraries ==="
# Remove Android SDK (if not already removed)
sudo rm -rf /usr/local/lib/android || true
sudo rm -rf /opt/ghc || true
# Remove .NET SDK and runtime
sudo rm -rf /usr/share/dotnet || true
sudo rm -rf /etc/skel/.dotnet || true
# Remove Haskell/GHC
sudo rm -rf /opt/ghc || true
sudo rm -rf /usr/local/.ghcup || true
# Remove CodeQL
sudo rm -rf /opt/hostedtoolcache/CodeQL || true
# Remove Boost
sudo rm -rf /usr/local/share/boost || true
# Remove Swift
sudo rm -rf /usr/share/swift || true
# Remove Julia
sudo rm -rf /usr/local/julia* || true
# Remove Rust (cargo/rustup)
sudo rm -rf /usr/share/rust || true
sudo rm -rf /home/runner/.rustup || true
sudo rm -rf /home/runner/.cargo || true
# Remove unnecessary hostedtoolcache items
sudo rm -rf /opt/hostedtoolcache/Python || true
sudo rm -rf /opt/hostedtoolcache/PyPy || true
sudo rm -rf /opt/hostedtoolcache/Ruby || true
sudo rm -rf /opt/hostedtoolcache/Java* || true
# Remove additional large directories
sudo rm -rf /usr/share/miniconda || true
sudo rm -rf /usr/local/graalvm || true
sudo rm -rf /usr/local/share/chromium || true
sudo rm -rf /usr/local/share/powershell || true
sudo rm -rf /usr/share/az_* || true
# Remove documentation
sudo rm -rf /usr/share/doc || true
sudo rm -rf /usr/share/man || true
# Remove unnecessary locales
sudo rm -rf /usr/share/locale || true
# Clean apt cache
sudo apt-get clean || true
sudo rm -rf /var/lib/apt/lists/* || true
sudo rm -rf /var/cache/apt/archives/* || true
# Clean tmp
sudo rm -rf /tmp/* || true
echo "=== Moving Docker data to /mnt for more space ==="
# Stop docker
sudo systemctl stop docker || true
# Move docker data directory to /mnt (which has ~70GB)
sudo mv /var/lib/docker /mnt/docker || true
sudo mkdir -p /var/lib/docker || true
sudo mount --bind /mnt/docker /var/lib/docker || true
# Restart docker
sudo systemctl start docker || true
echo "=== Final disk space ==="
df -h
echo "=== Docker info ==="
docker info | grep -E "Docker Root Dir|Storage Driver" || true
- name: Checkout code
uses: actions/checkout@v4

View File

@@ -106,6 +106,32 @@ import AlertStateTimelineService, {
Service as AlertStateTimelineServiceType,
} from "Common/Server/Services/AlertStateTimelineService";
// AlertEpisode Services
import AlertEpisodeService, {
Service as AlertEpisodeServiceType,
} from "Common/Server/Services/AlertEpisodeService";
import AlertEpisodeFeedService, {
Service as AlertEpisodeFeedServiceType,
} from "Common/Server/Services/AlertEpisodeFeedService";
import AlertEpisodeInternalNoteService, {
Service as AlertEpisodeInternalNoteServiceType,
} from "Common/Server/Services/AlertEpisodeInternalNoteService";
import AlertEpisodeMemberService, {
Service as AlertEpisodeMemberServiceType,
} from "Common/Server/Services/AlertEpisodeMemberService";
import AlertEpisodeOwnerTeamService, {
Service as AlertEpisodeOwnerTeamServiceType,
} from "Common/Server/Services/AlertEpisodeOwnerTeamService";
import AlertEpisodeOwnerUserService, {
Service as AlertEpisodeOwnerUserServiceType,
} from "Common/Server/Services/AlertEpisodeOwnerUserService";
import AlertEpisodeStateTimelineService, {
Service as AlertEpisodeStateTimelineServiceType,
} from "Common/Server/Services/AlertEpisodeStateTimelineService";
import AlertGroupingRuleService, {
Service as AlertGroupingRuleServiceType,
} from "Common/Server/Services/AlertGroupingRuleService";
import IncidentCustomFieldService, {
Service as IncidentCustomFieldServiceType,
} from "Common/Server/Services/IncidentCustomFieldService";
@@ -422,6 +448,16 @@ import AlertSeverity from "Common/Models/DatabaseModels/AlertSeverity";
import AlertState from "Common/Models/DatabaseModels/AlertState";
import AlertStateTimeline from "Common/Models/DatabaseModels/AlertStateTimeline";
// AlertEpisode Models
import AlertEpisode from "Common/Models/DatabaseModels/AlertEpisode";
import AlertEpisodeFeed from "Common/Models/DatabaseModels/AlertEpisodeFeed";
import AlertEpisodeInternalNote from "Common/Models/DatabaseModels/AlertEpisodeInternalNote";
import AlertEpisodeMember from "Common/Models/DatabaseModels/AlertEpisodeMember";
import AlertEpisodeOwnerTeam from "Common/Models/DatabaseModels/AlertEpisodeOwnerTeam";
import AlertEpisodeOwnerUser from "Common/Models/DatabaseModels/AlertEpisodeOwnerUser";
import AlertEpisodeStateTimeline from "Common/Models/DatabaseModels/AlertEpisodeStateTimeline";
import AlertGroupingRule from "Common/Models/DatabaseModels/AlertGroupingRule";
import IncidentCustomField from "Common/Models/DatabaseModels/IncidentCustomField";
import IncidentNoteTemplate from "Common/Models/DatabaseModels/IncidentNoteTemplate";
import IncidentPostmortemTemplate from "Common/Models/DatabaseModels/IncidentPostmortemTemplate";
@@ -905,6 +941,74 @@ const BaseAPIFeatureSet: FeatureSet = {
).getRouter(),
);
// AlertEpisode Routes
app.use(
`/${APP_NAME.toLocaleLowerCase()}`,
new BaseAPI<AlertEpisode, AlertEpisodeServiceType>(
AlertEpisode,
AlertEpisodeService,
).getRouter(),
);
app.use(
`/${APP_NAME.toLocaleLowerCase()}`,
new BaseAPI<AlertEpisodeFeed, AlertEpisodeFeedServiceType>(
AlertEpisodeFeed,
AlertEpisodeFeedService,
).getRouter(),
);
app.use(
`/${APP_NAME.toLocaleLowerCase()}`,
new BaseAPI<
AlertEpisodeInternalNote,
AlertEpisodeInternalNoteServiceType
>(AlertEpisodeInternalNote, AlertEpisodeInternalNoteService).getRouter(),
);
app.use(
`/${APP_NAME.toLocaleLowerCase()}`,
new BaseAPI<AlertEpisodeMember, AlertEpisodeMemberServiceType>(
AlertEpisodeMember,
AlertEpisodeMemberService,
).getRouter(),
);
app.use(
`/${APP_NAME.toLocaleLowerCase()}`,
new BaseAPI<AlertEpisodeOwnerTeam, AlertEpisodeOwnerTeamServiceType>(
AlertEpisodeOwnerTeam,
AlertEpisodeOwnerTeamService,
).getRouter(),
);
app.use(
`/${APP_NAME.toLocaleLowerCase()}`,
new BaseAPI<AlertEpisodeOwnerUser, AlertEpisodeOwnerUserServiceType>(
AlertEpisodeOwnerUser,
AlertEpisodeOwnerUserService,
).getRouter(),
);
app.use(
`/${APP_NAME.toLocaleLowerCase()}`,
new BaseAPI<
AlertEpisodeStateTimeline,
AlertEpisodeStateTimelineServiceType
>(
AlertEpisodeStateTimeline,
AlertEpisodeStateTimelineService,
).getRouter(),
);
app.use(
`/${APP_NAME.toLocaleLowerCase()}`,
new BaseAPI<AlertGroupingRule, AlertGroupingRuleServiceType>(
AlertGroupingRule,
AlertGroupingRuleService,
).getRouter(),
);
app.use(
`/${APP_NAME.toLocaleLowerCase()}`,
new BaseAnalyticsAPI<ExceptionInstance, ExceptionInstanceServiceType>(

View File

@@ -0,0 +1,30 @@
{{> Start this}}
{{> Logo this}}
{{> EmailTitle title=(concat "Alert Episode: " episodeTitle) }}
{{> InfoBlock info="You have been added as the owner of this alert episode."}}
{{> InfoBlock info="Here are the details: "}}
{{> DetailBoxStart this }}
{{> DetailBoxField title="Episode Title:" text=episodeTitle }}
{{> DetailBoxField title="Current State: " text=currentState }}
{{> DetailBoxField title="Severity: " text=episodeSeverity }}
{{> DetailBoxField title="Description: " text=episodeDescription }}
{{> DetailBoxEnd this }}
{{> InfoBlock info="You can view this alert episode by clicking on the button below - "}}
{{> ButtonBlock buttonUrl=episodeViewLink buttonText="View on Dashboard"}}
{{> InfoBlock info="You can also copy and paste this link:"}}
{{> InfoBlock info=episodeViewLink}}
{{> InfoBlock info="You will be notified when the status of this alert episode changes."}}
{{> Footer this }}
{{> End this}}

View File

@@ -0,0 +1,37 @@
{{> Start this}}
{{> Logo this}}
{{> EmailTitle title=(concat "Alert Episode: " episodeTitle) }}
{{> InfoBlock info="A new note has been posted on this alert episode."}}
{{> InfoBlock info="Here are the details: "}}
{{> DetailBoxStart this }}
{{> DetailBoxField title="Episode Title:" text=episodeTitle }}
{{> DetailBoxField title="Current State: " text=currentState }}
{{> DetailBoxField title="Severity: " text=episodeSeverity }}
{{#if isPrivateNote}}
{{> DetailBoxField title="Private Note: " text=note }}
{{else}}
{{> DetailBoxField title="Public Note: " text=note }}
{{/if}}
{{> DetailBoxEnd this }}
{{> InfoBlock info="You can view this alert episode by clicking on the button below - "}}
{{> ButtonBlock buttonUrl=episodeViewLink buttonText="View on Dashboard"}}
{{> InfoBlock info="You can also copy and paste this link:"}}
{{> InfoBlock info=episodeViewLink}}
{{> InfoBlock info="You will be notified when the status of this alert episode changes."}}
{{> OwnerInfo this }}
{{> UnsubscribeOwnerEmail this }}
{{> Footer this }}
{{> End this}}

View File

@@ -0,0 +1,35 @@
{{> Start this}}
{{> Logo this}}
{{> EmailTitle title=(concat "Alert Episode: " episodeTitle) }}
{{> InfoBlock info=(concat "A new alert episode has been created in the project - " projectName)}}
{{> InfoBlock info="Here are the details: "}}
{{> DetailBoxStart this }}
{{> DetailBoxField title="Episode Title:" text=episodeTitle }}
{{> DetailBoxField title="Current State: " text=currentState }}
{{> DetailBoxField title="Episode Created By: " text=declaredBy }}
{{> DetailBoxField title="Episode Created At: " text=declaredAt }}
{{> DetailBoxField title="Severity: " text=episodeSeverity }}
{{> DetailBoxField title="Description: " text=episodeDescription }}
{{> DetailBoxEnd this }}
{{> InfoBlock info="You can view this alert episode by clicking on the button below - "}}
{{> ButtonBlock buttonUrl=episodeViewLink buttonText="View on Dashboard"}}
{{> InfoBlock info="You can also copy and paste this link:"}}
{{> InfoBlock info=episodeViewLink}}
{{> InfoBlock info="You will be notified when the status of this alert episode changes."}}
{{> OwnerInfo this }}
{{> UnsubscribeOwnerEmail this }}
{{> Footer this }}
{{> End this}}

View File

@@ -0,0 +1,37 @@
{{> Start this}}
{{> Logo this}}
{{> EmailTitle title=(concat "Alert Episode: " episodeTitle) }}
{{> InfoBlock info="Alert episode state has changed"}}
{{> InfoBlock info="Here are the details: "}}
{{> DetailBoxStart this }}
{{> StateTransition this}}
{{#ifNotCond previousStateDurationText ""}}
{{> DetailBoxField title="Duration in Previous State:" text=previousStateDurationText }}
{{/ifNotCond}}
{{> DetailBoxField title="Episode Title:" text=episodeTitle }}
{{> DetailBoxField title="State changed at:" text=stateChangedAt }}
{{> DetailBoxField title="Severity:" text=episodeSeverity }}
{{> DetailBoxField title="Description:" text=episodeDescription }}
{{> DetailBoxEnd this }}
{{> InfoBlock info="You can view this alert episode by clicking on the button below - "}}
{{> ButtonBlock buttonUrl=episodeViewLink buttonText="View on Dashboard"}}
{{> InfoBlock info="You can also copy and paste this link:"}}
{{> InfoBlock info=episodeViewLink}}
{{> InfoBlock info="You will be notified when the status of this alert episode changes."}}
{{> OwnerInfo this }}
{{> UnsubscribeOwnerEmail this }}
{{> Footer this }}
{{> End this}}

View File

@@ -1,3 +1,4 @@
import AlertEpisode from "./AlertEpisode";
import AlertSeverity from "./AlertSeverity";
import AlertState from "./AlertState";
import Label from "./Label";
@@ -1071,4 +1072,79 @@ export default class Alert extends BaseModel {
})
public postUpdatesToWorkspaceChannels?: Array<NotificationRuleWorkspaceChannel> =
undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlert,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlert,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.EditAlert,
],
})
@TableColumn({
manyToOneRelationColumn: "alertEpisodeId",
type: TableColumnType.Entity,
modelType: AlertEpisode,
title: "Alert Episode",
description: "The episode this alert belongs to (if grouped)",
})
@ManyToOne(
() => {
return AlertEpisode;
},
{
eager: false,
nullable: true,
onDelete: "SET NULL",
orphanedRowAction: "nullify",
},
)
@JoinColumn({ name: "alertEpisodeId" })
public alertEpisode?: AlertEpisode = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlert,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlert,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.EditAlert,
],
})
@Index()
@TableColumn({
type: TableColumnType.ObjectID,
required: false,
title: "Alert Episode ID",
description: "The ID of the episode this alert belongs to (if grouped)",
})
@Column({
type: ColumnType.ObjectID,
nullable: true,
transformer: ObjectID.getDatabaseTransformer(),
})
public alertEpisodeId?: ObjectID = undefined;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,529 @@
import AlertEpisode from "./AlertEpisode";
import Project from "./Project";
import User from "./User";
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 ColumnLength from "../../Types/Database/ColumnLength";
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 ColorField from "../../Types/Database/ColorField";
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 Color from "../../Types/Color";
import IconProp from "../../Types/Icon/IconProp";
import ObjectID from "../../Types/ObjectID";
import Permission from "../../Types/Permission";
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
export enum AlertEpisodeFeedEventType {
EpisodeCreated = "EpisodeCreated",
EpisodeStateChanged = "EpisodeStateChanged",
EpisodeUpdated = "EpisodeUpdated",
AlertAdded = "AlertAdded",
AlertRemoved = "AlertRemoved",
OwnerUserAdded = "OwnerUserAdded",
OwnerTeamAdded = "OwnerTeamAdded",
OwnerUserRemoved = "OwnerUserRemoved",
OwnerTeamRemoved = "OwnerTeamRemoved",
OwnerNotificationSent = "OwnerNotificationSent",
PrivateNote = "PrivateNote",
RootCause = "RootCause",
OnCallPolicy = "OnCallPolicy",
OnCallNotification = "OnCallNotification",
SeverityChanged = "SeverityChanged",
}
@EnableDocumentation()
@CanAccessIfCanReadOn("alertEpisode")
@TenantColumn("projectId")
@TableAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeFeed,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeFeed,
],
delete: [],
update: [],
})
@EnableWorkflow({
create: true,
delete: true,
update: true,
read: true,
})
@CrudApiEndpoint(new Route("/alert-episode-feed"))
@Entity({
name: "AlertEpisodeFeed",
})
@TableMetadata({
tableName: "AlertEpisodeFeed",
singularName: "Alert Episode Feed",
pluralName: "Alert Episode Feeds",
icon: IconProp.List,
tableDescription:
"Log of the entire alert episode activity. This is a log of all the episode state changes, alerts added/removed, notes, etc.",
})
export default class AlertEpisodeFeed extends BaseModel {
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeFeed,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeFeed,
],
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.CreateAlertEpisodeFeed,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeFeed,
],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.ObjectID,
required: true,
canReadOnRelationQuery: true,
title: "Project ID",
description: "ID of your OneUptime Project in which this object belongs",
})
@Column({
type: ColumnType.ObjectID,
nullable: false,
transformer: ObjectID.getDatabaseTransformer(),
})
public projectId?: ObjectID = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeFeed,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeFeed,
],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "alertEpisodeId",
type: TableColumnType.Entity,
modelType: AlertEpisode,
title: "Alert Episode",
description: "Relation to Alert Episode in which this resource belongs",
})
@ManyToOne(
() => {
return AlertEpisode;
},
{
eager: false,
nullable: true,
onDelete: "CASCADE",
orphanedRowAction: "nullify",
},
)
@JoinColumn({ name: "alertEpisodeId" })
public alertEpisode?: AlertEpisode = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeFeed,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeFeed,
],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.ObjectID,
required: true,
title: "Alert Episode ID",
description: "Relation to Alert Episode ID in which this resource belongs",
})
@Column({
type: ColumnType.ObjectID,
nullable: false,
transformer: ObjectID.getDatabaseTransformer(),
})
public alertEpisodeId?: ObjectID = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeFeed,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeFeed,
],
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.CreateAlertEpisodeFeed,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeFeed,
],
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)",
})
@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)",
})
@Column({
type: ColumnType.ObjectID,
nullable: true,
transformer: ObjectID.getDatabaseTransformer(),
})
public deletedByUserId?: ObjectID = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeFeed,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeFeed,
],
update: [],
})
@TableColumn({
type: TableColumnType.Markdown,
required: true,
title: "Log (in Markdown)",
description: "Log of the entire alert episode activity in Markdown",
})
@Column({
type: ColumnType.Markdown,
nullable: false,
unique: false,
})
public feedInfoInMarkdown?: string = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeFeed,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeFeed,
],
update: [],
})
@TableColumn({
type: TableColumnType.Markdown,
required: false,
title: "More Information (in Markdown)",
description: "More information in Markdown",
})
@Column({
type: ColumnType.Markdown,
nullable: true,
unique: false,
})
public moreInformationInMarkdown?: string = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeFeed,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeFeed,
],
update: [],
})
@TableColumn({
type: TableColumnType.ShortText,
required: true,
title: "Alert Episode Feed Event",
description: "Alert Episode Feed Event Type",
})
@Column({
type: ColumnType.ShortText,
nullable: false,
unique: false,
})
public alertEpisodeFeedEventType?: AlertEpisodeFeedEventType = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeFeed,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeFeed,
],
update: [],
})
@ColorField()
@TableColumn({
type: TableColumnType.Color,
required: true,
title: "Color",
description: "Display color for the alert episode log",
})
@Column({
type: ColumnType.Color,
length: ColumnLength.Color,
nullable: false,
unique: false,
transformer: Color.getDatabaseTransformer(),
})
public displayColor?: Color = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeFeed,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeFeed,
],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "userId",
type: TableColumnType.Entity,
modelType: User,
title: "User",
description:
"Relation to User who this feed belongs to (if this feed belongs to a User)",
})
@ManyToOne(
() => {
return User;
},
{
eager: false,
nullable: true,
onDelete: "SET NULL",
orphanedRowAction: "nullify",
},
)
@JoinColumn({ name: "userId" })
public user?: User = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeFeed,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeFeed,
],
update: [],
})
@TableColumn({
type: TableColumnType.ObjectID,
title: "User ID",
description:
"User who this feed belongs to (if this feed belongs to a User)",
})
@Column({
type: ColumnType.ObjectID,
nullable: true,
transformer: ObjectID.getDatabaseTransformer(),
})
public userId?: ObjectID = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeFeed,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeFeed,
],
update: [],
})
@TableColumn({
title: "Feed Posted At",
description: "Date and time when the feed was posted",
type: TableColumnType.Date,
})
@Column({
type: ColumnType.Date,
nullable: true,
unique: false,
})
public postedAt?: Date = undefined;
}

View File

@@ -0,0 +1,455 @@
import AlertEpisode from "./AlertEpisode";
import File from "./File";
import Project from "./Project";
import User from "./User";
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 {
Column,
Entity,
Index,
JoinColumn,
JoinTable,
ManyToMany,
ManyToOne,
} from "typeorm";
@EnableDocumentation()
@CanAccessIfCanReadOn("alertEpisode")
@TenantColumn("projectId")
@TableAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeInternalNote,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeInternalNote,
],
delete: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.DeleteAlertEpisodeInternalNote,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.EditAlertEpisodeInternalNote,
],
})
@EnableWorkflow({
create: true,
delete: true,
update: true,
read: true,
})
@CrudApiEndpoint(new Route("/alert-episode-internal-note"))
@Entity({
name: "AlertEpisodeInternalNote",
})
@TableMetadata({
tableName: "AlertEpisodeInternalNote",
singularName: "Alert Episode Internal Note",
pluralName: "Alert Episode Internal Notes",
icon: IconProp.Lock,
tableDescription: "Manage internal notes for your alert episodes",
})
export default class AlertEpisodeInternalNote extends BaseModel {
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeInternalNote,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeInternalNote,
],
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.CreateAlertEpisodeInternalNote,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeInternalNote,
],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.ObjectID,
required: true,
canReadOnRelationQuery: true,
title: "Project ID",
description: "ID of your OneUptime Project in which this object belongs",
})
@Column({
type: ColumnType.ObjectID,
nullable: false,
transformer: ObjectID.getDatabaseTransformer(),
})
public projectId?: ObjectID = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeInternalNote,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeInternalNote,
],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "alertEpisodeId",
type: TableColumnType.Entity,
modelType: AlertEpisode,
title: "Alert Episode",
description: "Relation to Alert Episode in which this resource belongs",
})
@ManyToOne(
() => {
return AlertEpisode;
},
{
eager: false,
nullable: true,
onDelete: "CASCADE",
orphanedRowAction: "nullify",
},
)
@JoinColumn({ name: "alertEpisodeId" })
public alertEpisode?: AlertEpisode = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeInternalNote,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeInternalNote,
],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.ObjectID,
required: true,
title: "Alert Episode ID",
description: "Relation to Alert Episode ID in which this resource belongs",
})
@Column({
type: ColumnType.ObjectID,
nullable: false,
transformer: ObjectID.getDatabaseTransformer(),
})
public alertEpisodeId?: ObjectID = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeInternalNote,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeInternalNote,
],
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.CreateAlertEpisodeInternalNote,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeInternalNote,
],
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)",
})
@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)",
})
@Column({
type: ColumnType.ObjectID,
nullable: true,
transformer: ObjectID.getDatabaseTransformer(),
})
public deletedByUserId?: ObjectID = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeInternalNote,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeInternalNote,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.EditAlertEpisodeInternalNote,
],
})
@TableColumn({
type: TableColumnType.Markdown,
title: "Note",
description: "Notes in markdown",
})
@Column({
type: ColumnType.Markdown,
nullable: false,
unique: false,
})
public note?: string = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeInternalNote,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeInternalNote,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.EditAlertEpisodeInternalNote,
],
})
@TableColumn({
type: TableColumnType.EntityArray,
modelType: File,
title: "Attachments",
description: "Files attached to this note",
required: false,
})
@ManyToMany(
() => {
return File;
},
{
eager: false,
},
)
@JoinTable({
name: "AlertEpisodeInternalNoteFile",
joinColumn: {
name: "alertEpisodeInternalNoteId",
referencedColumnName: "_id",
},
inverseJoinColumn: {
name: "fileId",
referencedColumnName: "_id",
},
})
public attachments?: Array<File> = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeInternalNote,
],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.Boolean,
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.CreateAlertEpisodeInternalNote,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeInternalNote,
],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.LongText,
title: "Posted from Slack Message ID",
description:
"Unique identifier for the Slack message this note was created from (channel_id:message_ts). Used to prevent duplicate notes when multiple users react to the same message.",
required: false,
example: "C1234567890:1234567890.123456",
})
@Column({
type: ColumnType.LongText,
nullable: true,
})
public postedFromSlackMessageId?: string = undefined;
}

View File

@@ -0,0 +1,586 @@
import Alert from "./Alert";
import AlertEpisode from "./AlertEpisode";
import AlertGroupingRule from "./AlertGroupingRule";
import Project from "./Project";
import User from "./User";
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 ColumnLength from "../../Types/Database/ColumnLength";
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 { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
export enum AlertEpisodeMemberAddedBy {
Rule = "rule",
Manual = "manual",
API = "api",
}
@EnableDocumentation()
@TenantColumn("projectId")
@TableAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeMember,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeMember,
],
delete: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.DeleteAlertEpisodeMember,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.EditAlertEpisodeMember,
],
})
@EnableWorkflow({
create: true,
delete: true,
update: true,
read: true,
})
@CrudApiEndpoint(new Route("/alert-episode-member"))
@TableMetadata({
tableName: "AlertEpisodeMember",
singularName: "Alert Episode Member",
pluralName: "Alert Episode Members",
icon: IconProp.Layers,
tableDescription: "Link between alerts and episodes",
})
@Entity({
name: "AlertEpisodeMember",
})
@Index(["alertEpisodeId", "alertId", "projectId"])
export default class AlertEpisodeMember extends BaseModel {
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeMember,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeMember,
],
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: false,
onDelete: "CASCADE",
orphanedRowAction: "nullify",
},
)
@JoinColumn({ name: "projectId" })
public project?: Project = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeMember,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeMember,
],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.ObjectID,
required: true,
canReadOnRelationQuery: true,
title: "Project ID",
description: "ID of your OneUptime Project in which this object belongs",
})
@Column({
type: ColumnType.ObjectID,
nullable: false,
transformer: ObjectID.getDatabaseTransformer(),
})
public projectId?: ObjectID = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeMember,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeMember,
],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "alertEpisodeId",
type: TableColumnType.Entity,
modelType: AlertEpisode,
title: "Alert Episode",
description: "Relation to Alert Episode that this alert belongs to",
})
@ManyToOne(
() => {
return AlertEpisode;
},
{
eager: false,
nullable: false,
onDelete: "CASCADE",
orphanedRowAction: "nullify",
},
)
@JoinColumn({ name: "alertEpisodeId" })
public alertEpisode?: AlertEpisode = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeMember,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeMember,
],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.ObjectID,
required: true,
canReadOnRelationQuery: true,
title: "Alert Episode ID",
description: "ID of the Alert Episode that this alert belongs to",
})
@Column({
type: ColumnType.ObjectID,
nullable: false,
transformer: ObjectID.getDatabaseTransformer(),
})
public alertEpisodeId?: ObjectID = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeMember,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeMember,
],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "alertId",
type: TableColumnType.Entity,
modelType: Alert,
title: "Alert",
description: "Relation to Alert that is a member of this episode",
})
@ManyToOne(
() => {
return Alert;
},
{
eager: false,
nullable: false,
onDelete: "CASCADE",
orphanedRowAction: "nullify",
},
)
@JoinColumn({ name: "alertId" })
public alert?: Alert = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeMember,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeMember,
],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.ObjectID,
required: true,
canReadOnRelationQuery: true,
title: "Alert ID",
description: "ID of the Alert that is a member of this episode",
})
@Column({
type: ColumnType.ObjectID,
nullable: false,
transformer: ObjectID.getDatabaseTransformer(),
})
public alertId?: ObjectID = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeMember,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeMember,
],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.Date,
title: "Added At",
description: "When this alert was added to the episode",
})
@Column({
type: ColumnType.Date,
nullable: true,
unique: false,
})
public addedAt?: Date = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeMember,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeMember,
],
update: [],
})
@TableColumn({
type: TableColumnType.ShortText,
required: true,
isDefaultValueColumn: true,
title: "Added By",
description:
"How this alert was added to the episode (rule, manual, or api)",
defaultValue: AlertEpisodeMemberAddedBy.Rule,
})
@Column({
type: ColumnType.ShortText,
nullable: false,
default: AlertEpisodeMemberAddedBy.Rule,
length: ColumnLength.ShortText,
})
public addedBy?: AlertEpisodeMemberAddedBy = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeMember,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeMember,
],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "addedByUserId",
type: TableColumnType.Entity,
modelType: User,
title: "Added By User",
description:
"User who manually added this alert to the episode (if applicable)",
})
@ManyToOne(
() => {
return User;
},
{
eager: false,
nullable: true,
onDelete: "SET NULL",
orphanedRowAction: "nullify",
},
)
@JoinColumn({ name: "addedByUserId" })
public addedByUser?: User = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeMember,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeMember,
],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.ObjectID,
required: false,
title: "Added By User ID",
description: "User ID who manually added this alert to the episode",
})
@Column({
type: ColumnType.ObjectID,
nullable: true,
transformer: ObjectID.getDatabaseTransformer(),
})
public addedByUserId?: ObjectID = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeMember,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeMember,
],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "matchedRuleId",
type: TableColumnType.Entity,
modelType: AlertGroupingRule,
title: "Matched Rule",
description: "The grouping rule that matched this alert (if applicable)",
})
@ManyToOne(
() => {
return AlertGroupingRule;
},
{
eager: false,
nullable: true,
onDelete: "SET NULL",
orphanedRowAction: "nullify",
},
)
@JoinColumn({ name: "matchedRuleId" })
public matchedRule?: AlertGroupingRule = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeMember,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeMember,
],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.ObjectID,
required: false,
title: "Matched Rule ID",
description: "ID of the grouping rule that matched this alert",
})
@Column({
type: ColumnType.ObjectID,
nullable: true,
transformer: ObjectID.getDatabaseTransformer(),
})
public matchedRuleId?: ObjectID = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeMember,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeMember,
],
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.CreateAlertEpisodeMember,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeMember,
],
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)",
})
@Column({
type: ColumnType.ObjectID,
nullable: true,
transformer: ObjectID.getDatabaseTransformer(),
})
public createdByUserId?: ObjectID = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeMember,
],
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: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeMember,
],
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)",
})
@Column({
type: ColumnType.ObjectID,
nullable: true,
transformer: ObjectID.getDatabaseTransformer(),
})
public deletedByUserId?: ObjectID = undefined;
}

View File

@@ -0,0 +1,421 @@
import AlertEpisode from "./AlertEpisode";
import Project from "./Project";
import Team from "./Team";
import User from "./User";
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 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 { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
@EnableDocumentation()
@TenantColumn("projectId")
@TableAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeOwnerTeam,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeOwnerTeam,
],
delete: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.DeleteAlertEpisodeOwnerTeam,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.EditAlertEpisodeOwnerTeam,
],
})
@EnableWorkflow({
create: true,
delete: true,
update: true,
read: true,
})
@CrudApiEndpoint(new Route("/alert-episode-owner-team"))
@TableMetadata({
tableName: "AlertEpisodeOwnerTeam",
singularName: "Alert Episode Team Owner",
pluralName: "Alert Episode Team Owners",
icon: IconProp.Team,
tableDescription: "Add teams as owners to your alert episodes.",
})
@Entity({
name: "AlertEpisodeOwnerTeam",
})
@Index(["alertEpisodeId", "teamId", "projectId"])
export default class AlertEpisodeOwnerTeam extends BaseModel {
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeOwnerTeam,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeOwnerTeam,
],
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: false,
onDelete: "CASCADE",
orphanedRowAction: "nullify",
},
)
@JoinColumn({ name: "projectId" })
public project?: Project = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeOwnerTeam,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeOwnerTeam,
],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.ObjectID,
required: true,
canReadOnRelationQuery: true,
title: "Project ID",
description: "ID of your OneUptime Project in which this object belongs",
})
@Column({
type: ColumnType.ObjectID,
nullable: false,
transformer: ObjectID.getDatabaseTransformer(),
})
public projectId?: ObjectID = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeOwnerTeam,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeOwnerTeam,
],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "teamId",
type: TableColumnType.Entity,
modelType: Team,
title: "Team",
description:
"Team that is the owner. All users in this team will receive notifications.",
})
@ManyToOne(
() => {
return Team;
},
{
eager: false,
nullable: true,
onDelete: "CASCADE",
orphanedRowAction: "nullify",
},
)
@JoinColumn({ name: "teamId" })
public team?: Team = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeOwnerTeam,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeOwnerTeam,
],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.ObjectID,
required: true,
canReadOnRelationQuery: true,
title: "Team ID",
description: "ID of your OneUptime Team in which this object belongs",
})
@Column({
type: ColumnType.ObjectID,
nullable: false,
transformer: ObjectID.getDatabaseTransformer(),
})
public teamId?: ObjectID = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeOwnerTeam,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeOwnerTeam,
],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "alertEpisodeId",
type: TableColumnType.Entity,
modelType: AlertEpisode,
title: "Alert Episode",
description:
"Relation to Alert Episode Resource in which this object belongs",
})
@ManyToOne(
() => {
return AlertEpisode;
},
{
eager: false,
nullable: true,
onDelete: "CASCADE",
orphanedRowAction: "nullify",
},
)
@JoinColumn({ name: "alertEpisodeId" })
public alertEpisode?: AlertEpisode = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeOwnerTeam,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeOwnerTeam,
],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.ObjectID,
required: true,
canReadOnRelationQuery: true,
title: "Alert Episode ID",
description:
"ID of your OneUptime Alert Episode in which this object belongs",
})
@Column({
type: ColumnType.ObjectID,
nullable: false,
transformer: ObjectID.getDatabaseTransformer(),
})
public alertEpisodeId?: ObjectID = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeOwnerTeam,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeOwnerTeam,
],
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.CreateAlertEpisodeOwnerTeam,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeOwnerTeam,
],
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)",
})
@Column({
type: ColumnType.ObjectID,
nullable: true,
transformer: ObjectID.getDatabaseTransformer(),
})
public createdByUserId?: ObjectID = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeOwnerTeam,
],
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: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeOwnerTeam,
],
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)",
})
@Column({
type: ColumnType.ObjectID,
nullable: true,
transformer: ObjectID.getDatabaseTransformer(),
})
public deletedByUserId?: ObjectID = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeOwnerTeam,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeOwnerTeam,
],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.Boolean,
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;
}

View File

@@ -0,0 +1,419 @@
import AlertEpisode from "./AlertEpisode";
import Project from "./Project";
import User from "./User";
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 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 { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
@EnableDocumentation()
@TenantColumn("projectId")
@TableAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeOwnerUser,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeOwnerUser,
],
delete: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.DeleteAlertEpisodeOwnerUser,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.EditAlertEpisodeOwnerUser,
],
})
@EnableWorkflow({
create: true,
delete: true,
update: true,
read: true,
})
@CrudApiEndpoint(new Route("/alert-episode-owner-user"))
@TableMetadata({
tableName: "AlertEpisodeOwnerUser",
singularName: "Alert Episode User Owner",
pluralName: "Alert Episode User Owners",
icon: IconProp.User,
tableDescription: "Add users as owners to your alert episodes.",
})
@Entity({
name: "AlertEpisodeOwnerUser",
})
@Index(["alertEpisodeId", "userId", "projectId"])
export default class AlertEpisodeOwnerUser extends BaseModel {
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeOwnerUser,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeOwnerUser,
],
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: false,
onDelete: "CASCADE",
orphanedRowAction: "nullify",
},
)
@JoinColumn({ name: "projectId" })
public project?: Project = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeOwnerUser,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeOwnerUser,
],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.ObjectID,
required: true,
canReadOnRelationQuery: true,
title: "Project ID",
description: "ID of your OneUptime Project in which this object belongs",
})
@Column({
type: ColumnType.ObjectID,
nullable: false,
transformer: ObjectID.getDatabaseTransformer(),
})
public projectId?: ObjectID = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeOwnerUser,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeOwnerUser,
],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "userId",
type: TableColumnType.Entity,
modelType: User,
title: "User",
description: "User that is the owner of this episode",
})
@ManyToOne(
() => {
return User;
},
{
eager: false,
nullable: true,
onDelete: "CASCADE",
orphanedRowAction: "nullify",
},
)
@JoinColumn({ name: "userId" })
public user?: User = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeOwnerUser,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeOwnerUser,
],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.ObjectID,
required: true,
canReadOnRelationQuery: true,
title: "User ID",
description: "ID of the user who is the owner",
})
@Column({
type: ColumnType.ObjectID,
nullable: false,
transformer: ObjectID.getDatabaseTransformer(),
})
public userId?: ObjectID = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeOwnerUser,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeOwnerUser,
],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "alertEpisodeId",
type: TableColumnType.Entity,
modelType: AlertEpisode,
title: "Alert Episode",
description:
"Relation to Alert Episode Resource in which this object belongs",
})
@ManyToOne(
() => {
return AlertEpisode;
},
{
eager: false,
nullable: true,
onDelete: "CASCADE",
orphanedRowAction: "nullify",
},
)
@JoinColumn({ name: "alertEpisodeId" })
public alertEpisode?: AlertEpisode = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeOwnerUser,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeOwnerUser,
],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.ObjectID,
required: true,
canReadOnRelationQuery: true,
title: "Alert Episode ID",
description:
"ID of your OneUptime Alert Episode in which this object belongs",
})
@Column({
type: ColumnType.ObjectID,
nullable: false,
transformer: ObjectID.getDatabaseTransformer(),
})
public alertEpisodeId?: ObjectID = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeOwnerUser,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeOwnerUser,
],
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.CreateAlertEpisodeOwnerUser,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeOwnerUser,
],
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)",
})
@Column({
type: ColumnType.ObjectID,
nullable: true,
transformer: ObjectID.getDatabaseTransformer(),
})
public createdByUserId?: ObjectID = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeOwnerUser,
],
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: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeOwnerUser,
],
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)",
})
@Column({
type: ColumnType.ObjectID,
nullable: true,
transformer: ObjectID.getDatabaseTransformer(),
})
public deletedByUserId?: ObjectID = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeOwnerUser,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeOwnerUser,
],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.Boolean,
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;
}

View File

@@ -0,0 +1,523 @@
import AlertEpisode from "./AlertEpisode";
import AlertState from "./AlertState";
import Project from "./Project";
import User from "./User";
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 { JSONObject } from "../../Types/JSON";
import ObjectID from "../../Types/ObjectID";
import Permission from "../../Types/Permission";
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
@EnableDocumentation()
@CanAccessIfCanReadOn("alertEpisode")
@TenantColumn("projectId")
@TableAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeStateTimeline,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeStateTimeline,
],
delete: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.DeleteAlertEpisodeStateTimeline,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.EditAlertEpisodeStateTimeline,
],
})
@EnableWorkflow({
create: true,
delete: true,
update: true,
read: true,
})
@CrudApiEndpoint(new Route("/alert-episode-state-timeline"))
@Entity({
name: "AlertEpisodeStateTimeline",
})
@Index(["alertEpisodeId", "startsAt"])
@TableMetadata({
tableName: "AlertEpisodeStateTimeline",
singularName: "Alert Episode State Timeline",
pluralName: "Alert Episode State Timelines",
icon: IconProp.List,
tableDescription:
"Change state of the alert episodes (Created to Acknowledged for example)",
})
export default class AlertEpisodeStateTimeline extends BaseModel {
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeStateTimeline,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeStateTimeline,
],
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.CreateAlertEpisodeStateTimeline,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeStateTimeline,
],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.ObjectID,
required: true,
canReadOnRelationQuery: true,
title: "Project ID",
description: "ID of your OneUptime Project in which this object belongs",
})
@Column({
type: ColumnType.ObjectID,
nullable: false,
transformer: ObjectID.getDatabaseTransformer(),
})
public projectId?: ObjectID = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeStateTimeline,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeStateTimeline,
],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "alertEpisodeId",
type: TableColumnType.Entity,
modelType: AlertEpisode,
title: "Alert Episode",
description: "Relation to Alert Episode in which this resource belongs",
})
@ManyToOne(
() => {
return AlertEpisode;
},
{
eager: false,
nullable: true,
onDelete: "CASCADE",
orphanedRowAction: "nullify",
},
)
@JoinColumn({ name: "alertEpisodeId" })
public alertEpisode?: AlertEpisode = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeStateTimeline,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeStateTimeline,
],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.ObjectID,
required: true,
title: "Alert Episode ID",
description: "Relation to Alert Episode ID in which this resource belongs",
})
@Column({
type: ColumnType.ObjectID,
nullable: false,
transformer: ObjectID.getDatabaseTransformer(),
})
public alertEpisodeId?: ObjectID = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeStateTimeline,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeStateTimeline,
],
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.CreateAlertEpisodeStateTimeline,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeStateTimeline,
],
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)",
})
@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)",
})
@Column({
type: ColumnType.ObjectID,
nullable: true,
transformer: ObjectID.getDatabaseTransformer(),
})
public deletedByUserId?: ObjectID = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeStateTimeline,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeStateTimeline,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.EditAlertEpisodeStateTimeline,
],
})
@TableColumn({
manyToOneRelationColumn: "alertStateId",
type: TableColumnType.Entity,
modelType: AlertState,
title: "Alert State",
description:
"Alert State Relation. Which alert state does this episode change to?",
})
@ManyToOne(
() => {
return AlertState;
},
{
eager: false,
nullable: true,
orphanedRowAction: "nullify",
},
)
@JoinColumn({ name: "alertStateId" })
public alertState?: AlertState = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeStateTimeline,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeStateTimeline,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.EditAlertEpisodeStateTimeline,
],
})
@Index()
@TableColumn({
type: TableColumnType.ObjectID,
required: true,
title: "Alert State ID",
description:
"Alert State ID Relation. Which alert state does this episode change to?",
})
@Column({
type: ColumnType.ObjectID,
nullable: false,
transformer: ObjectID.getDatabaseTransformer(),
})
public alertStateId?: ObjectID = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeStateTimeline,
],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.Boolean,
required: true,
isDefaultValueColumn: true,
title: "Are Owners Notified",
description: "Are owners notified of state change?",
defaultValue: false,
})
@Column({
type: ColumnType.Boolean,
nullable: false,
default: false,
})
public isOwnerNotified?: boolean = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeStateTimeline,
],
update: [],
})
@TableColumn({
isDefaultValueColumn: false,
required: false,
type: TableColumnType.JSON,
})
@Column({
type: ColumnType.JSON,
nullable: true,
unique: false,
})
public stateChangeLog?: JSONObject = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeStateTimeline,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeStateTimeline,
],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.Markdown,
required: false,
isDefaultValueColumn: false,
title: "Root Cause",
description: "What is the root cause of this status change?",
})
@Column({
type: ColumnType.Markdown,
nullable: true,
})
public rootCause?: string = undefined;
@Index()
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeStateTimeline,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeStateTimeline,
],
update: [],
})
@TableColumn({
type: TableColumnType.Date,
title: "Ends At",
description: "When did this status change end?",
})
@Column({
type: ColumnType.Date,
nullable: true,
unique: false,
})
public endsAt?: Date = undefined;
@Index()
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateAlertEpisodeStateTimeline,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadAlertEpisodeStateTimeline,
],
update: [],
})
@TableColumn({
type: TableColumnType.Date,
title: "Starts At",
description: "When did this status change?",
})
@Column({
type: ColumnType.Date,
nullable: true,
unique: false,
})
public startsAt?: Date = undefined;
}

File diff suppressed because it is too large Load Diff

View File

@@ -189,6 +189,15 @@ import AlertSeverity from "./AlertSeverity";
import AlertNoteTemplate from "./AlertNoteTemplate";
import AlertFeed from "./AlertFeed";
import AlertEpisode from "./AlertEpisode";
import AlertEpisodeMember from "./AlertEpisodeMember";
import AlertEpisodeStateTimeline from "./AlertEpisodeStateTimeline";
import AlertEpisodeOwnerUser from "./AlertEpisodeOwnerUser";
import AlertEpisodeOwnerTeam from "./AlertEpisodeOwnerTeam";
import AlertEpisodeInternalNote from "./AlertEpisodeInternalNote";
import AlertEpisodeFeed from "./AlertEpisodeFeed";
import AlertGroupingRule from "./AlertGroupingRule";
import TableView from "./TableView";
import Dashboard from "./Dashboard";
@@ -273,6 +282,15 @@ const AllModelTypes: Array<{
AlertSeverity,
AlertNoteTemplate,
AlertEpisode,
AlertEpisodeMember,
AlertEpisodeStateTimeline,
AlertEpisodeOwnerUser,
AlertEpisodeOwnerTeam,
AlertEpisodeInternalNote,
AlertEpisodeFeed,
AlertGroupingRule,
MonitorStatusTimeline,
File,

View File

@@ -25,6 +25,7 @@ import Permission from "../../Types/Permission";
import UserNotificationEventType from "../../Types/UserNotification/UserNotificationEventType";
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
import Alert from "./Alert";
import AlertEpisode from "./AlertEpisode";
import EnableWorkflow from "../../Types/Database/EnableWorkflow";
@TableBillingAccessControl({
@@ -342,6 +343,75 @@ export default class OnCallDutyPolicyExecutionLog extends BaseModel {
})
public triggeredByAlertId?: ObjectID = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateProjectOnCallDutyPolicyExecutionLog,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadProjectOnCallDutyPolicyExecutionLog,
],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "triggeredByAlertEpisodeId",
type: TableColumnType.Entity,
modelType: AlertEpisode,
title: "Triggered By Alert Episode",
description:
"Relation to the alert episode which triggered this on-call escalation policy.",
})
@ManyToOne(
() => {
return AlertEpisode;
},
{
eager: false,
nullable: true,
onDelete: "CASCADE",
orphanedRowAction: "nullify",
},
)
@JoinColumn({ name: "triggeredByAlertEpisodeId" })
public triggeredByAlertEpisode?: AlertEpisode = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateProjectOnCallDutyPolicyExecutionLog,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadProjectOnCallDutyPolicyExecutionLog,
],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.ObjectID,
required: false,
canReadOnRelationQuery: true,
title: "Triggered By Alert Episode ID",
description:
"ID of the alert episode which triggered this on-call escalation policy.",
example: "a7b8c9d0-e1f2-3456-0123-567890123456",
})
@Column({
type: ColumnType.ObjectID,
nullable: true,
transformer: ObjectID.getDatabaseTransformer(),
})
public triggeredByAlertEpisodeId?: ObjectID = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,

View File

@@ -1,6 +1,7 @@
import Project from "./Project";
import Incident from "./Incident";
import Alert from "./Alert";
import AlertEpisode from "./AlertEpisode";
import ScheduledMaintenance from "./ScheduledMaintenance";
import StatusPage from "./StatusPage";
import StatusPageAnnouncement from "./StatusPageAnnouncement";
@@ -482,6 +483,62 @@ export default class WorkspaceNotificationLog extends BaseModel {
})
public alertId?: ObjectID = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadPushLog,
],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "alertEpisodeId",
type: TableColumnType.Entity,
modelType: AlertEpisode,
title: "Alert Episode",
description: "Alert Episode associated with this message (if any)",
})
@ManyToOne(
() => {
return AlertEpisode;
},
{
eager: false,
nullable: true,
onDelete: "CASCADE",
orphanedRowAction: "nullify",
},
)
@JoinColumn({ name: "alertEpisodeId" })
public alertEpisode?: AlertEpisode = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadPushLog,
],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.ObjectID,
required: false,
canReadOnRelationQuery: true,
title: "Alert Episode ID",
description: "ID of Alert Episode associated with this message (if any)",
})
@Column({
type: ColumnType.ObjectID,
nullable: true,
transformer: ObjectID.getDatabaseTransformer(),
})
public alertEpisodeId?: ObjectID = undefined;
@ColumnAccessControl({
create: [],
read: [

View File

@@ -31,6 +31,7 @@ import SlackAuthAction, {
} from "../Utils/Workspace/Slack/Actions/Auth";
import SlackIncidentActions from "../Utils/Workspace/Slack/Actions/Incident";
import SlackAlertActions from "../Utils/Workspace/Slack/Actions/Alert";
import SlackAlertEpisodeActions from "../Utils/Workspace/Slack/Actions/AlertEpisode";
import SlackScheduledMaintenanceActions from "../Utils/Workspace/Slack/Actions/ScheduledMaintenance";
import LIMIT_MAX from "../../Types/Database/LimitMax";
import SlackMonitorActions from "../Utils/Workspace/Slack/Actions/Monitor";
@@ -633,6 +634,19 @@ export default class SlackAPI {
});
}
if (
SlackAlertEpisodeActions.isAlertEpisodeAction({
actionType: action.actionType,
})
) {
return SlackAlertEpisodeActions.handleAlertEpisodeAction({
slackRequest: authResult,
action: action,
req: req,
res: res,
});
}
if (
SlackMonitorActions.isMonitorAction({
actionType: action.actionType,
@@ -816,6 +830,13 @@ export default class SlackAPI {
logger.error(err);
}
try {
await SlackAlertEpisodeActions.handleEmojiReaction(reactionData);
} catch (err) {
logger.error("Error handling alert episode emoji reaction:");
logger.error(err);
}
try {
await SlackScheduledMaintenanceActions.handleEmojiReaction(
reactionData,

View File

@@ -0,0 +1,751 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class MigrationName1768938069147 implements MigrationInterface {
public name = "MigrationName1768938069147";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "AlertGroupingRule" ("_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, "name" character varying(100) NOT NULL, "description" character varying(500), "priority" integer NOT NULL DEFAULT '1', "isEnabled" boolean NOT NULL DEFAULT true, "matchCriteria" jsonb, "timeWindowMinutes" integer NOT NULL DEFAULT '60', "groupByFields" jsonb, "episodeTitleTemplate" character varying(100), "resolveDelayMinutes" integer NOT NULL DEFAULT '0', "reopenWindowMinutes" integer NOT NULL DEFAULT '0', "inactivityTimeoutMinutes" integer NOT NULL DEFAULT '60', "defaultAssignToUserId" uuid, "defaultAssignToTeamId" uuid, "createdByUserId" uuid, "deletedByUserId" uuid, CONSTRAINT "PK_9097eb26247232b9d911f3dc0fd" PRIMARY KEY ("_id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_cfb25f386359c3717126ecea1e" ON "AlertGroupingRule" ("projectId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_b828bfbe2edbffd53213f4c2c4" ON "AlertGroupingRule" ("name") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_0e493339eed92199a43c5ddebe" ON "AlertGroupingRule" ("priority") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_c546da77e04aebb0249d1b1441" ON "AlertGroupingRule" ("isEnabled") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_a361c35b33d76e4c4d97f27e11" ON "AlertGroupingRule" ("defaultAssignToUserId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_de1e396519c6da05b889a12169" ON "AlertGroupingRule" ("defaultAssignToTeamId") `,
);
await queryRunner.query(
`CREATE TABLE "AlertEpisode" ("_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, "title" character varying(500) NOT NULL, "description" text, "episodeNumber" integer, "currentAlertStateId" uuid NOT NULL, "alertSeverityId" uuid, "rootCause" text, "lastAlertAddedAt" TIMESTAMP WITH TIME ZONE, "resolvedAt" TIMESTAMP WITH TIME ZONE, "assignedToUserId" uuid, "assignedToTeamId" uuid, "alertGroupingRuleId" uuid, "isOnCallPolicyExecuted" boolean NOT NULL DEFAULT false, "alertCount" integer NOT NULL DEFAULT '0', "isManuallyCreated" boolean NOT NULL DEFAULT false, "createdByUserId" uuid, "deletedByUserId" uuid, "isOwnerNotifiedOfEpisodeCreation" boolean NOT NULL DEFAULT false, "groupingKey" character varying(500), CONSTRAINT "PK_2e22f03c5e8057c1a9e64df8b5f" PRIMARY KEY ("_id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_32bad121c94b4024f2b42c56e6" ON "AlertEpisode" ("projectId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_609b97c21c0eed3ac245f155d6" ON "AlertEpisode" ("title") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_c81dab13dbd1ce1711d05a897f" ON "AlertEpisode" ("episodeNumber") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_3b98b5bde99d96631ca66c8fd8" ON "AlertEpisode" ("currentAlertStateId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_1707b042f7da296409fc8e6e3c" ON "AlertEpisode" ("alertSeverityId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_e761f26e4824f6379f2ebefef3" ON "AlertEpisode" ("lastAlertAddedAt") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_d5181cb8a1471f0130c771544a" ON "AlertEpisode" ("resolvedAt") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_0569ad819089ad1b62e7080b0b" ON "AlertEpisode" ("assignedToUserId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_a9dbe80e8446a3082d2904f616" ON "AlertEpisode" ("assignedToTeamId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_89099729ddded8c275b201a7c9" ON "AlertEpisode" ("alertGroupingRuleId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_c0bc17e7d0d4e8647ae7ab1f5c" ON "AlertEpisode" ("isOnCallPolicyExecuted") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_2fdffde2abde7855a92491482b" ON "AlertEpisode" ("alertCount") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_a95ab301f037e626b06af5ea7d" ON "AlertEpisode" ("isManuallyCreated") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_9a4c42288b10fe27e6cd655a0d" ON "AlertEpisode" ("isOwnerNotifiedOfEpisodeCreation") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_7daaa2e53985bc52154462b310" ON "AlertEpisode" ("groupingKey") `,
);
await queryRunner.query(
`CREATE TABLE "AlertEpisodeMember" ("_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, "alertEpisodeId" uuid NOT NULL, "alertId" uuid NOT NULL, "addedAt" TIMESTAMP WITH TIME ZONE, "addedBy" character varying(100) NOT NULL DEFAULT 'rule', "addedByUserId" uuid, "matchedRuleId" uuid, "createdByUserId" uuid, "deletedByUserId" uuid, CONSTRAINT "PK_c6ee6182c79bf8c05f1e95b7721" PRIMARY KEY ("_id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_96904f3696228eb910379f2331" ON "AlertEpisodeMember" ("projectId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_331aa5398b81014cf1b3e294ff" ON "AlertEpisodeMember" ("alertEpisodeId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_69af045b1aad0e7792edf2660b" ON "AlertEpisodeMember" ("alertId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_1b437e214ebfc5c9169fa28b73" ON "AlertEpisodeMember" ("addedAt") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_231127e3cfd3caee50d5852e1b" ON "AlertEpisodeMember" ("addedByUserId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_dc51318091c186f2b8122c5428" ON "AlertEpisodeMember" ("matchedRuleId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_b95fe8c9396c1e1bcf24a941fd" ON "AlertEpisodeMember" ("alertEpisodeId", "alertId", "projectId") `,
);
await queryRunner.query(
`CREATE TABLE "AlertEpisodeStateTimeline" ("_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, "alertEpisodeId" uuid NOT NULL, "createdByUserId" uuid, "deletedByUserId" uuid, "alertStateId" uuid NOT NULL, "isOwnerNotified" boolean NOT NULL DEFAULT false, "stateChangeLog" jsonb, "rootCause" text, "endsAt" TIMESTAMP WITH TIME ZONE, "startsAt" TIMESTAMP WITH TIME ZONE, CONSTRAINT "PK_90a60f43a80ed99d649d37718b0" PRIMARY KEY ("_id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_6ceb895e071ac6c5692bd0a286" ON "AlertEpisodeStateTimeline" ("projectId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_375d6f86d0f3e152ddf85daea3" ON "AlertEpisodeStateTimeline" ("alertEpisodeId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_c787c0bc5a12b94976f28d0a12" ON "AlertEpisodeStateTimeline" ("alertStateId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_fc7f8772f2daabc44003ba6550" ON "AlertEpisodeStateTimeline" ("isOwnerNotified") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_d5b9ab34c48f20d8f25e5897b3" ON "AlertEpisodeStateTimeline" ("rootCause") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_e1bd75133c8b3adb6b07af3d26" ON "AlertEpisodeStateTimeline" ("endsAt") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_69f8e12370a0309482c3ee8c72" ON "AlertEpisodeStateTimeline" ("startsAt") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_2d3ee6df3f7a160f826732ad4f" ON "AlertEpisodeStateTimeline" ("alertEpisodeId", "startsAt") `,
);
await queryRunner.query(
`CREATE TABLE "AlertEpisodeOwnerUser" ("_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, "userId" uuid NOT NULL, "alertEpisodeId" uuid NOT NULL, "createdByUserId" uuid, "deletedByUserId" uuid, "isOwnerNotified" boolean NOT NULL DEFAULT false, CONSTRAINT "PK_188167880144883dfbea50f0234" PRIMARY KEY ("_id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_41ba6bc50ab371af8ce95adf20" ON "AlertEpisodeOwnerUser" ("projectId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_cc00e04c42fa14e8a6a0ee9fb6" ON "AlertEpisodeOwnerUser" ("userId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_800af5b4ac2180cc4ade32718c" ON "AlertEpisodeOwnerUser" ("alertEpisodeId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_642bd8ead91eb90278a5356728" ON "AlertEpisodeOwnerUser" ("isOwnerNotified") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_79c03a537d5c1f4dbeb8beb355" ON "AlertEpisodeOwnerUser" ("alertEpisodeId", "userId", "projectId") `,
);
await queryRunner.query(
`CREATE TABLE "AlertEpisodeOwnerTeam" ("_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, "teamId" uuid NOT NULL, "alertEpisodeId" uuid NOT NULL, "createdByUserId" uuid, "deletedByUserId" uuid, "isOwnerNotified" boolean NOT NULL DEFAULT false, CONSTRAINT "PK_2239fecae595100af93eec3cec0" PRIMARY KEY ("_id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_143813e802d4b01c0ad70cf5db" ON "AlertEpisodeOwnerTeam" ("projectId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_f50385bf56d90ec19e26becd1c" ON "AlertEpisodeOwnerTeam" ("teamId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_818a93df19196a047511267990" ON "AlertEpisodeOwnerTeam" ("alertEpisodeId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_da4932e36b0d598f4f1bd8fa5b" ON "AlertEpisodeOwnerTeam" ("isOwnerNotified") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_74c82db90ec03c884ba9da813a" ON "AlertEpisodeOwnerTeam" ("alertEpisodeId", "teamId", "projectId") `,
);
await queryRunner.query(
`CREATE TABLE "AlertEpisodeInternalNote" ("_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, "alertEpisodeId" uuid NOT NULL, "createdByUserId" uuid, "deletedByUserId" uuid, "note" text NOT NULL, "isOwnerNotified" boolean NOT NULL DEFAULT false, CONSTRAINT "PK_3bc6cc2267abc203d9e85ad2602" PRIMARY KEY ("_id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_02013c0e8b919622f79183ee28" ON "AlertEpisodeInternalNote" ("projectId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_ac812fdc779677403928a936c9" ON "AlertEpisodeInternalNote" ("alertEpisodeId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_3a0c81b3e3e570221c5253bc14" ON "AlertEpisodeInternalNote" ("isOwnerNotified") `,
);
await queryRunner.query(
`CREATE TABLE "AlertEpisodeFeed" ("_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, "alertEpisodeId" uuid NOT NULL, "createdByUserId" uuid, "deletedByUserId" uuid, "feedInfoInMarkdown" text NOT NULL, "moreInformationInMarkdown" text, "alertEpisodeFeedEventType" character varying NOT NULL, "displayColor" character varying(10) NOT NULL, "userId" uuid, "postedAt" TIMESTAMP WITH TIME ZONE, CONSTRAINT "PK_964c353b1fc268fd060875b9f64" PRIMARY KEY ("_id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_f9286b0216728194aa75cd8f29" ON "AlertEpisodeFeed" ("projectId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_3cc423c26870c099bb43291203" ON "AlertEpisodeFeed" ("alertEpisodeId") `,
);
await queryRunner.query(
`CREATE TABLE "AlertGroupingRuleOnCallDutyPolicy" ("alertGroupingRuleId" uuid NOT NULL, "onCallDutyPolicyId" uuid NOT NULL, CONSTRAINT "PK_22f9896710fffa9d5909011407f" PRIMARY KEY ("alertGroupingRuleId", "onCallDutyPolicyId"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_cfbd2e0aa4ba9bf5613cd1f0de" ON "AlertGroupingRuleOnCallDutyPolicy" ("alertGroupingRuleId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_3a52cf44c3af5db42c746e9ba3" ON "AlertGroupingRuleOnCallDutyPolicy" ("onCallDutyPolicyId") `,
);
await queryRunner.query(
`CREATE TABLE "AlertEpisodeOnCallDutyPolicy" ("alertEpisodeId" uuid NOT NULL, "onCallDutyPolicyId" uuid NOT NULL, CONSTRAINT "PK_8fa29f51f3b6942f036d7ac1721" PRIMARY KEY ("alertEpisodeId", "onCallDutyPolicyId"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_fcbe4d926f19af8c15c6aed14e" ON "AlertEpisodeOnCallDutyPolicy" ("alertEpisodeId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_ca8d1c28cdf91b999911c78734" ON "AlertEpisodeOnCallDutyPolicy" ("onCallDutyPolicyId") `,
);
await queryRunner.query(
`CREATE TABLE "AlertEpisodeLabel" ("alertEpisodeId" uuid NOT NULL, "labelId" uuid NOT NULL, CONSTRAINT "PK_889ecdcbd147f7d667965979c53" PRIMARY KEY ("alertEpisodeId", "labelId"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_3c21d8f93c62e3a7cd4e321ddc" ON "AlertEpisodeLabel" ("alertEpisodeId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_1339030062467a133052b3a203" ON "AlertEpisodeLabel" ("labelId") `,
);
await queryRunner.query(
`CREATE TABLE "AlertEpisodeInternalNoteFile" ("alertEpisodeInternalNoteId" uuid NOT NULL, "fileId" uuid NOT NULL, CONSTRAINT "PK_da4c0328f4fdd41aa2361761ded" PRIMARY KEY ("alertEpisodeInternalNoteId", "fileId"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_2944093e5e375d021e2ad8b9e8" ON "AlertEpisodeInternalNoteFile" ("alertEpisodeInternalNoteId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_f05861a3dc9e8db97fed3f674a" ON "AlertEpisodeInternalNoteFile" ("fileId") `,
);
await queryRunner.query(`ALTER TABLE "Alert" ADD "alertEpisodeId" uuid`);
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_a8ec56304ee3dbb682be731793" ON "Alert" ("alertEpisodeId") `,
);
await queryRunner.query(
`ALTER TABLE "AlertGroupingRule" ADD CONSTRAINT "FK_cfb25f386359c3717126ecea1e2" FOREIGN KEY ("projectId") REFERENCES "Project"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "AlertGroupingRule" ADD CONSTRAINT "FK_a361c35b33d76e4c4d97f27e113" FOREIGN KEY ("defaultAssignToUserId") REFERENCES "User"("_id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "AlertGroupingRule" ADD CONSTRAINT "FK_de1e396519c6da05b889a12169a" FOREIGN KEY ("defaultAssignToTeamId") REFERENCES "Team"("_id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "AlertGroupingRule" ADD CONSTRAINT "FK_c2e138d694dfff4e3e86e6cfc6b" FOREIGN KEY ("createdByUserId") REFERENCES "User"("_id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "AlertGroupingRule" ADD CONSTRAINT "FK_7a91abf5593435797ac4fe99125" FOREIGN KEY ("deletedByUserId") REFERENCES "User"("_id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisode" ADD CONSTRAINT "FK_32bad121c94b4024f2b42c56e64" FOREIGN KEY ("projectId") REFERENCES "Project"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisode" ADD CONSTRAINT "FK_3b98b5bde99d96631ca66c8fd82" FOREIGN KEY ("currentAlertStateId") REFERENCES "AlertState"("_id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisode" ADD CONSTRAINT "FK_1707b042f7da296409fc8e6e3cd" FOREIGN KEY ("alertSeverityId") REFERENCES "AlertSeverity"("_id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisode" ADD CONSTRAINT "FK_0569ad819089ad1b62e7080b0b7" FOREIGN KEY ("assignedToUserId") REFERENCES "User"("_id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisode" ADD CONSTRAINT "FK_a9dbe80e8446a3082d2904f6167" FOREIGN KEY ("assignedToTeamId") REFERENCES "Team"("_id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisode" ADD CONSTRAINT "FK_89099729ddded8c275b201a7c9a" FOREIGN KEY ("alertGroupingRuleId") REFERENCES "AlertGroupingRule"("_id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisode" ADD CONSTRAINT "FK_58d264daa6693c3ee9e59a42953" FOREIGN KEY ("createdByUserId") REFERENCES "User"("_id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisode" ADD CONSTRAINT "FK_70fea6d5bce21e224230864acd1" FOREIGN KEY ("deletedByUserId") REFERENCES "User"("_id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "Alert" ADD CONSTRAINT "FK_a8ec56304ee3dbb682be7317937" FOREIGN KEY ("alertEpisodeId") REFERENCES "AlertEpisode"("_id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeMember" ADD CONSTRAINT "FK_96904f3696228eb910379f23317" FOREIGN KEY ("projectId") REFERENCES "Project"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeMember" ADD CONSTRAINT "FK_331aa5398b81014cf1b3e294ff7" FOREIGN KEY ("alertEpisodeId") REFERENCES "AlertEpisode"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeMember" ADD CONSTRAINT "FK_69af045b1aad0e7792edf2660bf" FOREIGN KEY ("alertId") REFERENCES "Alert"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeMember" ADD CONSTRAINT "FK_231127e3cfd3caee50d5852e1be" FOREIGN KEY ("addedByUserId") REFERENCES "User"("_id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeMember" ADD CONSTRAINT "FK_dc51318091c186f2b8122c5428d" FOREIGN KEY ("matchedRuleId") REFERENCES "AlertGroupingRule"("_id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeMember" ADD CONSTRAINT "FK_1756f31f87314129a7003d727e0" FOREIGN KEY ("createdByUserId") REFERENCES "User"("_id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeMember" ADD CONSTRAINT "FK_dea2569c6ecb31e997b82bc592e" FOREIGN KEY ("deletedByUserId") REFERENCES "User"("_id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeStateTimeline" ADD CONSTRAINT "FK_6ceb895e071ac6c5692bd0a286f" FOREIGN KEY ("projectId") REFERENCES "Project"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeStateTimeline" ADD CONSTRAINT "FK_375d6f86d0f3e152ddf85daea38" FOREIGN KEY ("alertEpisodeId") REFERENCES "AlertEpisode"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeStateTimeline" ADD CONSTRAINT "FK_7ba13caf6c7f089019b77e2e14b" FOREIGN KEY ("createdByUserId") REFERENCES "User"("_id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeStateTimeline" ADD CONSTRAINT "FK_19cd5959475f1459c24491d4634" FOREIGN KEY ("deletedByUserId") REFERENCES "User"("_id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeStateTimeline" ADD CONSTRAINT "FK_c787c0bc5a12b94976f28d0a12c" FOREIGN KEY ("alertStateId") REFERENCES "AlertState"("_id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeOwnerUser" ADD CONSTRAINT "FK_41ba6bc50ab371af8ce95adf206" FOREIGN KEY ("projectId") REFERENCES "Project"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeOwnerUser" ADD CONSTRAINT "FK_cc00e04c42fa14e8a6a0ee9fb6e" FOREIGN KEY ("userId") REFERENCES "User"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeOwnerUser" ADD CONSTRAINT "FK_800af5b4ac2180cc4ade32718c9" FOREIGN KEY ("alertEpisodeId") REFERENCES "AlertEpisode"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeOwnerUser" ADD CONSTRAINT "FK_093ca468ee1e7458065295f9432" FOREIGN KEY ("createdByUserId") REFERENCES "User"("_id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeOwnerUser" ADD CONSTRAINT "FK_917f125b25eabb6c5bab802d8fb" FOREIGN KEY ("deletedByUserId") REFERENCES "User"("_id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeOwnerTeam" ADD CONSTRAINT "FK_143813e802d4b01c0ad70cf5db8" FOREIGN KEY ("projectId") REFERENCES "Project"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeOwnerTeam" ADD CONSTRAINT "FK_f50385bf56d90ec19e26becd1c7" FOREIGN KEY ("teamId") REFERENCES "Team"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeOwnerTeam" ADD CONSTRAINT "FK_818a93df19196a047511267990f" FOREIGN KEY ("alertEpisodeId") REFERENCES "AlertEpisode"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeOwnerTeam" ADD CONSTRAINT "FK_37cc09e358934c5be8ff7856615" FOREIGN KEY ("createdByUserId") REFERENCES "User"("_id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeOwnerTeam" ADD CONSTRAINT "FK_47e5937c2b8988ae5f86c33a95a" FOREIGN KEY ("deletedByUserId") REFERENCES "User"("_id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeInternalNote" ADD CONSTRAINT "FK_02013c0e8b919622f79183ee285" FOREIGN KEY ("projectId") REFERENCES "Project"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeInternalNote" ADD CONSTRAINT "FK_ac812fdc779677403928a936c94" FOREIGN KEY ("alertEpisodeId") REFERENCES "AlertEpisode"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeInternalNote" ADD CONSTRAINT "FK_eaf6c93cfac5dfa742337dfc8cc" FOREIGN KEY ("createdByUserId") REFERENCES "User"("_id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeInternalNote" ADD CONSTRAINT "FK_345f9ec2dba210d9d7360f26e2a" FOREIGN KEY ("deletedByUserId") REFERENCES "User"("_id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeFeed" ADD CONSTRAINT "FK_f9286b0216728194aa75cd8f298" FOREIGN KEY ("projectId") REFERENCES "Project"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeFeed" ADD CONSTRAINT "FK_3cc423c26870c099bb432912033" FOREIGN KEY ("alertEpisodeId") REFERENCES "AlertEpisode"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeFeed" ADD CONSTRAINT "FK_74e7ff46a5bbbecc1be93bc52a1" FOREIGN KEY ("createdByUserId") REFERENCES "User"("_id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeFeed" ADD CONSTRAINT "FK_e44b2c00fac4093980b1d747c39" FOREIGN KEY ("deletedByUserId") REFERENCES "User"("_id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeFeed" ADD CONSTRAINT "FK_0a2917a7fa5853a2174a19983a4" FOREIGN KEY ("userId") REFERENCES "User"("_id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "AlertGroupingRuleOnCallDutyPolicy" ADD CONSTRAINT "FK_cfbd2e0aa4ba9bf5613cd1f0de2" FOREIGN KEY ("alertGroupingRuleId") REFERENCES "AlertGroupingRule"("_id") ON DELETE CASCADE ON UPDATE CASCADE`,
);
await queryRunner.query(
`ALTER TABLE "AlertGroupingRuleOnCallDutyPolicy" ADD CONSTRAINT "FK_3a52cf44c3af5db42c746e9ba3d" FOREIGN KEY ("onCallDutyPolicyId") REFERENCES "OnCallDutyPolicy"("_id") ON DELETE CASCADE ON UPDATE CASCADE`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeOnCallDutyPolicy" ADD CONSTRAINT "FK_fcbe4d926f19af8c15c6aed14e9" FOREIGN KEY ("alertEpisodeId") REFERENCES "AlertEpisode"("_id") ON DELETE CASCADE ON UPDATE CASCADE`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeOnCallDutyPolicy" ADD CONSTRAINT "FK_ca8d1c28cdf91b999911c78734a" FOREIGN KEY ("onCallDutyPolicyId") REFERENCES "OnCallDutyPolicy"("_id") ON DELETE CASCADE ON UPDATE CASCADE`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeLabel" ADD CONSTRAINT "FK_3c21d8f93c62e3a7cd4e321ddc3" FOREIGN KEY ("alertEpisodeId") REFERENCES "AlertEpisode"("_id") ON DELETE CASCADE ON UPDATE CASCADE`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeLabel" ADD CONSTRAINT "FK_1339030062467a133052b3a2038" FOREIGN KEY ("labelId") REFERENCES "Label"("_id") ON DELETE CASCADE ON UPDATE CASCADE`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeInternalNoteFile" ADD CONSTRAINT "FK_2944093e5e375d021e2ad8b9e82" FOREIGN KEY ("alertEpisodeInternalNoteId") REFERENCES "AlertEpisodeInternalNote"("_id") ON DELETE CASCADE ON UPDATE CASCADE`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeInternalNoteFile" ADD CONSTRAINT "FK_f05861a3dc9e8db97fed3f674a3" FOREIGN KEY ("fileId") REFERENCES "File"("_id") ON DELETE CASCADE ON UPDATE CASCADE`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "AlertEpisodeInternalNoteFile" DROP CONSTRAINT "FK_f05861a3dc9e8db97fed3f674a3"`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeInternalNoteFile" DROP CONSTRAINT "FK_2944093e5e375d021e2ad8b9e82"`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeLabel" DROP CONSTRAINT "FK_1339030062467a133052b3a2038"`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeLabel" DROP CONSTRAINT "FK_3c21d8f93c62e3a7cd4e321ddc3"`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeOnCallDutyPolicy" DROP CONSTRAINT "FK_ca8d1c28cdf91b999911c78734a"`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeOnCallDutyPolicy" DROP CONSTRAINT "FK_fcbe4d926f19af8c15c6aed14e9"`,
);
await queryRunner.query(
`ALTER TABLE "AlertGroupingRuleOnCallDutyPolicy" DROP CONSTRAINT "FK_3a52cf44c3af5db42c746e9ba3d"`,
);
await queryRunner.query(
`ALTER TABLE "AlertGroupingRuleOnCallDutyPolicy" DROP CONSTRAINT "FK_cfbd2e0aa4ba9bf5613cd1f0de2"`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeFeed" DROP CONSTRAINT "FK_0a2917a7fa5853a2174a19983a4"`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeFeed" DROP CONSTRAINT "FK_e44b2c00fac4093980b1d747c39"`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeFeed" DROP CONSTRAINT "FK_74e7ff46a5bbbecc1be93bc52a1"`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeFeed" DROP CONSTRAINT "FK_3cc423c26870c099bb432912033"`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeFeed" DROP CONSTRAINT "FK_f9286b0216728194aa75cd8f298"`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeInternalNote" DROP CONSTRAINT "FK_345f9ec2dba210d9d7360f26e2a"`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeInternalNote" DROP CONSTRAINT "FK_eaf6c93cfac5dfa742337dfc8cc"`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeInternalNote" DROP CONSTRAINT "FK_ac812fdc779677403928a936c94"`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeInternalNote" DROP CONSTRAINT "FK_02013c0e8b919622f79183ee285"`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeOwnerTeam" DROP CONSTRAINT "FK_47e5937c2b8988ae5f86c33a95a"`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeOwnerTeam" DROP CONSTRAINT "FK_37cc09e358934c5be8ff7856615"`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeOwnerTeam" DROP CONSTRAINT "FK_818a93df19196a047511267990f"`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeOwnerTeam" DROP CONSTRAINT "FK_f50385bf56d90ec19e26becd1c7"`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeOwnerTeam" DROP CONSTRAINT "FK_143813e802d4b01c0ad70cf5db8"`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeOwnerUser" DROP CONSTRAINT "FK_917f125b25eabb6c5bab802d8fb"`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeOwnerUser" DROP CONSTRAINT "FK_093ca468ee1e7458065295f9432"`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeOwnerUser" DROP CONSTRAINT "FK_800af5b4ac2180cc4ade32718c9"`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeOwnerUser" DROP CONSTRAINT "FK_cc00e04c42fa14e8a6a0ee9fb6e"`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeOwnerUser" DROP CONSTRAINT "FK_41ba6bc50ab371af8ce95adf206"`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeStateTimeline" DROP CONSTRAINT "FK_c787c0bc5a12b94976f28d0a12c"`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeStateTimeline" DROP CONSTRAINT "FK_19cd5959475f1459c24491d4634"`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeStateTimeline" DROP CONSTRAINT "FK_7ba13caf6c7f089019b77e2e14b"`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeStateTimeline" DROP CONSTRAINT "FK_375d6f86d0f3e152ddf85daea38"`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeStateTimeline" DROP CONSTRAINT "FK_6ceb895e071ac6c5692bd0a286f"`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeMember" DROP CONSTRAINT "FK_dea2569c6ecb31e997b82bc592e"`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeMember" DROP CONSTRAINT "FK_1756f31f87314129a7003d727e0"`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeMember" DROP CONSTRAINT "FK_dc51318091c186f2b8122c5428d"`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeMember" DROP CONSTRAINT "FK_231127e3cfd3caee50d5852e1be"`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeMember" DROP CONSTRAINT "FK_69af045b1aad0e7792edf2660bf"`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeMember" DROP CONSTRAINT "FK_331aa5398b81014cf1b3e294ff7"`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeMember" DROP CONSTRAINT "FK_96904f3696228eb910379f23317"`,
);
await queryRunner.query(
`ALTER TABLE "Alert" DROP CONSTRAINT "FK_a8ec56304ee3dbb682be7317937"`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisode" DROP CONSTRAINT "FK_70fea6d5bce21e224230864acd1"`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisode" DROP CONSTRAINT "FK_58d264daa6693c3ee9e59a42953"`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisode" DROP CONSTRAINT "FK_89099729ddded8c275b201a7c9a"`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisode" DROP CONSTRAINT "FK_a9dbe80e8446a3082d2904f6167"`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisode" DROP CONSTRAINT "FK_0569ad819089ad1b62e7080b0b7"`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisode" DROP CONSTRAINT "FK_1707b042f7da296409fc8e6e3cd"`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisode" DROP CONSTRAINT "FK_3b98b5bde99d96631ca66c8fd82"`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisode" DROP CONSTRAINT "FK_32bad121c94b4024f2b42c56e64"`,
);
await queryRunner.query(
`ALTER TABLE "AlertGroupingRule" DROP CONSTRAINT "FK_7a91abf5593435797ac4fe99125"`,
);
await queryRunner.query(
`ALTER TABLE "AlertGroupingRule" DROP CONSTRAINT "FK_c2e138d694dfff4e3e86e6cfc6b"`,
);
await queryRunner.query(
`ALTER TABLE "AlertGroupingRule" DROP CONSTRAINT "FK_de1e396519c6da05b889a12169a"`,
);
await queryRunner.query(
`ALTER TABLE "AlertGroupingRule" DROP CONSTRAINT "FK_a361c35b33d76e4c4d97f27e113"`,
);
await queryRunner.query(
`ALTER TABLE "AlertGroupingRule" DROP CONSTRAINT "FK_cfb25f386359c3717126ecea1e2"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_a8ec56304ee3dbb682be731793"`,
);
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 "Alert" DROP COLUMN "alertEpisodeId"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_f05861a3dc9e8db97fed3f674a"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_2944093e5e375d021e2ad8b9e8"`,
);
await queryRunner.query(`DROP TABLE "AlertEpisodeInternalNoteFile"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_1339030062467a133052b3a203"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_3c21d8f93c62e3a7cd4e321ddc"`,
);
await queryRunner.query(`DROP TABLE "AlertEpisodeLabel"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_ca8d1c28cdf91b999911c78734"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_fcbe4d926f19af8c15c6aed14e"`,
);
await queryRunner.query(`DROP TABLE "AlertEpisodeOnCallDutyPolicy"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_3a52cf44c3af5db42c746e9ba3"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_cfbd2e0aa4ba9bf5613cd1f0de"`,
);
await queryRunner.query(`DROP TABLE "AlertGroupingRuleOnCallDutyPolicy"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_3cc423c26870c099bb43291203"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_f9286b0216728194aa75cd8f29"`,
);
await queryRunner.query(`DROP TABLE "AlertEpisodeFeed"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_3a0c81b3e3e570221c5253bc14"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_ac812fdc779677403928a936c9"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_02013c0e8b919622f79183ee28"`,
);
await queryRunner.query(`DROP TABLE "AlertEpisodeInternalNote"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_74c82db90ec03c884ba9da813a"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_da4932e36b0d598f4f1bd8fa5b"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_818a93df19196a047511267990"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_f50385bf56d90ec19e26becd1c"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_143813e802d4b01c0ad70cf5db"`,
);
await queryRunner.query(`DROP TABLE "AlertEpisodeOwnerTeam"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_79c03a537d5c1f4dbeb8beb355"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_642bd8ead91eb90278a5356728"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_800af5b4ac2180cc4ade32718c"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_cc00e04c42fa14e8a6a0ee9fb6"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_41ba6bc50ab371af8ce95adf20"`,
);
await queryRunner.query(`DROP TABLE "AlertEpisodeOwnerUser"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_2d3ee6df3f7a160f826732ad4f"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_69f8e12370a0309482c3ee8c72"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_e1bd75133c8b3adb6b07af3d26"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_d5b9ab34c48f20d8f25e5897b3"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_fc7f8772f2daabc44003ba6550"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_c787c0bc5a12b94976f28d0a12"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_375d6f86d0f3e152ddf85daea3"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_6ceb895e071ac6c5692bd0a286"`,
);
await queryRunner.query(`DROP TABLE "AlertEpisodeStateTimeline"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_b95fe8c9396c1e1bcf24a941fd"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_dc51318091c186f2b8122c5428"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_231127e3cfd3caee50d5852e1b"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_1b437e214ebfc5c9169fa28b73"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_69af045b1aad0e7792edf2660b"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_331aa5398b81014cf1b3e294ff"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_96904f3696228eb910379f2331"`,
);
await queryRunner.query(`DROP TABLE "AlertEpisodeMember"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_7daaa2e53985bc52154462b310"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_9a4c42288b10fe27e6cd655a0d"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_a95ab301f037e626b06af5ea7d"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_2fdffde2abde7855a92491482b"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_c0bc17e7d0d4e8647ae7ab1f5c"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_89099729ddded8c275b201a7c9"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_a9dbe80e8446a3082d2904f616"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_0569ad819089ad1b62e7080b0b"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_d5181cb8a1471f0130c771544a"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_e761f26e4824f6379f2ebefef3"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_1707b042f7da296409fc8e6e3c"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_3b98b5bde99d96631ca66c8fd8"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_c81dab13dbd1ce1711d05a897f"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_609b97c21c0eed3ac245f155d6"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_32bad121c94b4024f2b42c56e6"`,
);
await queryRunner.query(`DROP TABLE "AlertEpisode"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_de1e396519c6da05b889a12169"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_a361c35b33d76e4c4d97f27e11"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_c546da77e04aebb0249d1b1441"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_0e493339eed92199a43c5ddebe"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_b828bfbe2edbffd53213f4c2c4"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_cfb25f386359c3717126ecea1e"`,
);
await queryRunner.query(`DROP TABLE "AlertGroupingRule"`);
}
}

View File

@@ -0,0 +1,41 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class MigrationName1769125561322 implements MigrationInterface {
public name = "MigrationName1769125561322";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "AlertGroupingRule" ADD "enableResolveDelay" boolean NOT NULL DEFAULT false`,
);
await queryRunner.query(
`ALTER TABLE "AlertGroupingRule" ADD "enableReopenWindow" boolean NOT NULL DEFAULT false`,
);
await queryRunner.query(
`ALTER TABLE "AlertGroupingRule" ADD "enableInactivityTimeout" boolean NOT NULL DEFAULT false`,
);
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":[]}}'`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
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 "AlertGroupingRule" DROP COLUMN "enableInactivityTimeout"`,
);
await queryRunner.query(
`ALTER TABLE "AlertGroupingRule" DROP COLUMN "enableReopenWindow"`,
);
await queryRunner.query(
`ALTER TABLE "AlertGroupingRule" DROP COLUMN "enableResolveDelay"`,
);
}
}

View File

@@ -0,0 +1,29 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class MigrationName1769170578688 implements MigrationInterface {
public name = "MigrationName1769170578688";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "AlertGroupingRule" ADD "enableTimeWindow" boolean NOT NULL DEFAULT false`,
);
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":[]}}'`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
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 "AlertGroupingRule" DROP COLUMN "enableTimeWindow"`,
);
}
}

View File

@@ -0,0 +1,177 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class MigrationName1769172358833 implements MigrationInterface {
public name = "MigrationName1769172358833";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "AlertGroupingRuleMonitor" ("alertGroupingRuleId" uuid NOT NULL, "monitorId" uuid NOT NULL, CONSTRAINT "PK_2b6faf923556df2242a16b93fb7" PRIMARY KEY ("alertGroupingRuleId", "monitorId"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_91b40cc6d343526075015f05a9" ON "AlertGroupingRuleMonitor" ("alertGroupingRuleId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_a819b7733a603696cfcaadba35" ON "AlertGroupingRuleMonitor" ("monitorId") `,
);
await queryRunner.query(
`CREATE TABLE "AlertGroupingRuleAlertSeverity" ("alertGroupingRuleId" uuid NOT NULL, "alertSeverityId" uuid NOT NULL, CONSTRAINT "PK_5178f1e1963b8496beda60f330d" PRIMARY KEY ("alertGroupingRuleId", "alertSeverityId"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_0e7726fda2f3288da32acd33a1" ON "AlertGroupingRuleAlertSeverity" ("alertGroupingRuleId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_c1d82dcbc353eb227ab2a05984" ON "AlertGroupingRuleAlertSeverity" ("alertSeverityId") `,
);
await queryRunner.query(
`CREATE TABLE "AlertGroupingRuleAlertLabel" ("alertGroupingRuleId" uuid NOT NULL, "labelId" uuid NOT NULL, CONSTRAINT "PK_b01145322bb355d6817b9c99045" PRIMARY KEY ("alertGroupingRuleId", "labelId"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_fc256e6b1e63df61175380f97b" ON "AlertGroupingRuleAlertLabel" ("alertGroupingRuleId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_12620c464765c622c231d7c459" ON "AlertGroupingRuleAlertLabel" ("labelId") `,
);
await queryRunner.query(
`CREATE TABLE "AlertGroupingRuleMonitorLabel" ("alertGroupingRuleId" uuid NOT NULL, "labelId" uuid NOT NULL, CONSTRAINT "PK_443ced6f783a0180e4eb8e0ce80" PRIMARY KEY ("alertGroupingRuleId", "labelId"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_8bb9e0543ad8ddb67480add741" ON "AlertGroupingRuleMonitorLabel" ("alertGroupingRuleId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_38332de99fcc4c051440602bfc" ON "AlertGroupingRuleMonitorLabel" ("labelId") `,
);
await queryRunner.query(
`ALTER TABLE "AlertGroupingRule" ADD "alertTitlePattern" character varying(500)`,
);
await queryRunner.query(
`ALTER TABLE "AlertGroupingRule" ADD "alertDescriptionPattern" character varying(500)`,
);
await queryRunner.query(
`ALTER TABLE "AlertGroupingRule" ADD "monitorNamePattern" character varying(500)`,
);
await queryRunner.query(
`ALTER TABLE "AlertGroupingRule" ADD "monitorDescriptionPattern" character varying(500)`,
);
await queryRunner.query(
`ALTER TABLE "AlertGroupingRule" ADD "groupByMonitor" boolean NOT NULL DEFAULT true`,
);
await queryRunner.query(
`ALTER TABLE "AlertGroupingRule" ADD "groupBySeverity" boolean NOT NULL DEFAULT false`,
);
await queryRunner.query(
`ALTER TABLE "AlertGroupingRule" ADD "groupByAlertTitle" boolean NOT NULL DEFAULT false`,
);
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(
`ALTER TABLE "AlertGroupingRuleMonitor" ADD CONSTRAINT "FK_91b40cc6d343526075015f05a9a" FOREIGN KEY ("alertGroupingRuleId") REFERENCES "AlertGroupingRule"("_id") ON DELETE CASCADE ON UPDATE CASCADE`,
);
await queryRunner.query(
`ALTER TABLE "AlertGroupingRuleMonitor" ADD CONSTRAINT "FK_a819b7733a603696cfcaadba356" FOREIGN KEY ("monitorId") REFERENCES "Monitor"("_id") ON DELETE CASCADE ON UPDATE CASCADE`,
);
await queryRunner.query(
`ALTER TABLE "AlertGroupingRuleAlertSeverity" ADD CONSTRAINT "FK_0e7726fda2f3288da32acd33a10" FOREIGN KEY ("alertGroupingRuleId") REFERENCES "AlertGroupingRule"("_id") ON DELETE CASCADE ON UPDATE CASCADE`,
);
await queryRunner.query(
`ALTER TABLE "AlertGroupingRuleAlertSeverity" ADD CONSTRAINT "FK_c1d82dcbc353eb227ab2a059847" FOREIGN KEY ("alertSeverityId") REFERENCES "AlertSeverity"("_id") ON DELETE CASCADE ON UPDATE CASCADE`,
);
await queryRunner.query(
`ALTER TABLE "AlertGroupingRuleAlertLabel" ADD CONSTRAINT "FK_fc256e6b1e63df61175380f97b7" FOREIGN KEY ("alertGroupingRuleId") REFERENCES "AlertGroupingRule"("_id") ON DELETE CASCADE ON UPDATE CASCADE`,
);
await queryRunner.query(
`ALTER TABLE "AlertGroupingRuleAlertLabel" ADD CONSTRAINT "FK_12620c464765c622c231d7c459e" FOREIGN KEY ("labelId") REFERENCES "Label"("_id") ON DELETE CASCADE ON UPDATE CASCADE`,
);
await queryRunner.query(
`ALTER TABLE "AlertGroupingRuleMonitorLabel" ADD CONSTRAINT "FK_8bb9e0543ad8ddb67480add741f" FOREIGN KEY ("alertGroupingRuleId") REFERENCES "AlertGroupingRule"("_id") ON DELETE CASCADE ON UPDATE CASCADE`,
);
await queryRunner.query(
`ALTER TABLE "AlertGroupingRuleMonitorLabel" ADD CONSTRAINT "FK_38332de99fcc4c051440602bfc0" FOREIGN KEY ("labelId") REFERENCES "Label"("_id") ON DELETE CASCADE ON UPDATE CASCADE`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "AlertGroupingRuleMonitorLabel" DROP CONSTRAINT "FK_38332de99fcc4c051440602bfc0"`,
);
await queryRunner.query(
`ALTER TABLE "AlertGroupingRuleMonitorLabel" DROP CONSTRAINT "FK_8bb9e0543ad8ddb67480add741f"`,
);
await queryRunner.query(
`ALTER TABLE "AlertGroupingRuleAlertLabel" DROP CONSTRAINT "FK_12620c464765c622c231d7c459e"`,
);
await queryRunner.query(
`ALTER TABLE "AlertGroupingRuleAlertLabel" DROP CONSTRAINT "FK_fc256e6b1e63df61175380f97b7"`,
);
await queryRunner.query(
`ALTER TABLE "AlertGroupingRuleAlertSeverity" DROP CONSTRAINT "FK_c1d82dcbc353eb227ab2a059847"`,
);
await queryRunner.query(
`ALTER TABLE "AlertGroupingRuleAlertSeverity" DROP CONSTRAINT "FK_0e7726fda2f3288da32acd33a10"`,
);
await queryRunner.query(
`ALTER TABLE "AlertGroupingRuleMonitor" DROP CONSTRAINT "FK_a819b7733a603696cfcaadba356"`,
);
await queryRunner.query(
`ALTER TABLE "AlertGroupingRuleMonitor" DROP CONSTRAINT "FK_91b40cc6d343526075015f05a9a"`,
);
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 "AlertGroupingRule" DROP COLUMN "groupByAlertTitle"`,
);
await queryRunner.query(
`ALTER TABLE "AlertGroupingRule" DROP COLUMN "groupBySeverity"`,
);
await queryRunner.query(
`ALTER TABLE "AlertGroupingRule" DROP COLUMN "groupByMonitor"`,
);
await queryRunner.query(
`ALTER TABLE "AlertGroupingRule" DROP COLUMN "monitorDescriptionPattern"`,
);
await queryRunner.query(
`ALTER TABLE "AlertGroupingRule" DROP COLUMN "monitorNamePattern"`,
);
await queryRunner.query(
`ALTER TABLE "AlertGroupingRule" DROP COLUMN "alertDescriptionPattern"`,
);
await queryRunner.query(
`ALTER TABLE "AlertGroupingRule" DROP COLUMN "alertTitlePattern"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_38332de99fcc4c051440602bfc"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_8bb9e0543ad8ddb67480add741"`,
);
await queryRunner.query(`DROP TABLE "AlertGroupingRuleMonitorLabel"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_12620c464765c622c231d7c459"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_fc256e6b1e63df61175380f97b"`,
);
await queryRunner.query(`DROP TABLE "AlertGroupingRuleAlertLabel"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_c1d82dcbc353eb227ab2a05984"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_0e7726fda2f3288da32acd33a1"`,
);
await queryRunner.query(`DROP TABLE "AlertGroupingRuleAlertSeverity"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_a819b7733a603696cfcaadba35"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_91b40cc6d343526075015f05a9"`,
);
await queryRunner.query(`DROP TABLE "AlertGroupingRuleMonitor"`);
}
}

View File

@@ -0,0 +1,71 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class MigrationName1769176450526 implements MigrationInterface {
public name = "MigrationName1769176450526";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "OnCallDutyPolicyExecutionLog" ADD "triggeredByAlertEpisodeId" uuid`,
);
await queryRunner.query(
`ALTER TABLE "WorkspaceNotificationLog" ADD "alertEpisodeId" uuid`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisodeInternalNote" ADD "postedFromSlackMessageId" character varying`,
);
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_71a74e4141a9de6626e3710f2d" ON "OnCallDutyPolicyExecutionLog" ("triggeredByAlertEpisodeId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_4eee6dbdf00be2aec7c6cdbcb3" ON "WorkspaceNotificationLog" ("alertEpisodeId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_26bd01eb674e2e659fe6409423" ON "AlertEpisodeInternalNote" ("postedFromSlackMessageId") `,
);
await queryRunner.query(
`ALTER TABLE "OnCallDutyPolicyExecutionLog" ADD CONSTRAINT "FK_71a74e4141a9de6626e3710f2d7" FOREIGN KEY ("triggeredByAlertEpisodeId") REFERENCES "AlertEpisode"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "WorkspaceNotificationLog" ADD CONSTRAINT "FK_4eee6dbdf00be2aec7c6cdbcb33" FOREIGN KEY ("alertEpisodeId") REFERENCES "AlertEpisode"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "WorkspaceNotificationLog" DROP CONSTRAINT "FK_4eee6dbdf00be2aec7c6cdbcb33"`,
);
await queryRunner.query(
`ALTER TABLE "OnCallDutyPolicyExecutionLog" DROP CONSTRAINT "FK_71a74e4141a9de6626e3710f2d7"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_26bd01eb674e2e659fe6409423"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_4eee6dbdf00be2aec7c6cdbcb3"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_71a74e4141a9de6626e3710f2d"`,
);
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 "AlertEpisodeInternalNote" DROP COLUMN "postedFromSlackMessageId"`,
);
await queryRunner.query(
`ALTER TABLE "WorkspaceNotificationLog" DROP COLUMN "alertEpisodeId"`,
);
await queryRunner.query(
`ALTER TABLE "OnCallDutyPolicyExecutionLog" DROP COLUMN "triggeredByAlertEpisodeId"`,
);
}
}

View File

@@ -0,0 +1,35 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class MigrationName1769190495840 implements MigrationInterface {
public name = "MigrationName1769190495840";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "AlertEpisode" ADD "remediationNotes" text`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisode" ADD "postUpdatesToWorkspaceChannels" jsonb`,
);
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":[]}}'`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
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 "AlertEpisode" DROP COLUMN "postUpdatesToWorkspaceChannels"`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisode" DROP COLUMN "remediationNotes"`,
);
}
}

View File

@@ -0,0 +1,29 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class MigrationName1769199303656 implements MigrationInterface {
public name = "MigrationName1769199303656";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "AlertGroupingRule" ADD "groupByService" boolean NOT NULL DEFAULT false`,
);
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":[]}}'`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
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 "AlertGroupingRule" DROP COLUMN "groupByService"`,
);
}
}

View File

@@ -0,0 +1,29 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class MigrationName1769202898645 implements MigrationInterface {
public name = "MigrationName1769202898645";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "AlertGroupingRule" ADD "episodeDescriptionTemplate" character varying`,
);
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":[]}}'`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
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 "AlertGroupingRule" DROP COLUMN "episodeDescriptionTemplate"`,
);
}
}

View File

@@ -0,0 +1,35 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class MigrationName1769428619414 implements MigrationInterface {
public name = "MigrationName1769428619414";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "AlertEpisode" ADD "titleTemplate" character varying(100)`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisode" ADD "descriptionTemplate" character varying`,
);
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":[]}}'`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
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 "AlertEpisode" DROP COLUMN "descriptionTemplate"`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisode" DROP COLUMN "titleTemplate"`,
);
}
}

View File

@@ -0,0 +1,47 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class MigrationName1769428821686 implements MigrationInterface {
public name = "MigrationName1769428821686";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "AlertGroupingRule" DROP COLUMN "episodeTitleTemplate"`,
);
await queryRunner.query(
`ALTER TABLE "AlertGroupingRule" ADD "episodeTitleTemplate" character varying`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisode" DROP COLUMN "titleTemplate"`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisode" ADD "titleTemplate" character varying`,
);
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":[]}}'`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
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 "AlertEpisode" DROP COLUMN "titleTemplate"`,
);
await queryRunner.query(
`ALTER TABLE "AlertEpisode" ADD "titleTemplate" character varying(100)`,
);
await queryRunner.query(
`ALTER TABLE "AlertGroupingRule" DROP COLUMN "episodeTitleTemplate"`,
);
await queryRunner.query(
`ALTER TABLE "AlertGroupingRule" ADD "episodeTitleTemplate" character varying(100)`,
);
}
}

View File

@@ -223,7 +223,17 @@ import { AddIncomingEmailMonitor1768335589018 } from "./1768335589018-AddIncomin
import { MigrationName1768422356713 } from "./1768422356713-MigrationName";
import { MigrationName1768583966447 } from "./1768583966447-MigrationName";
import { MigrationName1768825402472 } from "./1768825402472-MigrationName";
import { MigrationName1768938069147 } from "./1768938069147-MigrationName";
import { MigrationName1769125561322 } from "./1769125561322-MigrationName";
import { MigrationName1769169355244 } from "./1769169355244-MigrationName";
import { MigrationName1769170578688 } from "./1769170578688-MigrationName";
import { MigrationName1769172358833 } from "./1769172358833-MigrationName";
import { MigrationName1769176450526 } from "./1769176450526-MigrationName";
import { MigrationName1769190495840 } from "./1769190495840-MigrationName";
import { MigrationName1769199303656 } from "./1769199303656-MigrationName";
import { MigrationName1769202898645 } from "./1769202898645-MigrationName";
import { MigrationName1769428619414 } from "./1769428619414-MigrationName";
import { MigrationName1769428821686 } from "./1769428821686-MigrationName";
export default [
InitialMigration,
@@ -452,4 +462,14 @@ export default [
MigrationName1768583966447,
MigrationName1768825402472,
MigrationName1769169355244,
MigrationName1768938069147,
MigrationName1769125561322,
MigrationName1769170578688,
MigrationName1769172358833,
MigrationName1769176450526,
MigrationName1769190495840,
MigrationName1769199303656,
MigrationName1769202898645,
MigrationName1769428619414,
MigrationName1769428821686,
];

View File

@@ -0,0 +1,94 @@
import { Blue500 } from "../../Types/BrandColors";
import Color from "../../Types/Color";
import OneUptimeDate from "../../Types/Date";
import BadDataException from "../../Types/Exception/BadDataException";
import ObjectID from "../../Types/ObjectID";
import { IsBillingEnabled } from "../EnvironmentConfig";
import logger from "../Utils/Logger";
import DatabaseService from "./DatabaseService";
import Model, {
AlertEpisodeFeedEventType,
} from "../../Models/DatabaseModels/AlertEpisodeFeed";
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
export class Service extends DatabaseService<Model> {
public constructor() {
super(Model);
if (IsBillingEnabled) {
this.hardDeleteItemsOlderThanInDays("createdAt", 3 * 365); // 3 years
}
}
@CaptureSpan()
public async createAlertEpisodeFeedItem(data: {
alertEpisodeId: ObjectID;
feedInfoInMarkdown: string;
alertEpisodeFeedEventType: AlertEpisodeFeedEventType;
projectId: ObjectID;
moreInformationInMarkdown?: string | undefined;
displayColor?: Color | undefined;
userId?: ObjectID | undefined;
postedAt?: Date | undefined;
}): Promise<void> {
try {
if (!data.alertEpisodeId) {
throw new BadDataException("Alert Episode ID is required");
}
if (!data.feedInfoInMarkdown) {
throw new BadDataException("Log in markdown is required");
}
if (!data.alertEpisodeFeedEventType) {
throw new BadDataException("Alert episode log event is required");
}
if (!data.projectId) {
throw new BadDataException("Project ID is required");
}
const alertEpisodeFeed: Model = new Model();
if (!data.displayColor) {
data.displayColor = Blue500;
}
if (data.userId) {
alertEpisodeFeed.userId = data.userId;
}
alertEpisodeFeed.displayColor = data.displayColor;
alertEpisodeFeed.alertEpisodeId = data.alertEpisodeId;
alertEpisodeFeed.feedInfoInMarkdown = data.feedInfoInMarkdown;
alertEpisodeFeed.alertEpisodeFeedEventType =
data.alertEpisodeFeedEventType;
alertEpisodeFeed.projectId = data.projectId;
if (!data.postedAt) {
alertEpisodeFeed.postedAt = OneUptimeDate.getCurrentDate();
} else {
alertEpisodeFeed.postedAt = data.postedAt;
}
if (data.moreInformationInMarkdown) {
alertEpisodeFeed.moreInformationInMarkdown =
data.moreInformationInMarkdown;
}
await this.create({
data: alertEpisodeFeed,
props: {
isRoot: true,
},
});
} catch (error) {
logger.error("AlertEpisodeFeedService.createAlertEpisodeFeedItem");
logger.error(error);
// we dont want to throw the error here, as this is a non-critical operation
}
}
}
export default new Service();

View File

@@ -0,0 +1,71 @@
import ObjectID from "../../Types/ObjectID";
import DatabaseService from "./DatabaseService";
import Model from "../../Models/DatabaseModels/AlertEpisodeInternalNote";
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
import File from "../../Models/DatabaseModels/File";
export class Service extends DatabaseService<Model> {
public constructor() {
super(Model);
}
@CaptureSpan()
public async addNote(data: {
userId: ObjectID;
alertEpisodeId: ObjectID;
projectId: ObjectID;
note: string;
attachmentFileIds?: Array<ObjectID>;
postedFromSlackMessageId?: string;
}): Promise<Model> {
const internalNote: Model = new Model();
internalNote.createdByUserId = data.userId;
internalNote.alertEpisodeId = data.alertEpisodeId;
internalNote.projectId = data.projectId;
internalNote.note = data.note;
if (data.postedFromSlackMessageId) {
internalNote.postedFromSlackMessageId = data.postedFromSlackMessageId;
}
if (data.attachmentFileIds && data.attachmentFileIds.length > 0) {
internalNote.attachments = data.attachmentFileIds.map(
(fileId: ObjectID) => {
const file: File = new File();
file.id = fileId;
return file;
},
);
}
return this.create({
data: internalNote,
props: {
isRoot: true,
},
});
}
@CaptureSpan()
public async hasNoteFromSlackMessage(data: {
alertEpisodeId: ObjectID;
postedFromSlackMessageId: string;
}): Promise<boolean> {
const existingNote: Model | null = await this.findOneBy({
query: {
alertEpisodeId: data.alertEpisodeId,
postedFromSlackMessageId: data.postedFromSlackMessageId,
},
select: {
_id: true,
},
props: {
isRoot: true,
},
});
return existingNote !== null;
}
}
export default new Service();

View File

@@ -0,0 +1,267 @@
import CreateBy from "../Types/Database/CreateBy";
import DeleteBy from "../Types/Database/DeleteBy";
import { OnCreate, OnDelete } from "../Types/Database/Hooks";
import DatabaseService from "./DatabaseService";
import BadDataException from "../../Types/Exception/BadDataException";
import ObjectID from "../../Types/ObjectID";
import Model from "../../Models/DatabaseModels/AlertEpisodeMember";
import Alert from "../../Models/DatabaseModels/Alert";
import { IsBillingEnabled } from "../EnvironmentConfig";
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
import logger from "../Utils/Logger";
import AlertEpisodeFeedService from "./AlertEpisodeFeedService";
import { AlertEpisodeFeedEventType } from "../../Models/DatabaseModels/AlertEpisodeFeed";
import { Yellow500, Green500 } from "../../Types/BrandColors";
import OneUptimeDate from "../../Types/Date";
import AlertService from "./AlertService";
import AlertEpisodeService from "./AlertEpisodeService";
export class Service extends DatabaseService<Model> {
public constructor() {
super(Model);
if (IsBillingEnabled) {
this.hardDeleteItemsOlderThanInDays("createdAt", 3 * 365); // 3 years
}
}
@CaptureSpan()
protected override async onBeforeCreate(
createBy: CreateBy<Model>,
): Promise<OnCreate<Model>> {
if (!createBy.data.alertEpisodeId) {
throw new BadDataException("alertEpisodeId is required");
}
if (!createBy.data.alertId) {
throw new BadDataException("alertId is required");
}
// Check if this alert is already in the episode
const existingMember: Model | null = await this.findOneBy({
query: {
alertEpisodeId: createBy.data.alertEpisodeId,
alertId: createBy.data.alertId,
},
props: {
isRoot: true,
},
select: {
_id: true,
},
});
if (existingMember) {
throw new BadDataException("Alert is already a member of this episode");
}
// Set addedAt if not provided
if (!createBy.data.addedAt) {
createBy.data.addedAt = OneUptimeDate.getCurrentDate();
}
return { createBy, carryForward: null };
}
@CaptureSpan()
protected override async onCreateSuccess(
_onCreate: OnCreate<Model>,
createdItem: Model,
): Promise<Model> {
if (!createdItem.alertEpisodeId) {
throw new BadDataException("alertEpisodeId is required");
}
if (!createdItem.alertId) {
throw new BadDataException("alertId is required");
}
if (!createdItem.projectId) {
throw new BadDataException("projectId is required");
}
// Update alert's episode reference
await AlertService.updateOneById({
id: createdItem.alertId,
data: {
alertEpisodeId: createdItem.alertEpisodeId,
},
props: {
isRoot: true,
},
});
// Update episode's alertCount and lastAlertAddedAt
Promise.resolve()
.then(async () => {
try {
await AlertEpisodeService.updateAlertCount(
createdItem.alertEpisodeId!,
);
await AlertEpisodeService.updateLastAlertAddedAt(
createdItem.alertEpisodeId!,
);
} catch (error) {
logger.error(
`Error updating episode counts in AlertEpisodeMemberService.onCreateSuccess: ${error}`,
);
}
})
.catch((error: Error) => {
logger.error(
`Critical error in AlertEpisodeMemberService.onCreateSuccess: ${error}`,
);
});
// Get alert details for feed
const alert: Alert | null = await AlertService.findOneById({
id: createdItem.alertId,
select: {
alertNumber: true,
title: true,
},
props: {
isRoot: true,
},
});
// Create feed item
await AlertEpisodeFeedService.createAlertEpisodeFeedItem({
alertEpisodeId: createdItem.alertEpisodeId,
projectId: createdItem.projectId,
alertEpisodeFeedEventType: AlertEpisodeFeedEventType.AlertAdded,
displayColor: Yellow500,
feedInfoInMarkdown: `**Alert #${alert?.alertNumber || "N/A"}** added to episode: ${alert?.title || "No title"}`,
userId: createdItem.addedByUserId || undefined,
});
return createdItem;
}
@CaptureSpan()
protected override async onBeforeDelete(
deleteBy: DeleteBy<Model>,
): Promise<OnDelete<Model>> {
// Get the member records before deletion
const membersToDelete: Model[] = await this.findBy({
query: deleteBy.query,
props: {
isRoot: true,
},
select: {
alertEpisodeId: true,
alertId: true,
projectId: true,
},
limit: 100,
skip: 0,
});
return {
deleteBy,
carryForward: membersToDelete,
};
}
@CaptureSpan()
protected override async onDeleteSuccess(
onDelete: OnDelete<Model>,
_itemIdsBeforeDelete: ObjectID[],
): Promise<OnDelete<Model>> {
const membersDeleted: Model[] = onDelete.carryForward as Model[];
if (membersDeleted && membersDeleted.length > 0) {
for (const member of membersDeleted) {
if (member.alertId) {
// Clear the episode reference from the alert
await AlertService.updateOneById({
id: member.alertId,
data: {
alertEpisodeId: undefined as any,
},
props: {
isRoot: true,
},
});
// Get alert details for feed
const alert: Alert | null = await AlertService.findOneById({
id: member.alertId,
select: {
alertNumber: true,
title: true,
},
props: {
isRoot: true,
},
});
// Create feed item for removal
if (member.alertEpisodeId && member.projectId) {
await AlertEpisodeFeedService.createAlertEpisodeFeedItem({
alertEpisodeId: member.alertEpisodeId,
projectId: member.projectId,
alertEpisodeFeedEventType: AlertEpisodeFeedEventType.AlertRemoved,
displayColor: Green500,
feedInfoInMarkdown: `**Alert #${alert?.alertNumber || "N/A"}** removed from episode: ${alert?.title || "No title"}`,
});
}
}
if (member.alertEpisodeId) {
// Update episode's alertCount
await AlertEpisodeService.updateAlertCount(member.alertEpisodeId);
}
}
}
return onDelete;
}
@CaptureSpan()
public async getAlertsInEpisode(episodeId: ObjectID): Promise<ObjectID[]> {
const members: Model[] = await this.findBy({
query: {
alertEpisodeId: episodeId,
},
props: {
isRoot: true,
},
select: {
alertId: true,
},
limit: 1000,
skip: 0,
});
return members
.filter((m: Model) => {
return m.alertId;
})
.map((m: Model) => {
return m.alertId!;
});
}
@CaptureSpan()
public async isAlertInEpisode(
alertId: ObjectID,
episodeId: ObjectID,
): Promise<boolean> {
const member: Model | null = await this.findOneBy({
query: {
alertId: alertId,
alertEpisodeId: episodeId,
},
props: {
isRoot: true,
},
select: {
_id: true,
},
});
return member !== null;
}
}
export default new Service();

View File

@@ -0,0 +1,10 @@
import DatabaseService from "./DatabaseService";
import Model from "../../Models/DatabaseModels/AlertEpisodeOwnerTeam";
export class Service extends DatabaseService<Model> {
public constructor() {
super(Model);
}
}
export default new Service();

View File

@@ -0,0 +1,10 @@
import DatabaseService from "./DatabaseService";
import Model from "../../Models/DatabaseModels/AlertEpisodeOwnerUser";
export class Service extends DatabaseService<Model> {
public constructor() {
super(Model);
}
}
export default new Service();

View File

@@ -0,0 +1,953 @@
import CreateBy from "../Types/Database/CreateBy";
import { OnCreate, OnUpdate } from "../Types/Database/Hooks";
import DatabaseService from "./DatabaseService";
import AlertStateService from "./AlertStateService";
import BadDataException from "../../Types/Exception/BadDataException";
import ObjectID from "../../Types/ObjectID";
import PositiveNumber from "../../Types/PositiveNumber";
import Model from "../../Models/DatabaseModels/AlertEpisode";
import AlertState from "../../Models/DatabaseModels/AlertState";
import AlertSeverity from "../../Models/DatabaseModels/AlertSeverity";
import SortOrder from "../../Types/BaseDatabase/SortOrder";
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
import logger from "../Utils/Logger";
import DatabaseCommonInteractionProps from "../../Types/BaseDatabase/DatabaseCommonInteractionProps";
import AlertEpisodeStateTimeline from "../../Models/DatabaseModels/AlertEpisodeStateTimeline";
import AlertEpisodeStateTimelineService from "./AlertEpisodeStateTimelineService";
import { IsBillingEnabled } from "../EnvironmentConfig";
import OneUptimeDate from "../../Types/Date";
import AlertEpisodeFeedService from "./AlertEpisodeFeedService";
import { AlertEpisodeFeedEventType } from "../../Models/DatabaseModels/AlertEpisodeFeed";
import { Red500, Green500, Yellow500 } from "../../Types/BrandColors";
import URL from "../../Types/API/URL";
import DatabaseConfig from "../DatabaseConfig";
import AlertSeverityService from "./AlertSeverityService";
import AlertEpisodeMemberService from "./AlertEpisodeMemberService";
import AlertEpisodeOwnerUserService from "./AlertEpisodeOwnerUserService";
import AlertEpisodeOwnerTeamService from "./AlertEpisodeOwnerTeamService";
import TeamMemberService from "./TeamMemberService";
import AlertEpisodeOwnerUser from "../../Models/DatabaseModels/AlertEpisodeOwnerUser";
import AlertEpisodeOwnerTeam from "../../Models/DatabaseModels/AlertEpisodeOwnerTeam";
import AlertEpisodeMember from "../../Models/DatabaseModels/AlertEpisodeMember";
import User from "../../Models/DatabaseModels/User";
import { LIMIT_PER_PROJECT } from "../../Types/Database/LimitMax";
import NotificationRuleWorkspaceChannel from "../../Types/Workspace/NotificationRules/NotificationRuleWorkspaceChannel";
import WorkspaceType from "../../Types/Workspace/WorkspaceType";
import Typeof from "../../Types/Typeof";
import AlertService from "./AlertService";
export class Service extends DatabaseService<Model> {
public constructor() {
super(Model);
if (IsBillingEnabled) {
this.hardDeleteItemsOlderThanInDays("createdAt", 3 * 365); // 3 years
}
}
@CaptureSpan()
public async getExistingEpisodeNumberForProject(data: {
projectId: ObjectID;
}): Promise<number> {
const lastEpisode: Model | null = await this.findOneBy({
query: {
projectId: data.projectId,
},
select: {
episodeNumber: true,
},
sort: {
createdAt: SortOrder.Descending,
},
props: {
isRoot: true,
},
});
if (!lastEpisode) {
return 0;
}
return lastEpisode.episodeNumber ? Number(lastEpisode.episodeNumber) : 0;
}
@CaptureSpan()
protected override async onBeforeCreate(
createBy: CreateBy<Model>,
): Promise<OnCreate<Model>> {
if (!createBy.props.tenantId && !createBy.props.isRoot) {
throw new BadDataException("ProjectId required to create alert episode.");
}
const projectId: ObjectID =
createBy.props.tenantId || createBy.data.projectId!;
// Get the created state for episodes
const alertState: AlertState | null = await AlertStateService.findOneBy({
query: {
projectId: projectId,
isCreatedState: true,
},
select: {
_id: true,
},
props: {
isRoot: true,
},
});
if (!alertState || !alertState.id) {
throw new BadDataException(
"Created alert state not found for this project. Please add created alert state from settings.",
);
}
createBy.data.currentAlertStateId = alertState.id;
// Auto-generate episode number
const episodeNumberForThisEpisode: number =
(await this.getExistingEpisodeNumberForProject({
projectId: projectId,
})) + 1;
createBy.data.episodeNumber = episodeNumberForThisEpisode;
// Set initial lastAlertAddedAt
if (!createBy.data.lastAlertAddedAt) {
createBy.data.lastAlertAddedAt = OneUptimeDate.getCurrentDate();
}
return { createBy, carryForward: null };
}
@CaptureSpan()
protected override async onCreateSuccess(
_onCreate: OnCreate<Model>,
createdItem: Model,
): Promise<Model> {
if (!createdItem.projectId) {
throw new BadDataException("projectId is required");
}
if (!createdItem.id) {
throw new BadDataException("id is required");
}
if (!createdItem.currentAlertStateId) {
throw new BadDataException("currentAlertStateId is required");
}
// Create initial state timeline entry
Promise.resolve()
.then(async () => {
try {
await this.changeEpisodeState({
projectId: createdItem.projectId!,
episodeId: createdItem.id!,
alertStateId: createdItem.currentAlertStateId!,
notifyOwners: false,
rootCause: undefined,
props: {
isRoot: true,
},
});
} catch (error) {
logger.error(
`Handle episode state change failed in AlertEpisodeService.onCreateSuccess: ${error}`,
);
}
})
.then(async () => {
try {
await this.createEpisodeCreatedFeed(createdItem);
} catch (error) {
logger.error(
`Create episode feed failed in AlertEpisodeService.onCreateSuccess: ${error}`,
);
}
})
.catch((error: Error) => {
logger.error(
`Critical error in AlertEpisodeService.onCreateSuccess: ${error}`,
);
});
return createdItem;
}
@CaptureSpan()
private async createEpisodeCreatedFeed(episode: Model): Promise<void> {
if (!episode.id || !episode.projectId) {
return;
}
let feedInfoInMarkdown: string = `#### Episode ${episode.episodeNumber?.toString()} Created
**${episode.title || "No title provided."}**
`;
if (episode.description) {
feedInfoInMarkdown += `${episode.description}\n\n`;
}
if (episode.isManuallyCreated) {
feedInfoInMarkdown += `This episode was manually created.\n\n`;
}
await AlertEpisodeFeedService.createAlertEpisodeFeedItem({
alertEpisodeId: episode.id,
projectId: episode.projectId,
alertEpisodeFeedEventType: AlertEpisodeFeedEventType.EpisodeCreated,
displayColor: Red500,
feedInfoInMarkdown: feedInfoInMarkdown,
userId: episode.createdByUserId || undefined,
});
}
@CaptureSpan()
public async changeEpisodeState(data: {
projectId: ObjectID;
episodeId: ObjectID;
alertStateId: ObjectID;
notifyOwners: boolean;
rootCause: string | undefined;
props: DatabaseCommonInteractionProps;
cascadeToAlerts?: boolean;
}): Promise<void> {
const {
projectId,
episodeId,
alertStateId,
notifyOwners,
rootCause,
props,
cascadeToAlerts,
} = data;
// Get last episode state timeline
const lastEpisodeStateTimeline: AlertEpisodeStateTimeline | null =
await AlertEpisodeStateTimelineService.findOneBy({
query: {
alertEpisodeId: episodeId,
projectId: projectId,
},
select: {
_id: true,
alertStateId: true,
},
sort: {
createdAt: SortOrder.Descending,
},
props: {
isRoot: true,
},
});
if (
lastEpisodeStateTimeline &&
lastEpisodeStateTimeline.alertStateId &&
lastEpisodeStateTimeline.alertStateId.toString() ===
alertStateId.toString()
) {
return;
}
const stateTimeline: AlertEpisodeStateTimeline =
new AlertEpisodeStateTimeline();
stateTimeline.alertEpisodeId = episodeId;
stateTimeline.alertStateId = alertStateId;
stateTimeline.projectId = projectId;
stateTimeline.isOwnerNotified = !notifyOwners;
if (rootCause) {
stateTimeline.rootCause = rootCause;
}
await AlertEpisodeStateTimelineService.create({
data: stateTimeline,
props: props || {},
});
// Update resolved timestamp if this is a resolved state
const alertState: AlertState | null = await AlertStateService.findOneById({
id: alertStateId,
select: {
isResolvedState: true,
},
props: {
isRoot: true,
},
});
if (alertState?.isResolvedState) {
await this.updateOneById({
id: episodeId,
data: {
resolvedAt: OneUptimeDate.getCurrentDate(),
},
props: {
isRoot: true,
},
});
}
// Cascade state change to all member alerts if requested
if (cascadeToAlerts) {
await this.cascadeStateToMemberAlerts({
projectId,
episodeId,
alertStateId,
props,
});
}
}
@CaptureSpan()
private async cascadeStateToMemberAlerts(data: {
projectId: ObjectID;
episodeId: ObjectID;
alertStateId: ObjectID;
props: DatabaseCommonInteractionProps;
}): Promise<void> {
const { projectId, episodeId, alertStateId, props } = data;
// Get all member alerts for this episode
const members: Array<AlertEpisodeMember> =
await AlertEpisodeMemberService.findBy({
query: {
alertEpisodeId: episodeId,
projectId: projectId,
},
select: {
alertId: true,
},
props: {
isRoot: true,
},
limit: LIMIT_PER_PROJECT,
skip: 0,
});
if (members.length === 0) {
return;
}
// Update state for each member alert
for (const member of members) {
if (!member.alertId) {
continue;
}
try {
await AlertService.changeAlertState({
projectId: projectId,
alertId: member.alertId,
alertStateId: alertStateId,
notifyOwners: false, // Don't send notifications for cascaded state changes
rootCause: "State changed by episode state cascade.",
stateChangeLog: undefined,
props: props,
});
} catch (error) {
logger.error(
`Failed to cascade state change to alert ${member.alertId.toString()}: ${error}`,
);
}
}
}
@CaptureSpan()
public async acknowledgeEpisode(
episodeId: ObjectID,
acknowledgedByUserId: ObjectID,
cascadeToAlerts: boolean = true,
): Promise<void> {
const episode: Model | null = await this.findOneById({
id: episodeId,
select: {
projectId: true,
},
props: {
isRoot: true,
},
});
if (!episode || !episode.projectId) {
throw new BadDataException("Episode not found.");
}
const alertState: AlertState | null = await AlertStateService.findOneBy({
query: {
projectId: episode.projectId,
isAcknowledgedState: true,
},
select: {
_id: true,
},
props: {
isRoot: true,
},
});
if (!alertState || !alertState.id) {
throw new BadDataException(
"Acknowledged state not found for this project.",
);
}
await this.changeEpisodeState({
projectId: episode.projectId,
episodeId: episodeId,
alertStateId: alertState.id,
notifyOwners: true,
rootCause: undefined,
props: {
isRoot: true,
userId: acknowledgedByUserId,
},
cascadeToAlerts: cascadeToAlerts,
});
// Create feed for episode acknowledged
let feedMessage: string = "Episode has been acknowledged.";
if (cascadeToAlerts) {
feedMessage += " All member alerts have also been acknowledged.";
}
await AlertEpisodeFeedService.createAlertEpisodeFeedItem({
alertEpisodeId: episodeId,
projectId: episode.projectId,
alertEpisodeFeedEventType: AlertEpisodeFeedEventType.EpisodeStateChanged,
displayColor: Yellow500,
feedInfoInMarkdown: feedMessage,
userId: acknowledgedByUserId || undefined,
});
}
@CaptureSpan()
public async resolveEpisode(
episodeId: ObjectID,
resolvedByUserId: ObjectID,
cascadeToAlerts: boolean = true,
): Promise<void> {
const episode: Model | null = await this.findOneById({
id: episodeId,
select: {
projectId: true,
},
props: {
isRoot: true,
},
});
if (!episode || !episode.projectId) {
throw new BadDataException("Episode not found.");
}
const alertState: AlertState | null = await AlertStateService.findOneBy({
query: {
projectId: episode.projectId,
isResolvedState: true,
},
select: {
_id: true,
},
props: {
isRoot: true,
},
});
if (!alertState || !alertState.id) {
throw new BadDataException("Resolved state not found for this project.");
}
await this.changeEpisodeState({
projectId: episode.projectId,
episodeId: episodeId,
alertStateId: alertState.id,
notifyOwners: true,
rootCause: undefined,
props: {
isRoot: true,
userId: resolvedByUserId,
},
cascadeToAlerts: cascadeToAlerts,
});
// Create feed for episode resolved
let feedMessage: string = "Episode has been resolved.";
if (cascadeToAlerts) {
feedMessage += " All member alerts have also been resolved.";
}
await AlertEpisodeFeedService.createAlertEpisodeFeedItem({
alertEpisodeId: episodeId,
projectId: episode.projectId,
alertEpisodeFeedEventType: AlertEpisodeFeedEventType.EpisodeStateChanged,
displayColor: Green500,
feedInfoInMarkdown: feedMessage,
userId: resolvedByUserId || undefined,
});
}
@CaptureSpan()
public async updateEpisodeSeverity(data: {
episodeId: ObjectID;
severityId: ObjectID;
onlyIfHigher: boolean;
}): Promise<void> {
const { episodeId, severityId, onlyIfHigher } = data;
const episode: Model | null = await this.findOneById({
id: episodeId,
select: {
alertSeverityId: true,
alertSeverity: {
order: true,
},
},
props: {
isRoot: true,
},
});
if (!episode) {
throw new BadDataException("Episode not found.");
}
if (onlyIfHigher && episode.alertSeverity?.order !== undefined) {
// Get the new severity to check its order
const newSeverity: AlertSeverity | null =
await AlertSeverityService.findOneById({
id: severityId,
select: {
order: true,
},
props: {
isRoot: true,
},
});
if (newSeverity && newSeverity.order !== undefined) {
// Lower order = higher severity
if (newSeverity.order >= episode.alertSeverity.order) {
return; // Don't update if new severity is not higher
}
}
}
await this.updateOneById({
id: episodeId,
data: {
alertSeverityId: severityId,
},
props: {
isRoot: true,
},
});
}
@CaptureSpan()
public async updateAlertCount(episodeId: ObjectID): Promise<void> {
const count: PositiveNumber = await AlertEpisodeMemberService.countBy({
query: {
alertEpisodeId: episodeId,
},
props: {
isRoot: true,
},
});
const alertCount: number = count.toNumber();
// Get the episode to check for templates
const episode: Model | null = await this.findOneById({
id: episodeId,
select: {
titleTemplate: true,
descriptionTemplate: true,
title: true,
description: true,
},
props: {
isRoot: true,
},
});
const updateData: {
alertCount: number;
title?: string;
description?: string;
} = {
alertCount: alertCount,
};
// Update title with dynamic variables if template exists
if (episode?.titleTemplate) {
updateData.title = this.renderTemplateWithDynamicValues(
episode.titleTemplate,
alertCount,
);
}
// Update description with dynamic variables if template exists
if (episode?.descriptionTemplate) {
updateData.description = this.renderTemplateWithDynamicValues(
episode.descriptionTemplate,
alertCount,
);
}
await this.updateOneById({
id: episodeId,
data: updateData,
props: {
isRoot: true,
},
});
}
private renderTemplateWithDynamicValues(
template: string,
alertCount: number,
): string {
let result: string = template;
// Replace dynamic variables
result = result.replace(/\{\{alertCount\}\}/g, alertCount.toString());
return result;
}
@CaptureSpan()
public async updateLastAlertAddedAt(episodeId: ObjectID): Promise<void> {
await this.updateOneById({
id: episodeId,
data: {
lastAlertAddedAt: OneUptimeDate.getCurrentDate(),
},
props: {
isRoot: true,
},
});
}
@CaptureSpan()
public async isEpisodeResolved(episodeId: ObjectID): Promise<boolean> {
const episode: Model | null = await this.findOneById({
id: episodeId,
select: {
projectId: true,
currentAlertState: {
order: true,
},
},
props: {
isRoot: true,
},
});
if (!episode || !episode.projectId) {
throw new BadDataException("Episode not found.");
}
const resolvedState: AlertState =
await AlertStateService.getResolvedAlertState({
projectId: episode.projectId,
props: {
isRoot: true,
},
});
const currentOrder: number = episode.currentAlertState?.order || 0;
const resolvedOrder: number = resolvedState.order || 0;
return currentOrder >= resolvedOrder;
}
@CaptureSpan()
public async isEpisodeAcknowledged(data: {
episodeId: ObjectID;
}): Promise<boolean> {
const episode: Model | null = await this.findOneById({
id: data.episodeId,
select: {
projectId: true,
currentAlertState: {
order: true,
},
},
props: {
isRoot: true,
},
});
if (!episode || !episode.projectId) {
throw new BadDataException("Episode not found.");
}
const acknowledgedState: AlertState =
await AlertStateService.getAcknowledgedAlertState({
projectId: episode.projectId,
props: {
isRoot: true,
},
});
const currentOrder: number = episode.currentAlertState?.order || 0;
const acknowledgedOrder: number = acknowledgedState.order || 0;
return currentOrder >= acknowledgedOrder;
}
@CaptureSpan()
public async reopenEpisode(
episodeId: ObjectID,
reopenedByUserId?: ObjectID,
): Promise<void> {
const episode: Model | null = await this.findOneById({
id: episodeId,
select: {
projectId: true,
},
props: {
isRoot: true,
},
});
if (!episode || !episode.projectId) {
throw new BadDataException("Episode not found.");
}
// Get the created state
const createdState: AlertState | null = await AlertStateService.findOneBy({
query: {
projectId: episode.projectId,
isCreatedState: true,
},
select: {
_id: true,
},
props: {
isRoot: true,
},
});
if (!createdState || !createdState.id) {
throw new BadDataException("Created state not found for this project.");
}
await this.changeEpisodeState({
projectId: episode.projectId,
episodeId: episodeId,
alertStateId: createdState.id,
notifyOwners: true,
rootCause: "Episode reopened due to new alert added.",
props: {
isRoot: true,
userId: reopenedByUserId,
},
});
// Clear resolved timestamp
await this.updateOneById({
id: episodeId,
data: {
resolvedAt: undefined as any,
},
props: {
isRoot: true,
},
});
}
@CaptureSpan()
public async getEpisodeLinkInDashboard(
projectId: ObjectID,
episodeId: ObjectID,
): Promise<URL> {
const dashboardUrl: URL = await DatabaseConfig.getDashboardUrl();
return URL.fromString(dashboardUrl.toString()).addRoute(
`/${projectId.toString()}/alerts/episodes/${episodeId.toString()}`,
);
}
@CaptureSpan()
protected override async onUpdateSuccess(
onUpdate: OnUpdate<Model>,
updatedItemIds: ObjectID[],
): Promise<OnUpdate<Model>> {
// Handle state changes
if (
onUpdate.updateBy.data.currentAlertStateId &&
onUpdate.updateBy.props.tenantId
) {
for (const itemId of updatedItemIds) {
await this.changeEpisodeState({
projectId: onUpdate.updateBy.props.tenantId as ObjectID,
episodeId: itemId,
alertStateId: onUpdate.updateBy.data.currentAlertStateId as ObjectID,
notifyOwners: true,
rootCause: "State was changed when the episode was updated.",
props: {
isRoot: true,
},
});
}
}
return onUpdate;
}
@CaptureSpan()
public async findOwners(episodeId: ObjectID): Promise<Array<User>> {
// Get direct user owners
const ownerUsers: Array<AlertEpisodeOwnerUser> =
await AlertEpisodeOwnerUserService.findBy({
query: {
alertEpisodeId: episodeId,
},
select: {
_id: true,
user: {
_id: true,
email: true,
name: true,
timezone: true,
},
},
props: {
isRoot: true,
},
limit: LIMIT_PER_PROJECT,
skip: 0,
});
// Get team owners
const ownerTeams: Array<AlertEpisodeOwnerTeam> =
await AlertEpisodeOwnerTeamService.findBy({
query: {
alertEpisodeId: episodeId,
},
select: {
_id: true,
teamId: true,
},
props: {
isRoot: true,
},
limit: LIMIT_PER_PROJECT,
skip: 0,
});
const users: Array<User> = ownerUsers
.map((ownerUser: AlertEpisodeOwnerUser) => {
return ownerUser.user!;
})
.filter((user: User) => {
return Boolean(user);
});
// Expand teams to individual users
if (ownerTeams.length > 0) {
const teamIds: Array<ObjectID> = ownerTeams.map(
(ownerTeam: AlertEpisodeOwnerTeam) => {
return ownerTeam.teamId!;
},
);
const teamUsers: Array<User> =
await TeamMemberService.getUsersInTeams(teamIds);
for (const teamUser of teamUsers) {
// Avoid duplicates
if (
!users.find((user: User) => {
return user.id?.toString() === teamUser.id?.toString();
})
) {
users.push(teamUser);
}
}
}
return users;
}
@CaptureSpan()
public async getWorkspaceChannelForEpisode(data: {
episodeId: ObjectID;
workspaceType?: WorkspaceType | null;
}): Promise<Array<NotificationRuleWorkspaceChannel>> {
const episode: Model | null = await this.findOneById({
id: data.episodeId,
select: {
postUpdatesToWorkspaceChannels: true,
},
props: {
isRoot: true,
},
});
if (!episode) {
throw new BadDataException("Alert Episode not found.");
}
return (episode.postUpdatesToWorkspaceChannels || []).filter(
(channel: NotificationRuleWorkspaceChannel) => {
if (!data.workspaceType) {
return true;
}
return channel.workspaceType === data.workspaceType;
},
);
}
@CaptureSpan()
public async addOwners(
projectId: ObjectID,
episodeId: ObjectID,
userIds: Array<ObjectID>,
teamIds: Array<ObjectID>,
notifyOwners: boolean,
props: DatabaseCommonInteractionProps,
): Promise<void> {
for (let teamId of teamIds) {
if (typeof teamId === Typeof.String) {
teamId = new ObjectID(teamId.toString());
}
const teamOwner: AlertEpisodeOwnerTeam = new AlertEpisodeOwnerTeam();
teamOwner.alertEpisodeId = episodeId;
teamOwner.projectId = projectId;
teamOwner.teamId = teamId;
teamOwner.isOwnerNotified = !notifyOwners;
await AlertEpisodeOwnerTeamService.create({
data: teamOwner,
props: props,
});
}
for (let userId of userIds) {
if (typeof userId === Typeof.String) {
userId = new ObjectID(userId.toString());
}
const userOwner: AlertEpisodeOwnerUser = new AlertEpisodeOwnerUser();
userOwner.alertEpisodeId = episodeId;
userOwner.projectId = projectId;
userOwner.userId = userId;
userOwner.isOwnerNotified = !notifyOwners;
await AlertEpisodeOwnerUserService.create({
data: userOwner,
props: props,
});
}
}
}
export default new Service();

View File

@@ -0,0 +1,514 @@
import CreateBy from "../Types/Database/CreateBy";
import DeleteBy from "../Types/Database/DeleteBy";
import { OnCreate, OnDelete } from "../Types/Database/Hooks";
import QueryHelper from "../Types/Database/QueryHelper";
import DatabaseService from "./DatabaseService";
import AlertStateService from "./AlertStateService";
import UserService from "./UserService";
import SortOrder from "../../Types/BaseDatabase/SortOrder";
import OneUptimeDate from "../../Types/Date";
import BadDataException from "../../Types/Exception/BadDataException";
import ObjectID from "../../Types/ObjectID";
import PositiveNumber from "../../Types/PositiveNumber";
import AlertState from "../../Models/DatabaseModels/AlertState";
import AlertEpisode from "../../Models/DatabaseModels/AlertEpisode";
import AlertEpisodeStateTimeline from "../../Models/DatabaseModels/AlertEpisodeStateTimeline";
import { IsBillingEnabled } from "../EnvironmentConfig";
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
import logger from "../Utils/Logger";
import AlertEpisodeFeedService from "./AlertEpisodeFeedService";
import { AlertEpisodeFeedEventType } from "../../Models/DatabaseModels/AlertEpisodeFeed";
import Semaphore, { SemaphoreMutex } from "../Infrastructure/Semaphore";
import AlertEpisodeService from "./AlertEpisodeService";
export class Service extends DatabaseService<AlertEpisodeStateTimeline> {
public constructor() {
super(AlertEpisodeStateTimeline);
if (IsBillingEnabled) {
this.hardDeleteItemsOlderThanInDays("createdAt", 3 * 365); // 3 years
}
}
@CaptureSpan()
protected override async onBeforeCreate(
createBy: CreateBy<AlertEpisodeStateTimeline>,
): Promise<OnCreate<AlertEpisodeStateTimeline>> {
if (!createBy.data.alertEpisodeId) {
throw new BadDataException("alertEpisodeId is null");
}
let mutex: SemaphoreMutex | null = null;
try {
if (!createBy.data.startsAt) {
createBy.data.startsAt = OneUptimeDate.getCurrentDate();
}
try {
mutex = await Semaphore.lock({
key: createBy.data.alertEpisodeId.toString(),
namespace: "AlertEpisodeStateTimeline.create",
});
} catch (err) {
logger.error(err);
}
if (
(createBy.data.createdByUserId ||
createBy.data.createdByUser ||
createBy.props.userId) &&
!createBy.data.rootCause
) {
let userId: ObjectID | undefined = createBy.data.createdByUserId;
if (createBy.props.userId) {
userId = createBy.props.userId;
}
if (createBy.data.createdByUser && createBy.data.createdByUser.id) {
userId = createBy.data.createdByUser.id;
}
if (userId) {
createBy.data.rootCause = `Episode state created by ${await UserService.getUserMarkdownString(
{
userId: userId!,
projectId: createBy.data.projectId || createBy.props.tenantId!,
},
)}`;
}
}
const alertStateId: ObjectID | undefined | null =
createBy.data.alertStateId || createBy.data.alertState?.id;
if (!alertStateId) {
throw new BadDataException("alertStateId is null");
}
const stateBeforeThis: AlertEpisodeStateTimeline | null =
await this.findOneBy({
query: {
alertEpisodeId: createBy.data.alertEpisodeId,
startsAt: QueryHelper.lessThanEqualTo(createBy.data.startsAt),
},
sort: {
startsAt: SortOrder.Descending,
},
props: {
isRoot: true,
},
select: {
alertStateId: true,
alertState: {
order: true,
name: true,
},
startsAt: true,
endsAt: true,
},
});
logger.debug("State Before this");
logger.debug(stateBeforeThis);
// If this is the first state, then do not notify the owner.
if (!stateBeforeThis) {
// since this is the first status, do not notify the owner.
createBy.data.isOwnerNotified = true;
}
// Check if this new state and the previous state are same.
if (stateBeforeThis && stateBeforeThis.alertStateId && alertStateId) {
if (
stateBeforeThis.alertStateId.toString() === alertStateId.toString()
) {
throw new BadDataException(
"Episode state cannot be same as previous state.",
);
}
}
const stateAfterThis: AlertEpisodeStateTimeline | null =
await this.findOneBy({
query: {
alertEpisodeId: createBy.data.alertEpisodeId,
startsAt: QueryHelper.greaterThan(createBy.data.startsAt),
},
sort: {
startsAt: SortOrder.Ascending,
},
props: {
isRoot: true,
},
select: {
alertStateId: true,
startsAt: true,
endsAt: true,
},
});
// compute ends at. It's the start of the next status.
if (stateAfterThis && stateAfterThis.startsAt) {
createBy.data.endsAt = stateAfterThis.startsAt;
}
// Check if this new state and the next state are same.
if (stateAfterThis && stateAfterThis.alertStateId && alertStateId) {
if (
stateAfterThis.alertStateId.toString() === alertStateId.toString()
) {
throw new BadDataException(
"Episode state cannot be same as next state.",
);
}
}
logger.debug("State After this");
logger.debug(stateAfterThis);
return {
createBy,
carryForward: {
statusTimelineBeforeThisStatus: stateBeforeThis || null,
statusTimelineAfterThisStatus: stateAfterThis || null,
mutex: mutex,
},
};
} catch (error) {
// release the mutex if it was acquired.
if (mutex) {
try {
await Semaphore.release(mutex);
} catch (err) {
logger.error(err);
}
}
throw error;
}
}
@CaptureSpan()
protected override async onCreateSuccess(
onCreate: OnCreate<AlertEpisodeStateTimeline>,
createdItem: AlertEpisodeStateTimeline,
): Promise<AlertEpisodeStateTimeline> {
if (!createdItem.alertEpisodeId) {
throw new BadDataException("alertEpisodeId is null");
}
const mutex: SemaphoreMutex | null = onCreate.carryForward.mutex;
if (!createdItem.alertStateId) {
throw new BadDataException("alertStateId is null");
}
logger.debug("Status Timeline Before this");
logger.debug(onCreate.carryForward.statusTimelineBeforeThisStatus);
logger.debug("Status Timeline After this");
logger.debug(onCreate.carryForward.statusTimelineAfterThisStatus);
logger.debug("Created Item");
logger.debug(createdItem);
// Handle timeline updates
if (!onCreate.carryForward.statusTimelineBeforeThisStatus) {
// This is the first status, no need to update previous status.
logger.debug("This is the first status.");
} else if (!onCreate.carryForward.statusTimelineAfterThisStatus) {
// This is the last status. Update the previous status to end at the start of this status.
await this.updateOneById({
id: onCreate.carryForward.statusTimelineBeforeThisStatus.id!,
data: {
endsAt: createdItem.startsAt!,
},
props: {
isRoot: true,
},
});
logger.debug("This is the last status.");
} else {
// This is in the middle. Update the previous status to end at the start of this status.
await this.updateOneById({
id: onCreate.carryForward.statusTimelineBeforeThisStatus.id!,
data: {
endsAt: createdItem.startsAt!,
},
props: {
isRoot: true,
},
});
// Update the next status to start at the end of this status.
await this.updateOneById({
id: onCreate.carryForward.statusTimelineAfterThisStatus.id!,
data: {
startsAt: createdItem.endsAt!,
},
props: {
isRoot: true,
},
});
logger.debug("This status is in the middle.");
}
// Update episode's current state if this is the latest timeline entry
if (!createdItem.endsAt) {
await AlertEpisodeService.updateOneBy({
query: {
_id: createdItem.alertEpisodeId?.toString(),
},
data: {
currentAlertStateId: createdItem.alertStateId,
},
props: onCreate.createBy.props,
});
}
if (mutex) {
try {
await Semaphore.release(mutex);
} catch (err) {
logger.error(err);
}
}
const alertState: AlertState | null = await AlertStateService.findOneBy({
query: {
_id: createdItem.alertStateId.toString()!,
},
props: {
isRoot: true,
},
select: {
_id: true,
isResolvedState: true,
isAcknowledgedState: true,
isCreatedState: true,
color: true,
name: true,
},
});
const stateName: string = alertState?.name || "";
let stateEmoji: string = "➡️";
if (alertState?.isResolvedState) {
stateEmoji = "✅";
} else if (alertState?.isAcknowledgedState) {
stateEmoji = "👀";
} else if (alertState?.isCreatedState) {
stateEmoji = "🔴";
}
const episode: AlertEpisode | null = await AlertEpisodeService.findOneById({
id: createdItem.alertEpisodeId,
select: {
episodeNumber: true,
},
props: {
isRoot: true,
},
});
const episodeNumber: number = episode?.episodeNumber || 0;
await AlertEpisodeFeedService.createAlertEpisodeFeedItem({
alertEpisodeId: createdItem.alertEpisodeId!,
projectId: createdItem.projectId!,
alertEpisodeFeedEventType: AlertEpisodeFeedEventType.EpisodeStateChanged,
displayColor: alertState?.color,
feedInfoInMarkdown:
stateEmoji +
` Changed **Episode ${episodeNumber} State** to **` +
stateName +
"**",
moreInformationInMarkdown: createdItem.rootCause
? `**Cause:** \n${createdItem.rootCause}`
: undefined,
userId: createdItem.createdByUserId || onCreate.createBy.props.userId,
});
return createdItem;
}
@CaptureSpan()
protected override async onBeforeDelete(
deleteBy: DeleteBy<AlertEpisodeStateTimeline>,
): Promise<OnDelete<AlertEpisodeStateTimeline>> {
if (deleteBy.query._id) {
const episodeStateTimelineToBeDeleted: AlertEpisodeStateTimeline | null =
await this.findOneById({
id: new ObjectID(deleteBy.query._id as string),
select: {
alertEpisodeId: true,
startsAt: true,
endsAt: true,
},
props: {
isRoot: true,
},
});
const episodeId: ObjectID | undefined =
episodeStateTimelineToBeDeleted?.alertEpisodeId;
if (episodeId) {
const episodeStateTimeline: PositiveNumber = await this.countBy({
query: {
alertEpisodeId: episodeId,
},
props: {
isRoot: true,
},
});
if (!episodeStateTimelineToBeDeleted) {
throw new BadDataException("Episode state timeline not found.");
}
if (episodeStateTimeline.isOne()) {
throw new BadDataException(
"Cannot delete the only state timeline. Episode should have at least one state in its timeline.",
);
}
// Handle timeline adjustments
const stateBeforeThis: AlertEpisodeStateTimeline | null =
await this.findOneBy({
query: {
_id: QueryHelper.notEquals(deleteBy.query._id as string),
alertEpisodeId: episodeId,
startsAt: QueryHelper.lessThanEqualTo(
episodeStateTimelineToBeDeleted.startsAt!,
),
},
sort: {
startsAt: SortOrder.Descending,
},
props: {
isRoot: true,
},
select: {
alertStateId: true,
startsAt: true,
endsAt: true,
},
});
const stateAfterThis: AlertEpisodeStateTimeline | null =
await this.findOneBy({
query: {
alertEpisodeId: episodeId,
startsAt: QueryHelper.greaterThan(
episodeStateTimelineToBeDeleted.startsAt!,
),
},
sort: {
startsAt: SortOrder.Ascending,
},
props: {
isRoot: true,
},
select: {
alertStateId: true,
startsAt: true,
endsAt: true,
},
});
if (!stateBeforeThis) {
// This is the first state, no need to update previous state.
logger.debug("This is the first state.");
} else if (!stateAfterThis) {
// This is the last state. Update the previous state to end at the end of this state.
await this.updateOneById({
id: stateBeforeThis.id!,
data: {
endsAt: episodeStateTimelineToBeDeleted.endsAt!,
},
props: {
isRoot: true,
},
});
logger.debug("This is the last state.");
} else {
// This state is in the middle. Update the previous state to end at the start of the next state.
await this.updateOneById({
id: stateBeforeThis.id!,
data: {
endsAt: stateAfterThis.startsAt!,
},
props: {
isRoot: true,
},
});
// Update the next state to start at the start of this state.
await this.updateOneById({
id: stateAfterThis.id!,
data: {
startsAt: episodeStateTimelineToBeDeleted.startsAt!,
},
props: {
isRoot: true,
},
});
logger.debug("This state is in the middle.");
}
}
return { deleteBy, carryForward: episodeId };
}
return { deleteBy, carryForward: null };
}
@CaptureSpan()
protected override async onDeleteSuccess(
onDelete: OnDelete<AlertEpisodeStateTimeline>,
_itemIdsBeforeDelete: ObjectID[],
): Promise<OnDelete<AlertEpisodeStateTimeline>> {
if (onDelete.carryForward) {
const episodeId: ObjectID = onDelete.carryForward as ObjectID;
// Get last status of this episode.
const episodeStateTimeline: AlertEpisodeStateTimeline | null =
await this.findOneBy({
query: {
alertEpisodeId: episodeId,
},
sort: {
startsAt: SortOrder.Descending,
},
props: {
isRoot: true,
},
select: {
_id: true,
alertStateId: true,
},
});
if (episodeStateTimeline && episodeStateTimeline.alertStateId) {
await AlertEpisodeService.updateOneBy({
query: {
_id: episodeId.toString(),
},
data: {
currentAlertStateId: episodeStateTimeline.alertStateId,
},
props: {
isRoot: true,
},
});
}
}
return onDelete;
}
}
export default new Service();

View File

@@ -0,0 +1,888 @@
import ObjectID from "../../Types/ObjectID";
import AlertGroupingRule from "../../Models/DatabaseModels/AlertGroupingRule";
import Alert from "../../Models/DatabaseModels/Alert";
import AlertEpisode from "../../Models/DatabaseModels/AlertEpisode";
import AlertEpisodeMember, {
AlertEpisodeMemberAddedBy,
} from "../../Models/DatabaseModels/AlertEpisodeMember";
import AlertState from "../../Models/DatabaseModels/AlertState";
import Label from "../../Models/DatabaseModels/Label";
import Monitor from "../../Models/DatabaseModels/Monitor";
import AlertSeverity from "../../Models/DatabaseModels/AlertSeverity";
import ServiceMonitor from "../../Models/DatabaseModels/ServiceMonitor";
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
import logger from "../Utils/Logger";
import SortOrder from "../../Types/BaseDatabase/SortOrder";
import OneUptimeDate from "../../Types/Date";
import QueryHelper from "../Types/Database/QueryHelper";
import AlertGroupingRuleService from "./AlertGroupingRuleService";
import AlertEpisodeService from "./AlertEpisodeService";
import AlertStateService from "./AlertStateService";
import AlertEpisodeMemberService from "./AlertEpisodeMemberService";
import MonitorService from "./MonitorService";
import ServiceMonitorService from "./ServiceMonitorService";
export interface GroupingResult {
grouped: boolean;
episodeId?: ObjectID;
isNewEpisode?: boolean;
wasReopened?: boolean;
}
class AlertGroupingEngineServiceClass {
@CaptureSpan()
public async processAlert(alert: Alert): Promise<GroupingResult> {
logger.debug(`Processing alert ${alert.id} for grouping`);
try {
if (!alert.id || !alert.projectId) {
logger.warn("Alert missing id or projectId, skipping grouping");
return { grouped: false };
}
// If alert already has an episode, don't reprocess
if (alert.alertEpisodeId) {
return { grouped: true, episodeId: alert.alertEpisodeId };
}
// Get enabled rules sorted by priority
const rules: Array<AlertGroupingRule> =
await AlertGroupingRuleService.findBy({
query: {
projectId: alert.projectId,
isEnabled: true,
},
sort: {
priority: SortOrder.Ascending,
},
props: {
isRoot: true,
},
select: {
_id: true,
name: true,
priority: true,
// Match criteria fields
monitors: {
_id: true,
},
alertSeverities: {
_id: true,
},
alertLabels: {
_id: true,
},
monitorLabels: {
_id: true,
},
alertTitlePattern: true,
alertDescriptionPattern: true,
monitorNamePattern: true,
monitorDescriptionPattern: true,
// Group by fields
groupByMonitor: true,
groupBySeverity: true,
groupByAlertTitle: true,
groupByService: true,
// Time settings
enableTimeWindow: true,
timeWindowMinutes: true,
episodeTitleTemplate: true,
episodeDescriptionTemplate: true,
enableResolveDelay: true,
resolveDelayMinutes: true,
enableReopenWindow: true,
reopenWindowMinutes: true,
enableInactivityTimeout: true,
inactivityTimeoutMinutes: true,
defaultAssignToUserId: true,
defaultAssignToTeamId: true,
onCallDutyPolicies: {
_id: true,
},
},
limit: 100,
skip: 0,
});
if (rules.length === 0) {
logger.debug(
`No enabled grouping rules found for project ${alert.projectId}`,
);
return { grouped: false };
}
logger.debug(
`Found ${rules.length} enabled grouping rules for project ${alert.projectId}`,
);
// Find first matching rule
for (const rule of rules) {
const matches: boolean = await this.doesAlertMatchRule(alert, rule);
if (matches) {
logger.debug(
`Alert ${alert.id} matches rule ${rule.name || rule.id}`,
);
// Try to find existing episode or create new one
const result: GroupingResult = await this.groupAlertWithRule(
alert,
rule,
);
return result;
}
}
logger.debug(`Alert ${alert.id} did not match any grouping rules`);
return { grouped: false };
} catch (error) {
logger.error(`Error processing alert for grouping: ${error}`);
return { grouped: false };
}
}
@CaptureSpan()
private async doesAlertMatchRule(
alert: Alert,
rule: AlertGroupingRule,
): Promise<boolean> {
logger.debug(
`Checking if alert ${alert.id} matches rule ${rule.name || rule.id}`,
);
// Check monitor IDs - if monitors are specified, alert must be from one of them
if (rule.monitors && rule.monitors.length > 0) {
if (!alert.monitorId) {
return false;
}
const monitorIds: Array<string> = rule.monitors.map((m: Monitor) => {
return m.id?.toString() || "";
});
const alertMonitorIdStr: string = alert.monitorId.toString();
if (!monitorIds.includes(alertMonitorIdStr)) {
return false;
}
}
// Check alert severity IDs - if severities are specified, alert must have one of them
if (rule.alertSeverities && rule.alertSeverities.length > 0) {
if (!alert.alertSeverityId) {
return false;
}
const severityIds: Array<string> = rule.alertSeverities.map(
(s: AlertSeverity) => {
return s.id?.toString() || "";
},
);
const alertSeverityIdStr: string = alert.alertSeverityId.toString();
if (!severityIds.includes(alertSeverityIdStr)) {
return false;
}
}
// Check alert label IDs - if alert labels are specified, alert must have at least one of them
if (rule.alertLabels && rule.alertLabels.length > 0) {
if (!alert.labels || alert.labels.length === 0) {
return false;
}
const ruleLabelIds: Array<string> = rule.alertLabels.map((l: Label) => {
return l.id?.toString() || "";
});
const alertLabelIds: Array<string> = alert.labels.map((l: Label) => {
return l.id?.toString() || "";
});
const hasMatchingLabel: boolean = ruleLabelIds.some((labelId: string) => {
return alertLabelIds.includes(labelId);
});
if (!hasMatchingLabel) {
return false;
}
}
// Check monitor-related criteria (labels, name pattern, description pattern)
const hasMonitorCriteria: boolean = Boolean(
(rule.monitorLabels && rule.monitorLabels.length > 0) ||
rule.monitorNamePattern ||
rule.monitorDescriptionPattern,
);
if (hasMonitorCriteria) {
if (!alert.monitorId) {
return false;
}
// Load monitor with all needed fields
const monitor: Monitor | null = await MonitorService.findOneById({
id: alert.monitorId,
select: {
name: true,
description: true,
labels: {
_id: true,
},
},
props: {
isRoot: true,
},
});
if (!monitor) {
return false;
}
// Check monitor labels
if (rule.monitorLabels && rule.monitorLabels.length > 0) {
if (!monitor.labels || monitor.labels.length === 0) {
return false;
}
const ruleMonitorLabelIds: Array<string> = rule.monitorLabels.map(
(l: Label) => {
return l.id?.toString() || "";
},
);
const monitorLabelIds: Array<string> = monitor.labels.map(
(l: Label) => {
return l.id?.toString() || "";
},
);
const hasMatchingMonitorLabel: boolean = ruleMonitorLabelIds.some(
(labelId: string) => {
return monitorLabelIds.includes(labelId);
},
);
if (!hasMatchingMonitorLabel) {
return false;
}
}
// Check monitor name pattern (regex)
if (rule.monitorNamePattern) {
if (!monitor.name) {
return false;
}
try {
const regex: RegExp = new RegExp(rule.monitorNamePattern, "i");
if (!regex.test(monitor.name)) {
return false;
}
} catch {
logger.warn(
`Invalid regex pattern in rule ${rule.id}: ${rule.monitorNamePattern}`,
);
return false;
}
}
// Check monitor description pattern (regex)
if (rule.monitorDescriptionPattern) {
if (!monitor.description) {
return false;
}
try {
const regex: RegExp = new RegExp(rule.monitorDescriptionPattern, "i");
if (!regex.test(monitor.description)) {
return false;
}
} catch {
logger.warn(
`Invalid regex pattern in rule ${rule.id}: ${rule.monitorDescriptionPattern}`,
);
return false;
}
}
}
// Check alert title pattern (regex)
if (rule.alertTitlePattern) {
if (!alert.title) {
return false;
}
try {
const regex: RegExp = new RegExp(rule.alertTitlePattern, "i");
if (!regex.test(alert.title)) {
return false;
}
} catch {
logger.warn(
`Invalid regex pattern in rule ${rule.id}: ${rule.alertTitlePattern}`,
);
return false;
}
}
// Check alert description pattern (regex)
if (rule.alertDescriptionPattern) {
if (!alert.description) {
return false;
}
try {
const regex: RegExp = new RegExp(rule.alertDescriptionPattern, "i");
if (!regex.test(alert.description)) {
return false;
}
} catch {
logger.warn(
`Invalid regex pattern in rule ${rule.id}: ${rule.alertDescriptionPattern}`,
);
return false;
}
}
// If no criteria specified (all fields empty), rule matches all alerts
logger.debug(
`Rule ${rule.name || rule.id} matched alert ${alert.id} (all criteria passed)`,
);
return true;
}
@CaptureSpan()
private async groupAlertWithRule(
alert: Alert,
rule: AlertGroupingRule,
): Promise<GroupingResult> {
// Build the grouping key based on groupBy fields
const groupingKey: string = await this.buildGroupingKey(alert, rule);
// Calculate time window cutoff (only if time window is enabled)
let timeWindowCutoff: Date | null = null;
if (rule.enableTimeWindow) {
const timeWindowMinutes: number = rule.timeWindowMinutes || 60;
timeWindowCutoff = OneUptimeDate.getSomeMinutesAgo(timeWindowMinutes);
}
// Find existing active episode that matches
const existingEpisode: AlertEpisode | null =
await this.findMatchingActiveEpisode(
alert.projectId!,
rule.id!,
groupingKey,
timeWindowCutoff,
);
if (existingEpisode && existingEpisode.id) {
// Add alert to existing episode
await this.addAlertToEpisode(
alert,
existingEpisode.id,
AlertEpisodeMemberAddedBy.Rule,
rule.id!,
);
// Update episode severity if alert has higher severity
if (alert.alertSeverityId) {
await AlertEpisodeService.updateEpisodeSeverity({
episodeId: existingEpisode.id,
severityId: alert.alertSeverityId,
onlyIfHigher: true,
});
}
return {
grouped: true,
episodeId: existingEpisode.id,
isNewEpisode: false,
};
}
// Check if we can reopen a recently resolved episode (only if enabled)
if (rule.enableReopenWindow) {
const reopenWindowMinutes: number = rule.reopenWindowMinutes || 0;
if (reopenWindowMinutes > 0) {
const reopenCutoff: Date =
OneUptimeDate.getSomeMinutesAgo(reopenWindowMinutes);
const recentlyResolvedEpisode: AlertEpisode | null =
await this.findRecentlyResolvedEpisode(
alert.projectId!,
rule.id!,
groupingKey,
reopenCutoff,
);
if (recentlyResolvedEpisode && recentlyResolvedEpisode.id) {
// Reopen the episode
await AlertEpisodeService.reopenEpisode(recentlyResolvedEpisode.id);
// Add alert to reopened episode
await this.addAlertToEpisode(
alert,
recentlyResolvedEpisode.id,
AlertEpisodeMemberAddedBy.Rule,
rule.id!,
);
// Update episode severity if alert has higher severity
if (alert.alertSeverityId) {
await AlertEpisodeService.updateEpisodeSeverity({
episodeId: recentlyResolvedEpisode.id,
severityId: alert.alertSeverityId,
onlyIfHigher: true,
});
}
return {
grouped: true,
episodeId: recentlyResolvedEpisode.id,
isNewEpisode: false,
wasReopened: true,
};
}
}
}
// Create new episode
const newEpisode: AlertEpisode | null = await this.createNewEpisode(
alert,
rule,
groupingKey,
);
if (newEpisode && newEpisode.id) {
// Add alert to new episode
await this.addAlertToEpisode(
alert,
newEpisode.id,
AlertEpisodeMemberAddedBy.Rule,
rule.id!,
);
return { grouped: true, episodeId: newEpisode.id, isNewEpisode: true };
}
return { grouped: false };
}
@CaptureSpan()
private async buildGroupingKey(
alert: Alert,
rule: AlertGroupingRule,
): Promise<string> {
const parts: Array<string> = [];
/*
* Group by service - only if explicitly enabled
* Must be checked before monitor since service contains multiple monitors
*/
if (rule.groupByService && alert.monitorId) {
const serviceMonitor: ServiceMonitor | null =
await ServiceMonitorService.findOneBy({
query: {
monitorId: alert.monitorId,
},
select: {
serviceId: true,
},
props: {
isRoot: true,
},
});
if (serviceMonitor?.serviceId) {
parts.push(`service:${serviceMonitor.serviceId.toString()}`);
}
}
// Group by monitor - only if explicitly enabled
if (rule.groupByMonitor && alert.monitorId) {
parts.push(`monitor:${alert.monitorId.toString()}`);
}
// Group by severity - only if explicitly enabled
if (rule.groupBySeverity && alert.alertSeverityId) {
parts.push(`severity:${alert.alertSeverityId.toString()}`);
}
// Group by alert title - only if explicitly enabled
if (rule.groupByAlertTitle && alert.title) {
// Normalize title for grouping (remove numbers, etc.)
const normalizedTitle: string = alert.title
.toLowerCase()
.replace(/\d+/g, "X");
parts.push(`title:${normalizedTitle}`);
}
// If no group by options are enabled, all matching alerts go into a single episode
return parts.join("|") || "default";
}
@CaptureSpan()
private async findMatchingActiveEpisode(
projectId: ObjectID,
ruleId: ObjectID,
groupingKey: string,
timeWindowCutoff: Date | null,
): Promise<AlertEpisode | null> {
// Get resolved state to exclude resolved episodes
const resolvedState: AlertState | null = await AlertStateService.findOneBy({
query: {
projectId: projectId,
isResolvedState: true,
},
select: {
order: true,
},
props: {
isRoot: true,
},
});
/*
* Find active episode with matching rule and grouping key
* If time window is enabled, also filter by lastAlertAddedAt
* If time window is disabled (timeWindowCutoff is null), find any matching active episode
*/
type EpisodeQueryType = {
projectId: ObjectID;
alertGroupingRuleId: ObjectID;
groupingKey: string;
lastAlertAddedAt?: ReturnType<typeof QueryHelper.greaterThanEqualTo>;
};
const query: EpisodeQueryType = {
projectId: projectId,
alertGroupingRuleId: ruleId,
groupingKey: groupingKey,
};
// Only add time window filter if enabled
if (timeWindowCutoff) {
query.lastAlertAddedAt = QueryHelper.greaterThanEqualTo(timeWindowCutoff);
}
const episodes: Array<AlertEpisode> = await AlertEpisodeService.findBy({
query: query,
sort: {
lastAlertAddedAt: SortOrder.Descending,
},
select: {
_id: true,
lastAlertAddedAt: true,
currentAlertState: {
order: true,
},
},
props: {
isRoot: true,
},
limit: 10,
skip: 0,
});
// Filter to only active (non-resolved) episodes
for (const episode of episodes) {
const episodeOrder: number = episode.currentAlertState?.order || 0;
const resolvedOrder: number = resolvedState?.order || 999;
if (episodeOrder < resolvedOrder) {
return episode;
}
}
return null;
}
@CaptureSpan()
private async findRecentlyResolvedEpisode(
projectId: ObjectID,
ruleId: ObjectID,
groupingKey: string,
reopenCutoff: Date,
): Promise<AlertEpisode | null> {
// Find recently resolved episode with matching rule and grouping key
const episode: AlertEpisode | null = await AlertEpisodeService.findOneBy({
query: {
projectId: projectId,
alertGroupingRuleId: ruleId,
groupingKey: groupingKey,
resolvedAt: QueryHelper.greaterThanEqualTo(reopenCutoff),
},
sort: {
resolvedAt: SortOrder.Descending,
},
select: {
_id: true,
resolvedAt: true,
},
props: {
isRoot: true,
},
});
return episode;
}
@CaptureSpan()
private async createNewEpisode(
alert: Alert,
rule: AlertGroupingRule,
groupingKey: string,
): Promise<AlertEpisode | null> {
// Generate episode title from template (with initial alertCount of 1)
const title: string = this.generateEpisodeTitle(
alert,
rule.episodeTitleTemplate,
1, // Initial alert count
);
// Generate episode description from template (with initial alertCount of 1)
const description: string | undefined = this.generateEpisodeDescription(
alert,
rule.episodeDescriptionTemplate,
1, // Initial alert count
);
const newEpisode: AlertEpisode = new AlertEpisode();
newEpisode.projectId = alert.projectId!;
newEpisode.title = title;
if (description) {
newEpisode.description = description;
}
/*
* Store preprocessed templates for dynamic variable updates
* Static variables are replaced, dynamic ones (like {{alertCount}}) remain as placeholders
*/
if (rule.episodeTitleTemplate) {
newEpisode.titleTemplate = this.preprocessTemplate(
alert,
rule.episodeTitleTemplate,
);
}
if (rule.episodeDescriptionTemplate) {
newEpisode.descriptionTemplate = this.preprocessTemplate(
alert,
rule.episodeDescriptionTemplate,
);
}
newEpisode.alertGroupingRuleId = rule.id!;
newEpisode.groupingKey = groupingKey;
newEpisode.isManuallyCreated = false;
// Set severity from alert
if (alert.alertSeverityId) {
newEpisode.alertSeverityId = alert.alertSeverityId;
}
// Set default ownership from rule
if (rule.defaultAssignToUserId) {
newEpisode.assignedToUserId = rule.defaultAssignToUserId;
}
if (rule.defaultAssignToTeamId) {
newEpisode.assignedToTeamId = rule.defaultAssignToTeamId;
}
// Copy on-call policies from rule
if (rule.onCallDutyPolicies && rule.onCallDutyPolicies.length > 0) {
newEpisode.onCallDutyPolicies = rule.onCallDutyPolicies;
}
try {
const createdEpisode: AlertEpisode = await AlertEpisodeService.create({
data: newEpisode,
props: {
isRoot: true,
},
});
return createdEpisode;
} catch (error) {
logger.error(`Error creating new episode: ${error}`);
return null;
}
}
private generateEpisodeTitle(
alert: Alert,
template: string | undefined,
alertCount: number = 1,
): string {
if (!template) {
// Default title based on alert
if (alert.monitor?.name) {
return `Alert Episode: ${alert.monitor.name}`;
}
if (alert.title) {
return `Alert Episode: ${alert.title.substring(0, 50)}`;
}
return "Alert Episode";
}
return (
this.replaceTemplatePlaceholders(alert, template, alertCount) ||
"Alert Episode"
);
}
private generateEpisodeDescription(
alert: Alert,
template: string | undefined,
alertCount: number = 1,
): string | undefined {
if (!template) {
return undefined;
}
return (
this.replaceTemplatePlaceholders(alert, template, alertCount) || undefined
);
}
private replaceTemplatePlaceholders(
alert: Alert,
template: string,
alertCount: number = 1,
): string {
let result: string = template;
/*
* Static variables (from first alert)
* {{alertTitle}}
*/
if (alert.title) {
result = result.replace(/\{\{alertTitle\}\}/g, alert.title);
}
// {{alertDescription}}
if (alert.description) {
result = result.replace(/\{\{alertDescription\}\}/g, alert.description);
}
// {{monitorName}}
if (alert.monitor?.name) {
result = result.replace(/\{\{monitorName\}\}/g, alert.monitor.name);
}
// {{alertSeverity}}
if (alert.alertSeverity?.name) {
result = result.replace(
/\{\{alertSeverity\}\}/g,
alert.alertSeverity.name,
);
}
/*
* Dynamic variables (updated when alerts are added/removed)
* {{alertCount}}
*/
result = result.replace(/\{\{alertCount\}\}/g, alertCount.toString());
// Clean up any remaining unknown placeholders
result = result.replace(/\{\{[^}]+\}\}/g, "");
return result;
}
/*
* Preprocess template: replace static variables but keep dynamic ones as placeholders
* This is stored on the episode so we can re-render with updated dynamic values later
*/
private preprocessTemplate(alert: Alert, template: string): string {
let result: string = template;
/*
* Replace static variables (from first alert)
* {{alertTitle}}
*/
if (alert.title) {
result = result.replace(/\{\{alertTitle\}\}/g, alert.title);
}
// {{alertDescription}}
if (alert.description) {
result = result.replace(/\{\{alertDescription\}\}/g, alert.description);
}
// {{monitorName}}
if (alert.monitor?.name) {
result = result.replace(/\{\{monitorName\}\}/g, alert.monitor.name);
}
// {{alertSeverity}}
if (alert.alertSeverity?.name) {
result = result.replace(
/\{\{alertSeverity\}\}/g,
alert.alertSeverity.name,
);
}
/*
* Keep dynamic variables as placeholders (e.g., {{alertCount}})
* They will be replaced when title/description is re-rendered
*/
return result;
}
@CaptureSpan()
private async addAlertToEpisode(
alert: Alert,
episodeId: ObjectID,
addedBy: AlertEpisodeMemberAddedBy,
ruleId?: ObjectID,
): Promise<void> {
const member: AlertEpisodeMember = new AlertEpisodeMember();
member.projectId = alert.projectId!;
member.alertEpisodeId = episodeId;
member.alertId = alert.id!;
member.addedBy = addedBy;
if (ruleId) {
member.matchedRuleId = ruleId;
}
try {
await AlertEpisodeMemberService.create({
data: member,
props: {
isRoot: true,
},
});
} catch (error) {
// Check if it's a duplicate error (alert already in episode)
if (
error instanceof Error &&
error.message.includes("already a member")
) {
logger.debug(`Alert ${alert.id} is already in episode ${episodeId}`);
return;
}
throw error;
}
}
@CaptureSpan()
public async addAlertToEpisodeManually(
alert: Alert,
episodeId: ObjectID,
addedByUserId?: ObjectID,
): Promise<void> {
const member: AlertEpisodeMember = new AlertEpisodeMember();
member.projectId = alert.projectId!;
member.alertEpisodeId = episodeId;
member.alertId = alert.id!;
member.addedBy = AlertEpisodeMemberAddedBy.Manual;
if (addedByUserId) {
member.addedByUserId = addedByUserId;
}
await AlertEpisodeMemberService.create({
data: member,
props: {
isRoot: true,
},
});
// Update episode severity if needed
if (alert.alertSeverityId) {
await AlertEpisodeService.updateEpisodeSeverity({
episodeId: episodeId,
severityId: alert.alertSeverityId,
onlyIfHigher: true,
});
}
}
}
export default new AlertGroupingEngineServiceClass();

View File

@@ -0,0 +1,14 @@
import DatabaseService from "./DatabaseService";
import Model from "../../Models/DatabaseModels/AlertGroupingRule";
import { IsBillingEnabled } from "../EnvironmentConfig";
export class Service extends DatabaseService<Model> {
public constructor() {
super(Model);
if (IsBillingEnabled) {
this.hardDeleteItemsOlderThanInDays("createdAt", 3 * 365); // 3 years
}
}
}
export default new Service();

View File

@@ -54,6 +54,7 @@ import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
import MetricType from "../../Models/DatabaseModels/MetricType";
import Dictionary from "../../Types/Dictionary";
import OnCallDutyPolicy from "../../Models/DatabaseModels/OnCallDutyPolicy";
import AlertGroupingEngineService from "./AlertGroupingEngineService";
export class Service extends DatabaseService<Model> {
public constructor() {
@@ -351,6 +352,17 @@ export class Service extends DatabaseService<Model> {
}
return Promise.resolve();
})
.then(async () => {
// Process alert for grouping into episodes
try {
await AlertGroupingEngineService.processAlert(createdItem);
} catch (error) {
logger.error(
`Alert grouping failed in AlertService.onCreateSuccess: ${error}`,
);
return Promise.resolve();
}
})
.catch((error: Error) => {
logger.error(
`Critical error in AlertService sequential operations: ${error}`,

View File

@@ -28,6 +28,7 @@ export class CallService extends BaseService {
customTwilioConfig?: TwilioConfig | undefined;
incidentId?: ObjectID | undefined;
alertId?: ObjectID | undefined;
alertEpisodeId?: ObjectID | undefined;
scheduledMaintenanceId?: ObjectID | undefined;
statusPageId?: ObjectID | undefined;
statusPageAnnouncementId?: ObjectID | undefined;
@@ -57,6 +58,7 @@ export class CallService extends BaseService {
: undefined,
incidentId: options.incidentId?.toString(),
alertId: options.alertId?.toString(),
alertEpisodeId: options.alertEpisodeId?.toString(),
scheduledMaintenanceId: options.scheduledMaintenanceId?.toString(),
statusPageId: options.statusPageId?.toString(),
statusPageAnnouncementId: options.statusPageAnnouncementId?.toString(),

View File

@@ -158,6 +158,17 @@ import AlertOwnerTeamService from "./AlertOwnerTeamService";
import AlertOwnerUserService from "./AlertOwnerUserService";
import AlertSeverityService from "./AlertSeverityService";
import AlertNoteTemplateService from "./AlertNoteTemplateService";
// AlertEpisode Services
import AlertEpisodeService from "./AlertEpisodeService";
import AlertEpisodeFeedService from "./AlertEpisodeFeedService";
import AlertEpisodeInternalNoteService from "./AlertEpisodeInternalNoteService";
import AlertEpisodeMemberService from "./AlertEpisodeMemberService";
import AlertEpisodeOwnerTeamService from "./AlertEpisodeOwnerTeamService";
import AlertEpisodeOwnerUserService from "./AlertEpisodeOwnerUserService";
import AlertEpisodeStateTimelineService from "./AlertEpisodeStateTimelineService";
import AlertGroupingRuleService from "./AlertGroupingRuleService";
import TableViewService from "./TableViewService";
import ScheduledMaintenanceFeedService from "./ScheduledMaintenanceFeedService";
import AlertFeedService from "./AlertFeedService";
@@ -351,6 +362,16 @@ const services: Array<BaseService> = [
AlertNoteTemplateService,
AlertFeedService,
// AlertEpisode Services
AlertEpisodeService,
AlertEpisodeFeedService,
AlertEpisodeInternalNoteService,
AlertEpisodeMemberService,
AlertEpisodeOwnerTeamService,
AlertEpisodeOwnerUserService,
AlertEpisodeStateTimelineService,
AlertGroupingRuleService,
TableViewService,
MonitorTestService,

View File

@@ -23,6 +23,7 @@ export class MailService extends BaseService {
projectId?: ObjectID | undefined;
incidentId?: ObjectID | undefined;
alertId?: ObjectID | undefined;
alertEpisodeId?: ObjectID | undefined;
scheduledMaintenanceId?: ObjectID | undefined;
statusPageId?: ObjectID | undefined;
statusPageAnnouncementId?: ObjectID | undefined;
@@ -68,6 +69,10 @@ export class MailService extends BaseService {
body["alertId"] = options.alertId.toString();
}
if (options?.alertEpisodeId) {
body["alertEpisodeId"] = options.alertEpisodeId.toString();
}
if (options?.scheduledMaintenanceId) {
body["scheduledMaintenanceId"] =
options.scheduledMaintenanceId.toString();

View File

@@ -263,6 +263,7 @@ ${onCallPolicy.description || "No description provided."}
options: {
triggeredByIncidentId?: ObjectID | undefined;
triggeredByAlertId?: ObjectID | undefined;
triggeredByAlertEpisodeId?: ObjectID | undefined;
userNotificationEventType: UserNotificationEventType;
},
): Promise<void> {
@@ -323,6 +324,10 @@ ${onCallPolicy.description || "No description provided."}
log.triggeredByAlertId = options.triggeredByAlertId;
}
if (options.triggeredByAlertEpisodeId) {
log.triggeredByAlertEpisodeId = options.triggeredByAlertEpisodeId;
}
await OnCallDutyPolicyExecutionLogService.create({
data: log,
props: {

View File

@@ -28,6 +28,7 @@ export class SmsService extends BaseService {
customTwilioConfig?: TwilioConfig | undefined;
incidentId?: ObjectID | undefined;
alertId?: ObjectID | undefined;
alertEpisodeId?: ObjectID | undefined;
scheduledMaintenanceId?: ObjectID | undefined;
statusPageId?: ObjectID | undefined;
statusPageAnnouncementId?: ObjectID | undefined;
@@ -58,6 +59,7 @@ export class SmsService extends BaseService {
: undefined,
incidentId: options.incidentId?.toString(),
alertId: options.alertId?.toString(),
alertEpisodeId: options.alertEpisodeId?.toString(),
scheduledMaintenanceId: options.scheduledMaintenanceId?.toString(),
statusPageId: options.statusPageId?.toString(),
statusPageAnnouncementId: options.statusPageAnnouncementId?.toString(),

View File

@@ -49,6 +49,7 @@ export class Service extends DatabaseService<UserNotificationSetting> {
whatsAppMessage: WhatsAppMessagePayload;
incidentId?: ObjectID | undefined;
alertId?: ObjectID | undefined;
alertEpisodeId?: ObjectID | undefined;
scheduledMaintenanceId?: ObjectID | undefined;
statusPageId?: ObjectID | undefined;
statusPageAnnouncementId?: ObjectID | undefined;
@@ -113,6 +114,7 @@ export class Service extends DatabaseService<UserNotificationSetting> {
projectId: data.projectId,
incidentId: data.incidentId,
alertId: data.alertId,
alertEpisodeId: data.alertEpisodeId,
scheduledMaintenanceId: data.scheduledMaintenanceId,
statusPageId: data.statusPageId,
statusPageAnnouncementId: data.statusPageAnnouncementId,
@@ -158,6 +160,7 @@ export class Service extends DatabaseService<UserNotificationSetting> {
projectId: data.projectId,
incidentId: data.incidentId,
alertId: data.alertId,
alertEpisodeId: data.alertEpisodeId,
scheduledMaintenanceId: data.scheduledMaintenanceId,
statusPageId: data.statusPageId,
statusPageAnnouncementId: data.statusPageAnnouncementId,
@@ -210,6 +213,7 @@ export class Service extends DatabaseService<UserNotificationSetting> {
projectId: data.projectId,
incidentId: data.incidentId,
alertId: data.alertId,
alertEpisodeId: data.alertEpisodeId,
scheduledMaintenanceId: data.scheduledMaintenanceId,
statusPageId: data.statusPageId,
statusPageAnnouncementId: data.statusPageAnnouncementId,
@@ -254,6 +258,7 @@ export class Service extends DatabaseService<UserNotificationSetting> {
projectId: data.projectId,
incidentId: data.incidentId,
alertId: data.alertId,
alertEpisodeId: data.alertEpisodeId,
scheduledMaintenanceId: data.scheduledMaintenanceId,
statusPageId: data.statusPageId,
statusPageAnnouncementId: data.statusPageAnnouncementId,
@@ -340,6 +345,7 @@ export class Service extends DatabaseService<UserNotificationSetting> {
await this.addMonitorNotificationSettings(userId, projectId);
await this.addOnCallNotificationSettings(userId, projectId);
await this.addAlertNotificationSettings(userId, projectId);
await this.addAlertEpisodeNotificationSettings(userId, projectId);
}
private async addProbeOwnerNotificationSettings(
@@ -451,6 +457,23 @@ export class Service extends DatabaseService<UserNotificationSetting> {
);
}
private async addAlertEpisodeNotificationSettings(
userId: ObjectID,
projectId: ObjectID,
): Promise<void> {
await this.addNotificationSettingIfNotExists(
userId,
projectId,
NotificationSettingEventType.SEND_ALERT_EPISODE_CREATED_OWNER_NOTIFICATION,
);
await this.addNotificationSettingIfNotExists(
userId,
projectId,
NotificationSettingEventType.SEND_ALERT_EPISODE_STATE_CHANGED_OWNER_NOTIFICATION,
);
}
private async addNotificationSettingIfNotExists(
userId: ObjectID,
projectId: ObjectID,

View File

@@ -26,6 +26,7 @@ export class WhatsAppService extends BaseService {
userOnCallLogTimelineId?: ObjectID | undefined;
incidentId?: ObjectID | undefined;
alertId?: ObjectID | undefined;
alertEpisodeId?: ObjectID | undefined;
scheduledMaintenanceId?: ObjectID | undefined;
statusPageId?: ObjectID | undefined;
statusPageAnnouncementId?: ObjectID | undefined;
@@ -84,6 +85,10 @@ export class WhatsAppService extends BaseService {
body["alertId"] = options.alertId.toString();
}
if (options.alertEpisodeId) {
body["alertEpisodeId"] = options.alertEpisodeId.toString();
}
if (options.scheduledMaintenanceId) {
body["scheduledMaintenanceId"] =
options.scheduledMaintenanceId.toString();

View File

@@ -59,6 +59,7 @@ export interface MessageBlocksByWorkspaceType {
export interface NotificationFor {
incidentId?: ObjectID | undefined;
alertId?: ObjectID | undefined;
alertEpisodeId?: ObjectID | undefined;
scheduledMaintenanceId?: ObjectID | undefined;
monitorId?: ObjectID | undefined;
onCallDutyPolicyId?: ObjectID | undefined;
@@ -1943,6 +1944,11 @@ export class Service extends DatabaseService<WorkspaceNotificationRule> {
[NotificationRuleConditionCheckOn.OnCallDutyPolicyDescription]:
undefined,
[NotificationRuleConditionCheckOn.OnCallDutyPolicyLabels]: undefined,
[NotificationRuleConditionCheckOn.AlertEpisodeTitle]: undefined,
[NotificationRuleConditionCheckOn.AlertEpisodeDescription]: undefined,
[NotificationRuleConditionCheckOn.AlertEpisodeSeverity]: undefined,
[NotificationRuleConditionCheckOn.AlertEpisodeState]: undefined,
[NotificationRuleConditionCheckOn.AlertEpisodeLabels]: undefined,
};
}
@@ -2020,6 +2026,11 @@ export class Service extends DatabaseService<WorkspaceNotificationRule> {
[NotificationRuleConditionCheckOn.OnCallDutyPolicyDescription]:
undefined,
[NotificationRuleConditionCheckOn.OnCallDutyPolicyLabels]: undefined,
[NotificationRuleConditionCheckOn.AlertEpisodeTitle]: undefined,
[NotificationRuleConditionCheckOn.AlertEpisodeDescription]: undefined,
[NotificationRuleConditionCheckOn.AlertEpisodeSeverity]: undefined,
[NotificationRuleConditionCheckOn.AlertEpisodeState]: undefined,
[NotificationRuleConditionCheckOn.AlertEpisodeLabels]: undefined,
};
}
@@ -2103,6 +2114,11 @@ export class Service extends DatabaseService<WorkspaceNotificationRule> {
[NotificationRuleConditionCheckOn.OnCallDutyPolicyDescription]:
undefined,
[NotificationRuleConditionCheckOn.OnCallDutyPolicyLabels]: undefined,
[NotificationRuleConditionCheckOn.AlertEpisodeTitle]: undefined,
[NotificationRuleConditionCheckOn.AlertEpisodeDescription]: undefined,
[NotificationRuleConditionCheckOn.AlertEpisodeSeverity]: undefined,
[NotificationRuleConditionCheckOn.AlertEpisodeState]: undefined,
[NotificationRuleConditionCheckOn.AlertEpisodeLabels]: undefined,
};
}
@@ -2164,6 +2180,11 @@ export class Service extends DatabaseService<WorkspaceNotificationRule> {
[NotificationRuleConditionCheckOn.OnCallDutyPolicyDescription]:
undefined,
[NotificationRuleConditionCheckOn.OnCallDutyPolicyLabels]: undefined,
[NotificationRuleConditionCheckOn.AlertEpisodeTitle]: undefined,
[NotificationRuleConditionCheckOn.AlertEpisodeDescription]: undefined,
[NotificationRuleConditionCheckOn.AlertEpisodeSeverity]: undefined,
[NotificationRuleConditionCheckOn.AlertEpisodeState]: undefined,
[NotificationRuleConditionCheckOn.AlertEpisodeLabels]: undefined,
};
}
@@ -2224,6 +2245,11 @@ export class Service extends DatabaseService<WorkspaceNotificationRule> {
[NotificationRuleConditionCheckOn.ScheduledMaintenanceLabels]:
undefined,
[NotificationRuleConditionCheckOn.Monitors]: undefined,
[NotificationRuleConditionCheckOn.AlertEpisodeTitle]: undefined,
[NotificationRuleConditionCheckOn.AlertEpisodeDescription]: undefined,
[NotificationRuleConditionCheckOn.AlertEpisodeSeverity]: undefined,
[NotificationRuleConditionCheckOn.AlertEpisodeState]: undefined,
[NotificationRuleConditionCheckOn.AlertEpisodeLabels]: undefined,
};
}

View File

@@ -18,6 +18,11 @@ const templateDashboardLinkVariableMap: Partial<
[WhatsAppTemplateIds.AlertNotePostedOwnerNotification]: "alert_link",
[WhatsAppTemplateIds.AlertStateChangedOwnerNotification]: "alert_link",
[WhatsAppTemplateIds.AlertOwnerAddedNotification]: "alert_link",
[WhatsAppTemplateIds.AlertEpisodeCreatedOwnerNotification]: "episode_link",
[WhatsAppTemplateIds.AlertEpisodeNotePostedOwnerNotification]: "episode_link",
[WhatsAppTemplateIds.AlertEpisodeStateChangedOwnerNotification]:
"episode_link",
[WhatsAppTemplateIds.AlertEpisodeOwnerAddedNotification]: "episode_link",
[WhatsAppTemplateIds.IncidentCreated]: "incident_link",
[WhatsAppTemplateIds.IncidentCreatedOwnerNotification]: "incident_link",
[WhatsAppTemplateIds.IncidentNotePostedOwnerNotification]: "incident_link",
@@ -72,6 +77,14 @@ const templateIdByEventType: Record<
WhatsAppTemplateIds.AlertStateChangedOwnerNotification,
[NotificationSettingEventType.SEND_ALERT_OWNER_ADDED_NOTIFICATION]:
WhatsAppTemplateIds.AlertOwnerAddedNotification,
[NotificationSettingEventType.SEND_ALERT_EPISODE_CREATED_OWNER_NOTIFICATION]:
WhatsAppTemplateIds.AlertEpisodeCreatedOwnerNotification,
[NotificationSettingEventType.SEND_ALERT_EPISODE_NOTE_POSTED_OWNER_NOTIFICATION]:
WhatsAppTemplateIds.AlertEpisodeNotePostedOwnerNotification,
[NotificationSettingEventType.SEND_ALERT_EPISODE_STATE_CHANGED_OWNER_NOTIFICATION]:
WhatsAppTemplateIds.AlertEpisodeStateChangedOwnerNotification,
[NotificationSettingEventType.SEND_ALERT_EPISODE_OWNER_ADDED_NOTIFICATION]:
WhatsAppTemplateIds.AlertEpisodeOwnerAddedNotification,
[NotificationSettingEventType.SEND_MONITOR_OWNER_ADDED_NOTIFICATION]:
WhatsAppTemplateIds.MonitorOwnerAddedNotification,
[NotificationSettingEventType.SEND_MONITOR_CREATED_OWNER_NOTIFICATION]:

View File

@@ -37,6 +37,23 @@ export enum MicrosoftTeamsAlertActionType {
SubmitChangeAlertState = "SubmitChangeAlertState",
}
// Alert Episode Actions
export enum MicrosoftTeamsAlertEpisodeActionType {
AckAlertEpisode = "AckAlertEpisode",
ResolveAlertEpisode = "ResolveAlertEpisode",
ViewAlertEpisode = "ViewAlertEpisode",
AlertEpisodeCreated = "AlertEpisodeCreated",
AlertEpisodeStateChanged = "AlertEpisodeStateChanged",
AddAlertEpisodeNote = "AddAlertEpisodeNote",
ExecuteAlertEpisodeOnCallPolicy = "ExecuteAlertEpisodeOnCallPolicy",
ViewAddAlertEpisodeNote = "ViewAddAlertEpisodeNote",
SubmitAlertEpisodeNote = "SubmitAlertEpisodeNote",
ViewExecuteAlertEpisodeOnCallPolicy = "ViewExecuteAlertEpisodeOnCallPolicy",
SubmitExecuteAlertEpisodeOnCallPolicy = "SubmitExecuteAlertEpisodeOnCallPolicy",
ViewChangeAlertEpisodeState = "ViewChangeAlertEpisodeState",
SubmitChangeAlertEpisodeState = "SubmitChangeAlertEpisodeState",
}
// Monitor Actions
export enum MicrosoftTeamsMonitorActionType {
ViewMonitor = "ViewMonitor",
@@ -89,6 +106,7 @@ export enum TeamsActivityType {
export type MicrosoftTeamsActionType =
| MicrosoftTeamsIncidentActionType
| MicrosoftTeamsAlertActionType
| MicrosoftTeamsAlertEpisodeActionType
| MicrosoftTeamsMonitorActionType
| MicrosoftTeamsScheduledMaintenanceActionType
| MicrosoftTeamsOnCallDutyActionType

View File

@@ -0,0 +1,689 @@
import { ExpressRequest, ExpressResponse } from "../../../Express";
import Response from "../../../Response";
import MicrosoftTeamsAuthAction, {
MicrosoftTeamsAction,
MicrosoftTeamsRequest,
} from "./Auth";
import { MicrosoftTeamsAlertEpisodeActionType } from "./ActionTypes";
import logger from "../../../Logger";
import ObjectID from "../../../../../Types/ObjectID";
import AlertEpisodeService from "../../../../Services/AlertEpisodeService";
import AlertEpisode from "../../../../../Models/DatabaseModels/AlertEpisode";
import CaptureSpan from "../../../Telemetry/CaptureSpan";
import { TurnContext } from "botbuilder";
import { JSONObject, JSONValue } from "../../../../../Types/JSON";
import AlertEpisodeInternalNoteService from "../../../../Services/AlertEpisodeInternalNoteService";
import OnCallDutyPolicyService from "../../../../Services/OnCallDutyPolicyService";
import AlertStateService from "../../../../Services/AlertStateService";
import UserNotificationEventType from "../../../../../Types/UserNotification/UserNotificationEventType";
import OnCallDutyPolicy from "../../../../../Models/DatabaseModels/OnCallDutyPolicy";
import AlertState from "../../../../../Models/DatabaseModels/AlertState";
export default class MicrosoftTeamsAlertEpisodeActions {
@CaptureSpan()
public static isAlertEpisodeAction(data: { actionType: string }): boolean {
return (
data.actionType.includes("AlertEpisode") ||
data.actionType ===
MicrosoftTeamsAlertEpisodeActionType.AckAlertEpisode ||
data.actionType ===
MicrosoftTeamsAlertEpisodeActionType.ResolveAlertEpisode ||
data.actionType ===
MicrosoftTeamsAlertEpisodeActionType.ViewAlertEpisode ||
data.actionType ===
MicrosoftTeamsAlertEpisodeActionType.AlertEpisodeCreated ||
data.actionType ===
MicrosoftTeamsAlertEpisodeActionType.AlertEpisodeStateChanged ||
data.actionType ===
MicrosoftTeamsAlertEpisodeActionType.ViewAddAlertEpisodeNote ||
data.actionType ===
MicrosoftTeamsAlertEpisodeActionType.SubmitAlertEpisodeNote ||
data.actionType ===
MicrosoftTeamsAlertEpisodeActionType.ViewExecuteAlertEpisodeOnCallPolicy ||
data.actionType ===
MicrosoftTeamsAlertEpisodeActionType.SubmitExecuteAlertEpisodeOnCallPolicy ||
data.actionType ===
MicrosoftTeamsAlertEpisodeActionType.ViewChangeAlertEpisodeState ||
data.actionType ===
MicrosoftTeamsAlertEpisodeActionType.SubmitChangeAlertEpisodeState
);
}
@CaptureSpan()
public static async handleAlertEpisodeAction(data: {
teamsRequest: MicrosoftTeamsRequest;
action: MicrosoftTeamsAction;
req: ExpressRequest;
res: ExpressResponse;
}): Promise<void> {
const { teamsRequest, action } = data;
logger.debug("Handling Microsoft Teams alert episode action:");
logger.debug(action);
try {
switch (action.actionType) {
case MicrosoftTeamsAlertEpisodeActionType.AckAlertEpisode:
await this.acknowledgeAlertEpisode({
teamsRequest,
action,
});
break;
case MicrosoftTeamsAlertEpisodeActionType.ResolveAlertEpisode:
await this.resolveAlertEpisode({
teamsRequest,
action,
});
break;
case MicrosoftTeamsAlertEpisodeActionType.ViewAlertEpisode:
// This is handled by opening the URL directly
break;
default:
logger.debug("Unhandled alert episode action: " + action.actionType);
break;
}
} catch (error) {
logger.error("Error handling Microsoft Teams alert episode action:");
logger.error(error);
}
Response.sendTextResponse(data.req, data.res, "");
}
@CaptureSpan()
private static async acknowledgeAlertEpisode(data: {
teamsRequest: MicrosoftTeamsRequest;
action: MicrosoftTeamsAction;
}): Promise<void> {
const episodeId: string = data.action.actionValue || "";
if (!episodeId) {
logger.error("No episode ID provided for acknowledge action");
return;
}
logger.debug("Acknowledging alert episode: " + episodeId);
try {
const episode: AlertEpisode | null = await AlertEpisodeService.findOneBy({
query: {
_id: episodeId,
projectId: data.teamsRequest.projectId,
},
select: {
_id: true,
projectId: true,
currentAlertState: {
_id: true,
name: true,
isAcknowledgedState: true,
},
},
props: {
isRoot: true,
},
});
if (!episode) {
logger.error("Alert episode not found: " + episodeId);
return;
}
if (episode.currentAlertState?.isAcknowledgedState) {
logger.debug("Alert episode is already acknowledged");
return;
}
const oneUptimeUserId: ObjectID =
await MicrosoftTeamsAuthAction.getOneUptimeUserIdFromTeamsUserId({
teamsUserId: data.teamsRequest.userId || "",
projectId: data.teamsRequest.projectId,
});
await AlertEpisodeService.acknowledgeEpisode(
new ObjectID(episodeId),
oneUptimeUserId,
);
logger.debug("Alert episode acknowledged successfully");
} catch (error) {
logger.error("Error acknowledging alert episode:");
logger.error(error);
}
}
@CaptureSpan()
private static async resolveAlertEpisode(data: {
teamsRequest: MicrosoftTeamsRequest;
action: MicrosoftTeamsAction;
}): Promise<void> {
const episodeId: string = data.action.actionValue || "";
if (!episodeId) {
logger.error("No episode ID provided for resolve action");
return;
}
logger.debug("Resolving alert episode: " + episodeId);
try {
const episode: AlertEpisode | null = await AlertEpisodeService.findOneBy({
query: {
_id: episodeId,
projectId: data.teamsRequest.projectId,
},
select: {
_id: true,
projectId: true,
currentAlertState: {
_id: true,
name: true,
isResolvedState: true,
},
},
props: {
isRoot: true,
},
});
if (!episode) {
logger.error("Alert episode not found: " + episodeId);
return;
}
if (episode.currentAlertState?.isResolvedState) {
logger.debug("Alert episode is already resolved");
return;
}
const oneUptimeUserId: ObjectID =
await MicrosoftTeamsAuthAction.getOneUptimeUserIdFromTeamsUserId({
teamsUserId: data.teamsRequest.userId || "",
projectId: data.teamsRequest.projectId,
});
await AlertEpisodeService.resolveEpisode(
new ObjectID(episodeId),
oneUptimeUserId,
);
logger.debug("Alert episode resolved successfully");
} catch (error) {
logger.error("Error resolving alert episode:");
logger.error(error);
}
}
@CaptureSpan()
public static async handleBotAlertEpisodeAction(data: {
actionType: string;
actionValue: string;
value: JSONObject;
projectId: ObjectID;
oneUptimeUserId: ObjectID;
turnContext: TurnContext;
}): Promise<void> {
const {
actionType,
actionValue,
value,
projectId,
oneUptimeUserId,
turnContext,
} = data;
if (actionType === MicrosoftTeamsAlertEpisodeActionType.AckAlertEpisode) {
if (!actionValue) {
await turnContext.sendActivity(
"Unable to acknowledge: missing alert episode id.",
);
return;
}
await AlertEpisodeService.acknowledgeEpisode(
new ObjectID(actionValue),
oneUptimeUserId,
);
await turnContext.sendActivity("✅ Alert episode acknowledged.");
return;
}
if (
actionType === MicrosoftTeamsAlertEpisodeActionType.ResolveAlertEpisode
) {
if (!actionValue) {
await turnContext.sendActivity(
"Unable to resolve: missing alert episode id.",
);
return;
}
await AlertEpisodeService.resolveEpisode(
new ObjectID(actionValue),
oneUptimeUserId,
);
await turnContext.sendActivity("✅ Alert episode resolved.");
return;
}
if (actionType === MicrosoftTeamsAlertEpisodeActionType.ViewAlertEpisode) {
if (!actionValue) {
await turnContext.sendActivity(
"Unable to view alert episode: missing episode id.",
);
return;
}
const episode: AlertEpisode | null = await AlertEpisodeService.findOneBy({
query: {
_id: actionValue,
projectId: projectId,
},
select: {
_id: true,
title: true,
description: true,
currentAlertState: {
name: true,
},
alertSeverity: {
name: true,
},
createdAt: true,
alertCount: true,
},
props: {
isRoot: true,
},
});
if (!episode) {
await turnContext.sendActivity("Alert episode not found.");
return;
}
const message: string = `**Alert Episode Details**\n\n**Title:** ${episode.title}\n**Description:** ${episode.description || "No description"}\n**State:** ${episode.currentAlertState?.name || "Unknown"}\n**Severity:** ${episode.alertSeverity?.name || "Unknown"}\n**Alert Count:** ${episode.alertCount || 0}\n**Created At:** ${episode.createdAt ? new Date(episode.createdAt).toLocaleString() : "Unknown"}`;
await turnContext.sendActivity(message);
return;
}
if (
actionType ===
MicrosoftTeamsAlertEpisodeActionType.ViewAddAlertEpisodeNote
) {
if (!actionValue) {
await turnContext.sendActivity(
"Unable to add note: missing episode id.",
);
return;
}
// Send the input card
const card: JSONObject = this.buildAddAlertEpisodeNoteCard(actionValue);
await turnContext.sendActivity({
attachments: [
{
contentType: "application/vnd.microsoft.card.adaptive",
content: card,
},
],
});
return;
}
if (
actionType === MicrosoftTeamsAlertEpisodeActionType.SubmitAlertEpisodeNote
) {
if (!actionValue) {
await turnContext.sendActivity(
"Unable to add note: missing episode id.",
);
return;
}
// Check if form data is provided
const note: JSONValue = value["note"];
if (note) {
// Submit the note
const episodeId: ObjectID = new ObjectID(actionValue);
await AlertEpisodeInternalNoteService.addNote({
alertEpisodeId: episodeId,
note: note.toString(),
projectId: projectId,
userId: oneUptimeUserId,
});
await turnContext.sendActivity("✅ Note added successfully.");
// Hide the form card by deleting it
if (turnContext.activity.replyToId) {
await turnContext.deleteActivity(turnContext.activity.replyToId);
}
return;
}
await turnContext.sendActivity("Unable to add note: missing note data.");
return;
}
if (
actionType ===
MicrosoftTeamsAlertEpisodeActionType.ViewExecuteAlertEpisodeOnCallPolicy
) {
if (!actionValue) {
await turnContext.sendActivity(
"Unable to execute on-call policy: missing episode id.",
);
return;
}
// Send the input card
const card: JSONObject | null =
await this.buildExecuteAlertEpisodeOnCallPolicyCard(
actionValue,
projectId,
);
if (!card) {
await turnContext.sendActivity(
"No on-call policies found in the project",
);
return;
}
await turnContext.sendActivity({
attachments: [
{
contentType: "application/vnd.microsoft.card.adaptive",
content: card,
},
],
});
return;
}
if (
actionType ===
MicrosoftTeamsAlertEpisodeActionType.SubmitExecuteAlertEpisodeOnCallPolicy
) {
if (!actionValue) {
await turnContext.sendActivity(
"Unable to execute on-call policy: missing episode id.",
);
return;
}
// Check if form data is provided
const onCallPolicyId: JSONValue = value["onCallPolicy"];
if (onCallPolicyId) {
// Execute the policy
const episodeId: ObjectID = new ObjectID(actionValue);
await OnCallDutyPolicyService.executePolicy(
new ObjectID(onCallPolicyId.toString()),
{
triggeredByAlertEpisodeId: episodeId,
userNotificationEventType:
UserNotificationEventType.AlertEpisodeCreated,
},
);
await turnContext.sendActivity(
"✅ On-call policy executed successfully.",
);
// Hide the form card by deleting it
if (turnContext.activity.replyToId) {
await turnContext.deleteActivity(turnContext.activity.replyToId);
}
return;
}
await turnContext.sendActivity(
"Unable to execute on-call policy: missing policy id.",
);
return;
}
if (
actionType ===
MicrosoftTeamsAlertEpisodeActionType.ViewChangeAlertEpisodeState
) {
if (!actionValue) {
await turnContext.sendActivity(
"Unable to change episode state: missing episode id.",
);
return;
}
// Send the input card
const card: JSONObject = await this.buildChangeAlertEpisodeStateCard(
actionValue,
projectId,
);
await turnContext.sendActivity({
attachments: [
{
contentType: "application/vnd.microsoft.card.adaptive",
content: card,
},
],
});
return;
}
if (
actionType ===
MicrosoftTeamsAlertEpisodeActionType.SubmitChangeAlertEpisodeState
) {
if (!actionValue) {
await turnContext.sendActivity(
"Unable to change episode state: missing episode id.",
);
return;
}
// Check if form data is provided
const alertStateId: JSONValue = value["alertState"];
if (alertStateId) {
// Update the state
const episodeId: ObjectID = new ObjectID(actionValue);
await AlertEpisodeService.updateOneById({
id: episodeId,
data: {
currentAlertStateId: new ObjectID(alertStateId.toString()),
},
props: {
isRoot: true,
},
});
await turnContext.sendActivity(
"✅ Alert episode state changed successfully.",
);
// Hide the form card by deleting it
if (turnContext.activity.replyToId) {
await turnContext.deleteActivity(turnContext.activity.replyToId);
}
return;
}
await turnContext.sendActivity(
"Unable to change episode state: missing state id.",
);
return;
}
// Default fallback for unimplemented actions
await turnContext.sendActivity(
"Sorry, but the action " +
actionType +
" you requested is not implemented yet.",
);
}
private static buildAddAlertEpisodeNoteCard(episodeId: string): JSONObject {
return {
type: "AdaptiveCard",
$schema: "http://adaptivecards.io/schemas/adaptive-card.json",
version: "1.5",
body: [
{
type: "TextBlock",
text: "Add Alert Episode Note",
size: "Large",
weight: "Bolder",
},
{
type: "Input.Text",
id: "note",
label: "Note",
isMultiline: true,
placeholder: "Please type in plain text or markdown.",
},
],
actions: [
{
type: "Action.Submit",
title: "Submit",
data: {
action: MicrosoftTeamsAlertEpisodeActionType.SubmitAlertEpisodeNote,
actionValue: episodeId,
},
},
],
};
}
private static async buildExecuteAlertEpisodeOnCallPolicyCard(
episodeId: string,
projectId: ObjectID,
): Promise<JSONObject | null> {
const onCallPolicies: Array<OnCallDutyPolicy> =
await OnCallDutyPolicyService.findBy({
query: {
projectId: projectId,
},
select: {
name: true,
_id: true,
},
props: {
isRoot: true,
},
limit: 50,
skip: 0,
});
const choices: Array<{ title: string; value: string }> = onCallPolicies
.map((policy: OnCallDutyPolicy) => {
return {
title: policy.name || "",
value: policy._id?.toString() || "",
};
})
.filter((choice: { title: string; value: string }) => {
return choice.title && choice.value;
});
if (choices.length === 0) {
return null;
}
return {
type: "AdaptiveCard",
$schema: "http://adaptivecards.io/schemas/adaptive-card.json",
version: "1.5",
body: [
{
type: "TextBlock",
text: "Execute On-Call Policy",
size: "Large",
weight: "Bolder",
},
{
type: "Input.ChoiceSet",
id: "onCallPolicy",
label: "On-Call Policy",
style: "compact",
choices: choices,
},
],
actions: [
{
type: "Action.Submit",
title: "Execute",
data: {
action:
MicrosoftTeamsAlertEpisodeActionType.SubmitExecuteAlertEpisodeOnCallPolicy,
actionValue: episodeId,
},
},
],
};
}
private static async buildChangeAlertEpisodeStateCard(
episodeId: string,
projectId: ObjectID,
): Promise<JSONObject> {
const alertStates: Array<AlertState> =
await AlertStateService.getAllAlertStates({
projectId: projectId,
props: {
isRoot: true,
},
});
const choices: Array<{ title: string; value: string }> = alertStates
.map((state: AlertState) => {
return {
title: state.name || "",
value: state._id?.toString() || "",
};
})
.filter((choice: { title: string; value: string }) => {
return choice.title && choice.value;
});
return {
type: "AdaptiveCard",
$schema: "http://adaptivecards.io/schemas/adaptive-card.json",
version: "1.5",
body: [
{
type: "TextBlock",
text: "Change Alert Episode State",
size: "Large",
weight: "Bolder",
},
{
type: "Input.ChoiceSet",
id: "alertState",
label: "Alert State",
style: "compact",
choices: choices,
},
],
actions: [
{
type: "Action.Submit",
title: "Change",
data: {
action:
MicrosoftTeamsAlertEpisodeActionType.SubmitChangeAlertEpisodeState,
actionValue: episodeId,
},
},
],
};
}
}

View File

@@ -85,6 +85,7 @@ import {
MicrosoftTeamsOnCallDutyActionType,
} from "./Actions/ActionTypes";
import MicrosoftTeamsAlertActions from "./Actions/Alert";
import MicrosoftTeamsAlertEpisodeActions from "./Actions/AlertEpisode";
import MicrosoftTeamsMonitorActions from "./Actions/Monitor";
import MicrosoftTeamsScheduledMaintenanceActions from "./Actions/ScheduledMaintenance";
import MicrosoftTeamsOnCallDutyActions from "./Actions/OnCallDutyPolicy";
@@ -2519,6 +2520,21 @@ All monitoring checks are passing normally.`;
return;
}
// Handle alert episode actions
if (
MicrosoftTeamsAlertEpisodeActions.isAlertEpisodeAction({ actionType })
) {
await MicrosoftTeamsAlertEpisodeActions.handleBotAlertEpisodeAction({
actionType,
actionValue,
value,
projectId,
oneUptimeUserId,
turnContext: data.turnContext,
});
return;
}
// Handle monitor actions
if (MicrosoftTeamsMonitorActions.isMonitorAction({ actionType })) {
await MicrosoftTeamsMonitorActions.handleBotMonitorAction({

View File

@@ -26,6 +26,17 @@ enum SlackActionType {
SubmitExecuteAlertOnCallPolicy = "SubmitExecuteAlertOnCallPolicy",
ViewAlert = "ViewAlert",
// Alert Episode Actions
AcknowledgeAlertEpisode = "AcknowledgeAlertEpisode",
ResolveAlertEpisode = "ResolveAlertEpisode",
ViewAddAlertEpisodeNote = "ViewAddAlertEpisodeNote",
SubmitAlertEpisodeNote = "SubmitAlertEpisodeNote",
ViewChangeAlertEpisodeState = "ViewChangeAlertEpisodeState",
SubmitChangeAlertEpisodeState = "SubmitChangeAlertEpisodeState",
ViewExecuteAlertEpisodeOnCallPolicy = "ViewExecuteAlertEpisodeOnCallPolicy",
SubmitExecuteAlertEpisodeOnCallPolicy = "SubmitExecuteAlertEpisodeOnCallPolicy",
ViewAlertEpisode = "ViewAlertEpisode",
// Scheduled Maintenance Actions just like Incident Actions.
MarkScheduledMaintenanceAsComplete = "MarkScheduledMaintenanceAsComplete",
MarkScheduledMaintenanceAsOngoing = "MarkScheduledMaintenanceAsOngoing",

View File

@@ -0,0 +1,915 @@
import BadDataException from "../../../../../Types/Exception/BadDataException";
import ObjectID from "../../../../../Types/ObjectID";
import AlertEpisodeService from "../../../../Services/AlertEpisodeService";
import { ExpressRequest, ExpressResponse } from "../../../Express";
import SlackUtil from "../Slack";
import SlackActionType, { PrivateNoteEmojis } from "./ActionTypes";
import { SlackAction, SlackRequest } from "./Auth";
import Response from "../../../Response";
import {
WorkspaceDropdownBlock,
WorkspaceModalBlock,
WorkspacePayloadMarkdown,
WorkspaceTextAreaBlock,
} from "../../../../../Types/Workspace/WorkspaceMessagePayload";
import AlertEpisodeInternalNoteService from "../../../../Services/AlertEpisodeInternalNoteService";
import OnCallDutyPolicy from "../../../../../Models/DatabaseModels/OnCallDutyPolicy";
import OnCallDutyPolicyService from "../../../../Services/OnCallDutyPolicyService";
import { LIMIT_PER_PROJECT } from "../../../../../Types/Database/LimitMax";
import { DropdownOption } from "../../../../../UI/Components/Dropdown/Dropdown";
import UserNotificationEventType from "../../../../../Types/UserNotification/UserNotificationEventType";
import AlertState from "../../../../../Models/DatabaseModels/AlertState";
import AlertStateService from "../../../../Services/AlertStateService";
import logger from "../../../Logger";
import AccessTokenService from "../../../../Services/AccessTokenService";
import CaptureSpan from "../../../Telemetry/CaptureSpan";
import WorkspaceNotificationLogService from "../../../../Services/WorkspaceNotificationLogService";
import WorkspaceUserAuthTokenService from "../../../../Services/WorkspaceUserAuthTokenService";
import WorkspaceType from "../../../../../Types/Workspace/WorkspaceType";
import WorkspaceProjectAuthTokenService from "../../../../Services/WorkspaceProjectAuthTokenService";
import WorkspaceNotificationLog from "../../../../../Models/DatabaseModels/WorkspaceNotificationLog";
import WorkspaceProjectAuthToken from "../../../../../Models/DatabaseModels/WorkspaceProjectAuthToken";
import WorkspaceUserAuthToken from "../../../../../Models/DatabaseModels/WorkspaceUserAuthToken";
export default class SlackAlertEpisodeActions {
@CaptureSpan()
public static isAlertEpisodeAction(data: {
actionType: SlackActionType;
}): boolean {
const { actionType } = data;
switch (actionType) {
case SlackActionType.AcknowledgeAlertEpisode:
case SlackActionType.ResolveAlertEpisode:
case SlackActionType.ViewAddAlertEpisodeNote:
case SlackActionType.SubmitAlertEpisodeNote:
case SlackActionType.ViewChangeAlertEpisodeState:
case SlackActionType.SubmitChangeAlertEpisodeState:
case SlackActionType.ViewExecuteAlertEpisodeOnCallPolicy:
case SlackActionType.SubmitExecuteAlertEpisodeOnCallPolicy:
case SlackActionType.ViewAlertEpisode:
return true;
default:
return false;
}
}
@CaptureSpan()
public static async acknowledgeAlertEpisode(data: {
slackRequest: SlackRequest;
action: SlackAction;
req: ExpressRequest;
res: ExpressResponse;
}): Promise<void> {
const { slackRequest, req, res } = data;
const { botUserId, userId, projectAuthToken, slackUsername } = slackRequest;
const { actionValue } = data.action;
if (!actionValue) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid Alert Episode ID"),
);
}
if (!userId) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid User ID"),
);
}
if (!projectAuthToken) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid Project Auth Token"),
);
}
if (!botUserId) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid Bot User ID"),
);
}
if (data.action.actionType === SlackActionType.AcknowledgeAlertEpisode) {
const episodeId: ObjectID = new ObjectID(actionValue);
// We send this early let slack know we're ok. We'll do the rest in the background.
Response.sendJsonObjectResponse(req, res, {
response_action: "clear",
});
const isAlreadyAcknowledged: boolean =
await AlertEpisodeService.isEpisodeAcknowledged({
episodeId: episodeId,
});
if (isAlreadyAcknowledged) {
// send a message to the channel visible to user, that the episode has already been acknowledged.
const markdwonPayload: WorkspacePayloadMarkdown = {
_type: "WorkspacePayloadMarkdown",
text: `@${slackUsername}, unfortunately you cannot acknowledge the **[Alert Episode](${await AlertEpisodeService.getEpisodeLinkInDashboard(slackRequest.projectId!, episodeId)})**. It has already been acknowledged.`,
};
await SlackUtil.sendDirectMessageToUser({
messageBlocks: [markdwonPayload],
authToken: projectAuthToken,
workspaceUserId: slackRequest.slackUserId!,
});
return;
}
await AlertEpisodeService.acknowledgeEpisode(episodeId, userId);
// Log the button interaction
if (slackRequest.projectId) {
try {
const logData: {
projectId: ObjectID;
workspaceType: WorkspaceType;
channelId?: string;
userId: ObjectID;
buttonAction: string;
alertEpisodeId?: ObjectID;
} = {
projectId: slackRequest.projectId,
workspaceType: WorkspaceType.Slack,
userId: userId,
buttonAction: "acknowledge_alert_episode",
};
if (slackRequest.slackChannelId) {
logData.channelId = slackRequest.slackChannelId;
}
logData.alertEpisodeId = episodeId;
await WorkspaceNotificationLogService.logButtonPressed(logData, {
isRoot: true,
});
} catch (err) {
logger.error("Error logging button interaction:");
logger.error(err);
// Don't throw the error, just log it so the main flow continues
}
}
return;
}
// invalid action type.
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid Action Type"),
);
}
@CaptureSpan()
public static async resolveAlertEpisode(data: {
slackRequest: SlackRequest;
action: SlackAction;
req: ExpressRequest;
res: ExpressResponse;
}): Promise<void> {
const { slackRequest, req, res } = data;
const { botUserId, userId, projectAuthToken, slackUsername } = slackRequest;
const { actionValue } = data.action;
if (!actionValue) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid Alert Episode ID"),
);
}
if (!userId) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid User ID"),
);
}
if (!projectAuthToken) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid Project Auth Token"),
);
}
if (!botUserId) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid Bot User ID"),
);
}
if (data.action.actionType === SlackActionType.ResolveAlertEpisode) {
const episodeId: ObjectID = new ObjectID(actionValue);
// We send this early let slack know we're ok. We'll do the rest in the background.
Response.sendJsonObjectResponse(req, res, {
response_action: "clear",
});
const isAlreadyResolved: boolean =
await AlertEpisodeService.isEpisodeResolved(episodeId);
if (isAlreadyResolved) {
// send a message to the channel visible to user, that the episode has already been Resolved.
const markdwonPayload: WorkspacePayloadMarkdown = {
_type: "WorkspacePayloadMarkdown",
text: `@${slackUsername}, unfortunately you cannot resolve the **[Alert Episode](${await AlertEpisodeService.getEpisodeLinkInDashboard(slackRequest.projectId!, episodeId)})**. It has already been resolved.`,
};
await SlackUtil.sendDirectMessageToUser({
messageBlocks: [markdwonPayload],
authToken: projectAuthToken,
workspaceUserId: slackRequest.slackUserId!,
});
return;
}
await AlertEpisodeService.resolveEpisode(episodeId, userId);
return;
}
// invalid action type.
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid Action Type"),
);
}
@CaptureSpan()
public static async viewExecuteOnCallPolicy(data: {
slackRequest: SlackRequest;
action: SlackAction;
req: ExpressRequest;
res: ExpressResponse;
}): Promise<void> {
const { req, res } = data;
const { actionValue } = data.action;
if (!actionValue) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid Alert Episode ID"),
);
}
// We send this early let slack know we're ok. We'll do the rest in the background.
Response.sendJsonObjectResponse(req, res, {
response_action: "clear",
});
const onCallPolicies: Array<OnCallDutyPolicy> =
await OnCallDutyPolicyService.findBy({
query: {
projectId: data.slackRequest.projectId!,
},
select: {
name: true,
},
props: {
isRoot: true,
},
limit: LIMIT_PER_PROJECT,
skip: 0,
});
const dropdownOption: Array<DropdownOption> = onCallPolicies
.map((policy: OnCallDutyPolicy) => {
return {
label: policy.name || "",
value: policy._id?.toString() || "",
};
})
.filter((option: DropdownOption) => {
return option.label !== "" || option.value !== "";
});
const onCallPolicyDropdown: WorkspaceDropdownBlock = {
_type: "WorkspaceDropdownBlock",
label: "On Call Policy",
blockId: "onCallPolicy",
placeholder: "Select On Call Policy",
options: dropdownOption,
};
const modalBlock: WorkspaceModalBlock = {
_type: "WorkspaceModalBlock",
title: "Execute On Call Policy",
submitButtonTitle: "Submit",
cancelButtonTitle: "Cancel",
actionId: SlackActionType.SubmitExecuteAlertEpisodeOnCallPolicy,
actionValue: actionValue,
blocks: [onCallPolicyDropdown],
};
await SlackUtil.showModalToUser({
authToken: data.slackRequest.projectAuthToken!,
modalBlock: modalBlock,
triggerId: data.slackRequest.triggerId!,
});
}
@CaptureSpan()
public static async viewChangeAlertEpisodeState(data: {
slackRequest: SlackRequest;
action: SlackAction;
req: ExpressRequest;
res: ExpressResponse;
}): Promise<void> {
const { req, res } = data;
const { actionValue } = data.action;
if (!actionValue) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid Alert Episode ID"),
);
}
// We send this early let slack know we're ok. We'll do the rest in the background.
Response.sendJsonObjectResponse(req, res, {
response_action: "clear",
});
// Alert Episodes use the same alert states
const alertStates: Array<AlertState> =
await AlertStateService.getAllAlertStates({
projectId: data.slackRequest.projectId!,
props: {
isRoot: true,
},
});
const dropdownOptions: Array<DropdownOption> = alertStates
.map((state: AlertState) => {
return {
label: state.name || "",
value: state._id?.toString() || "",
};
})
.filter((option: DropdownOption) => {
return option.label !== "" || option.value !== "";
});
const statePickerDropdown: WorkspaceDropdownBlock = {
_type: "WorkspaceDropdownBlock",
label: "Episode State",
blockId: "episodeState",
placeholder: "Select Episode State",
options: dropdownOptions,
};
const modalBlock: WorkspaceModalBlock = {
_type: "WorkspaceModalBlock",
title: "Change Episode State",
submitButtonTitle: "Submit",
cancelButtonTitle: "Cancel",
actionId: SlackActionType.SubmitChangeAlertEpisodeState,
actionValue: actionValue,
blocks: [statePickerDropdown],
};
await SlackUtil.showModalToUser({
authToken: data.slackRequest.projectAuthToken!,
modalBlock: modalBlock,
triggerId: data.slackRequest.triggerId!,
});
}
@CaptureSpan()
public static async submitChangeAlertEpisodeState(data: {
slackRequest: SlackRequest;
action: SlackAction;
req: ExpressRequest;
res: ExpressResponse;
}): Promise<void> {
const { req, res } = data;
const { actionValue } = data.action;
if (!actionValue) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid Alert Episode ID"),
);
}
// We send this early let slack know we're ok. We'll do the rest in the background.
Response.sendJsonObjectResponse(req, res, {
response_action: "clear",
});
if (
!data.slackRequest.viewValues ||
!data.slackRequest.viewValues["episodeState"]
) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid View Values"),
);
}
const episodeId: ObjectID = new ObjectID(actionValue);
const stateString: string =
data.slackRequest.viewValues["episodeState"].toString();
const stateId: ObjectID = new ObjectID(stateString);
await AlertEpisodeService.updateOneById({
id: episodeId,
data: {
currentAlertStateId: stateId,
},
props:
await AccessTokenService.getDatabaseCommonInteractionPropsByUserAndProject(
{
userId: data.slackRequest.userId!,
projectId: data.slackRequest.projectId!,
},
),
});
// Log the button interaction
if (data.slackRequest.projectId && data.slackRequest.userId) {
try {
const logData: {
projectId: ObjectID;
workspaceType: WorkspaceType;
channelId?: string;
userId: ObjectID;
buttonAction: string;
alertEpisodeId?: ObjectID;
} = {
projectId: data.slackRequest.projectId,
workspaceType: WorkspaceType.Slack,
userId: data.slackRequest.userId,
buttonAction: "change_alert_episode_state",
};
if (data.slackRequest.slackChannelId) {
logData.channelId = data.slackRequest.slackChannelId;
}
logData.alertEpisodeId = episodeId;
await WorkspaceNotificationLogService.logButtonPressed(logData, {
isRoot: true,
});
} catch (err) {
logger.error("Error logging button interaction:");
logger.error(err);
// Don't throw the error, just log it so the main flow continues
}
}
}
@CaptureSpan()
public static async executeOnCallPolicy(data: {
slackRequest: SlackRequest;
action: SlackAction;
req: ExpressRequest;
res: ExpressResponse;
}): Promise<void> {
const { slackRequest, req, res } = data;
const { botUserId, userId, projectAuthToken, slackUsername } = slackRequest;
const { actionValue } = data.action;
if (!actionValue) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid Alert Episode ID"),
);
}
if (!userId) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid User ID"),
);
}
if (!projectAuthToken) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid Project Auth Token"),
);
}
if (!botUserId) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid Bot User ID"),
);
}
if (
data.action.actionType ===
SlackActionType.SubmitExecuteAlertEpisodeOnCallPolicy
) {
const episodeId: ObjectID = new ObjectID(actionValue);
// We send this early let slack know we're ok. We'll do the rest in the background.
Response.sendJsonObjectResponse(req, res, {
response_action: "clear",
});
const isAlreadyResolved: boolean =
await AlertEpisodeService.isEpisodeResolved(episodeId);
if (isAlreadyResolved) {
// send a message to the channel visible to user, that the episode has already been Resolved.
const markdwonPayload: WorkspacePayloadMarkdown = {
_type: "WorkspacePayloadMarkdown",
text: `@${slackUsername}, unfortunately you cannot execute the on-call policy for **[Alert Episode](${await AlertEpisodeService.getEpisodeLinkInDashboard(slackRequest.projectId!, episodeId)})**. It has already been resolved.`,
};
await SlackUtil.sendDirectMessageToUser({
messageBlocks: [markdwonPayload],
authToken: projectAuthToken,
workspaceUserId: slackRequest.slackUserId!,
});
return;
}
if (
!data.slackRequest.viewValues ||
!data.slackRequest.viewValues["onCallPolicy"]
) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid View Values"),
);
}
const onCallPolicyString: string =
data.slackRequest.viewValues["onCallPolicy"].toString();
// get the on-call policy id.
const onCallPolicyId: ObjectID = new ObjectID(onCallPolicyString);
await OnCallDutyPolicyService.executePolicy(onCallPolicyId, {
triggeredByAlertEpisodeId: episodeId,
userNotificationEventType: UserNotificationEventType.AlertCreated,
});
}
}
@CaptureSpan()
public static async submitAlertEpisodeNote(data: {
slackRequest: SlackRequest;
action: SlackAction;
req: ExpressRequest;
res: ExpressResponse;
}): Promise<void> {
const { req, res } = data;
const { actionValue } = data.action;
if (!actionValue) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid Alert Episode ID"),
);
}
if (!data.slackRequest.viewValues) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid View Values"),
);
}
if (!data.slackRequest.viewValues["note"]) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid Note"),
);
}
const episodeId: ObjectID = new ObjectID(actionValue);
const note: string = data.slackRequest.viewValues["note"].toString();
// send empty response.
Response.sendJsonObjectResponse(req, res, {
response_action: "clear",
});
await AlertEpisodeInternalNoteService.addNote({
alertEpisodeId: episodeId!,
note: note || "",
projectId: data.slackRequest.projectId!,
userId: data.slackRequest.userId!,
});
}
@CaptureSpan()
public static async viewAddAlertEpisodeNote(data: {
slackRequest: SlackRequest;
action: SlackAction;
req: ExpressRequest;
res: ExpressResponse;
}): Promise<void> {
const { req, res } = data;
const { actionValue } = data.action;
if (!actionValue) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid Alert Episode ID"),
);
}
// We send this early let slack know we're ok. We'll do the rest in the background.
Response.sendJsonObjectResponse(req, res, {
response_action: "clear",
});
const noteTextArea: WorkspaceTextAreaBlock = {
_type: "WorkspaceTextAreaBlock",
label: "Note",
blockId: "note",
placeholder: "Note",
description: "Please type in plain text or markdown.",
};
const modalBlock: WorkspaceModalBlock = {
_type: "WorkspaceModalBlock",
title: "Add Note",
submitButtonTitle: "Submit",
cancelButtonTitle: "Cancel",
actionId: SlackActionType.SubmitAlertEpisodeNote,
actionValue: actionValue,
blocks: [noteTextArea],
};
await SlackUtil.showModalToUser({
authToken: data.slackRequest.projectAuthToken!,
modalBlock: modalBlock,
triggerId: data.slackRequest.triggerId!,
});
}
@CaptureSpan()
public static async handleAlertEpisodeAction(data: {
slackRequest: SlackRequest;
action: SlackAction;
req: ExpressRequest;
res: ExpressResponse;
}): Promise<void> {
const actionType: SlackActionType | undefined = data.action.actionType;
if (actionType === SlackActionType.AcknowledgeAlertEpisode) {
return await this.acknowledgeAlertEpisode(data);
}
if (actionType === SlackActionType.ResolveAlertEpisode) {
return await this.resolveAlertEpisode(data);
}
if (actionType === SlackActionType.ViewAddAlertEpisodeNote) {
return await this.viewAddAlertEpisodeNote(data);
}
if (actionType === SlackActionType.SubmitAlertEpisodeNote) {
return await this.submitAlertEpisodeNote(data);
}
if (actionType === SlackActionType.ViewExecuteAlertEpisodeOnCallPolicy) {
return await this.viewExecuteOnCallPolicy(data);
}
if (actionType === SlackActionType.SubmitExecuteAlertEpisodeOnCallPolicy) {
return await this.executeOnCallPolicy(data);
}
if (actionType === SlackActionType.ViewChangeAlertEpisodeState) {
return await this.viewChangeAlertEpisodeState(data);
}
if (actionType === SlackActionType.SubmitChangeAlertEpisodeState) {
return await this.submitChangeAlertEpisodeState(data);
}
if (actionType === SlackActionType.ViewAlertEpisode) {
// do nothing. This is just a view episode action.
return Response.sendJsonObjectResponse(data.req, data.res, {
response_action: "clear",
});
}
// invalid action type.
return Response.sendErrorResponse(
data.req,
data.res,
new BadDataException("Invalid Action Type"),
);
}
@CaptureSpan()
public static async handleEmojiReaction(data: {
teamId: string;
reaction: string;
userId: string;
channelId: string;
messageTs: string;
}): Promise<void> {
logger.debug("Handling emoji reaction for Alert Episode with data:");
logger.debug(data);
const { teamId, reaction, userId, channelId, messageTs } = data;
// Alert Episodes only support private notes
const isPrivateNoteEmoji: boolean = PrivateNoteEmojis.includes(reaction);
if (!isPrivateNoteEmoji) {
logger.debug(
`Emoji "${reaction}" is not a supported private note emoji for alert episodes. Ignoring.`,
);
return;
}
// Get the project auth token using the team ID
const projectAuth: WorkspaceProjectAuthToken | null =
await WorkspaceProjectAuthTokenService.findOneBy({
query: {
workspaceProjectId: teamId,
},
select: {
projectId: true,
authToken: true,
},
props: {
isRoot: true,
},
});
if (!projectAuth || !projectAuth.projectId || !projectAuth.authToken) {
logger.debug(
"No project auth found for team ID. Ignoring emoji reaction.",
);
return;
}
const projectId: ObjectID = projectAuth.projectId;
const authToken: string = projectAuth.authToken;
// Find the alert episode linked to this channel
const workspaceLog: WorkspaceNotificationLog | null =
await WorkspaceNotificationLogService.findOneBy({
query: {
channelId: channelId,
workspaceType: WorkspaceType.Slack,
projectId: projectId,
},
select: {
alertEpisodeId: true,
},
props: {
isRoot: true,
},
});
if (!workspaceLog || !workspaceLog.alertEpisodeId) {
logger.debug(
"No alert episode found linked to this channel. Ignoring emoji reaction.",
);
return;
}
const episodeId: ObjectID = workspaceLog.alertEpisodeId;
// Get the user ID in OneUptime based on Slack user ID
const userAuth: WorkspaceUserAuthToken | null =
await WorkspaceUserAuthTokenService.findOneBy({
query: {
workspaceUserId: userId,
workspaceType: WorkspaceType.Slack,
projectId: projectId,
},
select: {
userId: true,
},
props: {
isRoot: true,
},
});
if (!userAuth || !userAuth.userId) {
logger.debug(
"No OneUptime user found for Slack user. Ignoring emoji reaction.",
);
return;
}
const oneUptimeUserId: ObjectID = userAuth.userId;
// Fetch the message text using the timestamp
let messageText: string | null = null;
try {
messageText = await SlackUtil.getMessageByTimestamp({
authToken: authToken,
channelId: channelId,
messageTs: messageTs,
});
} catch (err) {
logger.error("Error fetching message text:");
logger.error(err);
return;
}
if (!messageText) {
logger.debug("No message text found. Ignoring emoji reaction.");
return;
}
// Create a unique identifier for this Slack message to prevent duplicate notes
const postedFromSlackMessageId: string = `${channelId}:${messageTs}`;
// Check if a note from this Slack message already exists
const hasExistingNote: boolean =
await AlertEpisodeInternalNoteService.hasNoteFromSlackMessage({
alertEpisodeId: episodeId,
postedFromSlackMessageId: postedFromSlackMessageId,
});
if (hasExistingNote) {
logger.debug(
"Private note from this Slack message already exists. Skipping duplicate.",
);
return;
}
// Save as private note
try {
await AlertEpisodeInternalNoteService.addNote({
alertEpisodeId: episodeId,
note: messageText,
projectId: projectId,
userId: oneUptimeUserId,
postedFromSlackMessageId: postedFromSlackMessageId,
});
logger.debug("Private note added to alert episode successfully.");
} catch (err) {
logger.error("Error saving note:");
logger.error(err);
return;
}
// Send confirmation message as a reply to the original message thread
try {
const episodeLink: string = (
await AlertEpisodeService.getEpisodeLinkInDashboard(
projectId,
episodeId,
)
).toString();
const confirmationMessage: string = `✅ Message saved as *private note* to <${episodeLink}|Alert Episode>.`;
await SlackUtil.sendMessageToThread({
authToken: authToken,
channelId: channelId,
threadTs: messageTs,
text: confirmationMessage,
});
logger.debug("Confirmation message sent successfully.");
} catch (err) {
logger.error("Error sending confirmation message:");
logger.error(err);
// Don't throw - note was saved successfully, confirmation is best effort
}
}
}

View File

@@ -0,0 +1,120 @@
import BadDataException from "../../../../../Types/Exception/BadDataException";
import ObjectID from "../../../../../Types/ObjectID";
import {
WorkspaceMessageBlock,
WorkspaceMessagePayloadButton,
WorkspacePayloadButtons,
WorkspacePayloadDivider,
} from "../../../../../Types/Workspace/WorkspaceMessagePayload";
import AlertEpisodeService from "../../../../Services/AlertEpisodeService";
import SlackActionType from "../../../../Utils/Workspace/Slack/Actions/ActionTypes";
import CaptureSpan from "../../../Telemetry/CaptureSpan";
export default class SlackAlertEpisodeMessages {
@CaptureSpan()
public static async getAlertEpisodeCreateMessageBlocks(data: {
alertEpisodeId: ObjectID;
projectId: ObjectID;
}): Promise<Array<WorkspaceMessageBlock>> {
if (!data.alertEpisodeId) {
throw new BadDataException("Alert Episode ID is required");
}
// Slack.
const blockSlack: Array<WorkspaceMessageBlock> = [];
// add divider.
const dividerBlock: WorkspacePayloadDivider = {
_type: "WorkspacePayloadDivider",
};
blockSlack.push(dividerBlock);
/*
* now add buttons.
* View data.
* Execute On Call
* Acknowledge alert episode
* Resolve data.
* Change Alert Episode State.
* Add Note.
*/
const buttons: Array<WorkspaceMessagePayloadButton> = [];
// view data.
const viewAlertEpisodeButton: WorkspaceMessagePayloadButton = {
_type: "WorkspaceMessagePayloadButton",
title: "🔗 View Episode",
url: await AlertEpisodeService.getEpisodeLinkInDashboard(
data.projectId!,
data.alertEpisodeId!,
),
value: data.alertEpisodeId?.toString() || "",
actionId: SlackActionType.ViewAlertEpisode,
};
buttons.push(viewAlertEpisodeButton);
// execute on call.
const executeOnCallButton: WorkspaceMessagePayloadButton = {
_type: "WorkspaceMessagePayloadButton",
title: "📞 Execute On Call",
value: data.alertEpisodeId?.toString() || "",
actionId: SlackActionType.ViewExecuteAlertEpisodeOnCallPolicy,
};
buttons.push(executeOnCallButton);
// acknowledge data.
const acknowledgeAlertEpisodeButton: WorkspaceMessagePayloadButton = {
_type: "WorkspaceMessagePayloadButton",
title: "👀 Acknowledge Episode",
value: data.alertEpisodeId?.toString() || "",
actionId: SlackActionType.AcknowledgeAlertEpisode,
};
buttons.push(acknowledgeAlertEpisodeButton);
// resolve data.
const resolveAlertEpisodeButton: WorkspaceMessagePayloadButton = {
_type: "WorkspaceMessagePayloadButton",
title: "✅ Resolve Episode",
value: data.alertEpisodeId?.toString() || "",
actionId: SlackActionType.ResolveAlertEpisode,
};
buttons.push(resolveAlertEpisodeButton);
// change alert episode state.
const changeAlertEpisodeStateButton: WorkspaceMessagePayloadButton = {
_type: "WorkspaceMessagePayloadButton",
title: "➡️ Change Episode State",
value: data.alertEpisodeId?.toString() || "",
actionId: SlackActionType.ViewChangeAlertEpisodeState,
};
buttons.push(changeAlertEpisodeStateButton);
// add note.
const addNoteButton: WorkspaceMessagePayloadButton = {
_type: "WorkspaceMessagePayloadButton",
title: "📄 Add Note",
value: data.alertEpisodeId?.toString() || "",
actionId: SlackActionType.ViewAddAlertEpisodeNote,
};
buttons.push(addNoteButton);
const workspacePayloadButtons: WorkspacePayloadButtons = {
buttons: buttons,
_type: "WorkspacePayloadButtons",
};
blockSlack.push(workspacePayloadButtons);
return blockSlack;
}
}

View File

@@ -0,0 +1,74 @@
import ObjectID from "../../../../Types/ObjectID";
import NotificationRuleEventType from "../../../../Types/Workspace/NotificationRules/EventType";
import NotificationRuleWorkspaceChannel from "../../../../Types/Workspace/NotificationRules/NotificationRuleWorkspaceChannel";
import { WorkspaceMessageBlock } from "../../../../Types/Workspace/WorkspaceMessagePayload";
import WorkspaceType from "../../../../Types/Workspace/WorkspaceType";
import WorkspaceNotificationRuleService, {
MessageBlocksByWorkspaceType,
} from "../../../Services/WorkspaceNotificationRuleService";
import logger from "../../Logger";
import SlackAlertEpisodeMessages from "../Slack/Messages/AlertEpisode";
import CaptureSpan from "../../Telemetry/CaptureSpan";
export default class AlertEpisodeWorkspaceMessages {
@CaptureSpan()
public static async createChannelsAndInviteUsersToChannels(data: {
projectId: ObjectID;
alertEpisodeId: ObjectID;
episodeNumber: number;
}): Promise<{
channelsCreated: NotificationRuleWorkspaceChannel[];
} | null> {
try {
// we will notify the workspace about the alert episode creation with the bot tokken which is in WorkspaceProjectAuth Table.
return await WorkspaceNotificationRuleService.createChannelsAndInviteUsersToChannelsBasedOnRules(
{
projectId: data.projectId,
notificationFor: {
alertEpisodeId: data.alertEpisodeId,
},
notificationRuleEventType: NotificationRuleEventType.AlertEpisode,
channelNameSiffix: data.episodeNumber.toString(),
},
);
} catch (err) {
// log the error and continue.
logger.error(
"Error in AlertEpisode createChannelsAndInviteUsersToChannels",
);
logger.error(err);
return null;
}
}
@CaptureSpan()
public static async getAlertEpisodeCreateMessageBlocks(data: {
alertEpisodeId: ObjectID;
projectId: ObjectID;
}): Promise<Array<MessageBlocksByWorkspaceType>> {
const { alertEpisodeId, projectId } = data;
const slackBlocks: WorkspaceMessageBlock[] =
await SlackAlertEpisodeMessages.getAlertEpisodeCreateMessageBlocks({
alertEpisodeId: alertEpisodeId,
projectId: projectId!,
});
const microsoftTeamsBlocks: WorkspaceMessageBlock[] =
await SlackAlertEpisodeMessages.getAlertEpisodeCreateMessageBlocks({
alertEpisodeId: alertEpisodeId,
projectId: projectId!,
});
return [
{
workspaceType: WorkspaceType.Slack,
messageBlocks: slackBlocks,
},
{
workspaceType: WorkspaceType.MicrosoftTeams,
messageBlocks: microsoftTeamsBlocks,
},
];
}
}

View File

@@ -0,0 +1,200 @@
import AlertEpisodeMember, {
AlertEpisodeMemberAddedBy,
} from "../../../Models/DatabaseModels/AlertEpisodeMember";
import AlertEpisode from "../../../Models/DatabaseModels/AlertEpisode";
import ObjectID from "../../../Types/ObjectID";
import { describe, expect, test, beforeEach } from "@jest/globals";
describe("AlertEpisodeMemberService", () => {
const projectId: ObjectID = ObjectID.generate();
const alertId: ObjectID = ObjectID.generate();
const episodeId: ObjectID = ObjectID.generate();
const memberId: ObjectID = ObjectID.generate();
let mockMember: AlertEpisodeMember;
let mockEpisode: AlertEpisode;
beforeEach(() => {
mockMember = new AlertEpisodeMember();
mockMember._id = memberId.toString();
mockMember.id = memberId;
mockMember.projectId = projectId;
mockMember.alertId = alertId;
mockMember.alertEpisodeId = episodeId;
mockMember.addedBy = AlertEpisodeMemberAddedBy.Rule;
mockEpisode = new AlertEpisode();
mockEpisode._id = episodeId.toString();
mockEpisode.id = episodeId;
mockEpisode.projectId = projectId;
mockEpisode.title = "Test Episode";
});
describe("AlertEpisodeMember Model", () => {
test("should create a new AlertEpisodeMember instance", () => {
const member: AlertEpisodeMember = new AlertEpisodeMember();
expect(member).toBeInstanceOf(AlertEpisodeMember);
});
test("should create AlertEpisodeMember with an ID", () => {
const id: ObjectID = ObjectID.generate();
const member: AlertEpisodeMember = new AlertEpisodeMember(id);
expect(member.id).toEqual(id);
});
test("should set and get projectId correctly", () => {
const member: AlertEpisodeMember = new AlertEpisodeMember();
const projectId: ObjectID = ObjectID.generate();
member.projectId = projectId;
expect(member.projectId).toEqual(projectId);
});
test("should set and get alertId correctly", () => {
const member: AlertEpisodeMember = new AlertEpisodeMember();
const alertId: ObjectID = ObjectID.generate();
member.alertId = alertId;
expect(member.alertId).toEqual(alertId);
});
test("should set and get alertEpisodeId correctly", () => {
const member: AlertEpisodeMember = new AlertEpisodeMember();
const episodeId: ObjectID = ObjectID.generate();
member.alertEpisodeId = episodeId;
expect(member.alertEpisodeId).toEqual(episodeId);
});
test("should set addedBy to Rule", () => {
const member: AlertEpisodeMember = new AlertEpisodeMember();
member.addedBy = AlertEpisodeMemberAddedBy.Rule;
expect(member.addedBy).toBe(AlertEpisodeMemberAddedBy.Rule);
});
test("should set addedBy to Manual", () => {
const member: AlertEpisodeMember = new AlertEpisodeMember();
member.addedBy = AlertEpisodeMemberAddedBy.Manual;
expect(member.addedBy).toBe(AlertEpisodeMemberAddedBy.Manual);
});
test("should set addedBy to API", () => {
const member: AlertEpisodeMember = new AlertEpisodeMember();
member.addedBy = AlertEpisodeMemberAddedBy.API;
expect(member.addedBy).toBe(AlertEpisodeMemberAddedBy.API);
});
test("should set and get matchedRuleId correctly", () => {
const member: AlertEpisodeMember = new AlertEpisodeMember();
const ruleId: ObjectID = ObjectID.generate();
member.matchedRuleId = ruleId;
expect(member.matchedRuleId).toEqual(ruleId);
});
});
describe("AlertEpisodeMember with full data", () => {
test("should handle complete member record", () => {
const id: ObjectID = ObjectID.generate();
const projectId: ObjectID = ObjectID.generate();
const alertId: ObjectID = ObjectID.generate();
const episodeId: ObjectID = ObjectID.generate();
const ruleId: ObjectID = ObjectID.generate();
const member: AlertEpisodeMember = new AlertEpisodeMember(id);
member.projectId = projectId;
member.alertId = alertId;
member.alertEpisodeId = episodeId;
member.addedBy = AlertEpisodeMemberAddedBy.Rule;
member.matchedRuleId = ruleId;
expect(member.id).toEqual(id);
expect(member.projectId).toEqual(projectId);
expect(member.alertId).toEqual(alertId);
expect(member.alertEpisodeId).toEqual(episodeId);
expect(member.addedBy).toBe(AlertEpisodeMemberAddedBy.Rule);
expect(member.matchedRuleId).toEqual(ruleId);
});
test("should create member with minimal data", () => {
const member: AlertEpisodeMember = new AlertEpisodeMember();
const alertId: ObjectID = ObjectID.generate();
const episodeId: ObjectID = ObjectID.generate();
member.alertId = alertId;
member.alertEpisodeId = episodeId;
expect(member.alertId).toEqual(alertId);
expect(member.alertEpisodeId).toEqual(episodeId);
expect(member.matchedRuleId).toBeUndefined();
});
});
describe("Multiple members", () => {
test("should create distinct member instances", () => {
const member1: AlertEpisodeMember = new AlertEpisodeMember();
const member2: AlertEpisodeMember = new AlertEpisodeMember();
const alertId1: ObjectID = ObjectID.generate();
const alertId2: ObjectID = ObjectID.generate();
member1.alertId = alertId1;
member2.alertId = alertId2;
expect(member1.alertId).toEqual(alertId1);
expect(member2.alertId).toEqual(alertId2);
expect(member1.alertId).not.toEqual(member2.alertId);
});
test("should allow same alert to be linked to different episodes (model level)", () => {
/*
* Note: The service layer enforces single-episode constraint,
* but the model itself allows it
*/
const alertId: ObjectID = ObjectID.generate();
const episodeId1: ObjectID = ObjectID.generate();
const episodeId2: ObjectID = ObjectID.generate();
const member1: AlertEpisodeMember = new AlertEpisodeMember();
member1.alertId = alertId;
member1.alertEpisodeId = episodeId1;
const member2: AlertEpisodeMember = new AlertEpisodeMember();
member2.alertId = alertId;
member2.alertEpisodeId = episodeId2;
expect(member1.alertId).toEqual(member2.alertId);
expect(member1.alertEpisodeId).not.toEqual(member2.alertEpisodeId);
});
test("should allow different alerts to be in same episode", () => {
const episodeId: ObjectID = ObjectID.generate();
const alertId1: ObjectID = ObjectID.generate();
const alertId2: ObjectID = ObjectID.generate();
const member1: AlertEpisodeMember = new AlertEpisodeMember();
member1.alertId = alertId1;
member1.alertEpisodeId = episodeId;
const member2: AlertEpisodeMember = new AlertEpisodeMember();
member2.alertId = alertId2;
member2.alertEpisodeId = episodeId;
expect(member1.alertEpisodeId).toEqual(member2.alertEpisodeId);
expect(member1.alertId).not.toEqual(member2.alertId);
});
});
describe("AddedBy enum values", () => {
test("should have Rule value", () => {
expect(AlertEpisodeMemberAddedBy.Rule).toBeDefined();
expect(AlertEpisodeMemberAddedBy.Rule).toBe("rule");
});
test("should have Manual value", () => {
expect(AlertEpisodeMemberAddedBy.Manual).toBeDefined();
expect(AlertEpisodeMemberAddedBy.Manual).toBe("manual");
});
test("should have API value", () => {
expect(AlertEpisodeMemberAddedBy.API).toBeDefined();
expect(AlertEpisodeMemberAddedBy.API).toBe("api");
});
});
});

View File

@@ -0,0 +1,240 @@
import AlertEpisode from "../../../Models/DatabaseModels/AlertEpisode";
import AlertState from "../../../Models/DatabaseModels/AlertState";
import ObjectID from "../../../Types/ObjectID";
import { describe, expect, test, beforeEach } from "@jest/globals";
describe("AlertEpisodeService", () => {
const projectId: ObjectID = ObjectID.generate();
const episodeId: ObjectID = ObjectID.generate();
let mockEpisode: AlertEpisode;
let mockResolvedState: AlertState;
let mockAcknowledgedState: AlertState;
beforeEach(() => {
mockEpisode = new AlertEpisode();
mockEpisode._id = episodeId.toString();
mockEpisode.id = episodeId;
mockEpisode.projectId = projectId;
mockEpisode.title = "Test Episode";
mockEpisode.alertCount = 5;
mockResolvedState = new AlertState();
mockResolvedState._id = ObjectID.generate().toString();
mockResolvedState.order = 100;
mockResolvedState.isResolvedState = true;
mockAcknowledgedState = new AlertState();
mockAcknowledgedState._id = ObjectID.generate().toString();
mockAcknowledgedState.order = 50;
mockAcknowledgedState.isAcknowledgedState = true;
});
describe("AlertEpisode Model", () => {
test("should create a new AlertEpisode instance", () => {
const episode: AlertEpisode = new AlertEpisode();
expect(episode).toBeInstanceOf(AlertEpisode);
});
test("should create AlertEpisode with an ID", () => {
const id: ObjectID = ObjectID.generate();
const episode: AlertEpisode = new AlertEpisode(id);
expect(episode.id).toEqual(id);
});
test("should set and get title correctly", () => {
const episode: AlertEpisode = new AlertEpisode();
episode.title = "CPU Alert Episode";
expect(episode.title).toBe("CPU Alert Episode");
});
test("should set and get description correctly", () => {
const episode: AlertEpisode = new AlertEpisode();
episode.description = "Multiple CPU alerts grouped together";
expect(episode.description).toBe("Multiple CPU alerts grouped together");
});
test("should set and get alertCount correctly", () => {
const episode: AlertEpisode = new AlertEpisode();
episode.alertCount = 10;
expect(episode.alertCount).toBe(10);
});
test("should set and get projectId correctly", () => {
const episode: AlertEpisode = new AlertEpisode();
const projectId: ObjectID = ObjectID.generate();
episode.projectId = projectId;
expect(episode.projectId).toEqual(projectId);
});
test("should set and get isManuallyCreated correctly", () => {
const episode: AlertEpisode = new AlertEpisode();
episode.isManuallyCreated = true;
expect(episode.isManuallyCreated).toBe(true);
});
test("should set and get titleTemplate correctly", () => {
const episode: AlertEpisode = new AlertEpisode();
episode.titleTemplate = "{{alertTitle}} - {{alertCount}} alerts";
expect(episode.titleTemplate).toBe(
"{{alertTitle}} - {{alertCount}} alerts",
);
});
test("should set and get descriptionTemplate correctly", () => {
const episode: AlertEpisode = new AlertEpisode();
episode.descriptionTemplate = "Episode with {{alertCount}} alerts";
expect(episode.descriptionTemplate).toBe(
"Episode with {{alertCount}} alerts",
);
});
test("should set and get groupingKey correctly", () => {
const episode: AlertEpisode = new AlertEpisode();
episode.groupingKey = "monitor:abc123|severity:critical";
expect(episode.groupingKey).toBe("monitor:abc123|severity:critical");
});
test("should set and get rootCause correctly", () => {
const episode: AlertEpisode = new AlertEpisode();
episode.rootCause = "Database connection pool exhausted";
expect(episode.rootCause).toBe("Database connection pool exhausted");
});
test("should set and get lastAlertAddedAt correctly", () => {
const episode: AlertEpisode = new AlertEpisode();
const date: Date = new Date();
episode.lastAlertAddedAt = date;
expect(episode.lastAlertAddedAt).toEqual(date);
});
test("should set and get resolvedAt correctly", () => {
const episode: AlertEpisode = new AlertEpisode();
const date: Date = new Date();
episode.resolvedAt = date;
expect(episode.resolvedAt).toEqual(date);
});
test("should set and get assignedToUserId correctly", () => {
const episode: AlertEpisode = new AlertEpisode();
const userId: ObjectID = ObjectID.generate();
episode.assignedToUserId = userId;
expect(episode.assignedToUserId).toEqual(userId);
});
test("should set and get assignedToTeamId correctly", () => {
const episode: AlertEpisode = new AlertEpisode();
const teamId: ObjectID = ObjectID.generate();
episode.assignedToTeamId = teamId;
expect(episode.assignedToTeamId).toEqual(teamId);
});
});
describe("Episode with full data", () => {
test("should handle complete episode record", () => {
const id: ObjectID = ObjectID.generate();
const projectId: ObjectID = ObjectID.generate();
const userId: ObjectID = ObjectID.generate();
const teamId: ObjectID = ObjectID.generate();
const ruleId: ObjectID = ObjectID.generate();
const severityId: ObjectID = ObjectID.generate();
const episode: AlertEpisode = new AlertEpisode(id);
episode.projectId = projectId;
episode.title = "Database Connection Issues";
episode.description = "Multiple connection timeout alerts";
episode.alertCount = 15;
episode.isManuallyCreated = false;
episode.groupingKey = "monitor:db-server|severity:critical";
episode.titleTemplate = "DB Issues ({{alertCount}})";
episode.descriptionTemplate = "{{alertCount}} connection alerts";
episode.rootCause = "Network congestion";
episode.assignedToUserId = userId;
episode.assignedToTeamId = teamId;
episode.alertGroupingRuleId = ruleId;
episode.alertSeverityId = severityId;
expect(episode.id).toEqual(id);
expect(episode.projectId).toEqual(projectId);
expect(episode.title).toBe("Database Connection Issues");
expect(episode.description).toBe("Multiple connection timeout alerts");
expect(episode.alertCount).toBe(15);
expect(episode.isManuallyCreated).toBe(false);
expect(episode.groupingKey).toBe("monitor:db-server|severity:critical");
expect(episode.titleTemplate).toBe("DB Issues ({{alertCount}})");
expect(episode.descriptionTemplate).toBe(
"{{alertCount}} connection alerts",
);
expect(episode.rootCause).toBe("Network congestion");
expect(episode.assignedToUserId).toEqual(userId);
expect(episode.assignedToTeamId).toEqual(teamId);
expect(episode.alertGroupingRuleId).toEqual(ruleId);
expect(episode.alertSeverityId).toEqual(severityId);
});
});
describe("Template rendering helper", () => {
test("should replace {{alertCount}} in title template", () => {
const template: string = "CPU Issues - {{alertCount}} alerts";
const alertCount: number = 5;
const result: string = template.replace(
/\{\{alertCount\}\}/g,
alertCount.toString(),
);
expect(result).toBe("CPU Issues - 5 alerts");
});
test("should replace multiple occurrences of {{alertCount}}", () => {
const template: string = "{{alertCount}} alerts ({{alertCount}} total)";
const alertCount: number = 7;
const result: string = template.replace(
/\{\{alertCount\}\}/g,
alertCount.toString(),
);
expect(result).toBe("7 alerts (7 total)");
});
test("should handle template without placeholders", () => {
const template: string = "Static Episode Title";
const alertCount: number = 10;
const result: string = template.replace(
/\{\{alertCount\}\}/g,
alertCount.toString(),
);
expect(result).toBe("Static Episode Title");
});
test("should handle empty template", () => {
const template: string = "";
const alertCount: number = 3;
const result: string = template.replace(
/\{\{alertCount\}\}/g,
alertCount.toString(),
);
expect(result).toBe("");
});
});
describe("AlertState comparison", () => {
test("should correctly compare state orders for resolution check", () => {
const currentOrder: number = 100;
const resolvedOrder: number = 100;
const isResolved: boolean = currentOrder >= resolvedOrder;
expect(isResolved).toBe(true);
});
test("should correctly identify unresolved state", () => {
const currentOrder: number = 50;
const resolvedOrder: number = 100;
const isResolved: boolean = currentOrder >= resolvedOrder;
expect(isResolved).toBe(false);
});
test("should correctly identify acknowledged state", () => {
const currentOrder: number = 50;
const acknowledgedOrder: number = 50;
const isAcknowledged: boolean = currentOrder >= acknowledgedOrder;
expect(isAcknowledged).toBe(true);
});
});
});

View File

@@ -0,0 +1,542 @@
import Alert from "../../../Models/DatabaseModels/Alert";
import AlertEpisode from "../../../Models/DatabaseModels/AlertEpisode";
import AlertGroupingRule from "../../../Models/DatabaseModels/AlertGroupingRule";
import Monitor from "../../../Models/DatabaseModels/Monitor";
import AlertSeverity from "../../../Models/DatabaseModels/AlertSeverity";
import ObjectID from "../../../Types/ObjectID";
import { describe, expect, test, beforeEach } from "@jest/globals";
/**
* These tests focus on model-level testing for alert grouping components.
* Service integration tests require database connections and are covered
* in E2E tests.
*/
describe("AlertGroupingEngineService Models", () => {
const projectId: ObjectID = ObjectID.generate();
const alertId: ObjectID = ObjectID.generate();
const monitorId: ObjectID = ObjectID.generate();
const severityId: ObjectID = ObjectID.generate();
const ruleId: ObjectID = ObjectID.generate();
const episodeId: ObjectID = ObjectID.generate();
let mockAlert: Alert;
let mockMonitor: Monitor;
let mockSeverity: AlertSeverity;
let mockRule: AlertGroupingRule;
let mockEpisode: AlertEpisode;
beforeEach(() => {
// Setup mock monitor
mockMonitor = new Monitor();
mockMonitor._id = monitorId.toString();
mockMonitor.name = "Test Monitor";
// Setup mock severity
mockSeverity = new AlertSeverity();
mockSeverity._id = severityId.toString();
mockSeverity.name = "Critical";
// Setup mock alert
mockAlert = new Alert();
mockAlert._id = alertId.toString();
mockAlert.id = alertId;
mockAlert.projectId = projectId;
mockAlert.title = "CPU Usage High";
mockAlert.description = "CPU usage exceeded 90%";
mockAlert.monitor = mockMonitor;
mockAlert.monitorId = monitorId;
mockAlert.alertSeverity = mockSeverity;
mockAlert.alertSeverityId = severityId;
// Setup mock rule
mockRule = new AlertGroupingRule();
mockRule._id = ruleId.toString();
mockRule.id = ruleId;
mockRule.name = "Critical Alerts Rule";
mockRule.isEnabled = true;
mockRule.priority = 1;
mockRule.groupByMonitor = true;
mockRule.enableTimeWindow = true;
mockRule.timeWindowMinutes = 30;
// Setup mock episode
mockEpisode = new AlertEpisode();
mockEpisode._id = episodeId.toString();
mockEpisode.id = episodeId;
mockEpisode.projectId = projectId;
mockEpisode.title = "Test Episode";
});
describe("Alert Model for Grouping", () => {
test("should have alertEpisodeId field", () => {
const alert: Alert = new Alert();
const episodeId: ObjectID = ObjectID.generate();
alert.alertEpisodeId = episodeId;
expect(alert.alertEpisodeId).toEqual(episodeId);
});
test("should store title and description for template variables", () => {
expect(mockAlert.title).toBe("CPU Usage High");
expect(mockAlert.description).toBe("CPU usage exceeded 90%");
});
test("should reference monitor for template variables", () => {
expect(mockAlert.monitor?.name).toBe("Test Monitor");
});
test("should reference alert severity for template variables", () => {
expect(mockAlert.alertSeverity?.name).toBe("Critical");
});
});
describe("AlertGroupingRule Matching Criteria", () => {
describe("Monitor Matching", () => {
test("should support array of monitors for matching", () => {
mockRule.monitors = [mockMonitor];
expect(mockRule.monitors).toHaveLength(1);
expect(mockRule.monitors[0]).toBe(mockMonitor);
});
test("should check if monitor exists in rule monitors", () => {
const otherMonitor: Monitor = new Monitor();
otherMonitor._id = ObjectID.generate().toString();
mockRule.monitors = [mockMonitor, otherMonitor];
const monitorIds: Array<string | undefined> = mockRule.monitors.map(
(m: Monitor) => {
return m._id;
},
);
expect(monitorIds).toContain(mockMonitor._id);
expect(monitorIds).toContain(otherMonitor._id);
});
});
describe("Severity Matching", () => {
test("should support array of severities for matching", () => {
mockRule.alertSeverities = [mockSeverity];
expect(mockRule.alertSeverities).toHaveLength(1);
expect(mockRule.alertSeverities[0]).toBe(mockSeverity);
});
});
describe("Pattern Matching", () => {
test("should store title pattern for regex matching", () => {
mockRule.alertTitlePattern = "CPU.*High";
expect(mockRule.alertTitlePattern).toBe("CPU.*High");
});
test("should match alert title against pattern", () => {
mockRule.alertTitlePattern = "CPU.*High";
const pattern: RegExp = new RegExp(mockRule.alertTitlePattern, "i");
expect(pattern.test(mockAlert.title!)).toBe(true);
});
test("should not match when pattern doesn't match", () => {
mockRule.alertTitlePattern = "Memory.*Low";
const pattern: RegExp = new RegExp(mockRule.alertTitlePattern, "i");
expect(pattern.test(mockAlert.title!)).toBe(false);
});
test("should store description pattern for regex matching", () => {
mockRule.alertDescriptionPattern = "CPU usage exceeded.*";
expect(mockRule.alertDescriptionPattern).toBe("CPU usage exceeded.*");
});
test("should match alert description against pattern", () => {
mockRule.alertDescriptionPattern = "CPU usage exceeded.*";
const pattern: RegExp = new RegExp(
mockRule.alertDescriptionPattern,
"i",
);
expect(pattern.test(mockAlert.description!)).toBe(true);
});
});
});
describe("Grouping Key Generation Logic", () => {
test("should generate grouping key with monitor when groupByMonitor is true", () => {
mockRule.groupByMonitor = true;
mockRule.groupBySeverity = false;
mockRule.groupByAlertTitle = false;
const parts: string[] = [`rule:${mockRule._id}`];
if (mockRule.groupByMonitor && mockAlert.monitorId) {
parts.push(`monitor:${mockAlert.monitorId.toString()}`);
}
if (mockRule.groupBySeverity && mockAlert.alertSeverityId) {
parts.push(`severity:${mockAlert.alertSeverityId.toString()}`);
}
if (mockRule.groupByAlertTitle && mockAlert.title) {
parts.push(`title:${mockAlert.title}`);
}
const groupingKey: string = parts.join("|");
expect(groupingKey).toContain(`rule:${mockRule._id}`);
expect(groupingKey).toContain(
`monitor:${mockAlert.monitorId!.toString()}`,
);
expect(groupingKey).not.toContain("severity:");
expect(groupingKey).not.toContain("title:");
});
test("should generate grouping key with severity when groupBySeverity is true", () => {
mockRule.groupByMonitor = false;
mockRule.groupBySeverity = true;
mockRule.groupByAlertTitle = false;
const parts: string[] = [`rule:${mockRule._id}`];
if (mockRule.groupByMonitor && mockAlert.monitorId) {
parts.push(`monitor:${mockAlert.monitorId.toString()}`);
}
if (mockRule.groupBySeverity && mockAlert.alertSeverityId) {
parts.push(`severity:${mockAlert.alertSeverityId.toString()}`);
}
if (mockRule.groupByAlertTitle && mockAlert.title) {
parts.push(`title:${mockAlert.title}`);
}
const groupingKey: string = parts.join("|");
expect(groupingKey).toContain(`rule:${mockRule._id}`);
expect(groupingKey).not.toContain("monitor:");
expect(groupingKey).toContain(
`severity:${mockAlert.alertSeverityId!.toString()}`,
);
expect(groupingKey).not.toContain("title:");
});
test("should generate grouping key with all dimensions", () => {
mockRule.groupByMonitor = true;
mockRule.groupBySeverity = true;
mockRule.groupByAlertTitle = true;
const parts: string[] = [`rule:${mockRule._id}`];
if (mockRule.groupByMonitor && mockAlert.monitorId) {
parts.push(`monitor:${mockAlert.monitorId.toString()}`);
}
if (mockRule.groupBySeverity && mockAlert.alertSeverityId) {
parts.push(`severity:${mockAlert.alertSeverityId.toString()}`);
}
if (mockRule.groupByAlertTitle && mockAlert.title) {
parts.push(`title:${mockAlert.title}`);
}
const groupingKey: string = parts.join("|");
expect(groupingKey).toContain(`rule:${mockRule._id}`);
expect(groupingKey).toContain(
`monitor:${mockAlert.monitorId!.toString()}`,
);
expect(groupingKey).toContain(
`severity:${mockAlert.alertSeverityId!.toString()}`,
);
expect(groupingKey).toContain(`title:${mockAlert.title}`);
});
});
describe("Time Window Configuration", () => {
test("should store enableTimeWindow flag", () => {
mockRule.enableTimeWindow = true;
expect(mockRule.enableTimeWindow).toBe(true);
});
test("should store timeWindowMinutes", () => {
mockRule.timeWindowMinutes = 60;
expect(mockRule.timeWindowMinutes).toBe(60);
});
test("should store enableReopenWindow flag", () => {
mockRule.enableReopenWindow = true;
expect(mockRule.enableReopenWindow).toBe(true);
});
test("should store reopenWindowMinutes", () => {
mockRule.reopenWindowMinutes = 30;
expect(mockRule.reopenWindowMinutes).toBe(30);
});
test("should calculate if episode is within time window", () => {
const windowMinutes: number = 30;
const episodeCreatedAt: Date = new Date(Date.now() - 15 * 60 * 1000); // 15 minutes ago
const windowStart: Date = new Date(
Date.now() - windowMinutes * 60 * 1000,
);
const isWithinWindow: boolean = episodeCreatedAt >= windowStart;
expect(isWithinWindow).toBe(true);
});
test("should calculate if episode is outside time window", () => {
const windowMinutes: number = 30;
const episodeCreatedAt: Date = new Date(Date.now() - 45 * 60 * 1000); // 45 minutes ago
const windowStart: Date = new Date(
Date.now() - windowMinutes * 60 * 1000,
);
const isWithinWindow: boolean = episodeCreatedAt >= windowStart;
expect(isWithinWindow).toBe(false);
});
});
describe("Reopen Window Logic", () => {
test("should identify recently resolved episode for reopening", () => {
const reopenWindowMinutes: number = 30;
const resolvedAt: Date = new Date(Date.now() - 10 * 60 * 1000); // 10 minutes ago
const reopenWindowStart: Date = new Date(
Date.now() - reopenWindowMinutes * 60 * 1000,
);
mockEpisode.resolvedAt = resolvedAt;
const canReopen: boolean = mockEpisode.resolvedAt >= reopenWindowStart;
expect(canReopen).toBe(true);
});
test("should not reopen episode outside reopen window", () => {
const reopenWindowMinutes: number = 30;
const resolvedAt: Date = new Date(Date.now() - 45 * 60 * 1000); // 45 minutes ago
const reopenWindowStart: Date = new Date(
Date.now() - reopenWindowMinutes * 60 * 1000,
);
mockEpisode.resolvedAt = resolvedAt;
const canReopen: boolean = mockEpisode.resolvedAt >= reopenWindowStart;
expect(canReopen).toBe(false);
});
});
describe("Rule Priority", () => {
test("should sort rules by priority", () => {
const rule1: AlertGroupingRule = new AlertGroupingRule();
rule1.priority = 10;
const rule2: AlertGroupingRule = new AlertGroupingRule();
rule2.priority = 1;
const rule3: AlertGroupingRule = new AlertGroupingRule();
rule3.priority = 5;
const rules: AlertGroupingRule[] = [rule1, rule2, rule3];
rules.sort((a: AlertGroupingRule, b: AlertGroupingRule) => {
return (a.priority || 0) - (b.priority || 0);
});
expect(rules[0]!.priority).toBe(1);
expect(rules[1]!.priority).toBe(5);
expect(rules[2]!.priority).toBe(10);
});
});
});
describe("Template Variable Replacement Logic", () => {
const alertId: ObjectID = ObjectID.generate();
const monitorId: ObjectID = ObjectID.generate();
const severityId: ObjectID = ObjectID.generate();
let mockAlert: Alert;
let mockMonitor: Monitor;
let mockSeverity: AlertSeverity;
beforeEach(() => {
mockMonitor = new Monitor();
mockMonitor._id = monitorId.toString();
mockMonitor.name = "API Server";
mockSeverity = new AlertSeverity();
mockSeverity._id = severityId.toString();
mockSeverity.name = "Critical";
mockAlert = new Alert();
mockAlert._id = alertId.toString();
mockAlert.id = alertId;
mockAlert.title = "High CPU Usage";
mockAlert.description = "CPU usage is above threshold";
mockAlert.monitor = mockMonitor;
mockAlert.alertSeverity = mockSeverity;
});
describe("Static Variable Replacement", () => {
test("should replace {{alertTitle}} with alert title", () => {
const template: string = "Episode: {{alertTitle}}";
const result: string = template.replace(
/\{\{alertTitle\}\}/g,
mockAlert.title!,
);
expect(result).toBe("Episode: High CPU Usage");
});
test("should replace {{alertDescription}} with alert description", () => {
const template: string = "Details: {{alertDescription}}";
const result: string = template.replace(
/\{\{alertDescription\}\}/g,
mockAlert.description!,
);
expect(result).toBe("Details: CPU usage is above threshold");
});
test("should replace {{monitorName}} with monitor name", () => {
const template: string = "Alert on {{monitorName}}";
const result: string = template.replace(
/\{\{monitorName\}\}/g,
mockAlert.monitor?.name || "",
);
expect(result).toBe("Alert on API Server");
});
test("should replace {{alertSeverity}} with severity name", () => {
const template: string = "{{alertSeverity}} Alert Episode";
const result: string = template.replace(
/\{\{alertSeverity\}\}/g,
mockAlert.alertSeverity?.name || "",
);
expect(result).toBe("Critical Alert Episode");
});
test("should replace multiple variables in same template", () => {
let template: string =
"{{alertSeverity}}: {{alertTitle}} on {{monitorName}}";
template = template.replace(/\{\{alertTitle\}\}/g, mockAlert.title!);
template = template.replace(
/\{\{alertSeverity\}\}/g,
mockAlert.alertSeverity?.name || "",
);
template = template.replace(
/\{\{monitorName\}\}/g,
mockAlert.monitor?.name || "",
);
expect(template).toBe("Critical: High CPU Usage on API Server");
});
});
describe("Dynamic Variable Replacement", () => {
test("should replace {{alertCount}} with count", () => {
const template: string = "Episode ({{alertCount}} alerts)";
const alertCount: number = 5;
const result: string = template.replace(
/\{\{alertCount\}\}/g,
alertCount.toString(),
);
expect(result).toBe("Episode (5 alerts)");
});
test("should preserve {{alertCount}} placeholder in preprocessed template", () => {
let template: string = "{{alertTitle}} - {{alertCount}} alerts";
// Preprocess: replace static variables only
template = template.replace(/\{\{alertTitle\}\}/g, mockAlert.title!);
// {{alertCount}} should still be present
expect(template).toBe("High CPU Usage - {{alertCount}} alerts");
expect(template).toContain("{{alertCount}}");
});
test("should render final title with dynamic values", () => {
// Start with preprocessed template (static vars already replaced)
const preprocessedTemplate: string =
"High CPU Usage - {{alertCount}} alerts";
const alertCount: number = 10;
// Render dynamic values
const finalTitle: string = preprocessedTemplate.replace(
/\{\{alertCount\}\}/g,
alertCount.toString(),
);
expect(finalTitle).toBe("High CPU Usage - 10 alerts");
});
});
describe("Unknown Placeholder Handling", () => {
test("should remove unknown placeholders", () => {
let template: string = "{{alertTitle}} {{unknownVar}} Episode";
template = template.replace(/\{\{alertTitle\}\}/g, mockAlert.title!);
// Remove any remaining placeholders
template = template.replace(/\{\{[^}]+\}\}/g, "");
// Clean up extra spaces
template = template.replace(/\s+/g, " ").trim();
expect(template).toBe("High CPU Usage Episode");
});
});
describe("Default Title Generation", () => {
test("should use monitor name when no template provided", () => {
const defaultTitle: string = `Alert Episode: ${mockAlert.monitor?.name || mockAlert.title || "Alert Episode"}`;
expect(defaultTitle).toBe("Alert Episode: API Server");
});
test("should use alert title when no monitor", () => {
// Create alert without monitor
const alertNoMonitor: Alert = new Alert();
alertNoMonitor._id = alertId.toString();
alertNoMonitor.id = alertId;
alertNoMonitor.title = "High CPU Usage";
// monitor is intentionally not set
const defaultTitle: string = `Alert Episode: ${alertNoMonitor.monitor?.name || alertNoMonitor.title || "Alert Episode"}`;
expect(defaultTitle).toBe("Alert Episode: High CPU Usage");
});
test("should use generic title when no monitor and no alert title", () => {
// Create minimal alert without monitor and title
const alertMinimal: Alert = new Alert();
alertMinimal._id = alertId.toString();
alertMinimal.id = alertId;
// monitor and title are intentionally not set
const defaultTitle: string = `Alert Episode: ${alertMinimal.monitor?.name || alertMinimal.title || "Alert Episode"}`;
expect(defaultTitle).toBe("Alert Episode: Alert Episode");
});
});
});
describe("AlertEpisode Template Storage", () => {
test("should store titleTemplate for dynamic re-rendering", () => {
const episode: AlertEpisode = new AlertEpisode();
episode.titleTemplate = "High CPU Usage - {{alertCount}} alerts";
expect(episode.titleTemplate).toBe(
"High CPU Usage - {{alertCount}} alerts",
);
});
test("should store descriptionTemplate for dynamic re-rendering", () => {
const episode: AlertEpisode = new AlertEpisode();
episode.descriptionTemplate =
"Episode contains {{alertCount}} related alerts";
expect(episode.descriptionTemplate).toBe(
"Episode contains {{alertCount}} related alerts",
);
});
test("should store both title and titleTemplate", () => {
const episode: AlertEpisode = new AlertEpisode();
episode.title = "High CPU Usage - 1 alerts";
episode.titleTemplate = "High CPU Usage - {{alertCount}} alerts";
expect(episode.title).toBe("High CPU Usage - 1 alerts");
expect(episode.titleTemplate).toBe(
"High CPU Usage - {{alertCount}} alerts",
);
});
test("should re-render title from template when alert count changes", () => {
const episode: AlertEpisode = new AlertEpisode();
episode.titleTemplate = "High CPU Usage - {{alertCount}} alerts";
episode.alertCount = 1;
episode.title = episode.titleTemplate.replace(
/\{\{alertCount\}\}/g,
episode.alertCount.toString(),
);
expect(episode.title).toBe("High CPU Usage - 1 alerts");
// Simulate adding more alerts
episode.alertCount = 5;
episode.title = episode.titleTemplate.replace(
/\{\{alertCount\}\}/g,
episode.alertCount.toString(),
);
expect(episode.title).toBe("High CPU Usage - 5 alerts");
});
});

View File

@@ -0,0 +1,383 @@
import AlertGroupingRule from "../../../Models/DatabaseModels/AlertGroupingRule";
import Monitor from "../../../Models/DatabaseModels/Monitor";
import AlertSeverity from "../../../Models/DatabaseModels/AlertSeverity";
import Label from "../../../Models/DatabaseModels/Label";
import Team from "../../../Models/DatabaseModels/Team";
import User from "../../../Models/DatabaseModels/User";
import OnCallDutyPolicy from "../../../Models/DatabaseModels/OnCallDutyPolicy";
import ObjectID from "../../../Types/ObjectID";
import { describe, expect, test, beforeEach } from "@jest/globals";
describe("AlertGroupingRule Model", () => {
let rule: AlertGroupingRule;
beforeEach(() => {
rule = new AlertGroupingRule();
});
describe("constructor", () => {
test("should create a new AlertGroupingRule instance", () => {
expect(rule).toBeInstanceOf(AlertGroupingRule);
});
test("should create AlertGroupingRule with an ID", () => {
const id: ObjectID = ObjectID.generate();
const ruleWithId: AlertGroupingRule = new AlertGroupingRule(id);
expect(ruleWithId.id).toEqual(id);
});
});
describe("Basic properties", () => {
test("should set and get name correctly", () => {
rule.name = "Critical Production Alerts";
expect(rule.name).toBe("Critical Production Alerts");
});
test("should set and get description correctly", () => {
rule.description = "Groups all critical alerts from production services";
expect(rule.description).toBe(
"Groups all critical alerts from production services",
);
});
test("should set and get priority correctly", () => {
rule.priority = 1;
expect(rule.priority).toBe(1);
});
test("should set and get isEnabled correctly", () => {
rule.isEnabled = true;
expect(rule.isEnabled).toBe(true);
rule.isEnabled = false;
expect(rule.isEnabled).toBe(false);
});
test("should set and get projectId correctly", () => {
const projectId: ObjectID = ObjectID.generate();
rule.projectId = projectId;
expect(rule.projectId).toEqual(projectId);
});
});
describe("Match Criteria", () => {
describe("Monitors", () => {
test("should set and get monitors correctly", () => {
const monitor1: Monitor = new Monitor();
monitor1._id = ObjectID.generate().toString();
const monitor2: Monitor = new Monitor();
monitor2._id = ObjectID.generate().toString();
rule.monitors = [monitor1, monitor2];
expect(rule.monitors).toHaveLength(2);
expect(rule.monitors).toContain(monitor1);
expect(rule.monitors).toContain(monitor2);
});
test("should handle empty monitors array", () => {
rule.monitors = [];
expect(rule.monitors).toHaveLength(0);
});
});
describe("Alert Severities", () => {
test("should set and get alertSeverities correctly", () => {
const severity: AlertSeverity = new AlertSeverity();
severity._id = ObjectID.generate().toString();
severity.name = "Critical";
rule.alertSeverities = [severity];
expect(rule.alertSeverities).toHaveLength(1);
expect(rule.alertSeverities![0]).toBe(severity);
});
});
describe("Labels", () => {
test("should set and get monitorLabels correctly", () => {
const label: Label = new Label();
label._id = ObjectID.generate().toString();
label.name = "production";
rule.monitorLabels = [label];
expect(rule.monitorLabels).toHaveLength(1);
expect(rule.monitorLabels![0]).toBe(label);
});
});
describe("Pattern Matching", () => {
test("should set and get alertTitlePattern correctly", () => {
rule.alertTitlePattern = "CPU.*High";
expect(rule.alertTitlePattern).toBe("CPU.*High");
});
test("should set and get alertDescriptionPattern correctly", () => {
rule.alertDescriptionPattern = "memory.*exceeded";
expect(rule.alertDescriptionPattern).toBe("memory.*exceeded");
});
test("should set and get monitorNamePattern correctly", () => {
rule.monitorNamePattern = "prod-.*-api";
expect(rule.monitorNamePattern).toBe("prod-.*-api");
});
test("should set and get monitorDescriptionPattern correctly", () => {
rule.monitorDescriptionPattern = ".*production.*";
expect(rule.monitorDescriptionPattern).toBe(".*production.*");
});
});
});
describe("Group By settings", () => {
test("should set and get groupByMonitor correctly", () => {
rule.groupByMonitor = true;
expect(rule.groupByMonitor).toBe(true);
});
test("should set and get groupBySeverity correctly", () => {
rule.groupBySeverity = true;
expect(rule.groupBySeverity).toBe(true);
});
test("should set and get groupByAlertTitle correctly", () => {
rule.groupByAlertTitle = true;
expect(rule.groupByAlertTitle).toBe(true);
});
test("should handle all groupBy options as false", () => {
rule.groupByMonitor = false;
rule.groupBySeverity = false;
rule.groupByAlertTitle = false;
expect(rule.groupByMonitor).toBe(false);
expect(rule.groupBySeverity).toBe(false);
expect(rule.groupByAlertTitle).toBe(false);
});
test("should handle combination of groupBy options", () => {
rule.groupByMonitor = true;
rule.groupBySeverity = true;
rule.groupByAlertTitle = false;
expect(rule.groupByMonitor).toBe(true);
expect(rule.groupBySeverity).toBe(true);
expect(rule.groupByAlertTitle).toBe(false);
});
});
describe("Time settings", () => {
test("should set and get enableTimeWindow correctly", () => {
rule.enableTimeWindow = true;
expect(rule.enableTimeWindow).toBe(true);
});
test("should set and get timeWindowMinutes correctly", () => {
rule.timeWindowMinutes = 30;
expect(rule.timeWindowMinutes).toBe(30);
});
test("should set and get enableReopenWindow correctly", () => {
rule.enableReopenWindow = true;
expect(rule.enableReopenWindow).toBe(true);
});
test("should set and get reopenWindowMinutes correctly", () => {
rule.reopenWindowMinutes = 60;
expect(rule.reopenWindowMinutes).toBe(60);
});
test("should set and get enableInactivityTimeout correctly", () => {
rule.enableInactivityTimeout = true;
expect(rule.enableInactivityTimeout).toBe(true);
});
test("should set and get inactivityTimeoutMinutes correctly", () => {
rule.inactivityTimeoutMinutes = 120;
expect(rule.inactivityTimeoutMinutes).toBe(120);
});
test("should set and get enableResolveDelay correctly", () => {
rule.enableResolveDelay = true;
expect(rule.enableResolveDelay).toBe(true);
});
test("should set and get resolveDelayMinutes correctly", () => {
rule.resolveDelayMinutes = 15;
expect(rule.resolveDelayMinutes).toBe(15);
});
});
describe("Episode Template settings", () => {
test("should set and get episodeTitleTemplate correctly", () => {
rule.episodeTitleTemplate = "{{alertSeverity}}: {{alertTitle}}";
expect(rule.episodeTitleTemplate).toBe(
"{{alertSeverity}}: {{alertTitle}}",
);
});
test("should set and get episodeDescriptionTemplate correctly", () => {
rule.episodeDescriptionTemplate =
"Episode with {{alertCount}} alerts from {{monitorName}}";
expect(rule.episodeDescriptionTemplate).toBe(
"Episode with {{alertCount}} alerts from {{monitorName}}",
);
});
test("should handle template with all supported variables", () => {
rule.episodeTitleTemplate =
"{{alertSeverity}} on {{monitorName}}: {{alertTitle}} ({{alertCount}})";
expect(rule.episodeTitleTemplate).toBe(
"{{alertSeverity}} on {{monitorName}}: {{alertTitle}} ({{alertCount}})",
);
});
});
describe("Ownership settings", () => {
test("should set and get defaultAssignToUser correctly", () => {
const user: User = new User();
user._id = ObjectID.generate().toString();
rule.defaultAssignToUser = user;
expect(rule.defaultAssignToUser).toBe(user);
});
test("should set and get defaultAssignToUserId correctly", () => {
const userId: ObjectID = ObjectID.generate();
rule.defaultAssignToUserId = userId;
expect(rule.defaultAssignToUserId).toEqual(userId);
});
test("should set and get defaultAssignToTeam correctly", () => {
const team: Team = new Team();
team._id = ObjectID.generate().toString();
rule.defaultAssignToTeam = team;
expect(rule.defaultAssignToTeam).toBe(team);
});
test("should set and get defaultAssignToTeamId correctly", () => {
const teamId: ObjectID = ObjectID.generate();
rule.defaultAssignToTeamId = teamId;
expect(rule.defaultAssignToTeamId).toEqual(teamId);
});
});
describe("On-Call Policy settings", () => {
test("should set and get onCallDutyPolicies correctly", () => {
const policy1: OnCallDutyPolicy = new OnCallDutyPolicy();
policy1._id = ObjectID.generate().toString();
const policy2: OnCallDutyPolicy = new OnCallDutyPolicy();
policy2._id = ObjectID.generate().toString();
rule.onCallDutyPolicies = [policy1, policy2];
expect(rule.onCallDutyPolicies).toHaveLength(2);
});
test("should handle empty onCallDutyPolicies array", () => {
rule.onCallDutyPolicies = [];
expect(rule.onCallDutyPolicies).toHaveLength(0);
});
});
describe("Full AlertGroupingRule", () => {
test("should handle complete rule configuration", () => {
const id: ObjectID = ObjectID.generate();
const projectId: ObjectID = ObjectID.generate();
const userId: ObjectID = ObjectID.generate();
const teamId: ObjectID = ObjectID.generate();
const fullRule: AlertGroupingRule = new AlertGroupingRule(id);
fullRule.projectId = projectId;
fullRule.name = "Production Critical Alerts";
fullRule.description = "Groups critical alerts from production";
fullRule.priority = 1;
fullRule.isEnabled = true;
// Match criteria
fullRule.alertTitlePattern = ".*critical.*";
fullRule.alertDescriptionPattern = ".*production.*";
// Group by
fullRule.groupByMonitor = true;
fullRule.groupBySeverity = true;
fullRule.groupByAlertTitle = false;
// Time settings
fullRule.enableTimeWindow = true;
fullRule.timeWindowMinutes = 30;
fullRule.enableReopenWindow = true;
fullRule.reopenWindowMinutes = 60;
fullRule.enableInactivityTimeout = true;
fullRule.inactivityTimeoutMinutes = 120;
fullRule.enableResolveDelay = true;
fullRule.resolveDelayMinutes = 15;
// Templates
fullRule.episodeTitleTemplate =
"{{alertSeverity}} Episode: {{alertTitle}}";
fullRule.episodeDescriptionTemplate = "{{alertCount}} related alerts";
// Ownership
fullRule.defaultAssignToUserId = userId;
fullRule.defaultAssignToTeamId = teamId;
// Verify all fields
expect(fullRule.id).toEqual(id);
expect(fullRule.projectId).toEqual(projectId);
expect(fullRule.name).toBe("Production Critical Alerts");
expect(fullRule.description).toBe(
"Groups critical alerts from production",
);
expect(fullRule.priority).toBe(1);
expect(fullRule.isEnabled).toBe(true);
expect(fullRule.alertTitlePattern).toBe(".*critical.*");
expect(fullRule.groupByMonitor).toBe(true);
expect(fullRule.groupBySeverity).toBe(true);
expect(fullRule.enableTimeWindow).toBe(true);
expect(fullRule.timeWindowMinutes).toBe(30);
expect(fullRule.episodeTitleTemplate).toBe(
"{{alertSeverity}} Episode: {{alertTitle}}",
);
expect(fullRule.defaultAssignToUserId).toEqual(userId);
});
test("should create rule with minimal configuration", () => {
const minRule: AlertGroupingRule = new AlertGroupingRule();
minRule.name = "Basic Rule";
minRule.priority = 10;
minRule.isEnabled = true;
expect(minRule.name).toBe("Basic Rule");
expect(minRule.priority).toBe(10);
expect(minRule.isEnabled).toBe(true);
// All other fields should be undefined or default
expect(minRule.monitors).toBeUndefined();
expect(minRule.alertSeverities).toBeUndefined();
expect(minRule.alertTitlePattern).toBeUndefined();
expect(minRule.groupByMonitor).toBeUndefined();
expect(minRule.episodeTitleTemplate).toBeUndefined();
});
});
describe("Priority ordering", () => {
test("should correctly compare priority values", () => {
const rule1: AlertGroupingRule = new AlertGroupingRule();
rule1.priority = 1;
const rule2: AlertGroupingRule = new AlertGroupingRule();
rule2.priority = 10;
const rule3: AlertGroupingRule = new AlertGroupingRule();
rule3.priority = 5;
const rules: AlertGroupingRule[] = [rule2, rule3, rule1];
rules.sort((a: AlertGroupingRule, b: AlertGroupingRule) => {
return (a.priority || 0) - (b.priority || 0);
});
expect(rules[0]!.priority).toBe(1);
expect(rules[1]!.priority).toBe(5);
expect(rules[2]!.priority).toBe(10);
});
});
});

View File

@@ -3,4 +3,13 @@ enum SortOrder {
Descending = "DESC",
}
// Maps SortOrder to ARIA sort values for accessibility
export const SortOrderToAriaSortMap: Record<
SortOrder,
"ascending" | "descending"
> = {
[SortOrder.Ascending]: "ascending",
[SortOrder.Descending]: "descending",
};
export default SortOrder;

View File

@@ -42,6 +42,11 @@ enum EmailTemplateType {
AlertOwnerNotePosted = "AlertOwnerNotePosted.hbs",
AlertOwnerResourceCreated = "AlertOwnerResourceCreated.hbs",
AlertEpisodeOwnerAdded = "AlertEpisodeOwnerAdded.hbs",
AlertEpisodeOwnerStateChanged = "AlertEpisodeOwnerStateChanged.hbs",
AlertEpisodeOwnerNotePosted = "AlertEpisodeOwnerNotePosted.hbs",
AlertEpisodeOwnerResourceCreated = "AlertEpisodeOwnerResourceCreated.hbs",
ScheduledMaintenanceOwnerNotePosted = "ScheduledMaintenanceOwnerNotePosted.hbs",
ScheduledMaintenanceOwnerAdded = "ScheduledMaintenanceOwnerAdded.hbs",
ScheduledMaintenanceOwnerStateChanged = "ScheduledMaintenanceOwnerStateChanged.hbs",

View File

@@ -1,5 +1,6 @@
enum NotificationRuleType {
ON_CALL_EXECUTED = "When on-call policy is executed",
ON_CALL_EXECUTED_EPISODE = "When episode on-call policy is executed",
WHEN_USER_GOES_ON_CALL = "When user goes on call",
WHEN_USER_GOES_OFF_CALL = "When user goes off call",
}

View File

@@ -12,6 +12,13 @@ enum NotificationSettingEventType {
SEND_ALERT_STATE_CHANGED_OWNER_NOTIFICATION = "Send alert state changed notification when I am the owner of the alert",
SEND_ALERT_OWNER_ADDED_NOTIFICATION = "Send notification when I am added as a owner to the alert",
// Alert Episodes
SEND_ALERT_EPISODE_CREATED_OWNER_NOTIFICATION = "Send alert episode created notification when I am the owner of the alert episode",
SEND_ALERT_EPISODE_NOTE_POSTED_OWNER_NOTIFICATION = "Send alert episode note posted notification when I am the owner of the alert episode",
SEND_ALERT_EPISODE_STATE_CHANGED_OWNER_NOTIFICATION = "Send alert episode state changed notification when I am the owner of the alert episode",
SEND_ALERT_EPISODE_OWNER_ADDED_NOTIFICATION = "Send notification when I am added as a owner to the alert episode",
// Monitors
SEND_MONITOR_OWNER_ADDED_NOTIFICATION = "Send notification when I am added as a owner to the monitor",
SEND_MONITOR_CREATED_OWNER_NOTIFICATION = "Send monitor created notification when I am the owner of the monitor",

View File

@@ -701,6 +701,53 @@ enum Permission {
DeleteWorkspaceNotificationRule = "DeleteWorkspaceNotificationRule",
EditWorkspaceNotificationRule = "EditWorkspaceNotificationRule",
ReadWorkspaceNotificationRule = "ReadWorkspaceNotificationRule",
// Alert Episode Permissions
CreateAlertEpisode = "CreateAlertEpisode",
DeleteAlertEpisode = "DeleteAlertEpisode",
EditAlertEpisode = "EditAlertEpisode",
ReadAlertEpisode = "ReadAlertEpisode",
// Alert Episode Member Permissions
CreateAlertEpisodeMember = "CreateAlertEpisodeMember",
DeleteAlertEpisodeMember = "DeleteAlertEpisodeMember",
EditAlertEpisodeMember = "EditAlertEpisodeMember",
ReadAlertEpisodeMember = "ReadAlertEpisodeMember",
// Alert Grouping Rule Permissions
CreateAlertGroupingRule = "CreateAlertGroupingRule",
DeleteAlertGroupingRule = "DeleteAlertGroupingRule",
EditAlertGroupingRule = "EditAlertGroupingRule",
ReadAlertGroupingRule = "ReadAlertGroupingRule",
// Alert Episode State Timeline Permissions
CreateAlertEpisodeStateTimeline = "CreateAlertEpisodeStateTimeline",
DeleteAlertEpisodeStateTimeline = "DeleteAlertEpisodeStateTimeline",
EditAlertEpisodeStateTimeline = "EditAlertEpisodeStateTimeline",
ReadAlertEpisodeStateTimeline = "ReadAlertEpisodeStateTimeline",
// Alert Episode Owner User Permissions
CreateAlertEpisodeOwnerUser = "CreateAlertEpisodeOwnerUser",
DeleteAlertEpisodeOwnerUser = "DeleteAlertEpisodeOwnerUser",
EditAlertEpisodeOwnerUser = "EditAlertEpisodeOwnerUser",
ReadAlertEpisodeOwnerUser = "ReadAlertEpisodeOwnerUser",
// Alert Episode Owner Team Permissions
CreateAlertEpisodeOwnerTeam = "CreateAlertEpisodeOwnerTeam",
DeleteAlertEpisodeOwnerTeam = "DeleteAlertEpisodeOwnerTeam",
EditAlertEpisodeOwnerTeam = "EditAlertEpisodeOwnerTeam",
ReadAlertEpisodeOwnerTeam = "ReadAlertEpisodeOwnerTeam",
// Alert Episode Internal Note Permissions
CreateAlertEpisodeInternalNote = "CreateAlertEpisodeInternalNote",
DeleteAlertEpisodeInternalNote = "DeleteAlertEpisodeInternalNote",
EditAlertEpisodeInternalNote = "EditAlertEpisodeInternalNote",
ReadAlertEpisodeInternalNote = "ReadAlertEpisodeInternalNote",
// Alert Episode Feed Permissions
CreateAlertEpisodeFeed = "CreateAlertEpisodeFeed",
EditAlertEpisodeFeed = "EditAlertEpisodeFeed",
ReadAlertEpisodeFeed = "ReadAlertEpisodeFeed",
}
export class PermissionHelper {
@@ -5017,6 +5064,268 @@ export class PermissionHelper {
isAssignableToTenant: true,
isAccessControlPermission: false,
},
// Alert Episode Permissions
{
permission: Permission.CreateAlertEpisode,
title: "Create Alert Episode",
description:
"This permission can create Alert Episodes in this project.",
isAssignableToTenant: true,
isAccessControlPermission: true,
},
{
permission: Permission.DeleteAlertEpisode,
title: "Delete Alert Episode",
description:
"This permission can delete Alert Episodes of this project.",
isAssignableToTenant: true,
isAccessControlPermission: true,
},
{
permission: Permission.EditAlertEpisode,
title: "Edit Alert Episode",
description: "This permission can edit Alert Episodes of this project.",
isAssignableToTenant: true,
isAccessControlPermission: true,
},
{
permission: Permission.ReadAlertEpisode,
title: "Read Alert Episode",
description: "This permission can read Alert Episodes of this project.",
isAssignableToTenant: true,
isAccessControlPermission: true,
},
// Alert Episode Member Permissions
{
permission: Permission.CreateAlertEpisodeMember,
title: "Create Alert Episode Member",
description:
"This permission can add alerts to Alert Episodes in this project.",
isAssignableToTenant: true,
isAccessControlPermission: false,
},
{
permission: Permission.DeleteAlertEpisodeMember,
title: "Delete Alert Episode Member",
description:
"This permission can remove alerts from Alert Episodes of this project.",
isAssignableToTenant: true,
isAccessControlPermission: false,
},
{
permission: Permission.EditAlertEpisodeMember,
title: "Edit Alert Episode Member",
description:
"This permission can edit Alert Episode Members of this project.",
isAssignableToTenant: true,
isAccessControlPermission: false,
},
{
permission: Permission.ReadAlertEpisodeMember,
title: "Read Alert Episode Member",
description:
"This permission can read Alert Episode Members of this project.",
isAssignableToTenant: true,
isAccessControlPermission: false,
},
// Alert Grouping Rule Permissions
{
permission: Permission.CreateAlertGroupingRule,
title: "Create Alert Grouping Rule",
description:
"This permission can create Alert Grouping Rules in this project.",
isAssignableToTenant: true,
isAccessControlPermission: false,
},
{
permission: Permission.DeleteAlertGroupingRule,
title: "Delete Alert Grouping Rule",
description:
"This permission can delete Alert Grouping Rules of this project.",
isAssignableToTenant: true,
isAccessControlPermission: false,
},
{
permission: Permission.EditAlertGroupingRule,
title: "Edit Alert Grouping Rule",
description:
"This permission can edit Alert Grouping Rules of this project.",
isAssignableToTenant: true,
isAccessControlPermission: false,
},
{
permission: Permission.ReadAlertGroupingRule,
title: "Read Alert Grouping Rule",
description:
"This permission can read Alert Grouping Rules of this project.",
isAssignableToTenant: true,
isAccessControlPermission: false,
},
// Alert Episode State Timeline Permissions
{
permission: Permission.CreateAlertEpisodeStateTimeline,
title: "Create Alert Episode State Timeline",
description:
"This permission can create Alert Episode state history in this project.",
isAssignableToTenant: true,
isAccessControlPermission: false,
},
{
permission: Permission.DeleteAlertEpisodeStateTimeline,
title: "Delete Alert Episode State Timeline",
description:
"This permission can delete Alert Episode state history of this project.",
isAssignableToTenant: true,
isAccessControlPermission: false,
},
{
permission: Permission.EditAlertEpisodeStateTimeline,
title: "Edit Alert Episode State Timeline",
description:
"This permission can edit Alert Episode state history of this project.",
isAssignableToTenant: true,
isAccessControlPermission: false,
},
{
permission: Permission.ReadAlertEpisodeStateTimeline,
title: "Read Alert Episode State Timeline",
description:
"This permission can read Alert Episode state history of this project.",
isAssignableToTenant: true,
isAccessControlPermission: false,
},
// Alert Episode Owner User Permissions
{
permission: Permission.CreateAlertEpisodeOwnerUser,
title: "Create Alert Episode User Owner",
description:
"This permission can add user owners to Alert Episodes in this project.",
isAssignableToTenant: true,
isAccessControlPermission: false,
},
{
permission: Permission.DeleteAlertEpisodeOwnerUser,
title: "Delete Alert Episode User Owner",
description:
"This permission can remove user owners from Alert Episodes of this project.",
isAssignableToTenant: true,
isAccessControlPermission: false,
},
{
permission: Permission.EditAlertEpisodeOwnerUser,
title: "Edit Alert Episode User Owner",
description:
"This permission can edit Alert Episode user owners of this project.",
isAssignableToTenant: true,
isAccessControlPermission: false,
},
{
permission: Permission.ReadAlertEpisodeOwnerUser,
title: "Read Alert Episode User Owner",
description:
"This permission can read Alert Episode user owners of this project.",
isAssignableToTenant: true,
isAccessControlPermission: false,
},
// Alert Episode Owner Team Permissions
{
permission: Permission.CreateAlertEpisodeOwnerTeam,
title: "Create Alert Episode Team Owner",
description:
"This permission can add team owners to Alert Episodes in this project.",
isAssignableToTenant: true,
isAccessControlPermission: false,
},
{
permission: Permission.DeleteAlertEpisodeOwnerTeam,
title: "Delete Alert Episode Team Owner",
description:
"This permission can remove team owners from Alert Episodes of this project.",
isAssignableToTenant: true,
isAccessControlPermission: false,
},
{
permission: Permission.EditAlertEpisodeOwnerTeam,
title: "Edit Alert Episode Team Owner",
description:
"This permission can edit Alert Episode team owners of this project.",
isAssignableToTenant: true,
isAccessControlPermission: false,
},
{
permission: Permission.ReadAlertEpisodeOwnerTeam,
title: "Read Alert Episode Team Owner",
description:
"This permission can read Alert Episode team owners of this project.",
isAssignableToTenant: true,
isAccessControlPermission: false,
},
// Alert Episode Internal Note Permissions
{
permission: Permission.CreateAlertEpisodeInternalNote,
title: "Create Alert Episode Internal Note",
description:
"This permission can create Alert Episode internal notes in this project.",
isAssignableToTenant: true,
isAccessControlPermission: false,
},
{
permission: Permission.DeleteAlertEpisodeInternalNote,
title: "Delete Alert Episode Internal Note",
description:
"This permission can delete Alert Episode internal notes of this project.",
isAssignableToTenant: true,
isAccessControlPermission: false,
},
{
permission: Permission.EditAlertEpisodeInternalNote,
title: "Edit Alert Episode Internal Note",
description:
"This permission can edit Alert Episode internal notes of this project.",
isAssignableToTenant: true,
isAccessControlPermission: false,
},
{
permission: Permission.ReadAlertEpisodeInternalNote,
title: "Read Alert Episode Internal Note",
description:
"This permission can read Alert Episode internal notes of this project.",
isAssignableToTenant: true,
isAccessControlPermission: false,
},
// Alert Episode Feed Permissions
{
permission: Permission.CreateAlertEpisodeFeed,
title: "Create Alert Episode Feed",
description:
"This permission can create Alert Episode feed items in this project.",
isAssignableToTenant: true,
isAccessControlPermission: false,
},
{
permission: Permission.EditAlertEpisodeFeed,
title: "Edit Alert Episode Feed",
description:
"This permission can edit Alert Episode feed items of this project.",
isAssignableToTenant: true,
isAccessControlPermission: false,
},
{
permission: Permission.ReadAlertEpisodeFeed,
title: "Read Alert Episode Feed",
description:
"This permission can read Alert Episode feed items of this project.",
isAssignableToTenant: true,
isAccessControlPermission: false,
},
];
return permissions;

View File

@@ -1,6 +1,7 @@
enum UserNotificationEventType {
IncidentCreated = "Incident Created",
AlertCreated = "Alert Created",
AlertEpisodeCreated = "Alert Episode Created",
}
export default UserNotificationEventType;

View File

@@ -11,6 +11,10 @@ type TemplateIdsMap = {
readonly AlertNotePostedOwnerNotification: "oneuptime_alert_note_posted_owner_notification";
readonly AlertStateChangedOwnerNotification: "oneuptime_alert_state_changed_owner_notification";
readonly AlertOwnerAddedNotification: "oneuptime_alert_owner_added_notification";
readonly AlertEpisodeCreatedOwnerNotification: "oneuptime_alert_episode_created_owner_notification";
readonly AlertEpisodeNotePostedOwnerNotification: "oneuptime_alert_episode_note_posted_owner_notification";
readonly AlertEpisodeStateChangedOwnerNotification: "oneuptime_alert_episode_state_changed_owner_notification";
readonly AlertEpisodeOwnerAddedNotification: "oneuptime_alert_episode_owner_added_notification";
readonly MonitorOwnerAddedNotification: "oneuptime_monitor_owner_added_notification";
readonly MonitorCreatedOwnerNotification: "oneuptime_monitor_created_owner_notification";
readonly MonitorStatusChangedOwnerNotification: "oneuptime_monitor_status_changed_owner_notification";
@@ -52,6 +56,14 @@ const templateIds: TemplateIdsMap = {
AlertStateChangedOwnerNotification:
"oneuptime_alert_state_changed_owner_notification",
AlertOwnerAddedNotification: "oneuptime_alert_owner_added_notification",
AlertEpisodeCreatedOwnerNotification:
"oneuptime_alert_episode_created_owner_notification",
AlertEpisodeNotePostedOwnerNotification:
"oneuptime_alert_episode_note_posted_owner_notification",
AlertEpisodeStateChangedOwnerNotification:
"oneuptime_alert_episode_state_changed_owner_notification",
AlertEpisodeOwnerAddedNotification:
"oneuptime_alert_episode_owner_added_notification",
MonitorOwnerAddedNotification: "oneuptime_monitor_owner_added_notification",
MonitorCreatedOwnerNotification:
"oneuptime_monitor_created_owner_notification",
@@ -118,6 +130,10 @@ export const WhatsAppTemplateMessages: WhatsAppTemplateMessagesDefinition = {
[WhatsAppTemplateIds.AlertNotePostedOwnerNotification]: `A new note was posted on alert #{{alert_number}} ({{alert_title}}). Review the alert using {{alert_link}} on the OneUptime dashboard for updates.`,
[WhatsAppTemplateIds.AlertStateChangedOwnerNotification]: `Alert #{{alert_number}} ({{alert_title}}) state changed to {{alert_state}}. Track the alert status using {{alert_link}} on the OneUptime dashboard to stay informed.`,
[WhatsAppTemplateIds.AlertOwnerAddedNotification]: `You have been added as an owner of alert #{{alert_number}} ({{alert_title}}). Manage the alert using {{alert_link}} on the OneUptime dashboard to take action.`,
[WhatsAppTemplateIds.AlertEpisodeCreatedOwnerNotification]: `Alert Episode #{{episode_number}} ({{episode_title}}) has been created for project {{project_name}}. View alert episode details using {{episode_link}} on the OneUptime dashboard.`,
[WhatsAppTemplateIds.AlertEpisodeNotePostedOwnerNotification]: `A new note was posted on alert episode #{{episode_number}} ({{episode_title}}). Review the alert episode using {{episode_link}} on the OneUptime dashboard for updates.`,
[WhatsAppTemplateIds.AlertEpisodeStateChangedOwnerNotification]: `Alert Episode #{{episode_number}} ({{episode_title}}) state changed to {{episode_state}}. Track the alert episode status using {{episode_link}} on the OneUptime dashboard.`,
[WhatsAppTemplateIds.AlertEpisodeOwnerAddedNotification]: `You have been added as an owner of alert episode #{{episode_number}} ({{episode_title}}). Manage the alert episode using {{episode_link}} on the OneUptime dashboard.`,
[WhatsAppTemplateIds.MonitorOwnerAddedNotification]: `You have been added as an owner of monitor {{monitor_name}}. Manage the monitor using {{monitor_link}} on the OneUptime dashboard to keep things running.`,
[WhatsAppTemplateIds.MonitorCreatedOwnerNotification]: `Monitor {{monitor_name}} has been created. Check monitor {{monitor_link}} on the OneUptime dashboard `,
[WhatsAppTemplateIds.MonitorStatusChangedOwnerNotification]: `Monitor {{monitor_name}} status changed to {{monitor_status}}. Check the monitor status using {{monitor_link}} on the OneUptime dashboard to stay informed.`,
@@ -154,6 +170,10 @@ export const WhatsAppTemplateLanguage: Record<WhatsAppTemplateId, string> = {
[WhatsAppTemplateIds.AlertNotePostedOwnerNotification]: "en",
[WhatsAppTemplateIds.AlertStateChangedOwnerNotification]: "en",
[WhatsAppTemplateIds.AlertOwnerAddedNotification]: "en",
[WhatsAppTemplateIds.AlertEpisodeCreatedOwnerNotification]: "en",
[WhatsAppTemplateIds.AlertEpisodeNotePostedOwnerNotification]: "en",
[WhatsAppTemplateIds.AlertEpisodeStateChangedOwnerNotification]: "en",
[WhatsAppTemplateIds.AlertEpisodeOwnerAddedNotification]: "en",
[WhatsAppTemplateIds.MonitorOwnerAddedNotification]: "en",
[WhatsAppTemplateIds.MonitorCreatedOwnerNotification]: "en",
[WhatsAppTemplateIds.MonitorStatusChangedOwnerNotification]: "en",

View File

@@ -2,6 +2,7 @@ enum NotificationRuleEventType {
Incident = "Incident",
Monitor = "Monitor",
Alert = "Alert",
AlertEpisode = "Alert Episode",
ScheduledMaintenance = "Scheduled Maintenance",
OnCallDutyPolicy = "On-Call Duty Policy",
}

View File

@@ -25,6 +25,11 @@ export enum NotificationRuleConditionCheckOn {
AlertDescription = "Alert Description",
AlertSeverity = "Alert Severity",
AlertState = "Alert State",
AlertEpisodeTitle = "Alert Episode Title",
AlertEpisodeDescription = "Alert Episode Description",
AlertEpisodeSeverity = "Alert Episode Severity",
AlertEpisodeState = "Alert Episode State",
AlertEpisodeLabels = "Alert Episode Labels",
ScheduledMaintenanceTitle = "Scheduled Maintenance Title",
ScheduledMaintenanceDescription = "Scheduled Maintenance Description",
ScheduledMaintenanceState = "Scheduled Maintenance State",
@@ -94,6 +99,7 @@ export class NotificationRuleConditionUtil {
if (
eventType === NotificationRuleEventType.Incident ||
eventType === NotificationRuleEventType.Alert ||
eventType === NotificationRuleEventType.AlertEpisode ||
eventType === NotificationRuleEventType.ScheduledMaintenance
) {
// either create slack channel or select existing one should be active.
@@ -154,13 +160,16 @@ export class NotificationRuleConditionUtil {
case NotificationRuleConditionCheckOn.MonitorType:
case NotificationRuleConditionCheckOn.IncidentState:
case NotificationRuleConditionCheckOn.AlertState:
case NotificationRuleConditionCheckOn.AlertEpisodeState:
case NotificationRuleConditionCheckOn.MonitorStatus:
case NotificationRuleConditionCheckOn.ScheduledMaintenanceState:
case NotificationRuleConditionCheckOn.IncidentSeverity:
case NotificationRuleConditionCheckOn.AlertSeverity:
case NotificationRuleConditionCheckOn.AlertEpisodeSeverity:
case NotificationRuleConditionCheckOn.MonitorLabels:
case NotificationRuleConditionCheckOn.IncidentLabels:
case NotificationRuleConditionCheckOn.AlertLabels:
case NotificationRuleConditionCheckOn.AlertEpisodeLabels:
case NotificationRuleConditionCheckOn.ScheduledMaintenanceLabels:
case NotificationRuleConditionCheckOn.Monitors:
return true;
@@ -180,7 +189,10 @@ export class NotificationRuleConditionUtil {
monitors: Array<Monitor>;
checkOn: NotificationRuleConditionCheckOn;
}): Array<DropdownOption> {
if (data.checkOn === NotificationRuleConditionCheckOn.AlertSeverity) {
if (
data.checkOn === NotificationRuleConditionCheckOn.AlertSeverity ||
data.checkOn === NotificationRuleConditionCheckOn.AlertEpisodeSeverity
) {
return data.alertSeverities.map((severity: AlertSeverity) => {
return {
value: severity.id!.toString(),
@@ -234,6 +246,7 @@ export class NotificationRuleConditionUtil {
data.checkOn === NotificationRuleConditionCheckOn.MonitorLabels ||
data.checkOn === NotificationRuleConditionCheckOn.IncidentLabels ||
data.checkOn === NotificationRuleConditionCheckOn.AlertLabels ||
data.checkOn === NotificationRuleConditionCheckOn.AlertEpisodeLabels ||
data.checkOn ===
NotificationRuleConditionCheckOn.ScheduledMaintenanceLabels
) {
@@ -254,8 +267,11 @@ export class NotificationRuleConditionUtil {
});
}
// alert states
if (data.checkOn === NotificationRuleConditionCheckOn.AlertState) {
// alert states (also used for alert episodes)
if (
data.checkOn === NotificationRuleConditionCheckOn.AlertState ||
data.checkOn === NotificationRuleConditionCheckOn.AlertEpisodeState
) {
return data.alertStates.map((state: AlertState) => {
return {
value: state.id!.toString(),
@@ -298,6 +314,14 @@ export class NotificationRuleConditionUtil {
NotificationRuleConditionCheckOn.Monitors,
];
case NotificationRuleEventType.AlertEpisode:
return [
NotificationRuleConditionCheckOn.AlertEpisodeTitle,
NotificationRuleConditionCheckOn.AlertEpisodeDescription,
NotificationRuleConditionCheckOn.AlertEpisodeSeverity,
NotificationRuleConditionCheckOn.AlertEpisodeState,
NotificationRuleConditionCheckOn.AlertEpisodeLabels,
];
case NotificationRuleEventType.Monitor:
return [
NotificationRuleConditionCheckOn.MonitorName,
@@ -329,6 +353,8 @@ export class NotificationRuleConditionUtil {
case NotificationRuleConditionCheckOn.IncidentDescription:
case NotificationRuleConditionCheckOn.AlertTitle:
case NotificationRuleConditionCheckOn.AlertDescription:
case NotificationRuleConditionCheckOn.AlertEpisodeTitle:
case NotificationRuleConditionCheckOn.AlertEpisodeDescription:
case NotificationRuleConditionCheckOn.ScheduledMaintenanceTitle:
case NotificationRuleConditionCheckOn.ScheduledMaintenanceDescription:
return [
@@ -341,15 +367,18 @@ export class NotificationRuleConditionUtil {
];
case NotificationRuleConditionCheckOn.IncidentSeverity:
case NotificationRuleConditionCheckOn.AlertSeverity:
case NotificationRuleConditionCheckOn.AlertEpisodeSeverity:
return [ConditionType.ContainsAny, ConditionType.NotContains];
case NotificationRuleConditionCheckOn.IncidentState:
case NotificationRuleConditionCheckOn.AlertState:
case NotificationRuleConditionCheckOn.AlertEpisodeState:
case NotificationRuleConditionCheckOn.MonitorStatus:
case NotificationRuleConditionCheckOn.ScheduledMaintenanceState:
return [ConditionType.ContainsAny, ConditionType.NotContains];
case NotificationRuleConditionCheckOn.MonitorType:
return [ConditionType.ContainsAny, ConditionType.NotContains];
case NotificationRuleConditionCheckOn.AlertLabels:
case NotificationRuleConditionCheckOn.AlertEpisodeLabels:
case NotificationRuleConditionCheckOn.IncidentLabels:
case NotificationRuleConditionCheckOn.MonitorLabels:
case NotificationRuleConditionCheckOn.ScheduledMaintenanceLabels:

View File

@@ -60,15 +60,30 @@ const Accordion: FunctionComponent<ComponentProps> = (
className = "-ml-5 -mr-5 p-5 mt-1";
}
const accordionId: string = `accordion-content-${React.useId()}`;
const handleKeyDown: (event: React.KeyboardEvent) => void = (
event: React.KeyboardEvent,
): void => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setIsOpen(!isOpen);
}
};
return (
<div className={className}>
<div>
<div
className={`flex justify-between cursor-pointer`}
role="alert"
role="button"
tabIndex={0}
aria-expanded={isOpen}
aria-controls={accordionId}
onClick={() => {
setIsOpen(!isOpen);
}}
onKeyDown={handleKeyDown}
>
<div className="flex">
{props.title && (
@@ -109,7 +124,10 @@ const Accordion: FunctionComponent<ComponentProps> = (
{!isOpen && <div className="">{props.rightElement}</div>}
</div>
{isOpen && (
<div className={`space-y-5 ${props.title ? "mt-4" : ""}`}>
<div
id={accordionId}
className={`space-y-5 ${props.title ? "mt-4" : ""}`}
>
{props.children}
</div>
)}

View File

@@ -82,6 +82,7 @@ const Alert: FunctionComponent<ComponentProps> = (
data-testid={props.dataTestId}
onClick={props.onClick}
role="alert"
aria-live="polite"
style={props.color ? { backgroundColor: props.color.toString() } : {}}
>
<div className="alert-content flex">

View File

@@ -51,6 +51,18 @@ export interface ComponentProps {
dataTestId?: string;
className?: string | undefined;
tooltip?: string | undefined;
ariaLabel?: string | undefined;
ariaExpanded?: boolean | undefined;
ariaHaspopup?:
| "menu"
| "listbox"
| "dialog"
| "tree"
| "grid"
| "true"
| "false"
| undefined;
ariaControls?: string | undefined;
}
const Button: FunctionComponent<ComponentProps> = ({
@@ -69,6 +81,10 @@ const Button: FunctionComponent<ComponentProps> = ({
dataTestId,
className,
tooltip,
ariaLabel,
ariaExpanded,
ariaHaspopup,
ariaControls,
}: ComponentProps): ReactElement => {
useEffect(() => {
// componentDidMount
@@ -233,6 +249,14 @@ const Button: FunctionComponent<ComponentProps> = ({
buttonStyleCssClass += ` ` + className;
}
// For icon-only buttons, use title as aria-label for accessibility
const computedAriaLabel: string | undefined =
ariaLabel ||
(buttonStyle === ButtonStyleType.ICON ||
buttonStyle === ButtonStyleType.ICON_LIGHT
? title || tooltip
: undefined);
const getButton: GetReactElementFunction = (): ReactElement => {
return (
<button
@@ -247,6 +271,11 @@ const Button: FunctionComponent<ComponentProps> = ({
type={type}
disabled={disabled || isLoading}
className={buttonStyleCssClass}
aria-label={computedAriaLabel}
aria-disabled={disabled || isLoading}
aria-expanded={ariaExpanded}
aria-haspopup={ariaHaspopup}
aria-controls={ariaControls}
>
{isLoading && buttonStyle !== ButtonStyleType.ICON && (
<Icon icon={IconProp.Spinner} className={loadingIconClassName} />

View File

@@ -23,7 +23,11 @@ const CardSelect: FunctionComponent<ComponentProps> = (
): ReactElement => {
return (
<div data-testid={props.dataTestId}>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div
role="radiogroup"
aria-label="Select an option"
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
>
{props.options.map((option: CardSelectOption, index: number) => {
const isSelected: boolean = props.value === option.value;

View File

@@ -67,8 +67,10 @@ const CheckboxElement: FunctionComponent<CategoryProps> = (
onFocus={props.onFocus}
onBlur={props.onBlur}
data-testid={props.dataTestId}
aria-describedby="comments-description"
name="comments"
aria-describedby={
props.description ? "checkbox-description" : undefined
}
aria-invalid={props.error ? "true" : undefined}
type="checkbox"
className={`accent-indigo-600 h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600 ${
props.className || ""
@@ -78,7 +80,9 @@ const CheckboxElement: FunctionComponent<CategoryProps> = (
<div className="ml-3 text-sm leading-6">
<label className="font-medium text-gray-900">{props.title}</label>
{props.description && (
<div className="text-gray-500">{props.description}</div>
<div id="checkbox-description" className="text-gray-500">
{props.description}
</div>
)}
</div>
</div>

View File

@@ -17,6 +17,8 @@ const ColorCircle: FunctionComponent<ComponentProps> = (
style={{
backgroundColor: props.color.toString(),
}}
role="img"
aria-label={props.tooltip}
></div>
</Tooltip>
);

View File

@@ -13,12 +13,29 @@ export interface ComponentProps {
const ColorInput: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const hasOnClick: boolean = Boolean(props.onClick);
const colorLabel: string =
props.value?.toString() || props.placeholder || "No Color Selected";
const handleKeyDown: (event: React.KeyboardEvent) => void = (
event: React.KeyboardEvent,
): void => {
if (hasOnClick && (event.key === "Enter" || event.key === " ")) {
event.preventDefault();
props.onClick?.();
}
};
return (
<div
className={`flex ${props.className}`}
onClick={() => {
props.onClick?.();
}}
onKeyDown={handleKeyDown}
role={hasOnClick ? "button" : undefined}
tabIndex={hasOnClick ? 0 : undefined}
aria-label={hasOnClick ? `Color picker: ${colorLabel}` : undefined}
data-testid={props.dataTestId}
>
{props.value && (
@@ -34,11 +51,10 @@ const ColorInput: FunctionComponent<ComponentProps> = (
marginRight: "7px",
borderStyle: "solid",
}}
aria-hidden="true"
></div>
)}
<div>
{props.value?.toString() || props.placeholder || "No Color Selected"}
</div>
<div>{colorLabel}</div>
</div>
);
};

View File

@@ -19,16 +19,33 @@ const CopyableButton: FunctionComponent<ComponentProps> = (
}, 2000);
};
const handleCopy: () => Promise<void> = async (): Promise<void> => {
refreshCopyToClipboardState();
await navigator.clipboard?.writeText(props.textToBeCopied);
};
const handleKeyDown: (event: React.KeyboardEvent) => Promise<void> = async (
event: React.KeyboardEvent,
): Promise<void> => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
await handleCopy();
}
};
return (
<div
className={`${
copiedToClipboard ? "" : "cursor-pointer mt-0.5"
} flex ml-1 text-gray-500`}
onClick={async () => {
refreshCopyToClipboardState();
await navigator.clipboard?.writeText(props.textToBeCopied);
}}
role="copy-to-clipboard"
onClick={handleCopy}
onKeyDown={handleKeyDown}
role="button"
tabIndex={0}
aria-label={
copiedToClipboard ? "Copied to clipboard" : "Copy to clipboard"
}
aria-live="polite"
>
{" "}
{copiedToClipboard ? (

View File

@@ -395,7 +395,7 @@ const Detail: DetailFunction = <T extends GenericObject>(
style={{
height: "100px",
}}
alt=""
alt={field.title ? `${field.title} image` : "Uploaded image"}
/>
</div>
</div>

View File

@@ -2,6 +2,7 @@ import ObjectID from "../../../Types/ObjectID";
import React, {
FunctionComponent,
ReactElement,
useId,
useLayoutEffect,
useRef,
useState,
@@ -51,11 +52,15 @@ export interface ComponentProps {
error?: string | undefined;
id?: string | undefined;
dataTestId?: string | undefined;
ariaLabel?: string | undefined;
}
const Dropdown: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const uniqueId: string = useId();
const errorId: string = `dropdown-error-${uniqueId}`;
type GetDropdownOptionFromValueFunctionProps =
| undefined
| DropdownValue
@@ -497,6 +502,9 @@ const Dropdown: FunctionComponent<ComponentProps> = (
onFocus={() => {
props.onFocus?.();
}}
aria-label={props.ariaLabel}
aria-invalid={props.error ? true : undefined}
aria-describedby={props.error ? errorId : undefined}
classNames={{
control: (
state: ControlProps<DropdownOption, boolean, GroupBase<any>>,
@@ -699,7 +707,12 @@ const Dropdown: FunctionComponent<ComponentProps> = (
}}
/>
{props.error && (
<p data-testid="error-message" className="mt-1 text-sm text-red-400">
<p
id={errorId}
data-testid="error-message"
className="mt-1 text-sm text-red-400"
role="alert"
>
{props.error}
</p>
)}

View File

@@ -105,6 +105,29 @@ const FormField: <T extends GenericObject>(
}
};
type GetAutoCompleteFunction = (
fieldType: FormFieldSchemaType,
) => string | undefined;
const getAutoComplete: GetAutoCompleteFunction = (
fieldType: FormFieldSchemaType,
): string | undefined => {
switch (fieldType) {
case FormFieldSchemaType.Email:
return "email";
case FormFieldSchemaType.Password:
return "current-password";
case FormFieldSchemaType.Phone:
return "tel";
case FormFieldSchemaType.Name:
return "name";
case FormFieldSchemaType.URL:
return "url";
default:
return undefined;
}
};
const getFormField: GetReactElementFunction = (): ReactElement => {
const [
showMultiSelectCheckboxCategoryModal,
@@ -737,6 +760,11 @@ const FormField: <T extends GenericObject>(
error={props.touched && props.error ? props.error : undefined}
dataTestId={props.field.dataTestId}
type={fieldType as InputType}
autoComplete={
props.field.fieldType
? getAutoComplete(props.field.fieldType)
: undefined
}
onChange={(value: string) => {
onChange(value);
props.setFieldValue(props.fieldName, value);

View File

@@ -10,13 +10,44 @@ export interface ComponentProps {
const FullPageModal: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const handleClose: () => void = (): void => {
props.onClose?.();
};
const handleKeyDown: (event: React.KeyboardEvent) => void = (
event: React.KeyboardEvent,
): void => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
handleClose();
}
};
// Handle Escape key at the modal level
React.useEffect(() => {
const handleEscapeKey: (event: KeyboardEvent) => void = (
event: KeyboardEvent,
): void => {
if (event.key === "Escape") {
handleClose();
}
};
document.addEventListener("keydown", handleEscapeKey);
return () => {
document.removeEventListener("keydown", handleEscapeKey);
};
}, []);
return (
<div className="full-page-modal">
<div className="full-page-modal" role="dialog" aria-modal="true">
<div
className="margin-50 align-right"
onClick={() => {
props.onClose?.();
}}
onClick={handleClose}
onKeyDown={handleKeyDown}
role="button"
tabIndex={0}
aria-label="Close modal"
>
<Icon
icon={IconProp.Close}

View File

@@ -40,6 +40,7 @@ export interface ComponentProps {
autoFocus?: boolean | undefined;
disableSpellCheck?: boolean | undefined;
showSecondsForDateTime?: boolean | undefined;
autoComplete?: string | undefined;
}
const Input: FunctionComponent<ComponentProps> = (
@@ -156,6 +157,9 @@ const Input: FunctionComponent<ComponentProps> = (
onClick={props.onClick}
data-testid={props.dataTestId}
spellCheck={!props.disableSpellCheck}
autoComplete={props.autoComplete}
aria-invalid={props.error ? "true" : undefined}
aria-describedby={props.error ? "input-error-message" : undefined}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
const value: string | Date = e.target.value;
@@ -205,14 +209,22 @@ const Input: FunctionComponent<ComponentProps> = (
/>
{props.error && (
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
<div
className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3"
aria-hidden="true"
>
<Icon icon={IconProp.ErrorSolid} className="h-5 w-5 text-red-500" />
</div>
)}
</div>
{props.error && (
<p data-testid="error-message" className="mt-1 text-sm text-red-400">
<p
id="input-error-message"
data-testid="error-message"
className="mt-1 text-sm text-red-400"
role="alert"
>
{props.error}
</p>
)}

View File

@@ -53,6 +53,7 @@ const Link: FunctionComponent<ComponentProps> = (
onMouseLeave={props.onMouseLeave}
style={props.style}
title={props.title}
aria-label={props.title}
onAuxClick={(event: React.MouseEvent<HTMLAnchorElement>) => {
// middle click
if (event.button === 1) {

View File

@@ -25,11 +25,14 @@ const Loader: FunctionComponent<ComponentProps> = ({
if (loaderType === LoaderType.Bar) {
return (
<div
role="presentation"
role="status"
aria-label="Loading"
aria-live="polite"
className={`flex justify-center mt-1 ${className}`.trim()}
data-testid="bar-loader"
>
<BarLoader height={4} width={size} color={color.toString()} />
<span className="sr-only">Loading...</span>
</div>
);
}
@@ -37,11 +40,14 @@ const Loader: FunctionComponent<ComponentProps> = ({
if (loaderType === LoaderType.Beats) {
return (
<div
role="presentation"
role="status"
aria-label="Loading"
aria-live="polite"
className={`justify-center mt-1 ${className}`.trim()}
data-testid="beat-loader"
>
<BeatLoader size={size} color={color.toString()} />
<span className="sr-only">Loading...</span>
</div>
);
}

View File

@@ -1,10 +1,70 @@
import React, { FunctionComponent, ReactElement } from "react";
import React, {
FunctionComponent,
ReactElement,
useEffect,
useRef,
} from "react";
// https://github.com/remarkjs/react-markdown
import ReactMarkdown from "react-markdown";
// https://github.com/remarkjs/remark-gfm
import remarkGfm from "remark-gfm";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { a11yDark } from "react-syntax-highlighter/dist/esm/styles/prism";
import mermaid from "mermaid";
// Initialize mermaid
mermaid.initialize({
startOnLoad: false,
theme: "default",
securityLevel: "loose",
fontFamily: "inherit",
themeVariables: {
background: "#ffffff",
primaryColor: "#e0f2fe",
primaryTextColor: "#1e293b",
primaryBorderColor: "#0ea5e9",
lineColor: "#64748b",
secondaryColor: "#f1f5f9",
tertiaryColor: "#ffffff",
},
});
// Mermaid diagram component
const MermaidDiagram: FunctionComponent<{ chart: string }> = ({
chart,
}: {
chart: string;
}) => {
const containerRef: React.RefObject<HTMLDivElement | null> =
useRef<HTMLDivElement>(null);
useEffect(() => {
const renderDiagram: () => Promise<void> = async (): Promise<void> => {
if (containerRef.current) {
containerRef.current.innerHTML = "";
try {
const id: string = `mermaid-${Math.random().toString(36).substr(2, 9)}`;
const { svg } = await mermaid.render(id, chart);
if (containerRef.current) {
containerRef.current.innerHTML = svg;
}
} catch (error) {
if (containerRef.current) {
containerRef.current.innerHTML = `<pre class="text-red-500">Error rendering diagram: ${error}</pre>`;
}
}
}
};
renderDiagram();
}, [chart]);
return (
<div
ref={containerRef}
className="my-4 flex justify-center bg-white p-4 rounded-lg"
/>
);
};
export interface ComponentProps {
text: string;
@@ -84,6 +144,16 @@ const MarkdownViewer: FunctionComponent<ComponentProps> = (
},
pre: ({ children, ...rest }: any) => {
// Check if this is a mermaid diagram - don't render pre wrapper for mermaid
const isMermaid: boolean =
React.isValidElement(children) &&
(children as any).props?.className?.includes("language-mermaid");
if (isMermaid) {
// For mermaid, just return the children (MermaidDiagram component)
return <>{children}</>;
}
// Avoid double borders when SyntaxHighlighter is already styling the block.
const isSyntaxHighlighter: boolean =
React.isValidElement(children) &&
@@ -184,6 +254,11 @@ const MarkdownViewer: FunctionComponent<ComponentProps> = (
"",
);
// Handle mermaid diagrams
if (match && match[1] === "mermaid") {
return <MermaidDiagram chart={content} />;
}
const codeClassName: string =
content.includes("\n") ||
(match &&

View File

@@ -6,7 +6,12 @@ import ModalBody from "./ModalBody";
import ModalFooter from "./ModalFooter";
import { VeryLightGray } from "../../../Types/BrandColors";
import IconProp from "../../../Types/Icon/IconProp";
import React, { FunctionComponent, ReactElement } from "react";
import React, {
FunctionComponent,
ReactElement,
useEffect,
useRef,
} from "react";
export enum ModalWidth {
Normal,
@@ -38,6 +43,42 @@ export interface ComponentProps {
const Modal: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const modalRef: React.RefObject<HTMLDivElement> =
useRef<HTMLDivElement>(null);
// Handle Escape key to close modal
useEffect(() => {
const handleEscapeKey: (event: KeyboardEvent) => void = (
event: KeyboardEvent,
): void => {
if (event.key === "Escape" && props.onClose) {
props.onClose();
}
};
document.addEventListener("keydown", handleEscapeKey);
return () => {
document.removeEventListener("keydown", handleEscapeKey);
};
}, [props.onClose]);
// Focus trap and initial focus
useEffect(() => {
const modal: HTMLDivElement | null = modalRef.current;
if (modal) {
// Focus the first focusable element in the modal
const focusableElements: NodeListOf<Element> = modal.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
const firstFocusable: HTMLElement | undefined = focusableElements[0] as
| HTMLElement
| undefined;
if (firstFocusable) {
firstFocusable.focus();
}
}
}, []);
let iconBgColor: string = "bg-indigo-100";
let iconColor: string = "text-indigo-600";
@@ -57,8 +98,10 @@ const Modal: FunctionComponent<ComponentProps> = (
return (
<div
ref={modalRef}
className="relative z-20"
aria-labelledby="modal-title"
aria-describedby={props.description ? "modal-description" : undefined}
role="dialog"
aria-modal="true"
>
@@ -122,12 +165,13 @@ const Modal: FunctionComponent<ComponentProps> = (
{props.title}
</h3>
{props.description && (
<h3
<p
id="modal-description"
data-testid="modal-description"
className="text-sm leading-6 text-gray-500 mt-2"
>
{props.description}
</h3>
</p>
)}
</div>
{props.rightElement && (

View File

@@ -36,7 +36,8 @@ import FormValues from "../Forms/Types/FormValues";
import List from "../List/List";
import { ListDetailProps } from "../List/ListRow";
import ConfirmModal from "../Modal/ConfirmModal";
import { ModalWidth } from "../Modal/Modal";
import Modal, { ModalWidth } from "../Modal/Modal";
import MarkdownViewer from "../Markdown.tsx/MarkdownViewer";
import Filter from "../ModelFilter/Filter";
import { DropdownOption, DropdownOptionLabel } from "../Dropdown/Dropdown";
import OrderedStatesList from "../OrderedStatesList/OrderedStatesList";
@@ -153,6 +154,13 @@ export interface BaseTableProps<
| undefined
| ((data: Array<TBaseModel>, totalCount: number) => void);
cardProps?: CardComponentProps | undefined;
helpContent?:
| {
title: string;
description?: string | undefined;
markdown: string;
}
| undefined;
documentationLink?: Route | URL | undefined;
videoLink?: Route | URL | undefined;
showCreateForm?: undefined | boolean;
@@ -279,6 +287,7 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
);
const [showViewIdModal, setShowViewIdModal] = useState<boolean>(false);
const [showHelpModal, setShowHelpModal] = useState<boolean>(false);
const [viewId, setViewId] = useState<string | null>(null);
const [tableColumns, setColumns] = useState<Array<TableColumn<TBaseModel>>>(
[],
@@ -1046,6 +1055,19 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
});
}
// Add help content button if provided
if (props.helpContent) {
headerbuttons.push({
title: "",
icon: IconProp.Help,
buttonStyle: ButtonStyleType.ICON,
className: "py-0 pr-0 pl-1 mt-1",
onClick: () => {
setShowHelpModal(true);
},
});
}
// Add video link button if provided
if (props.videoLink) {
headerbuttons.push({
@@ -2105,6 +2127,25 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
closeButtonType={ButtonStyleType.OUTLINE}
/>
)}
{showHelpModal && props.helpContent && (
<Modal
title={props.helpContent.title}
description={props.helpContent.description}
onClose={() => {
setShowHelpModal(false);
}}
modalWidth={ModalWidth.Large}
submitButtonText="Close"
onSubmit={() => {
setShowHelpModal(false);
}}
>
<div className="p-2">
<MarkdownViewer text={props.helpContent.markdown} />
</div>
</Modal>
)}
</>
);
};

View File

@@ -1,8 +1,11 @@
import React, {
forwardRef,
ReactElement,
useCallback,
useEffect,
useId,
useImperativeHandle,
useRef,
useState,
} from "react";
import IconProp from "../../../Types/Icon/IconProp";
@@ -20,8 +23,14 @@ const MoreMenu: React.ForwardRefExoticComponent<
ComponentProps & React.RefAttributes<unknown>
> = forwardRef(
(props: ComponentProps, componentRef: React.ForwardedRef<unknown>) => {
const uniqueId: string = useId();
const menuId: string = `menu-${uniqueId}`;
const buttonId: string = `menu-button-${uniqueId}`;
const { ref, isComponentVisible, setIsComponentVisible } =
useComponentOutsideClick(false);
const [focusedIndex, setFocusedIndex] = useState<number>(-1);
const menuItemRefs: React.MutableRefObject<(HTMLDivElement | null)[]> =
useRef<(HTMLDivElement | null)[]>([]);
useImperativeHandle(componentRef, () => {
return {
@@ -41,18 +50,75 @@ const MoreMenu: React.ForwardRefExoticComponent<
useEffect(() => {
setDropdownVisible(isComponentVisible);
if (isComponentVisible) {
setFocusedIndex(0);
} else {
setFocusedIndex(-1);
}
}, [isComponentVisible]);
useEffect(() => {
if (focusedIndex >= 0 && menuItemRefs.current[focusedIndex]) {
menuItemRefs.current[focusedIndex]?.focus();
}
}, [focusedIndex]);
const handleKeyDown: (event: React.KeyboardEvent) => void = useCallback(
(event: React.KeyboardEvent): void => {
if (!isComponentVisible) {
return;
}
const itemCount: number = props.children.length;
switch (event.key) {
case "Escape":
event.preventDefault();
setIsComponentVisible(false);
break;
case "ArrowDown":
event.preventDefault();
setFocusedIndex((prev: number) => {
return (prev + 1) % itemCount;
});
break;
case "ArrowUp":
event.preventDefault();
setFocusedIndex((prev: number) => {
return (prev - 1 + itemCount) % itemCount;
});
break;
case "Home":
event.preventDefault();
setFocusedIndex(0);
break;
case "End":
event.preventDefault();
setFocusedIndex(itemCount - 1);
break;
}
},
[isComponentVisible, props.children.length, setIsComponentVisible],
);
return (
<div className="relative inline-block text-left">
<div
className="relative inline-block text-left"
onKeyDown={handleKeyDown}
>
{!props.elementToBeShownInsteadOfButton && (
<Button
id={buttonId}
icon={props.menuIcon || IconProp.More}
title={props.text || ""}
buttonStyle={ButtonStyleType.OUTLINE}
onClick={() => {
setIsComponentVisible(!isDropdownVisible);
}}
ariaLabel={props.text || "More options"}
ariaExpanded={isComponentVisible}
ariaHaspopup="menu"
ariaControls={isComponentVisible ? menuId : undefined}
/>
)}
@@ -63,21 +129,37 @@ const MoreMenu: React.ForwardRefExoticComponent<
{isComponentVisible && (
<div
ref={ref}
id={menuId}
className="absolute right-0 z-10 mt-2 w-56 origin-top-right divide-y divide-gray-100 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
role="menu"
aria-orientation="vertical"
aria-labelledby="menu-button"
aria-labelledby={buttonId}
>
{props.children.map((child: ReactElement, index: number) => {
return (
<div
key={index}
ref={(el: HTMLDivElement | null) => {
menuItemRefs.current[index] = el;
}}
role="menuitem"
tabIndex={focusedIndex === index ? 0 : -1}
onClick={() => {
if (isComponentVisible) {
setIsComponentVisible(false);
}
}}
onKeyDown={(e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setIsComponentVisible(false);
// Trigger child click
const clickEvent: MouseEvent = new MouseEvent("click", {
bubbles: true,
});
e.currentTarget.dispatchEvent(clickEvent);
}
}}
>
{child}
</div>

View File

@@ -48,14 +48,36 @@ const OrderedStatesList: OrderedStatesListFunction = <T extends GenericObject>(
if (props.data.length === 0) {
return (
<ErrorMessage
message={
props.noItemsMessage
? props.noItemsMessage
: `No ${props.singularLabel.toLocaleLowerCase()}`
}
onRefreshClick={props.onRefreshClick}
/>
<div className="text-center">
{/* Only show the no items message if there's no create button */}
{!props.onCreateNewItem && (
<ErrorMessage
message={
props.noItemsMessage
? props.noItemsMessage
: `No ${props.singularLabel.toLocaleLowerCase()}`
}
onRefreshClick={props.onRefreshClick}
/>
)}
{props.onCreateNewItem && (
<div className="my-10">
<div
className="m-auto inline-flex items-center cursor-pointer text-gray-400 hover:bg-gray-50 border hover:text-gray-600 rounded-full border-gray-300 p-5"
onClick={() => {
if (props.onCreateNewItem) {
props.onCreateNewItem(1);
}
}}
>
<Icon icon={IconProp.Add} className="h-5 w-5" />
<span className="text-sm ml-2">
Add New {props.singularLabel}
</span>
</div>
</div>
)}
</div>
);
}

View File

@@ -67,9 +67,10 @@ const Pagination: FunctionComponent<ComponentProps> = (
useState<boolean>(false);
return (
<div
<nav
className="flex items-center justify-between border-t border-gray-200 bg-white px-4"
data-testid={props.dataTestId}
aria-label={`Pagination for ${props.pluralLabel}`}
>
{/* Desktop layout: Description on left, all controls on right */}
<div className="hidden md:block">
@@ -92,7 +93,7 @@ const Pagination: FunctionComponent<ComponentProps> = (
{/* Desktop layout: All controls together on right */}
<div className="hidden md:flex">
<nav className="inline-flex -space-x-px rounded-md shadow-sm">
<div className="inline-flex -space-x-px rounded-md shadow-sm">
<div className="my-2">
<Button
dataTestId="show-pagination-modal-button"
@@ -100,14 +101,19 @@ const Pagination: FunctionComponent<ComponentProps> = (
buttonSize={ButtonSize.ExtraSmall}
icon={IconProp.AdjustmentHorizontal}
buttonStyle={ButtonStyleType.ICON_LIGHT}
ariaLabel="Open pagination settings"
onClick={() => {
setShowPaginationModel(true);
}}
/>
</div>
<ul className="py-3">
<ul className="py-3" role="list">
<li
role="button"
tabIndex={isPreviousDisabled ? -1 : 0}
aria-disabled={isPreviousDisabled}
aria-label="Go to previous page"
onClick={() => {
let currentPageNumber: number = props.currentPageNumber;
@@ -122,6 +128,24 @@ const Pagination: FunctionComponent<ComponentProps> = (
);
}
}}
onKeyDown={(e: React.KeyboardEvent) => {
if (
(e.key === "Enter" || e.key === " ") &&
!isPreviousDisabled
) {
e.preventDefault();
let currentPageNumber: number = props.currentPageNumber;
if (typeof currentPageNumber === "string") {
currentPageNumber = parseInt(currentPageNumber);
}
if (props.onNavigateToPage) {
props.onNavigateToPage(
currentPageNumber - 1,
props.itemsOnPage,
);
}
}
}}
className={` inline-flex items-center rounded-l-md border border-gray-300 bg-white px-2 py-2 text-sm font-medium text-gray-500 ${
isPreviousDisabled
? "bg-gray-100"
@@ -131,6 +155,10 @@ const Pagination: FunctionComponent<ComponentProps> = (
<span className="page-link">Previous</span>
</li>
<li
role="button"
tabIndex={isCurrentPageButtonDisabled ? -1 : 0}
aria-current="page"
aria-label={`Page ${props.currentPageNumber}, click to change page`}
data-testid="current-page-link"
className={` z-10 inline-flex items-center border border-x-0 border-gray-300 hover:bg-gray-50 px-4 py-2 text-sm font-medium text-text-600 cursor-pointer ${
isCurrentPageButtonDisabled ? "bg-gray-100" : ""
@@ -138,10 +166,20 @@ const Pagination: FunctionComponent<ComponentProps> = (
onClick={() => {
setShowPaginationModel(true);
}}
onKeyDown={(e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setShowPaginationModel(true);
}
}}
>
<span>{props.currentPageNumber}</span>
</li>
<li
role="button"
tabIndex={isNextDisabled ? -1 : 0}
aria-disabled={isNextDisabled}
aria-label="Go to next page"
onClick={() => {
let currentPageNumber: number = props.currentPageNumber;
@@ -156,6 +194,21 @@ const Pagination: FunctionComponent<ComponentProps> = (
);
}
}}
onKeyDown={(e: React.KeyboardEvent) => {
if ((e.key === "Enter" || e.key === " ") && !isNextDisabled) {
e.preventDefault();
let currentPageNumber: number = props.currentPageNumber;
if (typeof currentPageNumber === "string") {
currentPageNumber = parseInt(currentPageNumber);
}
if (props.onNavigateToPage) {
props.onNavigateToPage(
currentPageNumber + 1,
props.itemsOnPage,
);
}
}
}}
className={` inline-flex items-center rounded-r-md border border-gray-300 bg-white px-2 py-2 text-sm font-medium text-gray-500 ${
isNextDisabled
? "bg-gray-100"
@@ -165,7 +218,7 @@ const Pagination: FunctionComponent<ComponentProps> = (
<span>Next</span>
</li>
</ul>
</nav>
</div>
</div>
{/* Mobile layout: Navigate button on left, pagination controls on right */}
@@ -176,6 +229,7 @@ const Pagination: FunctionComponent<ComponentProps> = (
buttonSize={ButtonSize.ExtraSmall}
icon={IconProp.AdjustmentHorizontal}
buttonStyle={ButtonStyleType.ICON_LIGHT}
ariaLabel="Open pagination settings"
onClick={() => {
setShowPaginationModel(true);
}}
@@ -183,9 +237,13 @@ const Pagination: FunctionComponent<ComponentProps> = (
</div>
<div className="md:hidden">
<nav className="inline-flex -space-x-px rounded-md shadow-sm">
<ul>
<div className="inline-flex -space-x-px rounded-md shadow-sm">
<ul role="list">
<li
role="button"
tabIndex={isPreviousDisabled ? -1 : 0}
aria-disabled={isPreviousDisabled}
aria-label="Go to previous page"
onClick={() => {
let currentPageNumber: number = props.currentPageNumber;
@@ -200,6 +258,24 @@ const Pagination: FunctionComponent<ComponentProps> = (
);
}
}}
onKeyDown={(e: React.KeyboardEvent) => {
if (
(e.key === "Enter" || e.key === " ") &&
!isPreviousDisabled
) {
e.preventDefault();
let currentPageNumber: number = props.currentPageNumber;
if (typeof currentPageNumber === "string") {
currentPageNumber = parseInt(currentPageNumber);
}
if (props.onNavigateToPage) {
props.onNavigateToPage(
currentPageNumber - 1,
props.itemsOnPage,
);
}
}
}}
className={` inline-flex items-center rounded-l-md border border-gray-300 bg-white px-2 py-2 text-sm font-medium text-gray-500 ${
isPreviousDisabled
? "bg-gray-100"
@@ -209,6 +285,10 @@ const Pagination: FunctionComponent<ComponentProps> = (
<span className="page-link">Previous</span>
</li>
<li
role="button"
tabIndex={isCurrentPageButtonDisabled ? -1 : 0}
aria-current="page"
aria-label={`Page ${props.currentPageNumber}, click to change page`}
data-testid="current-page-link-mobile"
className={` z-10 inline-flex items-center border border-x-0 border-gray-300 hover:bg-gray-50 px-4 py-2 text-sm font-medium text-text-600 cursor-pointer ${
isCurrentPageButtonDisabled ? "bg-gray-100" : ""
@@ -216,10 +296,20 @@ const Pagination: FunctionComponent<ComponentProps> = (
onClick={() => {
setShowPaginationModel(true);
}}
onKeyDown={(e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setShowPaginationModel(true);
}
}}
>
<span>{props.currentPageNumber}</span>
</li>
<li
role="button"
tabIndex={isNextDisabled ? -1 : 0}
aria-disabled={isNextDisabled}
aria-label="Go to next page"
onClick={() => {
let currentPageNumber: number = props.currentPageNumber;
@@ -234,6 +324,21 @@ const Pagination: FunctionComponent<ComponentProps> = (
);
}
}}
onKeyDown={(e: React.KeyboardEvent) => {
if ((e.key === "Enter" || e.key === " ") && !isNextDisabled) {
e.preventDefault();
let currentPageNumber: number = props.currentPageNumber;
if (typeof currentPageNumber === "string") {
currentPageNumber = parseInt(currentPageNumber);
}
if (props.onNavigateToPage) {
props.onNavigateToPage(
currentPageNumber + 1,
props.itemsOnPage,
);
}
}
}}
className={` inline-flex items-center rounded-r-md border border-gray-300 bg-white px-2 py-2 text-sm font-medium text-gray-500 ${
isNextDisabled
? "bg-gray-100"
@@ -243,7 +348,7 @@ const Pagination: FunctionComponent<ComponentProps> = (
<span>Next</span>
</li>
</ul>
</nav>
</div>
</div>
{showPaginationModel && (
@@ -317,7 +422,7 @@ const Pagination: FunctionComponent<ComponentProps> = (
}}
/>
)}
</div>
</nav>
);
};

View File

@@ -50,13 +50,23 @@ const ProgressBar: FunctionComponent<ComponentProps> = (
}
return (
<div className={`w-full ${progressBarSize} mb-4 bg-gray-200 rounded-full`}>
<div
className={`w-full ${progressBarSize} mb-4 bg-gray-200 rounded-full`}
role="progressbar"
aria-valuenow={percent}
aria-valuemin={0}
aria-valuemax={100}
aria-label={`Progress: ${props.count} of ${props.totalCount} ${props.suffix} (${percent}%)`}
>
<div
data-testid="progress-bar"
className={`${progressBarSize} bg-indigo-600 rounded-full `}
style={{ width: percent + "%" }}
></div>
<div className="text-sm text-gray-400 mt-1 flex justify-between">
<div
className="text-sm text-gray-400 mt-1 flex justify-between"
aria-hidden="true"
>
<div data-testid="progress-bar-count">
{props.count} {props.suffix}
</div>

View File

@@ -4,6 +4,7 @@ import React, {
FunctionComponent,
ReactElement,
useEffect,
useId,
useState,
} from "react";
@@ -22,11 +23,14 @@ export interface ComponentProps {
tabIndex?: number | undefined;
error?: string | undefined;
dataTestId?: string | undefined;
ariaLabel?: string | undefined;
}
const Radio: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const uniqueId: string = useId();
const errorId: string = `radio-error-${uniqueId}`;
const [value, setValue] = useState<RadioValue | undefined>(
props.initialValue || props.value || undefined,
);
@@ -41,14 +45,20 @@ const Radio: FunctionComponent<ComponentProps> = (
<div
className={`mt-2 space-y-2 ${props.className}`}
data-testid={props.dataTestId}
role="radiogroup"
aria-label={props.ariaLabel}
aria-invalid={props.error ? "true" : undefined}
aria-describedby={props.error ? errorId : undefined}
>
{props.options.map((option: RadioOption, index: number) => {
const optionId: string = `${groupName}-option-${index}`;
return (
<div key={index} className="flex items-center gap-x-3">
<input
id={optionId}
tabIndex={props.tabIndex}
checked={value === option.value}
onClick={() => {
onChange={() => {
setValue(option.value);
if (props.onChange) {
props.onChange(option.value);
@@ -64,7 +74,10 @@ const Radio: FunctionComponent<ComponentProps> = (
type="radio"
className="h-4 w-4 border-gray-300 text-indigo-600 focus:ring-indigo-600"
/>
<label className="block text-sm font-medium leading-6 text-gray-900">
<label
htmlFor={optionId}
className="block text-sm font-medium leading-6 text-gray-900"
>
{option.label}
</label>
</div>
@@ -72,7 +85,12 @@ const Radio: FunctionComponent<ComponentProps> = (
})}
{props.error && (
<p data-testid="error-message" className="mt-1 text-sm text-red-400">
<p
id={errorId}
data-testid="error-message"
className="mt-1 text-sm text-red-400"
role="alert"
>
{props.error}
</p>
)}

View File

@@ -17,8 +17,13 @@ const Statusbubble: FunctionComponent<ComponentProps> = (
: Black.toString();
return (
<div className="flex" style={props.style}>
<div className="-mr-2 ml-5">
<div
className="flex"
style={props.style}
role="status"
aria-label={`Status: ${props.text}`}
>
<div className="-mr-2 ml-5" aria-hidden="true">
<span className="relative -left-1 -translate-x-full top-1/2 -translate-y-1/2 flex h-3.5 w-3.5">
<span
className={`${

View File

@@ -3,7 +3,9 @@ import Icon, { ThickProp } from "../Icon/Icon";
import FieldType from "../Types/FieldType";
import Column from "./Types/Column";
import Columns from "./Types/Columns";
import SortOrder from "../../../Types/BaseDatabase/SortOrder";
import SortOrder, {
SortOrderToAriaSortMap,
} from "../../../Types/BaseDatabase/SortOrder";
import GenericObject from "../../../Types/GenericObject";
import IconProp from "../../../Types/Icon/IconProp";
import React, { ReactElement, useEffect, useState } from "react";
@@ -52,9 +54,14 @@ const TableHeader: TableHeaderFunction = <T extends GenericObject>(
return (
<thead className="bg-gray-50" id={props.id}>
<tr>
{props.enableDragAndDrop && <th></th>}
{props.enableDragAndDrop && (
<th scope="col">
<span className="sr-only">Drag to reorder</span>
</th>
)}
{props.isBulkActionsEnabled && (
<th>
<th scope="col">
<span className="sr-only">Select all items</span>
<div className="ml-5">
<CheckboxElement
disabled={!props.hasTableItems}
@@ -79,9 +86,19 @@ const TableHeader: TableHeaderFunction = <T extends GenericObject>(
.map((column: Column<T>, i: number) => {
const canSort: boolean = !column.disableSort && Boolean(column.key);
const isSorted: boolean = canSort && props.sortBy === column.key;
const ariaSort: "ascending" | "descending" | "none" | undefined =
isSorted
? SortOrderToAriaSortMap[props.sortOrder]
: canSort
? "none"
: undefined;
return (
<th
key={i}
scope="col"
aria-sort={ariaSort}
className={`px-6 py-3 text-left text-sm font-semibold text-gray-900 ${
canSort ? "cursor-pointer" : ""
}`}

View File

@@ -18,6 +18,7 @@ export interface ComponentProps {
tab: Tab;
onClick?: () => void;
isSelected?: boolean;
tabPanelId?: string;
}
const TabElement: FunctionComponent<ComponentProps> = (
@@ -31,14 +32,28 @@ const TabElement: FunctionComponent<ComponentProps> = (
? `${backgroundColor} text-gray-700`
: "text-gray-500 hover:text-gray-700";
const handleKeyDown: (event: React.KeyboardEvent) => void = (
event: React.KeyboardEvent,
): void => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
props.onClick?.();
}
};
return (
<div className="mt-3 mb-3">
<div
id={`tab-${props.tab.name}`}
data-testid={`tab-${props.tab.name}`}
key={props.tab.name}
onClick={props.onClick}
onKeyDown={handleKeyDown}
className={`${stateClasses} ${baseClasses}`}
aria-current={props.isSelected ? "page" : undefined}
role="tab"
tabIndex={props.isSelected ? 0 : -1}
aria-selected={props.isSelected}
aria-controls={props.tabPanelId}
>
<div>{props.tab.name}</div>

View File

@@ -26,9 +26,12 @@ const Tabs: FunctionComponent<ComponentProps> = (
}
}, [currentTab]);
const tabPanelId: string = `tabpanel-${currentTab?.name || "default"}`;
return (
<div>
<nav
role="tablist"
className="flex space-x-2 overflow-x-auto md:overflow-visible md:space-x-4"
aria-label="Tabs"
>
@@ -41,11 +44,19 @@ const Tabs: FunctionComponent<ComponentProps> = (
setCurrentTab(tab);
}}
isSelected={tab === currentTab}
tabPanelId={tabPanelId}
/>
);
})}
</nav>
<div className="mt-3 ml-1">{currentTab && currentTab.children}</div>
<div
id={tabPanelId}
role="tabpanel"
aria-labelledby={`tab-${currentTab?.name || "default"}`}
className="mt-3 ml-1"
>
{currentTab && currentTab.children}
</div>
</div>
);
};

View File

@@ -66,6 +66,8 @@ const TextArea: FunctionComponent<ComponentProps> = (
className={`${className || ""}`}
value={text}
spellCheck={!props.disableSpellCheck}
aria-invalid={props.error ? "true" : undefined}
aria-describedby={props.error ? "textarea-error-message" : undefined}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value: string = e.target.value;
@@ -88,13 +90,21 @@ const TextArea: FunctionComponent<ComponentProps> = (
tabIndex={props.tabIndex}
/>
{props.error && (
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
<div
className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3"
aria-hidden="true"
>
<Icon icon={IconProp.ErrorSolid} className="h-5 w-5 text-red-500" />
</div>
)}
</div>
{props.error && (
<p data-testid="error-message" className="mt-1 text-sm text-red-400">
<p
id="textarea-error-message"
data-testid="error-message"
className="mt-1 text-sm text-red-400"
role="alert"
>
{props.error}
</p>
)}

View File

@@ -2,6 +2,7 @@ import React, {
FunctionComponent,
ReactElement,
useEffect,
useId,
useState,
} from "react";
import Tooltip from "../Tooltip/Tooltip";
@@ -25,6 +26,9 @@ export interface ComponentProps {
const Toggle: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const uniqueId: string = useId();
const labelId: string = `toggle-label-${uniqueId}`;
const errorId: string = `toggle-error-${uniqueId}`;
const [isChecked, setIsChecked] = useState<boolean>(
props.initialValue || false,
);
@@ -92,11 +96,13 @@ const Toggle: FunctionComponent<ComponentProps> = (
className={buttonClassName}
role="switch"
aria-checked={isChecked ? "true" : "false"}
aria-labelledby="annual-billing-label"
aria-labelledby={labelId}
aria-describedby={props.error ? errorId : undefined}
aria-invalid={props.error ? "true" : undefined}
>
<span aria-hidden="true" className={toggleClassName}></span>
</button>
<span className="ml-3" id="annual-billing-label">
<span className="ml-3" id={labelId}>
<span className="text-sm font-medium text-gray-900">
{props.title}
</span>
@@ -116,7 +122,12 @@ const Toggle: FunctionComponent<ComponentProps> = (
)}
</div>
{props.error && (
<p data-testid="error-message" className="mt-1 text-sm text-red-400">
<p
id={errorId}
data-testid="error-message"
className="mt-1 text-sm text-red-400"
role="alert"
>
{props.error}
</p>
)}

View File

@@ -15,7 +15,17 @@ const Tooltip: FunctionComponent<ComponentProps> = (
}
return (
<Tippy key={Math.random()} content={<span>{props.text}</span>}>
<Tippy
key={Math.random()}
content={<span>{props.text}</span>}
interactive={true}
trigger="mouseenter focus"
hideOnClick={false}
aria={{
content: "describedby",
expanded: "auto",
}}
>
{props.children}
</Tippy>
);

View File

@@ -21,6 +21,8 @@ const TopAlert: FunctionComponent<ComponentProps> = (
return (
<div
className={`flex items-center text-center gap-x-6 ${alertType.toString()} px-6 py-2.5 sm:px-3.5`}
role="alert"
aria-live="polite"
>
<div className="text-sm leading-6 text-white w-full">
<div className="w-full">

Some files were not shown because too many files have changed in this diff Show More