@oneuptime/common 10.5.3 → 10.5.6

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 (86) hide show
  1. package/Models/DatabaseModels/AlertEpisodeMember.ts +29 -0
  2. package/Models/DatabaseModels/AlertGroupingRule.ts +76 -0
  3. package/Models/DatabaseModels/GlobalConfig.ts +28 -0
  4. package/Models/DatabaseModels/IncidentEpisodeMember.ts +29 -0
  5. package/Models/DatabaseModels/IncidentGroupingRule.ts +76 -0
  6. package/Models/DatabaseModels/ProjectSmtpConfig.ts +62 -6
  7. package/Server/Infrastructure/Postgres/SchemaMigrations/1779971548393-AddLabelGroupByToGroupingRules.ts +37 -0
  8. package/Server/Infrastructure/Postgres/SchemaMigrations/1779975064262-AddTransportTypeToProjectSmtpConfig.ts +37 -0
  9. package/Server/Infrastructure/Postgres/SchemaMigrations/1779976190561-AddSmtpTransportTypeToGlobalConfig.ts +19 -0
  10. package/Server/Infrastructure/Postgres/SchemaMigrations/1779980428744-MigrationName.ts +23 -0
  11. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +8 -0
  12. package/Server/Services/AlertEpisodeMemberService.ts +23 -0
  13. package/Server/Services/AlertGroupingEngineService.ts +83 -0
  14. package/Server/Services/IncidentEpisodeMemberService.ts +23 -0
  15. package/Server/Services/IncidentGroupingEngineService.ts +99 -0
  16. package/Server/Services/MailService.ts +15 -3
  17. package/Server/Services/ProjectSmtpConfigService.ts +30 -10
  18. package/Server/Services/StatusPagePrivateUserService.ts +1 -0
  19. package/Server/Services/StatusPageSubscriberService.ts +3 -0
  20. package/Server/Services/UserNotificationSettingService.ts +12 -0
  21. package/Server/Utils/WhatsAppTemplateUtil.ts +4 -0
  22. package/Tests/Server/Services/AlertGroupingEngineService.test.ts +28 -0
  23. package/Tests/Server/Services/AlertGroupingRuleService.test.ts +14 -0
  24. package/Types/Email/EmailServer.ts +22 -4
  25. package/Types/Email/EmailTemplateType.ts +2 -0
  26. package/Types/Email/MailTransportType.ts +18 -0
  27. package/Types/NotificationSetting/NotificationSettingEventType.ts +2 -0
  28. package/Types/WhatsApp/WhatsAppTemplates.ts +10 -0
  29. package/UI/Components/Accordion/Accordion.tsx +12 -4
  30. package/build/dist/Models/DatabaseModels/AlertEpisodeMember.js +30 -0
  31. package/build/dist/Models/DatabaseModels/AlertEpisodeMember.js.map +1 -1
  32. package/build/dist/Models/DatabaseModels/AlertGroupingRule.js +78 -0
  33. package/build/dist/Models/DatabaseModels/AlertGroupingRule.js.map +1 -1
  34. package/build/dist/Models/DatabaseModels/GlobalConfig.js +28 -0
  35. package/build/dist/Models/DatabaseModels/GlobalConfig.js.map +1 -1
  36. package/build/dist/Models/DatabaseModels/IncidentEpisodeMember.js +30 -0
  37. package/build/dist/Models/DatabaseModels/IncidentEpisodeMember.js.map +1 -1
  38. package/build/dist/Models/DatabaseModels/IncidentGroupingRule.js +78 -0
  39. package/build/dist/Models/DatabaseModels/IncidentGroupingRule.js.map +1 -1
  40. package/build/dist/Models/DatabaseModels/ProjectSmtpConfig.js +59 -6
  41. package/build/dist/Models/DatabaseModels/ProjectSmtpConfig.js.map +1 -1
  42. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1779971548393-AddLabelGroupByToGroupingRules.js +18 -0
  43. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1779971548393-AddLabelGroupByToGroupingRules.js.map +1 -0
  44. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1779975064262-AddTransportTypeToProjectSmtpConfig.js +18 -0
  45. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1779975064262-AddTransportTypeToProjectSmtpConfig.js.map +1 -0
  46. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1779976190561-AddSmtpTransportTypeToGlobalConfig.js +12 -0
  47. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1779976190561-AddSmtpTransportTypeToGlobalConfig.js.map +1 -0
  48. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1779980428744-MigrationName.js +14 -0
  49. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1779980428744-MigrationName.js.map +1 -0
  50. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +8 -0
  51. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  52. package/build/dist/Server/Services/AlertEpisodeMemberService.js +20 -0
  53. package/build/dist/Server/Services/AlertEpisodeMemberService.js.map +1 -1
  54. package/build/dist/Server/Services/AlertGroupingEngineService.js +85 -0
  55. package/build/dist/Server/Services/AlertGroupingEngineService.js.map +1 -1
  56. package/build/dist/Server/Services/IncidentEpisodeMemberService.js +20 -0
  57. package/build/dist/Server/Services/IncidentEpisodeMemberService.js.map +1 -1
  58. package/build/dist/Server/Services/IncidentGroupingEngineService.js +95 -0
  59. package/build/dist/Server/Services/IncidentGroupingEngineService.js.map +1 -1
  60. package/build/dist/Server/Services/MailService.js +15 -3
  61. package/build/dist/Server/Services/MailService.js.map +1 -1
  62. package/build/dist/Server/Services/ProjectSmtpConfigService.js +24 -8
  63. package/build/dist/Server/Services/ProjectSmtpConfigService.js.map +1 -1
  64. package/build/dist/Server/Services/StatusPagePrivateUserService.js +1 -0
  65. package/build/dist/Server/Services/StatusPagePrivateUserService.js.map +1 -1
  66. package/build/dist/Server/Services/StatusPageSubscriberService.js +3 -0
  67. package/build/dist/Server/Services/StatusPageSubscriberService.js.map +1 -1
  68. package/build/dist/Server/Services/UserNotificationSettingService.js +2 -0
  69. package/build/dist/Server/Services/UserNotificationSettingService.js.map +1 -1
  70. package/build/dist/Server/Utils/WhatsAppTemplateUtil.js +2 -0
  71. package/build/dist/Server/Utils/WhatsAppTemplateUtil.js.map +1 -1
  72. package/build/dist/Tests/Server/Services/AlertGroupingEngineService.test.js +21 -0
  73. package/build/dist/Tests/Server/Services/AlertGroupingEngineService.test.js.map +1 -1
  74. package/build/dist/Tests/Server/Services/AlertGroupingRuleService.test.js +12 -0
  75. package/build/dist/Tests/Server/Services/AlertGroupingRuleService.test.js.map +1 -1
  76. package/build/dist/Types/Email/EmailTemplateType.js +2 -0
  77. package/build/dist/Types/Email/EmailTemplateType.js.map +1 -1
  78. package/build/dist/Types/Email/MailTransportType.js +19 -0
  79. package/build/dist/Types/Email/MailTransportType.js.map +1 -0
  80. package/build/dist/Types/NotificationSetting/NotificationSettingEventType.js +2 -0
  81. package/build/dist/Types/NotificationSetting/NotificationSettingEventType.js.map +1 -1
  82. package/build/dist/Types/WhatsApp/WhatsAppTemplates.js +6 -0
  83. package/build/dist/Types/WhatsApp/WhatsAppTemplates.js.map +1 -1
  84. package/build/dist/UI/Components/Accordion/Accordion.js +4 -4
  85. package/build/dist/UI/Components/Accordion/Accordion.js.map +1 -1
  86. package/package.json +1 -1
@@ -4,6 +4,7 @@ import { OnCreate, OnDelete } from "../Types/Database/Hooks";
4
4
  import DatabaseService from "./DatabaseService";
5
5
  import BadDataException from "../../Types/Exception/BadDataException";
6
6
  import ObjectID from "../../Types/ObjectID";
7
+ import PositiveNumber from "../../Types/PositiveNumber";
7
8
  import Model from "../../Models/DatabaseModels/IncidentEpisodeMember";
8
9
  import Incident from "../../Models/DatabaseModels/Incident";
9
10
  import IncidentEpisode from "../../Models/DatabaseModels/IncidentEpisode";
@@ -64,6 +65,28 @@ export class Service extends DatabaseService<Model> {
64
65
  createBy.data.addedAt = OneUptimeDate.getCurrentDate();
65
66
  }
66
67
 
68
+ /*
69
+ * If this is the very first incident in the episode (the founder), the
70
+ * "episode created" notification already covers it. Mark the member as
71
+ * "already notified" so the IncidentAdded-to-episode cron skips it. This
72
+ * avoids double-notifying owners when an episode is born with its first
73
+ * incident.
74
+ */
75
+ if (createBy.data.isOwnerNotifiedOfIncidentAdded === undefined) {
76
+ const existingMemberCount: PositiveNumber = await this.countBy({
77
+ query: {
78
+ incidentEpisodeId: createBy.data.incidentEpisodeId,
79
+ },
80
+ props: {
81
+ isRoot: true,
82
+ },
83
+ });
84
+
85
+ if (existingMemberCount.toNumber() === 0) {
86
+ createBy.data.isOwnerNotifiedOfIncidentAdded = true;
87
+ }
88
+ }
89
+
67
90
  return { createBy, carryForward: null };
68
91
  }
69
92
 
@@ -17,6 +17,7 @@ import SortOrder from "../../Types/BaseDatabase/SortOrder";
17
17
  import OneUptimeDate from "../../Types/Date";
18
18
  import QueryHelper from "../Types/Database/QueryHelper";
19
19
  import IncidentGroupingRuleService from "./IncidentGroupingRuleService";
20
+ import IncidentService from "./IncidentService";
20
21
  import IncidentEpisodeService from "./IncidentEpisodeService";
21
22
  import IncidentEpisodeMemberService from "./IncidentEpisodeMemberService";
22
23
  import IncidentEpisodeOwnerUserService from "./IncidentEpisodeOwnerUserService";
@@ -93,6 +94,8 @@ class IncidentGroupingEngineServiceClass {
93
94
  groupByMonitor: true,
94
95
  groupBySeverity: true,
95
96
  groupByIncidentTitle: true,
97
+ groupByIncidentLabels: true,
98
+ groupByMonitorLabels: true,
96
99
  // Time settings
97
100
  enableTimeWindow: true,
98
101
  timeWindowMinutes: true,
@@ -613,10 +616,100 @@ class IncidentGroupingEngineServiceClass {
613
616
  parts.push(`title:${normalizedTitle}`);
614
617
  }
615
618
 
619
+ // Group by incident labels (exact set match) - only if explicitly enabled
620
+ if (rule.groupByIncidentLabels && incident.id) {
621
+ const incidentLabels: Array<Label> =
622
+ await this.getIncidentLabels(incident);
623
+ const sortedLabelIds: Array<string> = incidentLabels
624
+ .map((l: Label) => {
625
+ return l.id?.toString() || "";
626
+ })
627
+ .filter((id: string) => {
628
+ return id.length > 0;
629
+ })
630
+ .sort();
631
+ parts.push(`incidentLabels:${sortedLabelIds.join(",")}`);
632
+ }
633
+
634
+ /*
635
+ * Group by monitor labels (exact set match) - only if explicitly enabled
636
+ * Incidents can be associated with multiple monitors; we union and dedupe labels across all of them.
637
+ */
638
+ if (
639
+ rule.groupByMonitorLabels &&
640
+ incident.monitors &&
641
+ incident.monitors.length > 0
642
+ ) {
643
+ const monitorLabelIdSet: Set<string> = new Set<string>();
644
+ for (const incidentMonitor of incident.monitors) {
645
+ if (!incidentMonitor || !incidentMonitor.id) {
646
+ continue;
647
+ }
648
+ const monitorLabels: Array<Label> = await this.getMonitorLabels(
649
+ incidentMonitor.id,
650
+ );
651
+ for (const label of monitorLabels) {
652
+ const labelIdStr: string = label.id?.toString() || "";
653
+ if (labelIdStr.length > 0) {
654
+ monitorLabelIdSet.add(labelIdStr);
655
+ }
656
+ }
657
+ }
658
+ const sortedLabelIds: Array<string> =
659
+ Array.from(monitorLabelIdSet).sort();
660
+ parts.push(`monitorLabels:${sortedLabelIds.join(",")}`);
661
+ }
662
+
616
663
  // If no group by options are enabled, all matching incidents go into a single episode
617
664
  return parts.join("|") || "default";
618
665
  }
619
666
 
667
+ @CaptureSpan()
668
+ private async getIncidentLabels(incident: Incident): Promise<Array<Label>> {
669
+ // If labels are already loaded on the incident, use them
670
+ if (incident.labels && Array.isArray(incident.labels)) {
671
+ return incident.labels;
672
+ }
673
+
674
+ if (!incident.id) {
675
+ return [];
676
+ }
677
+
678
+ // Re-load incident with labels
679
+ const reloadedIncident: Incident | null = await IncidentService.findOneById(
680
+ {
681
+ id: incident.id,
682
+ select: {
683
+ labels: {
684
+ _id: true,
685
+ },
686
+ },
687
+ props: {
688
+ isRoot: true,
689
+ },
690
+ },
691
+ );
692
+
693
+ return reloadedIncident?.labels || [];
694
+ }
695
+
696
+ @CaptureSpan()
697
+ private async getMonitorLabels(monitorId: ObjectID): Promise<Array<Label>> {
698
+ const monitor: Monitor | null = await MonitorService.findOneById({
699
+ id: monitorId,
700
+ select: {
701
+ labels: {
702
+ _id: true,
703
+ },
704
+ },
705
+ props: {
706
+ isRoot: true,
707
+ },
708
+ });
709
+
710
+ return monitor?.labels || [];
711
+ }
712
+
620
713
  @CaptureSpan()
621
714
  private async findMatchingActiveEpisode(
622
715
  projectId: ObjectID,
@@ -887,6 +980,12 @@ class IncidentGroupingEngineServiceClass {
887
980
  if (rule.groupByIncidentTitle) {
888
981
  groupByParts.push("Incident Title");
889
982
  }
983
+ if (rule.groupByIncidentLabels) {
984
+ groupByParts.push("Incident Labels");
985
+ }
986
+ if (rule.groupByMonitorLabels) {
987
+ groupByParts.push("Monitor Labels");
988
+ }
890
989
 
891
990
  const groupByDescription: string =
892
991
  groupByParts.length > 0
@@ -45,12 +45,24 @@ export class MailService extends BaseService {
45
45
 
46
46
  if (options && options.mailServer) {
47
47
  body["SMTP_ID"] = options.mailServer.id?.toString();
48
+ // host/port/secure are optional on EmailServer because HTTP-API
49
+ // transports (e.g. Microsoft Graph) don't use them. Only serialize the
50
+ // SMTP-only fields when they're actually present.
51
+ body["SMTP_TRANSPORT_TYPE"] =
52
+ options.mailServer.transportType || undefined;
48
53
  body["SMTP_USERNAME"] = options.mailServer.username || undefined;
49
54
  body["SMTP_EMAIL"] = options.mailServer.fromEmail.toString();
50
55
  body["SMTP_FROM_NAME"] = options.mailServer.fromName;
51
- body["SMTP_IS_SECURE"] = options.mailServer.secure;
52
- body["SMTP_PORT"] = options.mailServer.port.toNumber();
53
- body["SMTP_HOST"] = options.mailServer.host.toString();
56
+ body["SMTP_IS_SECURE"] =
57
+ options.mailServer.secure === undefined
58
+ ? undefined
59
+ : options.mailServer.secure;
60
+ body["SMTP_PORT"] = options.mailServer.port
61
+ ? options.mailServer.port.toNumber()
62
+ : undefined;
63
+ body["SMTP_HOST"] = options.mailServer.host
64
+ ? options.mailServer.host.toString()
65
+ : undefined;
54
66
  body["SMTP_PASSWORD"] = options.mailServer.password || undefined;
55
67
  body["SMTP_AUTH_TYPE"] = options.mailServer.authType || undefined;
56
68
  body["SMTP_CLIENT_ID"] = options.mailServer.clientId || undefined;
@@ -1,5 +1,6 @@
1
1
  import DatabaseService from "./DatabaseService";
2
2
  import EmailServer from "../../Types/Email/EmailServer";
3
+ import MailTransportType from "../../Types/Email/MailTransportType";
3
4
  import SMTPAuthenticationType from "../../Types/Email/SMTPAuthenticationType";
4
5
  import BadDataException from "../../Types/Exception/BadDataException";
5
6
  import URL from "../../Types/API/URL";
@@ -21,17 +22,28 @@ export class Service extends DatabaseService<Model> {
21
22
  throw new BadDataException("Project SMTP config id is not set");
22
23
  }
23
24
 
24
- if (!projectSmtpConfig.hostname) {
25
- throw new BadDataException("Project SMTP config host is not set");
26
- }
27
-
28
- if (!projectSmtpConfig.port) {
29
- throw new BadDataException("Project SMTP config port is not set");
30
- }
25
+ const transportType: MailTransportType =
26
+ projectSmtpConfig.transportType || MailTransportType.SMTP;
31
27
 
32
- // Get auth type, default to UsernamePassword for backward compatibility
28
+ /*
29
+ * Get auth type, default to UsernamePassword for backward compatibility.
30
+ * Microsoft Graph always uses OAuth (Client Credentials) regardless of what
31
+ * the user picked — but we still let the existing OAuth validation apply.
32
+ */
33
33
  const authType: SMTPAuthenticationType =
34
- projectSmtpConfig.authType || SMTPAuthenticationType.UsernamePassword;
34
+ transportType === MailTransportType.MicrosoftGraph
35
+ ? SMTPAuthenticationType.OAuth
36
+ : projectSmtpConfig.authType || SMTPAuthenticationType.UsernamePassword;
37
+
38
+ if (transportType === MailTransportType.SMTP) {
39
+ if (!projectSmtpConfig.hostname) {
40
+ throw new BadDataException("Project SMTP config host is not set");
41
+ }
42
+
43
+ if (!projectSmtpConfig.port) {
44
+ throw new BadDataException("Project SMTP config port is not set");
45
+ }
46
+ }
35
47
 
36
48
  // Validate based on auth type
37
49
  if (authType === SMTPAuthenticationType.UsernamePassword) {
@@ -43,7 +55,14 @@ export class Service extends DatabaseService<Model> {
43
55
  throw new BadDataException("Project SMTP config password is not set");
44
56
  }
45
57
  } else if (authType === SMTPAuthenticationType.OAuth) {
46
- if (!projectSmtpConfig.username) {
58
+ /*
59
+ * For Microsoft Graph, username is optional — we fall back to fromEmail
60
+ * as the sender mailbox. For SMTP+XOAUTH2, username is required.
61
+ */
62
+ if (
63
+ transportType === MailTransportType.SMTP &&
64
+ !projectSmtpConfig.username
65
+ ) {
47
66
  throw new BadDataException(
48
67
  "Project SMTP config username (email address) is not set for OAuth",
49
68
  );
@@ -85,6 +104,7 @@ export class Service extends DatabaseService<Model> {
85
104
 
86
105
  return {
87
106
  id: projectSmtpConfig.id!,
107
+ transportType: transportType,
88
108
  host: projectSmtpConfig.hostname,
89
109
  port: projectSmtpConfig.port,
90
110
  username: projectSmtpConfig.username,
@@ -92,6 +92,7 @@ export class Service extends DatabaseService<Model> {
92
92
  projectId: true,
93
93
  smtpConfig: {
94
94
  _id: true,
95
+ transportType: true,
95
96
  hostname: true,
96
97
  port: true,
97
98
  username: true,
@@ -684,6 +684,7 @@ Stay informed about service availability! 🚀`;
684
684
  name: true,
685
685
  smtpConfig: {
686
686
  _id: true,
687
+ transportType: true,
687
688
  hostname: true,
688
689
  port: true,
689
690
  username: true,
@@ -915,6 +916,7 @@ Stay informed about service availability! 🚀`;
915
916
  name: true,
916
917
  smtpConfig: {
917
918
  _id: true,
919
+ transportType: true,
918
920
  hostname: true,
919
921
  port: true,
920
922
  username: true,
@@ -1347,6 +1349,7 @@ Stay informed about service availability! 🚀`;
1347
1349
  allowSubscribersToChooseEventTypes: true,
1348
1350
  smtpConfig: {
1349
1351
  _id: true,
1352
+ transportType: true,
1350
1353
  hostname: true,
1351
1354
  port: true,
1352
1355
  username: true,
@@ -706,6 +706,12 @@ export class Service extends DatabaseService<UserNotificationSetting> {
706
706
  projectId,
707
707
  NotificationSettingEventType.SEND_ALERT_EPISODE_STATE_CHANGED_OWNER_NOTIFICATION,
708
708
  );
709
+
710
+ await this.addNotificationSettingIfNotExists(
711
+ userId,
712
+ projectId,
713
+ NotificationSettingEventType.SEND_ALERT_ADDED_TO_EPISODE_OWNER_NOTIFICATION,
714
+ );
709
715
  }
710
716
 
711
717
  private async addIncidentEpisodeNotificationSettings(
@@ -723,6 +729,12 @@ export class Service extends DatabaseService<UserNotificationSetting> {
723
729
  projectId,
724
730
  NotificationSettingEventType.SEND_INCIDENT_EPISODE_STATE_CHANGED_OWNER_NOTIFICATION,
725
731
  );
732
+
733
+ await this.addNotificationSettingIfNotExists(
734
+ userId,
735
+ projectId,
736
+ NotificationSettingEventType.SEND_INCIDENT_ADDED_TO_EPISODE_OWNER_NOTIFICATION,
737
+ );
726
738
  }
727
739
 
728
740
  private async addNotificationSettingIfNotExists(
@@ -94,6 +94,8 @@ const templateIdByEventType: Record<
94
94
  WhatsAppTemplateIds.AlertEpisodeStateChangedOwnerNotification,
95
95
  [NotificationSettingEventType.SEND_ALERT_EPISODE_OWNER_ADDED_NOTIFICATION]:
96
96
  WhatsAppTemplateIds.AlertEpisodeOwnerAddedNotification,
97
+ [NotificationSettingEventType.SEND_ALERT_ADDED_TO_EPISODE_OWNER_NOTIFICATION]:
98
+ WhatsAppTemplateIds.AlertAddedToEpisodeOwnerNotification,
97
99
  [NotificationSettingEventType.SEND_INCIDENT_EPISODE_CREATED_OWNER_NOTIFICATION]:
98
100
  WhatsAppTemplateIds.IncidentEpisodeCreatedOwnerNotification,
99
101
  [NotificationSettingEventType.SEND_INCIDENT_EPISODE_NOTE_POSTED_OWNER_NOTIFICATION]:
@@ -102,6 +104,8 @@ const templateIdByEventType: Record<
102
104
  WhatsAppTemplateIds.IncidentEpisodeStateChangedOwnerNotification,
103
105
  [NotificationSettingEventType.SEND_INCIDENT_EPISODE_OWNER_ADDED_NOTIFICATION]:
104
106
  WhatsAppTemplateIds.IncidentEpisodeOwnerAddedNotification,
107
+ [NotificationSettingEventType.SEND_INCIDENT_ADDED_TO_EPISODE_OWNER_NOTIFICATION]:
108
+ WhatsAppTemplateIds.IncidentAddedToEpisodeOwnerNotification,
105
109
  [NotificationSettingEventType.SEND_MONITOR_OWNER_ADDED_NOTIFICATION]:
106
110
  WhatsAppTemplateIds.MonitorOwnerAddedNotification,
107
111
  [NotificationSettingEventType.SEND_MONITOR_CREATED_OWNER_NOTIFICATION]:
@@ -232,6 +232,34 @@ describe("AlertGroupingEngineService Models", () => {
232
232
  );
233
233
  expect(groupingKey).toContain(`title:${mockAlert.title}`);
234
234
  });
235
+
236
+ test("should produce identical label key regardless of label order (exact set match)", () => {
237
+ // Simulates buildGroupingKey's label-sorting logic.
238
+ const labelIdsA: string[] = ["lbl-c", "lbl-a", "lbl-b"];
239
+ const labelIdsB: string[] = ["lbl-b", "lbl-c", "lbl-a"];
240
+
241
+ const keyA: string = `alertLabels:${[...labelIdsA].sort().join(",")}`;
242
+ const keyB: string = `alertLabels:${[...labelIdsB].sort().join(",")}`;
243
+
244
+ expect(keyA).toBe(keyB);
245
+ expect(keyA).toBe("alertLabels:lbl-a,lbl-b,lbl-c");
246
+ });
247
+
248
+ test("should produce different label keys for different label sets (exact set match)", () => {
249
+ // Alerts with [A,B] and [A,C] must NOT group together under exact set match.
250
+ const keyAB: string = `alertLabels:${["lbl-a", "lbl-b"].sort().join(",")}`;
251
+ const keyAC: string = `alertLabels:${["lbl-a", "lbl-c"].sort().join(",")}`;
252
+
253
+ expect(keyAB).not.toBe(keyAC);
254
+ });
255
+
256
+ test("should emit empty label key when alert has no labels", () => {
257
+ // Alerts with no labels should produce the same (empty) label key.
258
+ const labelIds: string[] = [];
259
+ const key: string = `alertLabels:${[...labelIds].sort().join(",")}`;
260
+
261
+ expect(key).toBe("alertLabels:");
262
+ });
235
263
  });
236
264
 
237
265
  describe("Time Window Configuration", () => {
@@ -143,14 +143,28 @@ describe("AlertGroupingRule Model", () => {
143
143
  expect(rule.groupByAlertTitle).toBe(true);
144
144
  });
145
145
 
146
+ test("should set and get groupByAlertLabels correctly", () => {
147
+ rule.groupByAlertLabels = true;
148
+ expect(rule.groupByAlertLabels).toBe(true);
149
+ });
150
+
151
+ test("should set and get groupByMonitorLabels correctly", () => {
152
+ rule.groupByMonitorLabels = true;
153
+ expect(rule.groupByMonitorLabels).toBe(true);
154
+ });
155
+
146
156
  test("should handle all groupBy options as false", () => {
147
157
  rule.groupByMonitor = false;
148
158
  rule.groupBySeverity = false;
149
159
  rule.groupByAlertTitle = false;
160
+ rule.groupByAlertLabels = false;
161
+ rule.groupByMonitorLabels = false;
150
162
 
151
163
  expect(rule.groupByMonitor).toBe(false);
152
164
  expect(rule.groupBySeverity).toBe(false);
153
165
  expect(rule.groupByAlertTitle).toBe(false);
166
+ expect(rule.groupByAlertLabels).toBe(false);
167
+ expect(rule.groupByMonitorLabels).toBe(false);
154
168
  });
155
169
 
156
170
  test("should handle combination of groupBy options", () => {
@@ -3,20 +3,38 @@ import URL from "../API/URL";
3
3
  import Email from "../Email";
4
4
  import ObjectID from "../ObjectID";
5
5
  import Port from "../Port";
6
+ import MailTransportType from "./MailTransportType";
6
7
  import OAuthProviderType from "./OAuthProviderType";
7
8
  import SMTPAuthenticationType from "./SMTPAuthenticationType";
8
9
 
9
10
  export default interface EmailServer {
10
11
  id?: ObjectID | undefined; // If this is custom SMTP, this is the ID of the SMTP config. Otherwise, it's undefined
11
- host: Hostname;
12
- port: Port;
12
+
13
+ /*
14
+ * Transport selection.
15
+ * Defaults to SMTP when undefined for backwards compatibility with existing
16
+ * EmailServer constructions.
17
+ */
18
+ transportType?: MailTransportType | undefined;
19
+
20
+ /*
21
+ * SMTP transport fields. Required when transportType === SMTP (or undefined).
22
+ * Not used for HTTP-API transports like MicrosoftGraph.
23
+ */
24
+ host?: Hostname | undefined;
25
+ port?: Port | undefined;
26
+ secure?: boolean | undefined;
27
+
13
28
  username: string | undefined;
14
29
  password: string | undefined;
15
- secure: boolean;
16
30
  fromEmail: Email;
17
31
  fromName: string;
18
32
 
19
- // OAuth 2.0 fields for any OAuth-enabled SMTP server
33
+ /*
34
+ * OAuth 2.0 fields. Used by:
35
+ * - SMTP transport when authType === OAuth (XOAUTH2)
36
+ * - HTTP-API transports (always — they always authenticate via OAuth2)
37
+ */
20
38
  authType?: SMTPAuthenticationType | undefined;
21
39
  clientId?: string | undefined; // OAuth Application Client ID
22
40
  clientSecret?: string | undefined; // OAuth Application Client Secret
@@ -47,11 +47,13 @@ enum EmailTemplateType {
47
47
  AlertEpisodeOwnerStateChanged = "AlertEpisodeOwnerStateChanged.hbs",
48
48
  AlertEpisodeOwnerNotePosted = "AlertEpisodeOwnerNotePosted.hbs",
49
49
  AlertEpisodeOwnerResourceCreated = "AlertEpisodeOwnerResourceCreated.hbs",
50
+ AlertEpisodeOwnerAlertAdded = "AlertEpisodeOwnerAlertAdded.hbs",
50
51
 
51
52
  IncidentEpisodeOwnerAdded = "IncidentEpisodeOwnerAdded.hbs",
52
53
  IncidentEpisodeOwnerStateChanged = "IncidentEpisodeOwnerStateChanged.hbs",
53
54
  IncidentEpisodeOwnerNotePosted = "IncidentEpisodeOwnerNotePosted.hbs",
54
55
  IncidentEpisodeOwnerResourceCreated = "IncidentEpisodeOwnerResourceCreated.hbs",
56
+ IncidentEpisodeOwnerIncidentAdded = "IncidentEpisodeOwnerIncidentAdded.hbs",
55
57
 
56
58
  SubscriberEpisodeCreated = "SubscriberEpisodeCreated.hbs",
57
59
  SubscriberEpisodeStateChanged = "SubscriberEpisodeStateChanged.hbs",
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Transport type for delivering email.
3
+ *
4
+ * - SMTP: traditional SMTP server (with optional XOAUTH2 / username+password / no auth)
5
+ * - MicrosoftGraph: Microsoft 365 mailbox via Microsoft Graph API
6
+ * (POST /v1.0/users/{sender}/sendMail). Required when the tenant has SMTP AUTH
7
+ * disabled — the Azure AD app only needs Mail.Send (application) permission.
8
+ *
9
+ * Adding a new HTTP-API provider (Gmail, SES, etc.) means adding a new enum value
10
+ * and a matching MailProvider implementation. See
11
+ * App/FeatureSet/Notification/Services/MailProviders.
12
+ */
13
+ enum MailTransportType {
14
+ SMTP = "SMTP",
15
+ MicrosoftGraph = "Microsoft Graph",
16
+ }
17
+
18
+ export default MailTransportType;
@@ -19,6 +19,7 @@ enum NotificationSettingEventType {
19
19
  SEND_ALERT_EPISODE_NOTE_POSTED_OWNER_NOTIFICATION = "Send alert episode note posted notification when I am the owner of the alert episode",
20
20
  SEND_ALERT_EPISODE_STATE_CHANGED_OWNER_NOTIFICATION = "Send alert episode state changed notification when I am the owner of the alert episode",
21
21
  SEND_ALERT_EPISODE_OWNER_ADDED_NOTIFICATION = "Send notification when I am added as a owner to the alert episode",
22
+ SEND_ALERT_ADDED_TO_EPISODE_OWNER_NOTIFICATION = "Send notification when a new alert is added to an alert episode I own",
22
23
 
23
24
  // Incident Episodes
24
25
 
@@ -26,6 +27,7 @@ enum NotificationSettingEventType {
26
27
  SEND_INCIDENT_EPISODE_NOTE_POSTED_OWNER_NOTIFICATION = "Send incident episode note posted notification when I am the owner of the incident episode",
27
28
  SEND_INCIDENT_EPISODE_STATE_CHANGED_OWNER_NOTIFICATION = "Send incident episode state changed notification when I am the owner of the incident episode",
28
29
  SEND_INCIDENT_EPISODE_OWNER_ADDED_NOTIFICATION = "Send notification when I am added as a owner to the incident episode",
30
+ SEND_INCIDENT_ADDED_TO_EPISODE_OWNER_NOTIFICATION = "Send notification when a new incident is added to an incident episode I own",
29
31
 
30
32
  // Monitors
31
33
  SEND_MONITOR_OWNER_ADDED_NOTIFICATION = "Send notification when I am added as a owner to the monitor",
@@ -18,10 +18,12 @@ type TemplateIdsMap = {
18
18
  readonly AlertEpisodeNotePostedOwnerNotification: "oneuptime_alert_episode_note_posted_owner_notification";
19
19
  readonly AlertEpisodeStateChangedOwnerNotification: "oneuptime_alert_episode_state_changed_owner_notification";
20
20
  readonly AlertEpisodeOwnerAddedNotification: "oneuptime_alert_episode_owner_added_notification";
21
+ readonly AlertAddedToEpisodeOwnerNotification: "oneuptime_alert_added_to_episode_owner_notification";
21
22
  readonly IncidentEpisodeCreatedOwnerNotification: "oneuptime_incident_episode_created_owner_notification";
22
23
  readonly IncidentEpisodeNotePostedOwnerNotification: "oneuptime_incident_episode_note_posted_owner_notification";
23
24
  readonly IncidentEpisodeStateChangedOwnerNotification: "oneuptime_incident_episode_state_changed_owner_notification";
24
25
  readonly IncidentEpisodeOwnerAddedNotification: "oneuptime_incident_episode_owner_added_notification";
26
+ readonly IncidentAddedToEpisodeOwnerNotification: "oneuptime_incident_added_to_episode_owner_notification";
25
27
  readonly MonitorOwnerAddedNotification: "oneuptime_monitor_owner_added_notification";
26
28
  readonly MonitorCreatedOwnerNotification: "oneuptime_monitor_created_owner_notification";
27
29
  readonly MonitorStatusChangedOwnerNotification: "oneuptime_monitor_status_changed_owner_notification";
@@ -75,6 +77,8 @@ const templateIds: TemplateIdsMap = {
75
77
  "oneuptime_alert_episode_state_changed_owner_notification",
76
78
  AlertEpisodeOwnerAddedNotification:
77
79
  "oneuptime_alert_episode_owner_added_notification",
80
+ AlertAddedToEpisodeOwnerNotification:
81
+ "oneuptime_alert_added_to_episode_owner_notification",
78
82
  IncidentEpisodeCreatedOwnerNotification:
79
83
  "oneuptime_incident_episode_created_owner_notification",
80
84
  IncidentEpisodeNotePostedOwnerNotification:
@@ -83,6 +87,8 @@ const templateIds: TemplateIdsMap = {
83
87
  "oneuptime_incident_episode_state_changed_owner_notification",
84
88
  IncidentEpisodeOwnerAddedNotification:
85
89
  "oneuptime_incident_episode_owner_added_notification",
90
+ IncidentAddedToEpisodeOwnerNotification:
91
+ "oneuptime_incident_added_to_episode_owner_notification",
86
92
  MonitorOwnerAddedNotification: "oneuptime_monitor_owner_added_notification",
87
93
  MonitorCreatedOwnerNotification:
88
94
  "oneuptime_monitor_created_owner_notification",
@@ -156,10 +162,12 @@ export const WhatsAppTemplateMessages: WhatsAppTemplateMessagesDefinition = {
156
162
  [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.`,
157
163
  [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.`,
158
164
  [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.`,
165
+ [WhatsAppTemplateIds.AlertAddedToEpisodeOwnerNotification]: `{{alert_count}} new alert(s) were added to alert episode #{{episode_number}} ({{episode_title}}). Review the alert episode using {{episode_link}} on the OneUptime dashboard.`,
159
166
  [WhatsAppTemplateIds.IncidentEpisodeCreatedOwnerNotification]: `Incident Episode #{{episode_number}} ({{episode_title}}) has been created for project {{project_name}}. View incident episode details using {{episode_link}} on the OneUptime dashboard.`,
160
167
  [WhatsAppTemplateIds.IncidentEpisodeNotePostedOwnerNotification]: `A new note was posted on incident episode #{{episode_number}} ({{episode_title}}). Review the incident episode using {{episode_link}} on the OneUptime dashboard for updates.`,
161
168
  [WhatsAppTemplateIds.IncidentEpisodeStateChangedOwnerNotification]: `Incident Episode #{{episode_number}} ({{episode_title}}) state changed to {{episode_state}}. Track the incident episode status using {{episode_link}} on the OneUptime dashboard.`,
162
169
  [WhatsAppTemplateIds.IncidentEpisodeOwnerAddedNotification]: `You have been added as an owner of incident episode #{{episode_number}} ({{episode_title}}). Manage the incident episode using {{episode_link}} on the OneUptime dashboard.`,
170
+ [WhatsAppTemplateIds.IncidentAddedToEpisodeOwnerNotification]: `{{incident_count}} new incident(s) were added to incident episode #{{episode_number}} ({{episode_title}}). Review the incident episode using {{episode_link}} on the OneUptime dashboard.`,
163
171
  [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.`,
164
172
  [WhatsAppTemplateIds.MonitorCreatedOwnerNotification]: `Monitor {{monitor_name}} has been created. Check monitor {{monitor_link}} on the OneUptime dashboard `,
165
173
  [WhatsAppTemplateIds.MonitorStatusChangedOwnerNotification]: `Monitor {{monitor_name}} status changed to {{monitor_status}}. Check the monitor status using {{monitor_link}} on the OneUptime dashboard to stay informed.`,
@@ -203,10 +211,12 @@ export const WhatsAppTemplateLanguage: Record<WhatsAppTemplateId, string> = {
203
211
  [WhatsAppTemplateIds.AlertEpisodeNotePostedOwnerNotification]: "en",
204
212
  [WhatsAppTemplateIds.AlertEpisodeStateChangedOwnerNotification]: "en",
205
213
  [WhatsAppTemplateIds.AlertEpisodeOwnerAddedNotification]: "en",
214
+ [WhatsAppTemplateIds.AlertAddedToEpisodeOwnerNotification]: "en",
206
215
  [WhatsAppTemplateIds.IncidentEpisodeCreatedOwnerNotification]: "en",
207
216
  [WhatsAppTemplateIds.IncidentEpisodeNotePostedOwnerNotification]: "en",
208
217
  [WhatsAppTemplateIds.IncidentEpisodeStateChangedOwnerNotification]: "en",
209
218
  [WhatsAppTemplateIds.IncidentEpisodeOwnerAddedNotification]: "en",
219
+ [WhatsAppTemplateIds.IncidentAddedToEpisodeOwnerNotification]: "en",
210
220
  [WhatsAppTemplateIds.MonitorOwnerAddedNotification]: "en",
211
221
  [WhatsAppTemplateIds.MonitorCreatedOwnerNotification]: "en",
212
222
  [WhatsAppTemplateIds.MonitorStatusChangedOwnerNotification]: "en",
@@ -75,7 +75,9 @@ const Accordion: FunctionComponent<ComponentProps> = (
75
75
  <div className={className}>
76
76
  <div>
77
77
  <div
78
- className={`flex justify-between items-start gap-3 cursor-pointer group/accordion-header rounded-lg -mx-2 px-2 py-2 transition-colors ${
78
+ className={`flex justify-between ${
79
+ props.description ? "items-start" : "items-center"
80
+ } gap-3 cursor-pointer group/accordion-header rounded-lg -mx-2 px-2 py-2 transition-colors ${
79
81
  isOpen ? "" : "hover:bg-gray-50/80"
80
82
  }`}
81
83
  role="button"
@@ -87,10 +89,16 @@ const Accordion: FunctionComponent<ComponentProps> = (
87
89
  }}
88
90
  onKeyDown={handleKeyDown}
89
91
  >
90
- <div className="flex items-start min-w-0 flex-1">
92
+ <div
93
+ className={`flex ${
94
+ props.description ? "items-start" : "items-center"
95
+ } min-w-0 flex-1`}
96
+ >
91
97
  {props.title && (
92
98
  <div
93
- className={`flex-shrink-0 mt-0.5 flex items-center justify-center w-6 h-6 rounded-md transition-all duration-200 ${
99
+ className={`flex-shrink-0 ${
100
+ props.description ? "mt-0.5" : ""
101
+ } flex items-center justify-center w-6 h-6 rounded-md transition-all duration-200 ${
94
102
  isOpen
95
103
  ? "bg-gray-900/5 text-gray-700"
96
104
  : "text-gray-400 group-hover/accordion-header:bg-gray-900/5 group-hover/accordion-header:text-gray-700"
@@ -126,7 +134,7 @@ const Accordion: FunctionComponent<ComponentProps> = (
126
134
  )}
127
135
  </div>
128
136
  {!isOpen && props.rightElement && (
129
- <div className="flex-shrink-0 mt-0.5">{props.rightElement}</div>
137
+ <div className="flex-shrink-0">{props.rightElement}</div>
130
138
  )}
131
139
  </div>
132
140
  {isOpen && (
@@ -54,6 +54,7 @@ let AlertEpisodeMember = class AlertEpisodeMember extends BaseModel {
54
54
  this.matchedRuleId = undefined;
55
55
  this.createdByUser = undefined;
56
56
  this.createdByUserId = undefined;
57
+ this.isOwnerNotifiedOfAlertAdded = undefined;
57
58
  this.deletedByUser = undefined;
58
59
  this.deletedByUserId = undefined;
59
60
  }
@@ -588,6 +589,35 @@ __decorate([
588
589
  }),
589
590
  __metadata("design:type", ObjectID)
590
591
  ], AlertEpisodeMember.prototype, "createdByUserId", void 0);
592
+ __decorate([
593
+ ColumnAccessControl({
594
+ create: [],
595
+ read: [
596
+ Permission.ProjectOwner,
597
+ Permission.ProjectAdmin,
598
+ Permission.ProjectMember,
599
+ Permission.Viewer,
600
+ Permission.AlertAdmin,
601
+ Permission.AlertMember,
602
+ Permission.AlertViewer,
603
+ Permission.ReadAlertEpisodeMember,
604
+ ],
605
+ update: [],
606
+ }),
607
+ TableColumn({
608
+ isDefaultValueColumn: true,
609
+ required: true,
610
+ type: TableColumnType.Boolean,
611
+ title: "Is Owner Notified of Alert Added to Episode",
612
+ description: "Has the owner been notified that this alert was added to the episode?",
613
+ }),
614
+ Column({
615
+ type: ColumnType.Boolean,
616
+ nullable: false,
617
+ default: false,
618
+ }),
619
+ __metadata("design:type", Boolean)
620
+ ], AlertEpisodeMember.prototype, "isOwnerNotifiedOfAlertAdded", void 0);
591
621
  __decorate([
592
622
  ColumnAccessControl({
593
623
  create: [],