@oneuptime/common 12.0.19 → 12.0.20

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 (66) hide show
  1. package/Server/API/EnterpriseLicenseAPI.ts +94 -19
  2. package/Server/EnvironmentConfig.ts +17 -0
  3. package/Server/Services/ProjectService.ts +104 -0
  4. package/Server/Utils/Response.ts +2 -2
  5. package/Server/Utils/StartServer.ts +2 -2
  6. package/Server/Views/Partials/AnalyticsConsent.ejs +533 -0
  7. package/Tests/App/Dashboard/AlertEpisodeViewFields.test.tsx +465 -0
  8. package/Tests/App/Dashboard/DashboardChartWidgetZoom.test.tsx +302 -0
  9. package/Tests/App/Dashboard/DiscoveryScanWizardValidation.test.tsx +408 -0
  10. package/Tests/App/Dashboard/IncidentEpisodeViewFields.test.tsx +474 -0
  11. package/Tests/App/Dashboard/MetricSeriesInvestigateMenu.test.tsx +368 -0
  12. package/Tests/Server/API/EnterpriseLicenseReportUserCount.test.ts +763 -0
  13. package/Tests/Server/Services/ProjectServiceChangePlan.test.ts +138 -0
  14. package/Tests/Server/Services/ProjectServiceCreateSubscriptionStarted.test.ts +516 -0
  15. package/Tests/Server/Utils/ResponseRenderAnalytics.test.ts +53 -0
  16. package/Tests/Server/Views/AnalyticsConsentPartial.test.ts +241 -0
  17. package/Tests/UI/Components/Charts/ChartTooltipSorted.test.tsx +129 -0
  18. package/Tests/UI/Components/Charts/TooltipEntries.test.ts +155 -0
  19. package/Tests/UI/Components/Detail/DetailFieldErrors.test.tsx +347 -0
  20. package/Tests/UI/Components/Forms/BasicFormStepValidation.test.tsx +392 -0
  21. package/Tests/UI/Components/Forms/ValidationFormSteps.test.ts +549 -0
  22. package/Tests/Utils/EnterpriseLicenseSync.test.ts +562 -0
  23. package/Types/Analytics/RevenueEvent.ts +1 -0
  24. package/Types/Marketing/MarketingEvent.ts +49 -6
  25. package/UI/Components/Charts/Area/AreaChart.tsx +8 -1
  26. package/UI/Components/Charts/Bar/BarChart.tsx +8 -1
  27. package/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.tsx +19 -4
  28. package/UI/Components/Charts/ChartLibrary/BarChart/BarChart.tsx +20 -2
  29. package/UI/Components/Charts/ChartLibrary/LineChart/LineChart.tsx +19 -4
  30. package/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.ts +105 -0
  31. package/UI/Components/Charts/Line/LineChart.tsx +8 -1
  32. package/UI/Components/Detail/Detail.tsx +18 -1
  33. package/Utils/EnterpriseLicense/EnterpriseLicenseSync.ts +244 -0
  34. package/build/dist/Server/API/EnterpriseLicenseAPI.js +66 -12
  35. package/build/dist/Server/API/EnterpriseLicenseAPI.js.map +1 -1
  36. package/build/dist/Server/EnvironmentConfig.js +15 -0
  37. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  38. package/build/dist/Server/Services/ProjectService.js +88 -0
  39. package/build/dist/Server/Services/ProjectService.js.map +1 -1
  40. package/build/dist/Server/Utils/Response.js +2 -2
  41. package/build/dist/Server/Utils/Response.js.map +1 -1
  42. package/build/dist/Server/Utils/StartServer.js +2 -2
  43. package/build/dist/Server/Utils/StartServer.js.map +1 -1
  44. package/build/dist/Types/Analytics/RevenueEvent.js +1 -0
  45. package/build/dist/Types/Analytics/RevenueEvent.js.map +1 -1
  46. package/build/dist/Types/Marketing/MarketingEvent.js +40 -1
  47. package/build/dist/Types/Marketing/MarketingEvent.js.map +1 -1
  48. package/build/dist/UI/Components/Charts/Area/AreaChart.js +9 -1
  49. package/build/dist/UI/Components/Charts/Area/AreaChart.js.map +1 -1
  50. package/build/dist/UI/Components/Charts/Bar/BarChart.js +9 -1
  51. package/build/dist/UI/Components/Charts/Bar/BarChart.js.map +1 -1
  52. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js +22 -13
  53. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js.map +1 -1
  54. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js +27 -15
  55. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js.map +1 -1
  56. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js +27 -18
  57. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js.map +1 -1
  58. package/build/dist/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.js +71 -0
  59. package/build/dist/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.js.map +1 -0
  60. package/build/dist/UI/Components/Charts/Line/LineChart.js +9 -1
  61. package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
  62. package/build/dist/UI/Components/Detail/Detail.js +17 -1
  63. package/build/dist/UI/Components/Detail/Detail.js.map +1 -1
  64. package/build/dist/Utils/EnterpriseLicense/EnterpriseLicenseSync.js +174 -0
  65. package/build/dist/Utils/EnterpriseLicense/EnterpriseLicenseSync.js.map +1 -0
  66. package/package.json +1 -1
@@ -1,7 +1,6 @@
1
1
  import EnterpriseLicense from "../../Models/DatabaseModels/EnterpriseLicense";
2
2
  import EnterpriseLicenseInstance from "../../Models/DatabaseModels/EnterpriseLicenseInstance";
3
3
  import BadDataException from "../../Types/Exception/BadDataException";
4
- import { JSONObject } from "../../Types/JSON";
5
4
  import PartialEntity from "../../Types/Database/PartialEntity";
6
5
  import EnterpriseLicenseInstanceSummary from "../../Types/EnterpriseLicense/EnterpriseLicenseInstanceSummary";
7
6
  import EnterpriseLicenseUsageUtil from "../../Utils/EnterpriseLicense/EnterpriseLicenseUsage";
@@ -26,6 +25,20 @@ import {
26
25
  import BaseAPI from "./BaseAPI";
27
26
  // import { Host } from "../EnvironmentConfig";
28
27
 
28
+ /*
29
+ * The license state every installation of a license key mirrors locally.
30
+ * Returned by both /validate and /report-user-count so that the daily report
31
+ * keeps a self-hosted installation current on its own.
32
+ */
33
+ export interface LicenseTerms {
34
+ companyName: string;
35
+ // ISO 8601, or null for a license with no expiry set.
36
+ expiresAt: string | null;
37
+ licenseKey: string;
38
+ // Null means the license carries no seat limit.
39
+ userLimit: number | null;
40
+ }
41
+
29
42
  export interface LicenseInstanceUpsert {
30
43
  licenseId: ObjectID;
31
44
  instanceId: string;
@@ -137,24 +150,15 @@ export default class EnterpriseLicenseAPI extends BaseAPI<
137
150
  const instances: Array<EnterpriseLicenseInstance> =
138
151
  await this.findLicenseInstances(license.id!);
139
152
 
140
- const payload: JSONObject = {
141
- companyName: license.companyName || "",
142
- expiresAt: license.expiresAt.toISOString(),
143
- licenseKey: license.licenseKey || "",
144
- userLimit:
145
- typeof license.userLimit === "number" ? license.userLimit : null,
146
- };
147
-
148
- const token: string = JSONWebToken.signJsonPayload(
149
- payload,
150
- Math.max(secondsUntilExpiry, 1),
151
- );
153
+ const terms: LicenseTerms = this.getLicenseTerms(license);
154
+
155
+ const token: string | null = this.signLicenseToken(license);
152
156
 
153
157
  return Response.sendJsonObjectResponse(req, res, {
154
- companyName: payload["companyName"] as string,
155
- expiresAt: payload["expiresAt"] as string,
156
- licenseKey: payload["licenseKey"] as string,
157
- userLimit: payload["userLimit"],
158
+ companyName: terms.companyName,
159
+ expiresAt: terms.expiresAt,
160
+ licenseKey: terms.licenseKey,
161
+ userLimit: terms.userLimit,
158
162
  currentUserCount:
159
163
  typeof license.currentUserCount === "number"
160
164
  ? license.currentUserCount
@@ -202,8 +206,19 @@ export default class EnterpriseLicenseAPI extends BaseAPI<
202
206
  query: {
203
207
  licenseKey: licenseKey,
204
208
  },
209
+ /*
210
+ * The full set of terms, not just the usage columns: this
211
+ * response is what a self-hosted installation mirrors into its
212
+ * own GlobalConfig every day, so anything it is missing here is
213
+ * a field the customer keeps the stale value of until somebody
214
+ * re-types the license key by hand. Same indexed lookup either
215
+ * way.
216
+ */
205
217
  select: {
206
218
  _id: true,
219
+ companyName: true,
220
+ expiresAt: true,
221
+ licenseKey: true,
207
222
  userLimit: true,
208
223
  isEvaluationLicense: true,
209
224
  },
@@ -282,13 +297,25 @@ export default class EnterpriseLicenseAPI extends BaseAPI<
282
297
  });
283
298
  }
284
299
 
300
+ const terms: LicenseTerms = this.getLicenseTerms(license);
301
+
285
302
  return Response.sendJsonObjectResponse(req, res, {
303
+ companyName: terms.companyName,
304
+ expiresAt: terms.expiresAt,
305
+ licenseKey: terms.licenseKey,
306
+ userLimit: terms.userLimit,
286
307
  currentUserCount: currentUserCount,
287
308
  userCountUpdatedAt: reportedAt.toISOString(),
288
- userLimit:
289
- typeof license.userLimit === "number" ? license.userLimit : null,
290
309
  isEvaluationLicense: Boolean(license.isEvaluationLicense),
291
310
  instances: this.getInstanceSummaries(instances),
311
+ /*
312
+ * Null once the license has expired. Unlike /validate this route
313
+ * does not reject an expired license - the instance is still
314
+ * expected to report, and the expiry notification emails are built
315
+ * from what it reports - but it must not keep being handed a fresh
316
+ * token, and the expiresAt above is what tells it the truth.
317
+ */
318
+ token: this.signLicenseToken(license),
292
319
  });
293
320
  } catch (err) {
294
321
  next(err);
@@ -297,6 +324,54 @@ export default class EnterpriseLicenseAPI extends BaseAPI<
297
324
  );
298
325
  }
299
326
 
327
+ /*
328
+ * The license terms a self-hosted installation mirrors locally. Built in one
329
+ * place so /validate and /report-user-count cannot drift: the seat limit was
330
+ * missing from what an instance could refresh for exactly as long as the two
331
+ * responses were written out by hand, separately.
332
+ */
333
+ private getLicenseTerms(license: EnterpriseLicense): LicenseTerms {
334
+ return {
335
+ companyName: license.companyName || "",
336
+ expiresAt: license.expiresAt ? license.expiresAt.toISOString() : null,
337
+ licenseKey: license.licenseKey || "",
338
+ userLimit:
339
+ typeof license.userLimit === "number" ? license.userLimit : null,
340
+ };
341
+ }
342
+
343
+ /*
344
+ * Signed with the license server's own secret, so it is opaque to the
345
+ * installation holding it - it is the proof that oneuptime.com recognized
346
+ * the key, and the instance only checks that it exists. An expired license
347
+ * gets none, which is why this returns null rather than a short-lived token.
348
+ */
349
+ private signLicenseToken(license: EnterpriseLicense): string | null {
350
+ if (!license.expiresAt) {
351
+ return null;
352
+ }
353
+
354
+ const secondsUntilExpiry: number = Math.floor(
355
+ (license.expiresAt.getTime() - Date.now()) / 1000,
356
+ );
357
+
358
+ if (secondsUntilExpiry <= 0) {
359
+ return null;
360
+ }
361
+
362
+ const terms: LicenseTerms = this.getLicenseTerms(license);
363
+
364
+ return JSONWebToken.signJsonPayload(
365
+ {
366
+ companyName: terms.companyName,
367
+ expiresAt: terms.expiresAt,
368
+ licenseKey: terms.licenseKey,
369
+ userLimit: terms.userLimit,
370
+ },
371
+ Math.max(secondsUntilExpiry, 1),
372
+ );
373
+ }
374
+
300
375
  private parseShortText(value: unknown): string {
301
376
  if (typeof value !== "string") {
302
377
  return "";
@@ -368,6 +368,23 @@ export const SubscriptionPlans: Array<SubscriptionPlan> =
368
368
  export const AnalyticsKey: string = process.env["ANALYTICS_KEY"] || "";
369
369
  export const AnalyticsHost: string = process.env["ANALYTICS_HOST"] || "";
370
370
 
371
+ /*
372
+ * Whether this deployment loads the Google Tag Manager container and pushes
373
+ * marketing conversions into it.
374
+ *
375
+ * Billing being enabled is necessary — a self-hosted install must never load
376
+ * googletagmanager.com — but it is not sufficient. CI runs the SaaS end-to-end
377
+ * suite with BILLING_ENABLED forced to true, and those scripted registrations
378
+ * fire a real `sign_up` into the production container: the key-events trigger
379
+ * matches on the event name alone, with no hostname condition, and GA4 data
380
+ * filters do not apply retroactively. This is the second condition, so an
381
+ * environment can keep billing on and still stay out of the container.
382
+ *
383
+ * Defaults to on, so the hosted product needs no deploy change.
384
+ */
385
+ export const GoogleTagManagerEnabled: boolean =
386
+ IsBillingEnabled && process.env["GOOGLE_TAG_MANAGER_ENABLED"] !== "false";
387
+
371
388
  /*
372
389
  * Shared secret Cal.com signs booking webhooks with (App/API/CalWebhook.ts).
373
390
  * Server-only — must never be added to FRONTEND_ENV_ALLOW_LIST. Empty means
@@ -897,6 +897,98 @@ These are no longer recorded against the project and have to be cancelled by han
897
897
  );
898
898
  }
899
899
 
900
+ /*
901
+ * The first-subscription conversion.
902
+ *
903
+ * A project created directly on a paid plan never passes through changePlan,
904
+ * so the plan-change pass below never sees it — and could not report it if
905
+ * it did, because direction is decided by plan ORDER and a first plan has no
906
+ * previous tier to compare against. That left the most bid-worthy self-serve
907
+ * conversion there is emitting nothing at all: a visitor could click an ad,
908
+ * sign up, pick Growth and pay, and no channel carried a single event.
909
+ *
910
+ * It is its own event rather than an upgrade with an empty old_plan because
911
+ * this is new business and an upgrade is expansion. Mixing them is a
912
+ * distinction nothing downstream could restore.
913
+ *
914
+ * Free projects reach here too — a project on the free plan is still
915
+ * subscribed at the payment provider — so the paid check is what keeps this
916
+ * a revenue event.
917
+ */
918
+ private captureSubscriptionStartedAnalytics(data: {
919
+ project: Model;
920
+ plan: SubscriptionPlan;
921
+ planId: string;
922
+ seats: number;
923
+ }): void {
924
+ if (!data.project.createdOwnerEmail) {
925
+ return;
926
+ }
927
+
928
+ // Per-month amount, or null when unknown (custom pricing / unknown plan).
929
+ const monthlyAmountInUSD: number | null = data.plan.isCustomPricing()
930
+ ? null
931
+ : data.plan.getYearlyPlanId() === data.planId
932
+ ? data.plan.getYearlySubscriptionAmountInUSD()
933
+ : data.plan.getMonthlySubscriptionAmountInUSD();
934
+
935
+ const planIsPaid: boolean =
936
+ data.plan.isCustomPricing() ||
937
+ (monthlyAmountInUSD !== null && monthlyAmountInUSD > 0);
938
+
939
+ if (!planIsPaid) {
940
+ return;
941
+ }
942
+
943
+ const properties: JSONObject = {
944
+ project_id: data.project.id?.toString() || "",
945
+ new_plan: data.plan.getName(),
946
+ seats: data.seats,
947
+ /*
948
+ * Always true. The project went straight from not existing to paying,
949
+ * which is the acquisition this event exists to report.
950
+ */
951
+ is_paid_conversion: true,
952
+ has_custom_pricing: data.plan.isCustomPricing(),
953
+ utm_source: data.project.utmSource || "",
954
+ utm_medium: data.project.utmMedium || "",
955
+ utm_campaign: data.project.utmCampaign || "",
956
+ utm_term: data.project.utmTerm || "",
957
+ utm_content: data.project.utmContent || "",
958
+ click_ids: data.project.clickIds || {},
959
+ };
960
+
961
+ if (monthlyAmountInUSD !== null) {
962
+ properties["new_monthly_amount_in_usd"] = monthlyAmountInUSD;
963
+ // Value for ROAS reporting: monthly recurring revenue at the start.
964
+ properties["value"] = monthlyAmountInUSD * data.seats;
965
+ properties["currency"] = "USD";
966
+ }
967
+
968
+ ProductAnalytics.capture({
969
+ event: "server/subscription_started",
970
+ distinctId: data.project.createdOwnerEmail.toString(),
971
+ properties: properties,
972
+ });
973
+
974
+ MarketingEventUtil.emitInBackground(
975
+ MarketingEventUtil.buildEvent({
976
+ eventType: MarketingEventType.SubscriptionStarted,
977
+ /*
978
+ * Naturally unique: a project has exactly one first subscription, so a
979
+ * queue retry carries the identical id and the receiver dedupes it.
980
+ * Unlike a plan change, this needs no timestamp to separate it from
981
+ * the next one — there is no next one.
982
+ */
983
+ eventId: `${MarketingEventType.SubscriptionStarted}:${data.project.id?.toString()}`,
984
+ occurredAt: data.project.createdAt || new Date(),
985
+ email: data.project.createdOwnerEmail.toString(),
986
+ attributionSource: data.project,
987
+ data: properties,
988
+ }),
989
+ );
990
+ }
991
+
900
992
  /*
901
993
  * The paid-conversion event for ad platforms: fires server-side (immune to
902
994
  * ad blockers) whenever a project's subscription plan changes, with enough
@@ -1286,6 +1378,18 @@ These are no longer recorded against the project and have to be cancelled by han
1286
1378
  },
1287
1379
  });
1288
1380
 
1381
+ /*
1382
+ * Raised after the row is written, mirroring changePlan: the write is
1383
+ * what makes the subscription real to OneUptime, and the conversion
1384
+ * describes a subscription that exists.
1385
+ */
1386
+ this.captureSubscriptionStartedAnalytics({
1387
+ project: createdItem,
1388
+ plan: plan,
1389
+ planId: createdItem.paymentProviderPlanId!,
1390
+ seats: 1,
1391
+ });
1392
+
1289
1393
  // mark the promo code as used it it exists.
1290
1394
 
1291
1395
  if (createdItem.paymentProviderPromoCode) {
@@ -25,7 +25,7 @@ import MimeType from "../../Types/File/MimeType";
25
25
  import PositiveNumber from "../../Types/PositiveNumber";
26
26
  import Route from "../../Types/API/Route";
27
27
  import CaptureSpan from "./Telemetry/CaptureSpan";
28
- import { IsBillingEnabled } from "../EnvironmentConfig";
28
+ import { GoogleTagManagerEnabled } from "../EnvironmentConfig";
29
29
 
30
30
  /*
31
31
  * Raster image types, which is to say the types that cannot carry script and
@@ -239,7 +239,7 @@ export default class Response {
239
239
  * already use; passing the flag explicitly still wins.
240
240
  */
241
241
  oneUptimeResponse.render(path, {
242
- enableGoogleTagManager: IsBillingEnabled,
242
+ enableGoogleTagManager: GoogleTagManagerEnabled,
243
243
  ...vars,
244
244
  });
245
245
  }
@@ -3,7 +3,7 @@ import CommonAPI from "../API/Index";
3
3
  import { StatusAPIOptions } from "../API/StatusAPI";
4
4
  import {
5
5
  AppVersion,
6
- IsBillingEnabled,
6
+ GoogleTagManagerEnabled,
7
7
  TrustedProxyHops,
8
8
  getFrontendEnvVars,
9
9
  } from "../EnvironmentConfig";
@@ -449,7 +449,7 @@ const init: InitFunction = async (
449
449
  }
450
450
 
451
451
  return res.render(path.resolve(process.cwd(), "views/index.ejs"), {
452
- enableGoogleTagManager: IsBillingEnabled || false,
452
+ enableGoogleTagManager: GoogleTagManagerEnabled,
453
453
  ...variables,
454
454
  });
455
455
  } catch (err) {