@oneuptime/common 10.2.3 → 10.2.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 (80) hide show
  1. package/Models/DatabaseModels/Index.ts +2 -0
  2. package/Models/DatabaseModels/Service.ts +26 -0
  3. package/Models/DatabaseModels/StatusPage.ts +43 -0
  4. package/Models/DatabaseModels/StatusPageOidc.ts +689 -0
  5. package/Server/API/StatusPageAPI.ts +78 -3
  6. package/Server/Infrastructure/Postgres/SchemaMigrations/1778521361934-MigrationName.ts +17 -0
  7. package/Server/Infrastructure/Postgres/SchemaMigrations/1778522070962-AddStatusPageOIDC.ts +63 -0
  8. package/Server/Infrastructure/Postgres/SchemaMigrations/1778582583897-MigrationName.ts +15 -0
  9. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +6 -0
  10. package/Server/Services/Index.ts +2 -0
  11. package/Server/Services/OpenTelemetryIngestService.ts +15 -0
  12. package/Server/Services/ScheduledMaintenanceService.ts +34 -0
  13. package/Server/Services/ServiceService.ts +37 -0
  14. package/Server/Services/StatusPageOidcService.ts +10 -0
  15. package/Server/Services/StatusPageSubscriberService.ts +101 -0
  16. package/Server/Types/Database/QueryHelper.ts +38 -0
  17. package/Server/Types/Database/QueryUtil.ts +77 -0
  18. package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +52 -0
  19. package/Server/Utils/StatusPageSubscriberWebhook.ts +39 -0
  20. package/Types/BaseDatabase/MultiSearch.ts +53 -0
  21. package/Types/Dashboard/DashboardComponents/ComponentArgument.ts +1 -0
  22. package/Types/Dashboard/DashboardComponents/DashboardChartComponent.ts +2 -0
  23. package/Types/JSON.ts +3 -0
  24. package/Types/Permission.ts +46 -0
  25. package/Types/SerializableObjectDictionary.ts +2 -0
  26. package/UI/Components/ModelTable/BaseModelTable.tsx +988 -4
  27. package/Utils/Dashboard/Components/DashboardChartComponent.ts +11 -0
  28. package/build/dist/Models/DatabaseModels/Index.js +2 -0
  29. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  30. package/build/dist/Models/DatabaseModels/Service.js +28 -0
  31. package/build/dist/Models/DatabaseModels/Service.js.map +1 -1
  32. package/build/dist/Models/DatabaseModels/StatusPage.js +45 -0
  33. package/build/dist/Models/DatabaseModels/StatusPage.js.map +1 -1
  34. package/build/dist/Models/DatabaseModels/StatusPageOidc.js +718 -0
  35. package/build/dist/Models/DatabaseModels/StatusPageOidc.js.map +1 -0
  36. package/build/dist/Server/API/StatusPageAPI.js +80 -35
  37. package/build/dist/Server/API/StatusPageAPI.js.map +1 -1
  38. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1778521361934-MigrationName.js +12 -0
  39. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1778521361934-MigrationName.js.map +1 -0
  40. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1778522070962-AddStatusPageOIDC.js +28 -0
  41. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1778522070962-AddStatusPageOIDC.js.map +1 -0
  42. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1778582583897-MigrationName.js +12 -0
  43. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1778582583897-MigrationName.js.map +1 -0
  44. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +6 -0
  45. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  46. package/build/dist/Server/Services/Index.js +2 -0
  47. package/build/dist/Server/Services/Index.js.map +1 -1
  48. package/build/dist/Server/Services/OpenTelemetryIngestService.js +11 -0
  49. package/build/dist/Server/Services/OpenTelemetryIngestService.js.map +1 -1
  50. package/build/dist/Server/Services/ScheduledMaintenanceService.js +31 -2
  51. package/build/dist/Server/Services/ScheduledMaintenanceService.js.map +1 -1
  52. package/build/dist/Server/Services/ServiceService.js +34 -0
  53. package/build/dist/Server/Services/ServiceService.js.map +1 -1
  54. package/build/dist/Server/Services/StatusPageOidcService.js +9 -0
  55. package/build/dist/Server/Services/StatusPageOidcService.js.map +1 -0
  56. package/build/dist/Server/Services/StatusPageSubscriberService.js +99 -4
  57. package/build/dist/Server/Services/StatusPageSubscriberService.js.map +1 -1
  58. package/build/dist/Server/Types/Database/QueryHelper.js +33 -0
  59. package/build/dist/Server/Types/Database/QueryHelper.js.map +1 -1
  60. package/build/dist/Server/Types/Database/QueryUtil.js +64 -0
  61. package/build/dist/Server/Types/Database/QueryUtil.js.map +1 -1
  62. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +44 -0
  63. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
  64. package/build/dist/Server/Utils/StatusPageSubscriberWebhook.js +19 -0
  65. package/build/dist/Server/Utils/StatusPageSubscriberWebhook.js.map +1 -0
  66. package/build/dist/Types/BaseDatabase/MultiSearch.js +44 -0
  67. package/build/dist/Types/BaseDatabase/MultiSearch.js.map +1 -0
  68. package/build/dist/Types/Dashboard/DashboardComponents/ComponentArgument.js +1 -0
  69. package/build/dist/Types/Dashboard/DashboardComponents/ComponentArgument.js.map +1 -1
  70. package/build/dist/Types/JSON.js +1 -0
  71. package/build/dist/Types/JSON.js.map +1 -1
  72. package/build/dist/Types/Permission.js +40 -0
  73. package/build/dist/Types/Permission.js.map +1 -1
  74. package/build/dist/Types/SerializableObjectDictionary.js +2 -0
  75. package/build/dist/Types/SerializableObjectDictionary.js.map +1 -1
  76. package/build/dist/UI/Components/ModelTable/BaseModelTable.js +591 -7
  77. package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
  78. package/build/dist/Utils/Dashboard/Components/DashboardChartComponent.js +9 -0
  79. package/build/dist/Utils/Dashboard/Components/DashboardChartComponent.js.map +1 -1
  80. package/package.json +1 -1
@@ -26,6 +26,7 @@ import StatusPageService, {
26
26
  Service as StatusPageServiceType,
27
27
  } from "../Services/StatusPageService";
28
28
  import StatusPageSsoService from "../Services/StatusPageSsoService";
29
+ import StatusPageOidcService from "../Services/StatusPageOidcService";
29
30
  import StatusPageSubscriberService from "../Services/StatusPageSubscriberService";
30
31
  import Query from "../Types/Database/Query";
31
32
  import QueryHelper from "../Types/Database/QueryHelper";
@@ -81,6 +82,7 @@ import StatusPageHeaderLink from "../../Models/DatabaseModels/StatusPageHeaderLi
81
82
  import StatusPageHistoryChartBarColorRule from "../../Models/DatabaseModels/StatusPageHistoryChartBarColorRule";
82
83
  import StatusPageResource from "../../Models/DatabaseModels/StatusPageResource";
83
84
  import StatusPageSSO from "../../Models/DatabaseModels/StatusPageSso";
85
+ import StatusPageOIDC from "../../Models/DatabaseModels/StatusPageOidc";
84
86
  import StatusPageSubscriber from "../../Models/DatabaseModels/StatusPageSubscriber";
85
87
  import StatusPageEventType from "../../Types/StatusPage/StatusPageEventType";
86
88
  import StatusPageResourceUptimeUtil from "../../Utils/StatusPage/ResourceUptime";
@@ -863,6 +865,7 @@ export default class StatusPageAPI extends BaseAPI<
863
865
  enableEmailSubscribers: true,
864
866
  enableSlackSubscribers: true,
865
867
  enableMicrosoftTeamsSubscribers: true,
868
+ enableWebhookSubscribers: true,
866
869
  enableSmsSubscribers: true,
867
870
  isPublicStatusPage: true,
868
871
  enableMasterPassword: true,
@@ -1094,6 +1097,46 @@ export default class StatusPageAPI extends BaseAPI<
1094
1097
  },
1095
1098
  );
1096
1099
 
1100
+ this.router.post(
1101
+ `${new this.entityType().getCrudApiPath()?.toString()}/oidc/:statusPageId`,
1102
+ UserMiddleware.getUserMiddleware,
1103
+ async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
1104
+ try {
1105
+ const objectId: ObjectID = new ObjectID(
1106
+ req.params["statusPageId"] as string,
1107
+ );
1108
+
1109
+ const oidc: Array<StatusPageOIDC> =
1110
+ await StatusPageOidcService.findBy({
1111
+ query: {
1112
+ statusPageId: objectId,
1113
+ isEnabled: true,
1114
+ },
1115
+ select: {
1116
+ name: true,
1117
+ description: true,
1118
+ _id: true,
1119
+ },
1120
+ limit: LIMIT_PER_PROJECT,
1121
+ skip: 0,
1122
+ props: {
1123
+ isRoot: true,
1124
+ },
1125
+ });
1126
+
1127
+ return Response.sendEntityArrayResponse(
1128
+ req,
1129
+ res,
1130
+ oidc,
1131
+ new PositiveNumber(oidc.length),
1132
+ StatusPageOIDC,
1133
+ );
1134
+ } catch (err) {
1135
+ next(err);
1136
+ }
1137
+ },
1138
+ );
1139
+
1097
1140
  // Get all status page resources for subscriber to subscribe to.
1098
1141
  this.router.post(
1099
1142
  `${new this.entityType()
@@ -3146,6 +3189,7 @@ export default class StatusPageAPI extends BaseAPI<
3146
3189
  enableEmailSubscribers: true,
3147
3190
  enableSlackSubscribers: true,
3148
3191
  enableMicrosoftTeamsSubscribers: true,
3192
+ enableWebhookSubscribers: true,
3149
3193
  enableSmsSubscribers: true,
3150
3194
  allowSubscribersToChooseResources: true,
3151
3195
  allowSubscribersToChooseEventTypes: true,
@@ -3539,6 +3583,7 @@ export default class StatusPageAPI extends BaseAPI<
3539
3583
  enableSmsSubscribers: true,
3540
3584
  enableSlackSubscribers: true,
3541
3585
  enableMicrosoftTeamsSubscribers: true,
3586
+ enableWebhookSubscribers: true,
3542
3587
  allowSubscribersToChooseResources: true,
3543
3588
  allowSubscribersToChooseEventTypes: true,
3544
3589
  showSubscriberPageOnStatusPage: true,
@@ -3623,17 +3668,32 @@ export default class StatusPageAPI extends BaseAPI<
3623
3668
  }
3624
3669
 
3625
3670
  if (
3671
+ req.body.data["subscriberWebhook"] &&
3672
+ !statusPage.enableWebhookSubscribers
3673
+ ) {
3674
+ logger.debug(
3675
+ `Webhook subscribers not enabled for status page with ID: ${objectId}`,
3676
+ getLogAttributesFromRequest(req as any),
3677
+ );
3678
+ throw new BadDataException(
3679
+ "Webhook subscribers not enabled for this status page.",
3680
+ );
3681
+ }
3682
+
3683
+ if (
3684
+ !req.params["subscriberId"] &&
3626
3685
  !req.body.data["subscriberEmail"] &&
3627
3686
  !req.body.data["subscriberPhone"] &&
3628
3687
  !req.body.data["slackWorkspaceName"] &&
3629
- !req.body.data["microsoftTeamsWorkspaceName"]
3688
+ !req.body.data["microsoftTeamsWorkspaceName"] &&
3689
+ !req.body.data["subscriberWebhook"]
3630
3690
  ) {
3631
3691
  logger.debug(
3632
- `No email, phone, slack workspace name, or Microsoft Teams workspace name provided for subscription to status page with ID: ${objectId}`,
3692
+ `No email, phone, slack workspace name, Microsoft Teams workspace name, or webhook URL provided for subscription to status page with ID: ${objectId}`,
3633
3693
  getLogAttributesFromRequest(req as any),
3634
3694
  );
3635
3695
  throw new BadDataException(
3636
- "Email, phone, slack workspace name, or Microsoft Teams workspace name is required to subscribe to this status page.",
3696
+ "Email, phone, slack workspace name, Microsoft Teams workspace name, or webhook URL is required to subscribe to this status page.",
3637
3697
  );
3638
3698
  }
3639
3699
 
@@ -3669,6 +3729,12 @@ export default class StatusPageAPI extends BaseAPI<
3669
3729
  ? (req.body.data["microsoftTeamsWorkspaceName"] as string)
3670
3730
  : undefined;
3671
3731
 
3732
+ const subscriberWebhookUrl: string | undefined = req.body.data[
3733
+ "subscriberWebhook"
3734
+ ]
3735
+ ? (req.body.data["subscriberWebhook"] as string)
3736
+ : undefined;
3737
+
3672
3738
  let statusPageSubscriber: StatusPageSubscriber | null = null;
3673
3739
 
3674
3740
  let isUpdate: boolean = false;
@@ -3761,6 +3827,15 @@ export default class StatusPageAPI extends BaseAPI<
3761
3827
  microsoftTeamsWorkspaceName;
3762
3828
  }
3763
3829
 
3830
+ if (subscriberWebhookUrl) {
3831
+ logger.debug(
3832
+ `Setting subscriber webhook URL: ${subscriberWebhookUrl}`,
3833
+ getLogAttributesFromRequest(req as any),
3834
+ );
3835
+ statusPageSubscriber.subscriberWebhook =
3836
+ URL.fromString(subscriberWebhookUrl);
3837
+ }
3838
+
3764
3839
  if (
3765
3840
  !isUpdate &&
3766
3841
  req.body.data["statusPageResources"] &&
@@ -0,0 +1,17 @@
1
+ import { MigrationInterface, QueryRunner } from "typeorm";
2
+
3
+ export class MigrationName1778521361934 implements MigrationInterface {
4
+ public name = "MigrationName1778521361934";
5
+
6
+ public async up(queryRunner: QueryRunner): Promise<void> {
7
+ await queryRunner.query(
8
+ `ALTER TABLE "StatusPage" ADD "enableWebhookSubscribers" boolean NOT NULL DEFAULT false`,
9
+ );
10
+ }
11
+
12
+ public async down(queryRunner: QueryRunner): Promise<void> {
13
+ await queryRunner.query(
14
+ `ALTER TABLE "StatusPage" DROP COLUMN "enableWebhookSubscribers"`,
15
+ );
16
+ }
17
+ }
@@ -0,0 +1,63 @@
1
+ import { MigrationInterface, QueryRunner } from "typeorm";
2
+
3
+ export class AddStatusPageOIDC1778522070962 implements MigrationInterface {
4
+ public name = "AddStatusPageOIDC1778522070962";
5
+
6
+ public async up(queryRunner: QueryRunner): Promise<void> {
7
+ await queryRunner.query(
8
+ `CREATE TABLE "StatusPageOIDC" ("_id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "deletedAt" TIMESTAMP WITH TIME ZONE, "version" integer NOT NULL, "projectId" uuid NOT NULL, "statusPageId" uuid NOT NULL, "name" character varying(100) NOT NULL, "description" character varying NOT NULL, "discoveryURL" text NOT NULL, "issuerURL" text NOT NULL, "clientId" character varying(100) NOT NULL, "clientSecret" character varying NOT NULL, "scopes" character varying(100) NOT NULL, "emailClaimName" character varying(100) NOT NULL, "nameClaimName" character varying(100), "createdByUserId" uuid, "deletedByUserId" uuid, "isEnabled" boolean NOT NULL DEFAULT false, "isTested" boolean NOT NULL DEFAULT false, CONSTRAINT "PK_d48cc7131b1383a0c293e1142f4" PRIMARY KEY ("_id"))`,
9
+ );
10
+ await queryRunner.query(
11
+ `CREATE INDEX "IDX_da08a1ff289745767b094724f7" ON "StatusPageOIDC" ("projectId") `,
12
+ );
13
+ await queryRunner.query(
14
+ `CREATE INDEX "IDX_13c9b134ba46772392bdfb760f" ON "StatusPageOIDC" ("statusPageId") `,
15
+ );
16
+ await queryRunner.query(
17
+ `ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type":"Recurring","value":{"intervalType":"Day","intervalCount":{"_type":"PositiveNumber","value":1}}}'`,
18
+ );
19
+ await queryRunner.query(
20
+ `ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type":"RestrictionTimes","value":{"restictionType":"None","dayRestrictionTimes":null,"weeklyRestrictionTimes":[]}}'`,
21
+ );
22
+ await queryRunner.query(
23
+ `ALTER TABLE "StatusPageOIDC" ADD CONSTRAINT "FK_da08a1ff289745767b094724f79" FOREIGN KEY ("projectId") REFERENCES "Project"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
24
+ );
25
+ await queryRunner.query(
26
+ `ALTER TABLE "StatusPageOIDC" ADD CONSTRAINT "FK_13c9b134ba46772392bdfb760fd" FOREIGN KEY ("statusPageId") REFERENCES "StatusPage"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
27
+ );
28
+ await queryRunner.query(
29
+ `ALTER TABLE "StatusPageOIDC" ADD CONSTRAINT "FK_8e3b6fea29c79c99389893961f1" FOREIGN KEY ("createdByUserId") REFERENCES "User"("_id") ON DELETE SET NULL ON UPDATE NO ACTION`,
30
+ );
31
+ await queryRunner.query(
32
+ `ALTER TABLE "StatusPageOIDC" ADD CONSTRAINT "FK_47054926e3844c88872599d5d55" FOREIGN KEY ("deletedByUserId") REFERENCES "User"("_id") ON DELETE SET NULL ON UPDATE NO ACTION`,
33
+ );
34
+ }
35
+
36
+ public async down(queryRunner: QueryRunner): Promise<void> {
37
+ await queryRunner.query(
38
+ `ALTER TABLE "StatusPageOIDC" DROP CONSTRAINT "FK_47054926e3844c88872599d5d55"`,
39
+ );
40
+ await queryRunner.query(
41
+ `ALTER TABLE "StatusPageOIDC" DROP CONSTRAINT "FK_8e3b6fea29c79c99389893961f1"`,
42
+ );
43
+ await queryRunner.query(
44
+ `ALTER TABLE "StatusPageOIDC" DROP CONSTRAINT "FK_13c9b134ba46772392bdfb760fd"`,
45
+ );
46
+ await queryRunner.query(
47
+ `ALTER TABLE "StatusPageOIDC" DROP CONSTRAINT "FK_da08a1ff289745767b094724f79"`,
48
+ );
49
+ await queryRunner.query(
50
+ `ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type": "RestrictionTimes", "value": {"restictionType": "None", "dayRestrictionTimes": null, "weeklyRestrictionTimes": []}}'`,
51
+ );
52
+ await queryRunner.query(
53
+ `ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type": "Recurring", "value": {"intervalType": "Day", "intervalCount": {"_type": "PositiveNumber", "value": 1}}}'`,
54
+ );
55
+ await queryRunner.query(
56
+ `DROP INDEX "public"."IDX_13c9b134ba46772392bdfb760f"`,
57
+ );
58
+ await queryRunner.query(
59
+ `DROP INDEX "public"."IDX_da08a1ff289745767b094724f7"`,
60
+ );
61
+ await queryRunner.query(`DROP TABLE "StatusPageOIDC"`);
62
+ }
63
+ }
@@ -0,0 +1,15 @@
1
+ import { MigrationInterface, QueryRunner } from "typeorm";
2
+
3
+ export class MigrationName1778582583897 implements MigrationInterface {
4
+ public name = "MigrationName1778582583897";
5
+
6
+ public async up(queryRunner: QueryRunner): Promise<void> {
7
+ await queryRunner.query(
8
+ `ALTER TABLE "Service" ADD "lastSeenAt" TIMESTAMP WITH TIME ZONE`,
9
+ );
10
+ }
11
+
12
+ public async down(queryRunner: QueryRunner): Promise<void> {
13
+ await queryRunner.query(`ALTER TABLE "Service" DROP COLUMN "lastSeenAt"`);
14
+ }
15
+ }
@@ -314,6 +314,9 @@ import { AddPrivacyRules1778440665575 } from "./1778440665575-AddPrivacyRules";
314
314
  import { AddEpisodePrivacyRules1778442385970 } from "./1778442385970-AddEpisodePrivacyRules";
315
315
  import { AddProjectOIDC1778506655291 } from "./1778506655291-AddProjectOIDC";
316
316
  import { MigrationName1778514515756 } from "./1778514515756-MigrationName";
317
+ import { MigrationName1778521361934 } from "./1778521361934-MigrationName";
318
+ import { AddStatusPageOIDC1778522070962 } from "./1778522070962-AddStatusPageOIDC";
319
+ import { MigrationName1778582583897 } from "./1778582583897-MigrationName";
317
320
  export default [
318
321
  InitialMigration,
319
322
  MigrationName1717678334852,
@@ -631,4 +634,7 @@ export default [
631
634
  AddEpisodePrivacyRules1778442385970,
632
635
  AddProjectOIDC1778506655291,
633
636
  MigrationName1778514515756,
637
+ MigrationName1778521361934,
638
+ AddStatusPageOIDC1778522070962,
639
+ MigrationName1778582583897,
634
640
  ];
@@ -130,6 +130,7 @@ import StatusPageResourceService from "./StatusPageResourceService";
130
130
  // Status Page
131
131
  import StatusPageService from "./StatusPageService";
132
132
  import StatusPageSsoService from "./StatusPageSsoService";
133
+ import StatusPageOidcService from "./StatusPageOidcService";
133
134
  import StatusPageSubscriberService from "./StatusPageSubscriberService";
134
135
  import StatusPageSubscriberNotificationTemplateService from "./StatusPageSubscriberNotificationTemplateService";
135
136
  import StatusPageSubscriberNotificationTemplateStatusPageService from "./StatusPageSubscriberNotificationTemplateStatusPageService";
@@ -335,6 +336,7 @@ const services: Array<BaseService> = [
335
336
  StatusPageResourceService,
336
337
  StatusPageService,
337
338
  StatusPageSsoService,
339
+ StatusPageOidcService,
338
340
  StatusPageSubscriberService,
339
341
  StatusPageSubscriberNotificationTemplateService,
340
342
  StatusPageSubscriberNotificationTemplateStatusPageService,
@@ -64,6 +64,21 @@ export default class OTelIngestService {
64
64
  projectId: data.projectId,
65
65
  });
66
66
 
67
+ /*
68
+ * Touch `lastSeenAt` on the service. Throttled per-service inside
69
+ * ServiceService.updateLastSeen so the steady-state firehose costs
70
+ * one in-memory cache lookup per batch.
71
+ */
72
+ try {
73
+ await ServiceService.updateLastSeen(result.serviceId);
74
+ } catch (err) {
75
+ logger.warn(
76
+ `telemetryServiceFromName lastSeen update failed for "${data.serviceName}": ${
77
+ err instanceof Error ? err.message : String(err)
78
+ }`,
79
+ );
80
+ }
81
+
67
82
  /*
68
83
  * Promote `oneuptime.label.<dim>=<val>` resource attributes into
69
84
  * project labels and attach them to the discovered service. The
@@ -51,6 +51,7 @@ import StatusPageEventType from "../../Types/StatusPage/StatusPageEventType";
51
51
  import ScheduledMaintenanceFeedService from "./ScheduledMaintenanceFeedService";
52
52
  import { ScheduledMaintenanceFeedEventType } from "../../Models/DatabaseModels/ScheduledMaintenanceFeed";
53
53
  import SlackUtil from "../Utils/Workspace/Slack/Slack";
54
+ import StatusPageSubscriberWebhookUtil from "../Utils/StatusPageSubscriberWebhook";
54
55
  import { Gray500, Red500 } from "../../Types/BrandColors";
55
56
  import Label from "../../Models/DatabaseModels/Label";
56
57
  import LabelService from "./LabelService";
@@ -332,6 +333,39 @@ ${resourcesAffected ? `**Resources Affected:** ${resourcesAffected}` : ""}
332
333
  });
333
334
  }
334
335
 
336
+ if (subscriber.subscriberWebhook) {
337
+ StatusPageSubscriberWebhookUtil.sendWebhookNotification({
338
+ webhookUrl: subscriber.subscriberWebhook,
339
+ payload: {
340
+ eventType: "ScheduledMaintenanceCreated",
341
+ statusPageId: statuspage.id!.toString(),
342
+ statusPageName: statusPageName,
343
+ statusPageUrl: statusPageURL,
344
+ unsubscribeUrl: unsubscribeUrl,
345
+ data: {
346
+ scheduledMaintenanceId: event.id?.toString() || "",
347
+ scheduledMaintenanceTitle: event.title || "",
348
+ scheduledMaintenanceDescription: event.description || "",
349
+ scheduledStartTime:
350
+ OneUptimeDate.getDateAsUserFriendlyFormattedString(
351
+ event.startsAt!,
352
+ ),
353
+ scheduledEndTime: event.endsAt
354
+ ? OneUptimeDate.getDateAsUserFriendlyFormattedString(
355
+ event.endsAt,
356
+ )
357
+ : "",
358
+ resourcesAffected: resourcesAffected,
359
+ detailsUrl: scheduledEventDetailsUrl,
360
+ },
361
+ },
362
+ }).catch((err: Error) => {
363
+ logger.error(err, {
364
+ projectId: statuspage.projectId?.toString(),
365
+ } as LogAttributes);
366
+ });
367
+ }
368
+
335
369
  if (subscriber.subscriberEmail) {
336
370
  // send email here.
337
371
  const statusPageIdString: string | null =
@@ -6,6 +6,7 @@ import ArrayUtil from "../../Utils/Array";
6
6
  import { BrightColors } from "../../Types/BrandColors";
7
7
  import BadDataException from "../../Types/Exception/BadDataException";
8
8
  import ObjectID from "../../Types/ObjectID";
9
+ import OneUptimeDate from "../../Types/Date";
9
10
  import Model from "../../Models/DatabaseModels/Service";
10
11
  import Label from "../../Models/DatabaseModels/Label";
11
12
  import Project from "../../Models/DatabaseModels/Project";
@@ -16,6 +17,9 @@ import crypto from "crypto";
16
17
 
17
18
  const DEFAULT_TELEMETRY_RETENTION_IN_DAYS: number = 15;
18
19
 
20
+ const LAST_SEEN_CACHE_NAMESPACE: string = "service-last-seen";
21
+ const LAST_SEEN_THROTTLE_SECONDS: number = 60;
22
+
19
23
  const LABELS_APPLIED_CACHE_NAMESPACE: string = "service-labels-applied";
20
24
  const LABELS_APPLIED_CACHE_TTL_SECONDS: number = 60;
21
25
 
@@ -80,6 +84,39 @@ export class Service extends DatabaseService<Model> {
80
84
  return DEFAULT_TELEMETRY_RETENTION_IN_DAYS;
81
85
  }
82
86
 
87
+ /*
88
+ * Refresh `lastSeenAt` for a service. Throttled per-service so the
89
+ * steady-state telemetry firehose (every metric/log/trace batch
90
+ * re-resolves the same serviceId) costs one in-memory cache lookup
91
+ * per batch instead of a DB write.
92
+ */
93
+ @CaptureSpan()
94
+ public async updateLastSeen(serviceId: ObjectID): Promise<void> {
95
+ const cacheKey: string = serviceId.toString();
96
+ const cached: string | null = await GlobalCache.getString(
97
+ LAST_SEEN_CACHE_NAMESPACE,
98
+ cacheKey,
99
+ );
100
+
101
+ if (cached) {
102
+ return;
103
+ }
104
+
105
+ await GlobalCache.setString(LAST_SEEN_CACHE_NAMESPACE, cacheKey, "1", {
106
+ expiresInSeconds: LAST_SEEN_THROTTLE_SECONDS,
107
+ });
108
+
109
+ await this.updateOneById({
110
+ id: serviceId,
111
+ data: {
112
+ lastSeenAt: OneUptimeDate.getCurrentDate(),
113
+ },
114
+ props: {
115
+ isRoot: true,
116
+ },
117
+ });
118
+ }
119
+
83
120
  /**
84
121
  * Additively attach labels to a telemetry service. Existing labels
85
122
  * are never removed — manual labels set via the UI survive ingest.
@@ -0,0 +1,10 @@
1
+ import DatabaseService from "./DatabaseService";
2
+ import Model from "../../Models/DatabaseModels/StatusPageOidc";
3
+
4
+ export class Service extends DatabaseService<Model> {
5
+ public constructor() {
6
+ super(Model);
7
+ }
8
+ }
9
+
10
+ export default new Service();
@@ -36,6 +36,7 @@ import StatusPageSubscriberNotificationMethod from "../../Types/StatusPage/Statu
36
36
  import NumberUtil from "../../Utils/Number";
37
37
  import SlackUtil from "../Utils/Workspace/Slack/Slack";
38
38
  import MicrosoftTeamsUtil from "../Utils/Workspace/MicrosoftTeams/MicrosoftTeams";
39
+ import StatusPageSubscriberWebhookUtil from "../Utils/StatusPageSubscriberWebhook";
39
40
  import StatusPageSubscriberNotificationTemplateService, {
40
41
  Service as StatusPageSubscriberNotificationTemplateServiceClass,
41
42
  } from "./StatusPageSubscriberNotificationTemplateService";
@@ -540,6 +541,43 @@ Stay informed about service availability! 🚀`;
540
541
  });
541
542
  }
542
543
 
544
+ // if generic webhook URL is provided and sendYouHaveSubscribedMessage is true, then ping the webhook with the subscription event.
545
+ if (
546
+ createdItem.subscriberWebhook &&
547
+ createdItem.sendYouHaveSubscribedMessage
548
+ ) {
549
+ logger.debug("Sending webhook notification for new subscriber.", {
550
+ projectId: createdItem.projectId?.toString(),
551
+ } as LogAttributes);
552
+
553
+ StatusPageSubscriberWebhookUtil.sendWebhookNotification({
554
+ webhookUrl: URL.fromString(createdItem.subscriberWebhook.toString()),
555
+ payload: {
556
+ eventType: "SubscriberSubscribed",
557
+ statusPageId: createdItem.statusPageId.toString(),
558
+ statusPageName: statusPageName,
559
+ statusPageUrl: statusPageURL,
560
+ unsubscribeUrl: unsubscribeLink,
561
+ data: {
562
+ message: `You have been subscribed to ${statusPageName}.`,
563
+ },
564
+ },
565
+ })
566
+ .then(() => {
567
+ logger.debug("Webhook notification sent successfully.", {
568
+ projectId: createdItem.projectId?.toString(),
569
+ } as LogAttributes);
570
+ })
571
+ .catch((err: Error) => {
572
+ logger.error("Error sending webhook notification:", {
573
+ projectId: createdItem.projectId?.toString(),
574
+ } as LogAttributes);
575
+ logger.error(err, {
576
+ projectId: createdItem.projectId?.toString(),
577
+ } as LogAttributes);
578
+ });
579
+ }
580
+
543
581
  // if Microsoft Teams incoming webhook is provided and sendYouHaveSubscribedMessage is true, then send a message to the Teams channel.
544
582
  if (
545
583
  createdItem.microsoftTeamsIncomingWebhookUrl &&
@@ -1345,6 +1383,69 @@ Stay informed about service availability! 🚀`;
1345
1383
  return statusPages;
1346
1384
  }
1347
1385
 
1386
+ @CaptureSpan()
1387
+ public async testSubscriberWebhook(data: {
1388
+ webhookUrl: string;
1389
+ statusPageId: ObjectID;
1390
+ }): Promise<void> {
1391
+ // basic validation - must be a valid URL
1392
+ let parsedUrl: URL;
1393
+ try {
1394
+ parsedUrl = URL.fromString(data.webhookUrl);
1395
+ } catch {
1396
+ throw new BadDataException("Invalid Webhook URL");
1397
+ }
1398
+
1399
+ // get the status page info
1400
+ const statusPage: StatusPage | null = await StatusPageService.findOneById({
1401
+ id: data.statusPageId,
1402
+ props: {
1403
+ isRoot: true,
1404
+ },
1405
+ select: {
1406
+ name: true,
1407
+ pageTitle: true,
1408
+ projectId: true,
1409
+ _id: true,
1410
+ },
1411
+ });
1412
+
1413
+ if (!statusPage) {
1414
+ throw new BadDataException("Status page not found");
1415
+ }
1416
+
1417
+ const statusPageName: string =
1418
+ statusPage.pageTitle || statusPage.name || "Status Page";
1419
+ const statusPageURL: string = await StatusPageService.getStatusPageURL(
1420
+ statusPage.id!,
1421
+ );
1422
+
1423
+ try {
1424
+ await StatusPageSubscriberWebhookUtil.sendWebhookNotification({
1425
+ webhookUrl: parsedUrl,
1426
+ payload: {
1427
+ eventType: "TestNotification",
1428
+ statusPageId: statusPage.id!.toString(),
1429
+ statusPageName: statusPageName,
1430
+ statusPageUrl: statusPageURL,
1431
+ unsubscribeUrl: "",
1432
+ data: {
1433
+ message:
1434
+ "This is a test notification from OneUptime. Your webhook is configured correctly.",
1435
+ },
1436
+ },
1437
+ });
1438
+ } catch (error) {
1439
+ logger.error("Error sending test webhook notification:", {
1440
+ projectId: statusPage?.projectId?.toString(),
1441
+ } as LogAttributes);
1442
+ logger.error(error, {
1443
+ projectId: statusPage?.projectId?.toString(),
1444
+ } as LogAttributes);
1445
+ throw error;
1446
+ }
1447
+ }
1448
+
1348
1449
  @CaptureSpan()
1349
1450
  public async testSlackWebhook(data: {
1350
1451
  webhookUrl: string;
@@ -138,6 +138,44 @@ export default class QueryHelper {
138
138
  );
139
139
  }
140
140
 
141
+ /**
142
+ * Searches the provided entity property names with a single OR-joined ILIKE.
143
+ *
144
+ * IMPORTANT: emit unquoted `alias.propertyName` references and let
145
+ * TypeORM's `replacePropertyNamesForTheWholeQuery` post-processor escape
146
+ * the table alias and translate property names → DB column names. Pre-
147
+ * quoting (e.g. `Incident."title"`) bypasses that pass, which leaves an
148
+ * unquoted `Incident` in the final SQL — Postgres then lowercases it and
149
+ * fails with `missing FROM-clause entry for table "incident"`.
150
+ */
151
+ @CaptureSpan()
152
+ public static multiSearch(
153
+ entityPropertyNames: Array<string>,
154
+ value: string,
155
+ ): FindWhereProperty<any> {
156
+ const trimmed: string = value.toLowerCase().trim();
157
+ const rid: string = Text.generateRandomText(10);
158
+
159
+ return Raw(
160
+ (alias: string) => {
161
+ const tableAlias: string = alias.includes(".")
162
+ ? (alias.split(".")[0] as string)
163
+ : alias;
164
+
165
+ const orConditions: string = entityPropertyNames
166
+ .map((field: string) => {
167
+ return `(CAST(${tableAlias}.${field} AS TEXT) ILIKE :${rid})`;
168
+ })
169
+ .join(" OR ");
170
+
171
+ return `(${orConditions})`;
172
+ },
173
+ {
174
+ [rid]: `%${trimmed}%`,
175
+ },
176
+ );
177
+ }
178
+
141
179
  @CaptureSpan()
142
180
  public static notContains(name: string): FindWhereProperty<any> {
143
181
  name = name.toLowerCase().trim();