@oneuptime/common 12.0.28 → 12.0.30

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 (367) hide show
  1. package/Models/DatabaseModels/DashboardDomain.ts +43 -0
  2. package/Models/DatabaseModels/Index.ts +11 -0
  3. package/Models/DatabaseModels/MonitorTemplate.ts +15 -3
  4. package/Models/DatabaseModels/OnCallDutyPolicySchedule.ts +43 -0
  5. package/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.ts +662 -0
  6. package/Models/DatabaseModels/ProjectOnCallCalendarFeed.ts +584 -0
  7. package/Models/DatabaseModels/StatusPageDomain.ts +43 -0
  8. package/Models/DatabaseModels/UserOnCallCalendarFeed.ts +546 -0
  9. package/Models/DatabaseModels/UserOnCallShiftReminder.ts +231 -0
  10. package/Models/DatabaseModels/UserOnCallShiftReminderLog.ts +331 -0
  11. package/Server/API/DashboardDomainAPI.ts +74 -0
  12. package/Server/API/MicrosoftTeamsAPI.ts +81 -180
  13. package/Server/API/OnCallCalendarAPI.ts +2194 -0
  14. package/Server/API/StatusPageDomainAPI.ts +74 -0
  15. package/Server/EnvironmentConfig.ts +138 -0
  16. package/Server/Infrastructure/OnCallCalendarFeedCache.ts +1050 -0
  17. package/Server/Infrastructure/Postgres/SchemaMigrations/1790200000000-AllowNullMonitorNameOnMonitorTemplate.ts +43 -0
  18. package/Server/Infrastructure/Postgres/SchemaMigrations/1790300000000-AddCertificateReissueRequestedAtToDomains.ts +40 -0
  19. package/Server/Infrastructure/Postgres/SchemaMigrations/1790400000000-AddOnCallCalendarFeeds.ts +253 -0
  20. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +6 -0
  21. package/Server/Middleware/OnCallCalendarFeedRateLimit.ts +487 -0
  22. package/Server/Middleware/ProjectAuthorization.ts +6 -1
  23. package/Server/Services/DashboardDomainService.ts +131 -0
  24. package/Server/Services/Index.ts +12 -0
  25. package/Server/Services/LogPipelineProcessorService.ts +88 -0
  26. package/Server/Services/MonitorTemplateService.ts +70 -11
  27. package/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.ts +32 -0
  28. package/Server/Services/OnCallDutyPolicyScheduleCalendarFeedService.ts +260 -0
  29. package/Server/Services/OnCallDutyPolicyScheduleLayerService.ts +70 -3
  30. package/Server/Services/OnCallDutyPolicyScheduleLayerUserService.ts +138 -3
  31. package/Server/Services/OnCallDutyPolicyScheduleService.ts +1465 -134
  32. package/Server/Services/OnCallDutyPolicyUserOverrideService.ts +167 -31
  33. package/Server/Services/ProjectOnCallCalendarFeedService.ts +192 -0
  34. package/Server/Services/StatusPageDomainService.ts +163 -3
  35. package/Server/Services/StatusPagePrivateUserSessionService.ts +197 -56
  36. package/Server/Services/TeamMemberService.ts +489 -1
  37. package/Server/Services/TeamService.ts +50 -0
  38. package/Server/Services/UserNotificationSettingService.ts +44 -1
  39. package/Server/Services/UserOnCallCalendarFeedService.ts +159 -0
  40. package/Server/Services/UserOnCallShiftReminderLogService.ts +151 -0
  41. package/Server/Services/UserOnCallShiftReminderService.ts +112 -0
  42. package/Server/Types/Domain.ts +17 -0
  43. package/Server/Utils/APIKey/AccessPermission.ts +24 -0
  44. package/Server/Utils/Greenlock/Greenlock.ts +156 -58
  45. package/Server/Utils/LogPipelineProcessorValidation.ts +112 -0
  46. package/Server/Utils/OnCall/CalendarFeedToken.ts +241 -0
  47. package/Server/Utils/OnCall/OnCallCalendarFeedRenderer.ts +1488 -0
  48. package/Server/Utils/OnCall/OnCallCalendarFeedUrls.ts +230 -0
  49. package/Server/Utils/OnCall/OnCallShiftChangeListeners.ts +227 -0
  50. package/Server/Utils/OnCall/OnCallShiftMaterializer.ts +1127 -0
  51. package/Server/Utils/OnCall/OnCallShiftReminderListener.ts +174 -0
  52. package/Server/Utils/OnCall/OnCallShiftReminderRunner.ts +2584 -0
  53. package/Server/Utils/Response.ts +134 -0
  54. package/Server/Utils/SecurityEvent/GoogleSecOps/GoogleSecOpsClient.ts +1094 -45
  55. package/Server/Utils/SecurityEvent/GoogleSecOps/GoogleSecOpsPoller.ts +153 -17
  56. package/Server/Utils/StartServer.ts +10 -0
  57. package/Server/Utils/Telemetry/AppMetrics.ts +51 -0
  58. package/Server/Utils/Workspace/MicrosoftTeams/Actions/Alert.ts +23 -11
  59. package/Server/Utils/Workspace/MicrosoftTeams/Actions/Auth.ts +0 -170
  60. package/Server/Utils/Workspace/MicrosoftTeams/Actions/Authorization.ts +158 -0
  61. package/Server/Utils/Workspace/MicrosoftTeams/Actions/Incident.ts +23 -11
  62. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.ts +35 -13
  63. package/Tests/App/Dashboard/MonitorTemplateColumnAndFacet.test.tsx +505 -0
  64. package/Tests/App/Dashboard/OnCallCalendarFeedLinks.test.tsx +229 -0
  65. package/Tests/App/Dashboard/OnCallCalendarFeedPlanGate.test.tsx +489 -0
  66. package/Tests/App/Dashboard/OnCallCalendarFeedSideMenus.test.tsx +149 -0
  67. package/Tests/App/Dashboard/OnCallCalendarFeedUtil.test.ts +855 -0
  68. package/Tests/App/Dashboard/OnCallPersonalCalendarFeed.test.tsx +1209 -0
  69. package/Tests/App/Dashboard/OnCallSharedCalendarFeedCard.test.tsx +1019 -0
  70. package/Tests/App/Dashboard/OnCallShiftRemindersAndUpcomingShifts.test.tsx +655 -0
  71. package/Tests/App/Dashboard/UserSettingsSetupChecklistModel.test.ts +73 -0
  72. package/Tests/App/StatusPage/StatusPageLoginCodeBootstrap.test.ts +106 -0
  73. package/Tests/App/StatusPage/StatusPageLoginCodeUtil.test.ts +84 -0
  74. package/Tests/App/StatusPage/StatusPageRedirectSafety.test.ts +119 -0
  75. package/Tests/Models/MonitorTemplateMonitorNameColumn.test.ts +258 -0
  76. package/Tests/Models/OnCallCalendarFeedModels.test.ts +642 -0
  77. package/Tests/Models/OnCallDutyPolicyScheduleCalendarFeed.test.ts +288 -0
  78. package/Tests/Models/OnCallDutyPolicyScheduleShiftConfigVersion.test.ts +88 -0
  79. package/Tests/Models/UserOnCallShiftReminderModels.test.ts +368 -0
  80. package/Tests/Server/API/CustomDomainReissueSslAPI.test.ts +469 -0
  81. package/Tests/Server/API/Helpers.ts +8 -0
  82. package/Tests/Server/API/MicrosoftTeamsBotMessagesEndpoint.test.ts +729 -0
  83. package/Tests/Server/API/MicrosoftTeamsBotTestEndpoint.test.ts +26 -0
  84. package/Tests/Server/API/MicrosoftTeamsLegacyWebhookRemoval.test.ts +402 -0
  85. package/Tests/Server/API/OnCallCalendarAPI.test.ts +4461 -0
  86. package/Tests/Server/EnvironmentConfigOnCallCalendarFeed.test.ts +357 -0
  87. package/Tests/Server/Infrastructure/OnCallCalendarFeedCache.test.ts +1589 -0
  88. package/Tests/Server/Infrastructure/Postgres/AddOnCallCalendarFeedsMigration.test.ts +369 -0
  89. package/Tests/Server/Middleware/OnCallCalendarFeedRateLimit.test.ts +1365 -0
  90. package/Tests/Server/Middleware/ProjectAuthorizationApiKeyMiddleware.test.ts +4 -0
  91. package/Tests/Server/Services/AddCertificateReissueRequestedAtToDomainsMigration.test.ts +287 -0
  92. package/Tests/Server/Services/CustomDomainCertificateReissue.test.ts +477 -0
  93. package/Tests/Server/Services/FeedRetentionConsistency.test.ts +70 -0
  94. package/Tests/Server/Services/LogPipelineProcessorSaveValidation.test.ts +249 -0
  95. package/Tests/Server/Services/MonitorTemplateServiceBulkSync.test.ts +549 -0
  96. package/Tests/Server/Services/MonitorTemplateServiceNetworkDeviceSync.test.ts +16 -3
  97. package/Tests/Server/Services/NetworkDeviceAutoImportRuleEngineService.test.ts +57 -0
  98. package/Tests/Server/Services/OnCallCalendarFeedServices.test.ts +1103 -0
  99. package/Tests/Server/Services/OnCallDutyPolicyScheduleResolver.test.ts +1037 -0
  100. package/Tests/Server/Services/OnCallShiftConfigPropagation.test.ts +1270 -0
  101. package/Tests/Server/Services/SecurityEventLastErrorColumnWidth.test.ts +151 -15
  102. package/Tests/Server/Services/StatusPagePrivateUserSessionService.test.ts +587 -0
  103. package/Tests/Server/Services/TeamDeleteMemberCleanup.test.ts +156 -0
  104. package/Tests/Server/Services/TeamMemberOnCallCleanup.test.ts +755 -0
  105. package/Tests/Server/Services/UserNotificationSettingShiftReminderDefaults.test.ts +267 -0
  106. package/Tests/Server/Services/UserOnCallShiftReminderServices.test.ts +583 -0
  107. package/Tests/Server/Types/Database/Permissions/ApiKeyFileAccess.test.ts +286 -0
  108. package/Tests/Server/Types/Database/Permissions/MonitorTemplateColumnAccess.test.ts +328 -0
  109. package/Tests/Server/Types/Database/Permissions/OnCallCalendarFeedPermissions.test.ts +254 -0
  110. package/Tests/Server/Types/Database/QueryHelperFindWithSameTextAndIsNull.test.ts +83 -0
  111. package/Tests/Server/Utils/AI/SRE/ConfidenceSignal.test.ts +377 -0
  112. package/Tests/Server/Utils/APIKey/AccessPermission.test.ts +16 -1
  113. package/Tests/Server/Utils/Greenlock/CertificateRenewalSchedule.test.ts +250 -0
  114. package/Tests/Server/Utils/LogPipelineProcessorValidation.test.ts +198 -0
  115. package/Tests/Server/Utils/OnCall/CalendarFeedToken.test.ts +467 -0
  116. package/Tests/Server/Utils/OnCall/OnCallCalendarFeedRenderer.test.ts +2142 -0
  117. package/Tests/Server/Utils/OnCall/OnCallCalendarFeedUrls.test.ts +278 -0
  118. package/Tests/Server/Utils/OnCall/OnCallResolverTestHarness.ts +440 -0
  119. package/Tests/Server/Utils/OnCall/OnCallShiftChangeListeners.test.ts +314 -0
  120. package/Tests/Server/Utils/OnCall/OnCallShiftMaterializer.test.ts +1502 -0
  121. package/Tests/Server/Utils/OnCall/OnCallShiftReminderListener.test.ts +491 -0
  122. package/Tests/Server/Utils/OnCall/OnCallShiftReminderRunner.test.ts +3088 -0
  123. package/Tests/Server/Utils/OnCall/OnCallShiftReminderTestHarness.ts +910 -0
  124. package/Tests/Server/Utils/ResponseSendCalendarResponse.test.ts +561 -0
  125. package/Tests/Server/Utils/SecurityEvent/ConnectorErrorMessage.test.ts +48 -9
  126. package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsAuth.test.ts +726 -0
  127. package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsClient.test.ts +74 -11
  128. package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsErrorHandling.test.ts +1107 -0
  129. package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsPoller.test.ts +20 -3
  130. package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsPollerFailureTaxonomy.test.ts +51 -11
  131. package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsPollerHardening.test.ts +1246 -0
  132. package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsRequestContract.test.ts +961 -0
  133. package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsResponseParsing.test.ts +653 -0
  134. package/Tests/Server/Utils/StartServerEncryptionSecretWarning.test.ts +64 -0
  135. package/Tests/Server/Utils/Telemetry/AppMetrics.test.ts +29 -0
  136. package/Tests/Server/Utils/Workspace/MicrosoftTeamsActionAuthorization.test.ts +880 -0
  137. package/Tests/Types/API/HTTPResponse.test.ts +175 -0
  138. package/Tests/Types/API/Route.test.ts +36 -4
  139. package/Tests/Types/Calendar/ICalendar.test.ts +488 -0
  140. package/Tests/Types/Database/DatabaseProperty.test.ts +171 -0
  141. package/Tests/Types/DateLocalShortDateTimeString.test.ts +518 -0
  142. package/Tests/Types/DateLocalTimeString.test.ts +259 -0
  143. package/Tests/Types/DateUserPrefers12HourFormat.test.ts +316 -0
  144. package/Tests/Types/NotificationSetting/NotificationSettingEventType.test.ts +53 -0
  145. package/Tests/Types/OnCallDutyPolicy/CalendarFeedTestFixtures.ts +310 -0
  146. package/Tests/Types/OnCallDutyPolicy/CalendarFeedWindow.test.ts +205 -0
  147. package/Tests/Types/OnCallDutyPolicy/LayerUtilLayerMetaAndIterationCap.test.ts +458 -0
  148. package/Tests/Types/OnCallDutyPolicy/MaterializedShift.test.ts +197 -0
  149. package/Tests/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.test.ts +1986 -0
  150. package/Tests/Types/OnCallDutyPolicy/ScheduleShiftUtilGroupKey.test.ts +678 -0
  151. package/Tests/Types/OnCallDutyPolicy/ShiftSeamUtil.test.ts +417 -0
  152. package/Tests/UI/Components/LogsHistogram.test.tsx +41 -2
  153. package/Tests/UI/Components/LogsViewerEmptyStateTimeRange.test.tsx +212 -0
  154. package/Tests/UI/Components/TelemetryChartClockFormat.test.tsx +359 -0
  155. package/Tests/UI/Components/TimeRangePickerClockFormat.test.tsx +422 -0
  156. package/Tests/UI/Components/TimeRangePickerDropdown.test.tsx +146 -8
  157. package/Tests/UI/MicrosoftTeams/MicrosoftTeamsMessagingEndpointGuidance.test.tsx +520 -0
  158. package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +10 -0
  159. package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +2 -0
  160. package/Tests/UI/Utils/Navigation.test.ts +82 -0
  161. package/Tests/Utils/Array.test.ts +100 -0
  162. package/Tests/Utils/CertificateReissue.test.ts +225 -0
  163. package/Tests/Utils/Grok/Grok.test.ts +492 -0
  164. package/Tests/Utils/Monitor/NetworkDeviceMonitorTemplateUtil.test.ts +181 -13
  165. package/Types/API/Route.ts +20 -5
  166. package/Types/Calendar/ICalendar.ts +442 -0
  167. package/Types/Date.ts +143 -13
  168. package/Types/Email/EmailTemplateType.ts +2 -0
  169. package/Types/NotificationSetting/NotificationSettingEventType.ts +4 -0
  170. package/Types/OnCallDutyPolicy/CalendarFeedWindow.ts +140 -0
  171. package/Types/OnCallDutyPolicy/Layer.ts +190 -35
  172. package/Types/OnCallDutyPolicy/MaterializedShift.ts +274 -0
  173. package/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.ts +1415 -0
  174. package/Types/OnCallDutyPolicy/ScheduleShiftUtil.ts +159 -4
  175. package/Types/OnCallDutyPolicy/ShiftSeamUtil.ts +106 -0
  176. package/Types/Rum/SessionReplay.ts +62 -1
  177. package/UI/Components/Date/TimeRangePickerDropdown.tsx +9 -5
  178. package/UI/Components/LogsViewer/LogsViewer.tsx +12 -7
  179. package/UI/Components/LogsViewer/components/HistogramTooltip.tsx +18 -17
  180. package/UI/Components/LogsViewer/components/LogsAnalyticsView.tsx +21 -28
  181. package/UI/Components/LogsViewer/components/LogsHistogram.tsx +2 -4
  182. package/UI/Components/TelemetryViewer/components/TelemetryHistogram.tsx +2 -4
  183. package/UI/Components/TelemetryViewer/components/TelemetryHistogramTooltip.tsx +17 -16
  184. package/UI/Utils/Navigation.ts +42 -0
  185. package/Utils/Array.ts +37 -0
  186. package/Utils/CertificateReissue.ts +128 -0
  187. package/Utils/Grok/Grok.ts +461 -0
  188. package/Utils/Grok/GrokPatterns.ts +118 -0
  189. package/Utils/Monitor/NetworkDeviceMonitorTemplateUtil.ts +58 -6
  190. package/build/dist/Models/DatabaseModels/DashboardDomain.js +44 -0
  191. package/build/dist/Models/DatabaseModels/DashboardDomain.js.map +1 -1
  192. package/build/dist/Models/DatabaseModels/Index.js +11 -0
  193. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  194. package/build/dist/Models/DatabaseModels/MonitorTemplate.js +17 -4
  195. package/build/dist/Models/DatabaseModels/MonitorTemplate.js.map +1 -1
  196. package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js +44 -0
  197. package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js.map +1 -1
  198. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.js +699 -0
  199. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.js.map +1 -0
  200. package/build/dist/Models/DatabaseModels/ProjectOnCallCalendarFeed.js +620 -0
  201. package/build/dist/Models/DatabaseModels/ProjectOnCallCalendarFeed.js.map +1 -0
  202. package/build/dist/Models/DatabaseModels/StatusPageDomain.js +44 -0
  203. package/build/dist/Models/DatabaseModels/StatusPageDomain.js.map +1 -1
  204. package/build/dist/Models/DatabaseModels/UserOnCallCalendarFeed.js +585 -0
  205. package/build/dist/Models/DatabaseModels/UserOnCallCalendarFeed.js.map +1 -0
  206. package/build/dist/Models/DatabaseModels/UserOnCallShiftReminder.js +247 -0
  207. package/build/dist/Models/DatabaseModels/UserOnCallShiftReminder.js.map +1 -0
  208. package/build/dist/Models/DatabaseModels/UserOnCallShiftReminderLog.js +355 -0
  209. package/build/dist/Models/DatabaseModels/UserOnCallShiftReminderLog.js.map +1 -0
  210. package/build/dist/Server/API/DashboardDomainAPI.js +43 -1
  211. package/build/dist/Server/API/DashboardDomainAPI.js.map +1 -1
  212. package/build/dist/Server/API/MicrosoftTeamsAPI.js +74 -118
  213. package/build/dist/Server/API/MicrosoftTeamsAPI.js.map +1 -1
  214. package/build/dist/Server/API/OnCallCalendarAPI.js +1371 -0
  215. package/build/dist/Server/API/OnCallCalendarAPI.js.map +1 -0
  216. package/build/dist/Server/API/StatusPageDomainAPI.js +43 -1
  217. package/build/dist/Server/API/StatusPageDomainAPI.js.map +1 -1
  218. package/build/dist/Server/EnvironmentConfig.js +102 -0
  219. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  220. package/build/dist/Server/Infrastructure/OnCallCalendarFeedCache.js +663 -0
  221. package/build/dist/Server/Infrastructure/OnCallCalendarFeedCache.js.map +1 -0
  222. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790200000000-AllowNullMonitorNameOnMonitorTemplate.js +34 -0
  223. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790200000000-AllowNullMonitorNameOnMonitorTemplate.js.map +1 -0
  224. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790300000000-AddCertificateReissueRequestedAtToDomains.js +28 -0
  225. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790300000000-AddCertificateReissueRequestedAtToDomains.js.map +1 -0
  226. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790400000000-AddOnCallCalendarFeeds.js +103 -0
  227. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790400000000-AddOnCallCalendarFeeds.js.map +1 -0
  228. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +6 -0
  229. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  230. package/build/dist/Server/Middleware/OnCallCalendarFeedRateLimit.js +342 -0
  231. package/build/dist/Server/Middleware/OnCallCalendarFeedRateLimit.js.map +1 -0
  232. package/build/dist/Server/Middleware/ProjectAuthorization.js +6 -1
  233. package/build/dist/Server/Middleware/ProjectAuthorization.js.map +1 -1
  234. package/build/dist/Server/Services/DashboardDomainService.js +104 -0
  235. package/build/dist/Server/Services/DashboardDomainService.js.map +1 -1
  236. package/build/dist/Server/Services/Index.js +12 -0
  237. package/build/dist/Server/Services/Index.js.map +1 -1
  238. package/build/dist/Server/Services/LogPipelineProcessorService.js +65 -0
  239. package/build/dist/Server/Services/LogPipelineProcessorService.js.map +1 -1
  240. package/build/dist/Server/Services/MonitorTemplateService.js +57 -11
  241. package/build/dist/Server/Services/MonitorTemplateService.js.map +1 -1
  242. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js +20 -0
  243. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js.map +1 -1
  244. package/build/dist/Server/Services/OnCallDutyPolicyScheduleCalendarFeedService.js +219 -0
  245. package/build/dist/Server/Services/OnCallDutyPolicyScheduleCalendarFeedService.js.map +1 -0
  246. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerService.js +47 -3
  247. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerService.js.map +1 -1
  248. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerUserService.js +112 -3
  249. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerUserService.js.map +1 -1
  250. package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js +955 -9
  251. package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js.map +1 -1
  252. package/build/dist/Server/Services/OnCallDutyPolicyUserOverrideService.js +93 -6
  253. package/build/dist/Server/Services/OnCallDutyPolicyUserOverrideService.js.map +1 -1
  254. package/build/dist/Server/Services/ProjectOnCallCalendarFeedService.js +157 -0
  255. package/build/dist/Server/Services/ProjectOnCallCalendarFeedService.js.map +1 -0
  256. package/build/dist/Server/Services/StatusPageDomainService.js +132 -3
  257. package/build/dist/Server/Services/StatusPageDomainService.js.map +1 -1
  258. package/build/dist/Server/Services/StatusPagePrivateUserSessionService.js +112 -32
  259. package/build/dist/Server/Services/StatusPagePrivateUserSessionService.js.map +1 -1
  260. package/build/dist/Server/Services/TeamMemberService.js +396 -1
  261. package/build/dist/Server/Services/TeamMemberService.js.map +1 -1
  262. package/build/dist/Server/Services/TeamService.js +43 -0
  263. package/build/dist/Server/Services/TeamService.js.map +1 -1
  264. package/build/dist/Server/Services/UserNotificationSettingService.js +26 -1
  265. package/build/dist/Server/Services/UserNotificationSettingService.js.map +1 -1
  266. package/build/dist/Server/Services/UserOnCallCalendarFeedService.js +140 -0
  267. package/build/dist/Server/Services/UserOnCallCalendarFeedService.js.map +1 -0
  268. package/build/dist/Server/Services/UserOnCallShiftReminderLogService.js +116 -0
  269. package/build/dist/Server/Services/UserOnCallShiftReminderLogService.js.map +1 -0
  270. package/build/dist/Server/Services/UserOnCallShiftReminderService.js +92 -0
  271. package/build/dist/Server/Services/UserOnCallShiftReminderService.js.map +1 -0
  272. package/build/dist/Server/Types/Domain.js +16 -0
  273. package/build/dist/Server/Types/Domain.js.map +1 -1
  274. package/build/dist/Server/Utils/APIKey/AccessPermission.js +24 -0
  275. package/build/dist/Server/Utils/APIKey/AccessPermission.js.map +1 -1
  276. package/build/dist/Server/Utils/Greenlock/Greenlock.js +110 -35
  277. package/build/dist/Server/Utils/Greenlock/Greenlock.js.map +1 -1
  278. package/build/dist/Server/Utils/LogPipelineProcessorValidation.js +77 -0
  279. package/build/dist/Server/Utils/LogPipelineProcessorValidation.js.map +1 -0
  280. package/build/dist/Server/Utils/OnCall/CalendarFeedToken.js +161 -0
  281. package/build/dist/Server/Utils/OnCall/CalendarFeedToken.js.map +1 -0
  282. package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedRenderer.js +983 -0
  283. package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedRenderer.js.map +1 -0
  284. package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedUrls.js +155 -0
  285. package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedUrls.js.map +1 -0
  286. package/build/dist/Server/Utils/OnCall/OnCallShiftChangeListeners.js +145 -0
  287. package/build/dist/Server/Utils/OnCall/OnCallShiftChangeListeners.js.map +1 -0
  288. package/build/dist/Server/Utils/OnCall/OnCallShiftMaterializer.js +752 -0
  289. package/build/dist/Server/Utils/OnCall/OnCallShiftMaterializer.js.map +1 -0
  290. package/build/dist/Server/Utils/OnCall/OnCallShiftReminderListener.js +92 -0
  291. package/build/dist/Server/Utils/OnCall/OnCallShiftReminderListener.js.map +1 -0
  292. package/build/dist/Server/Utils/OnCall/OnCallShiftReminderRunner.js +1786 -0
  293. package/build/dist/Server/Utils/OnCall/OnCallShiftReminderRunner.js.map +1 -0
  294. package/build/dist/Server/Utils/Response.js +97 -0
  295. package/build/dist/Server/Utils/Response.js.map +1 -1
  296. package/build/dist/Server/Utils/SecurityEvent/GoogleSecOps/GoogleSecOpsClient.js +777 -36
  297. package/build/dist/Server/Utils/SecurityEvent/GoogleSecOps/GoogleSecOpsClient.js.map +1 -1
  298. package/build/dist/Server/Utils/SecurityEvent/GoogleSecOps/GoogleSecOpsPoller.js +108 -17
  299. package/build/dist/Server/Utils/SecurityEvent/GoogleSecOps/GoogleSecOpsPoller.js.map +1 -1
  300. package/build/dist/Server/Utils/StartServer.js +9 -1
  301. package/build/dist/Server/Utils/StartServer.js.map +1 -1
  302. package/build/dist/Server/Utils/Telemetry/AppMetrics.js +41 -0
  303. package/build/dist/Server/Utils/Telemetry/AppMetrics.js.map +1 -1
  304. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Alert.js +17 -6
  305. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Alert.js.map +1 -1
  306. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Auth.js +0 -159
  307. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Auth.js.map +1 -1
  308. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Authorization.js +128 -0
  309. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Authorization.js.map +1 -0
  310. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Incident.js +17 -6
  311. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Incident.js.map +1 -1
  312. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js +25 -13
  313. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js.map +1 -1
  314. package/build/dist/Types/API/Route.js +16 -5
  315. package/build/dist/Types/API/Route.js.map +1 -1
  316. package/build/dist/Types/Calendar/ICalendar.js +290 -0
  317. package/build/dist/Types/Calendar/ICalendar.js.map +1 -0
  318. package/build/dist/Types/Date.js +115 -13
  319. package/build/dist/Types/Date.js.map +1 -1
  320. package/build/dist/Types/Email/EmailTemplateType.js +2 -0
  321. package/build/dist/Types/Email/EmailTemplateType.js.map +1 -1
  322. package/build/dist/Types/NotificationSetting/NotificationSettingEventType.js +3 -0
  323. package/build/dist/Types/NotificationSetting/NotificationSettingEventType.js.map +1 -1
  324. package/build/dist/Types/OnCallDutyPolicy/CalendarFeedWindow.js +97 -0
  325. package/build/dist/Types/OnCallDutyPolicy/CalendarFeedWindow.js.map +1 -0
  326. package/build/dist/Types/OnCallDutyPolicy/Layer.js +113 -19
  327. package/build/dist/Types/OnCallDutyPolicy/Layer.js.map +1 -1
  328. package/build/dist/Types/OnCallDutyPolicy/MaterializedShift.js +134 -0
  329. package/build/dist/Types/OnCallDutyPolicy/MaterializedShift.js.map +1 -0
  330. package/build/dist/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.js +800 -0
  331. package/build/dist/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.js.map +1 -0
  332. package/build/dist/Types/OnCallDutyPolicy/ScheduleShiftUtil.js +97 -3
  333. package/build/dist/Types/OnCallDutyPolicy/ScheduleShiftUtil.js.map +1 -1
  334. package/build/dist/Types/OnCallDutyPolicy/ShiftSeamUtil.js +77 -0
  335. package/build/dist/Types/OnCallDutyPolicy/ShiftSeamUtil.js.map +1 -0
  336. package/build/dist/Types/Rum/SessionReplay.js +38 -0
  337. package/build/dist/Types/Rum/SessionReplay.js.map +1 -1
  338. package/build/dist/UI/Components/Date/TimeRangePickerDropdown.js +9 -5
  339. package/build/dist/UI/Components/Date/TimeRangePickerDropdown.js.map +1 -1
  340. package/build/dist/UI/Components/LogsViewer/LogsViewer.js +12 -7
  341. package/build/dist/UI/Components/LogsViewer/LogsViewer.js.map +1 -1
  342. package/build/dist/UI/Components/LogsViewer/components/HistogramTooltip.js +15 -14
  343. package/build/dist/UI/Components/LogsViewer/components/HistogramTooltip.js.map +1 -1
  344. package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js +17 -22
  345. package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js.map +1 -1
  346. package/build/dist/UI/Components/LogsViewer/components/LogsHistogram.js +2 -4
  347. package/build/dist/UI/Components/LogsViewer/components/LogsHistogram.js.map +1 -1
  348. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryHistogram.js +2 -4
  349. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryHistogram.js.map +1 -1
  350. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryHistogramTooltip.js +15 -14
  351. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryHistogramTooltip.js.map +1 -1
  352. package/build/dist/UI/Utils/Navigation.js +30 -0
  353. package/build/dist/UI/Utils/Navigation.js.map +1 -1
  354. package/build/dist/Utils/Array.js +25 -0
  355. package/build/dist/Utils/Array.js.map +1 -1
  356. package/build/dist/Utils/CertificateReissue.js +97 -0
  357. package/build/dist/Utils/CertificateReissue.js.map +1 -0
  358. package/build/dist/Utils/Grok/Grok.js +324 -0
  359. package/build/dist/Utils/Grok/Grok.js.map +1 -0
  360. package/build/dist/Utils/Grok/GrokPatterns.js +112 -0
  361. package/build/dist/Utils/Grok/GrokPatterns.js.map +1 -0
  362. package/build/dist/Utils/Monitor/NetworkDeviceMonitorTemplateUtil.js +63 -3
  363. package/build/dist/Utils/Monitor/NetworkDeviceMonitorTemplateUtil.js.map +1 -1
  364. package/package.json +1 -1
  365. package/UI/Utils/JsonWebToken.ts +0 -14
  366. package/build/dist/UI/Utils/JsonWebToken.js +0 -10
  367. package/build/dist/UI/Utils/JsonWebToken.js.map +0 -1
@@ -7,7 +7,10 @@ import CalendarEvent from "../../Types/Calendar/CalendarEvent";
7
7
  import { LIMIT_PER_PROJECT } from "../../Types/Database/LimitMax";
8
8
  import OneUptimeDate from "../../Types/Date";
9
9
  import ObjectID from "../../Types/ObjectID";
10
- import LayerUtil, { LayerProps } from "../../Types/OnCallDutyPolicy/Layer";
10
+ import LayerUtil, {
11
+ LayerEventsResult,
12
+ LayerProps,
13
+ } from "../../Types/OnCallDutyPolicy/Layer";
11
14
  import { RestrictionType } from "../../Types/OnCallDutyPolicy/RestrictionTimes";
12
15
  import Recurring from "../../Types/Events/Recurring";
13
16
  import UserOverrideUtil, {
@@ -44,11 +47,115 @@ import OnCallDutyPolicyTimeLogService from "./OnCallDutyPolicyTimeLogService";
44
47
  import OnCallDutyPolicyScheduleLabelRuleEngineService from "./OnCallDutyPolicyScheduleLabelRuleEngineService";
45
48
  import OnCallDutyPolicyScheduleOwnerRuleEngineService from "./OnCallDutyPolicyScheduleOwnerRuleEngineService";
46
49
  import DeleteBy from "../Types/Database/DeleteBy";
47
- import { OnCreate, OnDelete } from "../Types/Database/Hooks";
50
+ import { OnCreate, OnDelete, OnUpdate } from "../Types/Database/Hooks";
48
51
  import PushNotificationMessage from "../../Types/PushNotification/PushNotificationMessage";
49
52
  import PushNotificationUtil from "../Utils/PushNotificationUtil";
50
53
  import { createWhatsAppMessageFromTemplate } from "../Utils/WhatsAppTemplateUtil";
51
54
  import { WhatsAppMessagePayload } from "../../Types/WhatsApp/WhatsAppMessage";
55
+ import { MaterializedShiftPolicy } from "../../Types/OnCallDutyPolicy/MaterializedShift";
56
+ import OnCallCalendarFeedCache from "../Infrastructure/OnCallCalendarFeedCache";
57
+ import OnCallShiftChangeListeners, {
58
+ OnCallShiftChangeEvent,
59
+ OnCallShiftChangeReason,
60
+ } from "../Utils/OnCall/OnCallShiftChangeListeners";
61
+
62
+ /*
63
+ * ---------------------------------------------------------------------------
64
+ * Resolver contracts (on-call calendar feeds / reminders / my-shifts)
65
+ * ---------------------------------------------------------------------------
66
+ */
67
+
68
+ // The schedule columns a resolved window carries along for its consumers.
69
+ export interface ResolvedScheduleInfo {
70
+ id: string;
71
+ name: string;
72
+ // IANA zone; absent for legacy schedules (engine ran in the server's zone).
73
+ timezone?: string;
74
+ projectId: string;
75
+ shiftConfigVersion: number;
76
+ }
77
+
78
+ /*
79
+ * Resolution of the same base events in ONE escalation policy's context.
80
+ * Only produced when the schedule is attached to two or more distinct policies
81
+ * and a policy-scoped override for a schedule member overlaps the window — the
82
+ * situation in which paging (which always resolves with the escalating policy)
83
+ * and the policy-agnostic roster can name different people.
84
+ */
85
+ export interface ResolvedPolicyVariant {
86
+ policyId: string;
87
+ policyName: string;
88
+ segments: Array<CalendarEvent>;
89
+ }
90
+
91
+ export interface ResolvedShiftSegments {
92
+ schedule: ResolvedScheduleInfo;
93
+ /*
94
+ * Resolved coverage segments (CalendarEvent.title = user id) that OVERLAP
95
+ * [windowStart, windowEnd). Deliberately unclipped: a segment that started
96
+ * before the window keeps its true start, because that start is the
97
+ * calendar event's identity.
98
+ */
99
+ segments: Array<CalendarEvent>;
100
+ policyVariants: Array<ResolvedPolicyVariant>;
101
+ // Distinct (policy, rule) attachments, ordered by policy then rule order.
102
+ attachedPolicies: Array<MaterializedShiftPolicy>;
103
+ // The layers as fed to the engine (for the coverage envelope).
104
+ layerProps: Array<LayerProps>;
105
+ // Distinct user ids across all layers, in layer order.
106
+ scheduleUserIds: Array<string>;
107
+ /*
108
+ * Every override that took part in the resolution (global ones plus, in a
109
+ * policy context, that policy's), so a consumer can map a segment's
110
+ * override meta back to its policy scope.
111
+ */
112
+ overrides: Array<UserOverrideRecord>;
113
+ /*
114
+ * max(updatedAt) over layers, layer users, participating overrides and
115
+ * policy attachments. The schedule row is EXCLUDED on purpose: its
116
+ * updatedAt moves on every roster refresh, which would make every feed
117
+ * fetch look like a change.
118
+ */
119
+ lastModifiedAt: Date;
120
+ // True when the engine hit its iteration cap; segments may be incomplete.
121
+ truncated: boolean;
122
+ }
123
+
124
+ export interface ResolveShiftSegmentsOptions {
125
+ scheduleId: ObjectID;
126
+ windowStart: Date;
127
+ windowEnd: Date;
128
+ maxSimulationIterations?: number | undefined;
129
+ }
130
+
131
+ export interface ResolveShiftSegmentsForSchedulesOptions {
132
+ scheduleIds: Array<ObjectID>;
133
+ windowStart: Date;
134
+ windowEnd: Date;
135
+ maxSimulationIterations?: number | undefined;
136
+ }
137
+
138
+ /*
139
+ * What a CRUD hook reports when an edit may have changed who is on call and
140
+ * when. See propagateShiftConfigChange.
141
+ */
142
+ export interface ShiftConfigChange {
143
+ scheduleIds: Array<ObjectID>;
144
+ projectId?: ObjectID | null | undefined;
145
+ // Users named directly by the change (added/removed/overridden/substitute).
146
+ userIds?: Array<ObjectID> | undefined;
147
+ reason: OnCallShiftChangeReason;
148
+ // The schedule rows are gone (delete) — nothing to bump.
149
+ skipVersionBump?: boolean | undefined;
150
+ }
151
+
152
+ // Rows loaded once for a batch of schedules, grouped per schedule.
153
+ interface ScheduleResolutionInputs {
154
+ schedule: OnCallDutyPolicySchedule;
155
+ layers: Array<OnCallDutyPolicyScheduleLayer>;
156
+ layerUsers: Array<OnCallDutyPolicyScheduleLayerUser>;
157
+ attachments: Array<OnCallDutyPolicyEscalationRuleSchedule>;
158
+ }
52
159
 
53
160
  export class Service extends DatabaseService<OnCallDutyPolicySchedule> {
54
161
  private layerUtil = new LayerUtil();
@@ -114,12 +221,185 @@ export class Service extends DatabaseService<OnCallDutyPolicySchedule> {
114
221
  });
115
222
  }
116
223
 
224
+ /*
225
+ * Capture the members of each schedule being deleted. Their layer-user
226
+ * rows cascade away at the database level (no hooks run), so this is the
227
+ * last moment the ids are available; onDeleteSuccess uses them to purge
228
+ * those users' calendar-feed bodies and to tell the shift-change
229
+ * listeners whose shift lists just changed. Best-effort.
230
+ */
231
+ const deletedSchedules: Array<{
232
+ scheduleId: ObjectID;
233
+ projectId: ObjectID | null;
234
+ userIds: Array<ObjectID>;
235
+ }> = [];
236
+
237
+ try {
238
+ const scheduleIds: Array<ObjectID> = callSchedules
239
+ .map((schedule: OnCallDutyPolicySchedule) => {
240
+ return schedule.id;
241
+ })
242
+ .filter((id: ObjectID | null | undefined): id is ObjectID => {
243
+ return Boolean(id);
244
+ });
245
+
246
+ const layerUsers: Array<OnCallDutyPolicyScheduleLayerUser> =
247
+ scheduleIds.length > 0
248
+ ? await OnCallDutyPolicyScheduleLayerUserService.findBy({
249
+ query: {
250
+ onCallDutyPolicyScheduleId: QueryHelper.any(scheduleIds),
251
+ },
252
+ select: {
253
+ userId: true,
254
+ onCallDutyPolicyScheduleId: true,
255
+ },
256
+ limit: LIMIT_PER_PROJECT,
257
+ skip: 0,
258
+ props: {
259
+ isRoot: true,
260
+ },
261
+ })
262
+ : [];
263
+
264
+ for (const schedule of callSchedules) {
265
+ if (!schedule.id) {
266
+ continue;
267
+ }
268
+
269
+ const scheduleIdString: string = schedule.id.toString();
270
+
271
+ deletedSchedules.push({
272
+ scheduleId: schedule.id,
273
+ projectId: schedule.projectId || null,
274
+ userIds: OnCallShiftChangeListeners.dedupe(
275
+ layerUsers
276
+ .filter((layerUser: OnCallDutyPolicyScheduleLayerUser) => {
277
+ return (
278
+ layerUser.onCallDutyPolicyScheduleId?.toString() ===
279
+ scheduleIdString
280
+ );
281
+ })
282
+ .map((layerUser: OnCallDutyPolicyScheduleLayerUser) => {
283
+ return layerUser.userId;
284
+ })
285
+ .filter((id: ObjectID | undefined): id is ObjectID => {
286
+ return Boolean(id);
287
+ }),
288
+ ),
289
+ });
290
+ }
291
+ } catch (err) {
292
+ logger.error(
293
+ "Error capturing schedule members before delete (best-effort).",
294
+ );
295
+ logger.error(err);
296
+ }
297
+
117
298
  return {
118
299
  deleteBy: deleteBy,
119
- carryForward: {},
300
+ carryForward: { deletedSchedules },
120
301
  };
121
302
  }
122
303
 
304
+ /*
305
+ * A deleted schedule takes its shared feed with it (FK cascade) and drops
306
+ * out of its members' personal feeds; purge what was rendered from it and
307
+ * let the shift-change listeners (reminders) re-plan for those users. No
308
+ * version bump — the row is gone.
309
+ */
310
+ protected override async onDeleteSuccess(
311
+ onDelete: OnDelete<OnCallDutyPolicySchedule>,
312
+ _itemIdsBeforeDelete: Array<ObjectID>,
313
+ ): Promise<OnDelete<OnCallDutyPolicySchedule>> {
314
+ const deletedSchedules: Array<{
315
+ scheduleId: ObjectID;
316
+ projectId: ObjectID | null;
317
+ userIds: Array<ObjectID>;
318
+ }> = onDelete.carryForward?.deletedSchedules || [];
319
+
320
+ for (const deleted of deletedSchedules) {
321
+ await this.propagateShiftConfigChange({
322
+ scheduleIds: [deleted.scheduleId],
323
+ projectId: deleted.projectId,
324
+ userIds: deleted.userIds,
325
+ reason: OnCallShiftChangeReason.ScheduleDeleted,
326
+ skipVersionBump: true,
327
+ });
328
+ }
329
+
330
+ return onDelete;
331
+ }
332
+
333
+ /*
334
+ * A rename or a timezone change alters every calendar event of the
335
+ * schedule (its title, and for a timezone change the instants themselves),
336
+ * so it must bump the schedule's shiftConfigVersion like any other
337
+ * configuration edit. The roster refresh writes through updateOneById with
338
+ * ignoreHooks, and the version bump itself is a raw statement, so neither
339
+ * re-enters this hook.
340
+ */
341
+ protected override async onUpdateSuccess(
342
+ onUpdate: OnUpdate<OnCallDutyPolicySchedule>,
343
+ updatedItemIds: Array<ObjectID>,
344
+ ): Promise<OnUpdate<OnCallDutyPolicySchedule>> {
345
+ const data: Record<string, unknown> = (onUpdate.updateBy?.data ||
346
+ {}) as unknown as Record<string, unknown>;
347
+
348
+ const touchesShiftConfiguration: boolean =
349
+ data["name"] !== undefined || data["timezone"] !== undefined;
350
+
351
+ if (!touchesShiftConfiguration || updatedItemIds.length === 0) {
352
+ return onUpdate;
353
+ }
354
+
355
+ try {
356
+ const schedules: Array<OnCallDutyPolicySchedule> = await this.findBy({
357
+ query: {
358
+ _id: QueryHelper.any(updatedItemIds),
359
+ },
360
+ select: {
361
+ _id: true,
362
+ projectId: true,
363
+ },
364
+ limit: LIMIT_PER_PROJECT,
365
+ skip: 0,
366
+ props: {
367
+ isRoot: true,
368
+ },
369
+ });
370
+
371
+ const byProject: Map<string, Array<ObjectID>> = new Map<
372
+ string,
373
+ Array<ObjectID>
374
+ >();
375
+
376
+ for (const schedule of schedules) {
377
+ if (!schedule.id) {
378
+ continue;
379
+ }
380
+ const projectKey: string = schedule.projectId?.toString() || "";
381
+ const list: Array<ObjectID> = byProject.get(projectKey) || [];
382
+ list.push(schedule.id);
383
+ byProject.set(projectKey, list);
384
+ }
385
+
386
+ for (const [projectKey, scheduleIds] of byProject) {
387
+ await this.propagateShiftConfigChange({
388
+ scheduleIds,
389
+ projectId: projectKey ? new ObjectID(projectKey) : null,
390
+ reason: OnCallShiftChangeReason.ScheduleChanged,
391
+ });
392
+ }
393
+ } catch (err) {
394
+ logger.error(
395
+ "Error propagating a schedule rename / timezone change to the calendar feeds (best-effort).",
396
+ );
397
+ logger.error(err);
398
+ }
399
+
400
+ return onUpdate;
401
+ }
402
+
123
403
  public async getOnCallSchedulesWhereUserIsOnCallDuty(data: {
124
404
  projectId: ObjectID;
125
405
  userId: ObjectID;
@@ -1321,12 +1601,38 @@ export class Service extends DatabaseService<OnCallDutyPolicySchedule> {
1321
1601
  const scheduleTimezone: string | undefined =
1322
1602
  schedule?.timezone?.toString() || undefined;
1323
1603
 
1604
+ const { layerProps, scheduleUserIds } = this.buildLayerPropsFromRows({
1605
+ layers,
1606
+ layerUsers,
1607
+ scheduleTimezone,
1608
+ });
1609
+
1610
+ const projectId: ObjectID | null = layers[0]?.projectId || null;
1611
+
1612
+ return { layerProps, projectId, scheduleUserIds };
1613
+ }
1614
+
1615
+ /*
1616
+ * Compose LayerProps from persisted layer + layer-user rows. Shared by the
1617
+ * single-schedule loader the paging/roster path uses and the batched loader
1618
+ * the calendar feeds use, so both feed the engine byte-identical input.
1619
+ * Layers must already be sorted by order; layer users by order.
1620
+ *
1621
+ * Each LayerProps is stamped with the layer's id and name (informational;
1622
+ * the engine copies them onto its events) so consumers can tell which
1623
+ * layer a merged segment came from.
1624
+ */
1625
+ private buildLayerPropsFromRows(data: {
1626
+ layers: Array<OnCallDutyPolicyScheduleLayer>;
1627
+ layerUsers: Array<OnCallDutyPolicyScheduleLayerUser>;
1628
+ scheduleTimezone: string | undefined;
1629
+ }): { layerProps: Array<LayerProps>; scheduleUserIds: Array<ObjectID> } {
1324
1630
  const layerProps: Array<LayerProps> = [];
1325
1631
  const scheduleUserIds: Array<ObjectID> = [];
1326
1632
  const seenUserIds: Set<string> = new Set<string>();
1327
1633
 
1328
- for (const layer of layers) {
1329
- const usersForLayer: Array<User> = layerUsers
1634
+ for (const layer of data.layers) {
1635
+ const usersForLayer: Array<User> = data.layerUsers
1330
1636
  .filter((layerUser: OnCallDutyPolicyScheduleLayerUser) => {
1331
1637
  return (
1332
1638
  layerUser.onCallDutyPolicyScheduleLayerId?.toString() ===
@@ -1348,171 +1654,1154 @@ export class Service extends DatabaseService<OnCallDutyPolicySchedule> {
1348
1654
  }
1349
1655
  }
1350
1656
 
1351
- layerProps.push({
1657
+ const props: LayerProps = {
1352
1658
  users: usersForLayer,
1353
1659
  startDateTimeOfLayer: layer.startsAt!,
1354
1660
  restrictionTimes: layer.restrictionTimes!,
1355
1661
  rotation: layer.rotation!,
1356
1662
  handOffTime: layer.handOffTime!,
1357
- timezone: scheduleTimezone,
1358
- });
1359
- }
1663
+ timezone: data.scheduleTimezone,
1664
+ };
1360
1665
 
1361
- const projectId: ObjectID | null = layers[0]?.projectId || null;
1666
+ const layerId: string | undefined = layer.id?.toString();
1667
+ if (layerId) {
1668
+ props.layerId = layerId;
1669
+ }
1362
1670
 
1363
- return { layerProps, projectId, scheduleUserIds };
1364
- }
1671
+ if (typeof layer.name === "string" && layer.name) {
1672
+ props.layerName = layer.name;
1673
+ }
1365
1674
 
1366
- private async fetchOverridesForSchedule(data: {
1367
- projectId: ObjectID;
1368
- scheduleUserIds: Array<ObjectID>;
1369
- windowStart: Date;
1370
- windowEnd: Date;
1371
- currentOnCallDutyPolicyId?: ObjectID | undefined;
1372
- }): Promise<Array<UserOverrideRecord>> {
1373
- if (data.scheduleUserIds.length === 0) {
1374
- return [];
1675
+ layerProps.push(props);
1375
1676
  }
1376
1677
 
1377
- /*
1378
- * When a policy context is provided, include overrides scoped to that
1379
- * policy plus global overrides. Without a policy context (e.g. schedule
1380
- * roster refresh, dashboard preview, incoming-call routing), only global
1381
- * overrides apply — a policy-specific override from one policy must not
1382
- * affect schedule resolution for a different policy.
1383
- */
1384
- const overrides: Array<OnCallDutyPolicyUserOverride> =
1385
- await OnCallDutyPolicyUserOverrideService.findBy({
1386
- query: {
1387
- projectId: data.projectId,
1388
- startsAt: QueryHelper.lessThanEqualTo(data.windowEnd),
1389
- endsAt: QueryHelper.greaterThanEqualTo(data.windowStart),
1390
- onCallDutyPolicyId: data.currentOnCallDutyPolicyId
1391
- ? QueryHelper.equalToOrNull(data.currentOnCallDutyPolicyId)
1392
- : QueryHelper.isNull(),
1393
- },
1394
- select: {
1395
- startsAt: true,
1396
- endsAt: true,
1397
- overrideUserId: true,
1398
- routeAlertsToUserId: true,
1399
- onCallDutyPolicyId: true,
1400
- },
1401
- sort: {
1402
- startsAt: SortOrder.Ascending,
1403
- },
1404
- limit: LIMIT_PER_PROJECT,
1405
- skip: 0,
1406
- props: {
1407
- isRoot: true,
1408
- },
1409
- });
1678
+ return { layerProps, scheduleUserIds };
1679
+ }
1410
1680
 
1411
- const scheduleUserIdSet: Set<string> = new Set<string>(
1412
- data.scheduleUserIds.map((id: ObjectID) => {
1413
- return id.toString();
1414
- }),
1415
- );
1681
+ /*
1682
+ * ---------------------------------------------------------------------------
1683
+ * Shift resolver — the source of truth for the calendar feeds, /my-shifts
1684
+ * and the shift reminders.
1685
+ * ---------------------------------------------------------------------------
1686
+ */
1416
1687
 
1417
- return overrides
1418
- .filter((o: OnCallDutyPolicyUserOverride) => {
1419
- const overrideUserId: string = o.overrideUserId?.toString() || "";
1420
- return scheduleUserIdSet.has(overrideUserId);
1421
- })
1422
- .map((o: OnCallDutyPolicyUserOverride): UserOverrideRecord => {
1423
- return {
1424
- overrideUserId: o.overrideUserId?.toString() || "",
1425
- routeAlertsToUserId: o.routeAlertsToUserId?.toString() || "",
1426
- startsAt: o.startsAt!,
1427
- endsAt: o.endsAt!,
1428
- onCallDutyPolicyId: o.onCallDutyPolicyId?.toString() || null,
1429
- };
1688
+ /**
1689
+ * Resolve every on-call segment of ONE schedule that overlaps
1690
+ * [windowStart, windowEnd), in the exact order the paging and roster paths
1691
+ * use: layers -> engine -> overrides. Returns null when the schedule does
1692
+ * not exist. See getResolvedShiftSegmentsForSchedules for the rules.
1693
+ */
1694
+ @CaptureSpan()
1695
+ public async getResolvedShiftSegments(
1696
+ options: ResolveShiftSegmentsOptions,
1697
+ ): Promise<ResolvedShiftSegments | null> {
1698
+ const resolved: Array<ResolvedShiftSegments> =
1699
+ await this.getResolvedShiftSegmentsForSchedules({
1700
+ scheduleIds: [options.scheduleId],
1701
+ windowStart: options.windowStart,
1702
+ windowEnd: options.windowEnd,
1703
+ maxSimulationIterations: options.maxSimulationIterations,
1430
1704
  });
1705
+
1706
+ return resolved[0] || null;
1431
1707
  }
1432
1708
 
1433
- /*
1434
- * Compute how far out to resolve calendar events. Returns at least 1 year
1435
- * from `from`, but extends far enough to contain (getNumberOfEvents + 1)
1436
- * rotation periods for the slowest layer, so multi-year rotations still yield
1437
- * a current and next event.
1709
+ /**
1710
+ * Batched resolver: loads the layers, layer users, policy attachments and
1711
+ * overrides of every schedule with a handful of IN queries, then resolves
1712
+ * each schedule in memory.
1713
+ *
1714
+ * Per schedule:
1715
+ * 1. LayerProps exactly as the roster path builds them (plus layer ids).
1716
+ * 2. Expansion window = 2 rotation periods before windowStart .. 2 after
1717
+ * windowEnd, so the segments overlapping the window carry their TRUE
1718
+ * start and end rather than the engine's clamp to the window edge.
1719
+ * 3. ONE multi-layer engine expansion, bounded by maxSimulationIterations
1720
+ * (default: the engine's own caps); `truncated` reports a cap hit.
1721
+ * 4. Overrides in the roster's policy context: the single attached
1722
+ * policy's scoped overrides plus globals when exactly one policy is
1723
+ * attached, globals only otherwise — the same rule as
1724
+ * refreshCurrentUserIdAndHandoffTimeInSchedule (getSingleAttachedPolicyId).
1725
+ * 5. With two or more distinct attached policies AND a policy-scoped
1726
+ * override for a member overlapping the window, the same base events
1727
+ * are re-resolved once per such policy -> policyVariants. The
1728
+ * expensive expansion runs once; the override pass is cheap.
1729
+ * 6. lastModifiedAt = max(updatedAt) over layers, layer users,
1730
+ * participating overrides and attachments — never the schedule row.
1731
+ *
1732
+ * Schedules that no longer exist are skipped; the result is in the order
1733
+ * of the input ids.
1438
1734
  */
1439
- private computeResolutionWindowEnd(
1440
- layerProps: Array<LayerProps>,
1441
- from: Date,
1442
- getNumberOfEvents: number,
1443
- ): Date {
1444
- const periodsNeeded: number = Math.max(2, getNumberOfEvents + 1);
1735
+ @CaptureSpan()
1736
+ public async getResolvedShiftSegmentsForSchedules(
1737
+ options: ResolveShiftSegmentsForSchedulesOptions,
1738
+ ): Promise<Array<ResolvedShiftSegments>> {
1739
+ const scheduleIds: Array<ObjectID> = OnCallShiftChangeListeners.dedupe(
1740
+ options.scheduleIds,
1741
+ );
1445
1742
 
1446
- let windowEnd: Date = from;
1447
- let anyLayerRestricted: boolean = false;
1743
+ if (scheduleIds.length === 0) {
1744
+ return [];
1745
+ }
1448
1746
 
1449
- for (const layer of layerProps) {
1450
- if (this.isLayerRestricted(layer)) {
1451
- anyLayerRestricted = true;
1452
- }
1747
+ if (
1748
+ !OneUptimeDate.isBefore(options.windowStart, options.windowEnd) &&
1749
+ options.windowStart.getTime() !== options.windowEnd.getTime()
1750
+ ) {
1751
+ throw new BadDataException("windowStart must be before windowEnd");
1752
+ }
1453
1753
 
1454
- if (!layer.rotation) {
1754
+ const inputsById: Map<string, ScheduleResolutionInputs> =
1755
+ await this.loadResolutionInputsForSchedules(scheduleIds);
1756
+
1757
+ /*
1758
+ * Build the layer props and the per-schedule expansion window first so
1759
+ * the override query below can cover every schedule in one statement
1760
+ * (overrides are project-scoped, not schedule-scoped).
1761
+ */
1762
+ interface PreparedSchedule {
1763
+ inputs: ScheduleResolutionInputs;
1764
+ layerProps: Array<LayerProps>;
1765
+ scheduleUserIds: Array<ObjectID>;
1766
+ expansionStart: Date;
1767
+ expansionEnd: Date;
1768
+ }
1769
+
1770
+ const prepared: Array<PreparedSchedule> = [];
1771
+ const projectIds: Set<string> = new Set<string>();
1772
+ let earliestStart: Date | null = null;
1773
+ let latestEnd: Date | null = null;
1774
+
1775
+ for (const scheduleId of scheduleIds) {
1776
+ const inputs: ScheduleResolutionInputs | undefined = inputsById.get(
1777
+ scheduleId.toString(),
1778
+ );
1779
+
1780
+ if (!inputs) {
1455
1781
  continue;
1456
1782
  }
1457
1783
 
1458
- let recurring: Recurring;
1459
- try {
1460
- recurring =
1461
- layer.rotation instanceof Recurring
1462
- ? layer.rotation
1463
- : Recurring.fromJSON(layer.rotation as any);
1464
- } catch {
1465
- continue;
1784
+ const { layerProps, scheduleUserIds } = this.buildLayerPropsFromRows({
1785
+ layers: inputs.layers,
1786
+ layerUsers: inputs.layerUsers,
1787
+ scheduleTimezone: inputs.schedule.timezone?.toString() || undefined,
1788
+ });
1789
+
1790
+ const expansionStart: Date =
1791
+ layerProps.length > 0
1792
+ ? this.computeResolutionWindowStart(
1793
+ layerProps,
1794
+ options.windowStart,
1795
+ 2,
1796
+ )
1797
+ : options.windowStart;
1798
+
1799
+ const expansionEnd: Date =
1800
+ layerProps.length > 0
1801
+ ? this.computeResolutionWindowEndByPeriods(
1802
+ layerProps,
1803
+ options.windowEnd,
1804
+ 2,
1805
+ )
1806
+ : options.windowEnd;
1807
+
1808
+ if (inputs.schedule.projectId) {
1809
+ projectIds.add(inputs.schedule.projectId.toString());
1466
1810
  }
1467
1811
 
1468
- let candidate: Date = from;
1469
- for (let i: number = 0; i < periodsNeeded; i++) {
1470
- candidate = Recurring.getNextDateInterval(candidate, recurring);
1812
+ if (
1813
+ !earliestStart ||
1814
+ OneUptimeDate.isBefore(expansionStart, earliestStart)
1815
+ ) {
1816
+ earliestStart = expansionStart;
1471
1817
  }
1472
1818
 
1473
- if (OneUptimeDate.isAfter(candidate, windowEnd)) {
1474
- windowEnd = candidate;
1819
+ if (!latestEnd || OneUptimeDate.isAfter(expansionEnd, latestEnd)) {
1820
+ latestEnd = expansionEnd;
1475
1821
  }
1822
+
1823
+ prepared.push({
1824
+ inputs,
1825
+ layerProps,
1826
+ scheduleUserIds,
1827
+ expansionStart,
1828
+ expansionEnd,
1829
+ });
1476
1830
  }
1477
1831
 
1478
- /*
1479
- * Floor the window. Restricted layers can have long coverage gaps (a
1480
- * weekend-only or business-hours restriction), so the next COVERED event may
1481
- * be far past the naive (getNumberOfEvents + 1)-period horizon — keep a
1482
- * generous 1-year floor for them so a current/next user is still found.
1483
- * UNRESTRICTED layers produce exactly one event per rotation period, so the
1484
- * per-layer candidate above already spans the events we need; flooring those
1485
- * at a full year forced a fast (hourly/daily) rotation to materialize
1486
- * thousands of events on every resolution, and getMultiLayerEvents then ran
1487
- * an O(n^2) overlap merge over them — a real worker stall (audit H2). For
1488
- * unrestricted layers a small 1-day floor is ample margin.
1489
- */
1490
- const floor: Date = anyLayerRestricted
1491
- ? OneUptimeDate.addRemoveYears(from, 1)
1492
- : OneUptimeDate.addRemoveDays(from, 1);
1832
+ if (prepared.length === 0) {
1833
+ return [];
1834
+ }
1493
1835
 
1494
- if (OneUptimeDate.isBefore(windowEnd, floor)) {
1495
- windowEnd = floor;
1836
+ const allOverrides: Array<OnCallDutyPolicyUserOverride> =
1837
+ projectIds.size > 0 && earliestStart && latestEnd
1838
+ ? await this.loadOverridesForProjects({
1839
+ projectIds: Array.from(projectIds).map((id: string) => {
1840
+ return new ObjectID(id);
1841
+ }),
1842
+ windowStart: earliestStart,
1843
+ windowEnd: latestEnd,
1844
+ })
1845
+ : [];
1846
+
1847
+ const results: Array<ResolvedShiftSegments> = [];
1848
+
1849
+ for (const item of prepared) {
1850
+ results.push(
1851
+ this.resolveScheduleInMemory({
1852
+ schedule: item.inputs.schedule,
1853
+ layers: item.inputs.layers,
1854
+ layerUsers: item.inputs.layerUsers,
1855
+ attachments: item.inputs.attachments,
1856
+ layerProps: item.layerProps,
1857
+ scheduleUserIds: item.scheduleUserIds,
1858
+ overrides: allOverrides,
1859
+ expansionStart: item.expansionStart,
1860
+ expansionEnd: item.expansionEnd,
1861
+ windowStart: options.windowStart,
1862
+ windowEnd: options.windowEnd,
1863
+ maxSimulationIterations: options.maxSimulationIterations,
1864
+ }),
1865
+ );
1496
1866
  }
1497
1867
 
1498
- return windowEnd;
1868
+ return results;
1499
1869
  }
1500
1870
 
1501
1871
  /*
1502
- * True when the layer carries an active (non-None) restriction. Handles both a
1503
- * hydrated RestrictionTimes instance (exposes `restictionType`) and the raw
1504
- * serialized JSON form ({ _type, value: { restictionType } }) so it is safe
1505
- * regardless of where the LayerProps came from.
1872
+ * Everything the resolver needs for a batch of schedules, in four queries.
1873
+ * Schedules that do not exist simply have no entry.
1506
1874
  */
1507
- private isLayerRestricted(layer: LayerProps): boolean {
1508
- const rt: unknown = layer.restrictionTimes;
1509
- if (!rt) {
1510
- return false;
1511
- }
1512
- const anyRt: {
1513
- restictionType?: RestrictionType | string;
1514
- value?: { restictionType?: RestrictionType | string };
1515
- } = rt as {
1875
+ private async loadResolutionInputsForSchedules(
1876
+ scheduleIds: Array<ObjectID>,
1877
+ ): Promise<Map<string, ScheduleResolutionInputs>> {
1878
+ const schedules: Array<OnCallDutyPolicySchedule> = await this.findBy({
1879
+ query: {
1880
+ _id: QueryHelper.any(scheduleIds),
1881
+ },
1882
+ select: {
1883
+ _id: true,
1884
+ name: true,
1885
+ timezone: true,
1886
+ projectId: true,
1887
+ shiftConfigVersion: true,
1888
+ createdAt: true,
1889
+ },
1890
+ limit: LIMIT_PER_PROJECT,
1891
+ skip: 0,
1892
+ props: {
1893
+ isRoot: true,
1894
+ },
1895
+ });
1896
+
1897
+ const byId: Map<string, ScheduleResolutionInputs> = new Map<
1898
+ string,
1899
+ ScheduleResolutionInputs
1900
+ >();
1901
+
1902
+ for (const schedule of schedules) {
1903
+ if (!schedule.id) {
1904
+ continue;
1905
+ }
1906
+ byId.set(schedule.id.toString(), {
1907
+ schedule,
1908
+ layers: [],
1909
+ layerUsers: [],
1910
+ attachments: [],
1911
+ });
1912
+ }
1913
+
1914
+ if (byId.size === 0) {
1915
+ return byId;
1916
+ }
1917
+
1918
+ const existingIds: Array<ObjectID> = Array.from(byId.keys()).map(
1919
+ (id: string) => {
1920
+ return new ObjectID(id);
1921
+ },
1922
+ );
1923
+
1924
+ const [layers, layerUsers, attachments]: [
1925
+ Array<OnCallDutyPolicyScheduleLayer>,
1926
+ Array<OnCallDutyPolicyScheduleLayerUser>,
1927
+ Array<OnCallDutyPolicyEscalationRuleSchedule>,
1928
+ ] = await Promise.all([
1929
+ OnCallDutyPolicyScheduleLayerService.findBy({
1930
+ query: {
1931
+ onCallDutyPolicyScheduleId: QueryHelper.any(existingIds),
1932
+ },
1933
+ select: {
1934
+ _id: true,
1935
+ order: true,
1936
+ name: true,
1937
+ description: true,
1938
+ startsAt: true,
1939
+ restrictionTimes: true,
1940
+ rotation: true,
1941
+ onCallDutyPolicyScheduleId: true,
1942
+ projectId: true,
1943
+ handOffTime: true,
1944
+ updatedAt: true,
1945
+ },
1946
+ sort: {
1947
+ order: SortOrder.Ascending,
1948
+ },
1949
+ limit: LIMIT_PER_PROJECT,
1950
+ skip: 0,
1951
+ props: {
1952
+ isRoot: true,
1953
+ },
1954
+ }),
1955
+ OnCallDutyPolicyScheduleLayerUserService.findBy({
1956
+ query: {
1957
+ onCallDutyPolicyScheduleId: QueryHelper.any(existingIds),
1958
+ },
1959
+ select: {
1960
+ _id: true,
1961
+ user: {
1962
+ _id: true,
1963
+ },
1964
+ userId: true,
1965
+ order: true,
1966
+ onCallDutyPolicyScheduleLayerId: true,
1967
+ onCallDutyPolicyScheduleId: true,
1968
+ updatedAt: true,
1969
+ },
1970
+ sort: {
1971
+ order: SortOrder.Ascending,
1972
+ },
1973
+ limit: LIMIT_PER_PROJECT,
1974
+ skip: 0,
1975
+ props: {
1976
+ isRoot: true,
1977
+ },
1978
+ }),
1979
+ OnCallDutyPolicyEscalationRuleScheduleService.findBy({
1980
+ query: {
1981
+ onCallDutyPolicyScheduleId: QueryHelper.any(existingIds),
1982
+ },
1983
+ select: {
1984
+ _id: true,
1985
+ onCallDutyPolicyScheduleId: true,
1986
+ onCallDutyPolicyId: true,
1987
+ onCallDutyPolicyEscalationRuleId: true,
1988
+ updatedAt: true,
1989
+ onCallDutyPolicy: {
1990
+ name: true,
1991
+ _id: true,
1992
+ },
1993
+ onCallDutyPolicyEscalationRule: {
1994
+ name: true,
1995
+ _id: true,
1996
+ order: true,
1997
+ },
1998
+ },
1999
+ limit: LIMIT_PER_PROJECT,
2000
+ skip: 0,
2001
+ props: {
2002
+ isRoot: true,
2003
+ },
2004
+ }),
2005
+ ]);
2006
+
2007
+ for (const layer of layers) {
2008
+ const entry: ScheduleResolutionInputs | undefined = byId.get(
2009
+ layer.onCallDutyPolicyScheduleId?.toString() || "",
2010
+ );
2011
+ if (entry) {
2012
+ entry.layers.push(layer);
2013
+ }
2014
+ }
2015
+
2016
+ for (const layerUser of layerUsers) {
2017
+ const entry: ScheduleResolutionInputs | undefined = byId.get(
2018
+ layerUser.onCallDutyPolicyScheduleId?.toString() || "",
2019
+ );
2020
+ if (entry) {
2021
+ entry.layerUsers.push(layerUser);
2022
+ }
2023
+ }
2024
+
2025
+ for (const attachment of attachments) {
2026
+ const entry: ScheduleResolutionInputs | undefined = byId.get(
2027
+ attachment.onCallDutyPolicyScheduleId?.toString() || "",
2028
+ );
2029
+ if (entry) {
2030
+ entry.attachments.push(attachment);
2031
+ }
2032
+ }
2033
+
2034
+ return byId;
2035
+ }
2036
+
2037
+ /*
2038
+ * Every override (global AND policy-scoped) of the given projects that
2039
+ * overlaps [windowStart, windowEnd]. The per-schedule policy scoping and
2040
+ * member filtering happen in memory (see selectOverridesForSchedule), which
2041
+ * mirrors fetchOverridesForSchedule's query exactly but for many schedules
2042
+ * at once.
2043
+ */
2044
+ private async loadOverridesForProjects(data: {
2045
+ projectIds: Array<ObjectID>;
2046
+ windowStart: Date;
2047
+ windowEnd: Date;
2048
+ }): Promise<Array<OnCallDutyPolicyUserOverride>> {
2049
+ if (data.projectIds.length === 0) {
2050
+ return [];
2051
+ }
2052
+
2053
+ return await OnCallDutyPolicyUserOverrideService.findBy({
2054
+ query: {
2055
+ projectId: QueryHelper.any(data.projectIds),
2056
+ startsAt: QueryHelper.lessThanEqualTo(data.windowEnd),
2057
+ endsAt: QueryHelper.greaterThanEqualTo(data.windowStart),
2058
+ },
2059
+ select: {
2060
+ _id: true,
2061
+ projectId: true,
2062
+ startsAt: true,
2063
+ endsAt: true,
2064
+ overrideUserId: true,
2065
+ routeAlertsToUserId: true,
2066
+ onCallDutyPolicyId: true,
2067
+ updatedAt: true,
2068
+ },
2069
+ sort: {
2070
+ startsAt: SortOrder.Ascending,
2071
+ },
2072
+ limit: LIMIT_PER_PROJECT,
2073
+ skip: 0,
2074
+ props: {
2075
+ isRoot: true,
2076
+ },
2077
+ });
2078
+ }
2079
+
2080
+ /*
2081
+ * The in-memory twin of fetchOverridesForSchedule: the overrides of this
2082
+ * schedule's project that overlap the window, whose overridden user is a
2083
+ * schedule member, scoped to `policyId` (that policy's plus globals) or to
2084
+ * globals only when no policy context is given.
2085
+ */
2086
+ private selectOverridesForSchedule(data: {
2087
+ overrides: Array<OnCallDutyPolicyUserOverride>;
2088
+ projectId: string;
2089
+ scheduleUserIds: Set<string>;
2090
+ windowStart: Date;
2091
+ windowEnd: Date;
2092
+ policyId: string | undefined;
2093
+ }): Array<OnCallDutyPolicyUserOverride> {
2094
+ return data.overrides.filter((override: OnCallDutyPolicyUserOverride) => {
2095
+ if (override.projectId?.toString() !== data.projectId) {
2096
+ return false;
2097
+ }
2098
+
2099
+ if (!override.startsAt || !override.endsAt) {
2100
+ return false;
2101
+ }
2102
+
2103
+ if (
2104
+ OneUptimeDate.isAfter(override.startsAt, data.windowEnd) ||
2105
+ OneUptimeDate.isBefore(override.endsAt, data.windowStart)
2106
+ ) {
2107
+ return false;
2108
+ }
2109
+
2110
+ if (
2111
+ !data.scheduleUserIds.has(override.overrideUserId?.toString() || "")
2112
+ ) {
2113
+ return false;
2114
+ }
2115
+
2116
+ const overridePolicyId: string | undefined =
2117
+ override.onCallDutyPolicyId?.toString() || undefined;
2118
+
2119
+ if (!overridePolicyId) {
2120
+ return true; // global
2121
+ }
2122
+
2123
+ return Boolean(data.policyId) && overridePolicyId === data.policyId;
2124
+ });
2125
+ }
2126
+
2127
+ private toUserOverrideRecord(
2128
+ override: OnCallDutyPolicyUserOverride,
2129
+ ): UserOverrideRecord {
2130
+ return {
2131
+ overrideUserId: override.overrideUserId?.toString() || "",
2132
+ routeAlertsToUserId: override.routeAlertsToUserId?.toString() || "",
2133
+ startsAt: override.startsAt!,
2134
+ endsAt: override.endsAt!,
2135
+ onCallDutyPolicyId: override.onCallDutyPolicyId?.toString() || null,
2136
+ };
2137
+ }
2138
+
2139
+ /*
2140
+ * The pure part of the resolver: given everything already loaded, expand
2141
+ * once and resolve the base context plus any policy variants.
2142
+ */
2143
+ private resolveScheduleInMemory(data: {
2144
+ schedule: OnCallDutyPolicySchedule;
2145
+ layers: Array<OnCallDutyPolicyScheduleLayer>;
2146
+ layerUsers: Array<OnCallDutyPolicyScheduleLayerUser>;
2147
+ attachments: Array<OnCallDutyPolicyEscalationRuleSchedule>;
2148
+ layerProps: Array<LayerProps>;
2149
+ scheduleUserIds: Array<ObjectID>;
2150
+ overrides: Array<OnCallDutyPolicyUserOverride>;
2151
+ expansionStart: Date;
2152
+ expansionEnd: Date;
2153
+ windowStart: Date;
2154
+ windowEnd: Date;
2155
+ maxSimulationIterations?: number | undefined;
2156
+ }): ResolvedShiftSegments {
2157
+ const scheduleId: string = data.schedule.id!.toString();
2158
+ const projectId: string = data.schedule.projectId?.toString() || "";
2159
+
2160
+ // Distinct (policy, rule) attachments, stable order.
2161
+ const attachedPolicies: Array<MaterializedShiftPolicy> =
2162
+ this.toAttachedPolicies(data.attachments);
2163
+
2164
+ const distinctPolicyIds: Array<string> = [];
2165
+ for (const policy of attachedPolicies) {
2166
+ if (!distinctPolicyIds.includes(policy.policyId)) {
2167
+ distinctPolicyIds.push(policy.policyId);
2168
+ }
2169
+ }
2170
+
2171
+ // The roster rule: exactly one attached policy => resolve in its context.
2172
+ const baseContextPolicyId: string | undefined =
2173
+ distinctPolicyIds.length === 1 ? distinctPolicyIds[0] : undefined;
2174
+
2175
+ // 1 + 2 + 3: one engine expansion over the widened window.
2176
+ let baseEvents: Array<CalendarEvent> = [];
2177
+ let truncated: boolean = false;
2178
+
2179
+ if (data.layerProps.length > 0) {
2180
+ const expansion: LayerEventsResult =
2181
+ this.layerUtil.getMultiLayerEventsWithMeta(
2182
+ {
2183
+ layers: data.layerProps,
2184
+ calendarStartDate: data.expansionStart,
2185
+ calendarEndDate: data.expansionEnd,
2186
+ },
2187
+ data.maxSimulationIterations !== undefined
2188
+ ? { maxSimulationIterations: data.maxSimulationIterations }
2189
+ : undefined,
2190
+ );
2191
+
2192
+ baseEvents = expansion.events;
2193
+ truncated = expansion.truncated;
2194
+ }
2195
+
2196
+ const scheduleUserIdSet: Set<string> = new Set<string>(
2197
+ data.scheduleUserIds.map((id: ObjectID) => {
2198
+ return id.toString();
2199
+ }),
2200
+ );
2201
+
2202
+ // 4: overrides in the base (roster) context.
2203
+ const baseOverrideRows: Array<OnCallDutyPolicyUserOverride> =
2204
+ this.selectOverridesForSchedule({
2205
+ overrides: data.overrides,
2206
+ projectId,
2207
+ scheduleUserIds: scheduleUserIdSet,
2208
+ windowStart: data.expansionStart,
2209
+ windowEnd: data.expansionEnd,
2210
+ policyId: baseContextPolicyId,
2211
+ });
2212
+
2213
+ const baseOverrides: Array<UserOverrideRecord> = baseOverrideRows.map(
2214
+ (override: OnCallDutyPolicyUserOverride) => {
2215
+ return this.toUserOverrideRecord(override);
2216
+ },
2217
+ );
2218
+
2219
+ const participatingOverrideRows: Array<OnCallDutyPolicyUserOverride> = [
2220
+ ...baseOverrideRows,
2221
+ ];
2222
+ const participatingOverrides: Array<UserOverrideRecord> = [
2223
+ ...baseOverrides,
2224
+ ];
2225
+
2226
+ let segments: Array<CalendarEvent> = baseEvents;
2227
+
2228
+ if (baseOverrides.length > 0 && baseEvents.length > 0) {
2229
+ segments = UserOverrideUtil.applyOverridesToEvents({
2230
+ events: baseEvents,
2231
+ overrides: baseOverrides,
2232
+ currentOnCallDutyPolicyId: baseContextPolicyId,
2233
+ });
2234
+ }
2235
+
2236
+ segments = this.filterEventsOverlappingWindow(
2237
+ segments,
2238
+ data.windowStart,
2239
+ data.windowEnd,
2240
+ );
2241
+
2242
+ // 5: policy variants.
2243
+ const policyVariants: Array<ResolvedPolicyVariant> = [];
2244
+
2245
+ if (distinctPolicyIds.length >= 2 && baseEvents.length > 0) {
2246
+ for (const policyId of distinctPolicyIds) {
2247
+ const scopedRows: Array<OnCallDutyPolicyUserOverride> =
2248
+ this.selectOverridesForSchedule({
2249
+ overrides: data.overrides,
2250
+ projectId,
2251
+ scheduleUserIds: scheduleUserIdSet,
2252
+ windowStart: data.expansionStart,
2253
+ windowEnd: data.expansionEnd,
2254
+ policyId,
2255
+ }).filter((override: OnCallDutyPolicyUserOverride) => {
2256
+ // Only the policy-scoped ones make this context differ.
2257
+ return Boolean(override.onCallDutyPolicyId);
2258
+ });
2259
+
2260
+ if (scopedRows.length === 0) {
2261
+ continue;
2262
+ }
2263
+
2264
+ const variantOverrideRows: Array<OnCallDutyPolicyUserOverride> = [
2265
+ ...scopedRows,
2266
+ ...baseOverrideRows,
2267
+ ];
2268
+
2269
+ const variantOverrides: Array<UserOverrideRecord> =
2270
+ variantOverrideRows.map((override: OnCallDutyPolicyUserOverride) => {
2271
+ return this.toUserOverrideRecord(override);
2272
+ });
2273
+
2274
+ for (const row of scopedRows) {
2275
+ participatingOverrideRows.push(row);
2276
+ }
2277
+ for (const record of variantOverrides.slice(0, scopedRows.length)) {
2278
+ participatingOverrides.push(record);
2279
+ }
2280
+
2281
+ const variantSegments: Array<CalendarEvent> =
2282
+ this.filterEventsOverlappingWindow(
2283
+ UserOverrideUtil.applyOverridesToEvents({
2284
+ events: baseEvents,
2285
+ overrides: variantOverrides,
2286
+ currentOnCallDutyPolicyId: policyId,
2287
+ }),
2288
+ data.windowStart,
2289
+ data.windowEnd,
2290
+ );
2291
+
2292
+ const policyName: string =
2293
+ attachedPolicies.find((policy: MaterializedShiftPolicy) => {
2294
+ return policy.policyId === policyId;
2295
+ })?.policyName || "";
2296
+
2297
+ policyVariants.push({
2298
+ policyId,
2299
+ policyName,
2300
+ segments: variantSegments,
2301
+ });
2302
+ }
2303
+ }
2304
+
2305
+ // 6: lastModifiedAt — schedule row excluded.
2306
+ const lastModifiedAt: Date = this.computeLastModifiedAt({
2307
+ schedule: data.schedule,
2308
+ layers: data.layers,
2309
+ layerUsers: data.layerUsers,
2310
+ attachments: data.attachments,
2311
+ overrides: participatingOverrideRows,
2312
+ });
2313
+
2314
+ const scheduleInfo: ResolvedScheduleInfo = {
2315
+ id: scheduleId,
2316
+ name: data.schedule.name || "",
2317
+ projectId,
2318
+ shiftConfigVersion: Service.toVersionNumber(
2319
+ data.schedule.shiftConfigVersion,
2320
+ ),
2321
+ };
2322
+
2323
+ const timezone: string | undefined =
2324
+ data.schedule.timezone?.toString() || undefined;
2325
+ if (timezone) {
2326
+ scheduleInfo.timezone = timezone;
2327
+ }
2328
+
2329
+ return {
2330
+ schedule: scheduleInfo,
2331
+ segments,
2332
+ policyVariants,
2333
+ attachedPolicies,
2334
+ layerProps: data.layerProps,
2335
+ scheduleUserIds: Array.from(scheduleUserIdSet),
2336
+ overrides: participatingOverrides,
2337
+ lastModifiedAt,
2338
+ truncated,
2339
+ };
2340
+ }
2341
+
2342
+ private toAttachedPolicies(
2343
+ attachments: Array<OnCallDutyPolicyEscalationRuleSchedule>,
2344
+ ): Array<MaterializedShiftPolicy> {
2345
+ const seen: Set<string> = new Set<string>();
2346
+ const policies: Array<MaterializedShiftPolicy> = [];
2347
+
2348
+ for (const attachment of attachments) {
2349
+ const policyId: string =
2350
+ attachment.onCallDutyPolicyId?.toString() ||
2351
+ attachment.onCallDutyPolicy?.id?.toString() ||
2352
+ "";
2353
+ const ruleId: string =
2354
+ attachment.onCallDutyPolicyEscalationRuleId?.toString() ||
2355
+ attachment.onCallDutyPolicyEscalationRule?.id?.toString() ||
2356
+ "";
2357
+
2358
+ if (!policyId) {
2359
+ continue;
2360
+ }
2361
+
2362
+ const key: string = `${policyId}:${ruleId}`;
2363
+ if (seen.has(key)) {
2364
+ continue;
2365
+ }
2366
+ seen.add(key);
2367
+
2368
+ policies.push({
2369
+ policyId,
2370
+ policyName: attachment.onCallDutyPolicy?.name || "",
2371
+ ruleId,
2372
+ ruleName: attachment.onCallDutyPolicyEscalationRule?.name || "",
2373
+ ruleOrder: Service.toVersionNumber(
2374
+ attachment.onCallDutyPolicyEscalationRule?.order,
2375
+ ),
2376
+ });
2377
+ }
2378
+
2379
+ policies.sort((a: MaterializedShiftPolicy, b: MaterializedShiftPolicy) => {
2380
+ if (a.policyName !== b.policyName) {
2381
+ return a.policyName < b.policyName ? -1 : 1;
2382
+ }
2383
+ if (a.policyId !== b.policyId) {
2384
+ return a.policyId < b.policyId ? -1 : 1;
2385
+ }
2386
+ if (a.ruleOrder !== b.ruleOrder) {
2387
+ return a.ruleOrder - b.ruleOrder;
2388
+ }
2389
+ return a.ruleId < b.ruleId ? -1 : a.ruleId > b.ruleId ? 1 : 0;
2390
+ });
2391
+
2392
+ return policies;
2393
+ }
2394
+
2395
+ private computeLastModifiedAt(data: {
2396
+ schedule: OnCallDutyPolicySchedule;
2397
+ layers: Array<{ updatedAt?: Date | undefined }>;
2398
+ layerUsers: Array<{ updatedAt?: Date | undefined }>;
2399
+ attachments: Array<{ updatedAt?: Date | undefined }>;
2400
+ overrides: Array<{ updatedAt?: Date | undefined }>;
2401
+ }): Date {
2402
+ let latest: Date | null = null;
2403
+
2404
+ const consider: (value: Date | undefined) => void = (
2405
+ value: Date | undefined,
2406
+ ): void => {
2407
+ if (!value) {
2408
+ return;
2409
+ }
2410
+ const asDate: Date = value instanceof Date ? value : new Date(value);
2411
+ if (Number.isNaN(asDate.getTime())) {
2412
+ return;
2413
+ }
2414
+ if (!latest || asDate.getTime() > latest.getTime()) {
2415
+ latest = asDate;
2416
+ }
2417
+ };
2418
+
2419
+ for (const rows of [
2420
+ data.layers,
2421
+ data.layerUsers,
2422
+ data.attachments,
2423
+ data.overrides,
2424
+ ]) {
2425
+ for (const row of rows) {
2426
+ consider(row.updatedAt);
2427
+ }
2428
+ }
2429
+
2430
+ if (latest) {
2431
+ return latest;
2432
+ }
2433
+
2434
+ /*
2435
+ * Nothing configured yet (no layers, no attachments): fall back to when
2436
+ * the schedule was CREATED — stable, unlike its updatedAt — and finally
2437
+ * to the epoch so the value is always a valid instant.
2438
+ */
2439
+ if (data.schedule.createdAt) {
2440
+ const created: Date = new Date(data.schedule.createdAt);
2441
+ if (!Number.isNaN(created.getTime())) {
2442
+ return created;
2443
+ }
2444
+ }
2445
+
2446
+ return new Date(0);
2447
+ }
2448
+
2449
+ private filterEventsOverlappingWindow(
2450
+ events: Array<CalendarEvent>,
2451
+ windowStart: Date,
2452
+ windowEnd: Date,
2453
+ ): Array<CalendarEvent> {
2454
+ return events.filter((event: CalendarEvent) => {
2455
+ if (!event || !event.start || !event.end) {
2456
+ return false;
2457
+ }
2458
+ return (
2459
+ event.start.getTime() < windowEnd.getTime() &&
2460
+ event.end.getTime() > windowStart.getTime()
2461
+ );
2462
+ });
2463
+ }
2464
+
2465
+ private static toVersionNumber(value: unknown): number {
2466
+ const parsed: number =
2467
+ typeof value === "number" ? value : parseInt(String(value ?? ""), 10);
2468
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
2469
+ }
2470
+
2471
+ /*
2472
+ * ---------------------------------------------------------------------------
2473
+ * Change propagation — shiftConfigVersion, feed caches, listeners.
2474
+ * ---------------------------------------------------------------------------
2475
+ */
2476
+
2477
+ /**
2478
+ * Atomically add one to shiftConfigVersion of each schedule, in a single
2479
+ * UPDATE per schedule (`SET col = COALESCE(col, 0) + 1`, no hooks, no
2480
+ * optimistic-lock bump — see atomicAddToColumnsByIdWithoutHooks). Best
2481
+ * effort: a failure is logged and never thrown, because this runs inside
2482
+ * the CRUD hooks of the user's edit and must not fail it.
2483
+ *
2484
+ * NEVER call this from refreshCurrentUserIdAndHandoffTimeInSchedule: the
2485
+ * roster refresh runs every handoff and touches no configuration, and the
2486
+ * version is what calendar clients see as SEQUENCE — bumping it there would
2487
+ * make every feed look edited every rotation period.
2488
+ */
2489
+ @CaptureSpan()
2490
+ public async bumpShiftConfigVersion(
2491
+ scheduleIds: Array<ObjectID>,
2492
+ ): Promise<void> {
2493
+ for (const scheduleId of OnCallShiftChangeListeners.dedupe(scheduleIds)) {
2494
+ try {
2495
+ await this.atomicAddToColumnsByIdWithoutHooks({
2496
+ id: scheduleId,
2497
+ add: { shiftConfigVersion: 1 },
2498
+ });
2499
+ } catch (err) {
2500
+ logger.error(
2501
+ `Error bumping shiftConfigVersion for schedule ${scheduleId.toString()} (best-effort).`,
2502
+ );
2503
+ logger.error(err);
2504
+ }
2505
+ }
2506
+ }
2507
+
2508
+ /**
2509
+ * The schedules in `projectId` whose layers include any of `userIds` —
2510
+ * the query refreshRostersForUserInProject uses, batched. Root read.
2511
+ */
2512
+ @CaptureSpan()
2513
+ public async getScheduleIdsForUsersInProject(data: {
2514
+ projectId: ObjectID;
2515
+ userIds: Array<ObjectID>;
2516
+ }): Promise<Array<ObjectID>> {
2517
+ const userIds: Array<ObjectID> = OnCallShiftChangeListeners.dedupe(
2518
+ data.userIds,
2519
+ );
2520
+
2521
+ if (userIds.length === 0) {
2522
+ return [];
2523
+ }
2524
+
2525
+ const layerUsers: Array<OnCallDutyPolicyScheduleLayerUser> =
2526
+ await OnCallDutyPolicyScheduleLayerUserService.findBy({
2527
+ query: {
2528
+ projectId: data.projectId,
2529
+ userId: QueryHelper.any(userIds),
2530
+ },
2531
+ select: {
2532
+ onCallDutyPolicyScheduleId: true,
2533
+ },
2534
+ limit: LIMIT_PER_PROJECT,
2535
+ skip: 0,
2536
+ props: {
2537
+ isRoot: true,
2538
+ },
2539
+ });
2540
+
2541
+ return OnCallShiftChangeListeners.dedupe(
2542
+ layerUsers
2543
+ .map((layerUser: OnCallDutyPolicyScheduleLayerUser) => {
2544
+ return layerUser.onCallDutyPolicyScheduleId;
2545
+ })
2546
+ .filter((id: ObjectID | undefined): id is ObjectID => {
2547
+ return Boolean(id);
2548
+ }),
2549
+ );
2550
+ }
2551
+
2552
+ /**
2553
+ * What every configuration hook calls after the roster refresh:
2554
+ * 1. bump shiftConfigVersion of the affected schedules (SEQUENCE / cache key),
2555
+ * 2. purge the calendar-feed caches of those schedules and of the users
2556
+ * the change names directly,
2557
+ * 3. tell the shift-change listeners (the reminder change pass) which
2558
+ * schedules and users to look at again — delivered in the background,
2559
+ * never awaited, never allowed to fail the edit.
2560
+ * Never throws.
2561
+ */
2562
+ @CaptureSpan()
2563
+ public async propagateShiftConfigChange(
2564
+ change: ShiftConfigChange,
2565
+ ): Promise<void> {
2566
+ try {
2567
+ const scheduleIds: Array<ObjectID> = OnCallShiftChangeListeners.dedupe(
2568
+ change.scheduleIds,
2569
+ );
2570
+ const explicitUserIds: Array<ObjectID> =
2571
+ OnCallShiftChangeListeners.dedupe(change.userIds || []);
2572
+
2573
+ if (scheduleIds.length > 0 && !change.skipVersionBump) {
2574
+ await this.bumpShiftConfigVersion(scheduleIds);
2575
+ }
2576
+
2577
+ for (const scheduleId of scheduleIds) {
2578
+ try {
2579
+ await OnCallCalendarFeedCache.purgeForSchedule(scheduleId.toString());
2580
+ } catch (err) {
2581
+ logger.error(err);
2582
+ }
2583
+ }
2584
+
2585
+ if (change.projectId) {
2586
+ for (const userId of explicitUserIds) {
2587
+ try {
2588
+ await OnCallCalendarFeedCache.purgeForUser(
2589
+ change.projectId.toString(),
2590
+ userId.toString(),
2591
+ );
2592
+ } catch (err) {
2593
+ logger.error(err);
2594
+ }
2595
+ }
2596
+ }
2597
+
2598
+ let userIds: Array<ObjectID> = explicitUserIds;
2599
+
2600
+ if (scheduleIds.length > 0) {
2601
+ try {
2602
+ const members: Array<OnCallDutyPolicyScheduleLayerUser> =
2603
+ await OnCallDutyPolicyScheduleLayerUserService.findBy({
2604
+ query: {
2605
+ onCallDutyPolicyScheduleId: QueryHelper.any(scheduleIds),
2606
+ },
2607
+ select: {
2608
+ userId: true,
2609
+ },
2610
+ limit: LIMIT_PER_PROJECT,
2611
+ skip: 0,
2612
+ props: {
2613
+ isRoot: true,
2614
+ },
2615
+ });
2616
+
2617
+ userIds = OnCallShiftChangeListeners.dedupe([
2618
+ ...explicitUserIds,
2619
+ ...members
2620
+ .map((member: OnCallDutyPolicyScheduleLayerUser) => {
2621
+ return member.userId;
2622
+ })
2623
+ .filter((id: ObjectID | undefined): id is ObjectID => {
2624
+ return Boolean(id);
2625
+ }),
2626
+ ]);
2627
+ } catch (err) {
2628
+ logger.error(
2629
+ "Error loading schedule members for the shift-change event (best-effort).",
2630
+ );
2631
+ logger.error(err);
2632
+ }
2633
+ }
2634
+
2635
+ const event: OnCallShiftChangeEvent =
2636
+ OnCallShiftChangeListeners.buildEvent({
2637
+ projectId: change.projectId,
2638
+ scheduleIds,
2639
+ userIds,
2640
+ reason: change.reason,
2641
+ });
2642
+
2643
+ // Background delivery; notify never rejects, the catch is belt-and-braces.
2644
+ OnCallShiftChangeListeners.notify(event).catch((err: Error) => {
2645
+ logger.error(err);
2646
+ });
2647
+ } catch (err) {
2648
+ logger.error(
2649
+ "Error propagating an on-call configuration change (best-effort).",
2650
+ );
2651
+ logger.error(err);
2652
+ }
2653
+ }
2654
+
2655
+ private async fetchOverridesForSchedule(data: {
2656
+ projectId: ObjectID;
2657
+ scheduleUserIds: Array<ObjectID>;
2658
+ windowStart: Date;
2659
+ windowEnd: Date;
2660
+ currentOnCallDutyPolicyId?: ObjectID | undefined;
2661
+ }): Promise<Array<UserOverrideRecord>> {
2662
+ if (data.scheduleUserIds.length === 0) {
2663
+ return [];
2664
+ }
2665
+
2666
+ /*
2667
+ * When a policy context is provided, include overrides scoped to that
2668
+ * policy plus global overrides. Without a policy context (e.g. schedule
2669
+ * roster refresh, dashboard preview, incoming-call routing), only global
2670
+ * overrides apply — a policy-specific override from one policy must not
2671
+ * affect schedule resolution for a different policy.
2672
+ */
2673
+ const overrides: Array<OnCallDutyPolicyUserOverride> =
2674
+ await OnCallDutyPolicyUserOverrideService.findBy({
2675
+ query: {
2676
+ projectId: data.projectId,
2677
+ startsAt: QueryHelper.lessThanEqualTo(data.windowEnd),
2678
+ endsAt: QueryHelper.greaterThanEqualTo(data.windowStart),
2679
+ onCallDutyPolicyId: data.currentOnCallDutyPolicyId
2680
+ ? QueryHelper.equalToOrNull(data.currentOnCallDutyPolicyId)
2681
+ : QueryHelper.isNull(),
2682
+ },
2683
+ select: {
2684
+ startsAt: true,
2685
+ endsAt: true,
2686
+ overrideUserId: true,
2687
+ routeAlertsToUserId: true,
2688
+ onCallDutyPolicyId: true,
2689
+ },
2690
+ sort: {
2691
+ startsAt: SortOrder.Ascending,
2692
+ },
2693
+ limit: LIMIT_PER_PROJECT,
2694
+ skip: 0,
2695
+ props: {
2696
+ isRoot: true,
2697
+ },
2698
+ });
2699
+
2700
+ const scheduleUserIdSet: Set<string> = new Set<string>(
2701
+ data.scheduleUserIds.map((id: ObjectID) => {
2702
+ return id.toString();
2703
+ }),
2704
+ );
2705
+
2706
+ return overrides
2707
+ .filter((o: OnCallDutyPolicyUserOverride) => {
2708
+ const overrideUserId: string = o.overrideUserId?.toString() || "";
2709
+ return scheduleUserIdSet.has(overrideUserId);
2710
+ })
2711
+ .map((o: OnCallDutyPolicyUserOverride): UserOverrideRecord => {
2712
+ return {
2713
+ overrideUserId: o.overrideUserId?.toString() || "",
2714
+ routeAlertsToUserId: o.routeAlertsToUserId?.toString() || "",
2715
+ startsAt: o.startsAt!,
2716
+ endsAt: o.endsAt!,
2717
+ onCallDutyPolicyId: o.onCallDutyPolicyId?.toString() || null,
2718
+ };
2719
+ });
2720
+ }
2721
+
2722
+ /*
2723
+ * Compute how far out to resolve calendar events. Returns at least 1 year
2724
+ * from `from`, but extends far enough to contain (getNumberOfEvents + 1)
2725
+ * rotation periods for the slowest layer, so multi-year rotations still yield
2726
+ * a current and next event.
2727
+ */
2728
+ private computeResolutionWindowEnd(
2729
+ layerProps: Array<LayerProps>,
2730
+ from: Date,
2731
+ getNumberOfEvents: number,
2732
+ ): Date {
2733
+ const periodsNeeded: number = Math.max(2, getNumberOfEvents + 1);
2734
+
2735
+ let windowEnd: Date = from;
2736
+ let anyLayerRestricted: boolean = false;
2737
+
2738
+ for (const layer of layerProps) {
2739
+ if (this.isLayerRestricted(layer)) {
2740
+ anyLayerRestricted = true;
2741
+ }
2742
+
2743
+ if (!layer.rotation) {
2744
+ continue;
2745
+ }
2746
+
2747
+ let recurring: Recurring;
2748
+ try {
2749
+ recurring =
2750
+ layer.rotation instanceof Recurring
2751
+ ? layer.rotation
2752
+ : Recurring.fromJSON(layer.rotation as any);
2753
+ } catch {
2754
+ continue;
2755
+ }
2756
+
2757
+ let candidate: Date = from;
2758
+ for (let i: number = 0; i < periodsNeeded; i++) {
2759
+ candidate = Recurring.getNextDateInterval(candidate, recurring);
2760
+ }
2761
+
2762
+ if (OneUptimeDate.isAfter(candidate, windowEnd)) {
2763
+ windowEnd = candidate;
2764
+ }
2765
+ }
2766
+
2767
+ /*
2768
+ * Floor the window. Restricted layers can have long coverage gaps (a
2769
+ * weekend-only or business-hours restriction), so the next COVERED event may
2770
+ * be far past the naive (getNumberOfEvents + 1)-period horizon — keep a
2771
+ * generous 1-year floor for them so a current/next user is still found.
2772
+ * UNRESTRICTED layers produce exactly one event per rotation period, so the
2773
+ * per-layer candidate above already spans the events we need; flooring those
2774
+ * at a full year forced a fast (hourly/daily) rotation to materialize
2775
+ * thousands of events on every resolution, and getMultiLayerEvents then ran
2776
+ * an O(n^2) overlap merge over them — a real worker stall (audit H2). For
2777
+ * unrestricted layers a small 1-day floor is ample margin.
2778
+ */
2779
+ const floor: Date = anyLayerRestricted
2780
+ ? OneUptimeDate.addRemoveYears(from, 1)
2781
+ : OneUptimeDate.addRemoveDays(from, 1);
2782
+
2783
+ if (OneUptimeDate.isBefore(windowEnd, floor)) {
2784
+ windowEnd = floor;
2785
+ }
2786
+
2787
+ return windowEnd;
2788
+ }
2789
+
2790
+ /*
2791
+ * True when the layer carries an active (non-None) restriction. Handles both a
2792
+ * hydrated RestrictionTimes instance (exposes `restictionType`) and the raw
2793
+ * serialized JSON form ({ _type, value: { restictionType } }) so it is safe
2794
+ * regardless of where the LayerProps came from.
2795
+ */
2796
+ private isLayerRestricted(layer: LayerProps): boolean {
2797
+ const rt: unknown = layer.restrictionTimes;
2798
+ if (!rt) {
2799
+ return false;
2800
+ }
2801
+ const anyRt: {
2802
+ restictionType?: RestrictionType | string;
2803
+ value?: { restictionType?: RestrictionType | string };
2804
+ } = rt as {
1516
2805
  restictionType?: RestrictionType | string;
1517
2806
  value?: { restictionType?: RestrictionType | string };
1518
2807
  };
@@ -1562,6 +2851,48 @@ export class Service extends DatabaseService<OnCallDutyPolicySchedule> {
1562
2851
  return windowStart;
1563
2852
  }
1564
2853
 
2854
+ /*
2855
+ * Mirror of computeResolutionWindowStart, stepping FORWARDS: the latest of
2856
+ * `from` plus `periodsForward` rotation periods across the layers (at least
2857
+ * a day). The calendar-feed resolver widens its expansion this far past the
2858
+ * requested window so the last overlapping segment carries its true end
2859
+ * instead of the engine's clamp to the window edge.
2860
+ */
2861
+ private computeResolutionWindowEndByPeriods(
2862
+ layerProps: Array<LayerProps>,
2863
+ from: Date,
2864
+ periodsForward: number,
2865
+ ): Date {
2866
+ let windowEnd: Date = OneUptimeDate.addRemoveDays(from, 1);
2867
+
2868
+ for (const layer of layerProps) {
2869
+ if (!layer.rotation) {
2870
+ continue;
2871
+ }
2872
+
2873
+ let recurring: Recurring;
2874
+ try {
2875
+ recurring =
2876
+ layer.rotation instanceof Recurring
2877
+ ? layer.rotation
2878
+ : Recurring.fromJSON(layer.rotation as any);
2879
+ } catch {
2880
+ continue;
2881
+ }
2882
+
2883
+ let candidate: Date = from;
2884
+ for (let i: number = 0; i < periodsForward; i++) {
2885
+ candidate = Recurring.getNextDateInterval(candidate, recurring);
2886
+ }
2887
+
2888
+ if (OneUptimeDate.isAfter(candidate, windowEnd)) {
2889
+ windowEnd = candidate;
2890
+ }
2891
+ }
2892
+
2893
+ return windowEnd;
2894
+ }
2895
+
1565
2896
  /*
1566
2897
  * Recover the TRUE start of the current on-call coverage window for
1567
2898
  * `currentUserId`. getEventByIndexInSchedule always expands from `now`, and