@oneuptime/common 11.3.26 → 11.4.0

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 (298) hide show
  1. package/Models/DatabaseModels/AIRun.ts +50 -0
  2. package/Models/DatabaseModels/IncidentEpisodeRoleMember.ts +1 -1
  3. package/Models/DatabaseModels/IncidentMember.ts +1 -1
  4. package/Models/DatabaseModels/IncomingCallPolicy.ts +13 -0
  5. package/Models/DatabaseModels/Index.ts +3 -0
  6. package/Models/DatabaseModels/MigrationFailure.ts +170 -0
  7. package/Models/DatabaseModels/OnCallDutyPolicyExecutionLog.ts +51 -2
  8. package/Models/DatabaseModels/OnCallDutyPolicySchedule.ts +44 -0
  9. package/Models/DatabaseModels/OnCallDutyPolicyScheduleLayer.ts +6 -0
  10. package/Models/DatabaseModels/OnCallDutyPolicyScheduleLayerUser.ts +6 -0
  11. package/Models/DatabaseModels/OnCallDutyPolicyTimeLog.ts +1 -1
  12. package/Models/DatabaseModels/OnCallDutyPolicyUserOverride.ts +2 -2
  13. package/Models/DatabaseModels/Project.ts +60 -0
  14. package/Server/API/AIInvestigationAPI.ts +148 -0
  15. package/Server/API/AlertAPI.ts +1 -1
  16. package/Server/API/IncidentAPI.ts +2 -2
  17. package/Server/API/IncidentEpisodeAPI.ts +1 -1
  18. package/Server/API/LlmProviderAPI.ts +1 -0
  19. package/Server/API/ScheduledMaintenanceAPI.ts +1 -1
  20. package/Server/Infrastructure/Postgres/SchemaMigrations/1783470000000-AddIncomingCallPolicyRoutingPhoneNumberUnique.ts +25 -0
  21. package/Server/Infrastructure/Postgres/SchemaMigrations/1783510935686-FixNotNullForeignKeysOnDelete.ts +81 -0
  22. package/Server/Infrastructure/Postgres/SchemaMigrations/1783515836148-MigrationName.ts +25 -0
  23. package/Server/Infrastructure/Postgres/SchemaMigrations/1783523076215-AddMigrationFailureTable.ts +35 -0
  24. package/Server/Infrastructure/Postgres/SchemaMigrations/1783598145111-AddEnableAutomaticIncidentInvestigationToProject.ts +19 -0
  25. package/Server/Infrastructure/Postgres/SchemaMigrations/1783615884106-AddInvestigationSubjectToAIRun.ts +33 -0
  26. package/Server/Infrastructure/Postgres/SchemaMigrations/1783632447975-AddEnableAutomaticAlertInvestigationToProject.ts +19 -0
  27. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +14 -0
  28. package/Server/Infrastructure/PostgresDatabase.ts +11 -0
  29. package/Server/Services/AIService.ts +74 -2
  30. package/Server/Services/AlertService.ts +25 -0
  31. package/Server/Services/AnalyticsDatabaseService.ts +10 -1
  32. package/Server/Services/DatabaseService.ts +10 -1
  33. package/Server/Services/IncidentService.ts +26 -1
  34. package/Server/Services/IncidentStateTimelineService.ts +19 -0
  35. package/Server/Services/IncomingCallPolicyEscalationRuleService.ts +75 -2
  36. package/Server/Services/IncomingCallPolicyService.ts +42 -1
  37. package/Server/Services/Index.ts +2 -0
  38. package/Server/Services/MigrationFailureService.ts +9 -0
  39. package/Server/Services/MonitorProbeService.ts +33 -7
  40. package/Server/Services/MonitorService.ts +19 -7
  41. package/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.ts +65 -0
  42. package/Server/Services/OnCallDutyPolicyEscalationRuleService.ts +185 -41
  43. package/Server/Services/OnCallDutyPolicyEscalationRuleUserService.ts +23 -15
  44. package/Server/Services/OnCallDutyPolicyExecutionLogService.ts +39 -0
  45. package/Server/Services/OnCallDutyPolicyScheduleLayerService.ts +148 -0
  46. package/Server/Services/OnCallDutyPolicyScheduleLayerUserService.ts +11 -2
  47. package/Server/Services/OnCallDutyPolicyScheduleService.ts +502 -26
  48. package/Server/Services/OnCallDutyPolicyTimeLogService.ts +49 -7
  49. package/Server/Services/OnCallDutyPolicyUserOverrideService.ts +218 -4
  50. package/Server/Services/ProjectCallSMSConfigService.ts +82 -1
  51. package/Server/Services/TeamMemberService.ts +6 -0
  52. package/Server/Services/UserNotificationRuleService.ts +13 -42
  53. package/Server/Services/UserOnCallLogService.ts +90 -0
  54. package/Server/Services/UserOnCallLogTimelineService.ts +43 -1
  55. package/Server/Types/Database/QueryUtil.ts +57 -0
  56. package/Server/Utils/AI/AlertAIContextBuilder.ts +6 -1
  57. package/Server/Utils/AI/Chat/ChatAgentRunner.ts +14 -2
  58. package/Server/Utils/AI/Chat/ObservabilityAssistant.ts +119 -11
  59. package/Server/Utils/AI/IncidentAIContextBuilder.ts +18 -3
  60. package/Server/Utils/AI/IncidentEpisodeAIContextBuilder.ts +6 -1
  61. package/Server/Utils/AI/ScheduledMaintenanceAIContextBuilder.ts +12 -2
  62. package/Server/Utils/AI/Sentinel/AlertInvestigationRunner.ts +141 -0
  63. package/Server/Utils/AI/Sentinel/IncidentInvestigationRunner.ts +178 -0
  64. package/Server/Utils/AI/Sentinel/IncidentPostmortemRunner.ts +105 -0
  65. package/Server/Utils/AI/Sentinel/SentinelInvestigationEngine.ts +405 -0
  66. package/Server/Utils/AI/Sentinel/SentinelMemory.ts +167 -0
  67. package/Server/Utils/AI/Toolbox/IncidentTools.ts +183 -1
  68. package/Server/Utils/AI/Toolbox/Index.ts +15 -1
  69. package/Server/Utils/AI/Toolbox/RecentChangesTools.ts +208 -0
  70. package/Server/Utils/AI/Toolbox/SentinelActionTools.ts +577 -0
  71. package/Server/Utils/AI/Toolbox/Serializer.ts +42 -0
  72. package/Server/Utils/Database/MigrationFailureLog.ts +240 -0
  73. package/Server/Utils/IncomingCallPhoneNumber.ts +41 -0
  74. package/Server/Utils/LLM/LLMService.ts +110 -11
  75. package/Server/Utils/Telemetry/IoTSnapshotScan.ts +12 -9
  76. package/Server/Utils/Telemetry/ProxmoxCephSnapshotScan.ts +12 -9
  77. package/Tests/Server/API/BaseAPI.test.ts +1 -0
  78. package/Tests/Server/Services/AnalyticsDatabaseService.test.ts +0 -8
  79. package/Tests/Server/Services/OnCallDutyPolicyScheduleLayerUserReorder.test.ts +233 -0
  80. package/Tests/Server/Services/OnCallDutyPolicyTimeLogServiceScoping.test.ts +242 -0
  81. package/Tests/Server/Services/OnCallDutyPolicyUserOverrideEdit.test.ts +198 -0
  82. package/Tests/Server/Services/UserOnCallLogClaimNotificationRule.test.ts +495 -0
  83. package/Tests/Server/Types/Database/QueryUtil.test.ts +74 -0
  84. package/Tests/Server/Utils/AI/LLMServiceToolCalling.test.ts +78 -0
  85. package/Tests/Server/Utils/AI/ToolResultSerializer.test.ts +68 -0
  86. package/Tests/Types/DateExhaustiveTimezone.test.ts +700 -0
  87. package/Tests/Types/DateTimezoneWallClock.test.ts +99 -0
  88. package/Tests/Types/Events/Recurring.test.ts +22 -9
  89. package/Tests/Types/OnCallDutyPolicy/LayerUtilAuditFixes.test.ts +291 -0
  90. package/Tests/Types/OnCallDutyPolicy/LayerUtilAuditFixesRound2.test.ts +398 -0
  91. package/Tests/Types/OnCallDutyPolicy/LayerUtilDSTWeekendGap.test.ts +114 -0
  92. package/Tests/Types/OnCallDutyPolicy/LayerUtilDSTWeeklyDayShift.test.ts +76 -0
  93. package/Tests/Types/OnCallDutyPolicy/LayerUtilDailyMutation.test.ts +128 -0
  94. package/Tests/Types/OnCallDutyPolicy/LayerUtilDailyProbe.test.ts +203 -0
  95. package/Tests/Types/OnCallDutyPolicy/LayerUtilDiffFuzz.test.ts +322 -0
  96. package/Tests/Types/OnCallDutyPolicy/LayerUtilEdgeCases.test.ts +280 -0
  97. package/Tests/Types/OnCallDutyPolicy/LayerUtilExhaustiveCurrentUser.test.ts +863 -0
  98. package/Tests/Types/OnCallDutyPolicy/LayerUtilExhaustiveDaily.test.ts +900 -0
  99. package/Tests/Types/OnCallDutyPolicy/LayerUtilExhaustiveMultiLayer.test.ts +1104 -0
  100. package/Tests/Types/OnCallDutyPolicy/LayerUtilExhaustiveRotation.test.ts +957 -0
  101. package/Tests/Types/OnCallDutyPolicy/LayerUtilExhaustiveWeekly.test.ts +1095 -0
  102. package/Tests/Types/OnCallDutyPolicy/LayerUtilInvariants.test.ts +232 -0
  103. package/Tests/Types/OnCallDutyPolicy/LayerUtilMergeAudit.test.ts +227 -0
  104. package/Tests/Types/OnCallDutyPolicy/LayerUtilMonthYearAudit.test.ts +255 -0
  105. package/Tests/Types/OnCallDutyPolicy/LayerUtilMultiLayerAudit.test.ts +388 -0
  106. package/Tests/Types/OnCallDutyPolicy/LayerUtilMultiLayerEdge.test.ts +300 -0
  107. package/Tests/Types/OnCallDutyPolicy/LayerUtilOvernightDSTAudit.test.ts +158 -0
  108. package/Tests/Types/OnCallDutyPolicy/LayerUtilRestrictedGapFix.test.ts +253 -0
  109. package/Tests/Types/OnCallDutyPolicy/LayerUtilRestrictedGapRepro.test.ts +157 -0
  110. package/Tests/Types/OnCallDutyPolicy/LayerUtilRotationFixes.test.ts +414 -0
  111. package/Tests/Types/OnCallDutyPolicy/LayerUtilTimezone.test.ts +243 -0
  112. package/Tests/Types/OnCallDutyPolicy/LayerUtilWeekendGapRepro.test.ts +112 -0
  113. package/Tests/Types/OnCallDutyPolicy/OverrideLensAudit.test.ts +117 -0
  114. package/Tests/Types/OnCallDutyPolicy/RestrictionTimes.test.ts +8 -6
  115. package/Tests/Types/OnCallDutyPolicy/RestrictionTimesDefaultDayAudit.test.ts +112 -0
  116. package/Tests/Types/OnCallDutyPolicy/RestrictionTimesExhaustive.test.ts +821 -0
  117. package/Tests/Types/OnCallDutyPolicy/RestrictionTimesMutationInvariant.test.ts +886 -0
  118. package/Tests/Types/OnCallDutyPolicy/ScheduleShiftUtil.test.ts +260 -0
  119. package/Tests/Types/OnCallDutyPolicy/UserOverrideUtil.test.ts +255 -0
  120. package/Tests/Types/OnCallDutyPolicy/UserOverrideUtilExhaustive.test.ts +1126 -0
  121. package/Types/Date.ts +246 -15
  122. package/Types/OnCallDutyPolicy/Layer.ts +790 -260
  123. package/Types/OnCallDutyPolicy/RestrictionTimes.ts +39 -13
  124. package/Types/OnCallDutyPolicy/ScheduleShiftUtil.ts +257 -0
  125. package/Types/OnCallDutyPolicy/UserOverrideUtil.ts +21 -5
  126. package/UI/Components/Calendar/Calendar.tsx +8 -2
  127. package/UI/Components/Charts/ChartGroup/ChartGroup.tsx +5 -5
  128. package/UI/Components/Events/RecurringFieldElement.tsx +16 -0
  129. package/UI/Components/ModelFormModal/ModelFormModal.tsx +8 -0
  130. package/UI/Components/RadioButtons/BasicRadioButtons.tsx +1 -1
  131. package/build/dist/Models/DatabaseModels/AIRun.js +52 -0
  132. package/build/dist/Models/DatabaseModels/AIRun.js.map +1 -1
  133. package/build/dist/Models/DatabaseModels/IncidentEpisodeRoleMember.js +1 -1
  134. package/build/dist/Models/DatabaseModels/IncidentEpisodeRoleMember.js.map +1 -1
  135. package/build/dist/Models/DatabaseModels/IncidentMember.js +1 -1
  136. package/build/dist/Models/DatabaseModels/IncidentMember.js.map +1 -1
  137. package/build/dist/Models/DatabaseModels/IncomingCallPolicy.js +10 -0
  138. package/build/dist/Models/DatabaseModels/IncomingCallPolicy.js.map +1 -1
  139. package/build/dist/Models/DatabaseModels/Index.js +2 -0
  140. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  141. package/build/dist/Models/DatabaseModels/MigrationFailure.js +196 -0
  142. package/build/dist/Models/DatabaseModels/MigrationFailure.js.map +1 -0
  143. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyExecutionLog.js +52 -2
  144. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyExecutionLog.js.map +1 -1
  145. package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js +45 -0
  146. package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js.map +1 -1
  147. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleLayer.js +6 -0
  148. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleLayer.js.map +1 -1
  149. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleLayerUser.js +6 -0
  150. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleLayerUser.js.map +1 -1
  151. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyTimeLog.js +1 -1
  152. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyTimeLog.js.map +1 -1
  153. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyUserOverride.js +2 -2
  154. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyUserOverride.js.map +1 -1
  155. package/build/dist/Models/DatabaseModels/Project.js +62 -0
  156. package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
  157. package/build/dist/Server/API/AIInvestigationAPI.js +107 -0
  158. package/build/dist/Server/API/AIInvestigationAPI.js.map +1 -0
  159. package/build/dist/Server/API/AlertAPI.js +1 -1
  160. package/build/dist/Server/API/IncidentAPI.js +2 -2
  161. package/build/dist/Server/API/IncidentEpisodeAPI.js +1 -1
  162. package/build/dist/Server/API/LlmProviderAPI.js +1 -0
  163. package/build/dist/Server/API/LlmProviderAPI.js.map +1 -1
  164. package/build/dist/Server/API/ScheduledMaintenanceAPI.js +1 -1
  165. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783470000000-AddIncomingCallPolicyRoutingPhoneNumberUnique.js +18 -0
  166. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783470000000-AddIncomingCallPolicyRoutingPhoneNumberUnique.js.map +1 -0
  167. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783510935686-FixNotNullForeignKeysOnDelete.js +38 -0
  168. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783510935686-FixNotNullForeignKeysOnDelete.js.map +1 -0
  169. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783515836148-MigrationName.js +16 -0
  170. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783515836148-MigrationName.js.map +1 -0
  171. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783523076215-AddMigrationFailureTable.js +18 -0
  172. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783523076215-AddMigrationFailureTable.js.map +1 -0
  173. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783598145111-AddEnableAutomaticIncidentInvestigationToProject.js +12 -0
  174. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783598145111-AddEnableAutomaticIncidentInvestigationToProject.js.map +1 -0
  175. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783615884106-AddInvestigationSubjectToAIRun.js +18 -0
  176. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783615884106-AddInvestigationSubjectToAIRun.js.map +1 -0
  177. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783632447975-AddEnableAutomaticAlertInvestigationToProject.js +12 -0
  178. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783632447975-AddEnableAutomaticAlertInvestigationToProject.js.map +1 -0
  179. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +14 -0
  180. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  181. package/build/dist/Server/Infrastructure/PostgresDatabase.js +9 -0
  182. package/build/dist/Server/Infrastructure/PostgresDatabase.js.map +1 -1
  183. package/build/dist/Server/Services/AIService.js +53 -2
  184. package/build/dist/Server/Services/AIService.js.map +1 -1
  185. package/build/dist/Server/Services/AlertService.js +24 -0
  186. package/build/dist/Server/Services/AlertService.js.map +1 -1
  187. package/build/dist/Server/Services/AnalyticsDatabaseService.js +10 -1
  188. package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
  189. package/build/dist/Server/Services/DatabaseService.js +10 -1
  190. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  191. package/build/dist/Server/Services/IncidentService.js +25 -1
  192. package/build/dist/Server/Services/IncidentService.js.map +1 -1
  193. package/build/dist/Server/Services/IncidentStateTimelineService.js +19 -0
  194. package/build/dist/Server/Services/IncidentStateTimelineService.js.map +1 -1
  195. package/build/dist/Server/Services/IncomingCallPolicyEscalationRuleService.js +55 -2
  196. package/build/dist/Server/Services/IncomingCallPolicyEscalationRuleService.js.map +1 -1
  197. package/build/dist/Server/Services/IncomingCallPolicyService.js +40 -0
  198. package/build/dist/Server/Services/IncomingCallPolicyService.js.map +1 -1
  199. package/build/dist/Server/Services/Index.js +2 -0
  200. package/build/dist/Server/Services/Index.js.map +1 -1
  201. package/build/dist/Server/Services/MigrationFailureService.js +9 -0
  202. package/build/dist/Server/Services/MigrationFailureService.js.map +1 -0
  203. package/build/dist/Server/Services/MonitorProbeService.js +33 -7
  204. package/build/dist/Server/Services/MonitorProbeService.js.map +1 -1
  205. package/build/dist/Server/Services/MonitorService.js +12 -5
  206. package/build/dist/Server/Services/MonitorService.js.map +1 -1
  207. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js +81 -22
  208. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js.map +1 -1
  209. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleService.js +134 -31
  210. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleService.js.map +1 -1
  211. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleUserService.js +21 -13
  212. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleUserService.js.map +1 -1
  213. package/build/dist/Server/Services/OnCallDutyPolicyExecutionLogService.js +36 -0
  214. package/build/dist/Server/Services/OnCallDutyPolicyExecutionLogService.js.map +1 -1
  215. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerService.js +121 -0
  216. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerService.js.map +1 -1
  217. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerUserService.js +11 -2
  218. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerUserService.js.map +1 -1
  219. package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js +409 -36
  220. package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js.map +1 -1
  221. package/build/dist/Server/Services/OnCallDutyPolicyTimeLogService.js +52 -8
  222. package/build/dist/Server/Services/OnCallDutyPolicyTimeLogService.js.map +1 -1
  223. package/build/dist/Server/Services/OnCallDutyPolicyUserOverrideService.js +170 -3
  224. package/build/dist/Server/Services/OnCallDutyPolicyUserOverrideService.js.map +1 -1
  225. package/build/dist/Server/Services/ProjectCallSMSConfigService.js +72 -0
  226. package/build/dist/Server/Services/ProjectCallSMSConfigService.js.map +1 -1
  227. package/build/dist/Server/Services/TeamMemberService.js +6 -0
  228. package/build/dist/Server/Services/TeamMemberService.js.map +1 -1
  229. package/build/dist/Server/Services/UserNotificationRuleService.js +12 -30
  230. package/build/dist/Server/Services/UserNotificationRuleService.js.map +1 -1
  231. package/build/dist/Server/Services/UserOnCallLogService.js +75 -0
  232. package/build/dist/Server/Services/UserOnCallLogService.js.map +1 -1
  233. package/build/dist/Server/Services/UserOnCallLogTimelineService.js +31 -1
  234. package/build/dist/Server/Services/UserOnCallLogTimelineService.js.map +1 -1
  235. package/build/dist/Server/Types/Database/QueryUtil.js +45 -0
  236. package/build/dist/Server/Types/Database/QueryUtil.js.map +1 -1
  237. package/build/dist/Server/Utils/AI/AlertAIContextBuilder.js +6 -1
  238. package/build/dist/Server/Utils/AI/AlertAIContextBuilder.js.map +1 -1
  239. package/build/dist/Server/Utils/AI/Chat/ChatAgentRunner.js +13 -2
  240. package/build/dist/Server/Utils/AI/Chat/ChatAgentRunner.js.map +1 -1
  241. package/build/dist/Server/Utils/AI/Chat/ObservabilityAssistant.js +59 -11
  242. package/build/dist/Server/Utils/AI/Chat/ObservabilityAssistant.js.map +1 -1
  243. package/build/dist/Server/Utils/AI/IncidentAIContextBuilder.js +18 -3
  244. package/build/dist/Server/Utils/AI/IncidentAIContextBuilder.js.map +1 -1
  245. package/build/dist/Server/Utils/AI/IncidentEpisodeAIContextBuilder.js +6 -1
  246. package/build/dist/Server/Utils/AI/IncidentEpisodeAIContextBuilder.js.map +1 -1
  247. package/build/dist/Server/Utils/AI/ScheduledMaintenanceAIContextBuilder.js +12 -2
  248. package/build/dist/Server/Utils/AI/ScheduledMaintenanceAIContextBuilder.js.map +1 -1
  249. package/build/dist/Server/Utils/AI/Sentinel/AlertInvestigationRunner.js +119 -0
  250. package/build/dist/Server/Utils/AI/Sentinel/AlertInvestigationRunner.js.map +1 -0
  251. package/build/dist/Server/Utils/AI/Sentinel/IncidentInvestigationRunner.js +147 -0
  252. package/build/dist/Server/Utils/AI/Sentinel/IncidentInvestigationRunner.js.map +1 -0
  253. package/build/dist/Server/Utils/AI/Sentinel/IncidentPostmortemRunner.js +93 -0
  254. package/build/dist/Server/Utils/AI/Sentinel/IncidentPostmortemRunner.js.map +1 -0
  255. package/build/dist/Server/Utils/AI/Sentinel/SentinelInvestigationEngine.js +311 -0
  256. package/build/dist/Server/Utils/AI/Sentinel/SentinelInvestigationEngine.js.map +1 -0
  257. package/build/dist/Server/Utils/AI/Sentinel/SentinelMemory.js +143 -0
  258. package/build/dist/Server/Utils/AI/Sentinel/SentinelMemory.js.map +1 -0
  259. package/build/dist/Server/Utils/AI/Toolbox/IncidentTools.js +152 -1
  260. package/build/dist/Server/Utils/AI/Toolbox/IncidentTools.js.map +1 -1
  261. package/build/dist/Server/Utils/AI/Toolbox/Index.js +10 -1
  262. package/build/dist/Server/Utils/AI/Toolbox/Index.js.map +1 -1
  263. package/build/dist/Server/Utils/AI/Toolbox/RecentChangesTools.js +165 -0
  264. package/build/dist/Server/Utils/AI/Toolbox/RecentChangesTools.js.map +1 -0
  265. package/build/dist/Server/Utils/AI/Toolbox/SentinelActionTools.js +451 -0
  266. package/build/dist/Server/Utils/AI/Toolbox/SentinelActionTools.js.map +1 -0
  267. package/build/dist/Server/Utils/AI/Toolbox/Serializer.js +40 -0
  268. package/build/dist/Server/Utils/AI/Toolbox/Serializer.js.map +1 -1
  269. package/build/dist/Server/Utils/Database/MigrationFailureLog.js +174 -0
  270. package/build/dist/Server/Utils/Database/MigrationFailureLog.js.map +1 -0
  271. package/build/dist/Server/Utils/IncomingCallPhoneNumber.js +30 -0
  272. package/build/dist/Server/Utils/IncomingCallPhoneNumber.js.map +1 -0
  273. package/build/dist/Server/Utils/LLM/LLMService.js +81 -5
  274. package/build/dist/Server/Utils/LLM/LLMService.js.map +1 -1
  275. package/build/dist/Server/Utils/Telemetry/IoTSnapshotScan.js +11 -8
  276. package/build/dist/Server/Utils/Telemetry/IoTSnapshotScan.js.map +1 -1
  277. package/build/dist/Server/Utils/Telemetry/ProxmoxCephSnapshotScan.js +11 -8
  278. package/build/dist/Server/Utils/Telemetry/ProxmoxCephSnapshotScan.js.map +1 -1
  279. package/build/dist/Types/Date.js +185 -15
  280. package/build/dist/Types/Date.js.map +1 -1
  281. package/build/dist/Types/OnCallDutyPolicy/Layer.js +602 -171
  282. package/build/dist/Types/OnCallDutyPolicy/Layer.js.map +1 -1
  283. package/build/dist/Types/OnCallDutyPolicy/RestrictionTimes.js +27 -13
  284. package/build/dist/Types/OnCallDutyPolicy/RestrictionTimes.js.map +1 -1
  285. package/build/dist/Types/OnCallDutyPolicy/ScheduleShiftUtil.js +159 -0
  286. package/build/dist/Types/OnCallDutyPolicy/ScheduleShiftUtil.js.map +1 -0
  287. package/build/dist/Types/OnCallDutyPolicy/UserOverrideUtil.js +20 -5
  288. package/build/dist/Types/OnCallDutyPolicy/UserOverrideUtil.js.map +1 -1
  289. package/build/dist/UI/Components/Calendar/Calendar.js +2 -2
  290. package/build/dist/UI/Components/Calendar/Calendar.js.map +1 -1
  291. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js +2 -2
  292. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js.map +1 -1
  293. package/build/dist/UI/Components/Events/RecurringFieldElement.js +13 -0
  294. package/build/dist/UI/Components/Events/RecurringFieldElement.js.map +1 -1
  295. package/build/dist/UI/Components/ModelFormModal/ModelFormModal.js.map +1 -1
  296. package/build/dist/UI/Components/RadioButtons/BasicRadioButtons.js +1 -1
  297. package/build/dist/UI/Components/RadioButtons/BasicRadioButtons.js.map +1 -1
  298. package/package.json +1 -1
@@ -4,12 +4,23 @@ import Recurring from "../Events/Recurring";
4
4
  import Typeof from "../Typeof";
5
5
  import RestrictionTimes, { RestrictionType, } from "./RestrictionTimes";
6
6
  export default class LayerUtil {
7
+ constructor() {
8
+ /*
9
+ * The timezone of the layer currently being expanded. Set at the start of
10
+ * getEvents and read by the restriction-trimming helpers so wall-clock
11
+ * restriction windows resolve in the schedule's zone. undefined => local
12
+ * time (legacy behavior). getEvents runs synchronously, so this per-call
13
+ * field is not subject to interleaving.
14
+ */
15
+ this.timezone = undefined;
16
+ }
7
17
  getEvents(data, options) {
8
18
  let events = [];
9
19
  if (!this.isDataValid(data)) {
10
20
  return [];
11
21
  }
12
22
  data = this.sanitizeData(data);
23
+ this.timezone = data.timezone;
13
24
  let start = data.calendarStartDate;
14
25
  const end = data.calendarEndDate;
15
26
  // start time of the layer is after the start time of the calendar, so we need to update the start time of the calendar
@@ -28,7 +39,7 @@ export default class LayerUtil {
28
39
  let currentEventStartTime = start;
29
40
  // bring handoff time to the same day as the currentStartTime.
30
41
  // before we do this, we need to update the user index.
31
- currentUserIndex = this.getCurrentUserIndexBasedOnHandoffTime({
42
+ const currentUserResolution = this.getCurrentUserIndexBasedOnHandoffTime({
32
43
  rotation,
33
44
  handOffTime,
34
45
  currentUserIndex,
@@ -37,6 +48,16 @@ export default class LayerUtil {
37
48
  currentEventStartTime,
38
49
  restrictionTimes: data.restrictionTimes,
39
50
  });
51
+ currentUserIndex = currentUserResolution.currentUserIndex;
52
+ /*
53
+ * True (un-clamped) start of the first rotation period we are about to
54
+ * expand. When the calendar window starts partway through a period (the
55
+ * live "who is on call now" path always starts its window at the current
56
+ * instant), currentEventStartTime is clamped to that instant. The advance
57
+ * guard in the loop below uses this to decide whether the first period
58
+ * consumed a rotation turn based on its FULL-span coverage (audit F2).
59
+ */
60
+ const firstPeriodTrueStart = currentUserResolution.currentPeriodStart;
40
61
  // update handoff time to the same day as current start time
41
62
  handOffTime = this.moveHandsOffTimeAfterCurrentEventStartTime({
42
63
  handOffTime,
@@ -57,15 +78,40 @@ export default class LayerUtil {
57
78
  ];
58
79
  return events;
59
80
  }
60
- // break clause. This loop executes 50 times at max.
61
- const maxLoopCount = 100;
81
+ /*
82
+ * Bound the loop by the actual calendar window instead of a fixed count.
83
+ * Each iteration advances currentEventStartTime by at least one rotation
84
+ * period (fully-restricted periods produce no event but still advance the
85
+ * handoff), so at most ~windowUnits/periodUnits periods can fall inside
86
+ * [start, end]. A fixed cap of 100 silently truncated long windows for short
87
+ * rotations (audit F1) and could even return ZERO events — the schedule
88
+ * reporting nobody on-call and no next user — when "now" sat in a restriction
89
+ * gap longer than 100 periods (audit F8, e.g. an hourly rotation with a
90
+ * weekend-only restriction). Scale the cap to the window with a generous
91
+ * margin, keeping a hard ceiling to bound pathological inputs.
92
+ */
93
+ const rawRotationCount = rotation.intervalCount.toNumber();
94
+ const periodUnitsForBound = Number.isFinite(rawRotationCount) && rawRotationCount >= 1
95
+ ? Math.floor(rawRotationCount)
96
+ : 1;
97
+ const windowUnits = this.getUnitsBetweenDates(start, end, rotation.intervalType);
98
+ const maxLoopCount = Math.min(1000000, Math.max(100, Math.ceil(windowUnits / periodUnitsForBound) + 10));
62
99
  let loopCount = 0;
100
+ /*
101
+ * The first loop iteration expands the rotation period that CONTAINS the
102
+ * window start; its currentEventStartTime may be clamped to the window
103
+ * start (now) rather than the true period start. Tracked so the rotation
104
+ * advance can be decided against the period's full span (audit F2).
105
+ */
106
+ let isFirstPeriod = true;
63
107
  while (!hasReachedTheEndOfTheCalendar) {
64
108
  loopCount++;
65
109
  if (loopCount > maxLoopCount) {
66
110
  break;
67
111
  }
68
112
  currentEventEndTime = handOffTime;
113
+ // The rotation boundary that ends this period, before any clamp to `end`.
114
+ const periodBoundaryEnd = handOffTime;
69
115
  // if current event start time and end time is the same then increase current event start time by 1 second.
70
116
  if (OneUptimeDate.isSame(currentEventStartTime, currentEventEndTime)) {
71
117
  currentEventStartTime = OneUptimeDate.addRemoveSeconds(currentEventEndTime, 1);
@@ -87,10 +133,15 @@ export default class LayerUtil {
87
133
  eventEndTime: currentEventEndTime,
88
134
  restrictionTimes: data.restrictionTimes,
89
135
  });
90
- events = [
91
- ...events,
92
- ...this.getCalendarEventsFromStartAndEndDates(trimmedStartAndEndTimes, data.users, currentUserIndex),
93
- ];
136
+ /*
137
+ * push() instead of rebuilding the array with [...events, ...new] every
138
+ * iteration. The spread reallocated and copied the whole accumulated array
139
+ * each period — O(n^2) over the loop — which, combined with a window sized
140
+ * to a slow layer, made a fast (e.g. hourly) layer's expansion quadratic
141
+ * (audit H2). Each period contributes only a handful of segments, so the
142
+ * spread of the small per-period array as push args is safe.
143
+ */
144
+ events.push(...this.getCalendarEventsFromStartAndEndDates(trimmedStartAndEndTimes, data.users, currentUserIndex));
94
145
  if ((options === null || options === void 0 ? void 0 : options.getNumberOfEvents) !== undefined) {
95
146
  if (events.length >= options.getNumberOfEvents) {
96
147
  return events;
@@ -105,15 +156,41 @@ export default class LayerUtil {
105
156
  rotation: data.rotation,
106
157
  });
107
158
  /*
108
- * Only advance the rotation if at least one event was actually generated
109
- * for this rotation period. Otherwise the user "lost" their turn to a
110
- * fully restricted window (e.g. a weekend with Mon-Fri restrictions),
111
- * which would skip rotations and break ordering across the gap. See
112
- * issue #2413.
159
+ * Only advance the rotation if this rotation period actually produced
160
+ * coverage. Otherwise the user "lost" their turn to a fully restricted
161
+ * window (e.g. a weekend with Mon-Fri restrictions), which would skip
162
+ * rotations and break ordering across the gap. See issue #2413.
113
163
  */
114
- if (trimmedStartAndEndTimes.length > 0) {
164
+ let periodProducedCoverage = trimmedStartAndEndTimes.length > 0;
165
+ /*
166
+ * First-period correction (audit F2): when the window starts partway
167
+ * through the current period AND begins after that period's restriction
168
+ * window has already closed (the live roster refresh resolving in a
169
+ * daily/weekend off-hours gap), the clamped [now, periodEnd] slice trims
170
+ * to nothing even though the period DID have coverage earlier. Deciding
171
+ * the advance on that empty clamped slice carried the current user into
172
+ * the next period, so every subsequent shift resolved one user off from
173
+ * the calendar/full expansion — paging/notifying the wrong "next" user.
174
+ * Re-evaluate the advance against the period's FULL span so a
175
+ * partially-elapsed period still consumes its rotation turn, while a
176
+ * genuinely fully-restricted period (full-span trim also empty) still
177
+ * correctly skips its turn and preserves the #2413 behavior.
178
+ */
179
+ if (isFirstPeriod &&
180
+ !periodProducedCoverage &&
181
+ data.restrictionTimes &&
182
+ data.restrictionTimes.restictionType !== RestrictionType.None) {
183
+ const fullSpanTrim = this.trimStartAndEndTimesBasedOnRestrictionTimes({
184
+ eventStartTime: firstPeriodTrueStart,
185
+ eventEndTime: periodBoundaryEnd,
186
+ restrictionTimes: data.restrictionTimes,
187
+ });
188
+ periodProducedCoverage = fullSpanTrim.length > 0;
189
+ }
190
+ if (periodProducedCoverage) {
115
191
  currentUserIndex = this.incrementUserIndex(currentUserIndex, data.users.length);
116
192
  }
193
+ isFirstPeriod = false;
117
194
  }
118
195
  // increment ids of all the events and return them, to make sure they are unique
119
196
  let id = 1;
@@ -169,105 +246,150 @@ export default class LayerUtil {
169
246
  if (OneUptimeDate.isAfter(data.handOffTime, data.currentEventStartTime)) {
170
247
  return data.handOffTime;
171
248
  }
172
- let handOffTime = data.handOffTime;
173
- let intervalBetweenStartTimeAndHandoffTime = 0;
174
- const rotationInterval = data.rotation.intervalCount.toNumber();
175
- if (data.rotation.intervalType === EventInterval.Day) {
176
- intervalBetweenStartTimeAndHandoffTime =
177
- OneUptimeDate.getDaysBetweenTwoDatesInclusive(handOffTime, data.currentEventStartTime);
178
- if (intervalBetweenStartTimeAndHandoffTime < rotationInterval) {
179
- intervalBetweenStartTimeAndHandoffTime = rotationInterval;
180
- }
181
- else if (intervalBetweenStartTimeAndHandoffTime % rotationInterval !==
182
- 0) {
183
- intervalBetweenStartTimeAndHandoffTime += rotationInterval;
184
- }
185
- // add intervalBetweenStartTimeAndHandoffTime to handoff time
186
- handOffTime = OneUptimeDate.addRemoveDays(handOffTime, intervalBetweenStartTimeAndHandoffTime);
187
- if (OneUptimeDate.isOnOrBefore(handOffTime, data.currentEventStartTime)) {
188
- handOffTime = OneUptimeDate.addRemoveDays(handOffTime, 1);
189
- }
190
- return handOffTime;
191
- }
192
- if (data.rotation.intervalType === EventInterval.Hour) {
193
- intervalBetweenStartTimeAndHandoffTime =
194
- OneUptimeDate.getHoursBetweenTwoDatesInclusive(handOffTime, data.currentEventStartTime);
195
- if (intervalBetweenStartTimeAndHandoffTime < rotationInterval) {
196
- intervalBetweenStartTimeAndHandoffTime = rotationInterval;
197
- }
198
- else if (intervalBetweenStartTimeAndHandoffTime % rotationInterval !==
199
- 0) {
200
- intervalBetweenStartTimeAndHandoffTime += rotationInterval;
201
- }
202
- // add intervalBetweenStartTimeAndHandoffTime to handoff time
203
- handOffTime = OneUptimeDate.addRemoveHours(handOffTime, intervalBetweenStartTimeAndHandoffTime);
204
- if (OneUptimeDate.isOnOrBefore(handOffTime, data.currentEventStartTime)) {
205
- handOffTime = OneUptimeDate.addRemoveHours(handOffTime, 1);
206
- }
207
- return handOffTime;
208
- }
209
- if (data.rotation.intervalType === EventInterval.Week) {
210
- intervalBetweenStartTimeAndHandoffTime =
211
- OneUptimeDate.getWeeksBetweenTwoDatesInclusive(handOffTime, data.currentEventStartTime);
212
- if (intervalBetweenStartTimeAndHandoffTime < rotationInterval) {
213
- intervalBetweenStartTimeAndHandoffTime = rotationInterval;
214
- }
215
- else if (intervalBetweenStartTimeAndHandoffTime % rotationInterval !==
216
- 0) {
217
- intervalBetweenStartTimeAndHandoffTime += rotationInterval;
218
- }
219
- // add intervalBetweenStartTimeAndHandoffTime to handoff time
220
- handOffTime = OneUptimeDate.addRemoveWeeks(handOffTime, intervalBetweenStartTimeAndHandoffTime);
221
- if (OneUptimeDate.isOnOrBefore(handOffTime, data.currentEventStartTime)) {
222
- handOffTime = OneUptimeDate.addRemoveWeeks(handOffTime, 1);
223
- }
224
- return handOffTime;
225
- }
226
- if (data.rotation.intervalType === EventInterval.Month) {
227
- intervalBetweenStartTimeAndHandoffTime =
228
- OneUptimeDate.getMonthsBetweenTwoDatesInclusive(handOffTime, data.currentEventStartTime);
229
- if (intervalBetweenStartTimeAndHandoffTime < rotationInterval) {
230
- intervalBetweenStartTimeAndHandoffTime = rotationInterval;
231
- }
232
- else if (intervalBetweenStartTimeAndHandoffTime % rotationInterval !==
233
- 0) {
234
- intervalBetweenStartTimeAndHandoffTime += rotationInterval;
235
- }
236
- // add intervalBetweenStartTimeAndHandoffTime to handoff time
237
- handOffTime = OneUptimeDate.addRemoveMonths(handOffTime, intervalBetweenStartTimeAndHandoffTime);
238
- if (OneUptimeDate.isOnOrBefore(handOffTime, data.currentEventStartTime)) {
239
- handOffTime = OneUptimeDate.addRemoveMonths(handOffTime, 1);
240
- }
241
- return handOffTime;
242
- }
243
- if (data.rotation.intervalType === EventInterval.Year) {
244
- intervalBetweenStartTimeAndHandoffTime =
245
- OneUptimeDate.getYearsBetweenTwoDatesInclusive(handOffTime, data.currentEventStartTime);
246
- if (intervalBetweenStartTimeAndHandoffTime < rotationInterval) {
247
- intervalBetweenStartTimeAndHandoffTime = rotationInterval;
248
- }
249
- else if (intervalBetweenStartTimeAndHandoffTime % rotationInterval !==
250
- 0) {
251
- intervalBetweenStartTimeAndHandoffTime += rotationInterval;
252
- }
253
- // add intervalBetweenStartTimeAndHandoffTime to handoff time
254
- handOffTime = OneUptimeDate.addRemoveYears(handOffTime, intervalBetweenStartTimeAndHandoffTime);
255
- if (OneUptimeDate.isOnOrBefore(handOffTime, data.currentEventStartTime)) {
256
- handOffTime = OneUptimeDate.addRemoveYears(handOffTime, 1);
257
- }
258
- return handOffTime;
249
+ const rawRotationInterval = data.rotation.intervalCount.toNumber();
250
+ /*
251
+ * Defensive clamp: an invalid interval count (0, NaN, negative) would make
252
+ * the alignment below produce an Invalid Date and spin the main getEvents
253
+ * loop. Treat any invalid value as a single unit.
254
+ */
255
+ const rotationInterval = Number.isFinite(rawRotationInterval) && rawRotationInterval >= 1
256
+ ? Math.floor(rawRotationInterval)
257
+ : 1;
258
+ const intervalType = data.rotation.intervalType;
259
+ /*
260
+ * Rotation boundaries are exactly handOffTime + k * rotationInterval units
261
+ * (k a non-negative integer). Return the SMALLEST such boundary that is
262
+ * strictly after currentEventStartTime.
263
+ *
264
+ * We start from the floor of the whole-period distance and step UP one full
265
+ * rotation period at a time until strictly after the target. This:
266
+ * - stays on the interval grid (multiples of rotationInterval), so
267
+ * intervalCount >= 2 rotations never drift onto an off-grid boundary
268
+ * (audit HIGH-1); and
269
+ * - never OVERSHOOTS by a whole period. The previous
270
+ * ceil(getUnitsInclusive / interval) * interval formula used an
271
+ * INCLUSIVE unit count, which overshot by a full period for positions in
272
+ * the last partial period before a boundary — and a DST offset shift
273
+ * could push the inclusive count across an even/odd threshold — yielding
274
+ * a first period that spanned two rotations and resolved the wrong
275
+ * current/next on-call user for intervalCount >= 2 rotations.
276
+ * addRotationUnits carries the timezone per interval type (wall-clock across
277
+ * DST for Day/Week/Month/Year; absolute for Hour), matching the main loop.
278
+ */
279
+ const unitsBetween = this.getUnitsBetweenDates(data.handOffTime, data.currentEventStartTime, intervalType);
280
+ let periods = Math.floor(unitsBetween / rotationInterval);
281
+ if (!Number.isFinite(periods) || periods < 0) {
282
+ periods = 0;
283
+ }
284
+ /*
285
+ * Compute the boundary `periods` rotation periods after the anchor. For
286
+ * Month/Year this ITERATES one period at a time (see addRotationPeriods),
287
+ * because moment end-of-month-clamps a single multiplied add differently
288
+ * than stepping period-by-period — e.g. Jan-31 + 11 months multiplied =
289
+ * Dec-31, but eleven iterated +1-month steps = Dec-28. countElapsedRotation
290
+ * Periods and the main getEvents loop both walk the ITERATED grid, so a
291
+ * multiplied step here produced handoff boundaries off that grid and paged
292
+ * the wrong on-call user for Month/Year rotations anchored on day 29-31 (or
293
+ * Feb-29 for Year) — audit H1.
294
+ */
295
+ let handOffTime = this.addRotationPeriods(data.handOffTime, periods, rotationInterval, intervalType);
296
+ let safety = 0;
297
+ while (OneUptimeDate.isOnOrBefore(handOffTime, data.currentEventStartTime) &&
298
+ safety < 1000000) {
299
+ periods++;
300
+ /*
301
+ * Step exactly ONE rotation period from the PREVIOUS boundary (not a fresh
302
+ * multiplied add from the anchor), so Month/Year stay on the same iterated,
303
+ * calendar-clamped grid as the initial jump above.
304
+ */
305
+ handOffTime = this.addRotationUnits(handOffTime, rotationInterval, intervalType);
306
+ safety++;
259
307
  }
260
308
  return handOffTime;
261
309
  }
310
+ /*
311
+ * Advance `anchor` by `numberOfPeriods` rotation periods (each period =
312
+ * rotationInterval units of intervalType). Hour/Day/Week have no calendar-
313
+ * length clamping, so a single multiplied add is exact and O(1). Month/Year
314
+ * DO clamp (moment: Jan-31 + 1mo = Feb-28, then Feb-28 + 1mo = Mar-28), so a
315
+ * multiplied add does NOT equal iterating; we step one period at a time to
316
+ * stay on the same grid the rest of the engine walks (audit H1). The period
317
+ * count for Month/Year is small even over decades, so iterating is cheap.
318
+ */
319
+ addRotationPeriods(anchor, numberOfPeriods, rotationInterval, intervalType) {
320
+ if (numberOfPeriods <= 0) {
321
+ return anchor;
322
+ }
323
+ if (intervalType === EventInterval.Month ||
324
+ intervalType === EventInterval.Year) {
325
+ let result = anchor;
326
+ let safety = 0;
327
+ while (safety < numberOfPeriods && safety < 1000000) {
328
+ result = this.addRotationUnits(result, rotationInterval, intervalType);
329
+ safety++;
330
+ }
331
+ return result;
332
+ }
333
+ return this.addRotationUnits(anchor, numberOfPeriods * rotationInterval, intervalType);
334
+ }
262
335
  getCurrentUserIndexBasedOnHandoffTime(data) {
336
+ /*
337
+ * Returns both the on-call user index for the rotation period that CONTAINS
338
+ * currentEventStartTime AND the true (un-clamped) start of that period.
339
+ * getEvents needs the true period start so it can decide, for the first
340
+ * (possibly clamped) period, whether that period consumed a rotation turn
341
+ * based on its FULL-span restriction coverage rather than the coverage in
342
+ * the clamped [now, periodEnd] slice (see the first-period advance guard in
343
+ * getEvents — audit F2).
344
+ */
263
345
  let currentUserIndex = data.currentUserIndex;
264
346
  // if current event start time is before layer start, idx unchanged.
265
347
  if (OneUptimeDate.isBefore(data.currentEventStartTime, data.startDateTimeOfLayer)) {
266
- return currentUserIndex;
348
+ return {
349
+ currentUserIndex,
350
+ currentPeriodStart: data.currentEventStartTime,
351
+ };
267
352
  }
268
353
  // if handoff is after current start, no rotation has occurred yet — idx unchanged.
269
354
  if (OneUptimeDate.isAfter(data.handOffTime, data.currentEventStartTime)) {
270
- return currentUserIndex;
355
+ /*
356
+ * No handoff has happened yet, so we are still inside the very first
357
+ * rotation period, which starts at the layer start.
358
+ */
359
+ return {
360
+ currentUserIndex,
361
+ currentPeriodStart: data.startDateTimeOfLayer,
362
+ };
363
+ }
364
+ /*
365
+ * Fast path: with no restriction, every rotation period produces exactly
366
+ * one event, so the current user index is simply the initial index plus the
367
+ * number of whole rotation periods elapsed since the first handoff. We
368
+ * compute this analytically in O(1) instead of simulating one iteration per
369
+ * period. The simulation below capped at 10000 iterations and returned the
370
+ * WRONG user for long-lived schedules (e.g. an hourly rotation older than
371
+ * ~14 months). Restricted layers still use the period-by-period simulation
372
+ * because fully-restricted periods must not advance the rotation.
373
+ */
374
+ if (data.restrictionTimes &&
375
+ data.restrictionTimes.restictionType === RestrictionType.None &&
376
+ data.users.length > 0) {
377
+ const firstBoundary = this.moveHandsOffTimeAfterCurrentEventStartTime({
378
+ handOffTime: data.handOffTime,
379
+ currentEventStartTime: data.startDateTimeOfLayer,
380
+ rotation: data.rotation,
381
+ });
382
+ const periodsElapsed = this.countElapsedRotationPeriods(firstBoundary, data.currentEventStartTime, data.rotation);
383
+ const length = data.users.length;
384
+ /*
385
+ * Unrestricted layers never have coverage gaps, so getEvents never needs
386
+ * the full-span first-period fallback for them; currentPeriodStart is
387
+ * returned for interface symmetry only and is not read on this path.
388
+ */
389
+ return {
390
+ currentUserIndex: (((currentUserIndex + periodsElapsed) % length) + length) % length,
391
+ currentPeriodStart: data.currentEventStartTime,
392
+ };
271
393
  }
272
394
  /*
273
395
  * Simulate rotation periods from layer start up to currentEventStartTime,
@@ -283,12 +405,22 @@ export default class LayerUtil {
283
405
  rotation: data.rotation,
284
406
  });
285
407
  /*
286
- * Generous safety bound: 10000 covers ~27 years of daily rotation or
287
- * ~14 months of hourly rotation. The loop normally exits via the
288
- * isBefore check; this cap only fires for pathologically long-running
289
- * schedules to keep the function bounded.
408
+ * Bound the simulation by the actual number of rotation periods between the
409
+ * layer start and the target, so the cap is always large enough to REACH the
410
+ * target for any realistic schedule age. A fixed 10000 cap (~14 months of
411
+ * hourly rotation) stopped early for long-lived sub-daily restricted
412
+ * schedules and returned the index at iteration 10000 instead of the index at
413
+ * "now" — paging the wrong current user (audit F9). We keep a very high
414
+ * ceiling to still bound pathological inputs. Restricted periods must be
415
+ * simulated one at a time (they must not advance the rotation), so this stays
416
+ * O(elapsed periods); for realistic ages that is small.
290
417
  */
291
- const maxIterations = 10000;
418
+ const rawSimCount = data.rotation.intervalCount.toNumber();
419
+ const simPeriodUnits = Number.isFinite(rawSimCount) && rawSimCount >= 1
420
+ ? Math.floor(rawSimCount)
421
+ : 1;
422
+ const simUnitsBetween = this.getUnitsBetweenDates(data.startDateTimeOfLayer, data.currentEventStartTime, data.rotation.intervalType);
423
+ const maxIterations = Math.min(5000000, Math.max(10000, Math.ceil(simUnitsBetween / simPeriodUnits) + 10));
292
424
  let iterations = 0;
293
425
  while (OneUptimeDate.isBefore(simulatedTime, data.currentEventStartTime) &&
294
426
  iterations < maxIterations) {
@@ -313,7 +445,110 @@ export default class LayerUtil {
313
445
  rotation: data.rotation,
314
446
  });
315
447
  }
316
- return currentUserIndex;
448
+ /*
449
+ * simulatedTime is now the true (un-clamped) start of the rotation period
450
+ * that contains data.currentEventStartTime — the loop advances it to the
451
+ * next period start each covered iteration and breaks once a period would
452
+ * extend past the target, so it holds the current period's real start.
453
+ */
454
+ return { currentUserIndex, currentPeriodStart: simulatedTime };
455
+ }
456
+ /*
457
+ * Count the number of rotation boundaries that fall on-or-before `target`,
458
+ * starting from `firstBoundary` and stepping by one rotation period. This is
459
+ * the number of whole rotation periods elapsed, used for the O(1) unrestricted
460
+ * current-user computation. Uses calendar-aware unit stepping so it stays
461
+ * correct for Month/Year (variable length) and across DST for Day/Week.
462
+ */
463
+ countElapsedRotationPeriods(firstBoundary, target, rotation) {
464
+ if (OneUptimeDate.isAfter(firstBoundary, target)) {
465
+ return 0;
466
+ }
467
+ const intervalType = rotation.intervalType;
468
+ const rawCount = rotation.intervalCount.toNumber();
469
+ const periodUnits = Number.isFinite(rawCount) && rawCount >= 1 ? Math.floor(rawCount) : 1;
470
+ /*
471
+ * Month and Year have variable calendar length (moment clamps end-of-month:
472
+ * Jan 31 + 1mo = Feb 29, and Feb 29 + 1mo = Mar 29). Because of that,
473
+ * boundary_k computed as a SINGLE multiplied step (anchor + k*units) does
474
+ * NOT equal advancing one period at a time — which is exactly how the real
475
+ * rotation in getEvents (via moveHandsOffTimeAfterCurrentEventStartTime)
476
+ * steps. Using the multiplied form here under-counted elapsed periods by one
477
+ * at month-end anchors and paged the previous on-call user (audit F0). So we
478
+ * iterate one clamped period at a time for Month/Year. The boundary count
479
+ * stays small even over decades of monthly/yearly rotation, so this is cheap.
480
+ */
481
+ if (intervalType === EventInterval.Month ||
482
+ intervalType === EventInterval.Year) {
483
+ let periods = 0;
484
+ let boundary = firstBoundary;
485
+ let safety = 0;
486
+ while (OneUptimeDate.isOnOrBefore(boundary, target) && safety < 100000) {
487
+ periods++;
488
+ // step from the PREVIOUS boundary, mirroring the main-loop rotation.
489
+ boundary = this.addRotationUnits(boundary, periodUnits, intervalType);
490
+ safety++;
491
+ }
492
+ return periods;
493
+ }
494
+ /*
495
+ * Hour/Day/Week have no calendar-length clamping, so the O(1) analytic count
496
+ * (a multiplied step) is exact and equals iterating.
497
+ */
498
+ const unitsBetween = this.getUnitsBetweenDates(firstBoundary, target, intervalType);
499
+ let periods = Math.floor(unitsBetween / periodUnits);
500
+ if (periods < 0) {
501
+ periods = 0;
502
+ }
503
+ /*
504
+ * `periods` is a lower bound (unit diffs truncate toward zero). Advance
505
+ * until firstBoundary + periods*periodUnits is strictly after target; the
506
+ * resulting count equals the number of boundaries on-or-before target.
507
+ */
508
+ let safety = 0;
509
+ while (OneUptimeDate.isOnOrBefore(this.addRotationUnits(firstBoundary, periods * periodUnits, intervalType), target) &&
510
+ safety < 100000) {
511
+ periods++;
512
+ safety++;
513
+ }
514
+ return periods;
515
+ }
516
+ addRotationUnits(date, units, intervalType) {
517
+ /*
518
+ * Day/Week/Month/Year preserve schedule wall-clock across DST (consistent
519
+ * with moveHandsOffTimeAfterCurrentEventStartTime); Hour is absolute.
520
+ */
521
+ const tz = this.timezone;
522
+ switch (intervalType) {
523
+ case EventInterval.Hour:
524
+ return OneUptimeDate.addRemoveHours(date, units);
525
+ case EventInterval.Day:
526
+ return OneUptimeDate.addRemoveDays(date, units, tz);
527
+ case EventInterval.Week:
528
+ return OneUptimeDate.addRemoveWeeks(date, units, tz);
529
+ case EventInterval.Month:
530
+ return OneUptimeDate.addRemoveMonths(date, units, tz);
531
+ case EventInterval.Year:
532
+ return OneUptimeDate.addRemoveYears(date, units, tz);
533
+ default:
534
+ return OneUptimeDate.addRemoveDays(date, units, tz);
535
+ }
536
+ }
537
+ getUnitsBetweenDates(from, to, intervalType) {
538
+ switch (intervalType) {
539
+ case EventInterval.Hour:
540
+ return OneUptimeDate.getHoursBetweenTwoDates(from, to);
541
+ case EventInterval.Day:
542
+ return OneUptimeDate.getDaysBetweenTwoDates(from, to);
543
+ case EventInterval.Week:
544
+ return OneUptimeDate.getWeeksBetweenTwoDates(from, to);
545
+ case EventInterval.Month:
546
+ return OneUptimeDate.getMonthsBetweenTwoDates(from, to);
547
+ case EventInterval.Year:
548
+ return OneUptimeDate.getYearsBetweenTwoDates(from, to);
549
+ default:
550
+ return OneUptimeDate.getDaysBetweenTwoDates(from, to);
551
+ }
317
552
  }
318
553
  trimStartAndEndTimesBasedOnRestrictionTimes(data) {
319
554
  const restrictionTimes = data.restrictionTimes;
@@ -327,15 +562,23 @@ export default class LayerUtil {
327
562
  }
328
563
  if (restrictionTimes.restictionType === RestrictionType.Daily &&
329
564
  restrictionTimes.dayRestrictionTimes) {
330
- // before this we need to make sure restrciton times are moved to the day of the event.
331
- restrictionTimes.dayRestrictionTimes.startTime =
332
- OneUptimeDate.keepTimeButMoveDay(restrictionTimes.dayRestrictionTimes.startTime, data.eventStartTime);
333
- restrictionTimes.dayRestrictionTimes.endTime =
334
- OneUptimeDate.keepTimeButMoveDay(restrictionTimes.dayRestrictionTimes.endTime, data.eventStartTime);
565
+ /*
566
+ * Move the restriction window to the event's day WITHOUT mutating the
567
+ * shared RestrictionTimes object. The previous code wrote the moved
568
+ * start/end back into restrictionTimes.dayRestrictionTimes, corrupting the
569
+ * caller's object across events/layers/calls and making resolution
570
+ * order-dependent (a hygiene defect flagged in the audit). keepTimeButMoveDay
571
+ * preserves the time-of-day regardless of the base day, so working on a
572
+ * local copy produces identical windows with no shared-state side effects.
573
+ */
574
+ const movedDayRestriction = {
575
+ startTime: OneUptimeDate.keepTimeButMoveDay(restrictionTimes.dayRestrictionTimes.startTime, data.eventStartTime, this.timezone),
576
+ endTime: OneUptimeDate.keepTimeButMoveDay(restrictionTimes.dayRestrictionTimes.endTime, data.eventStartTime, this.timezone),
577
+ };
335
578
  return this.getEventsByDailyRestriction({
336
579
  eventStartTime: data.eventStartTime,
337
580
  eventEndTime: data.eventEndTime,
338
- restrictionStartAndEndTime: restrictionTimes.dayRestrictionTimes,
581
+ restrictionStartAndEndTime: movedDayRestriction,
339
582
  props: {
340
583
  intervalType: EventInterval.Day,
341
584
  },
@@ -373,7 +616,47 @@ export default class LayerUtil {
373
616
  ...trimmedStartAndEndTimesForRestriction,
374
617
  ];
375
618
  }
376
- return trimmedStartAndEndTimes;
619
+ /*
620
+ * Collapse overlapping/touching segments. The wrap-around split in
621
+ * getWeeklyRestrictionTimesForWeek emits a head segment (early-week tail)
622
+ * plus a main segment, and getEventsByDailyRestriction tiles each weekly
623
+ * across the event window. For a rotation event spanning more than one ISO
624
+ * week, week k's main segment already covers the Sunday->Monday that week
625
+ * (k+1)'s head segment re-covers, producing duplicate/overlapping events for
626
+ * the same user (audit F3). Merging contiguous coverage is always safe here
627
+ * because every segment belongs to the same layer/user.
628
+ */
629
+ return this.mergeOverlappingStartAndEndTimes(trimmedStartAndEndTimes);
630
+ }
631
+ mergeOverlappingStartAndEndTimes(times) {
632
+ if (times.length <= 1) {
633
+ return times;
634
+ }
635
+ const sorted = [...times].sort((a, b) => {
636
+ if (OneUptimeDate.isBefore(a.startTime, b.startTime)) {
637
+ return -1;
638
+ }
639
+ if (OneUptimeDate.isAfter(a.startTime, b.startTime)) {
640
+ return 1;
641
+ }
642
+ return 0;
643
+ });
644
+ const merged = [];
645
+ for (const current of sorted) {
646
+ const last = merged[merged.length - 1];
647
+ // overlapping or directly touching the previous window -> extend it.
648
+ if (last && OneUptimeDate.isOnOrAfter(last.endTime, current.startTime)) {
649
+ if (OneUptimeDate.isAfter(current.endTime, last.endTime)) {
650
+ last.endTime = current.endTime;
651
+ }
652
+ continue;
653
+ }
654
+ merged.push({
655
+ startTime: current.startTime,
656
+ endTime: current.endTime,
657
+ });
658
+ }
659
+ return merged;
377
660
  }
378
661
  getWeeklyRestrictionTimesForWeek(data) {
379
662
  const weeklyRestrictionTimes = data.restrictionTimes.weeklyRestrictionTimes;
@@ -384,8 +667,8 @@ export default class LayerUtil {
384
667
  let startTime = weeklyRestriction.startTime;
385
668
  let endTime = weeklyRestriction.endTime;
386
669
  // move start and end times to the week of the event start time
387
- startTime = OneUptimeDate.moveDateToTheDayOfWeek(startTime, eventStartTime, OneUptimeDate.getDayOfWeek(startTime));
388
- endTime = OneUptimeDate.moveDateToTheDayOfWeek(endTime, eventStartTime, OneUptimeDate.getDayOfWeek(endTime));
670
+ startTime = OneUptimeDate.moveDateToTheDayOfWeek(startTime, eventStartTime, OneUptimeDate.getDayOfWeek(startTime, this.timezone), this.timezone);
671
+ endTime = OneUptimeDate.moveDateToTheDayOfWeek(endTime, eventStartTime, OneUptimeDate.getDayOfWeek(endTime, this.timezone), this.timezone);
389
672
  // now we have true start and end times of the weekly restriction
390
673
  // if start time is after end time, we need to add one week to the end time
391
674
  if (OneUptimeDate.isAfter(startTime, endTime)) {
@@ -394,22 +677,68 @@ export default class LayerUtil {
394
677
  * One for start of the week
395
678
  * and the other for end of the week .
396
679
  */
397
- const startOfWeek = data.eventStartTime;
398
- // add 7 days to the end time to get the end of the week
399
- const endOfTheWeek = OneUptimeDate.addRemoveDays(startOfWeek, 7);
680
+ /*
681
+ * Anchor the split to the START OF THE ISO WEEK that contains the event,
682
+ * NOT to data.eventStartTime. When resolution begins mid-week (the live
683
+ * "who is on call now" path always starts its window at the current
684
+ * instant), using eventStartTime made the head segment
685
+ * [eventStartTime, endTime] inverted (start > end) whenever "now" was
686
+ * already past the window's end-day. getEventsByDailyRestriction then
687
+ * mis-read that inverted segment as an overnight window and sprayed
688
+ * phantom all-day on-call coverage across every day of the week, paging
689
+ * the wrong user during hours the restriction excludes. Using the real
690
+ * week start keeps the head segment correctly ordered; the later
691
+ * intersection with the event window discards any portion that has
692
+ * already elapsed.
693
+ */
694
+ const startOfWeek = OneUptimeDate.getStartOfTheWeek(data.eventStartTime, this.timezone);
695
+ /*
696
+ * Head segment: the early-week tail (week start -> endTime) of a weekend
697
+ * window that opened the PREVIOUS period. This is what covers an
698
+ * in-progress wrap-around window when resolution starts mid-weekend
699
+ * (e.g. resolving on the Sunday of a Fri 20:00 -> Mon 08:00 window).
700
+ */
400
701
  startAndEndTimesOfWeeklyRestrictions.push({
401
702
  startTime: startOfWeek,
402
703
  endTime: endTime,
403
704
  });
705
+ /*
706
+ * Main segment: the contiguous window from startTime (this week) through
707
+ * endTime moved to the NEXT week. Because this is a wrap-around,
708
+ * startTime is later in the week than endTime, so endTime + 7 days is the
709
+ * window's true close (e.g. Fri 20:00 -> the following Mon 08:00).
710
+ * Expressing it as one forward window — rather than clipping to the end
711
+ * of THIS ISO week — lets getEventsByDailyRestriction tile it weekly
712
+ * across a multi-week rotation event without leaving the Sunday/Monday
713
+ * portion of the weekend uncovered.
714
+ */
404
715
  startAndEndTimesOfWeeklyRestrictions.push({
405
716
  startTime: startTime,
406
- endTime: endOfTheWeek,
717
+ /*
718
+ * Forward the schedule timezone so this +7-day step is a wall-clock
719
+ * week in the schedule's zone, consistent with every sibling
720
+ * day-step in the weekly tiling path (audit F8). Without it, when the
721
+ * server zone differs from the schedule zone across a DST transition,
722
+ * the wrap-around window's close drifted by the DST offset and that
723
+ * drift then propagated to every subsequent weekend of the expansion.
724
+ */
725
+ endTime: OneUptimeDate.addRemoveDays(endTime, 7, this.timezone),
726
+ });
727
+ }
728
+ else {
729
+ /*
730
+ * Non-wrapping restriction: emit the single window. This is gated in an
731
+ * `else` because the wrap-around case above is already fully described
732
+ * by the two split segments; previously this raw push ran
733
+ * unconditionally, adding a third INVERTED (start > end) segment that
734
+ * getEventsByDailyRestriction then re-expanded into phantom nightly
735
+ * on-call windows on every day of the week.
736
+ */
737
+ startAndEndTimesOfWeeklyRestrictions.push({
738
+ startTime,
739
+ endTime,
407
740
  });
408
741
  }
409
- startAndEndTimesOfWeeklyRestrictions.push({
410
- startTime,
411
- endTime,
412
- });
413
742
  }
414
743
  return startAndEndTimesOfWeeklyRestrictions;
415
744
  }
@@ -436,19 +765,36 @@ export default class LayerUtil {
436
765
  */
437
766
  if (OneUptimeDate.isBefore(restrictionEndTime, restrictionStartTime)) {
438
767
  const results = [];
439
- // We'll iterate day-by-day within the event range (max 31 iterations safeguard)
440
- let currentDayStart = OneUptimeDate.getStartOfDay(data.eventStartTime);
768
+ /*
769
+ * Iterate day-by-day within the event range. We start ONE day BEFORE the
770
+ * event's start day so the "morning" tail of the window that opened the
771
+ * previous night (e.g. a 22:00 -> 06:00 window covering 00:00 -> 06:00 on
772
+ * the event's own first day) is emitted. Previously the loop started at
773
+ * getStartOfDay(eventStart) and only ever tied the morning segment to the
774
+ * NEXT day, so a rotation event beginning at midnight lost its first-day
775
+ * morning coverage entirely, leaving a nightly gap where nobody was on
776
+ * call. The addIntersection clip to [eventStart, eventEnd] discards any
777
+ * segment of the extra leading day that falls outside the event.
778
+ */
779
+ let currentDayStart = OneUptimeDate.addRemoveDays(OneUptimeDate.getStartOfDay(data.eventStartTime, this.timezone), -1, this.timezone);
441
780
  const absoluteEventEnd = data.eventEndTime;
442
781
  let safetyCounter = 0;
443
- const maxDays = 62; // generous safeguard
782
+ /*
783
+ * Scale the day-by-day safeguard to the actual event span. A fixed 62-day
784
+ * cap dropped every night past ~day 62 for rotation events longer than
785
+ * that (e.g. a quarterly/annual rotation with an overnight restriction),
786
+ * leaving those nights with no on-call coverage (audit F2). Bound to the
787
+ * event length plus margin, with a hard ceiling for pathological inputs.
788
+ */
789
+ const maxDays = Math.min(4000, Math.max(62, OneUptimeDate.getDaysBetweenTwoDates(data.eventStartTime, data.eventEndTime) + 3));
444
790
  while (OneUptimeDate.isOnOrBefore(currentDayStart, absoluteEventEnd) &&
445
791
  safetyCounter < maxDays) {
446
792
  safetyCounter++;
447
- const segmentNightStart = OneUptimeDate.keepTimeButMoveDay(restrictionStartTime, currentDayStart);
448
- const segmentNightEnd = OneUptimeDate.getEndOfDay(segmentNightStart);
449
- const nextDayStart = OneUptimeDate.addRemoveDays(currentDayStart, 1);
450
- const segmentMorningStart = OneUptimeDate.getStartOfDay(nextDayStart);
451
- const segmentMorningEnd = OneUptimeDate.keepTimeButMoveDay(restrictionEndTime, nextDayStart);
793
+ const segmentNightStart = OneUptimeDate.keepTimeButMoveDay(restrictionStartTime, currentDayStart, this.timezone);
794
+ const segmentNightEnd = OneUptimeDate.getEndOfDay(segmentNightStart, this.timezone);
795
+ const nextDayStart = OneUptimeDate.addRemoveDays(currentDayStart, 1, this.timezone);
796
+ const segmentMorningStart = OneUptimeDate.getStartOfDay(nextDayStart, this.timezone);
797
+ const segmentMorningEnd = OneUptimeDate.keepTimeButMoveDay(restrictionEndTime, nextDayStart, this.timezone);
452
798
  // helper to add intersection if it overlaps the event window
453
799
  const addIntersection = (segStart, segEnd) => {
454
800
  // normalize zero / invalid lengths
@@ -473,8 +819,14 @@ export default class LayerUtil {
473
819
  const currentEndTime = data.eventEndTime;
474
820
  const trimmedStartAndEndTimes = [];
475
821
  let reachedTheEndOfTheCurrentEvent = false;
476
- // create a break clause. This loop executes 100 times at max.
477
- const maxLoopCount = 50;
822
+ /*
823
+ * Scale the break clause to the event span. The loop advances one restriction
824
+ * period (1 day for a Daily restriction, 7 days for a Weekly one) per
825
+ * iteration, so a single rotation event longer than ~50 days had its later
826
+ * days silently dropped, leaving no on-call coverage (audit F2). Days-in-event
827
+ * is a safe upper bound for both the daily (+1/day) and weekly (+7/day) paths.
828
+ */
829
+ const maxLoopCount = Math.min(4000, Math.max(50, OneUptimeDate.getDaysBetweenTwoDates(data.eventStartTime, data.eventEndTime) + 10));
478
830
  let loopCount = 0;
479
831
  while (!reachedTheEndOfTheCurrentEvent) {
480
832
  loopCount++;
@@ -489,15 +841,37 @@ export default class LayerUtil {
489
841
  if (OneUptimeDate.isOnOrAfter(restrictionStartTime, currentEndTime)) {
490
842
  return trimmedStartAndEndTimes;
491
843
  }
492
- // if current event start time is after the restriction end time then we need to return empty array as there is no event.
844
+ /*
845
+ * The event begins after THIS day's restriction window has already ended.
846
+ * Do NOT drop the whole event — a multi-day rotation event (e.g. a WEEKLY
847
+ * rotation whose handoff/start is at 20:00, with a 09:00-17:00 daily
848
+ * restriction) must still be covered on its subsequent days. Advance the
849
+ * restriction window to the next day/week and re-test instead of returning
850
+ * empty. Termination is preserved: after at most one advance the window's
851
+ * end moves past currentStartTime, and the "restrictionStart past
852
+ * currentEnd" guard above returns once the window moves past the event end
853
+ * (so a short event entirely after the window still yields no coverage).
854
+ */
493
855
  if (OneUptimeDate.isOnOrAfter(currentStartTime, restrictionEndTime)) {
494
- return trimmedStartAndEndTimes;
856
+ restrictionStartTime = OneUptimeDate.addRemoveDays(restrictionStartTime, data.props.intervalType === EventInterval.Day ? 1 : 7, // daily or weekly
857
+ this.timezone);
858
+ restrictionEndTime = OneUptimeDate.addRemoveDays(restrictionEndTime, data.props.intervalType === EventInterval.Day ? 1 : 7, // daily or weekly
859
+ this.timezone);
860
+ continue;
495
861
  }
496
862
  // if the restriction end time is before the restriction start time, we need to add one day to the restriction end time
497
863
  if (OneUptimeDate.isAfter(restrictionStartTime, restrictionEndTime)) {
498
864
  restrictionEndTime = OneUptimeDate.addRemoveDays(restrictionEndTime, data.props.intervalType === EventInterval.Day ? 1 : 7);
499
865
  }
500
- // 1 - if the current event falls within the restriction times, we need to return the current event.
866
+ /*
867
+ * The four cases below are mutually exclusive for a given iteration and
868
+ * are expressed as an if / else-if chain. This matters because cases 2 and
869
+ * 4 MUTATE currentStartTime / restrictionStartTime / restrictionEndTime and
870
+ * then continue the loop; without else-if, a later case would re-evaluate
871
+ * against the freshly-mutated state within the same iteration and emit a
872
+ * duplicate (or overlapping) window.
873
+ */
874
+ // 1 - the event falls entirely within the restriction window: emit it and finish.
501
875
  if (OneUptimeDate.isOnOrAfter(currentStartTime, restrictionStartTime) &&
502
876
  OneUptimeDate.isOnOrAfter(restrictionEndTime, currentEndTime)) {
503
877
  trimmedStartAndEndTimes.push({
@@ -506,20 +880,33 @@ export default class LayerUtil {
506
880
  });
507
881
  reachedTheEndOfTheCurrentEvent = true;
508
882
  }
883
+ else if (
509
884
  /*
510
- * 2 - Start Restriction: If the current event starts after the restriction start time and ends after the restriction end time, we need to return the current event with the start time of the current event and end time of the restriction
511
- * Use strict isAfter on the end so this branch does not double-fire with case 1 when currentEnd === restrictionEnd.
885
+ * 2 - Start Restriction: the event starts inside the restriction window
886
+ * but extends past its end. Emit [currentStart, restrictionEnd], then
887
+ * ADVANCE to the next restriction day/week and continue, so every
888
+ * remaining day of a multi-day rotation event is emitted. Previously this
889
+ * terminated the loop after the first day, dropping on-call coverage for
890
+ * every subsequent day of the rotation period (e.g. a weekly rotation
891
+ * with a 09:00-17:00 daily restriction and a handoff at/after 09:00
892
+ * covered only day 1). This now mirrors case 4's advance-and-continue.
893
+ * Strict isAfter on the end keeps this exclusive from case 1.
512
894
  */
513
- if (OneUptimeDate.isOnOrAfter(currentStartTime, restrictionStartTime) &&
895
+ OneUptimeDate.isOnOrAfter(currentStartTime, restrictionStartTime) &&
514
896
  OneUptimeDate.isAfter(currentEndTime, restrictionEndTime)) {
515
897
  trimmedStartAndEndTimes.push({
516
898
  startTime: currentStartTime,
517
899
  endTime: restrictionEndTime,
518
900
  });
519
- reachedTheEndOfTheCurrentEvent = true;
901
+ currentStartTime = OneUptimeDate.addRemoveSeconds(restrictionEndTime, 1);
902
+ restrictionStartTime = OneUptimeDate.addRemoveDays(restrictionStartTime, data.props.intervalType === EventInterval.Day ? 1 : 7, // daily or weekly
903
+ this.timezone);
904
+ restrictionEndTime = OneUptimeDate.addRemoveDays(restrictionEndTime, data.props.intervalType === EventInterval.Day ? 1 : 7, // daily or weekly
905
+ this.timezone);
520
906
  }
521
- // 3 - End Restriction - If the current event starts before the restriction start time and ends before the restriction end time, we need to return the current event with the start time of the restriction and end time of the current event.
522
- if (OneUptimeDate.isBefore(currentStartTime, restrictionStartTime) &&
907
+ else if (
908
+ // 3 - End Restriction - the event starts before the window and ends inside it.
909
+ OneUptimeDate.isBefore(currentStartTime, restrictionStartTime) &&
523
910
  OneUptimeDate.isBefore(currentEndTime, restrictionEndTime) &&
524
911
  OneUptimeDate.isAfter(currentEndTime, restrictionStartTime)) {
525
912
  trimmedStartAndEndTimes.push({
@@ -528,8 +915,9 @@ export default class LayerUtil {
528
915
  });
529
916
  reachedTheEndOfTheCurrentEvent = true;
530
917
  }
531
- // 4 - If the current event starts before the restriction start time and ends after the restriction end time, we need to return the current event with the start time of the restriction and end time of the restriction.
532
- if (OneUptimeDate.isBefore(currentStartTime, restrictionStartTime) &&
918
+ else if (
919
+ // 4 - the event spans the whole window: emit it, advance a day/week, continue.
920
+ OneUptimeDate.isBefore(currentStartTime, restrictionStartTime) &&
533
921
  OneUptimeDate.isOnOrAfter(currentEndTime, restrictionEndTime)) {
534
922
  trimmedStartAndEndTimes.push({
535
923
  startTime: restrictionStartTime,
@@ -537,8 +925,10 @@ export default class LayerUtil {
537
925
  });
538
926
  currentStartTime = OneUptimeDate.addRemoveSeconds(restrictionEndTime, 1);
539
927
  // add day to restriction start and end times.
540
- restrictionStartTime = OneUptimeDate.addRemoveDays(restrictionStartTime, data.props.intervalType === EventInterval.Day ? 1 : 7);
541
- restrictionEndTime = OneUptimeDate.addRemoveDays(restrictionEndTime, data.props.intervalType === EventInterval.Day ? 1 : 7);
928
+ restrictionStartTime = OneUptimeDate.addRemoveDays(restrictionStartTime, data.props.intervalType === EventInterval.Day ? 1 : 7, // daily or weekly
929
+ this.timezone);
930
+ restrictionEndTime = OneUptimeDate.addRemoveDays(restrictionEndTime, data.props.intervalType === EventInterval.Day ? 1 : 7, // daily or weekly
931
+ this.timezone);
542
932
  }
543
933
  }
544
934
  return trimmedStartAndEndTimes;
@@ -577,15 +967,25 @@ export default class LayerUtil {
577
967
  const events = [];
578
968
  let layerPriority = 1;
579
969
  for (const layer of data.layers) {
970
+ /*
971
+ * Do NOT forward getNumberOfEvents to the per-layer expansion. Capping
972
+ * each layer to N events before the priority merge can drop a lower-
973
+ * priority (fallback) layer's post-block coverage: if a higher-priority
974
+ * layer's restricted block swallows the fallback's first N events, the
975
+ * fallback's (N+1)-th event — the true "next" on-call after the block —
976
+ * is never generated, corrupting the merged "next" roster. The cap is
977
+ * applied only once, after the merge, below.
978
+ */
580
979
  const layerEvents = this.getEvents({
581
980
  users: layer.users,
582
981
  startDateTimeOfLayer: layer.startDateTimeOfLayer,
583
982
  restrictionTimes: layer.restrictionTimes,
584
983
  handOffTime: layer.handOffTime,
585
984
  rotation: layer.rotation,
985
+ timezone: layer.timezone,
586
986
  calendarStartDate: data.calendarStartDate,
587
987
  calendarEndDate: data.calendarEndDate,
588
- }, options);
988
+ });
589
989
  // add priority to each event
590
990
  for (const layerEvent of layerEvents) {
591
991
  const priorityEvent = Object.assign(Object.assign({}, layerEvent), { priority: layerPriority });
@@ -654,10 +1054,32 @@ export default class LayerUtil {
654
1054
  * end time of the final event will be the start time of the current event - 1 second
655
1055
  */
656
1056
  const tempFinalEventEnd = finalEvent.end;
1057
+ /*
1058
+ * Reconstruct the trailing tail FIRST, before the front-collapse
1059
+ * removal below. If the lower-priority (fallback) event originally
1060
+ * extended past the higher-priority event, the portion AFTER the
1061
+ * higher-priority window must survive as its own segment — even when
1062
+ * the FRONT of the final event collapses to zero/negative length
1063
+ * (which happens when the higher-priority event starts at or before
1064
+ * the final event's start, e.g. two back-to-back higher-priority
1065
+ * rotation windows over a 24/7 fallback layer). Previously this block
1066
+ * ran only AFTER the collapse checks, whose `continue` skipped it,
1067
+ * silently deleting the fallback layer's coverage after the higher-
1068
+ * priority window and leaving on-call gaps where nobody is paged.
1069
+ */
1070
+ if (OneUptimeDate.isAfter(tempFinalEventEnd, event.end)) {
1071
+ // add the trailing segment of the lower-priority event
1072
+ const trimmedEvent = Object.assign(Object.assign({}, finalEvent), { priority: finalEvent.priority, start: OneUptimeDate.addRemoveSeconds(event.end, 1), end: tempFinalEventEnd });
1073
+ // only keep it if it has positive length
1074
+ if (OneUptimeDate.isAfter(trimmedEvent.end, trimmedEvent.start)) {
1075
+ finalEvents.push(trimmedEvent);
1076
+ }
1077
+ }
657
1078
  finalEvent.end = OneUptimeDate.addRemoveSeconds(event.start, -1);
658
1079
  /*
659
1080
  * check if the final event end time is before the start time of the current event
660
1081
  * if it is, we need to remove the final event from the final events array
1082
+ * (the trailing tail, if any, was already preserved above)
661
1083
  */
662
1084
  if (OneUptimeDate.isBefore(finalEvent.end, finalEvent.start)) {
663
1085
  finalEvents.splice(i, 1);
@@ -670,22 +1092,19 @@ export default class LayerUtil {
670
1092
  i--; // Adjust index after removal
671
1093
  continue;
672
1094
  }
673
- // final event was originally ending after the current event, so we need to add the trimmed event to the final events array
674
- if (OneUptimeDate.isAfter(tempFinalEventEnd, event.end)) {
675
- // add the trimmed event to the final events array
676
- const trimmedEvent = Object.assign(Object.assign({}, finalEvent), { priority: finalEvent.priority, start: OneUptimeDate.addRemoveSeconds(event.end, 1), end: tempFinalEventEnd });
677
- // check if the event end time is before the start time of the trimmed event
678
- if (OneUptimeDate.isAfter(trimmedEvent.end, trimmedEvent.start)) {
679
- finalEvents.push(trimmedEvent);
680
- }
681
- }
682
1095
  }
683
1096
  else {
684
1097
  /*
685
- * trim the current event based on the final event
686
- * start time of the current event will be the end time of the final event + 1 second
1098
+ * Trim the current (lower-priority) event: push its start past this
1099
+ * higher-priority window. Use getGreaterDate (a monotonic max)
1100
+ * instead of a bare assignment so the result does NOT depend on the
1101
+ * order finalEvents are visited. That makes it safe to hoist the
1102
+ * per-iteration finalEvents.sort() out of the loop (audit H2): with
1103
+ * the old in-loop ascending sort, successive overlaps already had
1104
+ * monotonically increasing ends, so max equals the old assignment and
1105
+ * the output is unchanged.
687
1106
  */
688
- event.start = OneUptimeDate.addRemoveSeconds(finalEvent.end, 1);
1107
+ event.start = OneUptimeDate.getGreaterDate(event.start, OneUptimeDate.addRemoveSeconds(finalEvent.end, 1));
689
1108
  }
690
1109
  }
691
1110
  }
@@ -693,16 +1112,14 @@ export default class LayerUtil {
693
1112
  if (OneUptimeDate.isAfter(event.end, event.start)) {
694
1113
  finalEvents.push(event);
695
1114
  }
696
- // sort by start times
697
- finalEvents.sort((a, b) => {
698
- if (OneUptimeDate.isBefore(a.start, b.start)) {
699
- return -1;
700
- }
701
- if (OneUptimeDate.isAfter(a.start, b.start)) {
702
- return 1;
703
- }
704
- return 0;
705
- });
1115
+ /*
1116
+ * The finalEvents.sort() that used to run HERE — inside the per-event
1117
+ * loop — made the merge O(n^2 log n). It is hoisted to a single sort after
1118
+ * the loop (below). Correctness is preserved because overlap detection and
1119
+ * trimming do not depend on finalEvents being sorted (the current-event
1120
+ * trim above now uses a monotonic max), so sorting once at the end yields
1121
+ * the same result. Audit H2.
1122
+ */
706
1123
  // if an event starts and end at the same time, we need to remove it
707
1124
  for (let index = 0; index < finalEvents.length; index++) {
708
1125
  const finalEvent = finalEvents[index];
@@ -721,6 +1138,20 @@ export default class LayerUtil {
721
1138
  }
722
1139
  }
723
1140
  }
1141
+ /*
1142
+ * Single final sort by start time (hoisted out of the per-event loop above,
1143
+ * audit H2). Downstream consumers (getEvents id assignment, the schedule
1144
+ * service's current/next selection) expect events in start order.
1145
+ */
1146
+ finalEvents.sort((a, b) => {
1147
+ if (OneUptimeDate.isBefore(a.start, b.start)) {
1148
+ return -1;
1149
+ }
1150
+ if (OneUptimeDate.isAfter(a.start, b.start)) {
1151
+ return 1;
1152
+ }
1153
+ return 0;
1154
+ });
724
1155
  // convert PriorityCalendarEvents to CalendarEvents
725
1156
  const calendarEvents = [];
726
1157
  let id = 1;