@oneuptime/common 10.0.69 → 10.0.71

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 (106) hide show
  1. package/Models/DatabaseModels/KubernetesCluster.ts +7 -0
  2. package/Models/DatabaseModels/Project.ts +5 -5
  3. package/Server/Infrastructure/Postgres/SchemaMigrations/1776865086264-MigrationName.ts +11 -8
  4. package/Server/Infrastructure/Postgres/SchemaMigrations/1776881254913-DedupeKubernetesClustersAndAddUniqueIndex.ts +137 -0
  5. package/Server/Infrastructure/Postgres/SchemaMigrations/1776886248361-MigrationName.ts +17 -0
  6. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  7. package/Server/Services/AIBillingService.ts +2 -2
  8. package/Server/Services/BillingService.ts +116 -48
  9. package/Server/Services/DatabaseService.ts +10 -27
  10. package/Server/Services/KubernetesResourceService.ts +33 -10
  11. package/Server/Services/NotificationService.ts +2 -2
  12. package/Server/Types/Database/QueryHelper.ts +127 -0
  13. package/Server/Types/Database/QueryUtil.ts +250 -0
  14. package/Server/Utils/Monitor/MonitorAlert.ts +79 -0
  15. package/Server/Utils/Monitor/MonitorIncident.ts +79 -0
  16. package/Types/BaseDatabase/EndsWith.ts +41 -0
  17. package/Types/BaseDatabase/IncludesAll.ts +45 -0
  18. package/Types/BaseDatabase/IncludesNone.ts +45 -0
  19. package/Types/BaseDatabase/NotContains.ts +41 -0
  20. package/Types/BaseDatabase/StartsWith.ts +41 -0
  21. package/Types/Email.ts +50 -0
  22. package/Types/JSON.ts +20 -0
  23. package/Types/SerializableObjectDictionary.ts +10 -0
  24. package/UI/Components/Filters/BooleanFilter.tsx +1 -0
  25. package/UI/Components/Filters/DateFilter.tsx +220 -25
  26. package/UI/Components/Filters/DropdownFilter.tsx +1 -0
  27. package/UI/Components/Filters/EntityFilter.tsx +229 -41
  28. package/UI/Components/Filters/FilterViewer.tsx +231 -147
  29. package/UI/Components/Filters/FilterViewerItem.tsx +1 -11
  30. package/UI/Components/Filters/FiltersForm.tsx +146 -97
  31. package/UI/Components/Filters/NumberFilter.tsx +220 -34
  32. package/UI/Components/Filters/OperatorSelector.tsx +91 -0
  33. package/UI/Components/Filters/TextFilter.tsx +183 -71
  34. package/UI/Components/Filters/Types/FilterOperator.ts +73 -0
  35. package/UI/Components/ModelTable/BaseModelTable.tsx +10 -0
  36. package/build/dist/Models/DatabaseModels/KubernetesCluster.js +9 -1
  37. package/build/dist/Models/DatabaseModels/KubernetesCluster.js.map +1 -1
  38. package/build/dist/Models/DatabaseModels/Project.js +5 -5
  39. package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
  40. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1776865086264-MigrationName.js +1 -1
  41. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1776865086264-MigrationName.js.map +1 -1
  42. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1776881254913-DedupeKubernetesClustersAndAddUniqueIndex.js +125 -0
  43. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1776881254913-DedupeKubernetesClustersAndAddUniqueIndex.js.map +1 -0
  44. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1776886248361-MigrationName.js +12 -0
  45. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1776886248361-MigrationName.js.map +1 -0
  46. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  47. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  48. package/build/dist/Server/Services/AIBillingService.js +2 -2
  49. package/build/dist/Server/Services/AIBillingService.js.map +1 -1
  50. package/build/dist/Server/Services/BillingService.js +99 -39
  51. package/build/dist/Server/Services/BillingService.js.map +1 -1
  52. package/build/dist/Server/Services/DatabaseService.js +9 -6
  53. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  54. package/build/dist/Server/Services/KubernetesResourceService.js +4 -2
  55. package/build/dist/Server/Services/KubernetesResourceService.js.map +1 -1
  56. package/build/dist/Server/Services/NotificationService.js +2 -2
  57. package/build/dist/Server/Services/NotificationService.js.map +1 -1
  58. package/build/dist/Server/Types/Database/QueryHelper.js +110 -0
  59. package/build/dist/Server/Types/Database/QueryHelper.js.map +1 -1
  60. package/build/dist/Server/Types/Database/QueryUtil.js +186 -0
  61. package/build/dist/Server/Types/Database/QueryUtil.js.map +1 -1
  62. package/build/dist/Server/Utils/Monitor/MonitorAlert.js +68 -0
  63. package/build/dist/Server/Utils/Monitor/MonitorAlert.js.map +1 -1
  64. package/build/dist/Server/Utils/Monitor/MonitorIncident.js +68 -0
  65. package/build/dist/Server/Utils/Monitor/MonitorIncident.js.map +1 -1
  66. package/build/dist/Types/BaseDatabase/EndsWith.js +31 -0
  67. package/build/dist/Types/BaseDatabase/EndsWith.js.map +1 -0
  68. package/build/dist/Types/BaseDatabase/IncludesAll.js +34 -0
  69. package/build/dist/Types/BaseDatabase/IncludesAll.js.map +1 -0
  70. package/build/dist/Types/BaseDatabase/IncludesNone.js +34 -0
  71. package/build/dist/Types/BaseDatabase/IncludesNone.js.map +1 -0
  72. package/build/dist/Types/BaseDatabase/NotContains.js +31 -0
  73. package/build/dist/Types/BaseDatabase/NotContains.js.map +1 -0
  74. package/build/dist/Types/BaseDatabase/StartsWith.js +31 -0
  75. package/build/dist/Types/BaseDatabase/StartsWith.js.map +1 -0
  76. package/build/dist/Types/Email.js +42 -0
  77. package/build/dist/Types/Email.js.map +1 -1
  78. package/build/dist/Types/JSON.js +5 -0
  79. package/build/dist/Types/JSON.js.map +1 -1
  80. package/build/dist/Types/SerializableObjectDictionary.js +10 -0
  81. package/build/dist/Types/SerializableObjectDictionary.js.map +1 -1
  82. package/build/dist/UI/Components/Filters/BooleanFilter.js +1 -1
  83. package/build/dist/UI/Components/Filters/BooleanFilter.js.map +1 -1
  84. package/build/dist/UI/Components/Filters/DateFilter.js +155 -14
  85. package/build/dist/UI/Components/Filters/DateFilter.js.map +1 -1
  86. package/build/dist/UI/Components/Filters/DropdownFilter.js +1 -1
  87. package/build/dist/UI/Components/Filters/DropdownFilter.js.map +1 -1
  88. package/build/dist/UI/Components/Filters/EntityFilter.js +181 -30
  89. package/build/dist/UI/Components/Filters/EntityFilter.js.map +1 -1
  90. package/build/dist/UI/Components/Filters/FilterViewer.js +188 -98
  91. package/build/dist/UI/Components/Filters/FilterViewer.js.map +1 -1
  92. package/build/dist/UI/Components/Filters/FilterViewerItem.js +1 -6
  93. package/build/dist/UI/Components/Filters/FilterViewerItem.js.map +1 -1
  94. package/build/dist/UI/Components/Filters/FiltersForm.js +46 -38
  95. package/build/dist/UI/Components/Filters/FiltersForm.js.map +1 -1
  96. package/build/dist/UI/Components/Filters/NumberFilter.js +164 -23
  97. package/build/dist/UI/Components/Filters/NumberFilter.js.map +1 -1
  98. package/build/dist/UI/Components/Filters/OperatorSelector.js +41 -0
  99. package/build/dist/UI/Components/Filters/OperatorSelector.js.map +1 -0
  100. package/build/dist/UI/Components/Filters/TextFilter.js +131 -53
  101. package/build/dist/UI/Components/Filters/TextFilter.js.map +1 -1
  102. package/build/dist/UI/Components/Filters/Types/FilterOperator.js +63 -0
  103. package/build/dist/UI/Components/Filters/Types/FilterOperator.js.map +1 -0
  104. package/build/dist/UI/Components/ModelTable/BaseModelTable.js +9 -0
  105. package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
  106. package/package.json +1 -1
@@ -73,6 +73,13 @@ import {
73
73
  })
74
74
  @CrudApiEndpoint(new Route("/kubernetes-cluster"))
75
75
  @SlugifyColumn("name", "slug")
76
+ /*
77
+ * Enforce one cluster row per (projectId, clusterIdentifier) at the DB level.
78
+ * Without this, two pods emitting OTel telemetry for a new cluster at the
79
+ * same time (e.g. when the agent is first installed or during a rolling
80
+ * update) race in findOrCreateByClusterIdentifier and create duplicate rows.
81
+ */
82
+ @Index(["projectId", "clusterIdentifier"], { unique: true })
76
83
  @TableMetadata({
77
84
  tableName: "KubernetesCluster",
78
85
  singularName: "Kubernetes Cluster",
@@ -342,15 +342,15 @@ export default class Project extends TenantModel {
342
342
  update: [Permission.ProjectOwner, Permission.ManageProjectBilling],
343
343
  })
344
344
  @TableColumn({
345
- type: TableColumnType.Email,
345
+ type: TableColumnType.LongText,
346
346
  title: "Finance / Accounting Email",
347
347
  description:
348
- "Invoices, receipts and billing related notifications will be sent to this email in addition to project owner.",
349
- example: "accounting@example.com",
348
+ "Invoices, receipts and billing related notifications will be sent to these emails in addition to project owner. Separate multiple emails with a comma.",
349
+ example: "accounting@example.com, finance@example.com",
350
350
  })
351
351
  @Column({
352
- type: ColumnType.Email,
353
- length: ColumnLength.Email,
352
+ type: ColumnType.LongText,
353
+ length: ColumnLength.LongText,
354
354
  nullable: true,
355
355
  unique: false,
356
356
  })
@@ -1,14 +1,17 @@
1
1
  import { MigrationInterface, QueryRunner } from "typeorm";
2
2
 
3
3
  export class MigrationName1776865086264 implements MigrationInterface {
4
- name = 'MigrationName1776865086264'
4
+ public name: string = "MigrationName1776865086264";
5
5
 
6
- public async up(queryRunner: QueryRunner): Promise<void> {
7
- await queryRunner.query(`ALTER TABLE "KubernetesResource" ADD "containerCount" integer`);
8
- }
9
-
10
- public async down(queryRunner: QueryRunner): Promise<void> {
11
- await queryRunner.query(`ALTER TABLE "KubernetesResource" DROP COLUMN "containerCount"`);
12
- }
6
+ public async up(queryRunner: QueryRunner): Promise<void> {
7
+ await queryRunner.query(
8
+ `ALTER TABLE "KubernetesResource" ADD "containerCount" integer`,
9
+ );
10
+ }
13
11
 
12
+ public async down(queryRunner: QueryRunner): Promise<void> {
13
+ await queryRunner.query(
14
+ `ALTER TABLE "KubernetesResource" DROP COLUMN "containerCount"`,
15
+ );
16
+ }
14
17
  }
@@ -0,0 +1,137 @@
1
+ import { MigrationInterface, QueryRunner } from "typeorm";
2
+
3
+ /*
4
+ * Before this migration, KubernetesCluster had an app-level
5
+ * @UniqueColumnBy("projectId") check and a non-unique index on
6
+ * clusterIdentifier, but no DB-level uniqueness. Under concurrent telemetry
7
+ * from multiple agent pods (happens every time the agent is installed or
8
+ * rolls out), findOrCreateByClusterIdentifier would race between its find
9
+ * and its create, and the DB accepted both inserts — producing duplicate
10
+ * rows with identical (projectId, clusterIdentifier).
11
+ *
12
+ * This migration:
13
+ * 1. Reparents all FKs that reference duplicate clusters — KubernetesResource,
14
+ * KubernetesClusterOwnerUser, KubernetesClusterOwnerTeam — onto the
15
+ * oldest surviving row in each duplicate group.
16
+ * 2. Deletes the duplicate (non-survivor) rows.
17
+ * 3. Creates a DB-level unique index on (projectId, clusterIdentifier) so
18
+ * future races are rejected by the DB — the service's existing
19
+ * catch-and-refetch in findOrCreateByClusterIdentifier then returns the
20
+ * winning row instead of producing a duplicate.
21
+ *
22
+ * The auto-generator also picked up unrelated OnCallDutyPolicyScheduleLayer
23
+ * default-value drift. That's dev-environment drift, not the bug we're fixing;
24
+ * stripped from this migration.
25
+ */
26
+ export class DedupeKubernetesClustersAndAddUniqueIndex1776881254913
27
+ implements MigrationInterface
28
+ {
29
+ public name: string =
30
+ "DedupeKubernetesClustersAndAddUniqueIndex1776881254913";
31
+
32
+ public async up(queryRunner: QueryRunner): Promise<void> {
33
+ // 1: reparent KubernetesResource FKs from duplicates -> survivor.
34
+ await queryRunner.query(`
35
+ WITH survivors AS (
36
+ SELECT DISTINCT ON ("projectId", "clusterIdentifier")
37
+ _id AS survivor_id,
38
+ "projectId",
39
+ "clusterIdentifier"
40
+ FROM "KubernetesCluster"
41
+ ORDER BY "projectId", "clusterIdentifier", "createdAt" ASC, _id ASC
42
+ ),
43
+ losers AS (
44
+ SELECT kc._id AS loser_id, s.survivor_id
45
+ FROM "KubernetesCluster" kc
46
+ JOIN survivors s
47
+ ON s."projectId" = kc."projectId"
48
+ AND s."clusterIdentifier" = kc."clusterIdentifier"
49
+ WHERE kc._id <> s.survivor_id
50
+ )
51
+ UPDATE "KubernetesResource" kr
52
+ SET "kubernetesClusterId" = l.survivor_id
53
+ FROM losers l
54
+ WHERE kr."kubernetesClusterId" = l.loser_id;
55
+ `);
56
+
57
+ // 2: reparent KubernetesClusterOwnerUser FKs.
58
+ await queryRunner.query(`
59
+ WITH survivors AS (
60
+ SELECT DISTINCT ON ("projectId", "clusterIdentifier")
61
+ _id AS survivor_id,
62
+ "projectId",
63
+ "clusterIdentifier"
64
+ FROM "KubernetesCluster"
65
+ ORDER BY "projectId", "clusterIdentifier", "createdAt" ASC, _id ASC
66
+ ),
67
+ losers AS (
68
+ SELECT kc._id AS loser_id, s.survivor_id
69
+ FROM "KubernetesCluster" kc
70
+ JOIN survivors s
71
+ ON s."projectId" = kc."projectId"
72
+ AND s."clusterIdentifier" = kc."clusterIdentifier"
73
+ WHERE kc._id <> s.survivor_id
74
+ )
75
+ UPDATE "KubernetesClusterOwnerUser" o
76
+ SET "kubernetesClusterId" = l.survivor_id
77
+ FROM losers l
78
+ WHERE o."kubernetesClusterId" = l.loser_id;
79
+ `);
80
+
81
+ // 3: reparent KubernetesClusterOwnerTeam FKs.
82
+ await queryRunner.query(`
83
+ WITH survivors AS (
84
+ SELECT DISTINCT ON ("projectId", "clusterIdentifier")
85
+ _id AS survivor_id,
86
+ "projectId",
87
+ "clusterIdentifier"
88
+ FROM "KubernetesCluster"
89
+ ORDER BY "projectId", "clusterIdentifier", "createdAt" ASC, _id ASC
90
+ ),
91
+ losers AS (
92
+ SELECT kc._id AS loser_id, s.survivor_id
93
+ FROM "KubernetesCluster" kc
94
+ JOIN survivors s
95
+ ON s."projectId" = kc."projectId"
96
+ AND s."clusterIdentifier" = kc."clusterIdentifier"
97
+ WHERE kc._id <> s.survivor_id
98
+ )
99
+ UPDATE "KubernetesClusterOwnerTeam" o
100
+ SET "kubernetesClusterId" = l.survivor_id
101
+ FROM losers l
102
+ WHERE o."kubernetesClusterId" = l.loser_id;
103
+ `);
104
+
105
+ // 4: delete duplicate rows now that nothing references them.
106
+ await queryRunner.query(`
107
+ WITH survivors AS (
108
+ SELECT DISTINCT ON ("projectId", "clusterIdentifier")
109
+ _id AS survivor_id,
110
+ "projectId",
111
+ "clusterIdentifier"
112
+ FROM "KubernetesCluster"
113
+ ORDER BY "projectId", "clusterIdentifier", "createdAt" ASC, _id ASC
114
+ )
115
+ DELETE FROM "KubernetesCluster" kc
116
+ USING survivors s
117
+ WHERE s."projectId" = kc."projectId"
118
+ AND s."clusterIdentifier" = kc."clusterIdentifier"
119
+ AND kc._id <> s.survivor_id;
120
+ `);
121
+
122
+ // 5: add the DB-level composite unique index.
123
+ await queryRunner.query(
124
+ `CREATE UNIQUE INDEX "IDX_9756988b48848f4f7532a2af0d" ON "KubernetesCluster" ("projectId", "clusterIdentifier") `,
125
+ );
126
+ }
127
+
128
+ public async down(queryRunner: QueryRunner): Promise<void> {
129
+ await queryRunner.query(
130
+ `DROP INDEX "public"."IDX_9756988b48848f4f7532a2af0d"`,
131
+ );
132
+ /*
133
+ * Duplicate rows dropped in up() are lost — a down-migration cannot
134
+ * resurrect them (and reinstating duplicates is not desirable anyway).
135
+ */
136
+ }
137
+ }
@@ -0,0 +1,17 @@
1
+ import { MigrationInterface, QueryRunner } from "typeorm";
2
+
3
+ export class MigrationName1776886248361 implements MigrationInterface {
4
+ public name = "MigrationName1776886248361";
5
+
6
+ public async up(queryRunner: QueryRunner): Promise<void> {
7
+ await queryRunner.query(
8
+ `ALTER TABLE "Project" ALTER COLUMN "financeAccountingEmail" TYPE character varying(500)`,
9
+ );
10
+ }
11
+
12
+ public async down(queryRunner: QueryRunner): Promise<void> {
13
+ await queryRunner.query(
14
+ `ALTER TABLE "Project" ALTER COLUMN "financeAccountingEmail" TYPE character varying(100)`,
15
+ );
16
+ }
17
+ }
@@ -288,6 +288,7 @@ import { MigrationName1776544084793 } from "./1776544084793-MigrationName";
288
288
  import { MigrationName1776761171349 } from "./1776761171349-MigrationName";
289
289
  import { MigrationName1776801030808 } from "./1776801030808-MigrationName";
290
290
  import { MigrationName1776865086264 } from "./1776865086264-MigrationName";
291
+ import { DedupeKubernetesClustersAndAddUniqueIndex1776881254913 } from "./1776881254913-DedupeKubernetesClustersAndAddUniqueIndex";
291
292
  export default [
292
293
  InitialMigration,
293
294
  MigrationName1717678334852,
@@ -579,4 +580,5 @@ export default [
579
580
  MigrationName1776761171349,
580
581
  MigrationName1776801030808,
581
582
  MigrationName1776865086264,
583
+ DedupeKubernetesClustersAndAddUniqueIndex1776881254913,
582
584
  ];
@@ -94,8 +94,8 @@ export class AIBillingService extends BaseService {
94
94
  amountInUSD,
95
95
  {
96
96
  sendInvoiceByEmail: project.sendInvoicesByEmail || false,
97
- recipientEmail: project.financeAccountingEmail
98
- ? new Email(project.financeAccountingEmail)
97
+ recipientEmails: project.financeAccountingEmail
98
+ ? Email.parseList(project.financeAccountingEmail)
99
99
  : undefined,
100
100
  projectId: project.id || undefined,
101
101
  },
@@ -138,13 +138,37 @@ export class BillingService extends BaseService {
138
138
  line2 = rest.substring(0, 200);
139
139
  }
140
140
 
141
+ /*
142
+ * financeAccountingEmail may be a comma/semicolon-separated list. Parse the first
143
+ * valid address for Stripe (which stores a single email), and keep the full list
144
+ * in metadata so OneUptime can fan out invoice emails to every recipient.
145
+ */
146
+ const parsedEmails: Array<Email> = financeAccountingEmail
147
+ ? (() => {
148
+ try {
149
+ return Email.parseList(financeAccountingEmail);
150
+ } catch (err) {
151
+ logger.error(
152
+ `[Invoice Email] Failed to parse financeAccountingEmail for customer ${id}: ${err}`,
153
+ );
154
+ return [];
155
+ }
156
+ })()
157
+ : [];
158
+
159
+ const normalizedEmailList: string = parsedEmails
160
+ .map((e: Email): string => {
161
+ return e.toString();
162
+ })
163
+ .join(", ");
164
+
141
165
  const metadata: Record<string, string> = {
142
166
  business_details_full: businessDetails.substring(0, 5000),
143
167
  };
144
- if (financeAccountingEmail) {
145
- metadata["finance_accounting_email"] = financeAccountingEmail.substring(
168
+ if (normalizedEmailList) {
169
+ metadata["finance_accounting_email"] = normalizedEmailList.substring(
146
170
  0,
147
- 200,
171
+ 500,
148
172
  );
149
173
  } else {
150
174
  // Remove if cleared
@@ -165,11 +189,11 @@ export class BillingService extends BaseService {
165
189
  };
166
190
 
167
191
  /*
168
- * If finance / accounting email provided, set it as the customer email so Stripe sends
169
- * invoices / receipts there. (Stripe only supports a single email via API currently.)
192
+ * Stripe's customer.email only accepts one address. Use the first parsed email;
193
+ * the rest are delivered by OneUptime's own webhook-driven invoice email path.
170
194
  */
171
- if (financeAccountingEmail && financeAccountingEmail.trim().length > 0) {
172
- updateParams.email = financeAccountingEmail.trim();
195
+ if (parsedEmails.length > 0) {
196
+ updateParams.email = parsedEmails[0]!.toString();
173
197
  }
174
198
 
175
199
  if (line1) {
@@ -956,11 +980,16 @@ export class BillingService extends BaseService {
956
980
  @CaptureSpan()
957
981
  public async sendInvoiceByEmail(
958
982
  invoiceId: string,
959
- recipientEmail?: Email,
983
+ recipientEmails?: Array<Email>,
960
984
  projectId?: ObjectID,
961
985
  ): Promise<void> {
986
+ const recipientsForLog: string = (recipientEmails || [])
987
+ .map((e: Email): string => {
988
+ return e.toString();
989
+ })
990
+ .join(", ");
962
991
  logger.debug(
963
- `[Invoice Email] sendInvoiceByEmail called for invoice: ${invoiceId}, recipientEmail: ${recipientEmail?.toString()}`,
992
+ `[Invoice Email] sendInvoiceByEmail called for invoice: ${invoiceId}, recipients: ${recipientsForLog}`,
964
993
  );
965
994
 
966
995
  if (!this.isBillingEnabled()) {
@@ -986,13 +1015,22 @@ export class BillingService extends BaseService {
986
1015
  return;
987
1016
  }
988
1017
 
989
- // Determine recipient email
990
- let toEmail: Email | undefined = recipientEmail;
991
- if (!toEmail && stripeInvoice.customer_email) {
992
- toEmail = new Email(stripeInvoice.customer_email);
1018
+ /*
1019
+ * Determine recipient list. Fall back to the invoice's customer email when no
1020
+ * explicit recipients were supplied by the caller.
1021
+ */
1022
+ let toEmails: Array<Email> = recipientEmails || [];
1023
+ if (toEmails.length === 0 && stripeInvoice.customer_email) {
1024
+ try {
1025
+ toEmails = Email.parseList(stripeInvoice.customer_email);
1026
+ } catch (err) {
1027
+ logger.error(
1028
+ `[Invoice Email] Failed to parse Stripe customer_email "${stripeInvoice.customer_email}" for invoice ${invoiceId}: ${err}`,
1029
+ );
1030
+ }
993
1031
  }
994
1032
 
995
- if (!toEmail) {
1033
+ if (toEmails.length === 0) {
996
1034
  logger.error(
997
1035
  `[Invoice Email] No recipient email found for invoice ${invoiceId}`,
998
1036
  { projectId: projectId?.toString() } as LogAttributes,
@@ -1021,33 +1059,41 @@ export class BillingService extends BaseService {
1021
1059
  dashboardLink = `${DashboardClientUrl.toString()}/dashboard/${projectId.toString()}/settings/billing`;
1022
1060
  }
1023
1061
 
1024
- logger.debug(
1025
- `[Invoice Email] Sending invoice email to ${toEmail.toString()} - Invoice #${invoiceNumber}, Amount: ${amount}`,
1026
- );
1027
-
1028
- // Send email via OneUptime MailService
1029
- await MailService.sendMail(
1030
- {
1031
- toEmail: toEmail,
1032
- templateType: EmailTemplateType.Invoice,
1033
- vars: {
1034
- invoiceNumber: invoiceNumber,
1035
- invoiceDate: invoiceDate,
1036
- amount: amount,
1037
- description: description || "",
1038
- invoicePdfUrl: invoicePdfUrl || "",
1039
- dashboardLink: dashboardLink || "",
1040
- },
1041
- subject: `Invoice #${invoiceNumber} from OneUptime`,
1042
- },
1043
- {
1044
- projectId: projectId,
1045
- },
1046
- );
1062
+ for (const toEmail of toEmails) {
1063
+ logger.debug(
1064
+ `[Invoice Email] Sending invoice email to ${toEmail.toString()} - Invoice #${invoiceNumber}, Amount: ${amount}`,
1065
+ );
1066
+ try {
1067
+ await MailService.sendMail(
1068
+ {
1069
+ toEmail: toEmail,
1070
+ templateType: EmailTemplateType.Invoice,
1071
+ vars: {
1072
+ invoiceNumber: invoiceNumber,
1073
+ invoiceDate: invoiceDate,
1074
+ amount: amount,
1075
+ description: description || "",
1076
+ invoicePdfUrl: invoicePdfUrl || "",
1077
+ dashboardLink: dashboardLink || "",
1078
+ },
1079
+ subject: `Invoice #${invoiceNumber} from OneUptime`,
1080
+ },
1081
+ {
1082
+ projectId: projectId,
1083
+ },
1084
+ );
1047
1085
 
1048
- logger.debug(
1049
- `[Invoice Email] Successfully sent invoice ${invoiceId} email to ${toEmail.toString()}`,
1050
- );
1086
+ logger.debug(
1087
+ `[Invoice Email] Successfully sent invoice ${invoiceId} email to ${toEmail.toString()}`,
1088
+ );
1089
+ } catch (perRecipientErr) {
1090
+ // Keep going for the remaining recipients if one fails.
1091
+ logger.error(
1092
+ `[Invoice Email] Failed to send invoice ${invoiceId} to ${toEmail.toString()}: ${perRecipientErr}`,
1093
+ { projectId: projectId?.toString() } as LogAttributes,
1094
+ );
1095
+ }
1096
+ }
1051
1097
  } catch (err) {
1052
1098
  logger.error(
1053
1099
  `[Invoice Email] Failed to send invoice ${invoiceId} by email: ${err}`,
@@ -1108,16 +1154,21 @@ export class BillingService extends BaseService {
1108
1154
  amountInUsd: number,
1109
1155
  options?: {
1110
1156
  sendInvoiceByEmail?: boolean | undefined;
1111
- recipientEmail?: Email | undefined;
1157
+ recipientEmails?: Array<Email> | undefined;
1112
1158
  projectId?: ObjectID | undefined;
1113
1159
  },
1114
1160
  ): Promise<void> {
1115
1161
  const sendInvoiceByEmail: boolean = options?.sendInvoiceByEmail || false;
1116
- const recipientEmail: Email | undefined = options?.recipientEmail;
1162
+ const recipientEmails: Array<Email> | undefined = options?.recipientEmails;
1117
1163
  const projectId: ObjectID | undefined = options?.projectId;
1118
1164
 
1165
+ const recipientsForLog: string = (recipientEmails || [])
1166
+ .map((e: Email): string => {
1167
+ return e.toString();
1168
+ })
1169
+ .join(", ");
1119
1170
  logger.debug(
1120
- `[Invoice Email] generateInvoiceAndChargeCustomer called - customer: ${customerId}, amount: $${amountInUsd}, sendInvoiceByEmail: ${sendInvoiceByEmail}, recipientEmail: ${recipientEmail?.toString()}, projectId: ${projectId?.toString()}`,
1171
+ `[Invoice Email] generateInvoiceAndChargeCustomer called - customer: ${customerId}, amount: $${amountInUsd}, sendInvoiceByEmail: ${sendInvoiceByEmail}, recipients: ${recipientsForLog}, projectId: ${projectId?.toString()}`,
1121
1172
  );
1122
1173
 
1123
1174
  const invoice: Stripe.Invoice = await this.stripe.invoices.create({
@@ -1161,7 +1212,7 @@ export class BillingService extends BaseService {
1161
1212
  logger.debug(
1162
1213
  `[Invoice Email] sendInvoiceByEmail is true, sending invoice ${invoice.id} by email`,
1163
1214
  );
1164
- await this.sendInvoiceByEmail(invoice.id!, recipientEmail, projectId);
1215
+ await this.sendInvoiceByEmail(invoice.id!, recipientEmails, projectId);
1165
1216
  } else {
1166
1217
  logger.debug(
1167
1218
  `[Invoice Email] sendInvoiceByEmail is false, skipping email for invoice ${invoice.id}`,
@@ -1356,16 +1407,33 @@ export class BillingService extends BaseService {
1356
1407
  },
1357
1408
  });
1358
1409
 
1359
- let recipientEmail: Email | undefined = undefined;
1410
+ let recipientEmails: Array<Email> | undefined = undefined;
1360
1411
  let projectId: ObjectID | undefined = undefined;
1361
1412
 
1362
1413
  if (project) {
1363
1414
  projectId = project.id || undefined;
1364
1415
  if (project.financeAccountingEmail) {
1365
- recipientEmail = new Email(project.financeAccountingEmail);
1416
+ try {
1417
+ recipientEmails = Email.parseList(
1418
+ project.financeAccountingEmail,
1419
+ );
1420
+ } catch (parseErr) {
1421
+ /*
1422
+ * Log and fall back to Stripe customer email rather than dropping
1423
+ * the invoice delivery entirely due to a malformed address.
1424
+ */
1425
+ logger.error(
1426
+ `[Invoice Email] Failed to parse financeAccountingEmail for project ${projectId?.toString()}: ${parseErr}`,
1427
+ );
1428
+ }
1366
1429
  }
1430
+ const parsedForLog: string = (recipientEmails || [])
1431
+ .map((e: Email): string => {
1432
+ return e.toString();
1433
+ })
1434
+ .join(", ");
1367
1435
  logger.debug(
1368
- `[Invoice Email] Found project ${projectId?.toString()}, financeAccountingEmail: ${recipientEmail?.toString()}`,
1436
+ `[Invoice Email] Found project ${projectId?.toString()}, financeAccountingEmail recipients: ${parsedForLog}`,
1369
1437
  );
1370
1438
  } else {
1371
1439
  logger.debug(
@@ -1376,7 +1444,7 @@ export class BillingService extends BaseService {
1376
1444
  logger.debug(
1377
1445
  `[Invoice Email] Sending invoice ${invoice.id} by email`,
1378
1446
  );
1379
- await this.sendInvoiceByEmail(invoice.id, recipientEmail, projectId);
1447
+ await this.sendInvoiceByEmail(invoice.id, recipientEmails, projectId);
1380
1448
  logger.debug(
1381
1449
  `[Invoice Email] Successfully processed invoice.finalized - sent invoice ${invoice.id} by email`,
1382
1450
  );
@@ -69,6 +69,7 @@ import { FindWhere } from "../../Types/BaseDatabase/Query";
69
69
  import Realtime from "../Utils/Realtime";
70
70
  import ModelEventType from "../../Types/Realtime/ModelEventType";
71
71
  import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
72
+ import type AuditLogServiceType from "./AuditLogService";
72
73
 
73
74
  class DatabaseService<TBaseModel extends BaseModel> extends BaseService {
74
75
  public modelType!: { new (): TBaseModel };
@@ -782,14 +783,9 @@ class DatabaseService<TBaseModel extends BaseModel> extends BaseService {
782
783
  * which extend DatabaseService). A top-level import leaves
783
784
  * DatabaseService undefined at class-extension time for subclasses.
784
785
  */
785
- // eslint-disable-next-line @typescript-eslint/no-var-requires
786
- const auditLogService: {
787
- recordCreate: (data: {
788
- model: TBaseModel;
789
- createdItem: TBaseModel;
790
- props: DatabaseCommonInteractionProps;
791
- }) => Promise<void>;
792
- } = require("./AuditLogService").default;
786
+ const auditLogService: typeof AuditLogServiceType =
787
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires
788
+ require("./AuditLogService").default;
793
789
  await auditLogService.recordCreate({
794
790
  model: this.getModel(),
795
791
  createdItem: createBy.data,
@@ -1237,15 +1233,9 @@ class DatabaseService<TBaseModel extends BaseModel> extends BaseService {
1237
1233
  }
1238
1234
 
1239
1235
  if (this.getModel().enableAuditLogOn?.delete && items.length > 0) {
1240
- // eslint-disable-next-line @typescript-eslint/no-var-requires
1241
- const auditLogService: {
1242
- recordDelete: (args: {
1243
- model: TBaseModel;
1244
- deletedItem: TBaseModel;
1245
- itemId: ObjectID;
1246
- props: DatabaseCommonInteractionProps;
1247
- }) => Promise<void>;
1248
- } = require("./AuditLogService").default;
1236
+ const auditLogService: typeof AuditLogServiceType =
1237
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires
1238
+ require("./AuditLogService").default;
1249
1239
  for (const item of items) {
1250
1240
  if (item.id) {
1251
1241
  await auditLogService.recordDelete({
@@ -1667,16 +1657,9 @@ class DatabaseService<TBaseModel extends BaseModel> extends BaseService {
1667
1657
  !this.hasSameValues({ item, updatedItem }) &&
1668
1658
  item.id
1669
1659
  ) {
1670
- // eslint-disable-next-line @typescript-eslint/no-var-requires
1671
- const auditLogService: {
1672
- recordUpdate: (args: {
1673
- model: TBaseModel;
1674
- before: TBaseModel;
1675
- updatedFields: JSONObject;
1676
- itemId: ObjectID;
1677
- props: DatabaseCommonInteractionProps;
1678
- }) => Promise<void>;
1679
- } = require("./AuditLogService").default;
1660
+ const auditLogService: typeof AuditLogServiceType =
1661
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires
1662
+ require("./AuditLogService").default;
1680
1663
  await auditLogService.recordUpdate({
1681
1664
  model: this.getModel(),
1682
1665
  before: item,
@@ -113,7 +113,10 @@ function buildDegradedPod(row: {
113
113
  const scanForReason: (
114
114
  list: Array<Record<string, unknown>>,
115
115
  targetState: string,
116
- ) => { reason: string; message: string } | null = (list, targetState) => {
116
+ ) => { reason: string; message: string } | null = (
117
+ list: Array<Record<string, unknown>>,
118
+ targetState: string,
119
+ ) => {
117
120
  for (const cs of list) {
118
121
  if (cs["state"] !== targetState) {
119
122
  continue;
@@ -144,8 +147,10 @@ function buildDegradedPod(row: {
144
147
  reason = hit.reason;
145
148
  message = hit.message;
146
149
  } else {
147
- // Fall back to the pod-level reason/message fields set by the scheduler
148
- // (e.g. "Unschedulable" with "0/3 nodes are available: ...").
150
+ /*
151
+ * Fall back to the pod-level reason/message fields set by the scheduler
152
+ * (e.g. "Unschedulable" with "0/3 nodes are available: ...").
153
+ */
149
154
  const topReason: unknown = status["reason"];
150
155
  const topMessage: unknown = status["message"];
151
156
  if (typeof topReason === "string") {
@@ -211,7 +216,9 @@ function buildDegradedNode(row: {
211
216
 
212
217
  const findCondition: (
213
218
  predicate: (c: Record<string, unknown>) => boolean,
214
- ) => Record<string, unknown> | null = (predicate) => {
219
+ ) => Record<string, unknown> | null = (
220
+ predicate: (c: Record<string, unknown>) => boolean,
221
+ ) => {
215
222
  for (const c of conditions) {
216
223
  if (predicate(c)) {
217
224
  return c;
@@ -591,12 +598,28 @@ export class Service extends DatabaseService<Model> {
591
598
  const containerCount: number =
592
599
  parseInt(containerRows[0]?.total || "0", 10) || 0;
593
600
 
594
- const degradedPods: Array<DegradedPod> = degradedPodRows.map((row) => {
595
- return buildDegradedPod(row);
596
- });
597
- const degradedNodes: Array<DegradedNode> = degradedNodeRows.map((row) => {
598
- return buildDegradedNode(row);
599
- });
601
+ const degradedPods: Array<DegradedPod> = degradedPodRows.map(
602
+ (row: {
603
+ name: string;
604
+ namespaceKey: string;
605
+ phase: string | null;
606
+ status: unknown;
607
+ }) => {
608
+ return buildDegradedPod(row);
609
+ },
610
+ );
611
+ const degradedNodes: Array<DegradedNode> = degradedNodeRows.map(
612
+ (row: {
613
+ name: string;
614
+ isReady: boolean | null;
615
+ hasMemoryPressure: boolean | null;
616
+ hasDiskPressure: boolean | null;
617
+ hasPidPressure: boolean | null;
618
+ status: unknown;
619
+ }) => {
620
+ return buildDegradedNode(row);
621
+ },
622
+ );
600
623
 
601
624
  return {
602
625
  countsByKind,
@@ -90,8 +90,8 @@ export class NotificationService extends BaseService {
90
90
  amountInUSD,
91
91
  {
92
92
  sendInvoiceByEmail: project.sendInvoicesByEmail || false,
93
- recipientEmail: project.financeAccountingEmail
94
- ? new Email(project.financeAccountingEmail)
93
+ recipientEmails: project.financeAccountingEmail
94
+ ? Email.parseList(project.financeAccountingEmail)
95
95
  : undefined,
96
96
  projectId: project.id || undefined,
97
97
  },