@oneuptime/common 10.0.35 → 10.0.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/Models/DatabaseModels/Index.ts +2 -0
  2. package/Models/DatabaseModels/WorkspaceNotificationSummary.ts +819 -0
  3. package/Server/API/StatusPageAPI.ts +7 -0
  4. package/Server/API/WorkspaceNotificationSummaryAPI.ts +67 -0
  5. package/Server/Infrastructure/Postgres/SchemaMigrations/{1773761409952-MigrationName.ts → 1774000000001-MigrationName.ts} +2 -2
  6. package/Server/Infrastructure/Postgres/SchemaMigrations/1774355321449-MigrationName.ts +51 -0
  7. package/Server/Infrastructure/Postgres/SchemaMigrations/1774357353502-MigrationName.ts +29 -0
  8. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +6 -2
  9. package/Server/Middleware/MasterAdminAuthorization.ts +55 -0
  10. package/Server/Services/Index.ts +2 -0
  11. package/Server/Services/WorkspaceNotificationSummaryService.ts +1450 -0
  12. package/Server/Types/Markdown.ts +11 -3
  13. package/Server/Utils/Greenlock/Greenlock.ts +1 -0
  14. package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +4 -1
  15. package/Types/Code/CodeType.ts +1 -1
  16. package/Types/Metrics/MetricQueryConfigData.ts +1 -0
  17. package/Types/Monitor/CriteriaFilter.ts +19 -0
  18. package/Types/Monitor/KubernetesAlertTemplates.ts +703 -0
  19. package/Types/Monitor/KubernetesMetricCatalog.ts +347 -0
  20. package/Types/Monitor/MonitorCriteriaInstance.ts +86 -0
  21. package/Types/Monitor/MonitorStep.ts +36 -1
  22. package/Types/Monitor/MonitorStepKubernetesMonitor.ts +50 -0
  23. package/Types/Monitor/MonitorType.ts +14 -10
  24. package/Types/Permission.ts +42 -0
  25. package/Types/Workspace/NotificationSummary/WorkspaceNotificationSummaryItem.ts +13 -0
  26. package/Types/Workspace/NotificationSummary/WorkspaceNotificationSummaryType.ts +8 -0
  27. package/UI/Components/AlertBanner/AlertBanner.tsx +69 -0
  28. package/UI/Components/ConditionsTable/ConditionsTable.tsx +149 -0
  29. package/UI/Components/Dictionary/DictionaryOfStingsViewer.tsx +35 -15
  30. package/UI/Components/ExpandableText/ExpandableText.tsx +42 -0
  31. package/UI/Components/FilterButtons/FilterButtons.tsx +60 -0
  32. package/UI/Components/GanttChart/Bar/Index.tsx +23 -5
  33. package/UI/Components/Markdown.tsx/MarkdownEditor.tsx +4 -1
  34. package/UI/Components/ResourceUsageBar/ResourceUsageBar.tsx +58 -0
  35. package/UI/Components/StackedProgressBar/StackedProgressBar.tsx +81 -0
  36. package/UI/Components/StatusBadge/StatusBadge.tsx +44 -0
  37. package/UI/Components/Tabs/Tabs.tsx +36 -8
  38. package/UI/Utils/Dropdown.ts +2 -1
  39. package/build/dist/Models/DatabaseModels/Index.js +2 -0
  40. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  41. package/build/dist/Models/DatabaseModels/WorkspaceNotificationSummary.js +857 -0
  42. package/build/dist/Models/DatabaseModels/WorkspaceNotificationSummary.js.map +1 -0
  43. package/build/dist/Server/API/StatusPageAPI.js +2 -0
  44. package/build/dist/Server/API/StatusPageAPI.js.map +1 -1
  45. package/build/dist/Server/API/WorkspaceNotificationSummaryAPI.js +40 -0
  46. package/build/dist/Server/API/WorkspaceNotificationSummaryAPI.js.map +1 -0
  47. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/{1773761409952-MigrationName.js → 1774000000001-MigrationName.js} +3 -3
  48. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/{1773761409952-MigrationName.js.map → 1774000000001-MigrationName.js.map} +1 -1
  49. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1774355321449-MigrationName.js +24 -0
  50. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1774355321449-MigrationName.js.map +1 -0
  51. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1774357353502-MigrationName.js +16 -0
  52. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1774357353502-MigrationName.js.map +1 -0
  53. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +6 -2
  54. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  55. package/build/dist/Server/Middleware/MasterAdminAuthorization.js +25 -0
  56. package/build/dist/Server/Middleware/MasterAdminAuthorization.js.map +1 -0
  57. package/build/dist/Server/Services/Index.js +2 -0
  58. package/build/dist/Server/Services/Index.js.map +1 -1
  59. package/build/dist/Server/Services/WorkspaceNotificationSummaryService.js +1122 -0
  60. package/build/dist/Server/Services/WorkspaceNotificationSummaryService.js.map +1 -0
  61. package/build/dist/Server/Types/Markdown.js +10 -2
  62. package/build/dist/Server/Types/Markdown.js.map +1 -1
  63. package/build/dist/Server/Utils/Greenlock/Greenlock.js +1 -0
  64. package/build/dist/Server/Utils/Greenlock/Greenlock.js.map +1 -1
  65. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +2 -1
  66. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
  67. package/build/dist/Types/Code/CodeType.js +1 -1
  68. package/build/dist/Types/Code/CodeType.js.map +1 -1
  69. package/build/dist/Types/Monitor/CriteriaFilter.js +18 -0
  70. package/build/dist/Types/Monitor/CriteriaFilter.js.map +1 -1
  71. package/build/dist/Types/Monitor/KubernetesAlertTemplates.js +594 -0
  72. package/build/dist/Types/Monitor/KubernetesAlertTemplates.js.map +1 -0
  73. package/build/dist/Types/Monitor/KubernetesMetricCatalog.js +311 -0
  74. package/build/dist/Types/Monitor/KubernetesMetricCatalog.js.map +1 -0
  75. package/build/dist/Types/Monitor/MonitorCriteriaInstance.js +78 -0
  76. package/build/dist/Types/Monitor/MonitorCriteriaInstance.js.map +1 -1
  77. package/build/dist/Types/Monitor/MonitorStep.js +24 -1
  78. package/build/dist/Types/Monitor/MonitorStep.js.map +1 -1
  79. package/build/dist/Types/Monitor/MonitorStepKubernetesMonitor.js +30 -0
  80. package/build/dist/Types/Monitor/MonitorStepKubernetesMonitor.js.map +1 -0
  81. package/build/dist/Types/Monitor/MonitorType.js +13 -10
  82. package/build/dist/Types/Monitor/MonitorType.js.map +1 -1
  83. package/build/dist/Types/Permission.js +36 -0
  84. package/build/dist/Types/Permission.js.map +1 -1
  85. package/build/dist/Types/Workspace/NotificationSummary/WorkspaceNotificationSummaryItem.js +14 -0
  86. package/build/dist/Types/Workspace/NotificationSummary/WorkspaceNotificationSummaryItem.js.map +1 -0
  87. package/build/dist/Types/Workspace/NotificationSummary/WorkspaceNotificationSummaryType.js +9 -0
  88. package/build/dist/Types/Workspace/NotificationSummary/WorkspaceNotificationSummaryType.js.map +1 -0
  89. package/build/dist/UI/Components/AlertBanner/AlertBanner.js +42 -0
  90. package/build/dist/UI/Components/AlertBanner/AlertBanner.js.map +1 -0
  91. package/build/dist/UI/Components/ConditionsTable/ConditionsTable.js +83 -0
  92. package/build/dist/UI/Components/ConditionsTable/ConditionsTable.js.map +1 -0
  93. package/build/dist/UI/Components/Dictionary/DictionaryOfStingsViewer.js +14 -8
  94. package/build/dist/UI/Components/Dictionary/DictionaryOfStingsViewer.js.map +1 -1
  95. package/build/dist/UI/Components/ExpandableText/ExpandableText.js +19 -0
  96. package/build/dist/UI/Components/ExpandableText/ExpandableText.js.map +1 -0
  97. package/build/dist/UI/Components/FilterButtons/FilterButtons.js +17 -0
  98. package/build/dist/UI/Components/FilterButtons/FilterButtons.js.map +1 -0
  99. package/build/dist/UI/Components/GanttChart/Bar/Index.js +15 -3
  100. package/build/dist/UI/Components/GanttChart/Bar/Index.js.map +1 -1
  101. package/build/dist/UI/Components/Markdown.tsx/MarkdownEditor.js +3 -1
  102. package/build/dist/UI/Components/Markdown.tsx/MarkdownEditor.js.map +1 -1
  103. package/build/dist/UI/Components/ResourceUsageBar/ResourceUsageBar.js +23 -0
  104. package/build/dist/UI/Components/ResourceUsageBar/ResourceUsageBar.js.map +1 -0
  105. package/build/dist/UI/Components/StackedProgressBar/StackedProgressBar.js +34 -0
  106. package/build/dist/UI/Components/StackedProgressBar/StackedProgressBar.js.map +1 -0
  107. package/build/dist/UI/Components/StatusBadge/StatusBadge.js +22 -0
  108. package/build/dist/UI/Components/StatusBadge/StatusBadge.js.map +1 -0
  109. package/build/dist/UI/Components/Tabs/Tabs.js +32 -9
  110. package/build/dist/UI/Components/Tabs/Tabs.js.map +1 -1
  111. package/build/dist/UI/Utils/Dropdown.js +2 -1
  112. package/build/dist/UI/Utils/Dropdown.js.map +1 -1
  113. package/package.json +1 -1
@@ -0,0 +1,1450 @@
1
+ import ObjectID from "../../Types/ObjectID";
2
+ import DatabaseService from "./DatabaseService";
3
+ import WorkspaceNotificationSummary from "../../Models/DatabaseModels/WorkspaceNotificationSummary";
4
+ import WorkspaceNotificationSummaryType from "../../Types/Workspace/NotificationSummary/WorkspaceNotificationSummaryType";
5
+ import WorkspaceNotificationSummaryItem from "../../Types/Workspace/NotificationSummary/WorkspaceNotificationSummaryItem";
6
+ import BadDataException from "../../Types/Exception/BadDataException";
7
+ import IncidentService from "./IncidentService";
8
+ import AlertService from "./AlertService";
9
+ import IncidentEpisodeService from "./IncidentEpisodeService";
10
+ import AlertEpisodeService from "./AlertEpisodeService";
11
+ import IncidentStateTimelineService from "./IncidentStateTimelineService";
12
+ import AlertStateTimelineService from "./AlertStateTimelineService";
13
+ import Incident from "../../Models/DatabaseModels/Incident";
14
+ import Alert from "../../Models/DatabaseModels/Alert";
15
+ import IncidentEpisode from "../../Models/DatabaseModels/IncidentEpisode";
16
+ import AlertEpisode from "../../Models/DatabaseModels/AlertEpisode";
17
+ import IncidentStateTimeline from "../../Models/DatabaseModels/IncidentStateTimeline";
18
+ import AlertStateTimeline from "../../Models/DatabaseModels/AlertStateTimeline";
19
+ import Label from "../../Models/DatabaseModels/Label";
20
+ import Monitor from "../../Models/DatabaseModels/Monitor";
21
+ import WorkspaceNotificationLogService from "./WorkspaceNotificationLogService";
22
+ import WorkspaceNotificationStatus from "../../Types/Workspace/WorkspaceNotificationStatus";
23
+ import WorkspaceNotificationActionType from "../../Types/Workspace/WorkspaceNotificationActionType";
24
+ import logger from "../Utils/Logger";
25
+ import OneUptimeDate from "../../Types/Date";
26
+ import QueryHelper from "../Types/Database/QueryHelper";
27
+ import WorkspaceMessagePayload, {
28
+ WorkspaceMessageBlock,
29
+ WorkspacePayloadDivider,
30
+ WorkspacePayloadHeader,
31
+ WorkspacePayloadMarkdown,
32
+ } from "../../Types/Workspace/WorkspaceMessagePayload";
33
+ import WorkspaceUtil from "../Utils/Workspace/Workspace";
34
+ import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
35
+ import DatabaseCommonInteractionProps from "../../Types/BaseDatabase/DatabaseCommonInteractionProps";
36
+ import URL from "../../Types/API/URL";
37
+ import DatabaseConfig from "../DatabaseConfig";
38
+ import NotificationRuleCondition, {
39
+ NotificationRuleConditionCheckOn,
40
+ } from "../../Types/Workspace/NotificationRules/NotificationRuleCondition";
41
+ import FilterCondition from "../../Types/Filter/FilterCondition";
42
+ import { WorkspaceNotificationRuleUtil } from "../../Types/Workspace/NotificationRules/NotificationRuleUtil";
43
+ import IncidentNotificationRule from "../../Types/Workspace/NotificationRules/NotificationRuleTypes/IncidentNotificationRule";
44
+
45
+ /*
46
+ * NOTE ON FORMATTING:
47
+ * WorkspacePayloadMarkdown text goes through SlackifyMarkdown which converts
48
+ * standard markdown to Slack mrkdwn. So we must use:
49
+ * **bold** (NOT *bold*)
50
+ * _italic_ (same in both)
51
+ * [text](url) (NOT <url|text>)
52
+ */
53
+
54
+ interface TimelineData {
55
+ ackBy?: string | undefined;
56
+ resolvedBy?: string | undefined;
57
+ ackAt?: Date | undefined;
58
+ resolvedAt?: Date | undefined;
59
+ declaredAt?: Date | undefined;
60
+ }
61
+
62
+ export class Service extends DatabaseService<WorkspaceNotificationSummary> {
63
+ public constructor() {
64
+ super(WorkspaceNotificationSummary);
65
+ }
66
+
67
+ @CaptureSpan()
68
+ public async testSummary(data: {
69
+ summaryId: ObjectID;
70
+ projectId: ObjectID;
71
+ testByUserId: ObjectID;
72
+ props: DatabaseCommonInteractionProps;
73
+ }): Promise<void> {
74
+ await this.sendSummary({ summaryId: data.summaryId, isTest: true });
75
+ }
76
+
77
+ @CaptureSpan()
78
+ public async sendSummary(data: {
79
+ summaryId: ObjectID;
80
+ isTest?: boolean;
81
+ }): Promise<void> {
82
+ const summary: WorkspaceNotificationSummary | null = await this.findOneById(
83
+ {
84
+ id: data.summaryId,
85
+ select: {
86
+ projectId: true,
87
+ name: true,
88
+ workspaceType: true,
89
+ summaryType: true,
90
+ recurringInterval: true,
91
+ numberOfDaysOfData: true,
92
+ channelNames: true,
93
+ teamName: true,
94
+ summaryItems: true,
95
+ filters: true,
96
+ filterCondition: true,
97
+ },
98
+ props: {
99
+ isRoot: true,
100
+ },
101
+ },
102
+ );
103
+
104
+ if (!summary) {
105
+ throw new BadDataException("Summary not found");
106
+ }
107
+
108
+ if (!summary.projectId) {
109
+ throw new BadDataException("Summary project ID not found");
110
+ }
111
+
112
+ if (!summary.channelNames || summary.channelNames.length === 0) {
113
+ throw new BadDataException("No channel names configured for summary");
114
+ }
115
+
116
+ if (!summary.summaryItems || summary.summaryItems.length === 0) {
117
+ throw new BadDataException("No summary items selected");
118
+ }
119
+
120
+ const messageBlocks: Array<WorkspaceMessageBlock> =
121
+ await this.buildSummaryMessageBlocks({ summary });
122
+
123
+ const messagePayload: WorkspaceMessagePayload = {
124
+ _type: "WorkspaceMessagePayload",
125
+ channelNames: summary.channelNames,
126
+ channelIds: [],
127
+ messageBlocks: messageBlocks,
128
+ workspaceType: summary.workspaceType!,
129
+ teamId: summary.teamName || undefined,
130
+ };
131
+
132
+ try {
133
+ await WorkspaceUtil.postMessageToAllWorkspaceChannelsAsBot({
134
+ projectId: summary.projectId,
135
+ messagePayloadsByWorkspace: [messagePayload],
136
+ });
137
+
138
+ // Log successful send
139
+ for (const channelName of summary.channelNames) {
140
+ await WorkspaceNotificationLogService.createWorkspaceLog(
141
+ {
142
+ projectId: summary.projectId!,
143
+ workspaceType: summary.workspaceType!,
144
+ channelName: channelName,
145
+ actionType: WorkspaceNotificationActionType.SendMessage,
146
+ status: WorkspaceNotificationStatus.Success,
147
+ statusMessage: data.isTest
148
+ ? "Test summary sent successfully"
149
+ : "Summary sent successfully",
150
+ message: `${summary.summaryType || ""} summary "${summary.name || "Untitled"}" sent to channel "${channelName}"`,
151
+ },
152
+ { isRoot: true },
153
+ );
154
+ }
155
+ } catch (err) {
156
+ // Log failed send
157
+ for (const channelName of summary.channelNames) {
158
+ await WorkspaceNotificationLogService.createWorkspaceLog(
159
+ {
160
+ projectId: summary.projectId!,
161
+ workspaceType: summary.workspaceType!,
162
+ channelName: channelName,
163
+ actionType: WorkspaceNotificationActionType.SendMessage,
164
+ status: WorkspaceNotificationStatus.Error,
165
+ statusMessage: err instanceof Error ? err.message : "Unknown error",
166
+ message: `Failed to send ${summary.summaryType || ""} summary "${summary.name || "Untitled"}" to channel "${channelName}"`,
167
+ },
168
+ { isRoot: true },
169
+ ).catch((logErr: unknown) => {
170
+ logger.error(
171
+ "Failed to create workspace notification log for summary send failure",
172
+ );
173
+ logger.error(logErr);
174
+ });
175
+ }
176
+
177
+ throw err; // Re-throw so caller knows it failed
178
+ }
179
+
180
+ if (!data.isTest) {
181
+ await this.updateOneById({
182
+ id: data.summaryId,
183
+ data: {
184
+ lastSentAt: OneUptimeDate.getCurrentDate(),
185
+ },
186
+ props: {
187
+ isRoot: true,
188
+ },
189
+ });
190
+ }
191
+ }
192
+
193
+ // ───────────────────────── helpers ─────────────────────────
194
+
195
+ private static divider(): WorkspacePayloadDivider {
196
+ return { _type: "WorkspacePayloadDivider" };
197
+ }
198
+
199
+ private static header(text: string): WorkspacePayloadHeader {
200
+ return { _type: "WorkspacePayloadHeader", text };
201
+ }
202
+
203
+ private static md(text: string): WorkspacePayloadMarkdown {
204
+ return { _type: "WorkspacePayloadMarkdown", text };
205
+ }
206
+
207
+ private static bold(text: string): string {
208
+ return `**${text}**`;
209
+ }
210
+
211
+ private static link(url: string, text: string): string {
212
+ return `[${text}](${url})`;
213
+ }
214
+
215
+ private static formatDuration(totalMinutes: number): string {
216
+ if (totalMinutes < 1) {
217
+ return "< 1m";
218
+ }
219
+ const days: number = Math.floor(totalMinutes / 1440);
220
+ const hours: number = Math.floor((totalMinutes % 1440) / 60);
221
+ const mins: number = Math.round(totalMinutes % 60);
222
+
223
+ const parts: Array<string> = [];
224
+ if (days > 0) {
225
+ parts.push(`${days}d`);
226
+ }
227
+ if (hours > 0) {
228
+ parts.push(`${hours}h`);
229
+ }
230
+ if (mins > 0 || parts.length === 0) {
231
+ parts.push(`${mins}m`);
232
+ }
233
+ return parts.join(" ");
234
+ }
235
+
236
+ private static formatDate(date: Date): string {
237
+ return OneUptimeDate.getDateAsLocalFormattedString(date, true);
238
+ }
239
+
240
+ private static has(
241
+ items: Array<WorkspaceNotificationSummaryItem>,
242
+ item: WorkspaceNotificationSummaryItem,
243
+ ): boolean {
244
+ return items.includes(item);
245
+ }
246
+
247
+ // Check if an item matches the summary's filter conditions
248
+ private static matchesFilters(data: {
249
+ filters: Array<NotificationRuleCondition> | undefined;
250
+ filterCondition: FilterCondition | undefined;
251
+ values: {
252
+ [key in NotificationRuleConditionCheckOn]:
253
+ | string
254
+ | Array<string>
255
+ | undefined;
256
+ };
257
+ }): boolean {
258
+ if (!data.filters || data.filters.length === 0) {
259
+ return true; // no filters = include everything
260
+ }
261
+
262
+ const rule: IncidentNotificationRule = {
263
+ filters: data.filters,
264
+ filterCondition: data.filterCondition || FilterCondition.Any,
265
+ } as IncidentNotificationRule;
266
+
267
+ return WorkspaceNotificationRuleUtil.isRuleMatching({
268
+ notificationRule: rule,
269
+ values: data.values,
270
+ });
271
+ }
272
+
273
+ // Build values map for an incident
274
+ private static buildIncidentValues(incident: Incident): {
275
+ [key in NotificationRuleConditionCheckOn]:
276
+ | string
277
+ | Array<string>
278
+ | undefined;
279
+ } {
280
+ return {
281
+ [NotificationRuleConditionCheckOn.IncidentTitle]: incident.title || "",
282
+ [NotificationRuleConditionCheckOn.IncidentDescription]:
283
+ incident.description || "",
284
+ [NotificationRuleConditionCheckOn.IncidentSeverity]:
285
+ incident.incidentSeverity?._id?.toString() || "",
286
+ [NotificationRuleConditionCheckOn.IncidentState]:
287
+ incident.currentIncidentState?._id?.toString() || "",
288
+ [NotificationRuleConditionCheckOn.IncidentLabels]:
289
+ incident.labels?.map((l: Label) => {
290
+ return l._id?.toString() || "";
291
+ }) || [],
292
+ [NotificationRuleConditionCheckOn.Monitors]:
293
+ incident.monitors?.map((m: Monitor) => {
294
+ return m._id?.toString() || "";
295
+ }) || [],
296
+ // unused for incidents
297
+ [NotificationRuleConditionCheckOn.MonitorName]: undefined,
298
+ [NotificationRuleConditionCheckOn.MonitorType]: undefined,
299
+ [NotificationRuleConditionCheckOn.MonitorStatus]: undefined,
300
+ [NotificationRuleConditionCheckOn.AlertTitle]: undefined,
301
+ [NotificationRuleConditionCheckOn.AlertDescription]: undefined,
302
+ [NotificationRuleConditionCheckOn.AlertSeverity]: undefined,
303
+ [NotificationRuleConditionCheckOn.AlertState]: undefined,
304
+ [NotificationRuleConditionCheckOn.AlertLabels]: undefined,
305
+ [NotificationRuleConditionCheckOn.ScheduledMaintenanceTitle]: undefined,
306
+ [NotificationRuleConditionCheckOn.ScheduledMaintenanceDescription]:
307
+ undefined,
308
+ [NotificationRuleConditionCheckOn.ScheduledMaintenanceState]: undefined,
309
+ [NotificationRuleConditionCheckOn.ScheduledMaintenanceLabels]: undefined,
310
+ [NotificationRuleConditionCheckOn.MonitorLabels]: undefined,
311
+ [NotificationRuleConditionCheckOn.OnCallDutyPolicyName]: undefined,
312
+ [NotificationRuleConditionCheckOn.OnCallDutyPolicyDescription]: undefined,
313
+ [NotificationRuleConditionCheckOn.OnCallDutyPolicyLabels]: undefined,
314
+ [NotificationRuleConditionCheckOn.AlertEpisodeTitle]: undefined,
315
+ [NotificationRuleConditionCheckOn.AlertEpisodeDescription]: undefined,
316
+ [NotificationRuleConditionCheckOn.AlertEpisodeSeverity]: undefined,
317
+ [NotificationRuleConditionCheckOn.AlertEpisodeState]: undefined,
318
+ [NotificationRuleConditionCheckOn.AlertEpisodeLabels]: undefined,
319
+ [NotificationRuleConditionCheckOn.IncidentEpisodeTitle]: undefined,
320
+ [NotificationRuleConditionCheckOn.IncidentEpisodeDescription]: undefined,
321
+ [NotificationRuleConditionCheckOn.IncidentEpisodeSeverity]: undefined,
322
+ [NotificationRuleConditionCheckOn.IncidentEpisodeState]: undefined,
323
+ [NotificationRuleConditionCheckOn.IncidentEpisodeLabels]: undefined,
324
+ };
325
+ }
326
+
327
+ // Build values map for an alert
328
+ private static buildAlertValues(alert: Alert): {
329
+ [key in NotificationRuleConditionCheckOn]:
330
+ | string
331
+ | Array<string>
332
+ | undefined;
333
+ } {
334
+ return {
335
+ [NotificationRuleConditionCheckOn.AlertTitle]: alert.title || "",
336
+ [NotificationRuleConditionCheckOn.AlertDescription]:
337
+ alert.description || "",
338
+ [NotificationRuleConditionCheckOn.AlertSeverity]:
339
+ alert.alertSeverity?._id?.toString() || "",
340
+ [NotificationRuleConditionCheckOn.AlertState]:
341
+ alert.currentAlertState?._id?.toString() || "",
342
+ [NotificationRuleConditionCheckOn.AlertLabels]:
343
+ alert.labels?.map((l: Label) => {
344
+ return l._id?.toString() || "";
345
+ }) || [],
346
+ [NotificationRuleConditionCheckOn.Monitors]: alert.monitor?._id
347
+ ? [alert.monitor._id.toString()]
348
+ : [],
349
+ // unused for alerts
350
+ [NotificationRuleConditionCheckOn.MonitorName]: undefined,
351
+ [NotificationRuleConditionCheckOn.MonitorType]: undefined,
352
+ [NotificationRuleConditionCheckOn.MonitorStatus]: undefined,
353
+ [NotificationRuleConditionCheckOn.IncidentTitle]: undefined,
354
+ [NotificationRuleConditionCheckOn.IncidentDescription]: undefined,
355
+ [NotificationRuleConditionCheckOn.IncidentSeverity]: undefined,
356
+ [NotificationRuleConditionCheckOn.IncidentState]: undefined,
357
+ [NotificationRuleConditionCheckOn.IncidentLabels]: undefined,
358
+ [NotificationRuleConditionCheckOn.ScheduledMaintenanceTitle]: undefined,
359
+ [NotificationRuleConditionCheckOn.ScheduledMaintenanceDescription]:
360
+ undefined,
361
+ [NotificationRuleConditionCheckOn.ScheduledMaintenanceState]: undefined,
362
+ [NotificationRuleConditionCheckOn.ScheduledMaintenanceLabels]: undefined,
363
+ [NotificationRuleConditionCheckOn.MonitorLabels]: undefined,
364
+ [NotificationRuleConditionCheckOn.OnCallDutyPolicyName]: undefined,
365
+ [NotificationRuleConditionCheckOn.OnCallDutyPolicyDescription]: undefined,
366
+ [NotificationRuleConditionCheckOn.OnCallDutyPolicyLabels]: undefined,
367
+ [NotificationRuleConditionCheckOn.AlertEpisodeTitle]: undefined,
368
+ [NotificationRuleConditionCheckOn.AlertEpisodeDescription]: undefined,
369
+ [NotificationRuleConditionCheckOn.AlertEpisodeSeverity]: undefined,
370
+ [NotificationRuleConditionCheckOn.AlertEpisodeState]: undefined,
371
+ [NotificationRuleConditionCheckOn.AlertEpisodeLabels]: undefined,
372
+ [NotificationRuleConditionCheckOn.IncidentEpisodeTitle]: undefined,
373
+ [NotificationRuleConditionCheckOn.IncidentEpisodeDescription]: undefined,
374
+ [NotificationRuleConditionCheckOn.IncidentEpisodeSeverity]: undefined,
375
+ [NotificationRuleConditionCheckOn.IncidentEpisodeState]: undefined,
376
+ [NotificationRuleConditionCheckOn.IncidentEpisodeLabels]: undefined,
377
+ };
378
+ }
379
+
380
+ // Build values map for an incident episode
381
+ private static buildIncidentEpisodeValues(episode: IncidentEpisode): {
382
+ [key in NotificationRuleConditionCheckOn]:
383
+ | string
384
+ | Array<string>
385
+ | undefined;
386
+ } {
387
+ return {
388
+ [NotificationRuleConditionCheckOn.IncidentEpisodeTitle]:
389
+ episode.title || "",
390
+ [NotificationRuleConditionCheckOn.IncidentEpisodeDescription]:
391
+ episode.description || "",
392
+ [NotificationRuleConditionCheckOn.IncidentEpisodeSeverity]:
393
+ episode.incidentSeverity?._id?.toString() || "",
394
+ [NotificationRuleConditionCheckOn.IncidentEpisodeState]:
395
+ episode.currentIncidentState?._id?.toString() || "",
396
+ [NotificationRuleConditionCheckOn.IncidentEpisodeLabels]:
397
+ episode.labels?.map((l: Label) => {
398
+ return l._id?.toString() || "";
399
+ }) || [],
400
+ // unused for incident episodes
401
+ [NotificationRuleConditionCheckOn.IncidentTitle]: undefined,
402
+ [NotificationRuleConditionCheckOn.IncidentDescription]: undefined,
403
+ [NotificationRuleConditionCheckOn.IncidentSeverity]: undefined,
404
+ [NotificationRuleConditionCheckOn.IncidentState]: undefined,
405
+ [NotificationRuleConditionCheckOn.IncidentLabels]: undefined,
406
+ [NotificationRuleConditionCheckOn.AlertTitle]: undefined,
407
+ [NotificationRuleConditionCheckOn.AlertDescription]: undefined,
408
+ [NotificationRuleConditionCheckOn.AlertSeverity]: undefined,
409
+ [NotificationRuleConditionCheckOn.AlertState]: undefined,
410
+ [NotificationRuleConditionCheckOn.AlertLabels]: undefined,
411
+ [NotificationRuleConditionCheckOn.AlertEpisodeTitle]: undefined,
412
+ [NotificationRuleConditionCheckOn.AlertEpisodeDescription]: undefined,
413
+ [NotificationRuleConditionCheckOn.AlertEpisodeSeverity]: undefined,
414
+ [NotificationRuleConditionCheckOn.AlertEpisodeState]: undefined,
415
+ [NotificationRuleConditionCheckOn.AlertEpisodeLabels]: undefined,
416
+ [NotificationRuleConditionCheckOn.Monitors]: undefined,
417
+ [NotificationRuleConditionCheckOn.MonitorName]: undefined,
418
+ [NotificationRuleConditionCheckOn.MonitorType]: undefined,
419
+ [NotificationRuleConditionCheckOn.MonitorStatus]: undefined,
420
+ [NotificationRuleConditionCheckOn.MonitorLabels]: undefined,
421
+ [NotificationRuleConditionCheckOn.ScheduledMaintenanceTitle]: undefined,
422
+ [NotificationRuleConditionCheckOn.ScheduledMaintenanceDescription]:
423
+ undefined,
424
+ [NotificationRuleConditionCheckOn.ScheduledMaintenanceState]: undefined,
425
+ [NotificationRuleConditionCheckOn.ScheduledMaintenanceLabels]: undefined,
426
+ [NotificationRuleConditionCheckOn.OnCallDutyPolicyName]: undefined,
427
+ [NotificationRuleConditionCheckOn.OnCallDutyPolicyDescription]: undefined,
428
+ [NotificationRuleConditionCheckOn.OnCallDutyPolicyLabels]: undefined,
429
+ };
430
+ }
431
+
432
+ // Build values map for an alert episode
433
+ private static buildAlertEpisodeValues(episode: AlertEpisode): {
434
+ [key in NotificationRuleConditionCheckOn]:
435
+ | string
436
+ | Array<string>
437
+ | undefined;
438
+ } {
439
+ return {
440
+ [NotificationRuleConditionCheckOn.AlertEpisodeTitle]: episode.title || "",
441
+ [NotificationRuleConditionCheckOn.AlertEpisodeDescription]:
442
+ episode.description || "",
443
+ [NotificationRuleConditionCheckOn.AlertEpisodeSeverity]:
444
+ episode.alertSeverity?._id?.toString() || "",
445
+ [NotificationRuleConditionCheckOn.AlertEpisodeState]:
446
+ episode.currentAlertState?._id?.toString() || "",
447
+ [NotificationRuleConditionCheckOn.AlertEpisodeLabels]:
448
+ episode.labels?.map((l: Label) => {
449
+ return l._id?.toString() || "";
450
+ }) || [],
451
+ // unused for alert episodes
452
+ [NotificationRuleConditionCheckOn.IncidentTitle]: undefined,
453
+ [NotificationRuleConditionCheckOn.IncidentDescription]: undefined,
454
+ [NotificationRuleConditionCheckOn.IncidentSeverity]: undefined,
455
+ [NotificationRuleConditionCheckOn.IncidentState]: undefined,
456
+ [NotificationRuleConditionCheckOn.IncidentLabels]: undefined,
457
+ [NotificationRuleConditionCheckOn.IncidentEpisodeTitle]: undefined,
458
+ [NotificationRuleConditionCheckOn.IncidentEpisodeDescription]: undefined,
459
+ [NotificationRuleConditionCheckOn.IncidentEpisodeSeverity]: undefined,
460
+ [NotificationRuleConditionCheckOn.IncidentEpisodeState]: undefined,
461
+ [NotificationRuleConditionCheckOn.IncidentEpisodeLabels]: undefined,
462
+ [NotificationRuleConditionCheckOn.AlertTitle]: undefined,
463
+ [NotificationRuleConditionCheckOn.AlertDescription]: undefined,
464
+ [NotificationRuleConditionCheckOn.AlertSeverity]: undefined,
465
+ [NotificationRuleConditionCheckOn.AlertState]: undefined,
466
+ [NotificationRuleConditionCheckOn.AlertLabels]: undefined,
467
+ [NotificationRuleConditionCheckOn.Monitors]: undefined,
468
+ [NotificationRuleConditionCheckOn.MonitorName]: undefined,
469
+ [NotificationRuleConditionCheckOn.MonitorType]: undefined,
470
+ [NotificationRuleConditionCheckOn.MonitorStatus]: undefined,
471
+ [NotificationRuleConditionCheckOn.MonitorLabels]: undefined,
472
+ [NotificationRuleConditionCheckOn.ScheduledMaintenanceTitle]: undefined,
473
+ [NotificationRuleConditionCheckOn.ScheduledMaintenanceDescription]:
474
+ undefined,
475
+ [NotificationRuleConditionCheckOn.ScheduledMaintenanceState]: undefined,
476
+ [NotificationRuleConditionCheckOn.ScheduledMaintenanceLabels]: undefined,
477
+ [NotificationRuleConditionCheckOn.OnCallDutyPolicyName]: undefined,
478
+ [NotificationRuleConditionCheckOn.OnCallDutyPolicyDescription]: undefined,
479
+ [NotificationRuleConditionCheckOn.OnCallDutyPolicyLabels]: undefined,
480
+ };
481
+ }
482
+
483
+ // ───────────────────────── main builder ─────────────────────────
484
+
485
+ @CaptureSpan()
486
+ private async buildSummaryMessageBlocks(data: {
487
+ summary: WorkspaceNotificationSummary;
488
+ }): Promise<Array<WorkspaceMessageBlock>> {
489
+ const { summary } = data;
490
+ const blocks: Array<WorkspaceMessageBlock> = [];
491
+ const items: Array<WorkspaceNotificationSummaryItem> =
492
+ summary.summaryItems!;
493
+ const days: number = summary.numberOfDaysOfData || 7;
494
+ const type: WorkspaceNotificationSummaryType = summary.summaryType!;
495
+
496
+ const fromDate: Date = OneUptimeDate.addRemoveDays(
497
+ OneUptimeDate.getCurrentDate(),
498
+ -days,
499
+ );
500
+
501
+ const fromDateStr: string = Service.formatDate(fromDate);
502
+ const toDateStr: string = Service.formatDate(
503
+ OneUptimeDate.getCurrentDate(),
504
+ );
505
+
506
+ // Title
507
+ blocks.push(
508
+ Service.header(`${type} Summary — ${fromDateStr} to ${toDateStr}`),
509
+ );
510
+
511
+ blocks.push(
512
+ Service.md(
513
+ `_Reporting period: ${Service.bold(String(days))} day${days !== 1 ? "s" : ""}_`,
514
+ ),
515
+ );
516
+
517
+ blocks.push(Service.divider());
518
+
519
+ // Build type-specific content
520
+ if (
521
+ type === WorkspaceNotificationSummaryType.Incident ||
522
+ type === WorkspaceNotificationSummaryType.IncidentEpisode
523
+ ) {
524
+ await this.buildIncidentBlocks({
525
+ blocks,
526
+ items,
527
+ type,
528
+ fromDate,
529
+ projectId: summary.projectId!,
530
+ filters: summary.filters || undefined,
531
+ filterCondition: summary.filterCondition || undefined,
532
+ });
533
+ } else {
534
+ await this.buildAlertBlocks({
535
+ blocks,
536
+ items,
537
+ type,
538
+ fromDate,
539
+ projectId: summary.projectId!,
540
+ filters: summary.filters || undefined,
541
+ filterCondition: summary.filterCondition || undefined,
542
+ });
543
+ }
544
+
545
+ // Footer
546
+ blocks.push(Service.divider());
547
+ blocks.push(
548
+ Service.md(`_Sent by OneUptime • ${summary.name || "Untitled"}_`),
549
+ );
550
+
551
+ return blocks;
552
+ }
553
+
554
+ // ───────────────────────── Incidents ─────────────────────────
555
+
556
+ @CaptureSpan()
557
+ private async buildIncidentBlocks(data: {
558
+ blocks: Array<WorkspaceMessageBlock>;
559
+ items: Array<WorkspaceNotificationSummaryItem>;
560
+ type: WorkspaceNotificationSummaryType;
561
+ fromDate: Date;
562
+ projectId: ObjectID;
563
+ filters?: Array<NotificationRuleCondition> | undefined;
564
+ filterCondition?: FilterCondition | undefined;
565
+ }): Promise<void> {
566
+ if (data.type === WorkspaceNotificationSummaryType.IncidentEpisode) {
567
+ await this.buildIncidentEpisodeBlocks(data);
568
+ return;
569
+ }
570
+
571
+ const { blocks, items, fromDate, projectId } = data;
572
+
573
+ let incidents: Array<Incident> = await IncidentService.findAllBy({
574
+ query: {
575
+ projectId,
576
+ createdAt: QueryHelper.greaterThanEqualTo(fromDate),
577
+ },
578
+ select: {
579
+ _id: true,
580
+ title: true,
581
+ description: true,
582
+ incidentNumber: true,
583
+ incidentNumberWithPrefix: true,
584
+ incidentSeverity: { name: true, _id: true },
585
+ currentIncidentState: {
586
+ name: true,
587
+ _id: true,
588
+ isResolvedState: true,
589
+ isAcknowledgedState: true,
590
+ },
591
+ labels: { _id: true, name: true },
592
+ monitors: { name: true, _id: true },
593
+ createdAt: true,
594
+ declaredAt: true,
595
+ },
596
+ props: { isRoot: true },
597
+ });
598
+
599
+ // Apply filters
600
+ if (data.filters && data.filters.length > 0) {
601
+ incidents = incidents.filter((inc: Incident) => {
602
+ return Service.matchesFilters({
603
+ filters: data.filters,
604
+ filterCondition: data.filterCondition,
605
+ values: Service.buildIncidentValues(inc),
606
+ });
607
+ });
608
+ }
609
+
610
+ const dashboardUrl: URL = await DatabaseConfig.getDashboardUrl();
611
+
612
+ // Overview stats
613
+ if (Service.has(items, WorkspaceNotificationSummaryItem.TotalCount)) {
614
+ const resolved: number = incidents.filter((i: Incident) => {
615
+ return i.currentIncidentState?.isResolvedState;
616
+ }).length;
617
+ const open: number = incidents.length - resolved;
618
+
619
+ blocks.push(
620
+ Service.md(
621
+ `${Service.bold("Total:")} ${incidents.length} incident${incidents.length !== 1 ? "s" : ""} · ` +
622
+ `${Service.bold("Open:")} ${open} · ${Service.bold("Resolved:")} ${resolved}`,
623
+ ),
624
+ );
625
+ }
626
+
627
+ // Severity breakdown
628
+ if (
629
+ Service.has(items, WorkspaceNotificationSummaryItem.SeverityBreakdown)
630
+ ) {
631
+ const map: Map<string, number> = new Map();
632
+ for (const i of incidents) {
633
+ const s: string = i.incidentSeverity?.name || "Unknown";
634
+ map.set(s, (map.get(s) || 0) + 1);
635
+ }
636
+ if (map.size > 0) {
637
+ const parts: Array<string> = [];
638
+ for (const [sev, count] of map) {
639
+ parts.push(`${sev}: ${Service.bold(String(count))}`);
640
+ }
641
+ blocks.push(
642
+ Service.md(`${Service.bold("By Severity:")} ${parts.join(" · ")}`),
643
+ );
644
+ }
645
+ }
646
+
647
+ // State breakdown
648
+ if (Service.has(items, WorkspaceNotificationSummaryItem.StateBreakdown)) {
649
+ const map: Map<string, number> = new Map();
650
+ for (const i of incidents) {
651
+ const s: string = i.currentIncidentState?.name || "Unknown";
652
+ map.set(s, (map.get(s) || 0) + 1);
653
+ }
654
+ if (map.size > 0) {
655
+ const parts: Array<string> = [];
656
+ for (const [state, count] of map) {
657
+ parts.push(`${state}: ${Service.bold(String(count))}`);
658
+ }
659
+ blocks.push(
660
+ Service.md(`${Service.bold("By State:")} ${parts.join(" · ")}`),
661
+ );
662
+ }
663
+ }
664
+
665
+ // Timeline data for MTTA/MTTR/who
666
+ const needTimeline: boolean =
667
+ Service.has(items, WorkspaceNotificationSummaryItem.WhoAcknowledged) ||
668
+ Service.has(items, WorkspaceNotificationSummaryItem.WhoResolved) ||
669
+ Service.has(items, WorkspaceNotificationSummaryItem.TimeToAcknowledge) ||
670
+ Service.has(items, WorkspaceNotificationSummaryItem.TimeToResolve);
671
+
672
+ const tlMap: Map<string, TimelineData> = new Map();
673
+
674
+ if (needTimeline && incidents.length > 0) {
675
+ const ids: Array<ObjectID> = incidents
676
+ .filter((i: Incident) => {
677
+ return i._id;
678
+ })
679
+ .map((i: Incident) => {
680
+ return new ObjectID(i._id!.toString());
681
+ });
682
+
683
+ const timelines: Array<IncidentStateTimeline> =
684
+ await IncidentStateTimelineService.findAllBy({
685
+ query: {
686
+ projectId,
687
+ incidentId: QueryHelper.any(ids),
688
+ },
689
+ select: {
690
+ incidentId: true,
691
+ incidentState: {
692
+ isAcknowledgedState: true,
693
+ isResolvedState: true,
694
+ },
695
+ createdByUser: { name: true, email: true },
696
+ createdAt: true,
697
+ },
698
+ props: { isRoot: true },
699
+ });
700
+
701
+ for (const tl of timelines) {
702
+ const id: string = tl.incidentId?.toString() || "";
703
+ if (!tlMap.has(id)) {
704
+ tlMap.set(id, {});
705
+ }
706
+ const td: TimelineData = tlMap.get(id)!;
707
+ const userName: string =
708
+ tl.createdByUser?.name?.toString() ||
709
+ tl.createdByUser?.email?.toString() ||
710
+ "System";
711
+
712
+ if (tl.incidentState?.isAcknowledgedState && !td.ackAt) {
713
+ td.ackBy = userName;
714
+ td.ackAt = tl.createdAt;
715
+ }
716
+ if (tl.incidentState?.isResolvedState && !td.resolvedAt) {
717
+ td.resolvedBy = userName;
718
+ td.resolvedAt = tl.createdAt;
719
+ }
720
+ }
721
+
722
+ for (const inc of incidents) {
723
+ const id: string = inc._id?.toString() || "";
724
+ if (!tlMap.has(id)) {
725
+ tlMap.set(id, {});
726
+ }
727
+ tlMap.get(id)!.declaredAt = inc.declaredAt || inc.createdAt;
728
+ }
729
+ }
730
+
731
+ // MTTA
732
+ if (
733
+ Service.has(items, WorkspaceNotificationSummaryItem.TimeToAcknowledge)
734
+ ) {
735
+ const { avg, count } = this.computeAvg(tlMap, "ack");
736
+ blocks.push(
737
+ Service.md(
738
+ count > 0
739
+ ? `${Service.bold("MTTA (Mean Time to Acknowledge):")} ${Service.bold(Service.formatDuration(avg))} _(${count} acknowledged)_`
740
+ : `${Service.bold("MTTA (Mean Time to Acknowledge):")} _No incidents acknowledged_`,
741
+ ),
742
+ );
743
+ }
744
+
745
+ // MTTR
746
+ if (Service.has(items, WorkspaceNotificationSummaryItem.TimeToResolve)) {
747
+ const { avg, count } = this.computeAvg(tlMap, "resolve");
748
+ blocks.push(
749
+ Service.md(
750
+ count > 0
751
+ ? `${Service.bold("MTTR (Mean Time to Resolve):")} ${Service.bold(Service.formatDuration(avg))} _(${count} resolved)_`
752
+ : `${Service.bold("MTTR (Mean Time to Resolve):")} _No incidents resolved_`,
753
+ ),
754
+ );
755
+ }
756
+
757
+ // Resources affected
758
+ if (
759
+ Service.has(items, WorkspaceNotificationSummaryItem.ResourcesAffected)
760
+ ) {
761
+ const names: Set<string> = new Set();
762
+ for (const inc of incidents) {
763
+ if (inc.monitors) {
764
+ for (const m of inc.monitors) {
765
+ if (m.name) {
766
+ names.add(m.name);
767
+ }
768
+ }
769
+ }
770
+ }
771
+ if (names.size > 0) {
772
+ blocks.push(
773
+ Service.md(
774
+ `${Service.bold(`Resources Affected (${names.size}):`)} ${Array.from(names).join(", ")}`,
775
+ ),
776
+ );
777
+ }
778
+ }
779
+
780
+ // Detailed list
781
+ if (Service.has(items, WorkspaceNotificationSummaryItem.ListWithLinks)) {
782
+ blocks.push(Service.divider());
783
+
784
+ if (incidents.length === 0) {
785
+ blocks.push(Service.md(`_No incidents reported in this period._`));
786
+ return;
787
+ }
788
+
789
+ for (const inc of incidents) {
790
+ const id: string = inc._id?.toString() || "";
791
+ const display: string =
792
+ inc.incidentNumberWithPrefix || `#${inc.incidentNumber || ""}`;
793
+ const linkUrl: string = URL.fromString(dashboardUrl.toString())
794
+ .addRoute(`/${projectId.toString()}/incidents/${id}`)
795
+ .toString();
796
+ const td: TimelineData | undefined = tlMap.get(id);
797
+
798
+ // Title line with link
799
+ let text: string = `${Service.bold(Service.link(linkUrl, `${display} — ${inc.title || "Untitled"}`))}`;
800
+
801
+ // Meta line
802
+ const meta: Array<string> = [];
803
+ if (inc.incidentSeverity?.name) {
804
+ meta.push(`Severity: ${Service.bold(inc.incidentSeverity.name)}`);
805
+ }
806
+ if (inc.currentIncidentState?.name) {
807
+ meta.push(`State: ${Service.bold(inc.currentIncidentState.name)}`);
808
+ }
809
+ if (inc.declaredAt) {
810
+ meta.push(`Declared: ${Service.formatDate(inc.declaredAt)}`);
811
+ }
812
+ if (meta.length > 0) {
813
+ text += `\n${meta.join(" · ")}`;
814
+ }
815
+
816
+ // Ack & resolve line
817
+ const ackResolve: Array<string> = [];
818
+ if (
819
+ Service.has(items, WorkspaceNotificationSummaryItem.WhoAcknowledged)
820
+ ) {
821
+ if (td?.ackBy && td?.ackAt) {
822
+ ackResolve.push(
823
+ `Ack: ${Service.bold(td.ackBy)} in ${Service.formatDuration(OneUptimeDate.getMinutesBetweenTwoDates(td.declaredAt || inc.createdAt!, td.ackAt))}`,
824
+ );
825
+ } else {
826
+ ackResolve.push(`_Not yet acknowledged_`);
827
+ }
828
+ }
829
+ if (Service.has(items, WorkspaceNotificationSummaryItem.WhoResolved)) {
830
+ if (td?.resolvedBy && td?.resolvedAt) {
831
+ ackResolve.push(
832
+ `Resolved: ${Service.bold(td.resolvedBy)} in ${Service.formatDuration(OneUptimeDate.getMinutesBetweenTwoDates(td.declaredAt || inc.createdAt!, td.resolvedAt))}`,
833
+ );
834
+ } else if (!inc.currentIncidentState?.isResolvedState) {
835
+ ackResolve.push(`_Not yet resolved_`);
836
+ }
837
+ }
838
+ if (ackResolve.length > 0) {
839
+ text += `\n${ackResolve.join(" · ")}`;
840
+ }
841
+
842
+ blocks.push(Service.md(text));
843
+ }
844
+ }
845
+ }
846
+
847
+ // ───────────────────────── Incident Episodes ─────────────────────────
848
+
849
+ @CaptureSpan()
850
+ private async buildIncidentEpisodeBlocks(data: {
851
+ blocks: Array<WorkspaceMessageBlock>;
852
+ items: Array<WorkspaceNotificationSummaryItem>;
853
+ fromDate: Date;
854
+ projectId: ObjectID;
855
+ filters?: Array<NotificationRuleCondition> | undefined;
856
+ filterCondition?: FilterCondition | undefined;
857
+ }): Promise<void> {
858
+ const { blocks, items, fromDate, projectId } = data;
859
+
860
+ let episodes: Array<IncidentEpisode> =
861
+ await IncidentEpisodeService.findAllBy({
862
+ query: {
863
+ projectId,
864
+ createdAt: QueryHelper.greaterThanEqualTo(fromDate),
865
+ },
866
+ select: {
867
+ _id: true,
868
+ title: true,
869
+ description: true,
870
+ incidentSeverity: { name: true, _id: true },
871
+ currentIncidentState: {
872
+ name: true,
873
+ _id: true,
874
+ isResolvedState: true,
875
+ },
876
+ labels: { _id: true, name: true },
877
+ createdAt: true,
878
+ resolvedAt: true,
879
+ },
880
+ props: { isRoot: true },
881
+ });
882
+
883
+ // Apply filters
884
+ if (data.filters && data.filters.length > 0) {
885
+ episodes = episodes.filter((ep: IncidentEpisode) => {
886
+ return Service.matchesFilters({
887
+ filters: data.filters,
888
+ filterCondition: data.filterCondition,
889
+ values: Service.buildIncidentEpisodeValues(ep),
890
+ });
891
+ });
892
+ }
893
+
894
+ const dashboardUrl: URL = await DatabaseConfig.getDashboardUrl();
895
+
896
+ if (Service.has(items, WorkspaceNotificationSummaryItem.TotalCount)) {
897
+ const resolved: number = episodes.filter((e: IncidentEpisode) => {
898
+ return e.currentIncidentState?.isResolvedState;
899
+ }).length;
900
+ blocks.push(
901
+ Service.md(
902
+ `${Service.bold("Total:")} ${episodes.length} episode${episodes.length !== 1 ? "s" : ""} · ` +
903
+ `${Service.bold("Open:")} ${episodes.length - resolved} · ${Service.bold("Resolved:")} ${resolved}`,
904
+ ),
905
+ );
906
+ }
907
+
908
+ if (
909
+ Service.has(items, WorkspaceNotificationSummaryItem.SeverityBreakdown)
910
+ ) {
911
+ const map: Map<string, number> = new Map();
912
+ for (const e of episodes) {
913
+ const s: string = e.incidentSeverity?.name || "Unknown";
914
+ map.set(s, (map.get(s) || 0) + 1);
915
+ }
916
+ if (map.size > 0) {
917
+ const parts: Array<string> = [];
918
+ for (const [sev, c] of map) {
919
+ parts.push(`${sev}: ${Service.bold(String(c))}`);
920
+ }
921
+ blocks.push(
922
+ Service.md(`${Service.bold("By Severity:")} ${parts.join(" · ")}`),
923
+ );
924
+ }
925
+ }
926
+
927
+ if (Service.has(items, WorkspaceNotificationSummaryItem.StateBreakdown)) {
928
+ const map: Map<string, number> = new Map();
929
+ for (const e of episodes) {
930
+ const s: string = e.currentIncidentState?.name || "Unknown";
931
+ map.set(s, (map.get(s) || 0) + 1);
932
+ }
933
+ if (map.size > 0) {
934
+ const parts: Array<string> = [];
935
+ for (const [state, c] of map) {
936
+ parts.push(`${state}: ${Service.bold(String(c))}`);
937
+ }
938
+ blocks.push(
939
+ Service.md(`${Service.bold("By State:")} ${parts.join(" · ")}`),
940
+ );
941
+ }
942
+ }
943
+
944
+ if (Service.has(items, WorkspaceNotificationSummaryItem.TimeToResolve)) {
945
+ let total: number = 0;
946
+ let count: number = 0;
947
+ for (const e of episodes) {
948
+ if (e.resolvedAt && e.createdAt) {
949
+ total += OneUptimeDate.getMinutesBetweenTwoDates(
950
+ e.createdAt,
951
+ e.resolvedAt,
952
+ );
953
+ count++;
954
+ }
955
+ }
956
+ blocks.push(
957
+ Service.md(
958
+ count > 0
959
+ ? `${Service.bold("MTTR (Mean Time to Resolve):")} ${Service.bold(Service.formatDuration(Math.round(total / count)))} _(${count} resolved)_`
960
+ : `${Service.bold("MTTR (Mean Time to Resolve):")} _No episodes resolved_`,
961
+ ),
962
+ );
963
+ }
964
+
965
+ if (Service.has(items, WorkspaceNotificationSummaryItem.ListWithLinks)) {
966
+ blocks.push(Service.divider());
967
+
968
+ if (episodes.length === 0) {
969
+ blocks.push(Service.md(`_No incident episodes in this period._`));
970
+ return;
971
+ }
972
+
973
+ for (const ep of episodes) {
974
+ const id: string = ep._id?.toString() || "";
975
+ const linkUrl: string = URL.fromString(dashboardUrl.toString())
976
+ .addRoute(`/${projectId.toString()}/incidents/episodes/${id}`)
977
+ .toString();
978
+
979
+ let text: string = `${Service.bold(Service.link(linkUrl, ep.title || "Untitled Episode"))}`;
980
+ const meta: Array<string> = [];
981
+ if (ep.incidentSeverity?.name) {
982
+ meta.push(`Severity: ${Service.bold(ep.incidentSeverity.name)}`);
983
+ }
984
+ if (ep.currentIncidentState?.name) {
985
+ meta.push(`State: ${Service.bold(ep.currentIncidentState.name)}`);
986
+ }
987
+ if (ep.createdAt) {
988
+ meta.push(`Created: ${Service.formatDate(ep.createdAt)}`);
989
+ }
990
+ if (ep.resolvedAt && ep.createdAt) {
991
+ meta.push(
992
+ `Resolved in ${Service.bold(Service.formatDuration(OneUptimeDate.getMinutesBetweenTwoDates(ep.createdAt, ep.resolvedAt)))}`,
993
+ );
994
+ }
995
+ if (meta.length > 0) {
996
+ text += `\n${meta.join(" · ")}`;
997
+ }
998
+ blocks.push(Service.md(text));
999
+ }
1000
+ }
1001
+ }
1002
+
1003
+ // ───────────────────────── Alerts ─────────────────────────
1004
+
1005
+ @CaptureSpan()
1006
+ private async buildAlertBlocks(data: {
1007
+ blocks: Array<WorkspaceMessageBlock>;
1008
+ items: Array<WorkspaceNotificationSummaryItem>;
1009
+ type: WorkspaceNotificationSummaryType;
1010
+ fromDate: Date;
1011
+ projectId: ObjectID;
1012
+ filters?: Array<NotificationRuleCondition> | undefined;
1013
+ filterCondition?: FilterCondition | undefined;
1014
+ }): Promise<void> {
1015
+ if (data.type === WorkspaceNotificationSummaryType.AlertEpisode) {
1016
+ await this.buildAlertEpisodeBlocks(data);
1017
+ return;
1018
+ }
1019
+
1020
+ const { blocks, items, fromDate, projectId } = data;
1021
+
1022
+ let alerts: Array<Alert> = await AlertService.findAllBy({
1023
+ query: {
1024
+ projectId,
1025
+ createdAt: QueryHelper.greaterThanEqualTo(fromDate),
1026
+ },
1027
+ select: {
1028
+ _id: true,
1029
+ title: true,
1030
+ description: true,
1031
+ alertNumber: true,
1032
+ alertNumberWithPrefix: true,
1033
+ alertSeverity: { name: true, _id: true },
1034
+ currentAlertState: {
1035
+ name: true,
1036
+ _id: true,
1037
+ isResolvedState: true,
1038
+ isAcknowledgedState: true,
1039
+ },
1040
+ labels: { _id: true, name: true },
1041
+ monitor: { name: true, _id: true },
1042
+ createdAt: true,
1043
+ },
1044
+ props: { isRoot: true },
1045
+ });
1046
+
1047
+ // Apply filters
1048
+ if (data.filters && data.filters.length > 0) {
1049
+ alerts = alerts.filter((alert: Alert) => {
1050
+ return Service.matchesFilters({
1051
+ filters: data.filters,
1052
+ filterCondition: data.filterCondition,
1053
+ values: Service.buildAlertValues(alert),
1054
+ });
1055
+ });
1056
+ }
1057
+
1058
+ const dashboardUrl: URL = await DatabaseConfig.getDashboardUrl();
1059
+
1060
+ if (Service.has(items, WorkspaceNotificationSummaryItem.TotalCount)) {
1061
+ const resolved: number = alerts.filter((a: Alert) => {
1062
+ return a.currentAlertState?.isResolvedState;
1063
+ }).length;
1064
+ blocks.push(
1065
+ Service.md(
1066
+ `${Service.bold("Total:")} ${alerts.length} alert${alerts.length !== 1 ? "s" : ""} · ` +
1067
+ `${Service.bold("Open:")} ${alerts.length - resolved} · ${Service.bold("Resolved:")} ${resolved}`,
1068
+ ),
1069
+ );
1070
+ }
1071
+
1072
+ if (
1073
+ Service.has(items, WorkspaceNotificationSummaryItem.SeverityBreakdown)
1074
+ ) {
1075
+ const map: Map<string, number> = new Map();
1076
+ for (const a of alerts) {
1077
+ const s: string = a.alertSeverity?.name || "Unknown";
1078
+ map.set(s, (map.get(s) || 0) + 1);
1079
+ }
1080
+ if (map.size > 0) {
1081
+ const parts: Array<string> = [];
1082
+ for (const [sev, c] of map) {
1083
+ parts.push(`${sev}: ${Service.bold(String(c))}`);
1084
+ }
1085
+ blocks.push(
1086
+ Service.md(`${Service.bold("By Severity:")} ${parts.join(" · ")}`),
1087
+ );
1088
+ }
1089
+ }
1090
+
1091
+ if (Service.has(items, WorkspaceNotificationSummaryItem.StateBreakdown)) {
1092
+ const map: Map<string, number> = new Map();
1093
+ for (const a of alerts) {
1094
+ const s: string = a.currentAlertState?.name || "Unknown";
1095
+ map.set(s, (map.get(s) || 0) + 1);
1096
+ }
1097
+ if (map.size > 0) {
1098
+ const parts: Array<string> = [];
1099
+ for (const [state, c] of map) {
1100
+ parts.push(`${state}: ${Service.bold(String(c))}`);
1101
+ }
1102
+ blocks.push(
1103
+ Service.md(`${Service.bold("By State:")} ${parts.join(" · ")}`),
1104
+ );
1105
+ }
1106
+ }
1107
+
1108
+ // Timeline data
1109
+ const needTimeline: boolean =
1110
+ Service.has(items, WorkspaceNotificationSummaryItem.WhoAcknowledged) ||
1111
+ Service.has(items, WorkspaceNotificationSummaryItem.WhoResolved) ||
1112
+ Service.has(items, WorkspaceNotificationSummaryItem.TimeToAcknowledge) ||
1113
+ Service.has(items, WorkspaceNotificationSummaryItem.TimeToResolve);
1114
+
1115
+ const tlMap: Map<string, TimelineData> = new Map();
1116
+
1117
+ if (needTimeline && alerts.length > 0) {
1118
+ const ids: Array<ObjectID> = alerts
1119
+ .filter((a: Alert) => {
1120
+ return a._id;
1121
+ })
1122
+ .map((a: Alert) => {
1123
+ return new ObjectID(a._id!.toString());
1124
+ });
1125
+
1126
+ const timelines: Array<AlertStateTimeline> =
1127
+ await AlertStateTimelineService.findAllBy({
1128
+ query: { projectId, alertId: QueryHelper.any(ids) },
1129
+ select: {
1130
+ alertId: true,
1131
+ alertState: {
1132
+ isAcknowledgedState: true,
1133
+ isResolvedState: true,
1134
+ },
1135
+ createdByUser: { name: true, email: true },
1136
+ createdAt: true,
1137
+ },
1138
+ props: { isRoot: true },
1139
+ });
1140
+
1141
+ for (const tl of timelines) {
1142
+ const id: string = tl.alertId?.toString() || "";
1143
+ if (!tlMap.has(id)) {
1144
+ tlMap.set(id, {});
1145
+ }
1146
+ const td: TimelineData = tlMap.get(id)!;
1147
+ const userName: string =
1148
+ tl.createdByUser?.name?.toString() ||
1149
+ tl.createdByUser?.email?.toString() ||
1150
+ "System";
1151
+
1152
+ if (tl.alertState?.isAcknowledgedState && !td.ackAt) {
1153
+ td.ackBy = userName;
1154
+ td.ackAt = tl.createdAt;
1155
+ }
1156
+ if (tl.alertState?.isResolvedState && !td.resolvedAt) {
1157
+ td.resolvedBy = userName;
1158
+ td.resolvedAt = tl.createdAt;
1159
+ }
1160
+ }
1161
+
1162
+ for (const a of alerts) {
1163
+ const id: string = a._id?.toString() || "";
1164
+ if (!tlMap.has(id)) {
1165
+ tlMap.set(id, {});
1166
+ }
1167
+ tlMap.get(id)!.declaredAt = a.createdAt;
1168
+ }
1169
+ }
1170
+
1171
+ if (
1172
+ Service.has(items, WorkspaceNotificationSummaryItem.TimeToAcknowledge)
1173
+ ) {
1174
+ const { avg, count } = this.computeAvg(tlMap, "ack");
1175
+ blocks.push(
1176
+ Service.md(
1177
+ count > 0
1178
+ ? `${Service.bold("MTTA (Mean Time to Acknowledge):")} ${Service.bold(Service.formatDuration(avg))} _(${count} acknowledged)_`
1179
+ : `${Service.bold("MTTA (Mean Time to Acknowledge):")} _No alerts acknowledged_`,
1180
+ ),
1181
+ );
1182
+ }
1183
+
1184
+ if (Service.has(items, WorkspaceNotificationSummaryItem.TimeToResolve)) {
1185
+ const { avg, count } = this.computeAvg(tlMap, "resolve");
1186
+ blocks.push(
1187
+ Service.md(
1188
+ count > 0
1189
+ ? `${Service.bold("MTTR (Mean Time to Resolve):")} ${Service.bold(Service.formatDuration(avg))} _(${count} resolved)_`
1190
+ : `${Service.bold("MTTR (Mean Time to Resolve):")} _No alerts resolved_`,
1191
+ ),
1192
+ );
1193
+ }
1194
+
1195
+ if (
1196
+ Service.has(items, WorkspaceNotificationSummaryItem.ResourcesAffected)
1197
+ ) {
1198
+ const names: Set<string> = new Set();
1199
+ for (const a of alerts) {
1200
+ if (a.monitor?.name) {
1201
+ names.add(a.monitor.name);
1202
+ }
1203
+ }
1204
+ if (names.size > 0) {
1205
+ blocks.push(
1206
+ Service.md(
1207
+ `${Service.bold(`Resources Affected (${names.size}):`)} ${Array.from(names).join(", ")}`,
1208
+ ),
1209
+ );
1210
+ }
1211
+ }
1212
+
1213
+ if (Service.has(items, WorkspaceNotificationSummaryItem.ListWithLinks)) {
1214
+ blocks.push(Service.divider());
1215
+
1216
+ if (alerts.length === 0) {
1217
+ blocks.push(Service.md(`_No alerts reported in this period._`));
1218
+ return;
1219
+ }
1220
+
1221
+ for (const a of alerts) {
1222
+ const id: string = a._id?.toString() || "";
1223
+ const display: string =
1224
+ a.alertNumberWithPrefix || `#${a.alertNumber || ""}`;
1225
+ const linkUrl: string = URL.fromString(dashboardUrl.toString())
1226
+ .addRoute(`/${projectId.toString()}/alerts/${id}`)
1227
+ .toString();
1228
+ const td: TimelineData | undefined = tlMap.get(id);
1229
+
1230
+ let text: string = `${Service.bold(Service.link(linkUrl, `${display} — ${a.title || "Untitled"}`))}`;
1231
+
1232
+ const meta: Array<string> = [];
1233
+ if (a.alertSeverity?.name) {
1234
+ meta.push(`Severity: ${Service.bold(a.alertSeverity.name)}`);
1235
+ }
1236
+ if (a.currentAlertState?.name) {
1237
+ meta.push(`State: ${Service.bold(a.currentAlertState.name)}`);
1238
+ }
1239
+ if (a.createdAt) {
1240
+ meta.push(`Created: ${Service.formatDate(a.createdAt)}`);
1241
+ }
1242
+ if (meta.length > 0) {
1243
+ text += `\n${meta.join(" · ")}`;
1244
+ }
1245
+
1246
+ const ackResolve: Array<string> = [];
1247
+ if (
1248
+ Service.has(items, WorkspaceNotificationSummaryItem.WhoAcknowledged)
1249
+ ) {
1250
+ if (td?.ackBy && td?.ackAt) {
1251
+ ackResolve.push(
1252
+ `Ack: ${Service.bold(td.ackBy)} in ${Service.formatDuration(OneUptimeDate.getMinutesBetweenTwoDates(td.declaredAt || a.createdAt!, td.ackAt))}`,
1253
+ );
1254
+ } else {
1255
+ ackResolve.push(`_Not yet acknowledged_`);
1256
+ }
1257
+ }
1258
+ if (Service.has(items, WorkspaceNotificationSummaryItem.WhoResolved)) {
1259
+ if (td?.resolvedBy && td?.resolvedAt) {
1260
+ ackResolve.push(
1261
+ `Resolved: ${Service.bold(td.resolvedBy)} in ${Service.formatDuration(OneUptimeDate.getMinutesBetweenTwoDates(td.declaredAt || a.createdAt!, td.resolvedAt))}`,
1262
+ );
1263
+ } else if (!a.currentAlertState?.isResolvedState) {
1264
+ ackResolve.push(`_Not yet resolved_`);
1265
+ }
1266
+ }
1267
+ if (ackResolve.length > 0) {
1268
+ text += `\n${ackResolve.join(" · ")}`;
1269
+ }
1270
+
1271
+ blocks.push(Service.md(text));
1272
+ }
1273
+ }
1274
+ }
1275
+
1276
+ // ───────────────────────── Alert Episodes ─────────────────────────
1277
+
1278
+ @CaptureSpan()
1279
+ private async buildAlertEpisodeBlocks(data: {
1280
+ blocks: Array<WorkspaceMessageBlock>;
1281
+ items: Array<WorkspaceNotificationSummaryItem>;
1282
+ fromDate: Date;
1283
+ projectId: ObjectID;
1284
+ filters?: Array<NotificationRuleCondition> | undefined;
1285
+ filterCondition?: FilterCondition | undefined;
1286
+ }): Promise<void> {
1287
+ const { blocks, items, fromDate, projectId } = data;
1288
+
1289
+ let episodes: Array<AlertEpisode> = await AlertEpisodeService.findAllBy({
1290
+ query: {
1291
+ projectId,
1292
+ createdAt: QueryHelper.greaterThanEqualTo(fromDate),
1293
+ },
1294
+ select: {
1295
+ _id: true,
1296
+ title: true,
1297
+ description: true,
1298
+ alertSeverity: { name: true, _id: true },
1299
+ currentAlertState: { name: true, _id: true, isResolvedState: true },
1300
+ labels: { _id: true, name: true },
1301
+ createdAt: true,
1302
+ resolvedAt: true,
1303
+ },
1304
+ props: { isRoot: true },
1305
+ });
1306
+
1307
+ // Apply filters
1308
+ if (data.filters && data.filters.length > 0) {
1309
+ episodes = episodes.filter((ep: AlertEpisode) => {
1310
+ return Service.matchesFilters({
1311
+ filters: data.filters,
1312
+ filterCondition: data.filterCondition,
1313
+ values: Service.buildAlertEpisodeValues(ep),
1314
+ });
1315
+ });
1316
+ }
1317
+
1318
+ const dashboardUrl: URL = await DatabaseConfig.getDashboardUrl();
1319
+
1320
+ if (Service.has(items, WorkspaceNotificationSummaryItem.TotalCount)) {
1321
+ const resolved: number = episodes.filter((e: AlertEpisode) => {
1322
+ return e.currentAlertState?.isResolvedState;
1323
+ }).length;
1324
+ blocks.push(
1325
+ Service.md(
1326
+ `${Service.bold("Total:")} ${episodes.length} episode${episodes.length !== 1 ? "s" : ""} · ` +
1327
+ `${Service.bold("Open:")} ${episodes.length - resolved} · ${Service.bold("Resolved:")} ${resolved}`,
1328
+ ),
1329
+ );
1330
+ }
1331
+
1332
+ if (
1333
+ Service.has(items, WorkspaceNotificationSummaryItem.SeverityBreakdown)
1334
+ ) {
1335
+ const map: Map<string, number> = new Map();
1336
+ for (const e of episodes) {
1337
+ const s: string = e.alertSeverity?.name || "Unknown";
1338
+ map.set(s, (map.get(s) || 0) + 1);
1339
+ }
1340
+ if (map.size > 0) {
1341
+ const parts: Array<string> = [];
1342
+ for (const [sev, c] of map) {
1343
+ parts.push(`${sev}: ${Service.bold(String(c))}`);
1344
+ }
1345
+ blocks.push(
1346
+ Service.md(`${Service.bold("By Severity:")} ${parts.join(" · ")}`),
1347
+ );
1348
+ }
1349
+ }
1350
+
1351
+ if (Service.has(items, WorkspaceNotificationSummaryItem.StateBreakdown)) {
1352
+ const map: Map<string, number> = new Map();
1353
+ for (const e of episodes) {
1354
+ const s: string = e.currentAlertState?.name || "Unknown";
1355
+ map.set(s, (map.get(s) || 0) + 1);
1356
+ }
1357
+ if (map.size > 0) {
1358
+ const parts: Array<string> = [];
1359
+ for (const [state, c] of map) {
1360
+ parts.push(`${state}: ${Service.bold(String(c))}`);
1361
+ }
1362
+ blocks.push(
1363
+ Service.md(`${Service.bold("By State:")} ${parts.join(" · ")}`),
1364
+ );
1365
+ }
1366
+ }
1367
+
1368
+ if (Service.has(items, WorkspaceNotificationSummaryItem.TimeToResolve)) {
1369
+ let total: number = 0;
1370
+ let count: number = 0;
1371
+ for (const e of episodes) {
1372
+ if (e.resolvedAt && e.createdAt) {
1373
+ total += OneUptimeDate.getMinutesBetweenTwoDates(
1374
+ e.createdAt,
1375
+ e.resolvedAt,
1376
+ );
1377
+ count++;
1378
+ }
1379
+ }
1380
+ blocks.push(
1381
+ Service.md(
1382
+ count > 0
1383
+ ? `${Service.bold("MTTR (Mean Time to Resolve):")} ${Service.bold(Service.formatDuration(Math.round(total / count)))} _(${count} resolved)_`
1384
+ : `${Service.bold("MTTR (Mean Time to Resolve):")} _No episodes resolved_`,
1385
+ ),
1386
+ );
1387
+ }
1388
+
1389
+ if (Service.has(items, WorkspaceNotificationSummaryItem.ListWithLinks)) {
1390
+ blocks.push(Service.divider());
1391
+
1392
+ if (episodes.length === 0) {
1393
+ blocks.push(Service.md(`_No alert episodes in this period._`));
1394
+ return;
1395
+ }
1396
+
1397
+ for (const ep of episodes) {
1398
+ const id: string = ep._id?.toString() || "";
1399
+ const linkUrl: string = URL.fromString(dashboardUrl.toString())
1400
+ .addRoute(`/${projectId.toString()}/alerts/episodes/${id}`)
1401
+ .toString();
1402
+
1403
+ let text: string = `${Service.bold(Service.link(linkUrl, ep.title || "Untitled Episode"))}`;
1404
+ const meta: Array<string> = [];
1405
+ if (ep.alertSeverity?.name) {
1406
+ meta.push(`Severity: ${Service.bold(ep.alertSeverity.name)}`);
1407
+ }
1408
+ if (ep.currentAlertState?.name) {
1409
+ meta.push(`State: ${Service.bold(ep.currentAlertState.name)}`);
1410
+ }
1411
+ if (ep.createdAt) {
1412
+ meta.push(`Created: ${Service.formatDate(ep.createdAt)}`);
1413
+ }
1414
+ if (ep.resolvedAt && ep.createdAt) {
1415
+ meta.push(
1416
+ `Resolved in ${Service.bold(Service.formatDuration(OneUptimeDate.getMinutesBetweenTwoDates(ep.createdAt, ep.resolvedAt)))}`,
1417
+ );
1418
+ }
1419
+ if (meta.length > 0) {
1420
+ text += `\n${meta.join(" · ")}`;
1421
+ }
1422
+ blocks.push(Service.md(text));
1423
+ }
1424
+ }
1425
+ }
1426
+
1427
+ // ───────────────────────── Utilities ─────────────────────────
1428
+
1429
+ private computeAvg(
1430
+ tlMap: Map<string, TimelineData>,
1431
+ kind: "ack" | "resolve",
1432
+ ): { avg: number; count: number } {
1433
+ let total: number = 0;
1434
+ let count: number = 0;
1435
+ for (const [, td] of tlMap) {
1436
+ const eventTime: Date | undefined =
1437
+ kind === "ack" ? td.ackAt : td.resolvedAt;
1438
+ if (eventTime && td.declaredAt) {
1439
+ total += OneUptimeDate.getMinutesBetweenTwoDates(
1440
+ td.declaredAt,
1441
+ eventTime,
1442
+ );
1443
+ count++;
1444
+ }
1445
+ }
1446
+ return { avg: count > 0 ? Math.round(total / count) : 0, count };
1447
+ }
1448
+ }
1449
+
1450
+ export default new Service();