@oneuptime/common 12.0.12 → 12.0.13

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 (234) hide show
  1. package/Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel.ts +32 -5
  2. package/Models/DatabaseModels/NetworkDevice.ts +64 -0
  3. package/Models/DatabaseModels/StatusPage.ts +59 -0
  4. package/Models/DatabaseModels/StatusPageOidc.ts +2 -0
  5. package/Models/DatabaseModels/WorkspaceProjectAuthToken.ts +19 -2
  6. package/Server/API/AIChatAPI.ts +55 -56
  7. package/Server/API/CommonAPI.ts +89 -0
  8. package/Server/API/MicrosoftTeamsAPI.ts +20 -9
  9. package/Server/API/SlackAPI.ts +13 -10
  10. package/Server/API/StatusPageAPI.ts +2197 -2128
  11. package/Server/API/TelemetryAPI.ts +72 -3
  12. package/Server/Infrastructure/Postgres/SchemaMigrations/1787500000000-AddEnableSearchEngineIndexingToStatusPage.ts +30 -0
  13. package/Server/Infrastructure/Postgres/SchemaMigrations/1787600000000-AddNetworkDeviceReachabilityColumns.ts +47 -0
  14. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +4 -0
  15. package/Server/Middleware/UserAuthorization.ts +11 -2
  16. package/Server/Services/ApiKeyPermissionService.ts +116 -2
  17. package/Server/Services/DatabaseService.ts +17 -6
  18. package/Server/Services/LogAggregationService.ts +34 -0
  19. package/Server/Services/LogService.ts +21 -0
  20. package/Server/Services/NetworkSiteService.ts +12 -0
  21. package/Server/Services/SpanService.ts +21 -0
  22. package/Server/Services/TraceAggregationService.ts +15 -0
  23. package/Server/Services/WorkspaceNotificationRuleService.ts +172 -220
  24. package/Server/Types/AnalyticsDatabase/ModelPermission.ts +9 -0
  25. package/Server/Types/Database/Permissions/AccessControlPermission.ts +47 -16
  26. package/Server/Utils/APIKey/AccessPermission.ts +16 -40
  27. package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +144 -0
  28. package/Server/Utils/LogRedaction.ts +576 -0
  29. package/Server/Utils/Logger.ts +123 -46
  30. package/Server/Utils/Monitor/Criteria/SSLMonitorCriteria.ts +94 -23
  31. package/Server/Utils/Monitor/MonitorResource.ts +29 -9
  32. package/Server/Utils/Monitor/NetworkInventoryUtil.ts +28 -7
  33. package/Server/Utils/SessionReplay/SessionReplayGateCacheStore.ts +56 -10
  34. package/Server/Utils/StartServer.ts +30 -9
  35. package/Server/Utils/StatusPageSearchEngineIndexing.ts +33 -0
  36. package/Server/Utils/Telemetry/ResourceEntityFilter.ts +441 -0
  37. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.ts +488 -46
  38. package/Tests/App/Dashboard/MonitorTypePicker.test.tsx +300 -0
  39. package/Tests/App/StatusPage/StatusPageIndexPageRobotsMeta.test.ts +130 -0
  40. package/Tests/Models/DatabaseModels/DatabaseBaseModelToJSONObjectCache.test.ts +180 -0
  41. package/Tests/Models/StatusPageEnableSearchEngineIndexing.test.ts +157 -0
  42. package/Tests/Server/API/AIChatCancelAndFeedback.test.ts +23 -1
  43. package/Tests/Server/API/AIChatRouteAuthorization.test.ts +785 -0
  44. package/Tests/Server/API/BaseAPIApiKeyAuth.test.ts +21 -8
  45. package/Tests/Server/API/CommonAPIAuthGuard.test.ts +290 -0
  46. package/Tests/Server/API/MicrosoftTeamsManifest.test.ts +7 -1
  47. package/Tests/Server/API/StatusPageOverviewCache.test.ts +474 -0
  48. package/Tests/Server/API/StatusPageSeoSearchEngineIndexing.test.ts +189 -0
  49. package/Tests/Server/Infrastructure/InMemoryTTLCache.test.ts +158 -0
  50. package/Tests/Server/Infrastructure/Postgres/DeviceRoleAndDeclaredLinkParentMigration.test.ts +9 -60
  51. package/Tests/Server/Middleware/ProjectAuthorizationApiKeyMiddleware.test.ts +24 -15
  52. package/Tests/Server/Middleware/UserAuthorization.test.ts +79 -0
  53. package/Tests/Server/Services/AddNetworkDeviceReachabilityColumnsMigration.test.ts +332 -0
  54. package/Tests/Server/Services/ApiKeyPermissionService.test.ts +347 -0
  55. package/Tests/Server/Services/DatabaseServiceUpdateDebugLogging.test.ts +282 -0
  56. package/Tests/Server/Services/NetworkSiteService.test.ts +56 -2
  57. package/Tests/Server/Services/TelemetryResourceFacetFilters.test.ts +267 -0
  58. package/Tests/Server/Services/WorkspaceNotificationRuleTestRuleChannels.test.ts +702 -0
  59. package/Tests/Server/Types/Database/Permissions/AccessControlPermission.test.ts +165 -0
  60. package/Tests/Server/Utils/APIKey/AccessPermission.test.ts +48 -36
  61. package/Tests/Server/Utils/AnalyticsDatabase/StatementGenerator.test.ts +213 -0
  62. package/Tests/Server/Utils/LogRedaction.test.ts +415 -0
  63. package/Tests/Server/Utils/LoggerCredentialLeak.test.ts +245 -0
  64. package/Tests/Server/Utils/Monitor/Criteria/SSLMonitorCriteria.test.ts +374 -4
  65. package/Tests/Server/Utils/Monitor/MonitorResourceIngestedStepSelection.test.ts +455 -0
  66. package/Tests/Server/Utils/Monitor/NetworkDeviceReachabilityRoundTrip.test.ts +369 -0
  67. package/Tests/Server/Utils/Monitor/NetworkInventoryUtil.test.ts +86 -8
  68. package/Tests/Server/Utils/PrivacyFilterUtil.test.ts +64 -0
  69. package/Tests/Server/Utils/SessionReplay/SessionReplayGateCacheStore.test.ts +207 -0
  70. package/Tests/Server/Utils/StartServerBodyVerify.test.ts +335 -0
  71. package/Tests/Server/Utils/StatusPageSearchEngineIndexing.test.ts +60 -0
  72. package/Tests/Server/Utils/Telemetry/ResourceEntityFilter.test.ts +435 -0
  73. package/Tests/Server/Utils/Workspace/MicrosoftTeamsChannelSend.test.ts +607 -62
  74. package/Tests/Server/Utils/Workspace/MicrosoftTeamsInstalledTeamIdSpace.test.ts +778 -0
  75. package/Tests/Server/Utils/Workspace/MicrosoftTeamsTeamInstalls.test.ts +44 -10
  76. package/Tests/Types/API/HostnameFromAuthority.test.ts +164 -0
  77. package/Tests/Types/Database/AccessControl/ColumnAccessControlCache.test.ts +344 -0
  78. package/Tests/Types/Database/DatabasePropertyFindOperator.test.ts +518 -0
  79. package/Tests/Types/Database/TableColumnMetadataCache.test.ts +286 -0
  80. package/Tests/Types/Monitor/MonitorStep.test.ts +100 -0
  81. package/Tests/Types/Monitor/MonitorTypeKeywords.test.ts +360 -0
  82. package/Tests/Types/StatusPage/SearchEngineIndexing.test.ts +86 -0
  83. package/Tests/Types/Telemetry/ResourceEntityFacet.test.ts +254 -0
  84. package/Tests/UI/Components/CardSelect.test.tsx +1021 -0
  85. package/Tests/UI/Components/MasterPage.test.tsx +36 -1
  86. package/Tests/Utils/Monitor/MonitorMetricType.test.ts +66 -1
  87. package/Tests/Utils/Monitor/NetworkTopologyUtil.test.ts +115 -6
  88. package/Tests/Utils/NetworkDevice/DeviceReachabilityUtil.test.ts +610 -0
  89. package/Tests/Utils/NetworkSite/SiteStatusRollupUtil.test.ts +114 -45
  90. package/Types/API/Hostname.ts +79 -0
  91. package/Types/Database/AccessControl/ColumnAccessControl.ts +33 -11
  92. package/Types/Database/AccessControl/ColumnBillingAccessControl.ts +33 -11
  93. package/Types/Database/DatabaseProperty.ts +38 -0
  94. package/Types/Database/TableColumn.ts +34 -7
  95. package/Types/Events/Recurring.ts +16 -1
  96. package/Types/Monitor/MonitorStep.ts +13 -1
  97. package/Types/Monitor/MonitorType.ts +345 -40
  98. package/Types/Monitor/SSLMonitor/SslMonitorResponse.ts +43 -0
  99. package/Types/StatusPage/SearchEngineIndexing.ts +61 -0
  100. package/Types/Telemetry/ResourceEntityFacet.ts +211 -0
  101. package/UI/Components/CardSelect/CardSelect.tsx +714 -105
  102. package/UI/Components/EmptyState/EmptyState.tsx +8 -1
  103. package/UI/Components/Footer/Footer.tsx +32 -6
  104. package/UI/Components/Forms/Fields/FormField.tsx +3 -0
  105. package/UI/Components/Forms/Types/Field.ts +8 -0
  106. package/UI/Components/Loader/PageLoader.tsx +29 -8
  107. package/UI/Components/LogsViewer/components/LogsAnalyticsView.tsx +26 -4
  108. package/UI/Components/Markdown.tsx/LazyMarkdownViewer.tsx +17 -1
  109. package/UI/Components/MasterPage/MasterPage.tsx +24 -11
  110. package/Utils/Monitor/MonitorMetricType.ts +16 -1
  111. package/Utils/Monitor/NetworkTopologyUtil.ts +60 -8
  112. package/Utils/NetworkDevice/DeviceReachabilityUtil.ts +262 -0
  113. package/Utils/NetworkSite/SiteStatusRollupUtil.ts +41 -24
  114. package/Utils/Telemetry/CrossSignalScope.ts +75 -4
  115. package/build/dist/Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel.js +20 -4
  116. package/build/dist/Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel.js.map +1 -1
  117. package/build/dist/Models/DatabaseModels/NetworkDevice.js +66 -0
  118. package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
  119. package/build/dist/Models/DatabaseModels/StatusPage.js +60 -0
  120. package/build/dist/Models/DatabaseModels/StatusPage.js.map +1 -1
  121. package/build/dist/Models/DatabaseModels/StatusPageOidc.js +2 -0
  122. package/build/dist/Models/DatabaseModels/StatusPageOidc.js.map +1 -1
  123. package/build/dist/Models/DatabaseModels/WorkspaceProjectAuthToken.js.map +1 -1
  124. package/build/dist/Server/API/AIChatAPI.js +48 -35
  125. package/build/dist/Server/API/AIChatAPI.js.map +1 -1
  126. package/build/dist/Server/API/CommonAPI.js +59 -0
  127. package/build/dist/Server/API/CommonAPI.js.map +1 -1
  128. package/build/dist/Server/API/MicrosoftTeamsAPI.js +20 -6
  129. package/build/dist/Server/API/MicrosoftTeamsAPI.js.map +1 -1
  130. package/build/dist/Server/API/SlackAPI.js +13 -10
  131. package/build/dist/Server/API/SlackAPI.js.map +1 -1
  132. package/build/dist/Server/API/StatusPageAPI.js +745 -673
  133. package/build/dist/Server/API/StatusPageAPI.js.map +1 -1
  134. package/build/dist/Server/API/TelemetryAPI.js +34 -3
  135. package/build/dist/Server/API/TelemetryAPI.js.map +1 -1
  136. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787500000000-AddEnableSearchEngineIndexingToStatusPage.js +23 -0
  137. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787500000000-AddEnableSearchEngineIndexingToStatusPage.js.map +1 -0
  138. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787600000000-AddNetworkDeviceReachabilityColumns.js +34 -0
  139. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787600000000-AddNetworkDeviceReachabilityColumns.js.map +1 -0
  140. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +4 -0
  141. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  142. package/build/dist/Server/Middleware/UserAuthorization.js +6 -2
  143. package/build/dist/Server/Middleware/UserAuthorization.js.map +1 -1
  144. package/build/dist/Server/Services/ApiKeyPermissionService.js +90 -1
  145. package/build/dist/Server/Services/ApiKeyPermissionService.js.map +1 -1
  146. package/build/dist/Server/Services/DatabaseService.js +15 -6
  147. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  148. package/build/dist/Server/Services/LogAggregationService.js +2 -0
  149. package/build/dist/Server/Services/LogAggregationService.js.map +1 -1
  150. package/build/dist/Server/Services/LogService.js +16 -0
  151. package/build/dist/Server/Services/LogService.js.map +1 -1
  152. package/build/dist/Server/Services/NetworkSiteService.js +12 -0
  153. package/build/dist/Server/Services/NetworkSiteService.js.map +1 -1
  154. package/build/dist/Server/Services/SpanService.js +16 -0
  155. package/build/dist/Server/Services/SpanService.js.map +1 -1
  156. package/build/dist/Server/Services/TraceAggregationService.js +2 -0
  157. package/build/dist/Server/Services/TraceAggregationService.js.map +1 -1
  158. package/build/dist/Server/Services/WorkspaceNotificationRuleService.js +137 -164
  159. package/build/dist/Server/Services/WorkspaceNotificationRuleService.js.map +1 -1
  160. package/build/dist/Server/Types/AnalyticsDatabase/ModelPermission.js +9 -0
  161. package/build/dist/Server/Types/AnalyticsDatabase/ModelPermission.js.map +1 -1
  162. package/build/dist/Server/Types/Database/Permissions/AccessControlPermission.js +21 -13
  163. package/build/dist/Server/Types/Database/Permissions/AccessControlPermission.js.map +1 -1
  164. package/build/dist/Server/Utils/APIKey/AccessPermission.js +9 -32
  165. package/build/dist/Server/Utils/APIKey/AccessPermission.js.map +1 -1
  166. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +87 -0
  167. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
  168. package/build/dist/Server/Utils/LogRedaction.js +449 -0
  169. package/build/dist/Server/Utils/LogRedaction.js.map +1 -0
  170. package/build/dist/Server/Utils/Logger.js +98 -45
  171. package/build/dist/Server/Utils/Logger.js.map +1 -1
  172. package/build/dist/Server/Utils/Monitor/Criteria/SSLMonitorCriteria.js +65 -13
  173. package/build/dist/Server/Utils/Monitor/Criteria/SSLMonitorCriteria.js.map +1 -1
  174. package/build/dist/Server/Utils/Monitor/MonitorResource.js +20 -3
  175. package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
  176. package/build/dist/Server/Utils/Monitor/NetworkInventoryUtil.js +27 -7
  177. package/build/dist/Server/Utils/Monitor/NetworkInventoryUtil.js.map +1 -1
  178. package/build/dist/Server/Utils/SessionReplay/SessionReplayGateCacheStore.js +29 -10
  179. package/build/dist/Server/Utils/SessionReplay/SessionReplayGateCacheStore.js.map +1 -1
  180. package/build/dist/Server/Utils/StartServer.js +10 -7
  181. package/build/dist/Server/Utils/StartServer.js.map +1 -1
  182. package/build/dist/Server/Utils/StatusPageSearchEngineIndexing.js +24 -0
  183. package/build/dist/Server/Utils/StatusPageSearchEngineIndexing.js.map +1 -0
  184. package/build/dist/Server/Utils/Telemetry/ResourceEntityFilter.js +311 -0
  185. package/build/dist/Server/Utils/Telemetry/ResourceEntityFilter.js.map +1 -0
  186. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js +366 -37
  187. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js.map +1 -1
  188. package/build/dist/Types/API/Hostname.js +63 -0
  189. package/build/dist/Types/API/Hostname.js.map +1 -1
  190. package/build/dist/Types/Database/AccessControl/ColumnAccessControl.js +20 -6
  191. package/build/dist/Types/Database/AccessControl/ColumnAccessControl.js.map +1 -1
  192. package/build/dist/Types/Database/AccessControl/ColumnBillingAccessControl.js +20 -6
  193. package/build/dist/Types/Database/AccessControl/ColumnBillingAccessControl.js.map +1 -1
  194. package/build/dist/Types/Database/DatabaseProperty.js +38 -0
  195. package/build/dist/Types/Database/DatabaseProperty.js.map +1 -1
  196. package/build/dist/Types/Database/TableColumn.js +24 -6
  197. package/build/dist/Types/Database/TableColumn.js.map +1 -1
  198. package/build/dist/Types/Events/Recurring.js +13 -1
  199. package/build/dist/Types/Events/Recurring.js.map +1 -1
  200. package/build/dist/Types/Monitor/MonitorStep.js +9 -1
  201. package/build/dist/Types/Monitor/MonitorStep.js.map +1 -1
  202. package/build/dist/Types/Monitor/MonitorType.js +330 -31
  203. package/build/dist/Types/Monitor/MonitorType.js.map +1 -1
  204. package/build/dist/Types/StatusPage/SearchEngineIndexing.js +55 -0
  205. package/build/dist/Types/StatusPage/SearchEngineIndexing.js.map +1 -0
  206. package/build/dist/Types/Telemetry/ResourceEntityFacet.js +156 -0
  207. package/build/dist/Types/Telemetry/ResourceEntityFacet.js.map +1 -0
  208. package/build/dist/UI/Components/CardSelect/CardSelect.js +360 -40
  209. package/build/dist/UI/Components/CardSelect/CardSelect.js.map +1 -1
  210. package/build/dist/UI/Components/EmptyState/EmptyState.js +1 -1
  211. package/build/dist/UI/Components/EmptyState/EmptyState.js.map +1 -1
  212. package/build/dist/UI/Components/Footer/Footer.js +7 -4
  213. package/build/dist/UI/Components/Footer/Footer.js.map +1 -1
  214. package/build/dist/UI/Components/Forms/Fields/FormField.js +1 -1
  215. package/build/dist/UI/Components/Forms/Fields/FormField.js.map +1 -1
  216. package/build/dist/UI/Components/Loader/PageLoader.js +15 -5
  217. package/build/dist/UI/Components/Loader/PageLoader.js.map +1 -1
  218. package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js +16 -3
  219. package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js.map +1 -1
  220. package/build/dist/UI/Components/Markdown.tsx/LazyMarkdownViewer.js +13 -1
  221. package/build/dist/UI/Components/Markdown.tsx/LazyMarkdownViewer.js.map +1 -1
  222. package/build/dist/UI/Components/MasterPage/MasterPage.js +10 -2
  223. package/build/dist/UI/Components/MasterPage/MasterPage.js.map +1 -1
  224. package/build/dist/Utils/Monitor/MonitorMetricType.js +13 -1
  225. package/build/dist/Utils/Monitor/MonitorMetricType.js.map +1 -1
  226. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js +34 -7
  227. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js.map +1 -1
  228. package/build/dist/Utils/NetworkDevice/DeviceReachabilityUtil.js +177 -0
  229. package/build/dist/Utils/NetworkDevice/DeviceReachabilityUtil.js.map +1 -0
  230. package/build/dist/Utils/NetworkSite/SiteStatusRollupUtil.js +23 -27
  231. package/build/dist/Utils/NetworkSite/SiteStatusRollupUtil.js.map +1 -1
  232. package/build/dist/Utils/Telemetry/CrossSignalScope.js +44 -4
  233. package/build/dist/Utils/Telemetry/CrossSignalScope.js.map +1 -1
  234. package/package.json +1 -1
@@ -1,4 +1,5 @@
1
1
  import UserMiddleware from "../Middleware/UserAuthorization";
2
+ import InMemoryTTLCache from "../Infrastructure/InMemoryTTLCache";
2
3
  import AcmeChallengeService from "../Services/AcmeChallengeService";
3
4
  import IncidentEpisodeService from "../Services/IncidentEpisodeService";
4
5
  import IncidentEpisodeMemberService from "../Services/IncidentEpisodeMemberService";
@@ -36,6 +37,10 @@ import {
36
37
  NextFunction,
37
38
  } from "../Utils/Express";
38
39
  import logger, { getLogAttributesFromRequest } from "../Utils/Logger";
40
+ import {
41
+ SEARCH_ENGINE_INDEXING_FLAG_NAME,
42
+ isSearchEngineIndexingEnabled,
43
+ } from "../../Types/StatusPage/SearchEngineIndexing";
39
44
  import Response from "../Utils/Response";
40
45
  import BaseAPI from "./BaseAPI";
41
46
  import BaseModel from "../../Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel";
@@ -156,6 +161,29 @@ export default class StatusPageAPI extends BaseAPI<
156
161
  StatusPage,
157
162
  StatusPageServiceType
158
163
  > {
164
+ /*
165
+ * Post-auth overview responses keyed by resolved statusPageId. The payload
166
+ * is user-independent (authorization is a binary gate checked per-request,
167
+ * before any cache read), so one short-TTL snapshot per page per process
168
+ * serves every viewer. See buildOverviewResponse.
169
+ */
170
+ private static overviewResponseCache: InMemoryTTLCache<JSONObject> =
171
+ new InMemoryTTLCache<JSONObject>(500);
172
+
173
+ /*
174
+ * In-flight overview builds keyed by the same cache key, so concurrent
175
+ * cold-cache requests share one build instead of stampeding the database.
176
+ */
177
+ private static overviewResponseInFlight: Map<string, Promise<JSONObject>> =
178
+ new Map();
179
+
180
+ private static readonly OVERVIEW_CACHE_TTL_MS: number = 15_000;
181
+
182
+ public static clearOverviewResponseCache(): void {
183
+ this.overviewResponseCache.clear();
184
+ this.overviewResponseInFlight.clear();
185
+ }
186
+
159
187
  public constructor() {
160
188
  super(StatusPage, StatusPageService);
161
189
 
@@ -192,6 +220,7 @@ export default class StatusPageAPI extends BaseAPI<
192
220
  pageDescription: true,
193
221
  name: true,
194
222
  defaultLanguage: true,
223
+ enableSearchEngineIndexing: true,
195
224
  },
196
225
  props: {
197
226
  isRoot: true,
@@ -212,6 +241,14 @@ export default class StatusPageAPI extends BaseAPI<
212
241
  description: statusPage.pageDescription,
213
242
  _id: statusPage._id?.toString(),
214
243
  defaultLanguage: statusPage.defaultLanguage || null,
244
+ /*
245
+ * Drives <meta name="robots"> and X-Robots-Tag on the rendered page.
246
+ * Normalised here rather than shipping the raw column, so the two
247
+ * frontend servers never have to decide what a missing value means.
248
+ */
249
+ [SEARCH_ENGINE_INDEXING_FLAG_NAME]: isSearchEngineIndexingEnabled(
250
+ statusPage.enableSearchEngineIndexing,
251
+ ),
215
252
  });
216
253
  },
217
254
  );
@@ -1553,918 +1590,353 @@ export default class StatusPageAPI extends BaseAPI<
1553
1590
  req.params["statusPageIdOrDomain"] as string,
1554
1591
  );
1555
1592
 
1593
+ /*
1594
+ * checkHasReadAccess MUST stay before any cache read: it is what keeps
1595
+ * private pages, IP whitelists, and master passwords enforced on every
1596
+ * request even when the payload below is served from cache.
1597
+ */
1556
1598
  await this.checkHasReadAccess({
1557
1599
  statusPageId: statusPageId,
1558
1600
  req: req,
1559
1601
  });
1560
1602
 
1603
+ // Resolved id, so domain-served and id-served views share one entry.
1604
+ const cacheKey: string = statusPageId.toString();
1605
+
1561
1606
  /*
1562
- * The timeline window comes from the status page's configured
1563
- * showUptimeHistoryInDays. getStatusPageResourcesAndTimelines
1564
- * fetches the status page row anyway, so let it compute the window
1565
- * (clamped to 1..90 days, ending now) instead of issuing a separate
1566
- * StatusPage query here just to read that one column.
1607
+ * The cached JSONObject is shared across requests and must never be
1608
+ * mutated after build.
1567
1609
  */
1568
- const {
1569
- monitorStatuses,
1570
- monitorGroupCurrentStatuses,
1571
- statusPageResources,
1572
- statusPage,
1573
- monitorsOnStatusPage,
1574
- monitorStatusTimelines,
1575
- statusPageGroups,
1576
- monitorsInGroup,
1577
- startDateForMonitorTimeline: startDate,
1578
- endDateForMonitorTimeline: endDate,
1579
- } = await this.getStatusPageResourcesAndTimelines({
1580
- statusPageId: statusPageId,
1581
- });
1582
-
1583
- // check if status page has active incident.
1584
- let activeIncidents: Array<Incident> = [];
1585
- if (monitorsOnStatusPage.length > 0) {
1586
- let select: Select<Incident> = {
1587
- createdAt: true,
1588
- declaredAt: true,
1589
- updatedAt: true,
1590
- title: true,
1591
- description: true,
1592
- _id: true,
1593
- postmortemNote: true,
1594
- postmortemPostedAt: true,
1595
- showPostmortemOnStatusPage: true,
1596
- postmortemAttachments: {
1597
- _id: true,
1598
- name: true,
1599
- },
1600
- incidentSeverity: {
1601
- name: true,
1602
- color: true,
1603
- },
1604
- currentIncidentState: {
1605
- _id: true,
1606
- name: true,
1607
- color: true,
1608
- order: true,
1609
- },
1610
- monitors: {
1611
- _id: true,
1612
- },
1613
- };
1614
-
1615
- if (statusPage.showIncidentLabelsOnStatusPage) {
1616
- select = {
1617
- ...select,
1618
- labels: {
1619
- name: true,
1620
- color: true,
1621
- },
1622
- };
1610
+ let response: JSONObject | undefined =
1611
+ StatusPageAPI.overviewResponseCache.get(cacheKey);
1612
+
1613
+ if (!response) {
1614
+ let inFlight: Promise<JSONObject> | undefined =
1615
+ StatusPageAPI.overviewResponseInFlight.get(cacheKey);
1616
+
1617
+ if (!inFlight) {
1618
+ inFlight = this.buildOverviewResponse(statusPageId);
1619
+ StatusPageAPI.overviewResponseInFlight.set(cacheKey, inFlight);
1620
+
1621
+ inFlight
1622
+ .then((builtResponse: JSONObject) => {
1623
+ StatusPageAPI.overviewResponseCache.set(
1624
+ cacheKey,
1625
+ builtResponse,
1626
+ StatusPageAPI.OVERVIEW_CACHE_TTL_MS,
1627
+ );
1628
+ })
1629
+ .catch(() => {
1630
+ // Failed builds are never cached; the next request rebuilds.
1631
+ })
1632
+ .finally(() => {
1633
+ StatusPageAPI.overviewResponseInFlight.delete(cacheKey);
1634
+ });
1623
1635
  }
1624
1636
 
1625
- const unresolvedIncidentStates: Array<IncidentState> =
1626
- await IncidentStateService.getUnresolvedIncidentStates(
1627
- statusPage.projectId!,
1628
- {
1629
- isRoot: true,
1630
- },
1631
- );
1632
-
1633
- const unresolvedIncidentStateIds: Array<ObjectID> =
1634
- unresolvedIncidentStates.map((state: IncidentState) => {
1635
- return state.id!;
1636
- });
1637
-
1638
- if (statusPage.showIncidentsOnStatusPage) {
1639
- activeIncidents = await IncidentService.findBy({
1640
- query: {
1641
- monitors: monitorsOnStatusPage as any,
1642
- currentIncidentStateId: QueryHelper.any(
1643
- unresolvedIncidentStateIds,
1644
- ),
1645
- isVisibleOnStatusPage: true,
1646
- projectId: statusPage.projectId!,
1647
- },
1648
- select: select,
1649
- sort: {
1650
- declaredAt: SortOrder.Descending,
1651
- createdAt: SortOrder.Descending,
1652
- },
1653
-
1654
- skip: 0,
1655
- limit: LIMIT_PER_PROJECT,
1656
- props: {
1657
- isRoot: true,
1658
- },
1659
- });
1660
- }
1637
+ response = await inFlight;
1661
1638
  }
1662
1639
 
1663
- const incidentsOnStatusPage: Array<ObjectID> = activeIncidents.map(
1664
- (incident: Incident) => {
1665
- return incident.id!;
1666
- },
1667
- );
1640
+ // These can serve private-page data on a GET; never let shared caches store them.
1641
+ Response.setNoCacheHeaders(res);
1668
1642
 
1669
- let incidentPublicNotes: Array<IncidentPublicNote> = [];
1643
+ return Response.sendJsonObjectResponse(req, res, response);
1644
+ } catch (err) {
1645
+ next(err);
1646
+ }
1647
+ };
1670
1648
 
1671
- if (incidentsOnStatusPage.length > 0) {
1672
- incidentPublicNotes = await IncidentPublicNoteService.findBy({
1673
- query: {
1674
- incidentId: QueryHelper.any(incidentsOnStatusPage),
1675
- projectId: statusPage.projectId!,
1676
- },
1677
- select: {
1678
- note: true,
1679
- incidentId: true,
1680
- postedAt: true,
1681
- attachments: {
1682
- _id: true,
1683
- name: true,
1684
- },
1685
- },
1686
- sort: {
1687
- postedAt: SortOrder.Descending, // new note first
1688
- },
1689
- skip: 0,
1690
- limit: LIMIT_PER_PROJECT,
1691
- props: {
1692
- isRoot: true,
1693
- },
1694
- });
1695
- }
1649
+ const overviewApiPath: string = `${new this.entityType()
1650
+ .getCrudApiPath()
1651
+ ?.toString()}/overview/:statusPageIdOrDomain`;
1696
1652
 
1697
- let incidentStateTimelines: Array<IncidentStateTimeline> = [];
1653
+ this.router.post(
1654
+ overviewApiPath,
1655
+ UserMiddleware.getUserMiddleware,
1656
+ overviewHandler,
1657
+ );
1698
1658
 
1699
- if (incidentsOnStatusPage.length > 0) {
1700
- incidentStateTimelines = await IncidentStateTimelineService.findBy({
1701
- query: {
1702
- incidentId: QueryHelper.any(incidentsOnStatusPage),
1703
- projectId: statusPage.projectId!,
1704
- },
1705
- select: {
1706
- _id: true,
1707
- createdAt: true,
1708
- startsAt: true,
1709
- incidentId: true,
1710
- incidentState: {
1711
- _id: true,
1712
- name: true,
1713
- color: true,
1714
- isCreatedState: true,
1715
- isResolvedState: true,
1716
- isAcknowledgedState: true,
1717
- },
1718
- },
1659
+ /*
1660
+ * GET variant of the overview endpoint. Same middleware and same handler
1661
+ * as the POST route above — private status pages are rejected for
1662
+ * unauthenticated callers exactly as they are on POST.
1663
+ */
1664
+ this.router.get(
1665
+ overviewApiPath,
1666
+ UserMiddleware.getUserMiddleware,
1667
+ overviewHandler,
1668
+ );
1719
1669
 
1720
- sort: {
1721
- startsAt: SortOrder.Descending, // newer state changes first
1722
- },
1723
- skip: 0,
1724
- limit: LIMIT_PER_PROJECT,
1725
- props: {
1726
- isRoot: true,
1727
- },
1728
- });
1670
+ this.router.put(
1671
+ `${new this.entityType()
1672
+ .getCrudApiPath()
1673
+ ?.toString()}/update-subscription/:statusPageId/:subscriberId`,
1674
+ UserMiddleware.getUserMiddleware,
1675
+ async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
1676
+ try {
1677
+ await this.subscribeToStatusPage(req);
1678
+ return Response.sendEmptySuccessResponse(req, res);
1679
+ } catch (err) {
1680
+ next(err);
1729
1681
  }
1682
+ },
1683
+ );
1730
1684
 
1731
- // Fetch active episodes (similar to incidents)
1732
- let activeEpisodes: Array<IncidentEpisode> = [];
1733
- let activeEpisodesJson: JSONArray = [];
1734
- let episodePublicNotes: Array<IncidentEpisodePublicNote> = [];
1735
- let episodeStateTimelines: Array<IncidentEpisodeStateTimeline> = [];
1685
+ this.router.post(
1686
+ `${new this.entityType()
1687
+ .getCrudApiPath()
1688
+ ?.toString()}/get-subscription/:statusPageId/:subscriberId`,
1689
+ UserMiddleware.getUserMiddleware,
1690
+ async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
1691
+ try {
1692
+ const subscriber: StatusPageSubscriber =
1693
+ await this.getSubscriber(req);
1736
1694
 
1737
- /*
1738
- * Cheap guard before the expensive part: the block below scans every
1739
- * incident ever attached to this page's monitors (a many-to-many
1740
- * join, up to LIMIT_PER_PROJECT rows) just to discover episode
1741
- * membership — on every overview view, even though most pages have
1742
- * zero active episodes most of the time. One indexed COUNT of the
1743
- * project's unresolved, visible episodes lets us skip all of it in
1744
- * the common case. Behavior-preserving: the final activeEpisodes
1745
- * query applies exactly these three constraints, so count == 0
1746
- * implies the block's outputs stay empty.
1747
- */
1748
- let unresolvedIncidentStateIds: Array<ObjectID> = [];
1749
- let hasActiveEpisodes: boolean = false;
1695
+ return Response.sendEntityResponse(
1696
+ req,
1697
+ res,
1698
+ subscriber,
1699
+ StatusPageSubscriber,
1700
+ );
1701
+ } catch (err) {
1702
+ next(err);
1703
+ }
1704
+ },
1705
+ );
1750
1706
 
1751
- if (
1752
- statusPage.showEpisodesOnStatusPage &&
1753
- monitorsOnStatusPage.length > 0
1754
- ) {
1755
- const unresolvedIncidentStates: Array<IncidentState> =
1756
- await IncidentStateService.getUnresolvedIncidentStates(
1757
- statusPage.projectId!,
1758
- { isRoot: true },
1759
- );
1707
+ this.router.post(
1708
+ `${new this.entityType()
1709
+ .getCrudApiPath()
1710
+ ?.toString()}/subscribe/:statusPageId`,
1711
+ UserMiddleware.getUserMiddleware,
1712
+ async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
1713
+ try {
1714
+ await this.subscribeToStatusPage(req);
1760
1715
 
1761
- unresolvedIncidentStateIds = unresolvedIncidentStates.map(
1762
- (state: IncidentState) => {
1763
- return state.id!;
1764
- },
1765
- );
1716
+ return Response.sendEmptySuccessResponse(req, res);
1717
+ } catch (err) {
1718
+ next(err);
1719
+ }
1720
+ },
1721
+ );
1766
1722
 
1767
- const activeEpisodeCount: PositiveNumber =
1768
- await IncidentEpisodeService.countBy({
1769
- query: {
1770
- projectId: statusPage.projectId!,
1771
- isVisibleOnStatusPage: true,
1772
- currentIncidentStateId: QueryHelper.any(
1773
- unresolvedIncidentStateIds,
1774
- ),
1775
- },
1776
- props: {
1777
- isRoot: true,
1778
- },
1779
- });
1723
+ this.router.post(
1724
+ `${new this.entityType()
1725
+ .getCrudApiPath()
1726
+ ?.toString()}/manage-subscription/:statusPageId`,
1727
+ UserMiddleware.getUserMiddleware,
1728
+ async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
1729
+ try {
1730
+ await this.manageExistingSubscription(req);
1780
1731
 
1781
- hasActiveEpisodes = activeEpisodeCount.toNumber() > 0;
1732
+ return Response.sendEmptySuccessResponse(req, res);
1733
+ } catch (err) {
1734
+ next(err);
1782
1735
  }
1736
+ },
1737
+ );
1783
1738
 
1784
- if (hasActiveEpisodes) {
1785
- // First, get incidents that have monitors on status page
1786
- const incidentsForEpisodes: Array<Incident> =
1787
- await IncidentService.findBy({
1788
- query: {
1789
- monitors: monitorsOnStatusPage as any,
1790
- isVisibleOnStatusPage: true,
1791
- projectId: statusPage.projectId!,
1792
- },
1793
- select: {
1794
- _id: true,
1795
- },
1796
- skip: 0,
1797
- limit: LIMIT_PER_PROJECT,
1798
- props: {
1799
- isRoot: true,
1800
- },
1801
- });
1739
+ /*
1740
+ * Shared handler for the incidents list endpoint. Registered for both
1741
+ * POST and GET (same auth path). It does not read req.body.
1742
+ */
1743
+ const incidentsListHandler: (
1744
+ req: ExpressRequest,
1745
+ res: ExpressResponse,
1746
+ next: NextFunction,
1747
+ ) => Promise<void> = async (
1748
+ req: ExpressRequest,
1749
+ res: ExpressResponse,
1750
+ next: NextFunction,
1751
+ ): Promise<void> => {
1752
+ try {
1753
+ const objectId: ObjectID = await resolveStatusPageIdOrThrow(
1754
+ req.params["statusPageIdOrDomain"] as string,
1755
+ );
1802
1756
 
1803
- const incidentIdsForEpisodes: Array<ObjectID> =
1804
- incidentsForEpisodes.map((incident: Incident) => {
1805
- return incident.id!;
1806
- });
1757
+ const response: JSONObject = await this.getIncidents(
1758
+ objectId,
1759
+ null,
1760
+ req,
1761
+ );
1807
1762
 
1808
- // Get episode members for these incidents
1809
- let episodeMembers: Array<IncidentEpisodeMember> = [];
1810
- if (incidentIdsForEpisodes.length > 0) {
1811
- episodeMembers = await IncidentEpisodeMemberService.findBy({
1812
- query: {
1813
- incidentId: QueryHelper.any(incidentIdsForEpisodes),
1814
- projectId: statusPage.projectId!,
1815
- },
1816
- select: {
1817
- incidentEpisodeId: true,
1818
- incidentId: true,
1819
- },
1820
- skip: 0,
1821
- limit: LIMIT_PER_PROJECT,
1822
- props: {
1823
- isRoot: true,
1824
- },
1825
- });
1826
- }
1763
+ // These can serve private-page data on a GET; never let shared caches store them.
1764
+ Response.setNoCacheHeaders(res);
1827
1765
 
1828
- // Get unique episode IDs
1829
- const episodeIdsFromMembers: Set<string> = new Set();
1830
- for (const member of episodeMembers) {
1831
- if (member.incidentEpisodeId) {
1832
- episodeIdsFromMembers.add(member.incidentEpisodeId.toString());
1833
- }
1834
- }
1766
+ return Response.sendJsonObjectResponse(req, res, response);
1767
+ } catch (err) {
1768
+ next(err);
1769
+ }
1770
+ };
1835
1771
 
1836
- // Fetch active (unresolved) episodes
1837
- if (episodeIdsFromMembers.size > 0) {
1838
- // unresolvedIncidentStateIds was fetched by the guard above.
1839
- let selectEpisodes: Select<IncidentEpisode> = {
1840
- createdAt: true,
1841
- declaredAt: true,
1842
- updatedAt: true,
1843
- title: true,
1844
- description: true,
1845
- _id: true,
1846
- episodeNumber: true,
1847
- incidentSeverity: {
1848
- name: true,
1849
- color: true,
1850
- },
1851
- currentIncidentState: {
1852
- name: true,
1853
- color: true,
1854
- _id: true,
1855
- order: true,
1856
- isCreatedState: true,
1857
- isAcknowledgedState: true,
1858
- isResolvedState: true,
1859
- },
1860
- incidentCount: true,
1861
- };
1772
+ const incidentsListApiPath: string = `${new this.entityType()
1773
+ .getCrudApiPath()
1774
+ ?.toString()}/incidents/:statusPageIdOrDomain`;
1862
1775
 
1863
- if (statusPage.showEpisodeLabelsOnStatusPage) {
1864
- selectEpisodes = {
1865
- ...selectEpisodes,
1866
- labels: {
1867
- name: true,
1868
- color: true,
1869
- },
1870
- };
1871
- }
1776
+ this.router.post(
1777
+ incidentsListApiPath,
1778
+ UserMiddleware.getUserMiddleware,
1779
+ incidentsListHandler,
1780
+ );
1872
1781
 
1873
- activeEpisodes = await IncidentEpisodeService.findBy({
1874
- query: {
1875
- _id: QueryHelper.any(
1876
- Array.from(episodeIdsFromMembers).map((id: string) => {
1877
- return new ObjectID(id);
1878
- }),
1879
- ),
1880
- currentIncidentStateId: QueryHelper.any(
1881
- unresolvedIncidentStateIds,
1882
- ),
1883
- isVisibleOnStatusPage: true,
1884
- projectId: statusPage.projectId!,
1885
- },
1886
- select: selectEpisodes,
1887
- sort: {
1888
- declaredAt: SortOrder.Descending,
1889
- createdAt: SortOrder.Descending,
1890
- },
1891
- skip: 0,
1892
- limit: LIMIT_PER_PROJECT,
1893
- props: {
1894
- isRoot: true,
1895
- },
1896
- });
1782
+ this.router.get(
1783
+ incidentsListApiPath,
1784
+ UserMiddleware.getUserMiddleware,
1785
+ incidentsListHandler,
1786
+ );
1897
1787
 
1898
- // Build episode monitors map
1899
- if (activeEpisodes.length > 0) {
1900
- // Collect all incident IDs from episode members for active episodes
1901
- const activeEpisodeIds: Set<string> = new Set(
1902
- activeEpisodes.map((e: IncidentEpisode) => {
1903
- return e.id!.toString();
1904
- }),
1905
- );
1906
-
1907
- const memberIncidentIds: Array<ObjectID> = [];
1908
- for (const member of episodeMembers) {
1909
- if (
1910
- member.incidentEpisodeId &&
1911
- activeEpisodeIds.has(member.incidentEpisodeId.toString()) &&
1912
- member.incidentId &&
1913
- !memberIncidentIds.some((id: ObjectID) => {
1914
- return id.toString() === member.incidentId!.toString();
1915
- })
1916
- ) {
1917
- memberIncidentIds.push(member.incidentId);
1918
- }
1919
- }
1788
+ /*
1789
+ * Shared handler for the scheduled maintenance events list endpoint.
1790
+ * Registered for both POST and GET (same auth path). It does not read
1791
+ * req.body.
1792
+ */
1793
+ const scheduledMaintenanceEventsListHandler: (
1794
+ req: ExpressRequest,
1795
+ res: ExpressResponse,
1796
+ next: NextFunction,
1797
+ ) => Promise<void> = async (
1798
+ req: ExpressRequest,
1799
+ res: ExpressResponse,
1800
+ next: NextFunction,
1801
+ ): Promise<void> => {
1802
+ try {
1803
+ const objectId: ObjectID = await resolveStatusPageIdOrThrow(
1804
+ req.params["statusPageIdOrDomain"] as string,
1805
+ );
1920
1806
 
1921
- // Fetch incidents with monitors
1922
- let memberIncidents: Array<Incident> = [];
1923
- if (memberIncidentIds.length > 0) {
1924
- memberIncidents = await IncidentService.findBy({
1925
- query: {
1926
- _id: QueryHelper.any(memberIncidentIds),
1927
- isVisibleOnStatusPage: true,
1928
- projectId: statusPage.projectId!,
1929
- },
1930
- select: {
1931
- _id: true,
1932
- monitors: {
1933
- _id: true,
1934
- },
1935
- },
1936
- skip: 0,
1937
- limit: LIMIT_PER_PROJECT,
1938
- props: {
1939
- isRoot: true,
1940
- },
1941
- });
1942
- }
1807
+ const response: JSONObject = await this.getScheduledMaintenanceEvents(
1808
+ objectId,
1809
+ null,
1943
1810
 
1944
- // Build incident -> monitors map
1945
- const incidentMonitorsMap: Map<
1946
- string,
1947
- Array<ObjectID>
1948
- > = new Map();
1949
- for (const incident of memberIncidents) {
1950
- const incidentIdStr: string = incident.id!.toString();
1951
- const monitorIds: Array<ObjectID> = (incident.monitors || [])
1952
- .map((m: Monitor) => {
1953
- return new ObjectID(
1954
- m._id?.toString() || m.id?.toString() || "",
1955
- );
1956
- })
1957
- .filter((id: ObjectID) => {
1958
- return id.toString() !== "";
1959
- });
1960
- incidentMonitorsMap.set(incidentIdStr, monitorIds);
1961
- }
1811
+ req,
1812
+ );
1962
1813
 
1963
- // Build episode -> monitors map
1964
- const episodeMonitorsMap: Map<
1965
- string,
1966
- Array<ObjectID>
1967
- > = new Map();
1968
- for (const member of episodeMembers) {
1969
- if (
1970
- member.incidentEpisodeId &&
1971
- member.incidentId &&
1972
- activeEpisodeIds.has(member.incidentEpisodeId.toString())
1973
- ) {
1974
- const episodeIdStr: string =
1975
- member.incidentEpisodeId.toString();
1976
- const incidentIdStr: string = member.incidentId.toString();
1814
+ // These can serve private-page data on a GET; never let shared caches store them.
1815
+ Response.setNoCacheHeaders(res);
1977
1816
 
1978
- if (!episodeMonitorsMap.has(episodeIdStr)) {
1979
- episodeMonitorsMap.set(episodeIdStr, []);
1980
- }
1817
+ return Response.sendJsonObjectResponse(req, res, response);
1818
+ } catch (err) {
1819
+ next(err);
1820
+ }
1821
+ };
1981
1822
 
1982
- const episodeMonitors: Array<ObjectID> =
1983
- episodeMonitorsMap.get(episodeIdStr)!;
1984
- const incidentMonitors: Array<ObjectID> =
1985
- incidentMonitorsMap.get(incidentIdStr) || [];
1986
-
1987
- for (const monitorId of incidentMonitors) {
1988
- if (
1989
- !episodeMonitors.some((m: ObjectID) => {
1990
- return m.toString() === monitorId.toString();
1991
- })
1992
- ) {
1993
- episodeMonitors.push(monitorId);
1994
- }
1995
- }
1996
- }
1997
- }
1823
+ const scheduledMaintenanceEventsListApiPath: string = `${new this.entityType()
1824
+ .getCrudApiPath()
1825
+ ?.toString()}/scheduled-maintenance-events/:statusPageIdOrDomain`;
1998
1826
 
1999
- // Serialize episodes and add monitors
2000
- activeEpisodesJson = BaseModel.toJSONArray(
2001
- activeEpisodes,
2002
- IncidentEpisode,
2003
- );
2004
- for (const episodeJson of activeEpisodesJson) {
2005
- const episodeObj: JSONObject = episodeJson as JSONObject;
2006
- const episodeId: string | undefined =
2007
- episodeObj["_id"]?.toString();
2008
- if (episodeId) {
2009
- const monitorIds: Array<ObjectID> =
2010
- episodeMonitorsMap.get(episodeId) || [];
2011
- episodeObj["monitors"] = monitorIds.map((id: ObjectID) => {
2012
- return { _id: id.toString() };
2013
- });
2014
- }
2015
- }
1827
+ this.router.post(
1828
+ scheduledMaintenanceEventsListApiPath,
1829
+ UserMiddleware.getUserMiddleware,
1830
+ scheduledMaintenanceEventsListHandler,
1831
+ );
2016
1832
 
2017
- // Get episode public notes
2018
- const episodesOnStatusPage: Array<ObjectID> = activeEpisodes.map(
2019
- (episode: IncidentEpisode) => {
2020
- return episode.id!;
2021
- },
2022
- );
2023
-
2024
- if (episodesOnStatusPage.length > 0) {
2025
- episodePublicNotes =
2026
- await IncidentEpisodePublicNoteService.findBy({
2027
- query: {
2028
- incidentEpisodeId: QueryHelper.any(episodesOnStatusPage),
2029
- projectId: statusPage.projectId!,
2030
- },
2031
- select: {
2032
- postedAt: true,
2033
- note: true,
2034
- incidentEpisodeId: true,
2035
- attachments: {
2036
- _id: true,
2037
- name: true,
2038
- },
2039
- },
2040
- sort: {
2041
- postedAt: SortOrder.Descending,
2042
- },
2043
- skip: 0,
2044
- limit: LIMIT_PER_PROJECT,
2045
- props: {
2046
- isRoot: true,
2047
- },
2048
- });
2049
-
2050
- // Get episode state timelines
2051
- episodeStateTimelines =
2052
- await IncidentEpisodeStateTimelineService.findBy({
2053
- query: {
2054
- incidentEpisodeId: QueryHelper.any(episodesOnStatusPage),
2055
- projectId: statusPage.projectId!,
2056
- },
2057
- select: {
2058
- _id: true,
2059
- createdAt: true,
2060
- startsAt: true,
2061
- incidentEpisodeId: true,
2062
- incidentState: {
2063
- name: true,
2064
- color: true,
2065
- isCreatedState: true,
2066
- isAcknowledgedState: true,
2067
- isResolvedState: true,
2068
- },
2069
- },
2070
- sort: {
2071
- startsAt: SortOrder.Descending,
2072
- },
2073
- skip: 0,
2074
- limit: LIMIT_PER_PROJECT,
2075
- props: {
2076
- isRoot: true,
2077
- },
2078
- });
2079
- }
2080
- }
2081
- }
2082
- }
1833
+ this.router.get(
1834
+ scheduledMaintenanceEventsListApiPath,
1835
+ UserMiddleware.getUserMiddleware,
1836
+ scheduledMaintenanceEventsListHandler,
1837
+ );
1838
+
1839
+ /*
1840
+ * Shared handler for the announcements list endpoint. Registered for
1841
+ * both POST and GET (same auth path). It does not read req.body.
1842
+ */
1843
+ const announcementsListHandler: (
1844
+ req: ExpressRequest,
1845
+ res: ExpressResponse,
1846
+ next: NextFunction,
1847
+ ) => Promise<void> = async (
1848
+ req: ExpressRequest,
1849
+ res: ExpressResponse,
1850
+ next: NextFunction,
1851
+ ): Promise<void> => {
1852
+ try {
1853
+ const objectId: ObjectID = await resolveStatusPageIdOrThrow(
1854
+ req.params["statusPageIdOrDomain"] as string,
1855
+ );
2083
1856
 
2084
- // check if status page has active announcement.
1857
+ const response: JSONObject = await this.getAnnouncements(
1858
+ objectId,
1859
+ null,
2085
1860
 
2086
- const today: Date = OneUptimeDate.getCurrentDate();
1861
+ req,
1862
+ );
2087
1863
 
2088
- let activeAnnouncements: Array<StatusPageAnnouncement> = [];
1864
+ // These can serve private-page data on a GET; never let shared caches store them.
1865
+ Response.setNoCacheHeaders(res);
2089
1866
 
2090
- if (statusPage.showAnnouncementsOnStatusPage) {
2091
- activeAnnouncements = await StatusPageAnnouncementService.findBy({
2092
- query: {
2093
- statusPages: statusPageId as any,
2094
- showAnnouncementAt: QueryHelper.lessThan(today),
2095
- endAnnouncementAt: QueryHelper.greaterThanOrNull(today),
2096
- projectId: statusPage.projectId!,
2097
- },
2098
- select: {
2099
- createdAt: true,
2100
- title: true,
2101
- description: true,
2102
- _id: true,
2103
- showAnnouncementAt: true,
2104
- endAnnouncementAt: true,
2105
- },
2106
- skip: 0,
2107
- limit: LIMIT_PER_PROJECT,
2108
- props: {
2109
- isRoot: true,
2110
- },
2111
- });
2112
- }
1867
+ return Response.sendJsonObjectResponse(req, res, response);
1868
+ } catch (err) {
1869
+ next(err);
1870
+ }
1871
+ };
2113
1872
 
2114
- // check if status page has active scheduled events.
1873
+ const announcementsListApiPath: string = `${new this.entityType()
1874
+ .getCrudApiPath()
1875
+ ?.toString()}/announcements/:statusPageIdOrDomain`;
2115
1876
 
2116
- let scheduledEventsSelect: Select<ScheduledMaintenance> = {
2117
- createdAt: true,
2118
- title: true,
2119
- description: true,
2120
- _id: true,
2121
- endsAt: true,
2122
- startsAt: true,
2123
- currentScheduledMaintenanceState: {
2124
- name: true,
2125
- color: true,
2126
- isScheduledState: true,
2127
- isResolvedState: true,
2128
- isOngoingState: true,
2129
- },
2130
- monitors: {
2131
- _id: true,
2132
- },
2133
- };
1877
+ this.router.post(
1878
+ announcementsListApiPath,
1879
+ UserMiddleware.getUserMiddleware,
1880
+ announcementsListHandler,
1881
+ );
2134
1882
 
2135
- if (statusPage.showScheduledEventLabelsOnStatusPage) {
2136
- scheduledEventsSelect = {
2137
- ...scheduledEventsSelect,
2138
- labels: {
2139
- name: true,
2140
- color: true,
2141
- },
2142
- };
2143
- }
1883
+ this.router.get(
1884
+ announcementsListApiPath,
1885
+ UserMiddleware.getUserMiddleware,
1886
+ announcementsListHandler,
1887
+ );
2144
1888
 
2145
- let scheduledMaintenanceEvents: Array<ScheduledMaintenance> = [];
1889
+ this.router.post(
1890
+ `${new this.entityType()
1891
+ .getCrudApiPath()
1892
+ ?.toString()}/incidents/:statusPageIdOrDomain/:incidentId`,
1893
+ UserMiddleware.getUserMiddleware,
1894
+ async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
1895
+ try {
1896
+ const objectId: ObjectID = await resolveStatusPageIdOrThrow(
1897
+ req.params["statusPageIdOrDomain"] as string,
1898
+ );
2146
1899
 
2147
- if (statusPage.showScheduledMaintenanceEventsOnStatusPage) {
2148
- scheduledMaintenanceEvents = await ScheduledMaintenanceService.findBy(
2149
- {
2150
- query: {
2151
- currentScheduledMaintenanceState: {
2152
- isOngoingState: true,
2153
- } as any,
2154
- statusPages: statusPageId as any,
2155
- projectId: statusPage.projectId!,
2156
- isVisibleOnStatusPage: true,
2157
- },
2158
- select: scheduledEventsSelect,
2159
- sort: {
2160
- startsAt: SortOrder.Ascending,
2161
- },
2162
- skip: 0,
2163
- limit: LIMIT_PER_PROJECT,
2164
- props: {
2165
- isRoot: true,
2166
- },
2167
- },
1900
+ const incidentId: ObjectID = new ObjectID(
1901
+ req.params["incidentId"] as string,
2168
1902
  );
2169
- }
2170
1903
 
2171
- let futureScheduledMaintenanceEvents: Array<ScheduledMaintenance> = [];
1904
+ const response: JSONObject = await this.getIncidents(
1905
+ objectId,
1906
+ incidentId,
1907
+ req,
1908
+ );
2172
1909
 
2173
- if (statusPage.showScheduledMaintenanceEventsOnStatusPage) {
2174
- futureScheduledMaintenanceEvents =
2175
- await ScheduledMaintenanceService.findBy({
2176
- query: {
2177
- currentScheduledMaintenanceState: {
2178
- isScheduledState: true,
2179
- } as any,
2180
- statusPages: statusPageId as any,
2181
- projectId: statusPage.projectId!,
2182
- isVisibleOnStatusPage: true,
2183
- },
2184
- select: scheduledEventsSelect,
2185
- sort: {
2186
- startsAt: SortOrder.Ascending,
2187
- },
2188
- skip: 0,
2189
- limit: LIMIT_PER_PROJECT,
2190
- props: {
2191
- isRoot: true,
2192
- },
2193
- });
1910
+ return Response.sendJsonObjectResponse(req, res, response);
1911
+ } catch (err) {
1912
+ next(err);
2194
1913
  }
1914
+ },
1915
+ );
2195
1916
 
2196
- futureScheduledMaintenanceEvents.forEach(
2197
- (event: ScheduledMaintenance) => {
2198
- scheduledMaintenanceEvents.push(event);
2199
- },
2200
- );
1917
+ this.router.post(
1918
+ `${new this.entityType()
1919
+ .getCrudApiPath()
1920
+ ?.toString()}/scheduled-maintenance-events/:statusPageIdOrDomain/:scheduledMaintenanceId`,
1921
+ UserMiddleware.getUserMiddleware,
1922
+ async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
1923
+ try {
1924
+ const objectId: ObjectID = await resolveStatusPageIdOrThrow(
1925
+ req.params["statusPageIdOrDomain"] as string,
1926
+ );
2201
1927
 
2202
- const scheduledMaintenanceEventsOnStatusPage: Array<ObjectID> =
2203
- scheduledMaintenanceEvents.map((event: ScheduledMaintenance) => {
2204
- return event.id!;
2205
- });
1928
+ const scheduledMaintenanceId: ObjectID = new ObjectID(
1929
+ req.params["scheduledMaintenanceId"] as string,
1930
+ );
2206
1931
 
2207
- let scheduledMaintenanceEventsPublicNotes: Array<ScheduledMaintenancePublicNote> =
2208
- [];
1932
+ const response: JSONObject = await this.getScheduledMaintenanceEvents(
1933
+ objectId,
1934
+ scheduledMaintenanceId,
2209
1935
 
2210
- if (scheduledMaintenanceEventsOnStatusPage.length > 0) {
2211
- scheduledMaintenanceEventsPublicNotes =
2212
- await ScheduledMaintenancePublicNoteService.findBy({
2213
- query: {
2214
- scheduledMaintenanceId: QueryHelper.any(
2215
- scheduledMaintenanceEventsOnStatusPage,
2216
- ),
2217
- projectId: statusPage.projectId!,
2218
- },
2219
- select: {
2220
- postedAt: true,
2221
- note: true,
2222
- scheduledMaintenanceId: true,
2223
- attachments: {
2224
- _id: true,
2225
- name: true,
2226
- },
2227
- },
2228
- sort: {
2229
- postedAt: SortOrder.Ascending,
2230
- },
2231
- skip: 0,
2232
- limit: LIMIT_PER_PROJECT,
2233
- props: {
2234
- isRoot: true,
2235
- },
2236
- });
2237
- }
2238
-
2239
- let scheduledMaintenanceStateTimelines: Array<ScheduledMaintenanceStateTimeline> =
2240
- [];
2241
-
2242
- if (scheduledMaintenanceEventsOnStatusPage.length > 0) {
2243
- scheduledMaintenanceStateTimelines =
2244
- await ScheduledMaintenanceStateTimelineService.findBy({
2245
- query: {
2246
- scheduledMaintenanceId: QueryHelper.any(
2247
- scheduledMaintenanceEventsOnStatusPage,
2248
- ),
2249
- projectId: statusPage.projectId!,
2250
- },
2251
- select: {
2252
- _id: true,
2253
- createdAt: true,
2254
- startsAt: true,
2255
- scheduledMaintenanceId: true,
2256
- scheduledMaintenanceState: {
2257
- _id: true,
2258
- color: true,
2259
- name: true,
2260
- isScheduledState: true,
2261
- isResolvedState: true,
2262
- isOngoingState: true,
2263
- },
2264
- },
2265
-
2266
- sort: {
2267
- startsAt: SortOrder.Descending, // newer state changes first
2268
- },
2269
- skip: 0,
2270
- limit: LIMIT_PER_PROJECT,
2271
- props: {
2272
- isRoot: true,
2273
- },
2274
- });
2275
- }
2276
-
2277
- // get all status page bar chart rules
2278
- const statusPageHistoryChartBarColorRules: Array<StatusPageHistoryChartBarColorRule> =
2279
- await StatusPageHistoryChartBarColorRuleService.findBy({
2280
- query: {
2281
- statusPageId: statusPageId,
2282
- },
2283
- select: {
2284
- _id: true,
2285
- barColor: true,
2286
- order: true,
2287
- statusPageId: true,
2288
- uptimePercentGreaterThanOrEqualTo: true,
2289
- },
2290
- sort: {
2291
- order: SortOrder.Ascending,
2292
- },
2293
- skip: 0,
2294
- limit: LIMIT_PER_PROJECT,
2295
- props: {
2296
- isRoot: true,
2297
- },
2298
- });
2299
-
2300
- /*
2301
- * Fetch all incidents (active + resolved) in the timeline date range
2302
- * for the uptime bar tooltip and click-through
2303
- */
2304
- let timelineIncidents: Array<Incident> = [];
2305
- if (
2306
- monitorsOnStatusPage.length > 0 &&
2307
- statusPage.showIncidentsOnStatusPage
2308
- ) {
2309
- timelineIncidents = await IncidentService.findBy({
2310
- query: {
2311
- monitors: monitorsOnStatusPage as any,
2312
- declaredAt: QueryHelper.inBetween(startDate, endDate),
2313
- isVisibleOnStatusPage: true,
2314
- projectId: statusPage.projectId!,
2315
- },
2316
- select: {
2317
- _id: true,
2318
- title: true,
2319
- declaredAt: true,
2320
- incidentSeverity: {
2321
- name: true,
2322
- color: true,
2323
- },
2324
- currentIncidentState: {
2325
- _id: true,
2326
- name: true,
2327
- color: true,
2328
- },
2329
- monitors: {
2330
- _id: true,
2331
- },
2332
- },
2333
- sort: {
2334
- declaredAt: SortOrder.Descending,
2335
- },
2336
- skip: 0,
2337
- limit: LIMIT_PER_PROJECT,
2338
- props: {
2339
- isRoot: true,
2340
- },
2341
- });
2342
- }
2343
-
2344
- const overallStatus: MonitorStatus | null =
2345
- StatusPageService.getOverallMonitorStatus({
2346
- statusPageResources,
2347
- monitorStatuses,
2348
- monitorGroupCurrentStatuses,
2349
- });
2350
-
2351
- const response: JSONObject = {
2352
- overallStatus: overallStatus
2353
- ? BaseModel.toJSON(overallStatus, MonitorStatus)
2354
- : null,
2355
-
2356
- scheduledMaintenanceEventsPublicNotes: BaseModel.toJSONArray(
2357
- scheduledMaintenanceEventsPublicNotes,
2358
- ScheduledMaintenancePublicNote,
2359
- ),
2360
- statusPageHistoryChartBarColorRules: BaseModel.toJSONArray(
2361
- statusPageHistoryChartBarColorRules,
2362
- StatusPageHistoryChartBarColorRule,
2363
- ),
2364
- scheduledMaintenanceEvents: BaseModel.toJSONArray(
2365
- scheduledMaintenanceEvents,
2366
- ScheduledMaintenance,
2367
- ),
2368
- activeAnnouncements: BaseModel.toJSONArray(
2369
- activeAnnouncements,
2370
- StatusPageAnnouncement,
2371
- ),
2372
- incidentPublicNotes: BaseModel.toJSONArray(
2373
- incidentPublicNotes,
2374
- IncidentPublicNote,
2375
- ),
2376
-
2377
- activeIncidents: BaseModel.toJSONArray(activeIncidents, Incident),
2378
-
2379
- activeEpisodes: activeEpisodesJson,
2380
- episodePublicNotes: BaseModel.toJSONArray(
2381
- episodePublicNotes,
2382
- IncidentEpisodePublicNote,
2383
- ),
2384
- episodeStateTimelines: BaseModel.toJSONArray(
2385
- episodeStateTimelines,
2386
- IncidentEpisodeStateTimeline,
2387
- ),
2388
-
2389
- monitorStatusTimelines: BaseModel.toJSONArray(
2390
- monitorStatusTimelines,
2391
- MonitorStatusTimeline,
2392
- ),
2393
- resourceGroups: BaseModel.toJSONArray(
2394
- statusPageGroups,
2395
- StatusPageGroup,
2396
- ),
2397
- monitorStatuses: BaseModel.toJSONArray(
2398
- monitorStatuses,
2399
- MonitorStatus,
2400
- ),
2401
- statusPageResources: BaseModel.toJSONArray(
2402
- statusPageResources,
2403
- StatusPageResource,
2404
- ),
2405
- incidentStateTimelines: BaseModel.toJSONArray(
2406
- incidentStateTimelines,
2407
- IncidentStateTimeline,
2408
- ),
2409
- statusPage: (() => {
2410
- const statusPageJson: JSONObject = BaseModel.toJSONObject(
2411
- statusPage,
2412
- StatusPage,
2413
- );
2414
- delete statusPageJson["projectId"];
2415
- return statusPageJson;
2416
- })(),
2417
- scheduledMaintenanceStateTimelines: BaseModel.toJSONArray(
2418
- scheduledMaintenanceStateTimelines,
2419
- ScheduledMaintenanceStateTimeline,
2420
- ),
2421
-
2422
- monitorGroupCurrentStatuses: JSONFunctions.serialize(
2423
- monitorGroupCurrentStatuses,
2424
- ),
2425
- monitorsInGroup: JSONFunctions.serialize(monitorsInGroup),
2426
- timelineIncidents: BaseModel.toJSONArray(timelineIncidents, Incident),
2427
- };
2428
-
2429
- // These can serve private-page data on a GET; never let shared caches store them.
2430
- Response.setNoCacheHeaders(res);
2431
-
2432
- return Response.sendJsonObjectResponse(req, res, response);
2433
- } catch (err) {
2434
- next(err);
2435
- }
2436
- };
2437
-
2438
- const overviewApiPath: string = `${new this.entityType()
2439
- .getCrudApiPath()
2440
- ?.toString()}/overview/:statusPageIdOrDomain`;
2441
-
2442
- this.router.post(
2443
- overviewApiPath,
2444
- UserMiddleware.getUserMiddleware,
2445
- overviewHandler,
2446
- );
2447
-
2448
- /*
2449
- * GET variant of the overview endpoint. Same middleware and same handler
2450
- * as the POST route above — private status pages are rejected for
2451
- * unauthenticated callers exactly as they are on POST.
2452
- */
2453
- this.router.get(
2454
- overviewApiPath,
2455
- UserMiddleware.getUserMiddleware,
2456
- overviewHandler,
2457
- );
1936
+ req,
1937
+ );
2458
1938
 
2459
- this.router.put(
2460
- `${new this.entityType()
2461
- .getCrudApiPath()
2462
- ?.toString()}/update-subscription/:statusPageId/:subscriberId`,
2463
- UserMiddleware.getUserMiddleware,
2464
- async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
2465
- try {
2466
- await this.subscribeToStatusPage(req);
2467
- return Response.sendEmptySuccessResponse(req, res);
1939
+ return Response.sendJsonObjectResponse(req, res, response);
2468
1940
  } catch (err) {
2469
1941
  next(err);
2470
1942
  }
@@ -2474,35 +1946,51 @@ export default class StatusPageAPI extends BaseAPI<
2474
1946
  this.router.post(
2475
1947
  `${new this.entityType()
2476
1948
  .getCrudApiPath()
2477
- ?.toString()}/get-subscription/:statusPageId/:subscriberId`,
1949
+ ?.toString()}/announcements/:statusPageIdOrDomain/:announcementId`,
2478
1950
  UserMiddleware.getUserMiddleware,
2479
1951
  async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
2480
1952
  try {
2481
- const subscriber: StatusPageSubscriber =
2482
- await this.getSubscriber(req);
1953
+ const objectId: ObjectID = await resolveStatusPageIdOrThrow(
1954
+ req.params["statusPageIdOrDomain"] as string,
1955
+ );
1956
+
1957
+ const announcementId: ObjectID = new ObjectID(
1958
+ req.params["announcementId"] as string,
1959
+ );
1960
+
1961
+ const response: JSONObject = await this.getAnnouncements(
1962
+ objectId,
1963
+ announcementId,
2483
1964
 
2484
- return Response.sendEntityResponse(
2485
1965
  req,
2486
- res,
2487
- subscriber,
2488
- StatusPageSubscriber,
2489
1966
  );
1967
+
1968
+ return Response.sendJsonObjectResponse(req, res, response);
2490
1969
  } catch (err) {
2491
1970
  next(err);
2492
1971
  }
2493
1972
  },
2494
1973
  );
2495
1974
 
1975
+ // Episodes endpoints
2496
1976
  this.router.post(
2497
1977
  `${new this.entityType()
2498
1978
  .getCrudApiPath()
2499
- ?.toString()}/subscribe/:statusPageId`,
1979
+ ?.toString()}/episodes/:statusPageIdOrDomain`,
2500
1980
  UserMiddleware.getUserMiddleware,
2501
1981
  async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
2502
1982
  try {
2503
- await this.subscribeToStatusPage(req);
1983
+ const objectId: ObjectID = await resolveStatusPageIdOrThrow(
1984
+ req.params["statusPageIdOrDomain"] as string,
1985
+ );
2504
1986
 
2505
- return Response.sendEmptySuccessResponse(req, res);
1987
+ const response: JSONObject = await this.getEpisodes(
1988
+ objectId,
1989
+ null,
1990
+ req,
1991
+ );
1992
+
1993
+ return Response.sendJsonObjectResponse(req, res, response);
2506
1994
  } catch (err) {
2507
1995
  next(err);
2508
1996
  }
@@ -2512,319 +2000,42 @@ export default class StatusPageAPI extends BaseAPI<
2512
2000
  this.router.post(
2513
2001
  `${new this.entityType()
2514
2002
  .getCrudApiPath()
2515
- ?.toString()}/manage-subscription/:statusPageId`,
2003
+ ?.toString()}/episodes/:statusPageIdOrDomain/:episodeId`,
2516
2004
  UserMiddleware.getUserMiddleware,
2517
2005
  async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
2518
2006
  try {
2519
- await this.manageExistingSubscription(req);
2007
+ const objectId: ObjectID = await resolveStatusPageIdOrThrow(
2008
+ req.params["statusPageIdOrDomain"] as string,
2009
+ );
2520
2010
 
2521
- return Response.sendEmptySuccessResponse(req, res);
2011
+ const episodeId: ObjectID = new ObjectID(
2012
+ req.params["episodeId"] as string,
2013
+ );
2014
+
2015
+ const response: JSONObject = await this.getEpisodes(
2016
+ objectId,
2017
+ episodeId,
2018
+ req,
2019
+ );
2020
+
2021
+ return Response.sendJsonObjectResponse(req, res, response);
2522
2022
  } catch (err) {
2523
2023
  next(err);
2524
2024
  }
2525
2025
  },
2526
2026
  );
2027
+ }
2527
2028
 
2528
- /*
2529
- * Shared handler for the incidents list endpoint. Registered for both
2530
- * POST and GET (same auth path). It does not read req.body.
2531
- */
2532
- const incidentsListHandler: (
2533
- req: ExpressRequest,
2534
- res: ExpressResponse,
2535
- next: NextFunction,
2536
- ) => Promise<void> = async (
2537
- req: ExpressRequest,
2538
- res: ExpressResponse,
2539
- next: NextFunction,
2540
- ): Promise<void> => {
2541
- try {
2542
- const objectId: ObjectID = await resolveStatusPageIdOrThrow(
2543
- req.params["statusPageIdOrDomain"] as string,
2544
- );
2545
-
2546
- const response: JSONObject = await this.getIncidents(
2547
- objectId,
2548
- null,
2549
- req,
2550
- );
2551
-
2552
- // These can serve private-page data on a GET; never let shared caches store them.
2553
- Response.setNoCacheHeaders(res);
2554
-
2555
- return Response.sendJsonObjectResponse(req, res, response);
2556
- } catch (err) {
2557
- next(err);
2558
- }
2559
- };
2560
-
2561
- const incidentsListApiPath: string = `${new this.entityType()
2562
- .getCrudApiPath()
2563
- ?.toString()}/incidents/:statusPageIdOrDomain`;
2564
-
2565
- this.router.post(
2566
- incidentsListApiPath,
2567
- UserMiddleware.getUserMiddleware,
2568
- incidentsListHandler,
2569
- );
2570
-
2571
- this.router.get(
2572
- incidentsListApiPath,
2573
- UserMiddleware.getUserMiddleware,
2574
- incidentsListHandler,
2575
- );
2576
-
2577
- /*
2578
- * Shared handler for the scheduled maintenance events list endpoint.
2579
- * Registered for both POST and GET (same auth path). It does not read
2580
- * req.body.
2581
- */
2582
- const scheduledMaintenanceEventsListHandler: (
2583
- req: ExpressRequest,
2584
- res: ExpressResponse,
2585
- next: NextFunction,
2586
- ) => Promise<void> = async (
2587
- req: ExpressRequest,
2588
- res: ExpressResponse,
2589
- next: NextFunction,
2590
- ): Promise<void> => {
2591
- try {
2592
- const objectId: ObjectID = await resolveStatusPageIdOrThrow(
2593
- req.params["statusPageIdOrDomain"] as string,
2594
- );
2595
-
2596
- const response: JSONObject = await this.getScheduledMaintenanceEvents(
2597
- objectId,
2598
- null,
2599
-
2600
- req,
2601
- );
2602
-
2603
- // These can serve private-page data on a GET; never let shared caches store them.
2604
- Response.setNoCacheHeaders(res);
2605
-
2606
- return Response.sendJsonObjectResponse(req, res, response);
2607
- } catch (err) {
2608
- next(err);
2609
- }
2610
- };
2611
-
2612
- const scheduledMaintenanceEventsListApiPath: string = `${new this.entityType()
2613
- .getCrudApiPath()
2614
- ?.toString()}/scheduled-maintenance-events/:statusPageIdOrDomain`;
2615
-
2616
- this.router.post(
2617
- scheduledMaintenanceEventsListApiPath,
2618
- UserMiddleware.getUserMiddleware,
2619
- scheduledMaintenanceEventsListHandler,
2620
- );
2621
-
2622
- this.router.get(
2623
- scheduledMaintenanceEventsListApiPath,
2624
- UserMiddleware.getUserMiddleware,
2625
- scheduledMaintenanceEventsListHandler,
2626
- );
2627
-
2628
- /*
2629
- * Shared handler for the announcements list endpoint. Registered for
2630
- * both POST and GET (same auth path). It does not read req.body.
2631
- */
2632
- const announcementsListHandler: (
2633
- req: ExpressRequest,
2634
- res: ExpressResponse,
2635
- next: NextFunction,
2636
- ) => Promise<void> = async (
2637
- req: ExpressRequest,
2638
- res: ExpressResponse,
2639
- next: NextFunction,
2640
- ): Promise<void> => {
2641
- try {
2642
- const objectId: ObjectID = await resolveStatusPageIdOrThrow(
2643
- req.params["statusPageIdOrDomain"] as string,
2644
- );
2645
-
2646
- const response: JSONObject = await this.getAnnouncements(
2647
- objectId,
2648
- null,
2649
-
2650
- req,
2651
- );
2652
-
2653
- // These can serve private-page data on a GET; never let shared caches store them.
2654
- Response.setNoCacheHeaders(res);
2655
-
2656
- return Response.sendJsonObjectResponse(req, res, response);
2657
- } catch (err) {
2658
- next(err);
2659
- }
2660
- };
2661
-
2662
- const announcementsListApiPath: string = `${new this.entityType()
2663
- .getCrudApiPath()
2664
- ?.toString()}/announcements/:statusPageIdOrDomain`;
2665
-
2666
- this.router.post(
2667
- announcementsListApiPath,
2668
- UserMiddleware.getUserMiddleware,
2669
- announcementsListHandler,
2670
- );
2671
-
2672
- this.router.get(
2673
- announcementsListApiPath,
2674
- UserMiddleware.getUserMiddleware,
2675
- announcementsListHandler,
2676
- );
2677
-
2678
- this.router.post(
2679
- `${new this.entityType()
2680
- .getCrudApiPath()
2681
- ?.toString()}/incidents/:statusPageIdOrDomain/:incidentId`,
2682
- UserMiddleware.getUserMiddleware,
2683
- async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
2684
- try {
2685
- const objectId: ObjectID = await resolveStatusPageIdOrThrow(
2686
- req.params["statusPageIdOrDomain"] as string,
2687
- );
2688
-
2689
- const incidentId: ObjectID = new ObjectID(
2690
- req.params["incidentId"] as string,
2691
- );
2692
-
2693
- const response: JSONObject = await this.getIncidents(
2694
- objectId,
2695
- incidentId,
2696
- req,
2697
- );
2698
-
2699
- return Response.sendJsonObjectResponse(req, res, response);
2700
- } catch (err) {
2701
- next(err);
2702
- }
2703
- },
2704
- );
2705
-
2706
- this.router.post(
2707
- `${new this.entityType()
2708
- .getCrudApiPath()
2709
- ?.toString()}/scheduled-maintenance-events/:statusPageIdOrDomain/:scheduledMaintenanceId`,
2710
- UserMiddleware.getUserMiddleware,
2711
- async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
2712
- try {
2713
- const objectId: ObjectID = await resolveStatusPageIdOrThrow(
2714
- req.params["statusPageIdOrDomain"] as string,
2715
- );
2716
-
2717
- const scheduledMaintenanceId: ObjectID = new ObjectID(
2718
- req.params["scheduledMaintenanceId"] as string,
2719
- );
2720
-
2721
- const response: JSONObject = await this.getScheduledMaintenanceEvents(
2722
- objectId,
2723
- scheduledMaintenanceId,
2724
-
2725
- req,
2726
- );
2727
-
2728
- return Response.sendJsonObjectResponse(req, res, response);
2729
- } catch (err) {
2730
- next(err);
2731
- }
2732
- },
2733
- );
2734
-
2735
- this.router.post(
2736
- `${new this.entityType()
2737
- .getCrudApiPath()
2738
- ?.toString()}/announcements/:statusPageIdOrDomain/:announcementId`,
2739
- UserMiddleware.getUserMiddleware,
2740
- async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
2741
- try {
2742
- const objectId: ObjectID = await resolveStatusPageIdOrThrow(
2743
- req.params["statusPageIdOrDomain"] as string,
2744
- );
2745
-
2746
- const announcementId: ObjectID = new ObjectID(
2747
- req.params["announcementId"] as string,
2748
- );
2749
-
2750
- const response: JSONObject = await this.getAnnouncements(
2751
- objectId,
2752
- announcementId,
2753
-
2754
- req,
2755
- );
2756
-
2757
- return Response.sendJsonObjectResponse(req, res, response);
2758
- } catch (err) {
2759
- next(err);
2760
- }
2761
- },
2762
- );
2763
-
2764
- // Episodes endpoints
2765
- this.router.post(
2766
- `${new this.entityType()
2767
- .getCrudApiPath()
2768
- ?.toString()}/episodes/:statusPageIdOrDomain`,
2769
- UserMiddleware.getUserMiddleware,
2770
- async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
2771
- try {
2772
- const objectId: ObjectID = await resolveStatusPageIdOrThrow(
2773
- req.params["statusPageIdOrDomain"] as string,
2774
- );
2775
-
2776
- const response: JSONObject = await this.getEpisodes(
2777
- objectId,
2778
- null,
2779
- req,
2780
- );
2781
-
2782
- return Response.sendJsonObjectResponse(req, res, response);
2783
- } catch (err) {
2784
- next(err);
2785
- }
2786
- },
2787
- );
2788
-
2789
- this.router.post(
2790
- `${new this.entityType()
2791
- .getCrudApiPath()
2792
- ?.toString()}/episodes/:statusPageIdOrDomain/:episodeId`,
2793
- UserMiddleware.getUserMiddleware,
2794
- async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
2795
- try {
2796
- const objectId: ObjectID = await resolveStatusPageIdOrThrow(
2797
- req.params["statusPageIdOrDomain"] as string,
2798
- );
2799
-
2800
- const episodeId: ObjectID = new ObjectID(
2801
- req.params["episodeId"] as string,
2802
- );
2803
-
2804
- const response: JSONObject = await this.getEpisodes(
2805
- objectId,
2806
- episodeId,
2807
- req,
2808
- );
2809
-
2810
- return Response.sendJsonObjectResponse(req, res, response);
2811
- } catch (err) {
2812
- next(err);
2813
- }
2814
- },
2815
- );
2816
- }
2817
-
2818
- @CaptureSpan()
2819
- public async getScheduledMaintenanceEvents(
2820
- statusPageId: ObjectID,
2821
- scheduledMaintenanceId: ObjectID | null,
2822
- req: ExpressRequest,
2823
- ): Promise<JSONObject> {
2824
- await this.checkHasReadAccess({
2825
- statusPageId: statusPageId,
2826
- req: req,
2827
- });
2029
+ @CaptureSpan()
2030
+ public async getScheduledMaintenanceEvents(
2031
+ statusPageId: ObjectID,
2032
+ scheduledMaintenanceId: ObjectID | null,
2033
+ req: ExpressRequest,
2034
+ ): Promise<JSONObject> {
2035
+ await this.checkHasReadAccess({
2036
+ statusPageId: statusPageId,
2037
+ req: req,
2038
+ });
2828
2039
 
2829
2040
  const statusPage: StatusPage | null = await StatusPageService.findOneBy({
2830
2041
  query: {
@@ -3757,403 +2968,681 @@ export default class StatusPageAPI extends BaseAPI<
3757
2968
  getLogAttributesFromRequest(req as any),
3758
2969
  );
3759
2970
  throw new BadDataException(
3760
- "Email subscribers not enabled for this status page.",
2971
+ "Email subscribers not enabled for this status page.",
2972
+ );
2973
+ }
2974
+
2975
+ if (req.body.data["subscriberPhone"] && !statusPage.enableSmsSubscribers) {
2976
+ logger.debug(
2977
+ `SMS subscribers not enabled for status page with ID: ${objectId}`,
2978
+ getLogAttributesFromRequest(req as any),
2979
+ );
2980
+ throw new BadDataException(
2981
+ "SMS subscribers not enabled for this status page.",
2982
+ );
2983
+ }
2984
+
2985
+ // if no email or phone, throw error.
2986
+
2987
+ if (
2988
+ req.body.data["slackWorkspaceName"] &&
2989
+ !statusPage.enableSlackSubscribers
2990
+ ) {
2991
+ logger.debug(
2992
+ `Slack subscribers not enabled for status page with ID: ${objectId}`,
2993
+ getLogAttributesFromRequest(req as any),
2994
+ );
2995
+ throw new BadDataException(
2996
+ "Slack subscribers not enabled for this status page.",
2997
+ );
2998
+ }
2999
+
3000
+ if (
3001
+ req.body.data["microsoftTeamsWorkspaceName"] &&
3002
+ !statusPage.enableMicrosoftTeamsSubscribers
3003
+ ) {
3004
+ logger.debug(
3005
+ `Microsoft Teams subscribers not enabled for status page with ID: ${objectId}`,
3006
+ getLogAttributesFromRequest(req as any),
3007
+ );
3008
+ throw new BadDataException(
3009
+ "Microsoft Teams subscribers not enabled for this status page.",
3010
+ );
3011
+ }
3012
+
3013
+ if (
3014
+ req.body.data["subscriberWebhook"] &&
3015
+ !statusPage.enableWebhookSubscribers
3016
+ ) {
3017
+ logger.debug(
3018
+ `Webhook subscribers not enabled for status page with ID: ${objectId}`,
3019
+ getLogAttributesFromRequest(req as any),
3020
+ );
3021
+ throw new BadDataException(
3022
+ "Webhook subscribers not enabled for this status page.",
3023
+ );
3024
+ }
3025
+
3026
+ if (
3027
+ !req.params["subscriberId"] &&
3028
+ !req.body.data["subscriberEmail"] &&
3029
+ !req.body.data["subscriberPhone"] &&
3030
+ !req.body.data["slackWorkspaceName"] &&
3031
+ !req.body.data["microsoftTeamsWorkspaceName"] &&
3032
+ !req.body.data["subscriberWebhook"]
3033
+ ) {
3034
+ logger.debug(
3035
+ `No email, phone, slack workspace name, Microsoft Teams workspace name, or webhook URL provided for subscription to status page with ID: ${objectId}`,
3036
+ getLogAttributesFromRequest(req as any),
3037
+ );
3038
+ throw new BadDataException(
3039
+ "Email, phone, slack workspace name, Microsoft Teams workspace name, or webhook URL is required to subscribe to this status page.",
3040
+ );
3041
+ }
3042
+
3043
+ const email: Email | undefined = req.body.data["subscriberEmail"]
3044
+ ? new Email(req.body.data["subscriberEmail"] as string)
3045
+ : undefined;
3046
+
3047
+ const phone: Phone | undefined = req.body.data["subscriberPhone"]
3048
+ ? new Phone(req.body.data["subscriberPhone"] as string)
3049
+ : undefined;
3050
+
3051
+ const slackIncomingWebhookUrl: string | undefined = req.body.data[
3052
+ "slackIncomingWebhookUrl"
3053
+ ]
3054
+ ? (req.body.data["slackIncomingWebhookUrl"] as string)
3055
+ : undefined;
3056
+
3057
+ const slackWorkspaceName: string | undefined = req.body.data[
3058
+ "slackWorkspaceName"
3059
+ ]
3060
+ ? (req.body.data["slackWorkspaceName"] as string)
3061
+ : undefined;
3062
+
3063
+ const microsoftTeamsIncomingWebhookUrl: string | undefined = req.body.data[
3064
+ "microsoftTeamsIncomingWebhookUrl"
3065
+ ]
3066
+ ? (req.body.data["microsoftTeamsIncomingWebhookUrl"] as string)
3067
+ : undefined;
3068
+
3069
+ const microsoftTeamsWorkspaceName: string | undefined = req.body.data[
3070
+ "microsoftTeamsWorkspaceName"
3071
+ ]
3072
+ ? (req.body.data["microsoftTeamsWorkspaceName"] as string)
3073
+ : undefined;
3074
+
3075
+ const subscriberWebhookUrl: string | undefined = req.body.data[
3076
+ "subscriberWebhook"
3077
+ ]
3078
+ ? (req.body.data["subscriberWebhook"] as string)
3079
+ : undefined;
3080
+
3081
+ let statusPageSubscriber: StatusPageSubscriber | null = null;
3082
+
3083
+ let isUpdate: boolean = false;
3084
+
3085
+ if (!req.params["subscriberId"]) {
3086
+ logger.debug(
3087
+ `Creating new subscriber for status page with ID: ${objectId}`,
3088
+ getLogAttributesFromRequest(req as any),
3089
+ );
3090
+ statusPageSubscriber = new StatusPageSubscriber();
3091
+ } else {
3092
+ const subscriberId: ObjectID = new ObjectID(
3093
+ req.params["subscriberId"] as string,
3094
+ );
3095
+
3096
+ logger.debug(
3097
+ `Updating existing subscriber with ID: ${subscriberId} for status page with ID: ${objectId}`,
3098
+ getLogAttributesFromRequest(req as any),
3099
+ );
3100
+ /*
3101
+ * Scope the lookup to the status page in the route. This endpoint only
3102
+ * checks read access on that status page, so without the statusPageId
3103
+ * clause anyone holding a subscriber UUID could pass any status page they
3104
+ * can read and edit a subscriber belonging to a different — possibly
3105
+ * private — status page. A subscriber on another page must be
3106
+ * indistinguishable from one that does not exist, so that the shared
3107
+ * "Subscriber not found" below cannot be used to probe for valid UUIDs.
3108
+ */
3109
+ statusPageSubscriber = await StatusPageSubscriberService.findOneBy({
3110
+ query: {
3111
+ _id: subscriberId.toString(),
3112
+ statusPageId: objectId,
3113
+ },
3114
+ props: {
3115
+ isRoot: true,
3116
+ },
3117
+ });
3118
+
3119
+ if (!statusPageSubscriber) {
3120
+ logger.debug(
3121
+ `Subscriber not found with ID: ${subscriberId} on status page with ID: ${objectId}`,
3122
+ getLogAttributesFromRequest(req as any),
3123
+ );
3124
+ throw new BadDataException("Subscriber not found");
3125
+ }
3126
+
3127
+ isUpdate = true;
3128
+ }
3129
+
3130
+ if (email) {
3131
+ logger.debug(
3132
+ `Setting subscriber email: ${email}`,
3133
+ getLogAttributesFromRequest(req as any),
3134
+ );
3135
+ statusPageSubscriber.subscriberEmail = email;
3136
+ }
3137
+
3138
+ if (phone) {
3139
+ logger.debug(
3140
+ `Setting subscriber phone: ${phone}`,
3141
+ getLogAttributesFromRequest(req as any),
3142
+ );
3143
+ statusPageSubscriber.subscriberPhone = phone;
3144
+ }
3145
+
3146
+ if (slackIncomingWebhookUrl) {
3147
+ logger.debug(
3148
+ `Setting subscriber slack: ${slackIncomingWebhookUrl}`,
3149
+ getLogAttributesFromRequest(req as any),
3150
+ );
3151
+ statusPageSubscriber.slackIncomingWebhookUrl = URL.fromString(
3152
+ slackIncomingWebhookUrl,
3153
+ );
3154
+ }
3155
+
3156
+ if (slackWorkspaceName) {
3157
+ logger.debug(
3158
+ `Setting subscriber slack workspace name: ${slackWorkspaceName}`,
3159
+ getLogAttributesFromRequest(req as any),
3160
+ );
3161
+ statusPageSubscriber.slackWorkspaceName = slackWorkspaceName;
3162
+ }
3163
+
3164
+ if (microsoftTeamsIncomingWebhookUrl) {
3165
+ logger.debug(
3166
+ `Setting subscriber Microsoft Teams webhook: ${microsoftTeamsIncomingWebhookUrl}`,
3167
+ getLogAttributesFromRequest(req as any),
3168
+ );
3169
+ statusPageSubscriber.microsoftTeamsIncomingWebhookUrl = URL.fromString(
3170
+ microsoftTeamsIncomingWebhookUrl,
3171
+ );
3172
+ }
3173
+
3174
+ if (microsoftTeamsWorkspaceName) {
3175
+ logger.debug(
3176
+ `Setting subscriber Microsoft Teams workspace name: ${microsoftTeamsWorkspaceName}`,
3177
+ getLogAttributesFromRequest(req as any),
3178
+ );
3179
+ statusPageSubscriber.microsoftTeamsWorkspaceName =
3180
+ microsoftTeamsWorkspaceName;
3181
+ }
3182
+
3183
+ if (subscriberWebhookUrl) {
3184
+ logger.debug(
3185
+ `Setting subscriber webhook URL: ${subscriberWebhookUrl}`,
3186
+ getLogAttributesFromRequest(req as any),
3187
+ );
3188
+ statusPageSubscriber.subscriberWebhook =
3189
+ URL.fromString(subscriberWebhookUrl);
3190
+ }
3191
+
3192
+ if (
3193
+ !isUpdate &&
3194
+ req.body.data["statusPageResources"] &&
3195
+ !statusPage.allowSubscribersToChooseResources
3196
+ ) {
3197
+ logger.debug(
3198
+ `Subscribers not allowed to choose resources for status page with ID: ${objectId}`,
3199
+ getLogAttributesFromRequest(req as any),
3200
+ );
3201
+ throw new BadDataException(
3202
+ "Subscribers are not allowed to choose resources for this status page.",
3761
3203
  );
3762
3204
  }
3763
3205
 
3764
- if (req.body.data["subscriberPhone"] && !statusPage.enableSmsSubscribers) {
3206
+ if (
3207
+ !isUpdate &&
3208
+ req.body.data["statusPageEventTypes"] &&
3209
+ !statusPage.allowSubscribersToChooseEventTypes
3210
+ ) {
3765
3211
  logger.debug(
3766
- `SMS subscribers not enabled for status page with ID: ${objectId}`,
3212
+ `Subscribers not allowed to choose event types for status page with ID: ${objectId}`,
3767
3213
  getLogAttributesFromRequest(req as any),
3768
3214
  );
3769
3215
  throw new BadDataException(
3770
- "SMS subscribers not enabled for this status page.",
3216
+ "Subscribers are not allowed to choose event types for this status page.",
3771
3217
  );
3772
3218
  }
3773
3219
 
3774
- // if no email or phone, throw error.
3220
+ statusPageSubscriber.statusPageId = objectId;
3221
+ statusPageSubscriber.sendYouHaveSubscribedMessage = true;
3222
+ statusPageSubscriber.projectId = statusPage.projectId!;
3223
+ statusPageSubscriber.isSubscribedToAllResources = Boolean(
3224
+ req.body.data["isSubscribedToAllResources"],
3225
+ );
3226
+
3227
+ statusPageSubscriber.isSubscribedToAllEventTypes = Boolean(
3228
+ req.body.data["isSubscribedToAllEventTypes"],
3229
+ );
3775
3230
 
3776
3231
  if (
3777
- req.body.data["slackWorkspaceName"] &&
3778
- !statusPage.enableSlackSubscribers
3232
+ req.body.data["statusPageResources"] &&
3233
+ req.body.data["statusPageResources"].length > 0
3779
3234
  ) {
3780
3235
  logger.debug(
3781
- `Slack subscribers not enabled for status page with ID: ${objectId}`,
3236
+ `Setting subscriber resources: ${JSON.stringify(req.body.data["statusPageResources"])}`,
3782
3237
  getLogAttributesFromRequest(req as any),
3783
3238
  );
3784
- throw new BadDataException(
3785
- "Slack subscribers not enabled for this status page.",
3786
- );
3239
+ statusPageSubscriber.statusPageResources = req.body.data[
3240
+ "statusPageResources"
3241
+ ] as Array<StatusPageResource>;
3787
3242
  }
3788
3243
 
3789
3244
  if (
3790
- req.body.data["microsoftTeamsWorkspaceName"] &&
3791
- !statusPage.enableMicrosoftTeamsSubscribers
3245
+ req.body.data["statusPageEventTypes"] &&
3246
+ req.body.data["statusPageEventTypes"].length > 0
3792
3247
  ) {
3793
3248
  logger.debug(
3794
- `Microsoft Teams subscribers not enabled for status page with ID: ${objectId}`,
3249
+ `Setting subscriber event types: ${JSON.stringify(req.body.data["statusPageEventTypes"])}`,
3795
3250
  getLogAttributesFromRequest(req as any),
3796
3251
  );
3797
- throw new BadDataException(
3798
- "Microsoft Teams subscribers not enabled for this status page.",
3799
- );
3252
+ statusPageSubscriber.statusPageEventTypes = req.body.data[
3253
+ "statusPageEventTypes"
3254
+ ] as Array<StatusPageEventType>;
3800
3255
  }
3801
3256
 
3802
- if (
3803
- req.body.data["subscriberWebhook"] &&
3804
- !statusPage.enableWebhookSubscribers
3805
- ) {
3257
+ if (isUpdate) {
3258
+ // check isUnsubscribed is set to false.
3806
3259
  logger.debug(
3807
- `Webhook subscribers not enabled for status page with ID: ${objectId}`,
3260
+ `Updating subscriber with ID: ${statusPageSubscriber.id}`,
3808
3261
  getLogAttributesFromRequest(req as any),
3809
3262
  );
3810
- throw new BadDataException(
3811
- "Webhook subscribers not enabled for this status page.",
3263
+ statusPageSubscriber.isUnsubscribed = Boolean(
3264
+ req.body.data["isUnsubscribed"],
3812
3265
  );
3813
- }
3814
3266
 
3815
- if (
3816
- !req.params["subscriberId"] &&
3817
- !req.body.data["subscriberEmail"] &&
3818
- !req.body.data["subscriberPhone"] &&
3819
- !req.body.data["slackWorkspaceName"] &&
3820
- !req.body.data["microsoftTeamsWorkspaceName"] &&
3821
- !req.body.data["subscriberWebhook"]
3822
- ) {
3267
+ await StatusPageSubscriberService.updateOneById({
3268
+ id: statusPageSubscriber.id!,
3269
+ data: {
3270
+ statusPageResources: statusPageSubscriber.statusPageResources!,
3271
+ isSubscribedToAllResources:
3272
+ statusPageSubscriber.isSubscribedToAllResources!,
3273
+ statusPageEventTypes: statusPageSubscriber.statusPageEventTypes!,
3274
+ isSubscribedToAllEventTypes:
3275
+ statusPageSubscriber.isSubscribedToAllEventTypes!,
3276
+ isUnsubscribed: statusPageSubscriber.isUnsubscribed,
3277
+ } as any,
3278
+ props: {
3279
+ isRoot: true,
3280
+ },
3281
+ });
3282
+ } else {
3823
3283
  logger.debug(
3824
- `No email, phone, slack workspace name, Microsoft Teams workspace name, or webhook URL provided for subscription to status page with ID: ${objectId}`,
3284
+ `Creating new subscriber: ${JSON.stringify(statusPageSubscriber)}`,
3825
3285
  getLogAttributesFromRequest(req as any),
3826
3286
  );
3827
- throw new BadDataException(
3828
- "Email, phone, slack workspace name, Microsoft Teams workspace name, or webhook URL is required to subscribe to this status page.",
3829
- );
3287
+ await StatusPageSubscriberService.create({
3288
+ data: statusPageSubscriber,
3289
+ props: {
3290
+ isRoot: true,
3291
+ },
3292
+ });
3830
3293
  }
3831
3294
 
3832
- const email: Email | undefined = req.body.data["subscriberEmail"]
3833
- ? new Email(req.body.data["subscriberEmail"] as string)
3834
- : undefined;
3835
-
3836
- const phone: Phone | undefined = req.body.data["subscriberPhone"]
3837
- ? new Phone(req.body.data["subscriberPhone"] as string)
3838
- : undefined;
3839
-
3840
- const slackIncomingWebhookUrl: string | undefined = req.body.data[
3841
- "slackIncomingWebhookUrl"
3842
- ]
3843
- ? (req.body.data["slackIncomingWebhookUrl"] as string)
3844
- : undefined;
3845
-
3846
- const slackWorkspaceName: string | undefined = req.body.data[
3847
- "slackWorkspaceName"
3848
- ]
3849
- ? (req.body.data["slackWorkspaceName"] as string)
3850
- : undefined;
3851
-
3852
- const microsoftTeamsIncomingWebhookUrl: string | undefined = req.body.data[
3853
- "microsoftTeamsIncomingWebhookUrl"
3854
- ]
3855
- ? (req.body.data["microsoftTeamsIncomingWebhookUrl"] as string)
3856
- : undefined;
3295
+ logger.debug(
3296
+ `Subscription process completed for status page with ID: ${objectId}`,
3297
+ getLogAttributesFromRequest(req as any),
3298
+ );
3299
+ }
3857
3300
 
3858
- const microsoftTeamsWorkspaceName: string | undefined = req.body.data[
3859
- "microsoftTeamsWorkspaceName"
3860
- ]
3861
- ? (req.body.data["microsoftTeamsWorkspaceName"] as string)
3862
- : undefined;
3301
+ @CaptureSpan()
3302
+ public async getSubscriber(
3303
+ req: ExpressRequest,
3304
+ ): Promise<StatusPageSubscriber> {
3305
+ const objectId: ObjectID = new ObjectID(
3306
+ req.params["statusPageId"] as string,
3307
+ );
3863
3308
 
3864
- const subscriberWebhookUrl: string | undefined = req.body.data[
3865
- "subscriberWebhook"
3866
- ]
3867
- ? (req.body.data["subscriberWebhook"] as string)
3868
- : undefined;
3309
+ await this.checkHasReadAccess({
3310
+ statusPageId: objectId,
3311
+ req: req,
3312
+ });
3869
3313
 
3870
- let statusPageSubscriber: StatusPageSubscriber | null = null;
3314
+ const statusPage: StatusPage | null = await StatusPageService.findOneBy({
3315
+ query: {
3316
+ _id: objectId.toString(),
3317
+ },
3318
+ select: {
3319
+ _id: true,
3320
+ projectId: true,
3321
+ },
3322
+ props: {
3323
+ isRoot: true,
3324
+ },
3325
+ });
3871
3326
 
3872
- let isUpdate: boolean = false;
3327
+ if (!statusPage) {
3328
+ throw new BadDataException("Status Page not found");
3329
+ }
3873
3330
 
3874
- if (!req.params["subscriberId"]) {
3875
- logger.debug(
3876
- `Creating new subscriber for status page with ID: ${objectId}`,
3877
- getLogAttributesFromRequest(req as any),
3878
- );
3879
- statusPageSubscriber = new StatusPageSubscriber();
3880
- } else {
3881
- const subscriberId: ObjectID = new ObjectID(
3882
- req.params["subscriberId"] as string,
3883
- );
3331
+ const subscriberId: ObjectID = new ObjectID(
3332
+ req.params["subscriberId"] as string,
3333
+ );
3884
3334
 
3885
- logger.debug(
3886
- `Updating existing subscriber with ID: ${subscriberId} for status page with ID: ${objectId}`,
3887
- getLogAttributesFromRequest(req as any),
3888
- );
3889
- /*
3890
- * Scope the lookup to the status page in the route. This endpoint only
3891
- * checks read access on that status page, so without the statusPageId
3892
- * clause anyone holding a subscriber UUID could pass any status page they
3893
- * can read and edit a subscriber belonging to a different — possibly
3894
- * private — status page. A subscriber on another page must be
3895
- * indistinguishable from one that does not exist, so that the shared
3896
- * "Subscriber not found" below cannot be used to probe for valid UUIDs.
3897
- */
3898
- statusPageSubscriber = await StatusPageSubscriberService.findOneBy({
3335
+ const statusPageSubscriber: StatusPageSubscriber | null =
3336
+ await StatusPageSubscriberService.findOneBy({
3899
3337
  query: {
3900
3338
  _id: subscriberId.toString(),
3901
- statusPageId: objectId,
3339
+ statusPageId: statusPage.id!,
3340
+ },
3341
+ select: {
3342
+ isUnsubscribed: true,
3343
+ subscriberEmail: true,
3344
+ subscriberPhone: true,
3345
+ slackWorkspaceName: true,
3346
+ statusPageId: true,
3347
+ statusPageResources: true,
3348
+ isSubscribedToAllResources: true,
3349
+ statusPageEventTypes: true,
3350
+ isSubscribedToAllEventTypes: true,
3902
3351
  },
3903
3352
  props: {
3904
3353
  isRoot: true,
3905
3354
  },
3906
3355
  });
3907
3356
 
3908
- if (!statusPageSubscriber) {
3909
- logger.debug(
3910
- `Subscriber not found with ID: ${subscriberId} on status page with ID: ${objectId}`,
3911
- getLogAttributesFromRequest(req as any),
3912
- );
3913
- throw new BadDataException("Subscriber not found");
3914
- }
3357
+ if (!statusPageSubscriber) {
3358
+ throw new BadDataException("Subscriber not found");
3359
+ }
3360
+
3361
+ return statusPageSubscriber;
3362
+ }
3363
+
3364
+ @CaptureSpan()
3365
+ public async getIncidents(
3366
+ statusPageId: ObjectID,
3367
+ incidentId: ObjectID | null,
3368
+ req: ExpressRequest,
3369
+ ): Promise<JSONObject> {
3370
+ await this.checkHasReadAccess({
3371
+ statusPageId: statusPageId,
3372
+ req: req,
3373
+ });
3374
+
3375
+ const statusPage: StatusPage | null = await StatusPageService.findOneBy({
3376
+ query: {
3377
+ _id: statusPageId.toString(),
3378
+ },
3379
+ select: {
3380
+ _id: true,
3381
+ projectId: true,
3382
+ showIncidentHistoryInDays: true,
3383
+ showIncidentLabelsOnStatusPage: true,
3384
+ showIncidentsOnStatusPage: true,
3385
+ },
3386
+ props: {
3387
+ isRoot: true,
3388
+ },
3389
+ });
3390
+
3391
+ if (!statusPage) {
3392
+ throw new BadDataException("Status Page not found");
3393
+ }
3394
+
3395
+ if (!statusPage.showIncidentsOnStatusPage) {
3396
+ throw new BadDataException(
3397
+ "Incidents are not enabled on this status page.",
3398
+ );
3399
+ }
3400
+
3401
+ // get monitors on status page.
3402
+ const statusPageResources: Array<StatusPageResource> =
3403
+ await StatusPageService.getStatusPageResources({
3404
+ statusPageId: statusPageId,
3405
+ });
3406
+
3407
+ const { monitorsOnStatusPage, monitorsInGroup } =
3408
+ await StatusPageService.getMonitorIdsOnStatusPage({
3409
+ statusPageId: statusPageId,
3410
+ // reuse the resources fetched above instead of re-querying them
3411
+ statusPageResources: statusPageResources,
3412
+ });
3915
3413
 
3916
- isUpdate = true;
3917
- }
3414
+ const today: Date = OneUptimeDate.getCurrentDate();
3918
3415
 
3919
- if (email) {
3920
- logger.debug(
3921
- `Setting subscriber email: ${email}`,
3922
- getLogAttributesFromRequest(req as any),
3923
- );
3924
- statusPageSubscriber.subscriberEmail = email;
3925
- }
3416
+ const historyDays: Date = OneUptimeDate.getSomeDaysAgo(
3417
+ statusPage.showIncidentHistoryInDays || 14,
3418
+ );
3926
3419
 
3927
- if (phone) {
3928
- logger.debug(
3929
- `Setting subscriber phone: ${phone}`,
3930
- getLogAttributesFromRequest(req as any),
3931
- );
3932
- statusPageSubscriber.subscriberPhone = phone;
3933
- }
3420
+ let incidentQuery: Query<Incident> = {
3421
+ monitors: monitorsOnStatusPage as any,
3422
+ projectId: statusPage.projectId!,
3423
+ createdAt: QueryHelper.inBetween(historyDays, today),
3424
+ isVisibleOnStatusPage: true,
3425
+ };
3934
3426
 
3935
- if (slackIncomingWebhookUrl) {
3936
- logger.debug(
3937
- `Setting subscriber slack: ${slackIncomingWebhookUrl}`,
3938
- getLogAttributesFromRequest(req as any),
3939
- );
3940
- statusPageSubscriber.slackIncomingWebhookUrl = URL.fromString(
3941
- slackIncomingWebhookUrl,
3942
- );
3427
+ if (incidentId) {
3428
+ incidentQuery = {
3429
+ monitors: monitorsOnStatusPage as any,
3430
+ projectId: statusPage.projectId!,
3431
+ _id: incidentId.toString(),
3432
+ isVisibleOnStatusPage: true,
3433
+ };
3943
3434
  }
3944
3435
 
3945
- if (slackWorkspaceName) {
3946
- logger.debug(
3947
- `Setting subscriber slack workspace name: ${slackWorkspaceName}`,
3948
- getLogAttributesFromRequest(req as any),
3949
- );
3950
- statusPageSubscriber.slackWorkspaceName = slackWorkspaceName;
3951
- }
3436
+ // check if status page has active incident.
3437
+ let incidents: Array<Incident> = [];
3952
3438
 
3953
- if (microsoftTeamsIncomingWebhookUrl) {
3954
- logger.debug(
3955
- `Setting subscriber Microsoft Teams webhook: ${microsoftTeamsIncomingWebhookUrl}`,
3956
- getLogAttributesFromRequest(req as any),
3957
- );
3958
- statusPageSubscriber.microsoftTeamsIncomingWebhookUrl = URL.fromString(
3959
- microsoftTeamsIncomingWebhookUrl,
3960
- );
3961
- }
3439
+ let selectIncidents: Select<Incident> = {
3440
+ createdAt: true,
3441
+ declaredAt: true,
3442
+ updatedAt: true,
3443
+ title: true,
3444
+ description: true,
3445
+ _id: true,
3446
+ postmortemNote: true,
3447
+ postmortemPostedAt: true,
3448
+ showPostmortemOnStatusPage: true,
3449
+ postmortemAttachments: {
3450
+ _id: true,
3451
+ name: true,
3452
+ },
3453
+ incidentSeverity: {
3454
+ name: true,
3455
+ color: true,
3456
+ },
3457
+ currentIncidentState: {
3458
+ name: true,
3459
+ color: true,
3460
+ _id: true,
3461
+ order: true,
3462
+ },
3463
+ monitors: {
3464
+ _id: true,
3465
+ },
3466
+ };
3962
3467
 
3963
- if (microsoftTeamsWorkspaceName) {
3964
- logger.debug(
3965
- `Setting subscriber Microsoft Teams workspace name: ${microsoftTeamsWorkspaceName}`,
3966
- getLogAttributesFromRequest(req as any),
3967
- );
3968
- statusPageSubscriber.microsoftTeamsWorkspaceName =
3969
- microsoftTeamsWorkspaceName;
3468
+ if (statusPage.showIncidentLabelsOnStatusPage) {
3469
+ selectIncidents = {
3470
+ ...selectIncidents,
3471
+ labels: {
3472
+ name: true,
3473
+ color: true,
3474
+ },
3475
+ };
3970
3476
  }
3971
3477
 
3972
- if (subscriberWebhookUrl) {
3973
- logger.debug(
3974
- `Setting subscriber webhook URL: ${subscriberWebhookUrl}`,
3975
- getLogAttributesFromRequest(req as any),
3976
- );
3977
- statusPageSubscriber.subscriberWebhook =
3978
- URL.fromString(subscriberWebhookUrl);
3979
- }
3478
+ if (monitorsOnStatusPage.length > 0) {
3479
+ incidents = await IncidentService.findBy({
3480
+ query: incidentQuery,
3481
+ select: selectIncidents,
3482
+ sort: {
3483
+ declaredAt: SortOrder.Descending,
3484
+ createdAt: SortOrder.Descending,
3485
+ },
3486
+ skip: 0,
3487
+ limit: LIMIT_PER_PROJECT,
3488
+ props: {
3489
+ isRoot: true,
3490
+ },
3491
+ });
3980
3492
 
3981
- if (
3982
- !isUpdate &&
3983
- req.body.data["statusPageResources"] &&
3984
- !statusPage.allowSubscribersToChooseResources
3985
- ) {
3986
- logger.debug(
3987
- `Subscribers not allowed to choose resources for status page with ID: ${objectId}`,
3988
- getLogAttributesFromRequest(req as any),
3989
- );
3990
- throw new BadDataException(
3991
- "Subscribers are not allowed to choose resources for this status page.",
3992
- );
3993
- }
3493
+ let activeIncidents: Array<Incident> = [];
3994
3494
 
3995
- if (
3996
- !isUpdate &&
3997
- req.body.data["statusPageEventTypes"] &&
3998
- !statusPage.allowSubscribersToChooseEventTypes
3999
- ) {
4000
- logger.debug(
4001
- `Subscribers not allowed to choose event types for status page with ID: ${objectId}`,
4002
- getLogAttributesFromRequest(req as any),
4003
- );
4004
- throw new BadDataException(
4005
- "Subscribers are not allowed to choose event types for this status page.",
4006
- );
4007
- }
3495
+ const unresolvedIncidentStates: Array<IncidentState> =
3496
+ await IncidentStateService.getUnresolvedIncidentStates(
3497
+ statusPage.projectId!,
3498
+ {
3499
+ isRoot: true,
3500
+ },
3501
+ );
4008
3502
 
4009
- statusPageSubscriber.statusPageId = objectId;
4010
- statusPageSubscriber.sendYouHaveSubscribedMessage = true;
4011
- statusPageSubscriber.projectId = statusPage.projectId!;
4012
- statusPageSubscriber.isSubscribedToAllResources = Boolean(
4013
- req.body.data["isSubscribedToAllResources"],
4014
- );
3503
+ const unresolvbedIncidentStateIds: Array<ObjectID> =
3504
+ unresolvedIncidentStates.map((state: IncidentState) => {
3505
+ return state.id!;
3506
+ });
4015
3507
 
4016
- statusPageSubscriber.isSubscribedToAllEventTypes = Boolean(
4017
- req.body.data["isSubscribedToAllEventTypes"],
4018
- );
3508
+ // If there is no particular incident id to fetch then fetch active incidents.
3509
+ if (!incidentId) {
3510
+ activeIncidents = await IncidentService.findBy({
3511
+ query: {
3512
+ monitors: monitorsOnStatusPage as any,
3513
+ isVisibleOnStatusPage: true,
3514
+ currentIncidentStateId: QueryHelper.any(
3515
+ unresolvbedIncidentStateIds,
3516
+ ),
3517
+ projectId: statusPage.projectId!,
3518
+ },
3519
+ select: selectIncidents,
3520
+ sort: {
3521
+ declaredAt: SortOrder.Descending,
3522
+ createdAt: SortOrder.Descending,
3523
+ },
4019
3524
 
4020
- if (
4021
- req.body.data["statusPageResources"] &&
4022
- req.body.data["statusPageResources"].length > 0
4023
- ) {
4024
- logger.debug(
4025
- `Setting subscriber resources: ${JSON.stringify(req.body.data["statusPageResources"])}`,
4026
- getLogAttributesFromRequest(req as any),
4027
- );
4028
- statusPageSubscriber.statusPageResources = req.body.data[
4029
- "statusPageResources"
4030
- ] as Array<StatusPageResource>;
4031
- }
3525
+ skip: 0,
3526
+ limit: LIMIT_PER_PROJECT,
3527
+ props: {
3528
+ isRoot: true,
3529
+ },
3530
+ });
3531
+ }
4032
3532
 
4033
- if (
4034
- req.body.data["statusPageEventTypes"] &&
4035
- req.body.data["statusPageEventTypes"].length > 0
4036
- ) {
4037
- logger.debug(
4038
- `Setting subscriber event types: ${JSON.stringify(req.body.data["statusPageEventTypes"])}`,
4039
- getLogAttributesFromRequest(req as any),
4040
- );
4041
- statusPageSubscriber.statusPageEventTypes = req.body.data[
4042
- "statusPageEventTypes"
4043
- ] as Array<StatusPageEventType>;
4044
- }
3533
+ incidents = [...activeIncidents, ...incidents];
4045
3534
 
4046
- if (isUpdate) {
4047
- // check isUnsubscribed is set to false.
4048
- logger.debug(
4049
- `Updating subscriber with ID: ${statusPageSubscriber.id}`,
4050
- getLogAttributesFromRequest(req as any),
4051
- );
4052
- statusPageSubscriber.isUnsubscribed = Boolean(
4053
- req.body.data["isUnsubscribed"],
4054
- );
3535
+ // get distinct by id.
4055
3536
 
4056
- await StatusPageSubscriberService.updateOneById({
4057
- id: statusPageSubscriber.id!,
4058
- data: {
4059
- statusPageResources: statusPageSubscriber.statusPageResources!,
4060
- isSubscribedToAllResources:
4061
- statusPageSubscriber.isSubscribedToAllResources!,
4062
- statusPageEventTypes: statusPageSubscriber.statusPageEventTypes!,
4063
- isSubscribedToAllEventTypes:
4064
- statusPageSubscriber.isSubscribedToAllEventTypes!,
4065
- isUnsubscribed: statusPageSubscriber.isUnsubscribed,
4066
- } as any,
4067
- props: {
4068
- isRoot: true,
3537
+ incidents = ArrayUtil.distinctByFieldName(incidents, "_id");
3538
+ }
3539
+
3540
+ const incidentsOnStatusPage: Array<ObjectID> = incidents.map(
3541
+ (incident: Incident) => {
3542
+ return incident.id!;
3543
+ },
3544
+ );
3545
+
3546
+ let incidentPublicNotes: Array<IncidentPublicNote> = [];
3547
+
3548
+ if (incidentsOnStatusPage.length > 0) {
3549
+ incidentPublicNotes = await IncidentPublicNoteService.findBy({
3550
+ query: {
3551
+ incidentId: QueryHelper.any(incidentsOnStatusPage),
3552
+ projectId: statusPage.projectId!,
4069
3553
  },
4070
- });
4071
- } else {
4072
- logger.debug(
4073
- `Creating new subscriber: ${JSON.stringify(statusPageSubscriber)}`,
4074
- getLogAttributesFromRequest(req as any),
4075
- );
4076
- await StatusPageSubscriberService.create({
4077
- data: statusPageSubscriber,
3554
+ select: {
3555
+ postedAt: true,
3556
+ note: true,
3557
+ incidentId: true,
3558
+ attachments: {
3559
+ _id: true,
3560
+ name: true,
3561
+ },
3562
+ },
3563
+ sort: {
3564
+ postedAt: SortOrder.Descending, // new note first
3565
+ },
3566
+ skip: 0,
3567
+ limit: LIMIT_PER_PROJECT,
4078
3568
  props: {
4079
3569
  isRoot: true,
4080
3570
  },
4081
3571
  });
4082
3572
  }
4083
3573
 
4084
- logger.debug(
4085
- `Subscription process completed for status page with ID: ${objectId}`,
4086
- getLogAttributesFromRequest(req as any),
4087
- );
4088
- }
4089
-
4090
- @CaptureSpan()
4091
- public async getSubscriber(
4092
- req: ExpressRequest,
4093
- ): Promise<StatusPageSubscriber> {
4094
- const objectId: ObjectID = new ObjectID(
4095
- req.params["statusPageId"] as string,
4096
- );
4097
-
4098
- await this.checkHasReadAccess({
4099
- statusPageId: objectId,
4100
- req: req,
4101
- });
3574
+ let incidentStateTimelines: Array<IncidentStateTimeline> = [];
4102
3575
 
4103
- const statusPage: StatusPage | null = await StatusPageService.findOneBy({
4104
- query: {
4105
- _id: objectId.toString(),
4106
- },
4107
- select: {
4108
- _id: true,
4109
- projectId: true,
4110
- },
4111
- props: {
4112
- isRoot: true,
4113
- },
4114
- });
3576
+ if (incidentsOnStatusPage.length > 0) {
3577
+ incidentStateTimelines = await IncidentStateTimelineService.findBy({
3578
+ query: {
3579
+ incidentId: QueryHelper.any(incidentsOnStatusPage),
3580
+ projectId: statusPage.projectId!,
3581
+ },
3582
+ select: {
3583
+ _id: true,
3584
+ createdAt: true,
3585
+ startsAt: true,
3586
+ incidentId: true,
3587
+ incidentState: {
3588
+ name: true,
3589
+ color: true,
3590
+ },
3591
+ },
3592
+ sort: {
3593
+ startsAt: SortOrder.Descending, // newer state changes first
3594
+ },
4115
3595
 
4116
- if (!statusPage) {
4117
- throw new BadDataException("Status Page not found");
3596
+ skip: 0,
3597
+ limit: LIMIT_PER_PROJECT,
3598
+ props: {
3599
+ isRoot: true,
3600
+ },
3601
+ });
4118
3602
  }
4119
3603
 
4120
- const subscriberId: ObjectID = new ObjectID(
4121
- req.params["subscriberId"] as string,
4122
- );
4123
-
4124
- const statusPageSubscriber: StatusPageSubscriber | null =
4125
- await StatusPageSubscriberService.findOneBy({
3604
+ // get all the incident states for this project.
3605
+ const incidentStates: Array<IncidentState> =
3606
+ await IncidentStateService.findBy({
4126
3607
  query: {
4127
- _id: subscriberId.toString(),
4128
- statusPageId: statusPage.id!,
3608
+ projectId: statusPage.projectId!,
4129
3609
  },
4130
3610
  select: {
4131
- isUnsubscribed: true,
4132
- subscriberEmail: true,
4133
- subscriberPhone: true,
4134
- slackWorkspaceName: true,
4135
- statusPageId: true,
4136
- statusPageResources: true,
4137
- isSubscribedToAllResources: true,
4138
- statusPageEventTypes: true,
4139
- isSubscribedToAllEventTypes: true,
3611
+ isResolvedState: true,
3612
+ order: true,
4140
3613
  },
3614
+ limit: LIMIT_PER_PROJECT,
3615
+ skip: 0,
4141
3616
  props: {
4142
3617
  isRoot: true,
4143
3618
  },
4144
3619
  });
4145
3620
 
4146
- if (!statusPageSubscriber) {
4147
- throw new BadDataException("Subscriber not found");
4148
- }
3621
+ const response: JSONObject = {
3622
+ incidentPublicNotes: BaseModel.toJSONArray(
3623
+ incidentPublicNotes,
3624
+ IncidentPublicNote,
3625
+ ),
3626
+ incidentStates: BaseModel.toJSONArray(incidentStates, IncidentState),
3627
+ incidents: BaseModel.toJSONArray(incidents, Incident),
3628
+ statusPageResources: BaseModel.toJSONArray(
3629
+ statusPageResources,
3630
+ StatusPageResource,
3631
+ ),
3632
+ incidentStateTimelines: BaseModel.toJSONArray(
3633
+ incidentStateTimelines,
3634
+ IncidentStateTimeline,
3635
+ ),
3636
+ monitorsInGroup: JSONFunctions.serialize(monitorsInGroup),
3637
+ };
4149
3638
 
4150
- return statusPageSubscriber;
3639
+ return response;
4151
3640
  }
4152
3641
 
4153
3642
  @CaptureSpan()
4154
- public async getIncidents(
3643
+ public async getEpisodes(
4155
3644
  statusPageId: ObjectID,
4156
- incidentId: ObjectID | null,
3645
+ episodeId: ObjectID | null,
4157
3646
  req: ExpressRequest,
4158
3647
  ): Promise<JSONObject> {
4159
3648
  await this.checkHasReadAccess({
@@ -4168,9 +3657,9 @@ export default class StatusPageAPI extends BaseAPI<
4168
3657
  select: {
4169
3658
  _id: true,
4170
3659
  projectId: true,
4171
- showIncidentHistoryInDays: true,
4172
- showIncidentLabelsOnStatusPage: true,
4173
- showIncidentsOnStatusPage: true,
3660
+ showEpisodeHistoryInDays: true,
3661
+ showEpisodesOnStatusPage: true,
3662
+ showEpisodeLabelsOnStatusPage: true,
4174
3663
  },
4175
3664
  props: {
4176
3665
  isRoot: true,
@@ -4181,9 +3670,9 @@ export default class StatusPageAPI extends BaseAPI<
4181
3670
  throw new BadDataException("Status Page not found");
4182
3671
  }
4183
3672
 
4184
- if (!statusPage.showIncidentsOnStatusPage) {
3673
+ if (!statusPage.showEpisodesOnStatusPage) {
4185
3674
  throw new BadDataException(
4186
- "Incidents are not enabled on this status page.",
3675
+ "Episodes are not enabled on this status page.",
4187
3676
  );
4188
3677
  }
4189
3678
 
@@ -4203,42 +3692,133 @@ export default class StatusPageAPI extends BaseAPI<
4203
3692
  const today: Date = OneUptimeDate.getCurrentDate();
4204
3693
 
4205
3694
  const historyDays: Date = OneUptimeDate.getSomeDaysAgo(
4206
- statusPage.showIncidentHistoryInDays || 14,
3695
+ statusPage.showEpisodeHistoryInDays || 14,
4207
3696
  );
4208
3697
 
4209
- let incidentQuery: Query<Incident> = {
3698
+ /*
3699
+ * Get incidents that have monitors on this status page
3700
+ * Note: We don't filter by incident.isVisibleOnStatusPage here because
3701
+ * episode visibility is independent of incident visibility.
3702
+ * An episode should show if episode.isVisibleOnStatusPage is true,
3703
+ * regardless of whether its member incidents are visible.
3704
+ */
3705
+ const incidentQuery: Query<Incident> = {
4210
3706
  monitors: monitorsOnStatusPage as any,
4211
3707
  projectId: statusPage.projectId!,
4212
3708
  createdAt: QueryHelper.inBetween(historyDays, today),
3709
+ };
3710
+
3711
+ let incidents: Array<Incident> = [];
3712
+
3713
+ if (monitorsOnStatusPage.length > 0) {
3714
+ incidents = await IncidentService.findBy({
3715
+ query: incidentQuery,
3716
+ select: {
3717
+ _id: true,
3718
+ },
3719
+ skip: 0,
3720
+ limit: LIMIT_PER_PROJECT,
3721
+ props: {
3722
+ isRoot: true,
3723
+ },
3724
+ });
3725
+ }
3726
+
3727
+ const incidentIds: Array<ObjectID> = incidents.map((incident: Incident) => {
3728
+ return incident.id!;
3729
+ });
3730
+
3731
+ // Get episode members that link to these incidents
3732
+ let episodeMembers: Array<IncidentEpisodeMember> = [];
3733
+
3734
+ if (incidentIds.length > 0) {
3735
+ episodeMembers = await IncidentEpisodeMemberService.findBy({
3736
+ query: {
3737
+ incidentId: QueryHelper.any(incidentIds),
3738
+ projectId: statusPage.projectId!,
3739
+ },
3740
+ select: {
3741
+ incidentEpisodeId: true,
3742
+ incidentId: true,
3743
+ },
3744
+ skip: 0,
3745
+ limit: LIMIT_PER_PROJECT,
3746
+ props: {
3747
+ isRoot: true,
3748
+ },
3749
+ });
3750
+ }
3751
+
3752
+ // Get unique episode IDs
3753
+ const episodeIdsFromMembers: Set<string> = new Set();
3754
+ for (const member of episodeMembers) {
3755
+ if (member.incidentEpisodeId) {
3756
+ episodeIdsFromMembers.add(member.incidentEpisodeId.toString());
3757
+ }
3758
+ }
3759
+
3760
+ let episodeQuery: Query<IncidentEpisode> = {
3761
+ _id: QueryHelper.any(
3762
+ Array.from(episodeIdsFromMembers).map((id: string) => {
3763
+ return new ObjectID(id);
3764
+ }),
3765
+ ),
3766
+ projectId: statusPage.projectId!,
4213
3767
  isVisibleOnStatusPage: true,
4214
3768
  };
4215
3769
 
4216
- if (incidentId) {
4217
- incidentQuery = {
4218
- monitors: monitorsOnStatusPage as any,
3770
+ if (episodeId) {
3771
+ episodeQuery = {
3772
+ _id: episodeId.toString(),
4219
3773
  projectId: statusPage.projectId!,
4220
- _id: incidentId.toString(),
4221
3774
  isVisibleOnStatusPage: true,
4222
3775
  };
3776
+
3777
+ // When viewing a specific episode, also fetch its members directly
3778
+ const episodeMembersForSpecificEpisode: Array<IncidentEpisodeMember> =
3779
+ await IncidentEpisodeMemberService.findBy({
3780
+ query: {
3781
+ incidentEpisodeId: episodeId,
3782
+ projectId: statusPage.projectId!,
3783
+ },
3784
+ select: {
3785
+ incidentEpisodeId: true,
3786
+ incidentId: true,
3787
+ },
3788
+ skip: 0,
3789
+ limit: LIMIT_PER_PROJECT,
3790
+ props: {
3791
+ isRoot: true,
3792
+ },
3793
+ });
3794
+
3795
+ // Merge with existing episode members
3796
+ for (const member of episodeMembersForSpecificEpisode) {
3797
+ if (
3798
+ !episodeMembers.some((m: IncidentEpisodeMember) => {
3799
+ return (
3800
+ m.incidentEpisodeId?.toString() ===
3801
+ member.incidentEpisodeId?.toString() &&
3802
+ m.incidentId?.toString() === member.incidentId?.toString()
3803
+ );
3804
+ })
3805
+ ) {
3806
+ episodeMembers.push(member);
3807
+ }
3808
+ }
4223
3809
  }
4224
3810
 
4225
- // check if status page has active incident.
4226
- let incidents: Array<Incident> = [];
3811
+ // Get episodes
3812
+ let episodes: Array<IncidentEpisode> = [];
4227
3813
 
4228
- let selectIncidents: Select<Incident> = {
3814
+ let selectEpisodes: Select<IncidentEpisode> = {
4229
3815
  createdAt: true,
4230
3816
  declaredAt: true,
4231
3817
  updatedAt: true,
4232
3818
  title: true,
4233
3819
  description: true,
4234
3820
  _id: true,
4235
- postmortemNote: true,
4236
- postmortemPostedAt: true,
4237
- showPostmortemOnStatusPage: true,
4238
- postmortemAttachments: {
4239
- _id: true,
4240
- name: true,
4241
- },
3821
+ episodeNumber: true,
4242
3822
  incidentSeverity: {
4243
3823
  name: true,
4244
3824
  color: true,
@@ -4248,15 +3828,16 @@ export default class StatusPageAPI extends BaseAPI<
4248
3828
  color: true,
4249
3829
  _id: true,
4250
3830
  order: true,
3831
+ isCreatedState: true,
3832
+ isAcknowledgedState: true,
3833
+ isResolvedState: true,
4251
3834
  },
4252
- monitors: {
4253
- _id: true,
4254
- },
3835
+ incidentCount: true,
4255
3836
  };
4256
3837
 
4257
- if (statusPage.showIncidentLabelsOnStatusPage) {
4258
- selectIncidents = {
4259
- ...selectIncidents,
3838
+ if (statusPage.showEpisodeLabelsOnStatusPage) {
3839
+ selectEpisodes = {
3840
+ ...selectEpisodes,
4260
3841
  labels: {
4261
3842
  name: true,
4262
3843
  color: true,
@@ -4264,10 +3845,10 @@ export default class StatusPageAPI extends BaseAPI<
4264
3845
  };
4265
3846
  }
4266
3847
 
4267
- if (monitorsOnStatusPage.length > 0) {
4268
- incidents = await IncidentService.findBy({
4269
- query: incidentQuery,
4270
- select: selectIncidents,
3848
+ if (episodeIdsFromMembers.size > 0 || episodeId) {
3849
+ episodes = await IncidentEpisodeService.findBy({
3850
+ query: episodeQuery,
3851
+ select: selectEpisodes,
4271
3852
  sort: {
4272
3853
  declaredAt: SortOrder.Descending,
4273
3854
  createdAt: SortOrder.Descending,
@@ -4278,9 +3859,10 @@ export default class StatusPageAPI extends BaseAPI<
4278
3859
  isRoot: true,
4279
3860
  },
4280
3861
  });
3862
+ }
4281
3863
 
4282
- let activeIncidents: Array<Incident> = [];
4283
-
3864
+ // If no specific episode, also fetch active (unresolved) episodes
3865
+ if (!episodeId && episodeIdsFromMembers.size > 0) {
4284
3866
  const unresolvedIncidentStates: Array<IncidentState> =
4285
3867
  await IncidentStateService.getUnresolvedIncidentStates(
4286
3868
  statusPage.projectId!,
@@ -4289,68 +3871,145 @@ export default class StatusPageAPI extends BaseAPI<
4289
3871
  },
4290
3872
  );
4291
3873
 
4292
- const unresolvbedIncidentStateIds: Array<ObjectID> =
3874
+ const unresolvedIncidentStateIds: Array<ObjectID> =
4293
3875
  unresolvedIncidentStates.map((state: IncidentState) => {
4294
3876
  return state.id!;
4295
3877
  });
4296
3878
 
4297
- // If there is no particular incident id to fetch then fetch active incidents.
4298
- if (!incidentId) {
4299
- activeIncidents = await IncidentService.findBy({
3879
+ const activeEpisodes: Array<IncidentEpisode> =
3880
+ await IncidentEpisodeService.findBy({
4300
3881
  query: {
4301
- monitors: monitorsOnStatusPage as any,
4302
- isVisibleOnStatusPage: true,
4303
- currentIncidentStateId: QueryHelper.any(
4304
- unresolvbedIncidentStateIds,
3882
+ _id: QueryHelper.any(
3883
+ Array.from(episodeIdsFromMembers).map((id: string) => {
3884
+ return new ObjectID(id);
3885
+ }),
4305
3886
  ),
3887
+ isVisibleOnStatusPage: true,
3888
+ currentIncidentStateId: QueryHelper.any(unresolvedIncidentStateIds),
4306
3889
  projectId: statusPage.projectId!,
4307
3890
  },
4308
- select: selectIncidents,
3891
+ select: selectEpisodes,
4309
3892
  sort: {
4310
3893
  declaredAt: SortOrder.Descending,
4311
3894
  createdAt: SortOrder.Descending,
4312
3895
  },
4313
-
4314
3896
  skip: 0,
4315
3897
  limit: LIMIT_PER_PROJECT,
4316
3898
  props: {
4317
3899
  isRoot: true,
4318
3900
  },
4319
3901
  });
4320
- }
4321
-
4322
- incidents = [...activeIncidents, ...incidents];
4323
3902
 
4324
- // get distinct by id.
4325
-
4326
- incidents = ArrayUtil.distinctByFieldName(incidents, "_id");
3903
+ episodes = [...activeEpisodes, ...episodes];
3904
+ episodes = ArrayUtil.distinctByFieldName(episodes, "_id");
4327
3905
  }
4328
3906
 
4329
- const incidentsOnStatusPage: Array<ObjectID> = incidents.map(
4330
- (incident: Incident) => {
4331
- return incident.id!;
3907
+ const episodesOnStatusPage: Array<ObjectID> = episodes.map(
3908
+ (episode: IncidentEpisode) => {
3909
+ return episode.id!;
4332
3910
  },
4333
3911
  );
4334
3912
 
4335
- let incidentPublicNotes: Array<IncidentPublicNote> = [];
3913
+ /*
3914
+ * Build a map of episode ID -> monitor IDs from episode members
3915
+ * Collect all unique incident IDs from episode members
3916
+ */
3917
+ const memberIncidentIds: Array<ObjectID> = [];
3918
+ for (const member of episodeMembers) {
3919
+ if (
3920
+ member.incidentId &&
3921
+ !memberIncidentIds.some((id: ObjectID) => {
3922
+ return id.toString() === member.incidentId!.toString();
3923
+ })
3924
+ ) {
3925
+ memberIncidentIds.push(member.incidentId);
3926
+ }
3927
+ }
4336
3928
 
4337
- if (incidentsOnStatusPage.length > 0) {
4338
- incidentPublicNotes = await IncidentPublicNoteService.findBy({
3929
+ // Fetch incidents with their monitors
3930
+ let memberIncidents: Array<Incident> = [];
3931
+ if (memberIncidentIds.length > 0) {
3932
+ memberIncidents = await IncidentService.findBy({
4339
3933
  query: {
4340
- incidentId: QueryHelper.any(incidentsOnStatusPage),
3934
+ _id: QueryHelper.any(memberIncidentIds),
3935
+ projectId: statusPage.projectId!,
3936
+ },
3937
+ select: {
3938
+ _id: true,
3939
+ monitors: {
3940
+ _id: true,
3941
+ },
3942
+ },
3943
+ skip: 0,
3944
+ limit: LIMIT_PER_PROJECT,
3945
+ props: {
3946
+ isRoot: true,
3947
+ },
3948
+ });
3949
+ }
3950
+
3951
+ // Build a map of incident ID -> monitors
3952
+ const incidentMonitorsMap: Map<string, Array<ObjectID>> = new Map();
3953
+ for (const incident of memberIncidents) {
3954
+ const incidentIdStr: string = incident.id!.toString();
3955
+ const monitorIds: Array<ObjectID> = (incident.monitors || [])
3956
+ .map((m: Monitor) => {
3957
+ return new ObjectID(m._id?.toString() || m.id?.toString() || "");
3958
+ })
3959
+ .filter((id: ObjectID) => {
3960
+ return id.toString() !== "";
3961
+ });
3962
+ incidentMonitorsMap.set(incidentIdStr, monitorIds);
3963
+ }
3964
+
3965
+ // Build episode monitors map from members and incident monitors
3966
+ const episodeMonitorsMap: Map<string, Array<ObjectID>> = new Map();
3967
+ for (const member of episodeMembers) {
3968
+ if (member.incidentEpisodeId && member.incidentId) {
3969
+ const episodeIdStr: string = member.incidentEpisodeId.toString();
3970
+ const incidentIdStr: string = member.incidentId.toString();
3971
+
3972
+ if (!episodeMonitorsMap.has(episodeIdStr)) {
3973
+ episodeMonitorsMap.set(episodeIdStr, []);
3974
+ }
3975
+
3976
+ const episodeMonitors: Array<ObjectID> =
3977
+ episodeMonitorsMap.get(episodeIdStr)!;
3978
+ const incidentMonitors: Array<ObjectID> =
3979
+ incidentMonitorsMap.get(incidentIdStr) || [];
3980
+
3981
+ for (const monitorId of incidentMonitors) {
3982
+ if (
3983
+ !episodeMonitors.some((m: ObjectID) => {
3984
+ return m.toString() === monitorId.toString();
3985
+ })
3986
+ ) {
3987
+ episodeMonitors.push(monitorId);
3988
+ }
3989
+ }
3990
+ }
3991
+ }
3992
+
3993
+ // Get public notes for episodes
3994
+ let episodePublicNotes: Array<IncidentEpisodePublicNote> = [];
3995
+
3996
+ if (episodesOnStatusPage.length > 0) {
3997
+ episodePublicNotes = await IncidentEpisodePublicNoteService.findBy({
3998
+ query: {
3999
+ incidentEpisodeId: QueryHelper.any(episodesOnStatusPage),
4341
4000
  projectId: statusPage.projectId!,
4342
4001
  },
4343
4002
  select: {
4344
4003
  postedAt: true,
4345
4004
  note: true,
4346
- incidentId: true,
4005
+ incidentEpisodeId: true,
4347
4006
  attachments: {
4348
4007
  _id: true,
4349
4008
  name: true,
4350
4009
  },
4351
4010
  },
4352
4011
  sort: {
4353
- postedAt: SortOrder.Descending, // new note first
4012
+ postedAt: SortOrder.Descending,
4354
4013
  },
4355
4014
  skip: 0,
4356
4015
  limit: LIMIT_PER_PROJECT,
@@ -4360,28 +4019,31 @@ export default class StatusPageAPI extends BaseAPI<
4360
4019
  });
4361
4020
  }
4362
4021
 
4363
- let incidentStateTimelines: Array<IncidentStateTimeline> = [];
4022
+ // Get state timelines for episodes
4023
+ let episodeStateTimelines: Array<IncidentEpisodeStateTimeline> = [];
4364
4024
 
4365
- if (incidentsOnStatusPage.length > 0) {
4366
- incidentStateTimelines = await IncidentStateTimelineService.findBy({
4025
+ if (episodesOnStatusPage.length > 0) {
4026
+ episodeStateTimelines = await IncidentEpisodeStateTimelineService.findBy({
4367
4027
  query: {
4368
- incidentId: QueryHelper.any(incidentsOnStatusPage),
4028
+ incidentEpisodeId: QueryHelper.any(episodesOnStatusPage),
4369
4029
  projectId: statusPage.projectId!,
4370
4030
  },
4371
4031
  select: {
4372
4032
  _id: true,
4373
4033
  createdAt: true,
4374
4034
  startsAt: true,
4375
- incidentId: true,
4035
+ incidentEpisodeId: true,
4376
4036
  incidentState: {
4377
4037
  name: true,
4378
4038
  color: true,
4039
+ isCreatedState: true,
4040
+ isAcknowledgedState: true,
4041
+ isResolvedState: true,
4379
4042
  },
4380
4043
  },
4381
4044
  sort: {
4382
- startsAt: SortOrder.Descending, // newer state changes first
4045
+ startsAt: SortOrder.Descending,
4383
4046
  },
4384
-
4385
4047
  skip: 0,
4386
4048
  limit: LIMIT_PER_PROJECT,
4387
4049
  props: {
@@ -4390,7 +4052,7 @@ export default class StatusPageAPI extends BaseAPI<
4390
4052
  });
4391
4053
  }
4392
4054
 
4393
- // get all the incident states for this project.
4055
+ // Get all incident states for this project
4394
4056
  const incidentStates: Array<IncidentState> =
4395
4057
  await IncidentStateService.findBy({
4396
4058
  query: {
@@ -4407,20 +4069,37 @@ export default class StatusPageAPI extends BaseAPI<
4407
4069
  },
4408
4070
  });
4409
4071
 
4072
+ // Serialize episodes and add monitors to each
4073
+ const episodesJson: JSONArray = BaseModel.toJSONArray(
4074
+ episodes,
4075
+ IncidentEpisode,
4076
+ );
4077
+ for (const episodeJson of episodesJson) {
4078
+ const episodeObj: JSONObject = episodeJson as JSONObject;
4079
+ const episodeId: string | undefined = episodeObj["_id"]?.toString();
4080
+ if (episodeId) {
4081
+ const monitorIds: Array<ObjectID> =
4082
+ episodeMonitorsMap.get(episodeId) || [];
4083
+ episodeObj["monitors"] = monitorIds.map((id: ObjectID) => {
4084
+ return { _id: id.toString() };
4085
+ });
4086
+ }
4087
+ }
4088
+
4410
4089
  const response: JSONObject = {
4411
- incidentPublicNotes: BaseModel.toJSONArray(
4412
- incidentPublicNotes,
4413
- IncidentPublicNote,
4090
+ episodePublicNotes: BaseModel.toJSONArray(
4091
+ episodePublicNotes,
4092
+ IncidentEpisodePublicNote,
4414
4093
  ),
4415
4094
  incidentStates: BaseModel.toJSONArray(incidentStates, IncidentState),
4416
- incidents: BaseModel.toJSONArray(incidents, Incident),
4095
+ episodes: episodesJson,
4417
4096
  statusPageResources: BaseModel.toJSONArray(
4418
4097
  statusPageResources,
4419
4098
  StatusPageResource,
4420
4099
  ),
4421
- incidentStateTimelines: BaseModel.toJSONArray(
4422
- incidentStateTimelines,
4423
- IncidentStateTimeline,
4100
+ episodeStateTimelines: BaseModel.toJSONArray(
4101
+ episodeStateTimelines,
4102
+ IncidentEpisodeStateTimeline,
4424
4103
  ),
4425
4104
  monitorsInGroup: JSONFunctions.serialize(monitorsInGroup),
4426
4105
  };
@@ -4429,26 +4108,54 @@ export default class StatusPageAPI extends BaseAPI<
4429
4108
  }
4430
4109
 
4431
4110
  @CaptureSpan()
4432
- public async getEpisodes(
4433
- statusPageId: ObjectID,
4434
- episodeId: ObjectID | null,
4435
- req: ExpressRequest,
4436
- ): Promise<JSONObject> {
4437
- await this.checkHasReadAccess({
4438
- statusPageId: statusPageId,
4439
- req: req,
4440
- });
4111
+ public async getStatusPageResourcesAndTimelines(data: {
4112
+ statusPageId: ObjectID;
4113
+ /*
4114
+ * When omitted, the timeline window is computed from the status page's
4115
+ * own showUptimeHistoryInDays (clamped to 1..90 days, ending now)
4116
+ * the status page row is fetched below anyway, so callers that want the
4117
+ * page-configured window (the overview endpoint) don't need their own
4118
+ * StatusPage query just to read that column.
4119
+ */
4120
+ startDateForMonitorTimeline?: Date | undefined;
4121
+ endDateForMonitorTimeline?: Date | undefined;
4122
+ }): Promise<{
4123
+ statusPageResources: StatusPageResource[];
4124
+ monitorStatuses: MonitorStatus[];
4125
+ monitorStatusTimelines: MonitorStatusTimeline[];
4126
+ monitorGroupCurrentStatuses: Dictionary<ObjectID>;
4127
+ statusPageGroups: StatusPageGroup[];
4128
+ statusPage: StatusPage;
4129
+ monitorsOnStatusPage: ObjectID[];
4130
+ monitorsInGroup: Dictionary<ObjectID[]>;
4131
+ startDateForMonitorTimeline: Date;
4132
+ endDateForMonitorTimeline: Date;
4133
+ }> {
4134
+ const objectId: ObjectID = data.statusPageId;
4441
4135
 
4442
4136
  const statusPage: StatusPage | null = await StatusPageService.findOneBy({
4443
4137
  query: {
4444
- _id: statusPageId.toString(),
4138
+ _id: objectId.toString(),
4445
4139
  },
4446
4140
  select: {
4447
4141
  _id: true,
4448
4142
  projectId: true,
4449
- showEpisodeHistoryInDays: true,
4450
- showEpisodesOnStatusPage: true,
4143
+ isPublicStatusPage: true,
4144
+ overviewPageDescription: true,
4145
+ showIncidentLabelsOnStatusPage: true,
4146
+ showScheduledEventLabelsOnStatusPage: true,
4451
4147
  showEpisodeLabelsOnStatusPage: true,
4148
+ downtimeMonitorStatuses: {
4149
+ _id: true,
4150
+ },
4151
+ defaultBarColor: true,
4152
+ showOverallUptimePercentOnStatusPage: true,
4153
+ overallUptimePercentPrecision: true,
4154
+ showAnnouncementsOnStatusPage: true,
4155
+ showIncidentsOnStatusPage: true,
4156
+ showEpisodesOnStatusPage: true,
4157
+ showScheduledMaintenanceEventsOnStatusPage: true,
4158
+ showUptimeHistoryInDays: true,
4452
4159
  },
4453
4160
  props: {
4454
4161
  isRoot: true,
@@ -4459,51 +4166,42 @@ export default class StatusPageAPI extends BaseAPI<
4459
4166
  throw new BadDataException("Status Page not found");
4460
4167
  }
4461
4168
 
4462
- if (!statusPage.showEpisodesOnStatusPage) {
4463
- throw new BadDataException(
4464
- "Episodes are not enabled on this status page.",
4465
- );
4466
- }
4467
-
4468
- // get monitors on status page.
4469
- const statusPageResources: Array<StatusPageResource> =
4470
- await StatusPageService.getStatusPageResources({
4471
- statusPageId: statusPageId,
4472
- });
4169
+ let startDateForMonitorTimeline: Date | undefined =
4170
+ data.startDateForMonitorTimeline;
4171
+ let endDateForMonitorTimeline: Date | undefined =
4172
+ data.endDateForMonitorTimeline;
4473
4173
 
4474
- const { monitorsOnStatusPage, monitorsInGroup } =
4475
- await StatusPageService.getMonitorIdsOnStatusPage({
4476
- statusPageId: statusPageId,
4477
- // reuse the resources fetched above instead of re-querying them
4478
- statusPageResources: statusPageResources,
4479
- });
4174
+ if (!startDateForMonitorTimeline || !endDateForMonitorTimeline) {
4175
+ let uptimeHistoryDays: number = statusPage.showUptimeHistoryInDays || 90;
4480
4176
 
4481
- const today: Date = OneUptimeDate.getCurrentDate();
4177
+ if (uptimeHistoryDays > 90) {
4178
+ uptimeHistoryDays = 90;
4179
+ }
4482
4180
 
4483
- const historyDays: Date = OneUptimeDate.getSomeDaysAgo(
4484
- statusPage.showEpisodeHistoryInDays || 14,
4485
- );
4181
+ if (uptimeHistoryDays < 1) {
4182
+ uptimeHistoryDays = 1;
4183
+ }
4486
4184
 
4487
- /*
4488
- * Get incidents that have monitors on this status page
4489
- * Note: We don't filter by incident.isVisibleOnStatusPage here because
4490
- * episode visibility is independent of incident visibility.
4491
- * An episode should show if episode.isVisibleOnStatusPage is true,
4492
- * regardless of whether its member incidents are visible.
4493
- */
4494
- const incidentQuery: Query<Incident> = {
4495
- monitors: monitorsOnStatusPage as any,
4496
- projectId: statusPage.projectId!,
4497
- createdAt: QueryHelper.inBetween(historyDays, today),
4498
- };
4185
+ startDateForMonitorTimeline =
4186
+ OneUptimeDate.getSomeDaysAgo(uptimeHistoryDays);
4187
+ endDateForMonitorTimeline = OneUptimeDate.getCurrentDate();
4188
+ }
4499
4189
 
4500
- let incidents: Array<Incident> = [];
4190
+ //get monitor statuses
4501
4191
 
4502
- if (monitorsOnStatusPage.length > 0) {
4503
- incidents = await IncidentService.findBy({
4504
- query: incidentQuery,
4192
+ const monitorStatuses: Array<MonitorStatus> =
4193
+ await MonitorStatusService.findBy({
4194
+ query: {
4195
+ projectId: statusPage.projectId!,
4196
+ },
4505
4197
  select: {
4506
- _id: true,
4198
+ name: true,
4199
+ color: true,
4200
+ priority: true,
4201
+ isOperationalState: true,
4202
+ },
4203
+ sort: {
4204
+ priority: SortOrder.Ascending,
4507
4205
  },
4508
4206
  skip: 0,
4509
4207
  limit: LIMIT_PER_PROJECT,
@@ -4511,24 +4209,71 @@ export default class StatusPageAPI extends BaseAPI<
4511
4209
  isRoot: true,
4512
4210
  },
4513
4211
  });
4514
- }
4515
4212
 
4516
- const incidentIds: Array<ObjectID> = incidents.map((incident: Incident) => {
4517
- return incident.id!;
4518
- });
4213
+ // get resource groups.
4519
4214
 
4520
- // Get episode members that link to these incidents
4521
- let episodeMembers: Array<IncidentEpisodeMember> = [];
4215
+ const groups: Array<StatusPageGroup> = await StatusPageGroupService.findBy({
4216
+ query: {
4217
+ statusPageId: objectId,
4218
+ },
4219
+ select: {
4220
+ name: true,
4221
+ order: true,
4222
+ description: true,
4223
+ isExpandedByDefault: true,
4224
+ showCurrentStatus: true,
4225
+ showUptimePercent: true,
4226
+ uptimePercentPrecision: true,
4227
+ viewMode: true,
4228
+ rowAxisLabel: true,
4229
+ columnAxisLabel: true,
4230
+ rowAxisValues: true,
4231
+ columnAxisValues: true,
4232
+ parentStatusPageGroupId: true,
4233
+ },
4234
+ sort: {
4235
+ order: SortOrder.Ascending,
4236
+ },
4237
+ skip: 0,
4238
+ limit: LIMIT_PER_PROJECT,
4239
+ props: {
4240
+ isRoot: true,
4241
+ },
4242
+ });
4522
4243
 
4523
- if (incidentIds.length > 0) {
4524
- episodeMembers = await IncidentEpisodeMemberService.findBy({
4244
+ // get monitors on status page.
4245
+ const statusPageResources: Array<StatusPageResource> =
4246
+ await StatusPageResourceService.findBy({
4525
4247
  query: {
4526
- incidentId: QueryHelper.any(incidentIds),
4527
- projectId: statusPage.projectId!,
4248
+ statusPageId: objectId,
4528
4249
  },
4529
4250
  select: {
4530
- incidentEpisodeId: true,
4531
- incidentId: true,
4251
+ statusPageGroupId: true,
4252
+ statusPageGroup: {
4253
+ name: true,
4254
+ viewMode: true,
4255
+ rowAxisLabel: true,
4256
+ columnAxisLabel: true,
4257
+ },
4258
+ monitorId: true,
4259
+ displayTooltip: true,
4260
+ displayDescription: true,
4261
+ displayName: true,
4262
+ showStatusHistoryChart: true,
4263
+ showCurrentStatus: true,
4264
+ order: true,
4265
+ monitor: {
4266
+ _id: true,
4267
+ currentMonitorStatusId: true,
4268
+ },
4269
+ monitorGroupId: true,
4270
+ showUptimePercent: true,
4271
+ uptimePercentPrecision: true,
4272
+ rowAxisValue: true,
4273
+ columnAxisValue: true,
4274
+ },
4275
+ sort: {
4276
+ order: SortOrder.Ascending,
4532
4277
  },
4533
4278
  skip: 0,
4534
4279
  limit: LIMIT_PER_PROJECT,
@@ -4536,122 +4281,218 @@ export default class StatusPageAPI extends BaseAPI<
4536
4281
  isRoot: true,
4537
4282
  },
4538
4283
  });
4539
- }
4540
4284
 
4541
- // Get unique episode IDs
4542
- const episodeIdsFromMembers: Set<string> = new Set();
4543
- for (const member of episodeMembers) {
4544
- if (member.incidentEpisodeId) {
4545
- episodeIdsFromMembers.add(member.incidentEpisodeId.toString());
4285
+ const monitorGroupIds: Array<ObjectID> = statusPageResources
4286
+ .map((resource: StatusPageResource) => {
4287
+ return resource.monitorGroupId!;
4288
+ })
4289
+ .filter((id: ObjectID) => {
4290
+ return Boolean(id); // remove nulls
4291
+ });
4292
+
4293
+ // get monitors in the group.
4294
+ const monitorGroupCurrentStatuses: Dictionary<ObjectID> = {};
4295
+ const monitorsInGroup: Dictionary<Array<ObjectID>> = {};
4296
+
4297
+ // get monitor status charts.
4298
+ const monitorsOnStatusPage: Array<ObjectID> = statusPageResources
4299
+ .map((monitor: StatusPageResource) => {
4300
+ return monitor.monitorId!;
4301
+ })
4302
+ .filter((id: ObjectID) => {
4303
+ return Boolean(id); // remove nulls
4304
+ });
4305
+
4306
+ const monitorsOnStatusPageForTimeline: Array<ObjectID> = statusPageResources
4307
+ .filter((monitor: StatusPageResource) => {
4308
+ return monitor.showStatusHistoryChart || monitor.showUptimePercent;
4309
+ })
4310
+ .map((monitor: StatusPageResource) => {
4311
+ return monitor.monitorId!;
4312
+ })
4313
+ .filter((id: ObjectID) => {
4314
+ return Boolean(id); // remove nulls
4315
+ });
4316
+
4317
+ /*
4318
+ * Batched: this loop used to issue 4 queries per monitor group per page
4319
+ * view (3 inside MonitorGroupService.getCurrentStatus + one duplicate
4320
+ * group-resource fetch), on the hottest public endpoint in the product.
4321
+ * One shared fetch + the already-loaded `monitorStatuses` now serve every
4322
+ * group.
4323
+ */
4324
+ const monitorGroupResourcesByGroupId: Dictionary<
4325
+ Array<MonitorGroupResource>
4326
+ > =
4327
+ await MonitorGroupService.getMonitorGroupResourcesByGroupIds(
4328
+ monitorGroupIds,
4329
+ );
4330
+
4331
+ const monitorGroupStatuses: Dictionary<MonitorStatus> =
4332
+ await MonitorGroupService.getCurrentStatusesForMonitorGroups({
4333
+ monitorGroupIds: monitorGroupIds,
4334
+ monitorStatuses: monitorStatuses,
4335
+ monitorGroupResources: monitorGroupResourcesByGroupId,
4336
+ });
4337
+
4338
+ for (const monitorGroupId of monitorGroupIds) {
4339
+ // get current status of monitors in the group.
4340
+
4341
+ const currentStatus: MonitorStatus | undefined =
4342
+ monitorGroupStatuses[monitorGroupId.toString()];
4343
+
4344
+ if (currentStatus) {
4345
+ monitorGroupCurrentStatuses[monitorGroupId.toString()] =
4346
+ currentStatus.id!;
4546
4347
  }
4547
- }
4548
4348
 
4549
- let episodeQuery: Query<IncidentEpisode> = {
4550
- _id: QueryHelper.any(
4551
- Array.from(episodeIdsFromMembers).map((id: string) => {
4552
- return new ObjectID(id);
4349
+ // get monitors in the group.
4350
+
4351
+ const monitorsInGroupIds: Array<ObjectID> = (
4352
+ monitorGroupResourcesByGroupId[monitorGroupId.toString()] || []
4353
+ )
4354
+ .map((resource: MonitorGroupResource) => {
4355
+ return resource.monitorId!;
4356
+ })
4357
+ .filter((id: ObjectID) => {
4358
+ return Boolean(id); // remove nulls
4359
+ });
4360
+
4361
+ const shouldShowTimelineForThisGroup: boolean = Boolean(
4362
+ statusPageResources.find((resource: StatusPageResource) => {
4363
+ return (
4364
+ resource.monitorGroupId?.toString() === monitorGroupId.toString() &&
4365
+ (resource.showStatusHistoryChart || resource.showUptimePercent)
4366
+ );
4553
4367
  }),
4554
- ),
4555
- projectId: statusPage.projectId!,
4556
- isVisibleOnStatusPage: true,
4557
- };
4368
+ );
4558
4369
 
4559
- if (episodeId) {
4560
- episodeQuery = {
4561
- _id: episodeId.toString(),
4562
- projectId: statusPage.projectId!,
4563
- isVisibleOnStatusPage: true,
4564
- };
4370
+ for (const monitorId of monitorsInGroupIds) {
4371
+ if (!monitorId) {
4372
+ continue;
4373
+ }
4565
4374
 
4566
- // When viewing a specific episode, also fetch its members directly
4567
- const episodeMembersForSpecificEpisode: Array<IncidentEpisodeMember> =
4568
- await IncidentEpisodeMemberService.findBy({
4569
- query: {
4570
- incidentEpisodeId: episodeId,
4571
- projectId: statusPage.projectId!,
4572
- },
4573
- select: {
4574
- incidentEpisodeId: true,
4575
- incidentId: true,
4576
- },
4577
- skip: 0,
4578
- limit: LIMIT_PER_PROJECT,
4579
- props: {
4580
- isRoot: true,
4581
- },
4582
- });
4375
+ if (
4376
+ !monitorsOnStatusPage.find((item: ObjectID) => {
4377
+ return item.toString() === monitorId.toString();
4378
+ })
4379
+ ) {
4380
+ monitorsOnStatusPage.push(monitorId);
4381
+ }
4382
+
4383
+ // add this to the timeline event for this group.
4583
4384
 
4584
- // Merge with existing episode members
4585
- for (const member of episodeMembersForSpecificEpisode) {
4586
4385
  if (
4587
- !episodeMembers.some((m: IncidentEpisodeMember) => {
4588
- return (
4589
- m.incidentEpisodeId?.toString() ===
4590
- member.incidentEpisodeId?.toString() &&
4591
- m.incidentId?.toString() === member.incidentId?.toString()
4592
- );
4386
+ shouldShowTimelineForThisGroup &&
4387
+ !monitorsOnStatusPageForTimeline.find((item: ObjectID) => {
4388
+ return item.toString() === monitorId.toString();
4593
4389
  })
4594
4390
  ) {
4595
- episodeMembers.push(member);
4391
+ monitorsOnStatusPageForTimeline.push(monitorId);
4596
4392
  }
4597
4393
  }
4394
+
4395
+ monitorsInGroup[monitorGroupId.toString()] = monitorsInGroupIds;
4598
4396
  }
4599
4397
 
4600
- // Get episodes
4601
- let episodes: Array<IncidentEpisode> = [];
4398
+ const monitorStatusTimelines: Array<MonitorStatusTimeline> =
4399
+ await StatusPageService.getMonitorStatusTimelineForStatusPage({
4400
+ monitorIds: monitorsOnStatusPageForTimeline,
4401
+ startDate: startDateForMonitorTimeline,
4402
+ endDate: endDateForMonitorTimeline,
4403
+ });
4602
4404
 
4603
- let selectEpisodes: Select<IncidentEpisode> = {
4604
- createdAt: true,
4605
- declaredAt: true,
4606
- updatedAt: true,
4607
- title: true,
4608
- description: true,
4609
- _id: true,
4610
- episodeNumber: true,
4611
- incidentSeverity: {
4612
- name: true,
4613
- color: true,
4614
- },
4615
- currentIncidentState: {
4616
- name: true,
4617
- color: true,
4618
- _id: true,
4619
- order: true,
4620
- isCreatedState: true,
4621
- isAcknowledgedState: true,
4622
- isResolvedState: true,
4623
- },
4624
- incidentCount: true,
4405
+ // return everything.
4406
+
4407
+ return {
4408
+ statusPageResources,
4409
+ monitorStatuses,
4410
+ monitorGroupCurrentStatuses,
4411
+ statusPageGroups: groups,
4412
+ monitorStatusTimelines,
4413
+ statusPage,
4414
+ monitorsOnStatusPage,
4415
+ monitorsInGroup,
4416
+ startDateForMonitorTimeline,
4417
+ endDateForMonitorTimeline,
4625
4418
  };
4419
+ }
4626
4420
 
4627
- if (statusPage.showEpisodeLabelsOnStatusPage) {
4628
- selectEpisodes = {
4629
- ...selectEpisodes,
4630
- labels: {
4421
+ /*
4422
+ * Builds the full overview payload for one status page. Everything below
4423
+ * is a pure function of the statusPageId — every query runs with isRoot
4424
+ * props and nothing is read from the request — which is what makes the
4425
+ * result safe to cache and share across requests. The returned JSONObject
4426
+ * must never be mutated after build.
4427
+ */
4428
+ @CaptureSpan()
4429
+ public async buildOverviewResponse(
4430
+ statusPageId: ObjectID,
4431
+ ): Promise<JSONObject> {
4432
+ /*
4433
+ * The timeline window comes from the status page's configured
4434
+ * showUptimeHistoryInDays. getStatusPageResourcesAndTimelines
4435
+ * fetches the status page row anyway, so let it compute the window
4436
+ * (clamped to 1..90 days, ending now) instead of issuing a separate
4437
+ * StatusPage query here just to read that one column.
4438
+ */
4439
+ const {
4440
+ monitorStatuses,
4441
+ monitorGroupCurrentStatuses,
4442
+ statusPageResources,
4443
+ statusPage,
4444
+ monitorsOnStatusPage,
4445
+ monitorStatusTimelines,
4446
+ statusPageGroups,
4447
+ monitorsInGroup,
4448
+ startDateForMonitorTimeline: startDate,
4449
+ endDateForMonitorTimeline: endDate,
4450
+ } = await this.getStatusPageResourcesAndTimelines({
4451
+ statusPageId: statusPageId,
4452
+ });
4453
+
4454
+ // check if status page has active incident.
4455
+ let activeIncidents: Array<Incident> = [];
4456
+ if (monitorsOnStatusPage.length > 0) {
4457
+ let select: Select<Incident> = {
4458
+ createdAt: true,
4459
+ declaredAt: true,
4460
+ updatedAt: true,
4461
+ title: true,
4462
+ description: true,
4463
+ _id: true,
4464
+ postmortemNote: true,
4465
+ postmortemPostedAt: true,
4466
+ showPostmortemOnStatusPage: true,
4467
+ postmortemAttachments: {
4468
+ _id: true,
4469
+ name: true,
4470
+ },
4471
+ incidentSeverity: {
4631
4472
  name: true,
4632
4473
  color: true,
4633
4474
  },
4634
- };
4635
- }
4636
-
4637
- if (episodeIdsFromMembers.size > 0 || episodeId) {
4638
- episodes = await IncidentEpisodeService.findBy({
4639
- query: episodeQuery,
4640
- select: selectEpisodes,
4641
- sort: {
4642
- declaredAt: SortOrder.Descending,
4643
- createdAt: SortOrder.Descending,
4475
+ currentIncidentState: {
4476
+ _id: true,
4477
+ name: true,
4478
+ color: true,
4479
+ order: true,
4644
4480
  },
4645
- skip: 0,
4646
- limit: LIMIT_PER_PROJECT,
4647
- props: {
4648
- isRoot: true,
4481
+ monitors: {
4482
+ _id: true,
4649
4483
  },
4650
- });
4651
- }
4484
+ };
4485
+
4486
+ if (statusPage.showIncidentLabelsOnStatusPage) {
4487
+ select = {
4488
+ ...select,
4489
+ labels: {
4490
+ name: true,
4491
+ color: true,
4492
+ },
4493
+ };
4494
+ }
4652
4495
 
4653
- // If no specific episode, also fetch active (unresolved) episodes
4654
- if (!episodeId && episodeIdsFromMembers.size > 0) {
4655
4496
  const unresolvedIncidentStates: Array<IncidentState> =
4656
4497
  await IncidentStateService.getUnresolvedIncidentStates(
4657
4498
  statusPage.projectId!,
@@ -4665,140 +4506,54 @@ export default class StatusPageAPI extends BaseAPI<
4665
4506
  return state.id!;
4666
4507
  });
4667
4508
 
4668
- const activeEpisodes: Array<IncidentEpisode> =
4669
- await IncidentEpisodeService.findBy({
4509
+ if (statusPage.showIncidentsOnStatusPage) {
4510
+ activeIncidents = await IncidentService.findBy({
4670
4511
  query: {
4671
- _id: QueryHelper.any(
4672
- Array.from(episodeIdsFromMembers).map((id: string) => {
4673
- return new ObjectID(id);
4674
- }),
4675
- ),
4676
- isVisibleOnStatusPage: true,
4512
+ monitors: monitorsOnStatusPage as any,
4677
4513
  currentIncidentStateId: QueryHelper.any(unresolvedIncidentStateIds),
4514
+ isVisibleOnStatusPage: true,
4678
4515
  projectId: statusPage.projectId!,
4679
- },
4680
- select: selectEpisodes,
4681
- sort: {
4682
- declaredAt: SortOrder.Descending,
4683
- createdAt: SortOrder.Descending,
4684
- },
4685
- skip: 0,
4686
- limit: LIMIT_PER_PROJECT,
4687
- props: {
4688
- isRoot: true,
4689
- },
4690
- });
4691
-
4692
- episodes = [...activeEpisodes, ...episodes];
4693
- episodes = ArrayUtil.distinctByFieldName(episodes, "_id");
4694
- }
4695
-
4696
- const episodesOnStatusPage: Array<ObjectID> = episodes.map(
4697
- (episode: IncidentEpisode) => {
4698
- return episode.id!;
4699
- },
4700
- );
4701
-
4702
- /*
4703
- * Build a map of episode ID -> monitor IDs from episode members
4704
- * Collect all unique incident IDs from episode members
4705
- */
4706
- const memberIncidentIds: Array<ObjectID> = [];
4707
- for (const member of episodeMembers) {
4708
- if (
4709
- member.incidentId &&
4710
- !memberIncidentIds.some((id: ObjectID) => {
4711
- return id.toString() === member.incidentId!.toString();
4712
- })
4713
- ) {
4714
- memberIncidentIds.push(member.incidentId);
4715
- }
4716
- }
4717
-
4718
- // Fetch incidents with their monitors
4719
- let memberIncidents: Array<Incident> = [];
4720
- if (memberIncidentIds.length > 0) {
4721
- memberIncidents = await IncidentService.findBy({
4722
- query: {
4723
- _id: QueryHelper.any(memberIncidentIds),
4724
- projectId: statusPage.projectId!,
4725
- },
4726
- select: {
4727
- _id: true,
4728
- monitors: {
4729
- _id: true,
4730
- },
4731
- },
4732
- skip: 0,
4733
- limit: LIMIT_PER_PROJECT,
4734
- props: {
4735
- isRoot: true,
4736
- },
4737
- });
4738
- }
4739
-
4740
- // Build a map of incident ID -> monitors
4741
- const incidentMonitorsMap: Map<string, Array<ObjectID>> = new Map();
4742
- for (const incident of memberIncidents) {
4743
- const incidentIdStr: string = incident.id!.toString();
4744
- const monitorIds: Array<ObjectID> = (incident.monitors || [])
4745
- .map((m: Monitor) => {
4746
- return new ObjectID(m._id?.toString() || m.id?.toString() || "");
4747
- })
4748
- .filter((id: ObjectID) => {
4749
- return id.toString() !== "";
4750
- });
4751
- incidentMonitorsMap.set(incidentIdStr, monitorIds);
4752
- }
4753
-
4754
- // Build episode monitors map from members and incident monitors
4755
- const episodeMonitorsMap: Map<string, Array<ObjectID>> = new Map();
4756
- for (const member of episodeMembers) {
4757
- if (member.incidentEpisodeId && member.incidentId) {
4758
- const episodeIdStr: string = member.incidentEpisodeId.toString();
4759
- const incidentIdStr: string = member.incidentId.toString();
4760
-
4761
- if (!episodeMonitorsMap.has(episodeIdStr)) {
4762
- episodeMonitorsMap.set(episodeIdStr, []);
4763
- }
4764
-
4765
- const episodeMonitors: Array<ObjectID> =
4766
- episodeMonitorsMap.get(episodeIdStr)!;
4767
- const incidentMonitors: Array<ObjectID> =
4768
- incidentMonitorsMap.get(incidentIdStr) || [];
4516
+ },
4517
+ select: select,
4518
+ sort: {
4519
+ declaredAt: SortOrder.Descending,
4520
+ createdAt: SortOrder.Descending,
4521
+ },
4769
4522
 
4770
- for (const monitorId of incidentMonitors) {
4771
- if (
4772
- !episodeMonitors.some((m: ObjectID) => {
4773
- return m.toString() === monitorId.toString();
4774
- })
4775
- ) {
4776
- episodeMonitors.push(monitorId);
4777
- }
4778
- }
4523
+ skip: 0,
4524
+ limit: LIMIT_PER_PROJECT,
4525
+ props: {
4526
+ isRoot: true,
4527
+ },
4528
+ });
4779
4529
  }
4780
4530
  }
4781
4531
 
4782
- // Get public notes for episodes
4783
- let episodePublicNotes: Array<IncidentEpisodePublicNote> = [];
4532
+ const incidentsOnStatusPage: Array<ObjectID> = activeIncidents.map(
4533
+ (incident: Incident) => {
4534
+ return incident.id!;
4535
+ },
4536
+ );
4784
4537
 
4785
- if (episodesOnStatusPage.length > 0) {
4786
- episodePublicNotes = await IncidentEpisodePublicNoteService.findBy({
4538
+ let incidentPublicNotes: Array<IncidentPublicNote> = [];
4539
+
4540
+ if (incidentsOnStatusPage.length > 0) {
4541
+ incidentPublicNotes = await IncidentPublicNoteService.findBy({
4787
4542
  query: {
4788
- incidentEpisodeId: QueryHelper.any(episodesOnStatusPage),
4543
+ incidentId: QueryHelper.any(incidentsOnStatusPage),
4789
4544
  projectId: statusPage.projectId!,
4790
4545
  },
4791
4546
  select: {
4792
- postedAt: true,
4793
4547
  note: true,
4794
- incidentEpisodeId: true,
4548
+ incidentId: true,
4549
+ postedAt: true,
4795
4550
  attachments: {
4796
4551
  _id: true,
4797
4552
  name: true,
4798
4553
  },
4799
4554
  },
4800
4555
  sort: {
4801
- postedAt: SortOrder.Descending,
4556
+ postedAt: SortOrder.Descending, // new note first
4802
4557
  },
4803
4558
  skip: 0,
4804
4559
  limit: LIMIT_PER_PROJECT,
@@ -4808,30 +4563,402 @@ export default class StatusPageAPI extends BaseAPI<
4808
4563
  });
4809
4564
  }
4810
4565
 
4811
- // Get state timelines for episodes
4812
- let episodeStateTimelines: Array<IncidentEpisodeStateTimeline> = [];
4566
+ let incidentStateTimelines: Array<IncidentStateTimeline> = [];
4813
4567
 
4814
- if (episodesOnStatusPage.length > 0) {
4815
- episodeStateTimelines = await IncidentEpisodeStateTimelineService.findBy({
4568
+ if (incidentsOnStatusPage.length > 0) {
4569
+ incidentStateTimelines = await IncidentStateTimelineService.findBy({
4816
4570
  query: {
4817
- incidentEpisodeId: QueryHelper.any(episodesOnStatusPage),
4571
+ incidentId: QueryHelper.any(incidentsOnStatusPage),
4818
4572
  projectId: statusPage.projectId!,
4819
4573
  },
4820
4574
  select: {
4821
4575
  _id: true,
4822
4576
  createdAt: true,
4823
4577
  startsAt: true,
4824
- incidentEpisodeId: true,
4578
+ incidentId: true,
4825
4579
  incidentState: {
4580
+ _id: true,
4581
+ name: true,
4582
+ color: true,
4583
+ isCreatedState: true,
4584
+ isResolvedState: true,
4585
+ isAcknowledgedState: true,
4586
+ },
4587
+ },
4588
+
4589
+ sort: {
4590
+ startsAt: SortOrder.Descending, // newer state changes first
4591
+ },
4592
+ skip: 0,
4593
+ limit: LIMIT_PER_PROJECT,
4594
+ props: {
4595
+ isRoot: true,
4596
+ },
4597
+ });
4598
+ }
4599
+
4600
+ // Fetch active episodes (similar to incidents)
4601
+ let activeEpisodes: Array<IncidentEpisode> = [];
4602
+ let activeEpisodesJson: JSONArray = [];
4603
+ let episodePublicNotes: Array<IncidentEpisodePublicNote> = [];
4604
+ let episodeStateTimelines: Array<IncidentEpisodeStateTimeline> = [];
4605
+
4606
+ /*
4607
+ * Cheap guard before the expensive part: the block below scans every
4608
+ * incident ever attached to this page's monitors (a many-to-many
4609
+ * join, up to LIMIT_PER_PROJECT rows) just to discover episode
4610
+ * membership — on every overview view, even though most pages have
4611
+ * zero active episodes most of the time. One indexed COUNT of the
4612
+ * project's unresolved, visible episodes lets us skip all of it in
4613
+ * the common case. Behavior-preserving: the final activeEpisodes
4614
+ * query applies exactly these three constraints, so count == 0
4615
+ * implies the block's outputs stay empty.
4616
+ */
4617
+ let unresolvedIncidentStateIds: Array<ObjectID> = [];
4618
+ let hasActiveEpisodes: boolean = false;
4619
+
4620
+ if (
4621
+ statusPage.showEpisodesOnStatusPage &&
4622
+ monitorsOnStatusPage.length > 0
4623
+ ) {
4624
+ const unresolvedIncidentStates: Array<IncidentState> =
4625
+ await IncidentStateService.getUnresolvedIncidentStates(
4626
+ statusPage.projectId!,
4627
+ { isRoot: true },
4628
+ );
4629
+
4630
+ unresolvedIncidentStateIds = unresolvedIncidentStates.map(
4631
+ (state: IncidentState) => {
4632
+ return state.id!;
4633
+ },
4634
+ );
4635
+
4636
+ const activeEpisodeCount: PositiveNumber =
4637
+ await IncidentEpisodeService.countBy({
4638
+ query: {
4639
+ projectId: statusPage.projectId!,
4640
+ isVisibleOnStatusPage: true,
4641
+ currentIncidentStateId: QueryHelper.any(unresolvedIncidentStateIds),
4642
+ },
4643
+ props: {
4644
+ isRoot: true,
4645
+ },
4646
+ });
4647
+
4648
+ hasActiveEpisodes = activeEpisodeCount.toNumber() > 0;
4649
+ }
4650
+
4651
+ if (hasActiveEpisodes) {
4652
+ // First, get incidents that have monitors on status page
4653
+ const incidentsForEpisodes: Array<Incident> =
4654
+ await IncidentService.findBy({
4655
+ query: {
4656
+ monitors: monitorsOnStatusPage as any,
4657
+ isVisibleOnStatusPage: true,
4658
+ projectId: statusPage.projectId!,
4659
+ },
4660
+ select: {
4661
+ _id: true,
4662
+ },
4663
+ skip: 0,
4664
+ limit: LIMIT_PER_PROJECT,
4665
+ props: {
4666
+ isRoot: true,
4667
+ },
4668
+ });
4669
+
4670
+ const incidentIdsForEpisodes: Array<ObjectID> = incidentsForEpisodes.map(
4671
+ (incident: Incident) => {
4672
+ return incident.id!;
4673
+ },
4674
+ );
4675
+
4676
+ // Get episode members for these incidents
4677
+ let episodeMembers: Array<IncidentEpisodeMember> = [];
4678
+ if (incidentIdsForEpisodes.length > 0) {
4679
+ episodeMembers = await IncidentEpisodeMemberService.findBy({
4680
+ query: {
4681
+ incidentId: QueryHelper.any(incidentIdsForEpisodes),
4682
+ projectId: statusPage.projectId!,
4683
+ },
4684
+ select: {
4685
+ incidentEpisodeId: true,
4686
+ incidentId: true,
4687
+ },
4688
+ skip: 0,
4689
+ limit: LIMIT_PER_PROJECT,
4690
+ props: {
4691
+ isRoot: true,
4692
+ },
4693
+ });
4694
+ }
4695
+
4696
+ // Get unique episode IDs
4697
+ const episodeIdsFromMembers: Set<string> = new Set();
4698
+ for (const member of episodeMembers) {
4699
+ if (member.incidentEpisodeId) {
4700
+ episodeIdsFromMembers.add(member.incidentEpisodeId.toString());
4701
+ }
4702
+ }
4703
+
4704
+ // Fetch active (unresolved) episodes
4705
+ if (episodeIdsFromMembers.size > 0) {
4706
+ // unresolvedIncidentStateIds was fetched by the guard above.
4707
+ let selectEpisodes: Select<IncidentEpisode> = {
4708
+ createdAt: true,
4709
+ declaredAt: true,
4710
+ updatedAt: true,
4711
+ title: true,
4712
+ description: true,
4713
+ _id: true,
4714
+ episodeNumber: true,
4715
+ incidentSeverity: {
4826
4716
  name: true,
4827
4717
  color: true,
4718
+ },
4719
+ currentIncidentState: {
4720
+ name: true,
4721
+ color: true,
4722
+ _id: true,
4723
+ order: true,
4828
4724
  isCreatedState: true,
4829
4725
  isAcknowledgedState: true,
4830
4726
  isResolvedState: true,
4831
4727
  },
4728
+ incidentCount: true,
4729
+ };
4730
+
4731
+ if (statusPage.showEpisodeLabelsOnStatusPage) {
4732
+ selectEpisodes = {
4733
+ ...selectEpisodes,
4734
+ labels: {
4735
+ name: true,
4736
+ color: true,
4737
+ },
4738
+ };
4739
+ }
4740
+
4741
+ activeEpisodes = await IncidentEpisodeService.findBy({
4742
+ query: {
4743
+ _id: QueryHelper.any(
4744
+ Array.from(episodeIdsFromMembers).map((id: string) => {
4745
+ return new ObjectID(id);
4746
+ }),
4747
+ ),
4748
+ currentIncidentStateId: QueryHelper.any(unresolvedIncidentStateIds),
4749
+ isVisibleOnStatusPage: true,
4750
+ projectId: statusPage.projectId!,
4751
+ },
4752
+ select: selectEpisodes,
4753
+ sort: {
4754
+ declaredAt: SortOrder.Descending,
4755
+ createdAt: SortOrder.Descending,
4756
+ },
4757
+ skip: 0,
4758
+ limit: LIMIT_PER_PROJECT,
4759
+ props: {
4760
+ isRoot: true,
4761
+ },
4762
+ });
4763
+
4764
+ // Build episode monitors map
4765
+ if (activeEpisodes.length > 0) {
4766
+ // Collect all incident IDs from episode members for active episodes
4767
+ const activeEpisodeIds: Set<string> = new Set(
4768
+ activeEpisodes.map((e: IncidentEpisode) => {
4769
+ return e.id!.toString();
4770
+ }),
4771
+ );
4772
+
4773
+ const memberIncidentIds: Array<ObjectID> = [];
4774
+ for (const member of episodeMembers) {
4775
+ if (
4776
+ member.incidentEpisodeId &&
4777
+ activeEpisodeIds.has(member.incidentEpisodeId.toString()) &&
4778
+ member.incidentId &&
4779
+ !memberIncidentIds.some((id: ObjectID) => {
4780
+ return id.toString() === member.incidentId!.toString();
4781
+ })
4782
+ ) {
4783
+ memberIncidentIds.push(member.incidentId);
4784
+ }
4785
+ }
4786
+
4787
+ // Fetch incidents with monitors
4788
+ let memberIncidents: Array<Incident> = [];
4789
+ if (memberIncidentIds.length > 0) {
4790
+ memberIncidents = await IncidentService.findBy({
4791
+ query: {
4792
+ _id: QueryHelper.any(memberIncidentIds),
4793
+ isVisibleOnStatusPage: true,
4794
+ projectId: statusPage.projectId!,
4795
+ },
4796
+ select: {
4797
+ _id: true,
4798
+ monitors: {
4799
+ _id: true,
4800
+ },
4801
+ },
4802
+ skip: 0,
4803
+ limit: LIMIT_PER_PROJECT,
4804
+ props: {
4805
+ isRoot: true,
4806
+ },
4807
+ });
4808
+ }
4809
+
4810
+ // Build incident -> monitors map
4811
+ const incidentMonitorsMap: Map<string, Array<ObjectID>> = new Map();
4812
+ for (const incident of memberIncidents) {
4813
+ const incidentIdStr: string = incident.id!.toString();
4814
+ const monitorIds: Array<ObjectID> = (incident.monitors || [])
4815
+ .map((m: Monitor) => {
4816
+ return new ObjectID(
4817
+ m._id?.toString() || m.id?.toString() || "",
4818
+ );
4819
+ })
4820
+ .filter((id: ObjectID) => {
4821
+ return id.toString() !== "";
4822
+ });
4823
+ incidentMonitorsMap.set(incidentIdStr, monitorIds);
4824
+ }
4825
+
4826
+ // Build episode -> monitors map
4827
+ const episodeMonitorsMap: Map<string, Array<ObjectID>> = new Map();
4828
+ for (const member of episodeMembers) {
4829
+ if (
4830
+ member.incidentEpisodeId &&
4831
+ member.incidentId &&
4832
+ activeEpisodeIds.has(member.incidentEpisodeId.toString())
4833
+ ) {
4834
+ const episodeIdStr: string = member.incidentEpisodeId.toString();
4835
+ const incidentIdStr: string = member.incidentId.toString();
4836
+
4837
+ if (!episodeMonitorsMap.has(episodeIdStr)) {
4838
+ episodeMonitorsMap.set(episodeIdStr, []);
4839
+ }
4840
+
4841
+ const episodeMonitors: Array<ObjectID> =
4842
+ episodeMonitorsMap.get(episodeIdStr)!;
4843
+ const incidentMonitors: Array<ObjectID> =
4844
+ incidentMonitorsMap.get(incidentIdStr) || [];
4845
+
4846
+ for (const monitorId of incidentMonitors) {
4847
+ if (
4848
+ !episodeMonitors.some((m: ObjectID) => {
4849
+ return m.toString() === monitorId.toString();
4850
+ })
4851
+ ) {
4852
+ episodeMonitors.push(monitorId);
4853
+ }
4854
+ }
4855
+ }
4856
+ }
4857
+
4858
+ // Serialize episodes and add monitors
4859
+ activeEpisodesJson = BaseModel.toJSONArray(
4860
+ activeEpisodes,
4861
+ IncidentEpisode,
4862
+ );
4863
+ for (const episodeJson of activeEpisodesJson) {
4864
+ const episodeObj: JSONObject = episodeJson as JSONObject;
4865
+ const episodeId: string | undefined = episodeObj["_id"]?.toString();
4866
+ if (episodeId) {
4867
+ const monitorIds: Array<ObjectID> =
4868
+ episodeMonitorsMap.get(episodeId) || [];
4869
+ episodeObj["monitors"] = monitorIds.map((id: ObjectID) => {
4870
+ return { _id: id.toString() };
4871
+ });
4872
+ }
4873
+ }
4874
+
4875
+ // Get episode public notes
4876
+ const episodesOnStatusPage: Array<ObjectID> = activeEpisodes.map(
4877
+ (episode: IncidentEpisode) => {
4878
+ return episode.id!;
4879
+ },
4880
+ );
4881
+
4882
+ if (episodesOnStatusPage.length > 0) {
4883
+ episodePublicNotes = await IncidentEpisodePublicNoteService.findBy({
4884
+ query: {
4885
+ incidentEpisodeId: QueryHelper.any(episodesOnStatusPage),
4886
+ projectId: statusPage.projectId!,
4887
+ },
4888
+ select: {
4889
+ postedAt: true,
4890
+ note: true,
4891
+ incidentEpisodeId: true,
4892
+ attachments: {
4893
+ _id: true,
4894
+ name: true,
4895
+ },
4896
+ },
4897
+ sort: {
4898
+ postedAt: SortOrder.Descending,
4899
+ },
4900
+ skip: 0,
4901
+ limit: LIMIT_PER_PROJECT,
4902
+ props: {
4903
+ isRoot: true,
4904
+ },
4905
+ });
4906
+
4907
+ // Get episode state timelines
4908
+ episodeStateTimelines =
4909
+ await IncidentEpisodeStateTimelineService.findBy({
4910
+ query: {
4911
+ incidentEpisodeId: QueryHelper.any(episodesOnStatusPage),
4912
+ projectId: statusPage.projectId!,
4913
+ },
4914
+ select: {
4915
+ _id: true,
4916
+ createdAt: true,
4917
+ startsAt: true,
4918
+ incidentEpisodeId: true,
4919
+ incidentState: {
4920
+ name: true,
4921
+ color: true,
4922
+ isCreatedState: true,
4923
+ isAcknowledgedState: true,
4924
+ isResolvedState: true,
4925
+ },
4926
+ },
4927
+ sort: {
4928
+ startsAt: SortOrder.Descending,
4929
+ },
4930
+ skip: 0,
4931
+ limit: LIMIT_PER_PROJECT,
4932
+ props: {
4933
+ isRoot: true,
4934
+ },
4935
+ });
4936
+ }
4937
+ }
4938
+ }
4939
+ }
4940
+
4941
+ // check if status page has active announcement.
4942
+
4943
+ const today: Date = OneUptimeDate.getCurrentDate();
4944
+
4945
+ let activeAnnouncements: Array<StatusPageAnnouncement> = [];
4946
+
4947
+ if (statusPage.showAnnouncementsOnStatusPage) {
4948
+ activeAnnouncements = await StatusPageAnnouncementService.findBy({
4949
+ query: {
4950
+ statusPages: statusPageId as any,
4951
+ showAnnouncementAt: QueryHelper.lessThan(today),
4952
+ endAnnouncementAt: QueryHelper.greaterThanOrNull(today),
4953
+ projectId: statusPage.projectId!,
4832
4954
  },
4833
- sort: {
4834
- startsAt: SortOrder.Descending,
4955
+ select: {
4956
+ createdAt: true,
4957
+ title: true,
4958
+ description: true,
4959
+ _id: true,
4960
+ showAnnouncementAt: true,
4961
+ endAnnouncementAt: true,
4835
4962
  },
4836
4963
  skip: 0,
4837
4964
  limit: LIMIT_PER_PROJECT,
@@ -4841,156 +4968,180 @@ export default class StatusPageAPI extends BaseAPI<
4841
4968
  });
4842
4969
  }
4843
4970
 
4844
- // Get all incident states for this project
4845
- const incidentStates: Array<IncidentState> =
4846
- await IncidentStateService.findBy({
4971
+ // check if status page has active scheduled events.
4972
+
4973
+ let scheduledEventsSelect: Select<ScheduledMaintenance> = {
4974
+ createdAt: true,
4975
+ title: true,
4976
+ description: true,
4977
+ _id: true,
4978
+ endsAt: true,
4979
+ startsAt: true,
4980
+ currentScheduledMaintenanceState: {
4981
+ name: true,
4982
+ color: true,
4983
+ isScheduledState: true,
4984
+ isResolvedState: true,
4985
+ isOngoingState: true,
4986
+ },
4987
+ monitors: {
4988
+ _id: true,
4989
+ },
4990
+ };
4991
+
4992
+ if (statusPage.showScheduledEventLabelsOnStatusPage) {
4993
+ scheduledEventsSelect = {
4994
+ ...scheduledEventsSelect,
4995
+ labels: {
4996
+ name: true,
4997
+ color: true,
4998
+ },
4999
+ };
5000
+ }
5001
+
5002
+ let scheduledMaintenanceEvents: Array<ScheduledMaintenance> = [];
5003
+
5004
+ if (statusPage.showScheduledMaintenanceEventsOnStatusPage) {
5005
+ scheduledMaintenanceEvents = await ScheduledMaintenanceService.findBy({
4847
5006
  query: {
5007
+ currentScheduledMaintenanceState: {
5008
+ isOngoingState: true,
5009
+ } as any,
5010
+ statusPages: statusPageId as any,
4848
5011
  projectId: statusPage.projectId!,
5012
+ isVisibleOnStatusPage: true,
4849
5013
  },
4850
- select: {
4851
- isResolvedState: true,
4852
- order: true,
5014
+ select: scheduledEventsSelect,
5015
+ sort: {
5016
+ startsAt: SortOrder.Ascending,
4853
5017
  },
4854
- limit: LIMIT_PER_PROJECT,
4855
5018
  skip: 0,
5019
+ limit: LIMIT_PER_PROJECT,
4856
5020
  props: {
4857
5021
  isRoot: true,
4858
5022
  },
4859
5023
  });
4860
-
4861
- // Serialize episodes and add monitors to each
4862
- const episodesJson: JSONArray = BaseModel.toJSONArray(
4863
- episodes,
4864
- IncidentEpisode,
4865
- );
4866
- for (const episodeJson of episodesJson) {
4867
- const episodeObj: JSONObject = episodeJson as JSONObject;
4868
- const episodeId: string | undefined = episodeObj["_id"]?.toString();
4869
- if (episodeId) {
4870
- const monitorIds: Array<ObjectID> =
4871
- episodeMonitorsMap.get(episodeId) || [];
4872
- episodeObj["monitors"] = monitorIds.map((id: ObjectID) => {
4873
- return { _id: id.toString() };
4874
- });
4875
- }
4876
5024
  }
4877
5025
 
4878
- const response: JSONObject = {
4879
- episodePublicNotes: BaseModel.toJSONArray(
4880
- episodePublicNotes,
4881
- IncidentEpisodePublicNote,
4882
- ),
4883
- incidentStates: BaseModel.toJSONArray(incidentStates, IncidentState),
4884
- episodes: episodesJson,
4885
- statusPageResources: BaseModel.toJSONArray(
4886
- statusPageResources,
4887
- StatusPageResource,
4888
- ),
4889
- episodeStateTimelines: BaseModel.toJSONArray(
4890
- episodeStateTimelines,
4891
- IncidentEpisodeStateTimeline,
4892
- ),
4893
- monitorsInGroup: JSONFunctions.serialize(monitorsInGroup),
4894
- };
4895
-
4896
- return response;
4897
- }
5026
+ let futureScheduledMaintenanceEvents: Array<ScheduledMaintenance> = [];
4898
5027
 
4899
- @CaptureSpan()
4900
- public async getStatusPageResourcesAndTimelines(data: {
4901
- statusPageId: ObjectID;
4902
- /*
4903
- * When omitted, the timeline window is computed from the status page's
4904
- * own showUptimeHistoryInDays (clamped to 1..90 days, ending now) —
4905
- * the status page row is fetched below anyway, so callers that want the
4906
- * page-configured window (the overview endpoint) don't need their own
4907
- * StatusPage query just to read that column.
4908
- */
4909
- startDateForMonitorTimeline?: Date | undefined;
4910
- endDateForMonitorTimeline?: Date | undefined;
4911
- }): Promise<{
4912
- statusPageResources: StatusPageResource[];
4913
- monitorStatuses: MonitorStatus[];
4914
- monitorStatusTimelines: MonitorStatusTimeline[];
4915
- monitorGroupCurrentStatuses: Dictionary<ObjectID>;
4916
- statusPageGroups: StatusPageGroup[];
4917
- statusPage: StatusPage;
4918
- monitorsOnStatusPage: ObjectID[];
4919
- monitorsInGroup: Dictionary<ObjectID[]>;
4920
- startDateForMonitorTimeline: Date;
4921
- endDateForMonitorTimeline: Date;
4922
- }> {
4923
- const objectId: ObjectID = data.statusPageId;
5028
+ if (statusPage.showScheduledMaintenanceEventsOnStatusPage) {
5029
+ futureScheduledMaintenanceEvents =
5030
+ await ScheduledMaintenanceService.findBy({
5031
+ query: {
5032
+ currentScheduledMaintenanceState: {
5033
+ isScheduledState: true,
5034
+ } as any,
5035
+ statusPages: statusPageId as any,
5036
+ projectId: statusPage.projectId!,
5037
+ isVisibleOnStatusPage: true,
5038
+ },
5039
+ select: scheduledEventsSelect,
5040
+ sort: {
5041
+ startsAt: SortOrder.Ascending,
5042
+ },
5043
+ skip: 0,
5044
+ limit: LIMIT_PER_PROJECT,
5045
+ props: {
5046
+ isRoot: true,
5047
+ },
5048
+ });
5049
+ }
4924
5050
 
4925
- const statusPage: StatusPage | null = await StatusPageService.findOneBy({
4926
- query: {
4927
- _id: objectId.toString(),
4928
- },
4929
- select: {
4930
- _id: true,
4931
- projectId: true,
4932
- isPublicStatusPage: true,
4933
- overviewPageDescription: true,
4934
- showIncidentLabelsOnStatusPage: true,
4935
- showScheduledEventLabelsOnStatusPage: true,
4936
- showEpisodeLabelsOnStatusPage: true,
4937
- downtimeMonitorStatuses: {
4938
- _id: true,
4939
- },
4940
- defaultBarColor: true,
4941
- showOverallUptimePercentOnStatusPage: true,
4942
- overallUptimePercentPrecision: true,
4943
- showAnnouncementsOnStatusPage: true,
4944
- showIncidentsOnStatusPage: true,
4945
- showEpisodesOnStatusPage: true,
4946
- showScheduledMaintenanceEventsOnStatusPage: true,
4947
- showUptimeHistoryInDays: true,
4948
- },
4949
- props: {
4950
- isRoot: true,
4951
- },
5051
+ futureScheduledMaintenanceEvents.forEach((event: ScheduledMaintenance) => {
5052
+ scheduledMaintenanceEvents.push(event);
4952
5053
  });
4953
5054
 
4954
- if (!statusPage) {
4955
- throw new BadDataException("Status Page not found");
4956
- }
5055
+ const scheduledMaintenanceEventsOnStatusPage: Array<ObjectID> =
5056
+ scheduledMaintenanceEvents.map((event: ScheduledMaintenance) => {
5057
+ return event.id!;
5058
+ });
4957
5059
 
4958
- let startDateForMonitorTimeline: Date | undefined =
4959
- data.startDateForMonitorTimeline;
4960
- let endDateForMonitorTimeline: Date | undefined =
4961
- data.endDateForMonitorTimeline;
5060
+ let scheduledMaintenanceEventsPublicNotes: Array<ScheduledMaintenancePublicNote> =
5061
+ [];
4962
5062
 
4963
- if (!startDateForMonitorTimeline || !endDateForMonitorTimeline) {
4964
- let uptimeHistoryDays: number = statusPage.showUptimeHistoryInDays || 90;
5063
+ if (scheduledMaintenanceEventsOnStatusPage.length > 0) {
5064
+ scheduledMaintenanceEventsPublicNotes =
5065
+ await ScheduledMaintenancePublicNoteService.findBy({
5066
+ query: {
5067
+ scheduledMaintenanceId: QueryHelper.any(
5068
+ scheduledMaintenanceEventsOnStatusPage,
5069
+ ),
5070
+ projectId: statusPage.projectId!,
5071
+ },
5072
+ select: {
5073
+ postedAt: true,
5074
+ note: true,
5075
+ scheduledMaintenanceId: true,
5076
+ attachments: {
5077
+ _id: true,
5078
+ name: true,
5079
+ },
5080
+ },
5081
+ sort: {
5082
+ postedAt: SortOrder.Ascending,
5083
+ },
5084
+ skip: 0,
5085
+ limit: LIMIT_PER_PROJECT,
5086
+ props: {
5087
+ isRoot: true,
5088
+ },
5089
+ });
5090
+ }
4965
5091
 
4966
- if (uptimeHistoryDays > 90) {
4967
- uptimeHistoryDays = 90;
4968
- }
5092
+ let scheduledMaintenanceStateTimelines: Array<ScheduledMaintenanceStateTimeline> =
5093
+ [];
4969
5094
 
4970
- if (uptimeHistoryDays < 1) {
4971
- uptimeHistoryDays = 1;
4972
- }
5095
+ if (scheduledMaintenanceEventsOnStatusPage.length > 0) {
5096
+ scheduledMaintenanceStateTimelines =
5097
+ await ScheduledMaintenanceStateTimelineService.findBy({
5098
+ query: {
5099
+ scheduledMaintenanceId: QueryHelper.any(
5100
+ scheduledMaintenanceEventsOnStatusPage,
5101
+ ),
5102
+ projectId: statusPage.projectId!,
5103
+ },
5104
+ select: {
5105
+ _id: true,
5106
+ createdAt: true,
5107
+ startsAt: true,
5108
+ scheduledMaintenanceId: true,
5109
+ scheduledMaintenanceState: {
5110
+ _id: true,
5111
+ color: true,
5112
+ name: true,
5113
+ isScheduledState: true,
5114
+ isResolvedState: true,
5115
+ isOngoingState: true,
5116
+ },
5117
+ },
4973
5118
 
4974
- startDateForMonitorTimeline =
4975
- OneUptimeDate.getSomeDaysAgo(uptimeHistoryDays);
4976
- endDateForMonitorTimeline = OneUptimeDate.getCurrentDate();
5119
+ sort: {
5120
+ startsAt: SortOrder.Descending, // newer state changes first
5121
+ },
5122
+ skip: 0,
5123
+ limit: LIMIT_PER_PROJECT,
5124
+ props: {
5125
+ isRoot: true,
5126
+ },
5127
+ });
4977
5128
  }
4978
5129
 
4979
- //get monitor statuses
4980
-
4981
- const monitorStatuses: Array<MonitorStatus> =
4982
- await MonitorStatusService.findBy({
5130
+ // get all status page bar chart rules
5131
+ const statusPageHistoryChartBarColorRules: Array<StatusPageHistoryChartBarColorRule> =
5132
+ await StatusPageHistoryChartBarColorRuleService.findBy({
4983
5133
  query: {
4984
- projectId: statusPage.projectId!,
5134
+ statusPageId: statusPageId,
4985
5135
  },
4986
5136
  select: {
4987
- name: true,
4988
- color: true,
4989
- priority: true,
4990
- isOperationalState: true,
5137
+ _id: true,
5138
+ barColor: true,
5139
+ order: true,
5140
+ statusPageId: true,
5141
+ uptimePercentGreaterThanOrEqualTo: true,
4991
5142
  },
4992
5143
  sort: {
4993
- priority: SortOrder.Ascending,
5144
+ order: SortOrder.Ascending,
4994
5145
  },
4995
5146
  skip: 0,
4996
5147
  limit: LIMIT_PER_PROJECT,
@@ -4999,70 +5150,41 @@ export default class StatusPageAPI extends BaseAPI<
4999
5150
  },
5000
5151
  });
5001
5152
 
5002
- // get resource groups.
5003
-
5004
- const groups: Array<StatusPageGroup> = await StatusPageGroupService.findBy({
5005
- query: {
5006
- statusPageId: objectId,
5007
- },
5008
- select: {
5009
- name: true,
5010
- order: true,
5011
- description: true,
5012
- isExpandedByDefault: true,
5013
- showCurrentStatus: true,
5014
- showUptimePercent: true,
5015
- uptimePercentPrecision: true,
5016
- viewMode: true,
5017
- rowAxisLabel: true,
5018
- columnAxisLabel: true,
5019
- rowAxisValues: true,
5020
- columnAxisValues: true,
5021
- parentStatusPageGroupId: true,
5022
- },
5023
- sort: {
5024
- order: SortOrder.Ascending,
5025
- },
5026
- skip: 0,
5027
- limit: LIMIT_PER_PROJECT,
5028
- props: {
5029
- isRoot: true,
5030
- },
5031
- });
5032
-
5033
- // get monitors on status page.
5034
- const statusPageResources: Array<StatusPageResource> =
5035
- await StatusPageResourceService.findBy({
5153
+ /*
5154
+ * Fetch all incidents (active + resolved) in the timeline date range
5155
+ * for the uptime bar tooltip and click-through
5156
+ */
5157
+ let timelineIncidents: Array<Incident> = [];
5158
+ if (
5159
+ monitorsOnStatusPage.length > 0 &&
5160
+ statusPage.showIncidentsOnStatusPage
5161
+ ) {
5162
+ timelineIncidents = await IncidentService.findBy({
5036
5163
  query: {
5037
- statusPageId: objectId,
5164
+ monitors: monitorsOnStatusPage as any,
5165
+ declaredAt: QueryHelper.inBetween(startDate, endDate),
5166
+ isVisibleOnStatusPage: true,
5167
+ projectId: statusPage.projectId!,
5038
5168
  },
5039
5169
  select: {
5040
- statusPageGroupId: true,
5041
- statusPageGroup: {
5170
+ _id: true,
5171
+ title: true,
5172
+ declaredAt: true,
5173
+ incidentSeverity: {
5042
5174
  name: true,
5043
- viewMode: true,
5044
- rowAxisLabel: true,
5045
- columnAxisLabel: true,
5175
+ color: true,
5046
5176
  },
5047
- monitorId: true,
5048
- displayTooltip: true,
5049
- displayDescription: true,
5050
- displayName: true,
5051
- showStatusHistoryChart: true,
5052
- showCurrentStatus: true,
5053
- order: true,
5054
- monitor: {
5177
+ currentIncidentState: {
5178
+ _id: true,
5179
+ name: true,
5180
+ color: true,
5181
+ },
5182
+ monitors: {
5055
5183
  _id: true,
5056
- currentMonitorStatusId: true,
5057
5184
  },
5058
- monitorGroupId: true,
5059
- showUptimePercent: true,
5060
- uptimePercentPrecision: true,
5061
- rowAxisValue: true,
5062
- columnAxisValue: true,
5063
5185
  },
5064
5186
  sort: {
5065
- order: SortOrder.Ascending,
5187
+ declaredAt: SortOrder.Descending,
5066
5188
  },
5067
5189
  skip: 0,
5068
5190
  limit: LIMIT_PER_PROJECT,
@@ -5070,141 +5192,88 @@ export default class StatusPageAPI extends BaseAPI<
5070
5192
  isRoot: true,
5071
5193
  },
5072
5194
  });
5195
+ }
5073
5196
 
5074
- const monitorGroupIds: Array<ObjectID> = statusPageResources
5075
- .map((resource: StatusPageResource) => {
5076
- return resource.monitorGroupId!;
5077
- })
5078
- .filter((id: ObjectID) => {
5079
- return Boolean(id); // remove nulls
5080
- });
5081
-
5082
- // get monitors in the group.
5083
- const monitorGroupCurrentStatuses: Dictionary<ObjectID> = {};
5084
- const monitorsInGroup: Dictionary<Array<ObjectID>> = {};
5085
-
5086
- // get monitor status charts.
5087
- const monitorsOnStatusPage: Array<ObjectID> = statusPageResources
5088
- .map((monitor: StatusPageResource) => {
5089
- return monitor.monitorId!;
5090
- })
5091
- .filter((id: ObjectID) => {
5092
- return Boolean(id); // remove nulls
5093
- });
5094
-
5095
- const monitorsOnStatusPageForTimeline: Array<ObjectID> = statusPageResources
5096
- .filter((monitor: StatusPageResource) => {
5097
- return monitor.showStatusHistoryChart || monitor.showUptimePercent;
5098
- })
5099
- .map((monitor: StatusPageResource) => {
5100
- return monitor.monitorId!;
5101
- })
5102
- .filter((id: ObjectID) => {
5103
- return Boolean(id); // remove nulls
5104
- });
5105
-
5106
- /*
5107
- * Batched: this loop used to issue 4 queries per monitor group per page
5108
- * view (3 inside MonitorGroupService.getCurrentStatus + one duplicate
5109
- * group-resource fetch), on the hottest public endpoint in the product.
5110
- * One shared fetch + the already-loaded `monitorStatuses` now serve every
5111
- * group.
5112
- */
5113
- const monitorGroupResourcesByGroupId: Dictionary<
5114
- Array<MonitorGroupResource>
5115
- > =
5116
- await MonitorGroupService.getMonitorGroupResourcesByGroupIds(
5117
- monitorGroupIds,
5118
- );
5119
-
5120
- const monitorGroupStatuses: Dictionary<MonitorStatus> =
5121
- await MonitorGroupService.getCurrentStatusesForMonitorGroups({
5122
- monitorGroupIds: monitorGroupIds,
5123
- monitorStatuses: monitorStatuses,
5124
- monitorGroupResources: monitorGroupResourcesByGroupId,
5197
+ const overallStatus: MonitorStatus | null =
5198
+ StatusPageService.getOverallMonitorStatus({
5199
+ statusPageResources,
5200
+ monitorStatuses,
5201
+ monitorGroupCurrentStatuses,
5125
5202
  });
5126
5203
 
5127
- for (const monitorGroupId of monitorGroupIds) {
5128
- // get current status of monitors in the group.
5129
-
5130
- const currentStatus: MonitorStatus | undefined =
5131
- monitorGroupStatuses[monitorGroupId.toString()];
5132
-
5133
- if (currentStatus) {
5134
- monitorGroupCurrentStatuses[monitorGroupId.toString()] =
5135
- currentStatus.id!;
5136
- }
5137
-
5138
- // get monitors in the group.
5139
-
5140
- const monitorsInGroupIds: Array<ObjectID> = (
5141
- monitorGroupResourcesByGroupId[monitorGroupId.toString()] || []
5142
- )
5143
- .map((resource: MonitorGroupResource) => {
5144
- return resource.monitorId!;
5145
- })
5146
- .filter((id: ObjectID) => {
5147
- return Boolean(id); // remove nulls
5148
- });
5149
-
5150
- const shouldShowTimelineForThisGroup: boolean = Boolean(
5151
- statusPageResources.find((resource: StatusPageResource) => {
5152
- return (
5153
- resource.monitorGroupId?.toString() === monitorGroupId.toString() &&
5154
- (resource.showStatusHistoryChart || resource.showUptimePercent)
5155
- );
5156
- }),
5157
- );
5158
-
5159
- for (const monitorId of monitorsInGroupIds) {
5160
- if (!monitorId) {
5161
- continue;
5162
- }
5163
-
5164
- if (
5165
- !monitorsOnStatusPage.find((item: ObjectID) => {
5166
- return item.toString() === monitorId.toString();
5167
- })
5168
- ) {
5169
- monitorsOnStatusPage.push(monitorId);
5170
- }
5171
-
5172
- // add this to the timeline event for this group.
5204
+ const response: JSONObject = {
5205
+ overallStatus: overallStatus
5206
+ ? BaseModel.toJSON(overallStatus, MonitorStatus)
5207
+ : null,
5173
5208
 
5174
- if (
5175
- shouldShowTimelineForThisGroup &&
5176
- !monitorsOnStatusPageForTimeline.find((item: ObjectID) => {
5177
- return item.toString() === monitorId.toString();
5178
- })
5179
- ) {
5180
- monitorsOnStatusPageForTimeline.push(monitorId);
5181
- }
5182
- }
5209
+ scheduledMaintenanceEventsPublicNotes: BaseModel.toJSONArray(
5210
+ scheduledMaintenanceEventsPublicNotes,
5211
+ ScheduledMaintenancePublicNote,
5212
+ ),
5213
+ statusPageHistoryChartBarColorRules: BaseModel.toJSONArray(
5214
+ statusPageHistoryChartBarColorRules,
5215
+ StatusPageHistoryChartBarColorRule,
5216
+ ),
5217
+ scheduledMaintenanceEvents: BaseModel.toJSONArray(
5218
+ scheduledMaintenanceEvents,
5219
+ ScheduledMaintenance,
5220
+ ),
5221
+ activeAnnouncements: BaseModel.toJSONArray(
5222
+ activeAnnouncements,
5223
+ StatusPageAnnouncement,
5224
+ ),
5225
+ incidentPublicNotes: BaseModel.toJSONArray(
5226
+ incidentPublicNotes,
5227
+ IncidentPublicNote,
5228
+ ),
5183
5229
 
5184
- monitorsInGroup[monitorGroupId.toString()] = monitorsInGroupIds;
5185
- }
5230
+ activeIncidents: BaseModel.toJSONArray(activeIncidents, Incident),
5186
5231
 
5187
- const monitorStatusTimelines: Array<MonitorStatusTimeline> =
5188
- await StatusPageService.getMonitorStatusTimelineForStatusPage({
5189
- monitorIds: monitorsOnStatusPageForTimeline,
5190
- startDate: startDateForMonitorTimeline,
5191
- endDate: endDateForMonitorTimeline,
5192
- });
5232
+ activeEpisodes: activeEpisodesJson,
5233
+ episodePublicNotes: BaseModel.toJSONArray(
5234
+ episodePublicNotes,
5235
+ IncidentEpisodePublicNote,
5236
+ ),
5237
+ episodeStateTimelines: BaseModel.toJSONArray(
5238
+ episodeStateTimelines,
5239
+ IncidentEpisodeStateTimeline,
5240
+ ),
5193
5241
 
5194
- // return everything.
5242
+ monitorStatusTimelines: BaseModel.toJSONArray(
5243
+ monitorStatusTimelines,
5244
+ MonitorStatusTimeline,
5245
+ ),
5246
+ resourceGroups: BaseModel.toJSONArray(statusPageGroups, StatusPageGroup),
5247
+ monitorStatuses: BaseModel.toJSONArray(monitorStatuses, MonitorStatus),
5248
+ statusPageResources: BaseModel.toJSONArray(
5249
+ statusPageResources,
5250
+ StatusPageResource,
5251
+ ),
5252
+ incidentStateTimelines: BaseModel.toJSONArray(
5253
+ incidentStateTimelines,
5254
+ IncidentStateTimeline,
5255
+ ),
5256
+ statusPage: (() => {
5257
+ const statusPageJson: JSONObject = BaseModel.toJSONObject(
5258
+ statusPage,
5259
+ StatusPage,
5260
+ );
5261
+ delete statusPageJson["projectId"];
5262
+ return statusPageJson;
5263
+ })(),
5264
+ scheduledMaintenanceStateTimelines: BaseModel.toJSONArray(
5265
+ scheduledMaintenanceStateTimelines,
5266
+ ScheduledMaintenanceStateTimeline,
5267
+ ),
5195
5268
 
5196
- return {
5197
- statusPageResources,
5198
- monitorStatuses,
5199
- monitorGroupCurrentStatuses,
5200
- statusPageGroups: groups,
5201
- monitorStatusTimelines,
5202
- statusPage,
5203
- monitorsOnStatusPage,
5204
- monitorsInGroup,
5205
- startDateForMonitorTimeline,
5206
- endDateForMonitorTimeline,
5269
+ monitorGroupCurrentStatuses: JSONFunctions.serialize(
5270
+ monitorGroupCurrentStatuses,
5271
+ ),
5272
+ monitorsInGroup: JSONFunctions.serialize(monitorsInGroup),
5273
+ timelineIncidents: BaseModel.toJSONArray(timelineIncidents, Incident),
5207
5274
  };
5275
+
5276
+ return response;
5208
5277
  }
5209
5278
 
5210
5279
  private async getStatusPageAnnouncementAttachment(