@oneuptime/common 12.0.29 → 12.0.31

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 (664) hide show
  1. package/Models/AnalyticsModels/SecurityEvent.ts +48 -16
  2. package/Models/AnalyticsModels/ThreatIntelIndicator.ts +10 -5
  3. package/Models/DatabaseModels/AlertSeverity.ts +44 -0
  4. package/Models/DatabaseModels/DetectionRule.ts +16 -5
  5. package/Models/DatabaseModels/GoogleSecOpsConnection.ts +14 -5
  6. package/Models/DatabaseModels/IncidentSeverity.ts +44 -0
  7. package/Models/DatabaseModels/Index.ts +13 -0
  8. package/Models/DatabaseModels/NetworkDevice.ts +113 -3
  9. package/Models/DatabaseModels/NetworkDeviceAutoImportRule.ts +88 -0
  10. package/Models/DatabaseModels/NetworkDeviceDiscoveryScan.ts +22 -0
  11. package/Models/DatabaseModels/NetworkDeviceOidTemplate.ts +499 -0
  12. package/Models/DatabaseModels/OnCallDutyPolicySchedule.ts +43 -0
  13. package/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.ts +662 -0
  14. package/Models/DatabaseModels/ProjectOnCallCalendarFeed.ts +584 -0
  15. package/Models/DatabaseModels/ThreatIntelFeed.ts +16 -5
  16. package/Models/DatabaseModels/UserOnCallCalendarFeed.ts +546 -0
  17. package/Models/DatabaseModels/UserOnCallShiftReminder.ts +231 -0
  18. package/Models/DatabaseModels/UserOnCallShiftReminderLog.ts +331 -0
  19. package/Server/API/GlobalConfigAPI.ts +362 -183
  20. package/Server/API/OnCallCalendarAPI.ts +2194 -0
  21. package/Server/API/TelemetryAPI.ts +42 -4
  22. package/Server/EnvironmentConfig.ts +250 -0
  23. package/Server/Infrastructure/OnCallCalendarFeedCache.ts +1050 -0
  24. package/Server/Infrastructure/Postgres/SchemaMigrations/1790400000000-AddOnCallCalendarFeeds.ts +253 -0
  25. package/Server/Infrastructure/Postgres/SchemaMigrations/1790500000000-AddNetworkDeviceOidTemplate.ts +59 -0
  26. package/Server/Infrastructure/Postgres/SchemaMigrations/1790600000000-AddAutoImportRuleOidTemplate.ts +25 -0
  27. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +6 -0
  28. package/Server/Middleware/MultipartFormData.ts +118 -32
  29. package/Server/Middleware/OnCallCalendarFeedRateLimit.ts +487 -0
  30. package/Server/Services/AlertEpisodeLabelRuleEngineService.ts +9 -1
  31. package/Server/Services/AlertEpisodeOnCallRuleEngineService.ts +9 -1
  32. package/Server/Services/AlertEpisodeOwnerRuleEngineService.ts +9 -1
  33. package/Server/Services/AlertEpisodePrivacyRuleEngineService.ts +9 -1
  34. package/Server/Services/AlertGroupingEngineService.ts +9 -1
  35. package/Server/Services/AlertLabelRuleEngineService.ts +9 -1
  36. package/Server/Services/AlertOnCallRuleEngineService.ts +9 -1
  37. package/Server/Services/AlertOwnerRuleEngineService.ts +9 -1
  38. package/Server/Services/AlertPrivacyRuleEngineService.ts +9 -1
  39. package/Server/Services/AlertReminderRuleService.ts +9 -1
  40. package/Server/Services/AutoRemediationRuleEngineService.ts +9 -1
  41. package/Server/Services/CephClusterLabelRuleEngineService.ts +9 -1
  42. package/Server/Services/CephClusterOwnerRuleEngineService.ts +9 -1
  43. package/Server/Services/CloudResourceLabelRuleEngineService.ts +9 -1
  44. package/Server/Services/CloudResourceOwnerRuleEngineService.ts +9 -1
  45. package/Server/Services/DashboardLabelRuleEngineService.ts +9 -1
  46. package/Server/Services/DashboardOwnerRuleEngineService.ts +9 -1
  47. package/Server/Services/DockerHostLabelRuleEngineService.ts +9 -1
  48. package/Server/Services/DockerHostOwnerRuleEngineService.ts +9 -1
  49. package/Server/Services/DockerSwarmClusterLabelRuleEngineService.ts +9 -1
  50. package/Server/Services/DockerSwarmClusterOwnerRuleEngineService.ts +9 -1
  51. package/Server/Services/HostLabelRuleEngineService.ts +9 -1
  52. package/Server/Services/HostOwnerRuleEngineService.ts +9 -1
  53. package/Server/Services/IncidentEpisodeLabelRuleEngineService.ts +9 -1
  54. package/Server/Services/IncidentEpisodeOnCallRuleEngineService.ts +9 -1
  55. package/Server/Services/IncidentEpisodeOwnerRuleEngineService.ts +9 -1
  56. package/Server/Services/IncidentEpisodePrivacyRuleEngineService.ts +9 -1
  57. package/Server/Services/IncidentGroupingEngineService.ts +9 -1
  58. package/Server/Services/IncidentLabelRuleEngineService.ts +9 -1
  59. package/Server/Services/IncidentOnCallRuleEngineService.ts +9 -1
  60. package/Server/Services/IncidentOwnerRuleEngineService.ts +9 -1
  61. package/Server/Services/IncidentPrivacyRuleEngineService.ts +9 -1
  62. package/Server/Services/IncidentReminderRuleService.ts +9 -1
  63. package/Server/Services/IncidentSlaRuleService.ts +9 -1
  64. package/Server/Services/IncomingCallPolicyLabelRuleEngineService.ts +9 -1
  65. package/Server/Services/IncomingCallPolicyOwnerRuleEngineService.ts +9 -1
  66. package/Server/Services/Index.ts +14 -0
  67. package/Server/Services/IoTFleetLabelRuleEngineService.ts +9 -1
  68. package/Server/Services/IoTFleetOwnerRuleEngineService.ts +9 -1
  69. package/Server/Services/KubernetesClusterLabelRuleEngineService.ts +9 -1
  70. package/Server/Services/KubernetesClusterOwnerRuleEngineService.ts +9 -1
  71. package/Server/Services/KubernetesResourceService.ts +5 -1
  72. package/Server/Services/MonitorLabelRuleEngineService.ts +9 -1
  73. package/Server/Services/MonitorOwnerRuleEngineService.ts +9 -1
  74. package/Server/Services/NetworkDeviceAutoImportRuleEngineService.ts +35 -0
  75. package/Server/Services/NetworkDeviceAutoImportRuleService.ts +96 -0
  76. package/Server/Services/NetworkDeviceLabelRuleEngineService.ts +9 -1
  77. package/Server/Services/NetworkDeviceOidTemplateService.ts +201 -0
  78. package/Server/Services/NetworkDeviceOwnerRuleEngineService.ts +9 -1
  79. package/Server/Services/NetworkDeviceService.ts +209 -1
  80. package/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.ts +32 -0
  81. package/Server/Services/OnCallDutyPolicyLabelRuleEngineService.ts +9 -1
  82. package/Server/Services/OnCallDutyPolicyOwnerRuleEngineService.ts +9 -1
  83. package/Server/Services/OnCallDutyPolicyScheduleCalendarFeedService.ts +260 -0
  84. package/Server/Services/OnCallDutyPolicyScheduleLabelRuleEngineService.ts +9 -1
  85. package/Server/Services/OnCallDutyPolicyScheduleLayerService.ts +70 -3
  86. package/Server/Services/OnCallDutyPolicyScheduleLayerUserService.ts +138 -3
  87. package/Server/Services/OnCallDutyPolicyScheduleOwnerRuleEngineService.ts +9 -1
  88. package/Server/Services/OnCallDutyPolicyScheduleService.ts +1465 -134
  89. package/Server/Services/OnCallDutyPolicyUserOverrideService.ts +167 -31
  90. package/Server/Services/PodmanHostLabelRuleEngineService.ts +9 -1
  91. package/Server/Services/PodmanHostOwnerRuleEngineService.ts +9 -1
  92. package/Server/Services/ProjectOnCallCalendarFeedService.ts +192 -0
  93. package/Server/Services/ProxmoxClusterLabelRuleEngineService.ts +9 -1
  94. package/Server/Services/ProxmoxClusterOwnerRuleEngineService.ts +9 -1
  95. package/Server/Services/RumApplicationLabelRuleEngineService.ts +9 -1
  96. package/Server/Services/RumApplicationOwnerRuleEngineService.ts +9 -1
  97. package/Server/Services/RunbookLabelRuleEngineService.ts +9 -1
  98. package/Server/Services/RunbookOwnerRuleEngineService.ts +9 -1
  99. package/Server/Services/RunbookRuleEngineService.ts +9 -1
  100. package/Server/Services/ScheduledMaintenanceLabelRuleEngineService.ts +9 -1
  101. package/Server/Services/ScheduledMaintenanceOwnerRuleEngineService.ts +9 -1
  102. package/Server/Services/ScheduledMaintenanceReminderRuleService.ts +9 -1
  103. package/Server/Services/ServerlessFunctionLabelRuleEngineService.ts +9 -1
  104. package/Server/Services/ServerlessFunctionOwnerRuleEngineService.ts +9 -1
  105. package/Server/Services/ServiceLabelRuleEngineService.ts +9 -1
  106. package/Server/Services/ServiceOwnerRuleEngineService.ts +9 -1
  107. package/Server/Services/StatusPageLabelRuleEngineService.ts +9 -1
  108. package/Server/Services/StatusPageMonitorRuleEngineService.ts +7 -0
  109. package/Server/Services/StatusPageOwnerRuleEngineService.ts +9 -1
  110. package/Server/Services/TeamMemberService.ts +489 -1
  111. package/Server/Services/TeamService.ts +50 -0
  112. package/Server/Services/TelemetrySourceMapService.ts +266 -23
  113. package/Server/Services/UserNotificationSettingService.ts +44 -1
  114. package/Server/Services/UserOnCallCalendarFeedService.ts +159 -0
  115. package/Server/Services/UserOnCallShiftReminderLogService.ts +151 -0
  116. package/Server/Services/UserOnCallShiftReminderService.ts +112 -0
  117. package/Server/Services/UserService.ts +27 -0
  118. package/Server/Services/WorkflowLabelRuleEngineService.ts +9 -1
  119. package/Server/Services/WorkflowOwnerRuleEngineService.ts +9 -1
  120. package/Server/Utils/EnterpriseLicense/EnterpriseLicenseSeatUtil.ts +186 -0
  121. package/Server/Utils/Monitor/CapturedMetricAttributeUtil.ts +245 -0
  122. package/Server/Utils/Monitor/Criteria/SnmpMonitorCriteria.ts +21 -2
  123. package/Server/Utils/Monitor/MonitorMetricUtil.ts +55 -21
  124. package/Server/Utils/Monitor/MonitorTemplateUtil.ts +32 -0
  125. package/Server/Utils/Monitor/NetworkDeviceMetricUtil.ts +15 -2
  126. package/Server/Utils/Monitor/NetworkDeviceWalkUtil.ts +18 -1
  127. package/Server/Utils/Monitor/NetworkInventoryUtil.ts +10 -0
  128. package/Server/Utils/Monitor/SeriesResourceLabels.ts +34 -0
  129. package/Server/Utils/OnCall/CalendarFeedToken.ts +241 -0
  130. package/Server/Utils/OnCall/OnCallCalendarFeedRenderer.ts +1488 -0
  131. package/Server/Utils/OnCall/OnCallCalendarFeedUrls.ts +230 -0
  132. package/Server/Utils/OnCall/OnCallShiftChangeListeners.ts +227 -0
  133. package/Server/Utils/OnCall/OnCallShiftMaterializer.ts +1127 -0
  134. package/Server/Utils/OnCall/OnCallShiftReminderListener.ts +174 -0
  135. package/Server/Utils/OnCall/OnCallShiftReminderRunner.ts +2584 -0
  136. package/Server/Utils/Response.ts +134 -0
  137. package/Server/Utils/Rules/RuleEngineRuleRead.ts +48 -0
  138. package/Server/Utils/StartServer.ts +10 -0
  139. package/Server/Utils/Telemetry/AppMetrics.ts +51 -0
  140. package/Server/Utils/Telemetry/ExceptionSanitizer.ts +14 -1
  141. package/Server/Utils/Telemetry/LogExceptionExtractor.ts +14 -2
  142. package/Server/Utils/Telemetry/SourceMapResolver.ts +16 -5
  143. package/Server/Utils/Telemetry/StackTraceParser.ts +399 -0
  144. package/Tests/App/Dashboard/EventOverlayHook.test.tsx +125 -0
  145. package/Tests/App/Dashboard/HeaderImageAssets.test.tsx +93 -0
  146. package/Tests/App/Dashboard/OnCallCalendarFeedLinks.test.tsx +229 -0
  147. package/Tests/App/Dashboard/OnCallCalendarFeedPlanGate.test.tsx +489 -0
  148. package/Tests/App/Dashboard/OnCallCalendarFeedSideMenus.test.tsx +149 -0
  149. package/Tests/App/Dashboard/OnCallCalendarFeedUtil.test.ts +855 -0
  150. package/Tests/App/Dashboard/OnCallPersonalCalendarFeed.test.tsx +1209 -0
  151. package/Tests/App/Dashboard/OnCallReadinessSurfaces.test.tsx +3 -3
  152. package/Tests/App/Dashboard/OnCallSharedCalendarFeedCard.test.tsx +1019 -0
  153. package/Tests/App/Dashboard/OnCallShiftRemindersAndUpcomingShifts.test.tsx +655 -0
  154. package/Tests/App/Dashboard/UserElementEmail.test.tsx +12 -4
  155. package/Tests/App/Dashboard/UserEmailCallSites.test.tsx +1 -1
  156. package/Tests/App/Dashboard/UserSettingsSetupChecklistModel.test.ts +73 -0
  157. package/Tests/Models/DatabaseModels/DomainRoleTierCoverage.test.ts +14 -0
  158. package/Tests/Models/DatabaseModels/ModelRegistryInvariants.test.ts +469 -0
  159. package/Tests/Models/OnCallCalendarFeedModels.test.ts +642 -0
  160. package/Tests/Models/OnCallDutyPolicyScheduleCalendarFeed.test.ts +288 -0
  161. package/Tests/Models/OnCallDutyPolicyScheduleShiftConfigVersion.test.ts +88 -0
  162. package/Tests/Models/SecurityDomainAccessControl.test.ts +429 -0
  163. package/Tests/Models/UserOnCallShiftReminderModels.test.ts +368 -0
  164. package/Tests/Server/API/GlobalConfigLicense.test.ts +657 -0
  165. package/Tests/Server/API/Helpers.ts +8 -0
  166. package/Tests/Server/API/OnCallCalendarAPI.test.ts +4461 -0
  167. package/Tests/Server/API/SecurityEventAttributesAPI.test.ts +84 -6
  168. package/Tests/Server/API/TelemetryResolveStackTraceAPI.test.ts +805 -0
  169. package/Tests/Server/EnvironmentConfigOnCallCalendarFeed.test.ts +357 -0
  170. package/Tests/Server/Infrastructure/OnCallCalendarFeedCache.test.ts +1589 -0
  171. package/Tests/Server/Infrastructure/Postgres/AddOnCallCalendarFeedsMigration.test.ts +394 -0
  172. package/Tests/Server/Middleware/IdentityRateLimit.test.ts +1144 -0
  173. package/Tests/Server/Middleware/MultipartFormData.test.ts +618 -3
  174. package/Tests/Server/Middleware/OnCallCalendarFeedRateLimit.test.ts +1365 -0
  175. package/Tests/Server/Services/FeedRetentionConsistency.test.ts +70 -0
  176. package/Tests/Server/Services/MonitorLabelRuleEngineScale.test.ts +520 -0
  177. package/Tests/Server/Services/NetworkDeviceOidTemplateService.test.ts +332 -0
  178. package/Tests/Server/Services/NetworkDeviceOidTemplateWriteGuards.test.ts +409 -0
  179. package/Tests/Server/Services/OnCallCalendarFeedServices.test.ts +1103 -0
  180. package/Tests/Server/Services/OnCallDutyPolicyScheduleResolver.test.ts +1037 -0
  181. package/Tests/Server/Services/OnCallShiftConfigPropagation.test.ts +1270 -0
  182. package/Tests/Server/Services/RuleEngineRuleFetchLimit.test.ts +239 -0
  183. package/Tests/Server/Services/TeamDeleteMemberCleanup.test.ts +156 -0
  184. package/Tests/Server/Services/TeamMemberOnCallCleanup.test.ts +755 -0
  185. package/Tests/Server/Services/TelemetrySourceMapService.test.ts +1210 -2
  186. package/Tests/Server/Services/UserNotificationSettingShiftReminderDefaults.test.ts +267 -0
  187. package/Tests/Server/Services/UserOnCallShiftReminderServices.test.ts +583 -0
  188. package/Tests/Server/Services/UserServiceSeatLimit.test.ts +182 -0
  189. package/Tests/Server/Types/AnalyticsDatabase/SecurityEventModelPermission.test.ts +255 -0
  190. package/Tests/Server/Types/Database/Permissions/OnCallCalendarFeedPermissions.test.ts +254 -0
  191. package/Tests/Server/Types/Database/Permissions/ReadBlockPermission.test.ts +212 -0
  192. package/Tests/Server/Types/Database/Permissions/SecurityRoleAccess.test.ts +413 -0
  193. package/Tests/Server/Utils/CodeRepository.test.ts +689 -0
  194. package/Tests/Server/Utils/EnterpriseLicense/EnterpriseLicenseSeatUtil.test.ts +495 -0
  195. package/Tests/Server/Utils/ExceptionSanitizer.test.ts +77 -0
  196. package/Tests/Server/Utils/Monitor/CapturedMetricAttributeUtil.test.ts +284 -0
  197. package/Tests/Server/Utils/Monitor/MonitorCustomMetricAttributes.test.ts +503 -0
  198. package/Tests/Server/Utils/Monitor/MonitorTemplatePrototypePollution.test.ts +106 -0
  199. package/Tests/Server/Utils/Monitor/NetworkDeviceWalkLogWritePath.test.ts +33 -4
  200. package/Tests/Server/Utils/Monitor/NetworkDeviceWalkUtil.test.ts +14 -4
  201. package/Tests/Server/Utils/Monitor/NetworkInventoryUtil.test.ts +44 -0
  202. package/Tests/Server/Utils/OnCall/CalendarFeedToken.test.ts +467 -0
  203. package/Tests/Server/Utils/OnCall/OnCallCalendarFeedRenderer.test.ts +2142 -0
  204. package/Tests/Server/Utils/OnCall/OnCallCalendarFeedUrls.test.ts +278 -0
  205. package/Tests/Server/Utils/OnCall/OnCallResolverTestHarness.ts +440 -0
  206. package/Tests/Server/Utils/OnCall/OnCallShiftChangeListeners.test.ts +314 -0
  207. package/Tests/Server/Utils/OnCall/OnCallShiftMaterializer.test.ts +1502 -0
  208. package/Tests/Server/Utils/OnCall/OnCallShiftReminderListener.test.ts +491 -0
  209. package/Tests/Server/Utils/OnCall/OnCallShiftReminderRunner.test.ts +3088 -0
  210. package/Tests/Server/Utils/OnCall/OnCallShiftReminderTestHarness.ts +910 -0
  211. package/Tests/Server/Utils/OpenAPISpec.test.ts +536 -0
  212. package/Tests/Server/Utils/ResponseSendCalendarResponse.test.ts +561 -0
  213. package/Tests/Server/Utils/Rules/RuleEngineRuleRead.test.ts +115 -0
  214. package/Tests/Server/Utils/StackTraceParser.test.ts +610 -0
  215. package/Tests/Server/Utils/StartServerEncryptionSecretWarning.test.ts +64 -0
  216. package/Tests/Server/Utils/Telemetry/AppMetrics.test.ts +29 -0
  217. package/Tests/Server/Utils/Telemetry/LogExceptionExtractor.test.ts +0 -0
  218. package/Tests/Server/Utils/Telemetry/SourceMapLimits.test.ts +656 -0
  219. package/Tests/Server/Utils/Telemetry/SourceMapResolver.test.ts +100 -0
  220. package/Tests/Types/Calendar/ICalendar.test.ts +488 -0
  221. package/Tests/Types/Monitor/SnmpMonitor/SnmpOidListUtil.test.ts +454 -0
  222. package/Tests/Types/NotificationSetting/NotificationSettingEventType.test.ts +53 -0
  223. package/Tests/Types/OnCallDutyPolicy/CalendarFeedTestFixtures.ts +310 -0
  224. package/Tests/Types/OnCallDutyPolicy/CalendarFeedWindow.test.ts +205 -0
  225. package/Tests/Types/OnCallDutyPolicy/LayerUtilLayerMetaAndIterationCap.test.ts +458 -0
  226. package/Tests/Types/OnCallDutyPolicy/MaterializedShift.test.ts +197 -0
  227. package/Tests/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.test.ts +1986 -0
  228. package/Tests/Types/OnCallDutyPolicy/ScheduleShiftUtilGroupKey.test.ts +678 -0
  229. package/Tests/Types/OnCallDutyPolicy/ShiftSeamUtil.test.ts +417 -0
  230. package/Tests/Types/SecurityRolePermissions.test.ts +193 -0
  231. package/Tests/UI/Components/Charts/AnnotationLayout.test.ts +773 -0
  232. package/Tests/UI/Components/Charts/ChartAnnotationRail.test.tsx +789 -0
  233. package/Tests/UI/Components/Charts/ChartBucketIdentity.test.ts +254 -0
  234. package/Tests/UI/Components/Charts/TimeAnnotation.test.ts +471 -0
  235. package/Tests/UI/Components/EditionLabelLicenseRefresh.test.tsx +405 -0
  236. package/Tests/UI/Components/ErrorMessageRefreshControl.test.tsx +83 -0
  237. package/Tests/UI/Components/MarkdownConverters.test.ts +686 -0
  238. package/Tests/UI/Components/ModelTable/BaseModelTableDeleteAndEmptyStates.test.tsx +341 -0
  239. package/Tests/UI/Components/OrderedStatesList.test.tsx +5 -5
  240. package/Tests/UI/Components/PendingProjectInvitations.test.tsx +1 -1
  241. package/Tests/UI/Components/TableSortAndMobileColumns.test.tsx +195 -0
  242. package/Tests/UI/Components/XAxis.test.ts +270 -5
  243. package/Tests/UI/Components/XAxisDaylightSaving.test.ts +113 -0
  244. package/Tests/UI/Styles/ChartFocusRing.test.ts +153 -0
  245. package/Tests/UI/Styles/ChartFocusRingCoverage.test.tsx +377 -0
  246. package/Tests/UI/Styles/ThemeStylesheet.ts +162 -0
  247. package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +10 -0
  248. package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +2 -0
  249. package/Tests/Utils/EnterpriseLicenseSeats.test.ts +507 -0
  250. package/Tests/Utils/NetworkDiscovery/DiscoveredDeviceBuilder.test.ts +951 -1
  251. package/Tests/Utils/NetworkDiscovery/DiscoveredHostUtil.test.ts +238 -0
  252. package/Tests/Utils/NetworkDiscovery/DiscoveryReverseDnsChain.test.ts +1056 -0
  253. package/Tests/Utils/NetworkDiscovery/ReverseDnsNameUtil.test.ts +882 -0
  254. package/Types/Calendar/ICalendar.ts +442 -0
  255. package/Types/Date.ts +39 -0
  256. package/Types/Email/EmailTemplateType.ts +2 -0
  257. package/Types/Monitor/SnmpMonitor/SnmpOidListUtil.ts +333 -0
  258. package/Types/NotificationSetting/NotificationSettingEventType.ts +4 -0
  259. package/Types/OnCallDutyPolicy/CalendarFeedWindow.ts +140 -0
  260. package/Types/OnCallDutyPolicy/Layer.ts +190 -35
  261. package/Types/OnCallDutyPolicy/MaterializedShift.ts +274 -0
  262. package/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.ts +1415 -0
  263. package/Types/OnCallDutyPolicy/ScheduleShiftUtil.ts +159 -4
  264. package/Types/OnCallDutyPolicy/ShiftSeamUtil.ts +106 -0
  265. package/Types/Permission.ts +96 -0
  266. package/Types/Rum/SessionReplay.ts +21 -0
  267. package/Types/Telemetry/SourceMap.ts +8 -0
  268. package/UI/Components/Charts/ChartLibrary/Annotations/AnnotationHoverCard.tsx +176 -0
  269. package/UI/Components/Charts/ChartLibrary/Annotations/AnnotationLayout.ts +745 -0
  270. package/UI/Components/Charts/ChartLibrary/Annotations/ChartAnnotationLayer.tsx +576 -0
  271. package/UI/Components/Charts/ChartLibrary/Annotations/UseChartAnnotations.tsx +204 -0
  272. package/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.tsx +491 -544
  273. package/UI/Components/Charts/ChartLibrary/BarChart/BarChart.tsx +324 -372
  274. package/UI/Components/Charts/ChartLibrary/LineChart/LineChart.tsx +487 -540
  275. package/UI/Components/Charts/ChartLibrary/Types/FormattedReferenceRegion.ts +7 -3
  276. package/UI/Components/Charts/ChartLibrary/Types/FormattedTimeReferenceLine.ts +14 -3
  277. package/UI/Components/Charts/Types/ChartEventKind.ts +13 -0
  278. package/UI/Components/Charts/Types/ReferenceRegionProps.ts +6 -0
  279. package/UI/Components/Charts/Types/TimeReferenceLineProps.ts +20 -3
  280. package/UI/Components/Charts/Utils/TimeAnnotation.ts +69 -35
  281. package/UI/Components/Charts/Utils/XAxis.ts +125 -18
  282. package/UI/Components/EditionLabel/EditionLabel.tsx +280 -26
  283. package/UI/Components/ErrorMessage/ErrorMessage.tsx +21 -10
  284. package/UI/Components/Feed/FeedItem.tsx +4 -5
  285. package/UI/Components/Header/HeaderIconDropdownButton.tsx +1 -2
  286. package/UI/Components/Header/UserProfile/UserProfile.tsx +1 -2
  287. package/UI/Components/Image/Image.tsx +20 -3
  288. package/UI/Components/Markdown.tsx/MarkdownConverters.ts +0 -0
  289. package/UI/Components/MemberRoleAssignment/MemberRoleAssignment.tsx +1 -2
  290. package/UI/Components/ModelTable/BaseModelTable.tsx +150 -24
  291. package/UI/Components/Table/TableHeader.tsx +64 -48
  292. package/UI/Components/Table/TableRow.tsx +13 -3
  293. package/UI/Styles/Theme.css +38 -0
  294. package/Utils/EnterpriseLicense/EnterpriseLicenseSeats.ts +271 -0
  295. package/Utils/NetworkDiscovery/AutoImportRuleMatcher.ts +6 -0
  296. package/Utils/NetworkDiscovery/DiscoveredDeviceBuilder.ts +141 -10
  297. package/Utils/NetworkDiscovery/DiscoveredHostUtil.ts +55 -3
  298. package/Utils/NetworkDiscovery/ReverseDnsNameUtil.ts +151 -0
  299. package/Utils/Rules/RuleEngineLimits.ts +32 -0
  300. package/build/dist/Models/AnalyticsModels/SecurityEvent.js +48 -16
  301. package/build/dist/Models/AnalyticsModels/SecurityEvent.js.map +1 -1
  302. package/build/dist/Models/AnalyticsModels/ThreatIntelIndicator.js +10 -5
  303. package/build/dist/Models/AnalyticsModels/ThreatIntelIndicator.js.map +1 -1
  304. package/build/dist/Models/DatabaseModels/AlertSeverity.js +46 -1
  305. package/build/dist/Models/DatabaseModels/AlertSeverity.js.map +1 -1
  306. package/build/dist/Models/DatabaseModels/DetectionRule.js +16 -5
  307. package/build/dist/Models/DatabaseModels/DetectionRule.js.map +1 -1
  308. package/build/dist/Models/DatabaseModels/GoogleSecOpsConnection.js +14 -5
  309. package/build/dist/Models/DatabaseModels/GoogleSecOpsConnection.js.map +1 -1
  310. package/build/dist/Models/DatabaseModels/IncidentSeverity.js +46 -1
  311. package/build/dist/Models/DatabaseModels/IncidentSeverity.js.map +1 -1
  312. package/build/dist/Models/DatabaseModels/Index.js +13 -0
  313. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  314. package/build/dist/Models/DatabaseModels/NetworkDevice.js +115 -3
  315. package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
  316. package/build/dist/Models/DatabaseModels/NetworkDeviceAutoImportRule.js +88 -0
  317. package/build/dist/Models/DatabaseModels/NetworkDeviceAutoImportRule.js.map +1 -1
  318. package/build/dist/Models/DatabaseModels/NetworkDeviceDiscoveryScan.js.map +1 -1
  319. package/build/dist/Models/DatabaseModels/NetworkDeviceOidTemplate.js +519 -0
  320. package/build/dist/Models/DatabaseModels/NetworkDeviceOidTemplate.js.map +1 -0
  321. package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js +44 -0
  322. package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js.map +1 -1
  323. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.js +699 -0
  324. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.js.map +1 -0
  325. package/build/dist/Models/DatabaseModels/ProjectOnCallCalendarFeed.js +620 -0
  326. package/build/dist/Models/DatabaseModels/ProjectOnCallCalendarFeed.js.map +1 -0
  327. package/build/dist/Models/DatabaseModels/ThreatIntelFeed.js +16 -5
  328. package/build/dist/Models/DatabaseModels/ThreatIntelFeed.js.map +1 -1
  329. package/build/dist/Models/DatabaseModels/UserOnCallCalendarFeed.js +585 -0
  330. package/build/dist/Models/DatabaseModels/UserOnCallCalendarFeed.js.map +1 -0
  331. package/build/dist/Models/DatabaseModels/UserOnCallShiftReminder.js +247 -0
  332. package/build/dist/Models/DatabaseModels/UserOnCallShiftReminder.js.map +1 -0
  333. package/build/dist/Models/DatabaseModels/UserOnCallShiftReminderLog.js +355 -0
  334. package/build/dist/Models/DatabaseModels/UserOnCallShiftReminderLog.js.map +1 -0
  335. package/build/dist/Server/API/GlobalConfigAPI.js +293 -176
  336. package/build/dist/Server/API/GlobalConfigAPI.js.map +1 -1
  337. package/build/dist/Server/API/OnCallCalendarAPI.js +1371 -0
  338. package/build/dist/Server/API/OnCallCalendarAPI.js.map +1 -0
  339. package/build/dist/Server/API/TelemetryAPI.js +31 -3
  340. package/build/dist/Server/API/TelemetryAPI.js.map +1 -1
  341. package/build/dist/Server/EnvironmentConfig.js +185 -0
  342. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  343. package/build/dist/Server/Infrastructure/OnCallCalendarFeedCache.js +663 -0
  344. package/build/dist/Server/Infrastructure/OnCallCalendarFeedCache.js.map +1 -0
  345. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790400000000-AddOnCallCalendarFeeds.js +103 -0
  346. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790400000000-AddOnCallCalendarFeeds.js.map +1 -0
  347. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790500000000-AddNetworkDeviceOidTemplate.js +26 -0
  348. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790500000000-AddNetworkDeviceOidTemplate.js.map +1 -0
  349. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790600000000-AddAutoImportRuleOidTemplate.js +14 -0
  350. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790600000000-AddAutoImportRuleOidTemplate.js.map +1 -0
  351. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +6 -0
  352. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  353. package/build/dist/Server/Middleware/MultipartFormData.js +93 -28
  354. package/build/dist/Server/Middleware/MultipartFormData.js.map +1 -1
  355. package/build/dist/Server/Middleware/OnCallCalendarFeedRateLimit.js +342 -0
  356. package/build/dist/Server/Middleware/OnCallCalendarFeedRateLimit.js.map +1 -0
  357. package/build/dist/Server/Services/AlertEpisodeLabelRuleEngineService.js +8 -1
  358. package/build/dist/Server/Services/AlertEpisodeLabelRuleEngineService.js.map +1 -1
  359. package/build/dist/Server/Services/AlertEpisodeOnCallRuleEngineService.js +8 -1
  360. package/build/dist/Server/Services/AlertEpisodeOnCallRuleEngineService.js.map +1 -1
  361. package/build/dist/Server/Services/AlertEpisodeOwnerRuleEngineService.js +8 -1
  362. package/build/dist/Server/Services/AlertEpisodeOwnerRuleEngineService.js.map +1 -1
  363. package/build/dist/Server/Services/AlertEpisodePrivacyRuleEngineService.js +8 -1
  364. package/build/dist/Server/Services/AlertEpisodePrivacyRuleEngineService.js.map +1 -1
  365. package/build/dist/Server/Services/AlertGroupingEngineService.js +8 -1
  366. package/build/dist/Server/Services/AlertGroupingEngineService.js.map +1 -1
  367. package/build/dist/Server/Services/AlertLabelRuleEngineService.js +8 -1
  368. package/build/dist/Server/Services/AlertLabelRuleEngineService.js.map +1 -1
  369. package/build/dist/Server/Services/AlertOnCallRuleEngineService.js +8 -1
  370. package/build/dist/Server/Services/AlertOnCallRuleEngineService.js.map +1 -1
  371. package/build/dist/Server/Services/AlertOwnerRuleEngineService.js +8 -1
  372. package/build/dist/Server/Services/AlertOwnerRuleEngineService.js.map +1 -1
  373. package/build/dist/Server/Services/AlertPrivacyRuleEngineService.js +8 -1
  374. package/build/dist/Server/Services/AlertPrivacyRuleEngineService.js.map +1 -1
  375. package/build/dist/Server/Services/AlertReminderRuleService.js +8 -1
  376. package/build/dist/Server/Services/AlertReminderRuleService.js.map +1 -1
  377. package/build/dist/Server/Services/AutoRemediationRuleEngineService.js +8 -1
  378. package/build/dist/Server/Services/AutoRemediationRuleEngineService.js.map +1 -1
  379. package/build/dist/Server/Services/CephClusterLabelRuleEngineService.js +8 -1
  380. package/build/dist/Server/Services/CephClusterLabelRuleEngineService.js.map +1 -1
  381. package/build/dist/Server/Services/CephClusterOwnerRuleEngineService.js +8 -1
  382. package/build/dist/Server/Services/CephClusterOwnerRuleEngineService.js.map +1 -1
  383. package/build/dist/Server/Services/CloudResourceLabelRuleEngineService.js +8 -1
  384. package/build/dist/Server/Services/CloudResourceLabelRuleEngineService.js.map +1 -1
  385. package/build/dist/Server/Services/CloudResourceOwnerRuleEngineService.js +8 -1
  386. package/build/dist/Server/Services/CloudResourceOwnerRuleEngineService.js.map +1 -1
  387. package/build/dist/Server/Services/DashboardLabelRuleEngineService.js +8 -1
  388. package/build/dist/Server/Services/DashboardLabelRuleEngineService.js.map +1 -1
  389. package/build/dist/Server/Services/DashboardOwnerRuleEngineService.js +8 -1
  390. package/build/dist/Server/Services/DashboardOwnerRuleEngineService.js.map +1 -1
  391. package/build/dist/Server/Services/DockerHostLabelRuleEngineService.js +8 -1
  392. package/build/dist/Server/Services/DockerHostLabelRuleEngineService.js.map +1 -1
  393. package/build/dist/Server/Services/DockerHostOwnerRuleEngineService.js +8 -1
  394. package/build/dist/Server/Services/DockerHostOwnerRuleEngineService.js.map +1 -1
  395. package/build/dist/Server/Services/DockerSwarmClusterLabelRuleEngineService.js +8 -1
  396. package/build/dist/Server/Services/DockerSwarmClusterLabelRuleEngineService.js.map +1 -1
  397. package/build/dist/Server/Services/DockerSwarmClusterOwnerRuleEngineService.js +8 -1
  398. package/build/dist/Server/Services/DockerSwarmClusterOwnerRuleEngineService.js.map +1 -1
  399. package/build/dist/Server/Services/HostLabelRuleEngineService.js +8 -1
  400. package/build/dist/Server/Services/HostLabelRuleEngineService.js.map +1 -1
  401. package/build/dist/Server/Services/HostOwnerRuleEngineService.js +8 -1
  402. package/build/dist/Server/Services/HostOwnerRuleEngineService.js.map +1 -1
  403. package/build/dist/Server/Services/IncidentEpisodeLabelRuleEngineService.js +8 -1
  404. package/build/dist/Server/Services/IncidentEpisodeLabelRuleEngineService.js.map +1 -1
  405. package/build/dist/Server/Services/IncidentEpisodeOnCallRuleEngineService.js +8 -1
  406. package/build/dist/Server/Services/IncidentEpisodeOnCallRuleEngineService.js.map +1 -1
  407. package/build/dist/Server/Services/IncidentEpisodeOwnerRuleEngineService.js +8 -1
  408. package/build/dist/Server/Services/IncidentEpisodeOwnerRuleEngineService.js.map +1 -1
  409. package/build/dist/Server/Services/IncidentEpisodePrivacyRuleEngineService.js +8 -1
  410. package/build/dist/Server/Services/IncidentEpisodePrivacyRuleEngineService.js.map +1 -1
  411. package/build/dist/Server/Services/IncidentGroupingEngineService.js +8 -1
  412. package/build/dist/Server/Services/IncidentGroupingEngineService.js.map +1 -1
  413. package/build/dist/Server/Services/IncidentLabelRuleEngineService.js +8 -1
  414. package/build/dist/Server/Services/IncidentLabelRuleEngineService.js.map +1 -1
  415. package/build/dist/Server/Services/IncidentOnCallRuleEngineService.js +8 -1
  416. package/build/dist/Server/Services/IncidentOnCallRuleEngineService.js.map +1 -1
  417. package/build/dist/Server/Services/IncidentOwnerRuleEngineService.js +8 -1
  418. package/build/dist/Server/Services/IncidentOwnerRuleEngineService.js.map +1 -1
  419. package/build/dist/Server/Services/IncidentPrivacyRuleEngineService.js +8 -1
  420. package/build/dist/Server/Services/IncidentPrivacyRuleEngineService.js.map +1 -1
  421. package/build/dist/Server/Services/IncidentReminderRuleService.js +8 -1
  422. package/build/dist/Server/Services/IncidentReminderRuleService.js.map +1 -1
  423. package/build/dist/Server/Services/IncidentSlaRuleService.js +8 -1
  424. package/build/dist/Server/Services/IncidentSlaRuleService.js.map +1 -1
  425. package/build/dist/Server/Services/IncomingCallPolicyLabelRuleEngineService.js +8 -1
  426. package/build/dist/Server/Services/IncomingCallPolicyLabelRuleEngineService.js.map +1 -1
  427. package/build/dist/Server/Services/IncomingCallPolicyOwnerRuleEngineService.js +8 -1
  428. package/build/dist/Server/Services/IncomingCallPolicyOwnerRuleEngineService.js.map +1 -1
  429. package/build/dist/Server/Services/Index.js +14 -0
  430. package/build/dist/Server/Services/Index.js.map +1 -1
  431. package/build/dist/Server/Services/IoTFleetLabelRuleEngineService.js +8 -1
  432. package/build/dist/Server/Services/IoTFleetLabelRuleEngineService.js.map +1 -1
  433. package/build/dist/Server/Services/IoTFleetOwnerRuleEngineService.js +8 -1
  434. package/build/dist/Server/Services/IoTFleetOwnerRuleEngineService.js.map +1 -1
  435. package/build/dist/Server/Services/KubernetesClusterLabelRuleEngineService.js +8 -1
  436. package/build/dist/Server/Services/KubernetesClusterLabelRuleEngineService.js.map +1 -1
  437. package/build/dist/Server/Services/KubernetesClusterOwnerRuleEngineService.js +8 -1
  438. package/build/dist/Server/Services/KubernetesClusterOwnerRuleEngineService.js.map +1 -1
  439. package/build/dist/Server/Services/KubernetesResourceService.js +5 -1
  440. package/build/dist/Server/Services/KubernetesResourceService.js.map +1 -1
  441. package/build/dist/Server/Services/MonitorLabelRuleEngineService.js +8 -1
  442. package/build/dist/Server/Services/MonitorLabelRuleEngineService.js.map +1 -1
  443. package/build/dist/Server/Services/MonitorOwnerRuleEngineService.js +8 -1
  444. package/build/dist/Server/Services/MonitorOwnerRuleEngineService.js.map +1 -1
  445. package/build/dist/Server/Services/NetworkDeviceAutoImportRuleEngineService.js +30 -21
  446. package/build/dist/Server/Services/NetworkDeviceAutoImportRuleEngineService.js.map +1 -1
  447. package/build/dist/Server/Services/NetworkDeviceAutoImportRuleService.js +64 -0
  448. package/build/dist/Server/Services/NetworkDeviceAutoImportRuleService.js.map +1 -1
  449. package/build/dist/Server/Services/NetworkDeviceLabelRuleEngineService.js +8 -1
  450. package/build/dist/Server/Services/NetworkDeviceLabelRuleEngineService.js.map +1 -1
  451. package/build/dist/Server/Services/NetworkDeviceOidTemplateService.js +180 -0
  452. package/build/dist/Server/Services/NetworkDeviceOidTemplateService.js.map +1 -0
  453. package/build/dist/Server/Services/NetworkDeviceOwnerRuleEngineService.js +8 -1
  454. package/build/dist/Server/Services/NetworkDeviceOwnerRuleEngineService.js.map +1 -1
  455. package/build/dist/Server/Services/NetworkDeviceService.js +154 -1
  456. package/build/dist/Server/Services/NetworkDeviceService.js.map +1 -1
  457. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js +20 -0
  458. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js.map +1 -1
  459. package/build/dist/Server/Services/OnCallDutyPolicyLabelRuleEngineService.js +8 -1
  460. package/build/dist/Server/Services/OnCallDutyPolicyLabelRuleEngineService.js.map +1 -1
  461. package/build/dist/Server/Services/OnCallDutyPolicyOwnerRuleEngineService.js +8 -1
  462. package/build/dist/Server/Services/OnCallDutyPolicyOwnerRuleEngineService.js.map +1 -1
  463. package/build/dist/Server/Services/OnCallDutyPolicyScheduleCalendarFeedService.js +219 -0
  464. package/build/dist/Server/Services/OnCallDutyPolicyScheduleCalendarFeedService.js.map +1 -0
  465. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLabelRuleEngineService.js +8 -1
  466. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLabelRuleEngineService.js.map +1 -1
  467. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerService.js +47 -3
  468. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerService.js.map +1 -1
  469. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerUserService.js +112 -3
  470. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerUserService.js.map +1 -1
  471. package/build/dist/Server/Services/OnCallDutyPolicyScheduleOwnerRuleEngineService.js +8 -1
  472. package/build/dist/Server/Services/OnCallDutyPolicyScheduleOwnerRuleEngineService.js.map +1 -1
  473. package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js +955 -9
  474. package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js.map +1 -1
  475. package/build/dist/Server/Services/OnCallDutyPolicyUserOverrideService.js +93 -6
  476. package/build/dist/Server/Services/OnCallDutyPolicyUserOverrideService.js.map +1 -1
  477. package/build/dist/Server/Services/PodmanHostLabelRuleEngineService.js +8 -1
  478. package/build/dist/Server/Services/PodmanHostLabelRuleEngineService.js.map +1 -1
  479. package/build/dist/Server/Services/PodmanHostOwnerRuleEngineService.js +8 -1
  480. package/build/dist/Server/Services/PodmanHostOwnerRuleEngineService.js.map +1 -1
  481. package/build/dist/Server/Services/ProjectOnCallCalendarFeedService.js +157 -0
  482. package/build/dist/Server/Services/ProjectOnCallCalendarFeedService.js.map +1 -0
  483. package/build/dist/Server/Services/ProxmoxClusterLabelRuleEngineService.js +8 -1
  484. package/build/dist/Server/Services/ProxmoxClusterLabelRuleEngineService.js.map +1 -1
  485. package/build/dist/Server/Services/ProxmoxClusterOwnerRuleEngineService.js +8 -1
  486. package/build/dist/Server/Services/ProxmoxClusterOwnerRuleEngineService.js.map +1 -1
  487. package/build/dist/Server/Services/RumApplicationLabelRuleEngineService.js +8 -1
  488. package/build/dist/Server/Services/RumApplicationLabelRuleEngineService.js.map +1 -1
  489. package/build/dist/Server/Services/RumApplicationOwnerRuleEngineService.js +8 -1
  490. package/build/dist/Server/Services/RumApplicationOwnerRuleEngineService.js.map +1 -1
  491. package/build/dist/Server/Services/RunbookLabelRuleEngineService.js +8 -1
  492. package/build/dist/Server/Services/RunbookLabelRuleEngineService.js.map +1 -1
  493. package/build/dist/Server/Services/RunbookOwnerRuleEngineService.js +8 -1
  494. package/build/dist/Server/Services/RunbookOwnerRuleEngineService.js.map +1 -1
  495. package/build/dist/Server/Services/RunbookRuleEngineService.js +8 -1
  496. package/build/dist/Server/Services/RunbookRuleEngineService.js.map +1 -1
  497. package/build/dist/Server/Services/ScheduledMaintenanceLabelRuleEngineService.js +8 -1
  498. package/build/dist/Server/Services/ScheduledMaintenanceLabelRuleEngineService.js.map +1 -1
  499. package/build/dist/Server/Services/ScheduledMaintenanceOwnerRuleEngineService.js +8 -1
  500. package/build/dist/Server/Services/ScheduledMaintenanceOwnerRuleEngineService.js.map +1 -1
  501. package/build/dist/Server/Services/ScheduledMaintenanceReminderRuleService.js +8 -1
  502. package/build/dist/Server/Services/ScheduledMaintenanceReminderRuleService.js.map +1 -1
  503. package/build/dist/Server/Services/ServerlessFunctionLabelRuleEngineService.js +8 -1
  504. package/build/dist/Server/Services/ServerlessFunctionLabelRuleEngineService.js.map +1 -1
  505. package/build/dist/Server/Services/ServerlessFunctionOwnerRuleEngineService.js +8 -1
  506. package/build/dist/Server/Services/ServerlessFunctionOwnerRuleEngineService.js.map +1 -1
  507. package/build/dist/Server/Services/ServiceLabelRuleEngineService.js +8 -1
  508. package/build/dist/Server/Services/ServiceLabelRuleEngineService.js.map +1 -1
  509. package/build/dist/Server/Services/ServiceOwnerRuleEngineService.js +8 -1
  510. package/build/dist/Server/Services/ServiceOwnerRuleEngineService.js.map +1 -1
  511. package/build/dist/Server/Services/StatusPageLabelRuleEngineService.js +8 -1
  512. package/build/dist/Server/Services/StatusPageLabelRuleEngineService.js.map +1 -1
  513. package/build/dist/Server/Services/StatusPageMonitorRuleEngineService.js +6 -0
  514. package/build/dist/Server/Services/StatusPageMonitorRuleEngineService.js.map +1 -1
  515. package/build/dist/Server/Services/StatusPageOwnerRuleEngineService.js +8 -1
  516. package/build/dist/Server/Services/StatusPageOwnerRuleEngineService.js.map +1 -1
  517. package/build/dist/Server/Services/TeamMemberService.js +396 -1
  518. package/build/dist/Server/Services/TeamMemberService.js.map +1 -1
  519. package/build/dist/Server/Services/TeamService.js +43 -0
  520. package/build/dist/Server/Services/TeamService.js.map +1 -1
  521. package/build/dist/Server/Services/TelemetrySourceMapService.js +213 -19
  522. package/build/dist/Server/Services/TelemetrySourceMapService.js.map +1 -1
  523. package/build/dist/Server/Services/UserNotificationSettingService.js +26 -1
  524. package/build/dist/Server/Services/UserNotificationSettingService.js.map +1 -1
  525. package/build/dist/Server/Services/UserOnCallCalendarFeedService.js +140 -0
  526. package/build/dist/Server/Services/UserOnCallCalendarFeedService.js.map +1 -0
  527. package/build/dist/Server/Services/UserOnCallShiftReminderLogService.js +116 -0
  528. package/build/dist/Server/Services/UserOnCallShiftReminderLogService.js.map +1 -0
  529. package/build/dist/Server/Services/UserOnCallShiftReminderService.js +92 -0
  530. package/build/dist/Server/Services/UserOnCallShiftReminderService.js.map +1 -0
  531. package/build/dist/Server/Services/UserService.js +25 -0
  532. package/build/dist/Server/Services/UserService.js.map +1 -1
  533. package/build/dist/Server/Services/WorkflowLabelRuleEngineService.js +8 -1
  534. package/build/dist/Server/Services/WorkflowLabelRuleEngineService.js.map +1 -1
  535. package/build/dist/Server/Services/WorkflowOwnerRuleEngineService.js +8 -1
  536. package/build/dist/Server/Services/WorkflowOwnerRuleEngineService.js.map +1 -1
  537. package/build/dist/Server/Utils/EnterpriseLicense/EnterpriseLicenseSeatUtil.js +145 -0
  538. package/build/dist/Server/Utils/EnterpriseLicense/EnterpriseLicenseSeatUtil.js.map +1 -0
  539. package/build/dist/Server/Utils/Monitor/CapturedMetricAttributeUtil.js +201 -0
  540. package/build/dist/Server/Utils/Monitor/CapturedMetricAttributeUtil.js.map +1 -0
  541. package/build/dist/Server/Utils/Monitor/Criteria/SnmpMonitorCriteria.js +19 -2
  542. package/build/dist/Server/Utils/Monitor/Criteria/SnmpMonitorCriteria.js.map +1 -1
  543. package/build/dist/Server/Utils/Monitor/MonitorMetricUtil.js +42 -23
  544. package/build/dist/Server/Utils/Monitor/MonitorMetricUtil.js.map +1 -1
  545. package/build/dist/Server/Utils/Monitor/MonitorTemplateUtil.js +29 -0
  546. package/build/dist/Server/Utils/Monitor/MonitorTemplateUtil.js.map +1 -1
  547. package/build/dist/Server/Utils/Monitor/NetworkDeviceMetricUtil.js +11 -2
  548. package/build/dist/Server/Utils/Monitor/NetworkDeviceMetricUtil.js.map +1 -1
  549. package/build/dist/Server/Utils/Monitor/NetworkDeviceWalkUtil.js +16 -1
  550. package/build/dist/Server/Utils/Monitor/NetworkDeviceWalkUtil.js.map +1 -1
  551. package/build/dist/Server/Utils/Monitor/NetworkInventoryUtil.js +10 -0
  552. package/build/dist/Server/Utils/Monitor/NetworkInventoryUtil.js.map +1 -1
  553. package/build/dist/Server/Utils/Monitor/SeriesResourceLabels.js +33 -0
  554. package/build/dist/Server/Utils/Monitor/SeriesResourceLabels.js.map +1 -1
  555. package/build/dist/Server/Utils/OnCall/CalendarFeedToken.js +161 -0
  556. package/build/dist/Server/Utils/OnCall/CalendarFeedToken.js.map +1 -0
  557. package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedRenderer.js +983 -0
  558. package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedRenderer.js.map +1 -0
  559. package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedUrls.js +155 -0
  560. package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedUrls.js.map +1 -0
  561. package/build/dist/Server/Utils/OnCall/OnCallShiftChangeListeners.js +145 -0
  562. package/build/dist/Server/Utils/OnCall/OnCallShiftChangeListeners.js.map +1 -0
  563. package/build/dist/Server/Utils/OnCall/OnCallShiftMaterializer.js +752 -0
  564. package/build/dist/Server/Utils/OnCall/OnCallShiftMaterializer.js.map +1 -0
  565. package/build/dist/Server/Utils/OnCall/OnCallShiftReminderListener.js +92 -0
  566. package/build/dist/Server/Utils/OnCall/OnCallShiftReminderListener.js.map +1 -0
  567. package/build/dist/Server/Utils/OnCall/OnCallShiftReminderRunner.js +1786 -0
  568. package/build/dist/Server/Utils/OnCall/OnCallShiftReminderRunner.js.map +1 -0
  569. package/build/dist/Server/Utils/Response.js +97 -0
  570. package/build/dist/Server/Utils/Response.js.map +1 -1
  571. package/build/dist/Server/Utils/Rules/RuleEngineRuleRead.js +37 -0
  572. package/build/dist/Server/Utils/Rules/RuleEngineRuleRead.js.map +1 -0
  573. package/build/dist/Server/Utils/StartServer.js +9 -1
  574. package/build/dist/Server/Utils/StartServer.js.map +1 -1
  575. package/build/dist/Server/Utils/Telemetry/AppMetrics.js +41 -0
  576. package/build/dist/Server/Utils/Telemetry/AppMetrics.js.map +1 -1
  577. package/build/dist/Server/Utils/Telemetry/ExceptionSanitizer.js +12 -1
  578. package/build/dist/Server/Utils/Telemetry/ExceptionSanitizer.js.map +1 -1
  579. package/build/dist/Server/Utils/Telemetry/LogExceptionExtractor.js +14 -2
  580. package/build/dist/Server/Utils/Telemetry/LogExceptionExtractor.js.map +1 -1
  581. package/build/dist/Server/Utils/Telemetry/SourceMapResolver.js +15 -5
  582. package/build/dist/Server/Utils/Telemetry/SourceMapResolver.js.map +1 -1
  583. package/build/dist/Server/Utils/Telemetry/StackTraceParser.js +307 -0
  584. package/build/dist/Server/Utils/Telemetry/StackTraceParser.js.map +1 -1
  585. package/build/dist/Types/Calendar/ICalendar.js +290 -0
  586. package/build/dist/Types/Calendar/ICalendar.js.map +1 -0
  587. package/build/dist/Types/Date.js +27 -0
  588. package/build/dist/Types/Date.js.map +1 -1
  589. package/build/dist/Types/Email/EmailTemplateType.js +2 -0
  590. package/build/dist/Types/Email/EmailTemplateType.js.map +1 -1
  591. package/build/dist/Types/Monitor/SnmpMonitor/SnmpOidListUtil.js +253 -0
  592. package/build/dist/Types/Monitor/SnmpMonitor/SnmpOidListUtil.js.map +1 -0
  593. package/build/dist/Types/NotificationSetting/NotificationSettingEventType.js +3 -0
  594. package/build/dist/Types/NotificationSetting/NotificationSettingEventType.js.map +1 -1
  595. package/build/dist/Types/OnCallDutyPolicy/CalendarFeedWindow.js +97 -0
  596. package/build/dist/Types/OnCallDutyPolicy/CalendarFeedWindow.js.map +1 -0
  597. package/build/dist/Types/OnCallDutyPolicy/Layer.js +113 -19
  598. package/build/dist/Types/OnCallDutyPolicy/Layer.js.map +1 -1
  599. package/build/dist/Types/OnCallDutyPolicy/MaterializedShift.js +134 -0
  600. package/build/dist/Types/OnCallDutyPolicy/MaterializedShift.js.map +1 -0
  601. package/build/dist/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.js +800 -0
  602. package/build/dist/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.js.map +1 -0
  603. package/build/dist/Types/OnCallDutyPolicy/ScheduleShiftUtil.js +97 -3
  604. package/build/dist/Types/OnCallDutyPolicy/ScheduleShiftUtil.js.map +1 -1
  605. package/build/dist/Types/OnCallDutyPolicy/ShiftSeamUtil.js +77 -0
  606. package/build/dist/Types/OnCallDutyPolicy/ShiftSeamUtil.js.map +1 -0
  607. package/build/dist/Types/Permission.js +86 -0
  608. package/build/dist/Types/Permission.js.map +1 -1
  609. package/build/dist/Types/Rum/SessionReplay.js +19 -0
  610. package/build/dist/Types/Rum/SessionReplay.js.map +1 -1
  611. package/build/dist/UI/Components/Charts/ChartLibrary/Annotations/AnnotationHoverCard.js +83 -0
  612. package/build/dist/UI/Components/Charts/ChartLibrary/Annotations/AnnotationHoverCard.js.map +1 -0
  613. package/build/dist/UI/Components/Charts/ChartLibrary/Annotations/AnnotationLayout.js +531 -0
  614. package/build/dist/UI/Components/Charts/ChartLibrary/Annotations/AnnotationLayout.js.map +1 -0
  615. package/build/dist/UI/Components/Charts/ChartLibrary/Annotations/ChartAnnotationLayer.js +264 -0
  616. package/build/dist/UI/Components/Charts/ChartLibrary/Annotations/ChartAnnotationLayer.js.map +1 -0
  617. package/build/dist/UI/Components/Charts/ChartLibrary/Annotations/UseChartAnnotations.js +122 -0
  618. package/build/dist/UI/Components/Charts/ChartLibrary/Annotations/UseChartAnnotations.js.map +1 -0
  619. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js +213 -233
  620. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js.map +1 -1
  621. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js +139 -152
  622. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js.map +1 -1
  623. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js +212 -232
  624. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js.map +1 -1
  625. package/build/dist/UI/Components/Charts/Types/ChartEventKind.js +14 -0
  626. package/build/dist/UI/Components/Charts/Types/ChartEventKind.js.map +1 -0
  627. package/build/dist/UI/Components/Charts/Utils/TimeAnnotation.js +57 -28
  628. package/build/dist/UI/Components/Charts/Utils/TimeAnnotation.js.map +1 -1
  629. package/build/dist/UI/Components/Charts/Utils/XAxis.js +103 -17
  630. package/build/dist/UI/Components/Charts/Utils/XAxis.js.map +1 -1
  631. package/build/dist/UI/Components/EditionLabel/EditionLabel.js +198 -23
  632. package/build/dist/UI/Components/EditionLabel/EditionLabel.js.map +1 -1
  633. package/build/dist/UI/Components/ErrorMessage/ErrorMessage.js +15 -5
  634. package/build/dist/UI/Components/ErrorMessage/ErrorMessage.js.map +1 -1
  635. package/build/dist/UI/Components/Feed/FeedItem.js +4 -5
  636. package/build/dist/UI/Components/Feed/FeedItem.js.map +1 -1
  637. package/build/dist/UI/Components/Header/HeaderIconDropdownButton.js +1 -2
  638. package/build/dist/UI/Components/Header/HeaderIconDropdownButton.js.map +1 -1
  639. package/build/dist/UI/Components/Header/UserProfile/UserProfile.js.map +1 -1
  640. package/build/dist/UI/Components/Image/Image.js +6 -1
  641. package/build/dist/UI/Components/Image/Image.js.map +1 -1
  642. package/build/dist/UI/Components/Markdown.tsx/MarkdownConverters.js +0 -0
  643. package/build/dist/UI/Components/Markdown.tsx/MarkdownConverters.js.map +1 -1
  644. package/build/dist/UI/Components/MemberRoleAssignment/MemberRoleAssignment.js +1 -2
  645. package/build/dist/UI/Components/MemberRoleAssignment/MemberRoleAssignment.js.map +1 -1
  646. package/build/dist/UI/Components/ModelTable/BaseModelTable.js +104 -23
  647. package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
  648. package/build/dist/UI/Components/Table/TableHeader.js +20 -22
  649. package/build/dist/UI/Components/Table/TableHeader.js.map +1 -1
  650. package/build/dist/UI/Components/Table/TableRow.js +13 -4
  651. package/build/dist/UI/Components/Table/TableRow.js.map +1 -1
  652. package/build/dist/Utils/EnterpriseLicense/EnterpriseLicenseSeats.js +153 -0
  653. package/build/dist/Utils/EnterpriseLicense/EnterpriseLicenseSeats.js.map +1 -0
  654. package/build/dist/Utils/NetworkDiscovery/AutoImportRuleMatcher.js.map +1 -1
  655. package/build/dist/Utils/NetworkDiscovery/DiscoveredDeviceBuilder.js +127 -7
  656. package/build/dist/Utils/NetworkDiscovery/DiscoveredDeviceBuilder.js.map +1 -1
  657. package/build/dist/Utils/NetworkDiscovery/DiscoveredHostUtil.js +50 -3
  658. package/build/dist/Utils/NetworkDiscovery/DiscoveredHostUtil.js.map +1 -1
  659. package/build/dist/Utils/NetworkDiscovery/ReverseDnsNameUtil.js +132 -0
  660. package/build/dist/Utils/NetworkDiscovery/ReverseDnsNameUtil.js.map +1 -0
  661. package/build/dist/Utils/Rules/RuleEngineLimits.js +31 -0
  662. package/build/dist/Utils/Rules/RuleEngineLimits.js.map +1 -0
  663. package/jest.config.json +1 -0
  664. package/package.json +2 -2
@@ -0,0 +1,2584 @@
1
+ import OnCallDutyPolicyScheduleService from "../../Services/OnCallDutyPolicyScheduleService";
2
+ import UserNotificationSettingService from "../../Services/UserNotificationSettingService";
3
+ import UserOnCallShiftReminderService from "../../Services/UserOnCallShiftReminderService";
4
+ import UserService from "../../Services/UserService";
5
+ import UserOnCallShiftReminderLogService, {
6
+ Service as UserOnCallShiftReminderLogServiceClass,
7
+ } from "../../Services/UserOnCallShiftReminderLogService";
8
+ import DatabaseConfig from "../../DatabaseConfig";
9
+ import GlobalCache from "../../Infrastructure/GlobalCache";
10
+ import Semaphore, { SemaphoreMutex } from "../../Infrastructure/Semaphore";
11
+ import QueryHelper from "../../Types/Database/QueryHelper";
12
+ import PostgresErrorTranslator from "../Database/PostgresErrorTranslator";
13
+ import logger from "../Logger";
14
+ import PushNotificationUtil from "../PushNotificationUtil";
15
+ import { createWhatsAppMessageFromTemplate } from "../WhatsAppTemplateUtil";
16
+ import Telemetry, { TelemetryCounter } from "../Telemetry";
17
+ import CaptureSpan from "../Telemetry/CaptureSpan";
18
+ import OnCallShiftMaterializer, {
19
+ MaterializeResult,
20
+ MaterializedScheduleInfo,
21
+ MaterializedUserInfo,
22
+ } from "./OnCallShiftMaterializer";
23
+ import { OnCallShiftChangeEvent } from "./OnCallShiftChangeListeners";
24
+ import OnCallDutyPolicySchedule from "../../../Models/DatabaseModels/OnCallDutyPolicySchedule";
25
+ import User from "../../../Models/DatabaseModels/User";
26
+ import UserNotificationSetting from "../../../Models/DatabaseModels/UserNotificationSetting";
27
+ import UserOnCallShiftReminder from "../../../Models/DatabaseModels/UserOnCallShiftReminder";
28
+ import UserOnCallShiftReminderLog, {
29
+ UserOnCallShiftReminderLogKind,
30
+ } from "../../../Models/DatabaseModels/UserOnCallShiftReminderLog";
31
+ import { CallRequestMessage } from "../../../Types/Call/CallRequest";
32
+ import LIMIT_MAX, { LIMIT_PER_PROJECT } from "../../../Types/Database/LimitMax";
33
+ import OneUptimeDate from "../../../Types/Date";
34
+ import Dictionary from "../../../Types/Dictionary";
35
+ import { EmailEnvelope } from "../../../Types/Email/EmailMessage";
36
+ import EmailTemplateType from "../../../Types/Email/EmailTemplateType";
37
+ import NotificationSettingEventType from "../../../Types/NotificationSetting/NotificationSettingEventType";
38
+ import ObjectID from "../../../Types/ObjectID";
39
+ import {
40
+ MaterializedShift,
41
+ MaterializedShiftPolicy,
42
+ } from "../../../Types/OnCallDutyPolicy/MaterializedShift";
43
+ import OnCallCalendarFeedUtil from "../../../Types/OnCallDutyPolicy/OnCallCalendarFeedUtil";
44
+ import PushNotificationMessage from "../../../Types/PushNotification/PushNotificationMessage";
45
+ import { SMSMessage } from "../../../Types/SMS/SMS";
46
+ import Timezone from "../../../Types/Timezone";
47
+ import { WhatsAppTemplateIds } from "../../../Types/WhatsApp/WhatsAppTemplates";
48
+ import URL from "../../../Types/API/URL";
49
+ import { WhatsAppMessagePayload } from "../../../Types/WhatsApp/WhatsAppMessage";
50
+
51
+ /*
52
+ * Shift reminders ("your on-call shift on Payments starts in 1 hour") and the
53
+ * change notices that keep them honest when a shift changes hands inside a
54
+ * reminder window.
55
+ *
56
+ * Two entry points, one ledger:
57
+ *
58
+ * - runSweep: the five-minute cron body. Reads the lead times every user
59
+ * configured (UserOnCallShiftReminder), materializes the next
60
+ * [now, now + longest lead + 30 min] of every schedule those users can
61
+ * hold a shift on, and sends one reminder per (user, schedule, shift
62
+ * start, lead) whose "start minus lead" instant fell inside this tick's
63
+ * window. The window is a WATERMARK, not a run-time slice: it runs from
64
+ * the previous completed tick (capped at 30 minutes back) to now, so a
65
+ * skipped or late tick is caught up by the next one, while a long outage
66
+ * never floods (and never reminds anyone about a shift that already
67
+ * started — the lateness cap).
68
+ *
69
+ * - runChangePass: called from the on-call configuration hooks through
70
+ * OnCallShiftChangeListeners. Re-materializes the affected schedules and
71
+ * (a) sends ONE catch-up to a user who now holds a shift that starts
72
+ * inside one of their leads but was never reminded about it (a late
73
+ * override, a rotation edit), and (b) sends ONE "reassigned" notice to a
74
+ * user who was reminded about a shift they no longer hold.
75
+ *
76
+ * The ledger is UserOnCallShiftReminderLog with a UNIQUE index over
77
+ * (user, schedule, shiftStartsAt, minutesBeforeShift, kind). Every send is
78
+ * claim -> send -> stamp: the row is inserted with sentAt NULL first (a
79
+ * unique violation means another replica already has it), the notification
80
+ * goes out, then sentAt is stamped. A thrown send deletes the claim so the
81
+ * next tick retries while the shift is still ahead; a claim that is older
82
+ * than RECLAIM_AFTER_MINUTES with sentAt still NULL belonged to a worker
83
+ * that died mid-send and is re-claimed with a conditional update. Postgres,
84
+ * not Redis: the compose Redis is non-persistent, and a flush must never
85
+ * re-page anyone.
86
+ *
87
+ * shiftStartsAt is the seam-normalised, minute-aligned MaterializedShift
88
+ * start, so a shift whose engine start moves by a second (a layer edit that
89
+ * re-cuts the seams) still maps onto the same ledger row.
90
+ */
91
+
92
+ // Enqueued/scheduled by App/FeatureSet/Workers/Jobs/OnCallDutySchedule/*.
93
+ export const SHIFT_REMINDER_JOB_NAME: string =
94
+ "OnCallDutySchedule:SendShiftReminders";
95
+ export const SHIFT_REMINDER_LOG_RETENTION_JOB_NAME: string =
96
+ "OnCallDutySchedule:DeleteOldShiftReminderLogs";
97
+
98
+ // Listener name under which the change pass registers (idempotent).
99
+ export const SHIFT_REMINDER_LISTENER_NAME: string = "shift-reminders";
100
+
101
+ // Watermark of the last COMPLETED sweep, in GlobalCache (Redis).
102
+ export const SHIFT_REMINDER_WATERMARK_NAMESPACE: string =
103
+ "OnCallShiftReminders";
104
+ export const SHIFT_REMINDER_WATERMARK_KEY: string = "watermark";
105
+ export const SHIFT_REMINDER_WATERMARK_TTL_SECONDS: number =
106
+ OneUptimeDate.getSecondsInDays(1);
107
+
108
+ /*
109
+ * How far back a sweep may reach when the watermark is missing (Redis
110
+ * restart) or older than this. A longer outage deliberately loses the
111
+ * reminders whose lead instant fell inside it: re-sending an hour of
112
+ * "starts in 15 minutes" messages after the fact helps nobody.
113
+ */
114
+ export const SHIFT_REMINDER_MAX_LOOKBACK_MINUTES: number = 30;
115
+
116
+ // A claim with sentAt NULL older than this is assumed orphaned and retried.
117
+ export const SHIFT_REMINDER_RECLAIM_AFTER_MINUTES: number = 10;
118
+
119
+ // Materialization window = [now, now + longest lead + this].
120
+ export const SHIFT_REMINDER_WINDOW_PADDING_MINUTES: number = 30;
121
+
122
+ /*
123
+ * When the sweep is at most one tick behind the lead instant the message says
124
+ * the lead ("starts in 1 hour"); further behind, it says the true remaining
125
+ * time so a late tick never claims an hour that is already half gone.
126
+ */
127
+ export const SHIFT_REMINDER_LEAD_TEXT_TOLERANCE_MINUTES: number = 5;
128
+
129
+ // Retention of ledger rows, measured from the shift's start.
130
+ export const SHIFT_REMINDER_LOG_RETENTION_DAYS: number = 30;
131
+ export const SHIFT_REMINDER_LOG_DELETE_BATCH_SIZE: number = 100;
132
+
133
+ // Sweep lock. The lock must outlive the job timeout (runJobWithTimeout races).
134
+ export const SHIFT_REMINDER_SWEEP_LOCK_NAMESPACE: string = "Workers.Cron";
135
+ export const SHIFT_REMINDER_JOB_TIMEOUT_MS: number =
136
+ OneUptimeDate.convertMinutesToMilliseconds(10);
137
+ export const SHIFT_REMINDER_SWEEP_LOCK_TIMEOUT_MS: number =
138
+ OneUptimeDate.convertMinutesToMilliseconds(12);
139
+
140
+ const MILLISECONDS_PER_MINUTE: number = 60 * 1000;
141
+
142
+ const METRIC_NAME: string = "oncall_shift_reminders";
143
+ const METRIC_OUTCOME_ATTRIBUTE: string =
144
+ "oneuptime.oncall_shift_reminder.outcome";
145
+
146
+ export enum ShiftReminderOutcome {
147
+ Sent = "sent",
148
+ SkippedLate = "skipped_late",
149
+ SkippedAlreadySent = "skipped_already_sent",
150
+ SkippedInFlight = "skipped_in_flight",
151
+ SkippedNoPolicy = "skipped_no_policy",
152
+ ClaimCollision = "claim_collision",
153
+ ClaimRetry = "claim_retry",
154
+ SendFailed = "send_failed",
155
+ MissingSettings = "missing_settings",
156
+ CatchUpSent = "catch_up_sent",
157
+ ReassignedSent = "reassigned_sent",
158
+ }
159
+
160
+ export interface ShiftReminderSweepStats {
161
+ now: Date;
162
+ lookbackFrom: Date;
163
+ watermarkFound: boolean;
164
+ /*
165
+ * The instant the watermark was stamped with: `now` for a clean pass,
166
+ * `lookbackFrom` when a project failed, so the next tick re-covers this
167
+ * window instead of stepping over it.
168
+ */
169
+ watermarkWrittenAt: Date;
170
+ projects: number;
171
+ usersWithReminders: number;
172
+ shiftsConsidered: number;
173
+ sent: number;
174
+ skippedLate: number;
175
+ skippedAlreadySent: number;
176
+ skippedInFlight: number;
177
+ skippedNoPolicy: number;
178
+ claimCollisions: number;
179
+ claimRetries: number;
180
+ sendFailures: number;
181
+ missingSettings: number;
182
+ truncatedSchedules: number;
183
+ errors: number;
184
+ }
185
+
186
+ export interface ShiftReminderChangePassStats {
187
+ now: Date;
188
+ projectId: string | null;
189
+ users: number;
190
+ catchUpsSent: number;
191
+ reassignedSent: number;
192
+ claimCollisions: number;
193
+ sendFailures: number;
194
+ missingSettings: number;
195
+ errors: number;
196
+ // Why the pass ended early, when it did.
197
+ skippedReason: string | null;
198
+ }
199
+
200
+ export interface ShiftReminderRetentionStats {
201
+ deleted: number;
202
+ cutoff: Date;
203
+ }
204
+
205
+ // Where a recipient's wall clock is rendered: their zone, else the schedule's, else UTC.
206
+ export interface ShiftReminderMessage {
207
+ subject: string;
208
+ text: string;
209
+ pushTitle: string;
210
+ pushBody: string;
211
+ vars: Dictionary<string>;
212
+ templateType: EmailTemplateType;
213
+ eventType: NotificationSettingEventType;
214
+ timezone: string;
215
+ whenText: string;
216
+ }
217
+
218
+ interface UserReminderPlan {
219
+ userId: ObjectID;
220
+ projectId: ObjectID;
221
+ // Distinct, descending.
222
+ leads: Array<number>;
223
+ maxLead: number;
224
+ }
225
+
226
+ interface ProjectReminderPlan {
227
+ projectId: ObjectID;
228
+ users: Map<string, UserReminderPlan>;
229
+ maxLead: number;
230
+ }
231
+
232
+ interface LedgerRow {
233
+ id: ObjectID;
234
+ userId: string;
235
+ scheduleId: string;
236
+ shiftStartsAt: Date;
237
+ minutesBeforeShift: number;
238
+ kind: UserOnCallShiftReminderLogKind;
239
+ claimedAt: Date;
240
+ sentAt: Date | null;
241
+ }
242
+
243
+ interface Ledger {
244
+ byKey: Map<string, LedgerRow>;
245
+ rows: Array<LedgerRow>;
246
+ /*
247
+ * Every ledger row this pass has seen: the snapshot it loaded plus every
248
+ * row it claimed itself. A row for the same shift that is NOT in here was
249
+ * written by another pass while this one was running (see
250
+ * yieldToConcurrentSibling).
251
+ */
252
+ knownIds: Set<string>;
253
+ }
254
+
255
+ interface ClaimResult {
256
+ claimId: ObjectID | null;
257
+ outcome: "claimed" | "reclaimed" | "already-sent" | "in-flight" | "collision";
258
+ }
259
+
260
+ interface SendContext {
261
+ now: Date;
262
+ projectId: ObjectID;
263
+ dashboardUrl: string;
264
+ users: Map<string, MaterializedUserInfo>;
265
+ }
266
+
267
+ export default class OnCallShiftReminderRunner {
268
+ private static counter: TelemetryCounter | null = null;
269
+
270
+ // (userId|projectId|YYYY-MM-DD) already warned about a missing settings row.
271
+ private static missingSettingsWarned: Set<string> = new Set<string>();
272
+
273
+ // -- Cron entry points ------------------------------------------------
274
+
275
+ /**
276
+ * The five-minute cron body: one sweep under a cross-replica lock. A held
277
+ * lock (or an unreachable Redis) skips the tick — the holder is already
278
+ * covering the same window, and the watermark makes the next tick catch
279
+ * up anything this one would have done.
280
+ */
281
+ @CaptureSpan()
282
+ public static async runSweepUnderLock(options?: {
283
+ now?: Date | undefined;
284
+ }): Promise<ShiftReminderSweepStats | null> {
285
+ let mutex: SemaphoreMutex | null = null;
286
+
287
+ try {
288
+ mutex = await Semaphore.lock({
289
+ key: SHIFT_REMINDER_JOB_NAME,
290
+ namespace: SHIFT_REMINDER_SWEEP_LOCK_NAMESPACE,
291
+ lockTimeout: SHIFT_REMINDER_SWEEP_LOCK_TIMEOUT_MS,
292
+ acquireAttemptsLimit: 1,
293
+ });
294
+ } catch (err) {
295
+ logger.debug(
296
+ `${SHIFT_REMINDER_JOB_NAME}: could not acquire the sweep lock; a sweep is already in flight (or Redis is unavailable). Skipping this run: ${err}`,
297
+ );
298
+ return null;
299
+ }
300
+
301
+ try {
302
+ return await OnCallShiftReminderRunner.runSweep(options);
303
+ } finally {
304
+ try {
305
+ await Semaphore.release(mutex);
306
+ } catch (err) {
307
+ logger.error(
308
+ `${SHIFT_REMINDER_JOB_NAME}: error releasing the sweep lock: ${err}`,
309
+ );
310
+ }
311
+ }
312
+ }
313
+
314
+ /**
315
+ * One sweep: every due (user, shift, lead) inside
316
+ * (lookbackFrom + lead, now + lead], claimed and sent once. Never throws
317
+ * for one user's or one project's bad data; throws only when the reminder
318
+ * table itself cannot be read, in which case the watermark is NOT advanced
319
+ * and the next tick covers the same window.
320
+ *
321
+ * A project or user that threw (a DB timeout inside the materializer, a
322
+ * transient Redis error) is isolated the same way, but its window would
323
+ * otherwise be lost for good: the next tick starts where this one ended
324
+ * and `isDue` never looks back at a lead instant again. So whenever
325
+ * anything failed the watermark is stamped with `lookbackFrom` instead of
326
+ * `now` and the next tick re-covers the identical window. The re-run is a
327
+ * no-op for everything that did send — the UNIQUE ledger turns it into
328
+ * "already sent" — and the 30-minute lookback cap bounds a project that
329
+ * keeps failing.
330
+ */
331
+ @CaptureSpan()
332
+ public static async runSweep(options?: {
333
+ now?: Date | undefined;
334
+ }): Promise<ShiftReminderSweepStats> {
335
+ const now: Date = options?.now || OneUptimeDate.getCurrentDate();
336
+ const watermark: Date | null =
337
+ await OnCallShiftReminderRunner.readWatermark();
338
+ const lookbackFrom: Date = OnCallShiftReminderRunner.computeLookbackFrom(
339
+ now,
340
+ watermark,
341
+ );
342
+
343
+ const stats: ShiftReminderSweepStats = {
344
+ now,
345
+ lookbackFrom,
346
+ watermarkFound: watermark !== null,
347
+ watermarkWrittenAt: now,
348
+ projects: 0,
349
+ usersWithReminders: 0,
350
+ shiftsConsidered: 0,
351
+ sent: 0,
352
+ skippedLate: 0,
353
+ skippedAlreadySent: 0,
354
+ skippedInFlight: 0,
355
+ skippedNoPolicy: 0,
356
+ claimCollisions: 0,
357
+ claimRetries: 0,
358
+ sendFailures: 0,
359
+ missingSettings: 0,
360
+ truncatedSchedules: 0,
361
+ errors: 0,
362
+ };
363
+
364
+ const reminders: Array<UserOnCallShiftReminder> =
365
+ await UserOnCallShiftReminderService.findAllBy({
366
+ query: {},
367
+ select: {
368
+ _id: true,
369
+ projectId: true,
370
+ userId: true,
371
+ minutesBeforeShift: true,
372
+ },
373
+ props: {
374
+ isRoot: true,
375
+ },
376
+ });
377
+
378
+ const plans: Map<string, ProjectReminderPlan> =
379
+ OnCallShiftReminderRunner.buildProjectPlans(reminders);
380
+
381
+ stats.projects = plans.size;
382
+
383
+ for (const plan of plans.values()) {
384
+ stats.usersWithReminders += plan.users.size;
385
+ }
386
+
387
+ if (plans.size === 0) {
388
+ await OnCallShiftReminderRunner.writeWatermark(now);
389
+ return stats;
390
+ }
391
+
392
+ const dashboardUrl: string =
393
+ await OnCallShiftReminderRunner.getDashboardUrl();
394
+
395
+ for (const plan of plans.values()) {
396
+ try {
397
+ await OnCallShiftReminderRunner.sweepProject({
398
+ plan,
399
+ now,
400
+ lookbackFrom,
401
+ dashboardUrl,
402
+ stats,
403
+ });
404
+ } catch (err) {
405
+ stats.errors++;
406
+ logger.error(
407
+ `${SHIFT_REMINDER_JOB_NAME}: failed to process project ${plan.projectId.toString()}`,
408
+ );
409
+ logger.error(err);
410
+ }
411
+ }
412
+
413
+ /*
414
+ * Hold the window open when anything failed. Re-covering it costs
415
+ * nothing: every reminder that DID go out has a stamped ledger row and
416
+ * comes back as "already sent", and an isolated failure always happens
417
+ * before its send (a failed stamp is caught inside deliver, precisely so
418
+ * that "sent" and "swept" cannot disagree here).
419
+ */
420
+ stats.watermarkWrittenAt = stats.errors > 0 ? lookbackFrom : now;
421
+
422
+ if (stats.errors > 0) {
423
+ logger.warn(
424
+ `${SHIFT_REMINDER_JOB_NAME}: ${stats.errors} failure(s) this tick; holding the watermark at ${lookbackFrom.toISOString()} so the next tick re-covers this window.`,
425
+ );
426
+ }
427
+
428
+ await OnCallShiftReminderRunner.writeWatermark(stats.watermarkWrittenAt);
429
+
430
+ logger.debug(
431
+ `${SHIFT_REMINDER_JOB_NAME}: sweep complete — ${stats.sent} sent, ${stats.skippedLate} skipped (late), ${stats.claimRetries} claim retries, ${stats.claimCollisions} claim collisions, ${stats.sendFailures} send failures, ${stats.errors} errors.`,
432
+ );
433
+
434
+ return stats;
435
+ }
436
+
437
+ /**
438
+ * Ledger retention: rows for shifts that started more than
439
+ * SHIFT_REMINDER_LOG_RETENTION_DAYS ago are deleted in batches. Keyed on
440
+ * the SHIFT start (not the claim time) so a row can never disappear while
441
+ * the shift it de-duplicates is still ahead.
442
+ */
443
+ @CaptureSpan()
444
+ public static async deleteOldLogs(options?: {
445
+ now?: Date | undefined;
446
+ retentionDays?: number | undefined;
447
+ batchSize?: number | undefined;
448
+ }): Promise<ShiftReminderRetentionStats> {
449
+ const now: Date = options?.now || OneUptimeDate.getCurrentDate();
450
+ const retentionDays: number =
451
+ options?.retentionDays ?? SHIFT_REMINDER_LOG_RETENTION_DAYS;
452
+ const batchSize: number =
453
+ options?.batchSize ?? SHIFT_REMINDER_LOG_DELETE_BATCH_SIZE;
454
+ const cutoff: Date = OneUptimeDate.addRemoveDays(now, retentionDays * -1);
455
+
456
+ let deleted: number = 0;
457
+
458
+ while (true) {
459
+ const count: number = await UserOnCallShiftReminderLogService.deleteBy({
460
+ query: {
461
+ shiftStartsAt: QueryHelper.lessThanEqualTo(cutoff),
462
+ },
463
+ limit: batchSize,
464
+ skip: 0,
465
+ props: {
466
+ isRoot: true,
467
+ },
468
+ });
469
+
470
+ deleted += count;
471
+
472
+ if (count === 0) {
473
+ break;
474
+ }
475
+ }
476
+
477
+ return { deleted, cutoff };
478
+ }
479
+
480
+ // -- Change pass (hook-triggered) ---------------------------------------
481
+
482
+ /**
483
+ * Catch-up + reassigned notices for one shift-change event. Fire-and-forget
484
+ * from the hooks (through OnCallShiftChangeListeners); never throws.
485
+ */
486
+ @CaptureSpan()
487
+ public static async runChangePass(
488
+ event: OnCallShiftChangeEvent,
489
+ options?: { now?: Date | undefined },
490
+ ): Promise<ShiftReminderChangePassStats> {
491
+ const now: Date = options?.now || OneUptimeDate.getCurrentDate();
492
+
493
+ const stats: ShiftReminderChangePassStats = {
494
+ now,
495
+ projectId: null,
496
+ users: 0,
497
+ catchUpsSent: 0,
498
+ reassignedSent: 0,
499
+ claimCollisions: 0,
500
+ sendFailures: 0,
501
+ missingSettings: 0,
502
+ errors: 0,
503
+ skippedReason: null,
504
+ };
505
+
506
+ try {
507
+ const projectId: ObjectID | null =
508
+ event.projectId ||
509
+ (await OnCallShiftReminderRunner.resolveProjectIdFromSchedules(
510
+ event.scheduleIds,
511
+ ));
512
+
513
+ if (!projectId) {
514
+ stats.skippedReason = "no-project";
515
+ return stats;
516
+ }
517
+
518
+ stats.projectId = projectId.toString();
519
+
520
+ const eventScheduleIds: Array<ObjectID> =
521
+ OnCallShiftReminderRunner.dedupeIds(event.scheduleIds);
522
+
523
+ /*
524
+ * Users to look at: everyone the hook named, plus everyone holding a
525
+ * ledger row for a future shift on an affected schedule (they may need
526
+ * a "reassigned" notice even if the hook did not name them).
527
+ */
528
+ const scheduleRows: Array<LedgerRow> =
529
+ eventScheduleIds.length > 0
530
+ ? await OnCallShiftReminderRunner.loadLedgerRows({
531
+ projectId,
532
+ scheduleIds: eventScheduleIds,
533
+ from: now,
534
+ })
535
+ : [];
536
+
537
+ const userIds: Array<ObjectID> = OnCallShiftReminderRunner.dedupeIds([
538
+ ...event.userIds,
539
+ ...scheduleRows.map((row: LedgerRow) => {
540
+ return new ObjectID(row.userId);
541
+ }),
542
+ ]);
543
+
544
+ if (userIds.length === 0) {
545
+ stats.skippedReason = "no-users";
546
+ return stats;
547
+ }
548
+
549
+ const reminders: Array<UserOnCallShiftReminder> =
550
+ await UserOnCallShiftReminderService.findBy({
551
+ query: {
552
+ projectId: projectId,
553
+ userId: QueryHelper.any(userIds),
554
+ },
555
+ select: {
556
+ _id: true,
557
+ projectId: true,
558
+ userId: true,
559
+ minutesBeforeShift: true,
560
+ },
561
+ limit: LIMIT_PER_PROJECT,
562
+ skip: 0,
563
+ props: {
564
+ isRoot: true,
565
+ },
566
+ });
567
+
568
+ const plans: Map<string, ProjectReminderPlan> =
569
+ OnCallShiftReminderRunner.buildProjectPlans(reminders);
570
+ const plan: ProjectReminderPlan | undefined = plans.get(
571
+ projectId.toString(),
572
+ );
573
+
574
+ // Every ledger row of those users for shifts still ahead.
575
+ const ledger: Ledger = OnCallShiftReminderRunner.buildLedger(
576
+ await OnCallShiftReminderRunner.loadLedgerRows({
577
+ projectId,
578
+ userIds,
579
+ from: now,
580
+ }),
581
+ );
582
+
583
+ const futureRowsExist: boolean = ledger.rows.length > 0;
584
+
585
+ if (!plan && !futureRowsExist) {
586
+ stats.skippedReason = "nothing-to-do";
587
+ return stats;
588
+ }
589
+
590
+ /*
591
+ * The window has to reach the farthest lead (for catch-ups) AND the
592
+ * farthest shift anyone was already reminded about (to judge whether
593
+ * they still hold it).
594
+ */
595
+ let windowMinutes: number = plan ? plan.maxLead : 0;
596
+
597
+ for (const row of ledger.rows) {
598
+ const minutesAhead: number = Math.ceil(
599
+ (row.shiftStartsAt.getTime() - now.getTime()) /
600
+ MILLISECONDS_PER_MINUTE,
601
+ );
602
+ windowMinutes = Math.max(windowMinutes, minutesAhead);
603
+ }
604
+
605
+ const windowEnd: Date = OneUptimeDate.addRemoveMinutes(
606
+ now,
607
+ windowMinutes + SHIFT_REMINDER_WINDOW_PADDING_MINUTES,
608
+ );
609
+
610
+ /*
611
+ * Schedules to materialize: the ones the hook named, the ones the
612
+ * reminded users can hold shifts on, and the ones their ledger rows
613
+ * point at.
614
+ */
615
+ const scheduleIds: Array<ObjectID> = [...eventScheduleIds];
616
+
617
+ for (const row of ledger.rows) {
618
+ scheduleIds.push(new ObjectID(row.scheduleId));
619
+ }
620
+
621
+ if (plan) {
622
+ // One batched lookup for every reminded user of this project.
623
+ const candidatesByUser: Map<
624
+ string,
625
+ Array<ObjectID>
626
+ > = await OnCallShiftMaterializer.getCandidateScheduleIdsForUsers({
627
+ userIds: Array.from(plan.users.values()).map(
628
+ (userPlan: UserReminderPlan) => {
629
+ return userPlan.userId;
630
+ },
631
+ ),
632
+ projectIds: [projectId],
633
+ windowStart: now,
634
+ windowEnd,
635
+ includeCoveringShifts: true,
636
+ });
637
+
638
+ for (const candidates of candidatesByUser.values()) {
639
+ scheduleIds.push(...candidates);
640
+ }
641
+ }
642
+
643
+ const distinctScheduleIds: Array<ObjectID> =
644
+ OnCallShiftReminderRunner.dedupeIds(scheduleIds);
645
+
646
+ if (distinctScheduleIds.length === 0) {
647
+ stats.skippedReason = "no-schedules";
648
+ return stats;
649
+ }
650
+
651
+ const result: MaterializeResult =
652
+ await OnCallShiftMaterializer.materializeForSchedules({
653
+ scheduleIds: distinctScheduleIds,
654
+ windowStart: now,
655
+ windowEnd,
656
+ now,
657
+ });
658
+
659
+ const resolvedSchedules: Map<string, MaterializedScheduleInfo> = new Map<
660
+ string,
661
+ MaterializedScheduleInfo
662
+ >();
663
+
664
+ for (const schedule of result.schedules) {
665
+ resolvedSchedules.set(schedule.scheduleId, schedule);
666
+ }
667
+
668
+ const context: SendContext = {
669
+ now,
670
+ projectId,
671
+ dashboardUrl: await OnCallShiftReminderRunner.getDashboardUrl(),
672
+ users: OnCallShiftReminderRunner.toUserMap(result.users),
673
+ };
674
+
675
+ await OnCallShiftReminderRunner.backfillRecipients({
676
+ userIds,
677
+ ledger,
678
+ context,
679
+ });
680
+
681
+ stats.users = userIds.length;
682
+
683
+ for (const userId of userIds) {
684
+ try {
685
+ const userKey: string = userId.toString();
686
+ const userPlan: UserReminderPlan | undefined =
687
+ plan?.users.get(userKey);
688
+ const heldShifts: Array<MaterializedShift> =
689
+ OnCallShiftMaterializer.filterShiftsForUser(
690
+ result.shifts,
691
+ userId,
692
+ ).filter((shift: MaterializedShift) => {
693
+ return shift.start.getTime() > now.getTime();
694
+ });
695
+
696
+ if (userPlan) {
697
+ await OnCallShiftReminderRunner.sendCatchUps({
698
+ userPlan,
699
+ heldShifts,
700
+ ledger,
701
+ context,
702
+ stats,
703
+ });
704
+ }
705
+
706
+ await OnCallShiftReminderRunner.sendReassignedNotices({
707
+ userId,
708
+ heldShifts,
709
+ allShifts: result.shifts,
710
+ ledger,
711
+ resolvedSchedules,
712
+ context,
713
+ stats,
714
+ });
715
+ } catch (err) {
716
+ stats.errors++;
717
+ logger.error(
718
+ `${SHIFT_REMINDER_LISTENER_NAME}: change pass failed for user ${userId.toString()} in project ${projectId.toString()}`,
719
+ );
720
+ logger.error(err);
721
+ }
722
+ }
723
+ } catch (err) {
724
+ stats.errors++;
725
+ logger.error(
726
+ `${SHIFT_REMINDER_LISTENER_NAME}: change pass failed (reason ${event.reason})`,
727
+ );
728
+ logger.error(err);
729
+ }
730
+
731
+ return stats;
732
+ }
733
+
734
+ // -- Watermark ----------------------------------------------------------
735
+
736
+ public static async readWatermark(): Promise<Date | null> {
737
+ try {
738
+ const value: string | null = await GlobalCache.getString(
739
+ SHIFT_REMINDER_WATERMARK_NAMESPACE,
740
+ SHIFT_REMINDER_WATERMARK_KEY,
741
+ );
742
+
743
+ if (!value) {
744
+ return null;
745
+ }
746
+
747
+ const parsed: Date = new Date(value);
748
+
749
+ if (Number.isNaN(parsed.getTime())) {
750
+ return null;
751
+ }
752
+
753
+ return parsed;
754
+ } catch (err) {
755
+ logger.warn(
756
+ `${SHIFT_REMINDER_JOB_NAME}: could not read the watermark; falling back to a ${SHIFT_REMINDER_MAX_LOOKBACK_MINUTES}-minute lookback: ${err}`,
757
+ );
758
+ return null;
759
+ }
760
+ }
761
+
762
+ public static async writeWatermark(now: Date): Promise<void> {
763
+ try {
764
+ await GlobalCache.setString(
765
+ SHIFT_REMINDER_WATERMARK_NAMESPACE,
766
+ SHIFT_REMINDER_WATERMARK_KEY,
767
+ now.toISOString(),
768
+ { expiresInSeconds: SHIFT_REMINDER_WATERMARK_TTL_SECONDS },
769
+ );
770
+ } catch (err) {
771
+ logger.warn(
772
+ `${SHIFT_REMINDER_JOB_NAME}: could not write the watermark; the next tick falls back to a ${SHIFT_REMINDER_MAX_LOOKBACK_MINUTES}-minute lookback: ${err}`,
773
+ );
774
+ }
775
+ }
776
+
777
+ /**
778
+ * Lower bound of this tick's window: the previous completed tick, but
779
+ * never more than SHIFT_REMINDER_MAX_LOOKBACK_MINUTES ago and never in the
780
+ * future (clock skew between replicas).
781
+ */
782
+ public static computeLookbackFrom(now: Date, watermark: Date | null): Date {
783
+ const floor: Date = OneUptimeDate.addRemoveMinutes(
784
+ now,
785
+ SHIFT_REMINDER_MAX_LOOKBACK_MINUTES * -1,
786
+ );
787
+
788
+ if (!watermark) {
789
+ return floor;
790
+ }
791
+
792
+ const clamped: number = Math.min(watermark.getTime(), now.getTime());
793
+
794
+ return new Date(Math.max(floor.getTime(), clamped));
795
+ }
796
+
797
+ // -- Pure helpers (exported for tests) -----------------------------------
798
+
799
+ /**
800
+ * True when a shift with this start is due for this lead inside the
801
+ * window (lookbackFrom + lead, now + lead].
802
+ */
803
+ public static isDue(data: {
804
+ start: Date;
805
+ lead: number;
806
+ now: Date;
807
+ lookbackFrom: Date;
808
+ }): boolean {
809
+ const leadMs: number = data.lead * MILLISECONDS_PER_MINUTE;
810
+ const startMs: number = data.start.getTime();
811
+
812
+ return (
813
+ startMs > data.lookbackFrom.getTime() + leadMs &&
814
+ startMs <= data.now.getTime() + leadMs
815
+ );
816
+ }
817
+
818
+ /** "1 hour", "1 hour 30 minutes", "2 weeks", "15 minutes". */
819
+ public static describeMinutes(minutes: number): string {
820
+ const whole: number = Math.round(minutes);
821
+
822
+ if (whole <= 0) {
823
+ return "less than a minute";
824
+ }
825
+
826
+ const units: Array<[string, number]> = [
827
+ ["week", 7 * 24 * 60],
828
+ ["day", 24 * 60],
829
+ ["hour", 60],
830
+ ["minute", 1],
831
+ ];
832
+
833
+ const parts: Array<string> = [];
834
+ let remaining: number = whole;
835
+
836
+ for (const [name, size] of units) {
837
+ const count: number = Math.floor(remaining / size);
838
+
839
+ if (count > 0) {
840
+ parts.push(`${count} ${name}${count === 1 ? "" : "s"}`);
841
+ remaining -= count * size;
842
+ }
843
+ }
844
+
845
+ return parts.slice(0, 2).join(" ");
846
+ }
847
+
848
+ /**
849
+ * The "starts in …" text of a regular reminder: the configured lead when
850
+ * the sweep is at most one tick behind it, the real remaining time
851
+ * otherwise.
852
+ */
853
+ public static describeRemaining(data: {
854
+ lead: number;
855
+ start: Date;
856
+ now: Date;
857
+ }): string {
858
+ const remainingMinutes: number =
859
+ (data.start.getTime() - data.now.getTime()) / MILLISECONDS_PER_MINUTE;
860
+
861
+ if (
862
+ remainingMinutes <= data.lead &&
863
+ data.lead - remainingMinutes <= SHIFT_REMINDER_LEAD_TEXT_TOLERANCE_MINUTES
864
+ ) {
865
+ return OnCallShiftReminderRunner.describeMinutes(data.lead);
866
+ }
867
+
868
+ return OnCallShiftReminderRunner.describeMinutes(remainingMinutes);
869
+ }
870
+
871
+ /** The zone a recipient's wall clock is rendered in. */
872
+ public static resolveTimezone(data: {
873
+ userTimezone?: string | undefined;
874
+ scheduleTimezone?: string | undefined;
875
+ }): string {
876
+ if (OnCallCalendarFeedUtil.isValidTimezone(data.userTimezone)) {
877
+ return data.userTimezone as string;
878
+ }
879
+
880
+ if (OnCallCalendarFeedUtil.isValidTimezone(data.scheduleTimezone)) {
881
+ return data.scheduleTimezone as string;
882
+ }
883
+
884
+ return Timezone.UTC;
885
+ }
886
+
887
+ /** "Thu 3 Sep 18:00 Europe/Berlin" */
888
+ public static formatInstant(date: Date, timezone: string): string {
889
+ return `${OneUptimeDate.getDateAsCustomFormattedStringInTimezone({
890
+ date,
891
+ format: "ddd D MMM HH:mm",
892
+ timezone,
893
+ })} ${timezone}`;
894
+ }
895
+
896
+ /** Distinct policy names, alphabetical, joined with ", ". */
897
+ public static describePolicyNames(
898
+ policies: Array<MaterializedShiftPolicy>,
899
+ ): string {
900
+ return OnCallCalendarFeedUtil.getDistinctPolicies(policies)
901
+ .map((policy: MaterializedShiftPolicy) => {
902
+ return policy.policyName;
903
+ })
904
+ .join(", ");
905
+ }
906
+
907
+ /** The ledger key = the UNIQUE index of UserOnCallShiftReminderLog. */
908
+ public static ledgerKey(data: {
909
+ userId: string | ObjectID;
910
+ scheduleId: string | ObjectID;
911
+ shiftStartsAt: Date;
912
+ minutesBeforeShift: number;
913
+ kind: UserOnCallShiftReminderLogKind;
914
+ }): string {
915
+ const start: number =
916
+ UserOnCallShiftReminderLogServiceClass.truncateToMinute(
917
+ data.shiftStartsAt,
918
+ ).getTime();
919
+
920
+ return `${data.userId.toString()}|${data.scheduleId.toString()}|${start}|${data.minutesBeforeShift}|${data.kind}`;
921
+ }
922
+
923
+ /** Builds the regular reminder message for one shift and lead. */
924
+ public static buildReminderMessage(data: {
925
+ shift: MaterializedShift;
926
+ lead: number;
927
+ now: Date;
928
+ timezone: string;
929
+ dashboardUrl: string;
930
+ }): ShiftReminderMessage {
931
+ const { shift } = data;
932
+ const whenText: string = OnCallShiftReminderRunner.formatInstant(
933
+ shift.start,
934
+ data.timezone,
935
+ );
936
+ const endsText: string = OnCallShiftReminderRunner.formatInstant(
937
+ shift.end,
938
+ data.timezone,
939
+ );
940
+ const remainingText: string = OnCallShiftReminderRunner.describeRemaining({
941
+ lead: data.lead,
942
+ start: shift.start,
943
+ now: data.now,
944
+ });
945
+ const policyNames: string = OnCallShiftReminderRunner.describePolicyNames(
946
+ shift.policies,
947
+ );
948
+ const coveringFor: string | null =
949
+ shift.override && shift.override.originalUserId !== shift.userId
950
+ ? shift.override.originalUserName
951
+ : null;
952
+
953
+ const coveringClause: string = coveringFor
954
+ ? ` (you are covering for ${coveringFor})`
955
+ : "";
956
+
957
+ const sentence: string = `Your on-call shift on ${shift.scheduleName} for ${policyNames} starts in ${remainingText} (${whenText})${coveringClause}.`;
958
+
959
+ const scheduleViewLink: string = OnCallCalendarFeedUtil.getScheduleUrl(
960
+ data.dashboardUrl,
961
+ shift.projectId,
962
+ shift.scheduleId,
963
+ );
964
+
965
+ const vars: Dictionary<string> = {
966
+ scheduleName: shift.scheduleName,
967
+ policyNames,
968
+ leadText: OnCallShiftReminderRunner.describeMinutes(data.lead),
969
+ remainingText,
970
+ startsAt: whenText,
971
+ endsAt: endsText,
972
+ timezone: data.timezone,
973
+ description: sentence,
974
+ coveringFor: coveringFor || "",
975
+ scheduleViewLink,
976
+ };
977
+
978
+ return {
979
+ subject: `Reminder: your on-call shift on ${shift.scheduleName} starts in ${remainingText}`,
980
+ text: `This is a message from OneUptime. ${sentence} To change these reminders go to User Settings in the OneUptime Dashboard.`,
981
+ pushTitle: "On-call shift reminder",
982
+ pushBody: `Your on-call shift on ${shift.scheduleName} starts in ${remainingText} (${whenText}).`,
983
+ vars,
984
+ templateType: EmailTemplateType.UserOnCallShiftReminder,
985
+ eventType:
986
+ NotificationSettingEventType.SEND_BEFORE_USER_ON_CALL_SHIFT_STARTS,
987
+ timezone: data.timezone,
988
+ whenText,
989
+ };
990
+ }
991
+
992
+ /**
993
+ * The catch-up message: the same shape as a reminder, prefixed so it reads
994
+ * as the late notice it is.
995
+ *
996
+ * It deliberately does NOT say the shift "now" starts in X. A catch-up
997
+ * goes to anyone holding a shift inside one of their leads with no
998
+ * reminder row, and that includes shifts that did not move at all — the
999
+ * user configured the lead after its instant had passed, or the worker was
1000
+ * down for it — so the message would claim a change that never happened
1001
+ * the next time a colleague edits an unrelated layer on the schedule. What
1002
+ * IS always true is that they have not been told yet; when the shift did
1003
+ * change hands the covering clause says so.
1004
+ */
1005
+ public static buildCatchUpMessage(data: {
1006
+ shift: MaterializedShift;
1007
+ now: Date;
1008
+ timezone: string;
1009
+ dashboardUrl: string;
1010
+ }): ShiftReminderMessage {
1011
+ const { shift } = data;
1012
+ const whenText: string = OnCallShiftReminderRunner.formatInstant(
1013
+ shift.start,
1014
+ data.timezone,
1015
+ );
1016
+ const endsText: string = OnCallShiftReminderRunner.formatInstant(
1017
+ shift.end,
1018
+ data.timezone,
1019
+ );
1020
+ const remainingMinutes: number =
1021
+ (shift.start.getTime() - data.now.getTime()) / MILLISECONDS_PER_MINUTE;
1022
+ const remainingText: string =
1023
+ OnCallShiftReminderRunner.describeMinutes(remainingMinutes);
1024
+ const policyNames: string = OnCallShiftReminderRunner.describePolicyNames(
1025
+ shift.policies,
1026
+ );
1027
+ const coveringFor: string | null =
1028
+ shift.override && shift.override.originalUserId !== shift.userId
1029
+ ? shift.override.originalUserName
1030
+ : null;
1031
+ const coveringClause: string = coveringFor
1032
+ ? ` (you are covering for ${coveringFor})`
1033
+ : "";
1034
+
1035
+ const sentence: string = `Heads up: your on-call shift on ${shift.scheduleName} for ${policyNames} starts in ${remainingText} (${whenText})${coveringClause}.`;
1036
+
1037
+ const scheduleViewLink: string = OnCallCalendarFeedUtil.getScheduleUrl(
1038
+ data.dashboardUrl,
1039
+ shift.projectId,
1040
+ shift.scheduleId,
1041
+ );
1042
+
1043
+ const vars: Dictionary<string> = {
1044
+ scheduleName: shift.scheduleName,
1045
+ policyNames,
1046
+ leadText: remainingText,
1047
+ remainingText,
1048
+ startsAt: whenText,
1049
+ endsAt: endsText,
1050
+ timezone: data.timezone,
1051
+ description: sentence,
1052
+ coveringFor: coveringFor || "",
1053
+ scheduleViewLink,
1054
+ };
1055
+
1056
+ return {
1057
+ subject: `Heads up: your on-call shift on ${shift.scheduleName} starts in ${remainingText}`,
1058
+ text: `This is a message from OneUptime. ${sentence} To change these reminders go to User Settings in the OneUptime Dashboard.`,
1059
+ pushTitle: "On-call shift reminder",
1060
+ pushBody: `Your on-call shift on ${shift.scheduleName} starts in ${remainingText} (${whenText})${coveringClause}.`,
1061
+ vars,
1062
+ templateType: EmailTemplateType.UserOnCallShiftReminder,
1063
+ eventType:
1064
+ NotificationSettingEventType.SEND_BEFORE_USER_ON_CALL_SHIFT_STARTS,
1065
+ timezone: data.timezone,
1066
+ whenText,
1067
+ };
1068
+ }
1069
+
1070
+ /** "Your shift on Payments at Thu 3 Sep 18:00 Europe/Berlin is now covered by Bob." */
1071
+ public static buildReassignedMessage(data: {
1072
+ scheduleName: string;
1073
+ projectId: string;
1074
+ scheduleId: string;
1075
+ shiftStartsAt: Date;
1076
+ coveredBy: string | null;
1077
+ timezone: string;
1078
+ dashboardUrl: string;
1079
+ }): ShiftReminderMessage {
1080
+ const whenText: string = OnCallShiftReminderRunner.formatInstant(
1081
+ data.shiftStartsAt,
1082
+ data.timezone,
1083
+ );
1084
+ const outcome: string = data.coveredBy
1085
+ ? `is now covered by ${data.coveredBy}`
1086
+ : "is no longer assigned to you";
1087
+ const sentence: string = `Your on-call shift on ${data.scheduleName} at ${whenText} ${outcome}.`;
1088
+
1089
+ const scheduleViewLink: string = OnCallCalendarFeedUtil.getScheduleUrl(
1090
+ data.dashboardUrl,
1091
+ data.projectId,
1092
+ data.scheduleId,
1093
+ );
1094
+
1095
+ const vars: Dictionary<string> = {
1096
+ scheduleName: data.scheduleName,
1097
+ startsAt: whenText,
1098
+ timezone: data.timezone,
1099
+ coveredBy: data.coveredBy || "",
1100
+ description: sentence,
1101
+ scheduleViewLink,
1102
+ };
1103
+
1104
+ return {
1105
+ subject: `Your on-call shift on ${data.scheduleName} ${outcome}`,
1106
+ text: `This is a message from OneUptime. ${sentence} To change these notices go to User Settings in the OneUptime Dashboard.`,
1107
+ pushTitle: "On-call shift reassigned",
1108
+ pushBody: sentence,
1109
+ vars,
1110
+ templateType: EmailTemplateType.UserOnCallShiftReassigned,
1111
+ eventType:
1112
+ NotificationSettingEventType.SEND_WHEN_USER_ON_CALL_SHIFT_IS_REASSIGNED,
1113
+ timezone: data.timezone,
1114
+ whenText,
1115
+ };
1116
+ }
1117
+
1118
+ /** For tests: forget which (user, project, day) were already warned about. */
1119
+ public static resetMissingSettingsWarnings(): void {
1120
+ OnCallShiftReminderRunner.missingSettingsWarned = new Set<string>();
1121
+ }
1122
+
1123
+ // -- Sweep internals ----------------------------------------------------
1124
+
1125
+ private static async sweepProject(data: {
1126
+ plan: ProjectReminderPlan;
1127
+ now: Date;
1128
+ lookbackFrom: Date;
1129
+ dashboardUrl: string;
1130
+ stats: ShiftReminderSweepStats;
1131
+ }): Promise<void> {
1132
+ const { plan, now, lookbackFrom, stats } = data;
1133
+
1134
+ const windowEnd: Date = OneUptimeDate.addRemoveMinutes(
1135
+ now,
1136
+ plan.maxLead + SHIFT_REMINDER_WINDOW_PADDING_MINUTES,
1137
+ );
1138
+
1139
+ const userIds: Array<ObjectID> = Array.from(plan.users.values()).map(
1140
+ (userPlan: UserReminderPlan) => {
1141
+ return userPlan.userId;
1142
+ },
1143
+ );
1144
+
1145
+ /*
1146
+ * Candidate schedules of every reminded user in ONE batched lookup —
1147
+ * two or three queries for the whole project rather than per user —
1148
+ * and then a single materialization per tick.
1149
+ */
1150
+ const candidatesByUser: Map<
1151
+ string,
1152
+ Array<ObjectID>
1153
+ > = await OnCallShiftMaterializer.getCandidateScheduleIdsForUsers({
1154
+ userIds,
1155
+ projectIds: [plan.projectId],
1156
+ windowStart: now,
1157
+ windowEnd,
1158
+ includeCoveringShifts: true,
1159
+ });
1160
+
1161
+ const scheduleIds: Array<ObjectID> = [];
1162
+
1163
+ for (const candidates of candidatesByUser.values()) {
1164
+ scheduleIds.push(...candidates);
1165
+ }
1166
+
1167
+ const distinctScheduleIds: Array<ObjectID> =
1168
+ OnCallShiftReminderRunner.dedupeIds(scheduleIds);
1169
+
1170
+ if (distinctScheduleIds.length === 0) {
1171
+ return;
1172
+ }
1173
+
1174
+ const result: MaterializeResult =
1175
+ await OnCallShiftMaterializer.materializeForSchedules({
1176
+ scheduleIds: distinctScheduleIds,
1177
+ windowStart: now,
1178
+ windowEnd,
1179
+ now,
1180
+ });
1181
+
1182
+ for (const schedule of result.schedules) {
1183
+ if (schedule.truncated) {
1184
+ stats.truncatedSchedules++;
1185
+ logger.warn(
1186
+ `${SHIFT_REMINDER_JOB_NAME}: schedule ${schedule.scheduleId} hit the simulation cap; reminders for it may be incomplete this tick.`,
1187
+ );
1188
+ }
1189
+ }
1190
+
1191
+ const ledger: Ledger = OnCallShiftReminderRunner.buildLedger(
1192
+ await OnCallShiftReminderRunner.loadLedgerRows({
1193
+ projectId: plan.projectId,
1194
+ userIds,
1195
+ from: now,
1196
+ }),
1197
+ );
1198
+
1199
+ const context: SendContext = {
1200
+ now,
1201
+ projectId: plan.projectId,
1202
+ dashboardUrl: data.dashboardUrl,
1203
+ users: OnCallShiftReminderRunner.toUserMap(result.users),
1204
+ };
1205
+
1206
+ for (const userPlan of plan.users.values()) {
1207
+ try {
1208
+ await OnCallShiftReminderRunner.sweepUser({
1209
+ userPlan,
1210
+ shifts: OnCallShiftMaterializer.filterShiftsForUser(
1211
+ result.shifts,
1212
+ userPlan.userId,
1213
+ ),
1214
+ lookbackFrom,
1215
+ ledger,
1216
+ context,
1217
+ stats,
1218
+ });
1219
+ } catch (err) {
1220
+ stats.errors++;
1221
+ logger.error(
1222
+ `${SHIFT_REMINDER_JOB_NAME}: failed to process user ${userPlan.userId.toString()} in project ${plan.projectId.toString()}`,
1223
+ );
1224
+ logger.error(err);
1225
+ }
1226
+ }
1227
+ }
1228
+
1229
+ private static async sweepUser(data: {
1230
+ userPlan: UserReminderPlan;
1231
+ shifts: Array<MaterializedShift>;
1232
+ lookbackFrom: Date;
1233
+ ledger: Ledger;
1234
+ context: SendContext;
1235
+ stats: ShiftReminderSweepStats;
1236
+ }): Promise<void> {
1237
+ const { userPlan, lookbackFrom, ledger, context, stats } = data;
1238
+ const now: Date = context.now;
1239
+
1240
+ for (const shift of data.shifts) {
1241
+ stats.shiftsConsidered++;
1242
+
1243
+ for (const lead of userPlan.leads) {
1244
+ if (
1245
+ !OnCallShiftReminderRunner.isDue({
1246
+ start: shift.start,
1247
+ lead,
1248
+ now,
1249
+ lookbackFrom,
1250
+ })
1251
+ ) {
1252
+ continue;
1253
+ }
1254
+
1255
+ // Lateness cap: never "starts in 15 minutes" after it started.
1256
+ if (shift.start.getTime() <= now.getTime()) {
1257
+ stats.skippedLate++;
1258
+ OnCallShiftReminderRunner.recordMetric(
1259
+ ShiftReminderOutcome.SkippedLate,
1260
+ );
1261
+ continue;
1262
+ }
1263
+
1264
+ // A schedule attached to no policy cannot page anyone.
1265
+ if (shift.policies.length === 0) {
1266
+ stats.skippedNoPolicy++;
1267
+ OnCallShiftReminderRunner.recordMetric(
1268
+ ShiftReminderOutcome.SkippedNoPolicy,
1269
+ );
1270
+ continue;
1271
+ }
1272
+
1273
+ await OnCallShiftReminderRunner.sendRegularReminder({
1274
+ shift,
1275
+ lead,
1276
+ userPlan,
1277
+ ledger,
1278
+ context,
1279
+ stats,
1280
+ });
1281
+ }
1282
+ }
1283
+ }
1284
+
1285
+ private static async sendRegularReminder(data: {
1286
+ shift: MaterializedShift;
1287
+ lead: number;
1288
+ userPlan: UserReminderPlan;
1289
+ ledger: Ledger;
1290
+ context: SendContext;
1291
+ stats: ShiftReminderSweepStats;
1292
+ }): Promise<void> {
1293
+ const { shift, lead, userPlan, ledger, context, stats } = data;
1294
+
1295
+ /*
1296
+ * Does a catch-up already cover this lead? The change pass keys its
1297
+ * catch-up with the LARGEST matching lead, so an exact-key check alone
1298
+ * would let a smaller lead fire a near-identical message minutes later
1299
+ * (an override at T-17 with leads [60, 15]: "now starts in 17 minutes"
1300
+ * from the catch-up, then "starts in 15 minutes" from the sweep). A
1301
+ * catch-up claimed at or after this lead's instant — minus the same
1302
+ * tolerance the "starts in" text uses — already WAS this lead's
1303
+ * reminder; an older one (a catch-up at T-30 against a 15-minute lead)
1304
+ * was not, and the configured reminder still goes out.
1305
+ */
1306
+ const shiftStartsAt: Date =
1307
+ UserOnCallShiftReminderLogServiceClass.truncateToMinute(shift.start);
1308
+ const coveredFrom: number =
1309
+ shiftStartsAt.getTime() -
1310
+ (lead + SHIFT_REMINDER_LEAD_TEXT_TOLERANCE_MINUTES) *
1311
+ MILLISECONDS_PER_MINUTE;
1312
+
1313
+ const coveredByCatchUp: boolean = ledger.rows.some((row: LedgerRow) => {
1314
+ return (
1315
+ row.kind === UserOnCallShiftReminderLogKind.CatchUp &&
1316
+ row.userId === userPlan.userId.toString() &&
1317
+ row.scheduleId === shift.scheduleId &&
1318
+ row.shiftStartsAt.getTime() === shiftStartsAt.getTime() &&
1319
+ row.claimedAt.getTime() >= coveredFrom
1320
+ );
1321
+ });
1322
+
1323
+ if (coveredByCatchUp) {
1324
+ stats.skippedAlreadySent++;
1325
+ OnCallShiftReminderRunner.recordMetric(
1326
+ ShiftReminderOutcome.SkippedAlreadySent,
1327
+ );
1328
+ return;
1329
+ }
1330
+
1331
+ const claim: ClaimResult = await OnCallShiftReminderRunner.claim({
1332
+ projectId: context.projectId,
1333
+ userId: userPlan.userId,
1334
+ scheduleId: shift.scheduleId,
1335
+ shiftStartsAt: shift.start,
1336
+ minutesBeforeShift: lead,
1337
+ kind: UserOnCallShiftReminderLogKind.Reminder,
1338
+ ledger,
1339
+ now: context.now,
1340
+ });
1341
+
1342
+ switch (claim.outcome) {
1343
+ case "already-sent":
1344
+ stats.skippedAlreadySent++;
1345
+ OnCallShiftReminderRunner.recordMetric(
1346
+ ShiftReminderOutcome.SkippedAlreadySent,
1347
+ );
1348
+ return;
1349
+ case "in-flight":
1350
+ stats.skippedInFlight++;
1351
+ OnCallShiftReminderRunner.recordMetric(
1352
+ ShiftReminderOutcome.SkippedInFlight,
1353
+ );
1354
+ return;
1355
+ case "collision":
1356
+ stats.claimCollisions++;
1357
+ OnCallShiftReminderRunner.recordMetric(
1358
+ ShiftReminderOutcome.ClaimCollision,
1359
+ );
1360
+ return;
1361
+ case "reclaimed":
1362
+ stats.claimRetries++;
1363
+ OnCallShiftReminderRunner.recordMetric(ShiftReminderOutcome.ClaimRetry);
1364
+ break;
1365
+ case "claimed":
1366
+ break;
1367
+ }
1368
+
1369
+ if (!claim.claimId) {
1370
+ return;
1371
+ }
1372
+
1373
+ // Another pass claimed the same shift from its own snapshot: it wins.
1374
+ const conflicted: boolean =
1375
+ await OnCallShiftReminderRunner.yieldToConcurrentSibling({
1376
+ projectId: context.projectId,
1377
+ userId: userPlan.userId,
1378
+ scheduleId: shift.scheduleId,
1379
+ shiftStartsAt,
1380
+ claimId: claim.claimId,
1381
+ claimedAt: context.now,
1382
+ ledger,
1383
+ });
1384
+
1385
+ if (conflicted) {
1386
+ stats.claimCollisions++;
1387
+ OnCallShiftReminderRunner.recordMetric(
1388
+ ShiftReminderOutcome.ClaimCollision,
1389
+ );
1390
+ return;
1391
+ }
1392
+
1393
+ const recipient: MaterializedUserInfo | undefined = context.users.get(
1394
+ userPlan.userId.toString(),
1395
+ );
1396
+
1397
+ const message: ShiftReminderMessage =
1398
+ OnCallShiftReminderRunner.buildReminderMessage({
1399
+ shift,
1400
+ lead,
1401
+ now: context.now,
1402
+ timezone: OnCallShiftReminderRunner.resolveTimezone({
1403
+ userTimezone: recipient?.timezone,
1404
+ scheduleTimezone: shift.scheduleTimezone,
1405
+ }),
1406
+ dashboardUrl: context.dashboardUrl,
1407
+ });
1408
+
1409
+ const delivered: boolean = await OnCallShiftReminderRunner.deliver({
1410
+ claimId: claim.claimId,
1411
+ userId: userPlan.userId,
1412
+ projectId: context.projectId,
1413
+ scheduleId: new ObjectID(shift.scheduleId),
1414
+ policies: shift.policies,
1415
+ message,
1416
+ now: context.now,
1417
+ onMissingSettings: () => {
1418
+ stats.missingSettings++;
1419
+ },
1420
+ });
1421
+
1422
+ if (delivered) {
1423
+ stats.sent++;
1424
+ OnCallShiftReminderRunner.recordMetric(ShiftReminderOutcome.Sent);
1425
+ } else {
1426
+ stats.sendFailures++;
1427
+ OnCallShiftReminderRunner.recordMetric(ShiftReminderOutcome.SendFailed);
1428
+ }
1429
+ }
1430
+
1431
+ // -- Change-pass internals ------------------------------------------------
1432
+
1433
+ private static async sendCatchUps(data: {
1434
+ userPlan: UserReminderPlan;
1435
+ heldShifts: Array<MaterializedShift>;
1436
+ ledger: Ledger;
1437
+ context: SendContext;
1438
+ stats: ShiftReminderChangePassStats;
1439
+ }): Promise<void> {
1440
+ const { userPlan, ledger, context, stats } = data;
1441
+ const now: Date = context.now;
1442
+ const userKey: string = userPlan.userId.toString();
1443
+
1444
+ for (const shift of data.heldShifts) {
1445
+ if (shift.policies.length === 0) {
1446
+ continue;
1447
+ }
1448
+
1449
+ const remainingMs: number = shift.start.getTime() - now.getTime();
1450
+
1451
+ const matchingLeads: Array<number> = userPlan.leads.filter(
1452
+ (lead: number) => {
1453
+ return remainingMs <= lead * MILLISECONDS_PER_MINUTE;
1454
+ },
1455
+ );
1456
+
1457
+ if (matchingLeads.length === 0) {
1458
+ continue;
1459
+ }
1460
+
1461
+ const lead: number = Math.max(...matchingLeads);
1462
+
1463
+ /*
1464
+ * Already told about this shift (any lead, reminder or catch-up)?
1465
+ * Then no catch-up — unless a LATER "reassigned" notice took it away
1466
+ * again, in which case the user must hear that it is theirs after
1467
+ * all.
1468
+ */
1469
+ const told: Array<LedgerRow> = ledger.rows.filter((row: LedgerRow) => {
1470
+ return (
1471
+ row.userId === userKey &&
1472
+ row.scheduleId === shift.scheduleId &&
1473
+ row.shiftStartsAt.getTime() === shift.start.getTime() &&
1474
+ (row.kind === UserOnCallShiftReminderLogKind.Reminder ||
1475
+ row.kind === UserOnCallShiftReminderLogKind.CatchUp)
1476
+ );
1477
+ });
1478
+
1479
+ const reassignedRow: LedgerRow | undefined = ledger.rows.find(
1480
+ (row: LedgerRow) => {
1481
+ return (
1482
+ row.userId === userKey &&
1483
+ row.scheduleId === shift.scheduleId &&
1484
+ row.shiftStartsAt.getTime() === shift.start.getTime() &&
1485
+ row.kind === UserOnCallShiftReminderLogKind.Reassigned
1486
+ );
1487
+ },
1488
+ );
1489
+
1490
+ const latestTold: number = told.reduce((max: number, row: LedgerRow) => {
1491
+ return Math.max(max, row.claimedAt.getTime());
1492
+ }, 0);
1493
+
1494
+ const takenBackLater: boolean =
1495
+ reassignedRow !== undefined &&
1496
+ reassignedRow.claimedAt.getTime() >= latestTold;
1497
+
1498
+ if (told.length > 0 && !takenBackLater) {
1499
+ continue;
1500
+ }
1501
+
1502
+ const claim: ClaimResult = await OnCallShiftReminderRunner.claim({
1503
+ projectId: context.projectId,
1504
+ userId: userPlan.userId,
1505
+ scheduleId: shift.scheduleId,
1506
+ shiftStartsAt: shift.start,
1507
+ minutesBeforeShift: lead,
1508
+ kind: UserOnCallShiftReminderLogKind.CatchUp,
1509
+ ledger,
1510
+ now,
1511
+ });
1512
+
1513
+ if (claim.outcome === "collision") {
1514
+ stats.claimCollisions++;
1515
+ }
1516
+
1517
+ if (!claim.claimId || claim.outcome === "already-sent") {
1518
+ continue;
1519
+ }
1520
+
1521
+ // A sweep tick claimed the same shift meanwhile: the older claim wins.
1522
+ const conflicted: boolean =
1523
+ await OnCallShiftReminderRunner.yieldToConcurrentSibling({
1524
+ projectId: context.projectId,
1525
+ userId: userPlan.userId,
1526
+ scheduleId: shift.scheduleId,
1527
+ shiftStartsAt:
1528
+ UserOnCallShiftReminderLogServiceClass.truncateToMinute(
1529
+ shift.start,
1530
+ ),
1531
+ claimId: claim.claimId,
1532
+ claimedAt: now,
1533
+ ledger,
1534
+ });
1535
+
1536
+ if (conflicted) {
1537
+ stats.claimCollisions++;
1538
+ OnCallShiftReminderRunner.recordMetric(
1539
+ ShiftReminderOutcome.ClaimCollision,
1540
+ );
1541
+ continue;
1542
+ }
1543
+
1544
+ const recipient: MaterializedUserInfo | undefined =
1545
+ context.users.get(userKey);
1546
+
1547
+ const message: ShiftReminderMessage =
1548
+ OnCallShiftReminderRunner.buildCatchUpMessage({
1549
+ shift,
1550
+ now,
1551
+ timezone: OnCallShiftReminderRunner.resolveTimezone({
1552
+ userTimezone: recipient?.timezone,
1553
+ scheduleTimezone: shift.scheduleTimezone,
1554
+ }),
1555
+ dashboardUrl: context.dashboardUrl,
1556
+ });
1557
+
1558
+ const delivered: boolean = await OnCallShiftReminderRunner.deliver({
1559
+ claimId: claim.claimId,
1560
+ userId: userPlan.userId,
1561
+ projectId: context.projectId,
1562
+ scheduleId: new ObjectID(shift.scheduleId),
1563
+ policies: shift.policies,
1564
+ message,
1565
+ now,
1566
+ onMissingSettings: () => {
1567
+ stats.missingSettings++;
1568
+ },
1569
+ });
1570
+
1571
+ if (!delivered) {
1572
+ stats.sendFailures++;
1573
+ continue;
1574
+ }
1575
+
1576
+ stats.catchUpsSent++;
1577
+ OnCallShiftReminderRunner.recordMetric(ShiftReminderOutcome.CatchUpSent);
1578
+
1579
+ // The shift is theirs again; let a future flip produce a fresh notice.
1580
+ if (reassignedRow && takenBackLater) {
1581
+ await OnCallShiftReminderRunner.deleteLedgerRow(reassignedRow.id);
1582
+ ledger.rows = ledger.rows.filter((row: LedgerRow) => {
1583
+ return row.id.toString() !== reassignedRow.id.toString();
1584
+ });
1585
+ ledger.byKey.delete(
1586
+ OnCallShiftReminderRunner.ledgerKey({
1587
+ userId: reassignedRow.userId,
1588
+ scheduleId: reassignedRow.scheduleId,
1589
+ shiftStartsAt: reassignedRow.shiftStartsAt,
1590
+ minutesBeforeShift: reassignedRow.minutesBeforeShift,
1591
+ kind: reassignedRow.kind,
1592
+ }),
1593
+ );
1594
+ }
1595
+ }
1596
+ }
1597
+
1598
+ private static async sendReassignedNotices(data: {
1599
+ userId: ObjectID;
1600
+ heldShifts: Array<MaterializedShift>;
1601
+ allShifts: Array<MaterializedShift>;
1602
+ ledger: Ledger;
1603
+ resolvedSchedules: Map<string, MaterializedScheduleInfo>;
1604
+ context: SendContext;
1605
+ stats: ShiftReminderChangePassStats;
1606
+ }): Promise<void> {
1607
+ const { ledger, context, stats } = data;
1608
+ const now: Date = context.now;
1609
+ const userKey: string = data.userId.toString();
1610
+
1611
+ // One notice per (schedule, start), however many leads were reminded.
1612
+ const seen: Set<string> = new Set<string>();
1613
+
1614
+ for (const row of ledger.rows) {
1615
+ if (
1616
+ row.userId !== userKey ||
1617
+ (row.kind !== UserOnCallShiftReminderLogKind.Reminder &&
1618
+ row.kind !== UserOnCallShiftReminderLogKind.CatchUp) ||
1619
+ row.shiftStartsAt.getTime() <= now.getTime()
1620
+ ) {
1621
+ continue;
1622
+ }
1623
+
1624
+ const shiftKey: string = `${row.scheduleId}|${row.shiftStartsAt.getTime()}`;
1625
+
1626
+ if (seen.has(shiftKey)) {
1627
+ continue;
1628
+ }
1629
+
1630
+ seen.add(shiftKey);
1631
+
1632
+ const schedule: MaterializedScheduleInfo | undefined =
1633
+ data.resolvedSchedules.get(row.scheduleId);
1634
+
1635
+ // Not resolved this pass (or unreliable): cannot judge, say nothing.
1636
+ if (!schedule || schedule.truncated) {
1637
+ continue;
1638
+ }
1639
+
1640
+ const stillHolds: boolean = data.heldShifts.some(
1641
+ (shift: MaterializedShift) => {
1642
+ return (
1643
+ shift.scheduleId === row.scheduleId &&
1644
+ shift.start.getTime() === row.shiftStartsAt.getTime()
1645
+ );
1646
+ },
1647
+ );
1648
+
1649
+ if (stillHolds) {
1650
+ continue;
1651
+ }
1652
+
1653
+ /*
1654
+ * A reassigned notice newer than the last reminder/catch-up was
1655
+ * already sent for this shift.
1656
+ */
1657
+ const latestTold: number = ledger.rows.reduce(
1658
+ (max: number, candidate: LedgerRow) => {
1659
+ if (
1660
+ candidate.userId === userKey &&
1661
+ candidate.scheduleId === row.scheduleId &&
1662
+ candidate.shiftStartsAt.getTime() === row.shiftStartsAt.getTime() &&
1663
+ (candidate.kind === UserOnCallShiftReminderLogKind.Reminder ||
1664
+ candidate.kind === UserOnCallShiftReminderLogKind.CatchUp)
1665
+ ) {
1666
+ return Math.max(max, candidate.claimedAt.getTime());
1667
+ }
1668
+ return max;
1669
+ },
1670
+ 0,
1671
+ );
1672
+
1673
+ const existingNotice: LedgerRow | undefined = ledger.byKey.get(
1674
+ OnCallShiftReminderRunner.ledgerKey({
1675
+ userId: userKey,
1676
+ scheduleId: row.scheduleId,
1677
+ shiftStartsAt: row.shiftStartsAt,
1678
+ minutesBeforeShift: 0,
1679
+ kind: UserOnCallShiftReminderLogKind.Reassigned,
1680
+ }),
1681
+ );
1682
+
1683
+ if (existingNotice && existingNotice.claimedAt.getTime() >= latestTold) {
1684
+ continue;
1685
+ }
1686
+
1687
+ const claim: ClaimResult = await OnCallShiftReminderRunner.claim({
1688
+ projectId: context.projectId,
1689
+ userId: data.userId,
1690
+ scheduleId: row.scheduleId,
1691
+ shiftStartsAt: row.shiftStartsAt,
1692
+ minutesBeforeShift: 0,
1693
+ kind: UserOnCallShiftReminderLogKind.Reassigned,
1694
+ ledger,
1695
+ now,
1696
+ });
1697
+
1698
+ if (claim.outcome === "collision") {
1699
+ stats.claimCollisions++;
1700
+ }
1701
+
1702
+ if (!claim.claimId || claim.outcome === "already-sent") {
1703
+ continue;
1704
+ }
1705
+
1706
+ const replacement: MaterializedShift | undefined = data.allShifts.find(
1707
+ (shift: MaterializedShift) => {
1708
+ return (
1709
+ shift.scheduleId === row.scheduleId &&
1710
+ shift.start.getTime() === row.shiftStartsAt.getTime() &&
1711
+ shift.userId !== userKey &&
1712
+ !shift.policyVariantOf
1713
+ );
1714
+ },
1715
+ );
1716
+
1717
+ const recipient: MaterializedUserInfo | undefined =
1718
+ context.users.get(userKey);
1719
+
1720
+ const message: ShiftReminderMessage =
1721
+ OnCallShiftReminderRunner.buildReassignedMessage({
1722
+ scheduleName: schedule.scheduleName,
1723
+ projectId: schedule.projectId,
1724
+ scheduleId: schedule.scheduleId,
1725
+ shiftStartsAt: row.shiftStartsAt,
1726
+ coveredBy: replacement ? replacement.userName : null,
1727
+ timezone: OnCallShiftReminderRunner.resolveTimezone({
1728
+ userTimezone: recipient?.timezone,
1729
+ scheduleTimezone: schedule.scheduleTimezone,
1730
+ }),
1731
+ dashboardUrl: context.dashboardUrl,
1732
+ });
1733
+
1734
+ const delivered: boolean = await OnCallShiftReminderRunner.deliver({
1735
+ claimId: claim.claimId,
1736
+ userId: data.userId,
1737
+ projectId: context.projectId,
1738
+ scheduleId: new ObjectID(schedule.scheduleId),
1739
+ policies: schedule.attachedPolicies,
1740
+ message,
1741
+ now,
1742
+ onMissingSettings: () => {
1743
+ stats.missingSettings++;
1744
+ },
1745
+ });
1746
+
1747
+ if (!delivered) {
1748
+ stats.sendFailures++;
1749
+ continue;
1750
+ }
1751
+
1752
+ stats.reassignedSent++;
1753
+ OnCallShiftReminderRunner.recordMetric(
1754
+ ShiftReminderOutcome.ReassignedSent,
1755
+ );
1756
+ }
1757
+ }
1758
+
1759
+ // -- Ledger -------------------------------------------------------------
1760
+
1761
+ /**
1762
+ * Claim the ledger row for one (user, schedule, start, lead, kind):
1763
+ * insert with sentAt NULL; on a unique violation, look at the existing
1764
+ * row — sent means done, a fresh claim means another worker is on it, a
1765
+ * stale claim is re-claimed with a conditional update so exactly one
1766
+ * worker wins.
1767
+ */
1768
+ private static async claim(data: {
1769
+ projectId: ObjectID;
1770
+ userId: ObjectID;
1771
+ scheduleId: string;
1772
+ shiftStartsAt: Date;
1773
+ minutesBeforeShift: number;
1774
+ kind: UserOnCallShiftReminderLogKind;
1775
+ ledger: Ledger;
1776
+ now: Date;
1777
+ }): Promise<ClaimResult> {
1778
+ const shiftStartsAt: Date =
1779
+ UserOnCallShiftReminderLogServiceClass.truncateToMinute(
1780
+ data.shiftStartsAt,
1781
+ );
1782
+ const key: string = OnCallShiftReminderRunner.ledgerKey({
1783
+ userId: data.userId,
1784
+ scheduleId: data.scheduleId,
1785
+ shiftStartsAt,
1786
+ minutesBeforeShift: data.minutesBeforeShift,
1787
+ kind: data.kind,
1788
+ });
1789
+
1790
+ const existing: LedgerRow | undefined = data.ledger.byKey.get(key);
1791
+
1792
+ if (existing) {
1793
+ return await OnCallShiftReminderRunner.reclaim(existing, data.now);
1794
+ }
1795
+
1796
+ const row: UserOnCallShiftReminderLog = new UserOnCallShiftReminderLog();
1797
+ row.projectId = data.projectId;
1798
+ row.userId = data.userId;
1799
+ row.onCallDutyPolicyScheduleId = new ObjectID(data.scheduleId);
1800
+ row.shiftStartsAt = shiftStartsAt;
1801
+ row.minutesBeforeShift = data.minutesBeforeShift;
1802
+ row.kind = data.kind;
1803
+ row.claimedAt = data.now;
1804
+
1805
+ try {
1806
+ const created: UserOnCallShiftReminderLog =
1807
+ await UserOnCallShiftReminderLogService.create({
1808
+ data: row,
1809
+ props: {
1810
+ isRoot: true,
1811
+ },
1812
+ });
1813
+
1814
+ if (!created.id) {
1815
+ return { claimId: null, outcome: "collision" };
1816
+ }
1817
+
1818
+ const ledgerRow: LedgerRow = {
1819
+ id: created.id,
1820
+ userId: data.userId.toString(),
1821
+ scheduleId: data.scheduleId,
1822
+ shiftStartsAt,
1823
+ minutesBeforeShift: data.minutesBeforeShift,
1824
+ kind: data.kind,
1825
+ claimedAt: data.now,
1826
+ sentAt: null,
1827
+ };
1828
+
1829
+ data.ledger.byKey.set(key, ledgerRow);
1830
+ data.ledger.rows.push(ledgerRow);
1831
+ data.ledger.knownIds.add(created.id.toString());
1832
+
1833
+ return { claimId: created.id, outcome: "claimed" };
1834
+ } catch (err) {
1835
+ if (PostgresErrorTranslator.isUniqueViolation(err)) {
1836
+ return { claimId: null, outcome: "collision" };
1837
+ }
1838
+
1839
+ throw err;
1840
+ }
1841
+ }
1842
+
1843
+ private static async reclaim(
1844
+ existing: LedgerRow,
1845
+ now: Date,
1846
+ ): Promise<ClaimResult> {
1847
+ if (existing.sentAt) {
1848
+ return { claimId: null, outcome: "already-sent" };
1849
+ }
1850
+
1851
+ const reclaimBefore: Date = OneUptimeDate.addRemoveMinutes(
1852
+ now,
1853
+ SHIFT_REMINDER_RECLAIM_AFTER_MINUTES * -1,
1854
+ );
1855
+
1856
+ if (existing.claimedAt.getTime() > reclaimBefore.getTime()) {
1857
+ return { claimId: null, outcome: "in-flight" };
1858
+ }
1859
+
1860
+ // Conditional: only the worker whose UPDATE matches the stale row wins.
1861
+ const updated: number = await UserOnCallShiftReminderLogService.updateOneBy(
1862
+ {
1863
+ query: {
1864
+ _id: existing.id,
1865
+ sentAt: QueryHelper.isNull(),
1866
+ claimedAt: QueryHelper.lessThanEqualTo(reclaimBefore),
1867
+ },
1868
+ data: {
1869
+ claimedAt: now,
1870
+ },
1871
+ props: {
1872
+ isRoot: true,
1873
+ },
1874
+ },
1875
+ );
1876
+
1877
+ if (updated !== 1) {
1878
+ return { claimId: null, outcome: "in-flight" };
1879
+ }
1880
+
1881
+ existing.claimedAt = now;
1882
+
1883
+ return { claimId: existing.id, outcome: "reclaimed" };
1884
+ }
1885
+
1886
+ private static async stampSent(
1887
+ claimId: ObjectID,
1888
+ sentAt: Date,
1889
+ ): Promise<void> {
1890
+ await UserOnCallShiftReminderLogService.updateOneById({
1891
+ id: claimId,
1892
+ data: {
1893
+ sentAt,
1894
+ },
1895
+ props: {
1896
+ isRoot: true,
1897
+ },
1898
+ });
1899
+ }
1900
+
1901
+ private static async deleteLedgerRow(claimId: ObjectID): Promise<void> {
1902
+ await UserOnCallShiftReminderLogService.deleteOneBy({
1903
+ query: {
1904
+ _id: claimId,
1905
+ },
1906
+ props: {
1907
+ isRoot: true,
1908
+ },
1909
+ });
1910
+ }
1911
+
1912
+ /**
1913
+ * The one duplicate the UNIQUE index cannot catch: the sweep and a
1914
+ * hook-triggered change pass deciding about the SAME (user, schedule,
1915
+ * shift start) at the same moment, in two processes. Both decide from a
1916
+ * snapshot taken before either claimed anything, and `reminder|lead` and
1917
+ * `catch-up|lead` are different keys, so both inserts succeed and both
1918
+ * messages go out seconds apart.
1919
+ *
1920
+ * So after claiming, re-read this shift's rows: a reminder/catch-up row
1921
+ * this pass did not write (not in the snapshot, not claimed by it) means
1922
+ * somebody else is notifying about the same shift. The OLDER claim wins —
1923
+ * ties broken by id, so the two sides always agree on the winner — and
1924
+ * the loser releases its claim and says nothing. Returns true when THIS
1925
+ * pass is the loser.
1926
+ *
1927
+ * A failed re-read never blocks a reminder: it logs and sends.
1928
+ */
1929
+ private static async yieldToConcurrentSibling(data: {
1930
+ projectId: ObjectID;
1931
+ userId: ObjectID;
1932
+ scheduleId: string;
1933
+ shiftStartsAt: Date;
1934
+ claimId: ObjectID;
1935
+ claimedAt: Date;
1936
+ ledger: Ledger;
1937
+ }): Promise<boolean> {
1938
+ const mine: string = data.claimId.toString();
1939
+
1940
+ let rows: Array<LedgerRow> = [];
1941
+
1942
+ try {
1943
+ rows = await OnCallShiftReminderRunner.loadLedgerRows({
1944
+ projectId: data.projectId,
1945
+ userIds: [data.userId],
1946
+ scheduleIds: [new ObjectID(data.scheduleId)],
1947
+ from: data.shiftStartsAt,
1948
+ });
1949
+ } catch (err) {
1950
+ logger.warn(
1951
+ `${SHIFT_REMINDER_JOB_NAME}: could not re-read the ledger after claiming ${mine}; sending anyway: ${err}`,
1952
+ );
1953
+ return false;
1954
+ }
1955
+
1956
+ const winner: LedgerRow | undefined = rows.find((row: LedgerRow) => {
1957
+ const id: string = row.id.toString();
1958
+
1959
+ if (
1960
+ id === mine ||
1961
+ data.ledger.knownIds.has(id) ||
1962
+ row.shiftStartsAt.getTime() !== data.shiftStartsAt.getTime() ||
1963
+ (row.kind !== UserOnCallShiftReminderLogKind.Reminder &&
1964
+ row.kind !== UserOnCallShiftReminderLogKind.CatchUp)
1965
+ ) {
1966
+ return false;
1967
+ }
1968
+
1969
+ return (
1970
+ row.claimedAt.getTime() < data.claimedAt.getTime() ||
1971
+ (row.claimedAt.getTime() === data.claimedAt.getTime() && id < mine)
1972
+ );
1973
+ });
1974
+
1975
+ if (!winner) {
1976
+ return false;
1977
+ }
1978
+
1979
+ logger.debug(
1980
+ `${SHIFT_REMINDER_JOB_NAME}: another pass is already notifying user ${data.userId.toString()} about the shift on schedule ${data.scheduleId} starting ${data.shiftStartsAt.toISOString()}; releasing claim ${mine}.`,
1981
+ );
1982
+
1983
+ await OnCallShiftReminderRunner.releaseClaim(data.claimId, data.ledger);
1984
+
1985
+ return true;
1986
+ }
1987
+
1988
+ /** Delete a claim this pass made and forget it, best-effort. */
1989
+ private static async releaseClaim(
1990
+ claimId: ObjectID,
1991
+ ledger: Ledger,
1992
+ ): Promise<void> {
1993
+ try {
1994
+ await OnCallShiftReminderRunner.deleteLedgerRow(claimId);
1995
+ } catch (err) {
1996
+ logger.error(
1997
+ `${SHIFT_REMINDER_JOB_NAME}: could not release claim ${claimId.toString()}; it becomes re-claimable after ${SHIFT_REMINDER_RECLAIM_AFTER_MINUTES} minutes.`,
1998
+ );
1999
+ logger.error(err);
2000
+ }
2001
+
2002
+ const id: string = claimId.toString();
2003
+
2004
+ ledger.rows = ledger.rows.filter((row: LedgerRow) => {
2005
+ return row.id.toString() !== id;
2006
+ });
2007
+
2008
+ for (const [key, row] of ledger.byKey) {
2009
+ if (row.id.toString() === id) {
2010
+ ledger.byKey.delete(key);
2011
+ }
2012
+ }
2013
+ }
2014
+
2015
+ /**
2016
+ * Send through the user's notification settings, then stamp the claim.
2017
+ * A thrown send deletes the claim so the next tick retries; returns
2018
+ * whether the row was stamped.
2019
+ */
2020
+ private static async deliver(data: {
2021
+ claimId: ObjectID;
2022
+ userId: ObjectID;
2023
+ projectId: ObjectID;
2024
+ scheduleId: ObjectID;
2025
+ policies: Array<MaterializedShiftPolicy>;
2026
+ message: ShiftReminderMessage;
2027
+ now: Date;
2028
+ onMissingSettings: () => void;
2029
+ }): Promise<boolean> {
2030
+ const { message } = data;
2031
+
2032
+ try {
2033
+ const hasSettings: boolean =
2034
+ await OnCallShiftReminderRunner.warnIfSettingsMissing({
2035
+ userId: data.userId,
2036
+ projectId: data.projectId,
2037
+ eventType: message.eventType,
2038
+ now: data.now,
2039
+ });
2040
+
2041
+ if (!hasSettings) {
2042
+ data.onMissingSettings();
2043
+ OnCallShiftReminderRunner.recordMetric(
2044
+ ShiftReminderOutcome.MissingSettings,
2045
+ );
2046
+ }
2047
+
2048
+ const emailEnvelope: EmailEnvelope = {
2049
+ templateType: message.templateType,
2050
+ vars: message.vars,
2051
+ subject: message.subject,
2052
+ };
2053
+
2054
+ const smsMessage: SMSMessage = {
2055
+ message: message.text,
2056
+ };
2057
+
2058
+ const callRequestMessage: CallRequestMessage = {
2059
+ data: [
2060
+ {
2061
+ sayMessage: `${message.text} Good bye.`,
2062
+ },
2063
+ ],
2064
+ };
2065
+
2066
+ const pushNotificationMessage: PushNotificationMessage =
2067
+ PushNotificationUtil.createGenericNotification({
2068
+ title: message.pushTitle,
2069
+ body: message.pushBody,
2070
+ clickAction: message.vars["scheduleViewLink"] || "",
2071
+ tag: "on-call-shift-reminder",
2072
+ requireInteraction: false,
2073
+ });
2074
+
2075
+ const whatsAppMessage: WhatsAppMessagePayload | undefined =
2076
+ OnCallShiftReminderRunner.buildWhatsAppMessage(message);
2077
+
2078
+ const firstPolicy: MaterializedShiftPolicy | undefined = data.policies[0];
2079
+
2080
+ await UserNotificationSettingService.sendUserNotification({
2081
+ userId: data.userId,
2082
+ projectId: data.projectId,
2083
+ eventType: message.eventType,
2084
+ emailEnvelope,
2085
+ smsMessage,
2086
+ callRequestMessage,
2087
+ pushNotificationMessage,
2088
+ whatsAppMessage,
2089
+ onCallScheduleId: data.scheduleId,
2090
+ onCallPolicyId: firstPolicy
2091
+ ? new ObjectID(firstPolicy.policyId)
2092
+ : undefined,
2093
+ onCallPolicyEscalationRuleId: firstPolicy
2094
+ ? new ObjectID(firstPolicy.ruleId)
2095
+ : undefined,
2096
+ });
2097
+ } catch (err) {
2098
+ logger.error(
2099
+ `${SHIFT_REMINDER_JOB_NAME}: sending "${message.eventType}" to user ${data.userId.toString()} failed; releasing the claim so the next tick retries.`,
2100
+ );
2101
+ logger.error(err);
2102
+
2103
+ try {
2104
+ await OnCallShiftReminderRunner.deleteLedgerRow(data.claimId);
2105
+ } catch (deleteErr) {
2106
+ logger.error(
2107
+ `${SHIFT_REMINDER_JOB_NAME}: could not release claim ${data.claimId.toString()}; it becomes re-claimable after ${SHIFT_REMINDER_RECLAIM_AFTER_MINUTES} minutes.`,
2108
+ );
2109
+ logger.error(deleteErr);
2110
+ }
2111
+
2112
+ return false;
2113
+ }
2114
+
2115
+ /*
2116
+ * The message is out. A failed stamp (a database blip between the send
2117
+ * and the UPDATE) must not throw: it would abort the rest of this
2118
+ * user's tick — every other shift and lead they are due — over a
2119
+ * notification that WAS delivered. Log it instead; the claim row stays
2120
+ * unstamped and may be re-claimed later, which is the safe direction.
2121
+ */
2122
+ try {
2123
+ await OnCallShiftReminderRunner.stampSent(
2124
+ data.claimId,
2125
+ OneUptimeDate.getCurrentDate(),
2126
+ );
2127
+ } catch (err) {
2128
+ logger.error(
2129
+ `${SHIFT_REMINDER_JOB_NAME}: "${message.eventType}" was delivered to user ${data.userId.toString()}, but claim ${data.claimId.toString()} could not be stamped as sent; it may be re-sent after ${SHIFT_REMINDER_RECLAIM_AFTER_MINUTES} minutes.`,
2130
+ );
2131
+ logger.error(err);
2132
+ }
2133
+
2134
+ return true;
2135
+ }
2136
+
2137
+ /**
2138
+ * WhatsApp delivers Meta-approved TEMPLATE messages only: a body-only
2139
+ * payload is rejected by the notification service before it ever reaches
2140
+ * Meta, which leaves a failed WhatsAppLog row and delivers nothing. No
2141
+ * template exists for the two new event types, and registering one is a
2142
+ * Meta approval rather than a code change — so:
2143
+ *
2144
+ * - a shift reminder (and its catch-up) reuses the approved
2145
+ * "you are next on-call for <policy> on <schedule>" template, whose
2146
+ * wording fits it exactly;
2147
+ * - a reassignment, which no approved template describes, sends no
2148
+ * WhatsApp payload at all, so the notification service skips the
2149
+ * channel cleanly instead of failing it.
2150
+ *
2151
+ * Email, SMS, call and push always carry the full text either way.
2152
+ */
2153
+ public static buildWhatsAppMessage(
2154
+ message: ShiftReminderMessage,
2155
+ ): WhatsAppMessagePayload | undefined {
2156
+ if (
2157
+ message.eventType !==
2158
+ NotificationSettingEventType.SEND_BEFORE_USER_ON_CALL_SHIFT_STARTS
2159
+ ) {
2160
+ return undefined;
2161
+ }
2162
+
2163
+ const scheduleLink: string = message.vars["scheduleViewLink"] || "";
2164
+
2165
+ try {
2166
+ return createWhatsAppMessageFromTemplate({
2167
+ templateKey: WhatsAppTemplateIds.OnCallUserIsNextNotification,
2168
+ actionLink: scheduleLink || undefined,
2169
+ templateVariables: {
2170
+ on_call_policy_name: message.vars["policyNames"] || "",
2171
+ schedule_name: message.vars["scheduleName"] || "",
2172
+ schedule_link: scheduleLink,
2173
+ },
2174
+ });
2175
+ } catch (err) {
2176
+ logger.warn(
2177
+ `${SHIFT_REMINDER_JOB_NAME}: could not build the WhatsApp template message for "${message.eventType}"; the channel is skipped: ${err}`,
2178
+ );
2179
+ return undefined;
2180
+ }
2181
+ }
2182
+
2183
+ /**
2184
+ * sendUserNotification silently sends nothing without a
2185
+ * UserNotificationSetting row. The DataMigration backfills one for every
2186
+ * member, so this should be unreachable — log loudly (once per user, project
2187
+ * and day) if it is not, so a silent zero is impossible.
2188
+ */
2189
+ private static async warnIfSettingsMissing(data: {
2190
+ userId: ObjectID;
2191
+ projectId: ObjectID;
2192
+ eventType: NotificationSettingEventType;
2193
+ now: Date;
2194
+ }): Promise<boolean> {
2195
+ const setting: UserNotificationSetting | null =
2196
+ await UserNotificationSettingService.findOneBy({
2197
+ query: {
2198
+ userId: data.userId,
2199
+ projectId: data.projectId,
2200
+ eventType: data.eventType,
2201
+ },
2202
+ select: {
2203
+ _id: true,
2204
+ },
2205
+ props: {
2206
+ isRoot: true,
2207
+ },
2208
+ });
2209
+
2210
+ if (setting) {
2211
+ return true;
2212
+ }
2213
+
2214
+ const day: string = data.now.toISOString().slice(0, 10);
2215
+ const warnKey: string = `${data.userId.toString()}|${data.projectId.toString()}|${data.eventType}|${day}`;
2216
+
2217
+ if (!OnCallShiftReminderRunner.missingSettingsWarned.has(warnKey)) {
2218
+ // Keep the set bounded: forget other days' entries.
2219
+ for (const key of OnCallShiftReminderRunner.missingSettingsWarned) {
2220
+ if (!key.endsWith(`|${day}`)) {
2221
+ OnCallShiftReminderRunner.missingSettingsWarned.delete(key);
2222
+ }
2223
+ }
2224
+
2225
+ OnCallShiftReminderRunner.missingSettingsWarned.add(warnKey);
2226
+
2227
+ logger.warn(
2228
+ `${SHIFT_REMINDER_JOB_NAME}: user ${data.userId.toString()} in project ${data.projectId.toString()} has no UserNotificationSetting row for "${data.eventType}", so nothing will be delivered. Run the AddShiftReminderNotificationSettingsForUsers data migration (or have the user re-save their notification settings).`,
2229
+ );
2230
+ }
2231
+
2232
+ return false;
2233
+ }
2234
+
2235
+ private static async loadLedgerRows(data: {
2236
+ projectId: ObjectID;
2237
+ userIds?: Array<ObjectID> | undefined;
2238
+ scheduleIds?: Array<ObjectID> | undefined;
2239
+ from: Date;
2240
+ }): Promise<Array<LedgerRow>> {
2241
+ if (
2242
+ (data.userIds && data.userIds.length === 0) ||
2243
+ (data.scheduleIds && data.scheduleIds.length === 0)
2244
+ ) {
2245
+ return [];
2246
+ }
2247
+
2248
+ const query: Record<string, unknown> = {
2249
+ projectId: data.projectId,
2250
+ shiftStartsAt: QueryHelper.greaterThanEqualTo(data.from),
2251
+ };
2252
+
2253
+ if (data.userIds) {
2254
+ query["userId"] = QueryHelper.any(data.userIds);
2255
+ }
2256
+
2257
+ if (data.scheduleIds) {
2258
+ query["onCallDutyPolicyScheduleId"] = QueryHelper.any(data.scheduleIds);
2259
+ }
2260
+
2261
+ const rows: Array<UserOnCallShiftReminderLog> =
2262
+ await UserOnCallShiftReminderLogService.findBy({
2263
+ query: query as never,
2264
+ select: {
2265
+ _id: true,
2266
+ userId: true,
2267
+ onCallDutyPolicyScheduleId: true,
2268
+ shiftStartsAt: true,
2269
+ minutesBeforeShift: true,
2270
+ kind: true,
2271
+ claimedAt: true,
2272
+ sentAt: true,
2273
+ },
2274
+ limit: LIMIT_MAX,
2275
+ skip: 0,
2276
+ props: {
2277
+ isRoot: true,
2278
+ },
2279
+ });
2280
+
2281
+ const ledgerRows: Array<LedgerRow> = [];
2282
+
2283
+ for (const row of rows) {
2284
+ if (
2285
+ !row.id ||
2286
+ !row.userId ||
2287
+ !row.onCallDutyPolicyScheduleId ||
2288
+ !row.shiftStartsAt ||
2289
+ !row.kind
2290
+ ) {
2291
+ continue;
2292
+ }
2293
+
2294
+ ledgerRows.push({
2295
+ id: row.id,
2296
+ userId: row.userId.toString(),
2297
+ scheduleId: row.onCallDutyPolicyScheduleId.toString(),
2298
+ shiftStartsAt: UserOnCallShiftReminderLogServiceClass.truncateToMinute(
2299
+ OneUptimeDate.fromString(row.shiftStartsAt),
2300
+ ),
2301
+ minutesBeforeShift: row.minutesBeforeShift ?? 0,
2302
+ kind: row.kind,
2303
+ claimedAt: row.claimedAt
2304
+ ? OneUptimeDate.fromString(row.claimedAt)
2305
+ : new Date(0),
2306
+ sentAt: row.sentAt ? OneUptimeDate.fromString(row.sentAt) : null,
2307
+ });
2308
+ }
2309
+
2310
+ return ledgerRows;
2311
+ }
2312
+
2313
+ private static buildLedger(rows: Array<LedgerRow>): Ledger {
2314
+ const byKey: Map<string, LedgerRow> = new Map<string, LedgerRow>();
2315
+ const knownIds: Set<string> = new Set<string>();
2316
+
2317
+ for (const row of rows) {
2318
+ byKey.set(
2319
+ OnCallShiftReminderRunner.ledgerKey({
2320
+ userId: row.userId,
2321
+ scheduleId: row.scheduleId,
2322
+ shiftStartsAt: row.shiftStartsAt,
2323
+ minutesBeforeShift: row.minutesBeforeShift,
2324
+ kind: row.kind,
2325
+ }),
2326
+ row,
2327
+ );
2328
+ knownIds.add(row.id.toString());
2329
+ }
2330
+
2331
+ return { byKey, rows: [...rows], knownIds };
2332
+ }
2333
+
2334
+ // -- Plans ----------------------------------------------------------------
2335
+
2336
+ private static buildProjectPlans(
2337
+ reminders: Array<UserOnCallShiftReminder>,
2338
+ ): Map<string, ProjectReminderPlan> {
2339
+ const plans: Map<string, ProjectReminderPlan> = new Map<
2340
+ string,
2341
+ ProjectReminderPlan
2342
+ >();
2343
+
2344
+ for (const reminder of reminders) {
2345
+ if (
2346
+ !reminder.projectId ||
2347
+ !reminder.userId ||
2348
+ typeof reminder.minutesBeforeShift !== "number" ||
2349
+ !Number.isFinite(reminder.minutesBeforeShift) ||
2350
+ reminder.minutesBeforeShift <= 0
2351
+ ) {
2352
+ continue;
2353
+ }
2354
+
2355
+ const projectKey: string = reminder.projectId.toString();
2356
+ const userKey: string = reminder.userId.toString();
2357
+
2358
+ let plan: ProjectReminderPlan | undefined = plans.get(projectKey);
2359
+
2360
+ if (!plan) {
2361
+ plan = {
2362
+ projectId: reminder.projectId,
2363
+ users: new Map<string, UserReminderPlan>(),
2364
+ maxLead: 0,
2365
+ };
2366
+ plans.set(projectKey, plan);
2367
+ }
2368
+
2369
+ let userPlan: UserReminderPlan | undefined = plan.users.get(userKey);
2370
+
2371
+ if (!userPlan) {
2372
+ userPlan = {
2373
+ userId: reminder.userId,
2374
+ projectId: reminder.projectId,
2375
+ leads: [],
2376
+ maxLead: 0,
2377
+ };
2378
+ plan.users.set(userKey, userPlan);
2379
+ }
2380
+
2381
+ const lead: number = Math.round(reminder.minutesBeforeShift);
2382
+
2383
+ if (!userPlan.leads.includes(lead)) {
2384
+ userPlan.leads.push(lead);
2385
+ userPlan.leads.sort((a: number, b: number) => {
2386
+ return b - a;
2387
+ });
2388
+ }
2389
+
2390
+ userPlan.maxLead = Math.max(userPlan.maxLead, lead);
2391
+ plan.maxLead = Math.max(plan.maxLead, lead);
2392
+ }
2393
+
2394
+ return plans;
2395
+ }
2396
+
2397
+ // -- Misc -----------------------------------------------------------------
2398
+
2399
+ private static async resolveProjectIdFromSchedules(
2400
+ scheduleIds: Array<ObjectID>,
2401
+ ): Promise<ObjectID | null> {
2402
+ const ids: Array<ObjectID> =
2403
+ OnCallShiftReminderRunner.dedupeIds(scheduleIds);
2404
+
2405
+ if (ids.length === 0) {
2406
+ return null;
2407
+ }
2408
+
2409
+ const schedules: Array<OnCallDutyPolicySchedule> =
2410
+ await OnCallDutyPolicyScheduleService.findBy({
2411
+ query: {
2412
+ _id: QueryHelper.any(ids),
2413
+ },
2414
+ select: {
2415
+ _id: true,
2416
+ projectId: true,
2417
+ },
2418
+ limit: LIMIT_PER_PROJECT,
2419
+ skip: 0,
2420
+ props: {
2421
+ isRoot: true,
2422
+ },
2423
+ });
2424
+
2425
+ for (const schedule of schedules) {
2426
+ if (schedule.projectId) {
2427
+ return schedule.projectId;
2428
+ }
2429
+ }
2430
+
2431
+ return null;
2432
+ }
2433
+
2434
+ private static async getDashboardUrl(): Promise<string> {
2435
+ try {
2436
+ const url: URL = await DatabaseConfig.getDashboardUrl();
2437
+ return url.toString();
2438
+ } catch (err) {
2439
+ logger.warn(
2440
+ `${SHIFT_REMINDER_JOB_NAME}: could not resolve the dashboard URL; links in reminders will be relative: ${err}`,
2441
+ );
2442
+ return "/dashboard";
2443
+ }
2444
+ }
2445
+
2446
+ /**
2447
+ * The materializer only looks up users it can see in the resolution: the
2448
+ * segment holders, the parties of an override, and the schedule's CURRENT
2449
+ * layer members. A user who was just removed from the layer — or from the
2450
+ * project — is none of those, yet they are exactly who a "reassigned"
2451
+ * notice goes to. Without them the notice falls back to the SCHEDULE's
2452
+ * timezone, so a Berlin engineer would read a New York wall clock. One
2453
+ * root lookup fills in the recipients that are missing AND actually have a
2454
+ * ledger row (nobody else can receive a notice), so the common pass adds
2455
+ * no query at all.
2456
+ */
2457
+ private static async backfillRecipients(data: {
2458
+ userIds: Array<ObjectID>;
2459
+ ledger: Ledger;
2460
+ context: SendContext;
2461
+ }): Promise<void> {
2462
+ const missing: Array<ObjectID> = data.userIds.filter((userId: ObjectID) => {
2463
+ const key: string = userId.toString();
2464
+
2465
+ if (data.context.users.has(key)) {
2466
+ return false;
2467
+ }
2468
+
2469
+ return data.ledger.rows.some((row: LedgerRow) => {
2470
+ return row.userId === key;
2471
+ });
2472
+ });
2473
+
2474
+ if (missing.length === 0) {
2475
+ return;
2476
+ }
2477
+
2478
+ try {
2479
+ const rows: Array<User> = await UserService.findBy({
2480
+ query: {
2481
+ _id: QueryHelper.any(missing),
2482
+ },
2483
+ select: {
2484
+ _id: true,
2485
+ name: true,
2486
+ email: true,
2487
+ timezone: true,
2488
+ },
2489
+ limit: LIMIT_PER_PROJECT,
2490
+ skip: 0,
2491
+ props: {
2492
+ isRoot: true,
2493
+ },
2494
+ });
2495
+
2496
+ for (const row of rows) {
2497
+ const userId: string | undefined = row.id?.toString();
2498
+
2499
+ if (!userId) {
2500
+ continue;
2501
+ }
2502
+
2503
+ const name: string = row.name?.toString().trim() || "";
2504
+ const email: string = row.email?.toString().trim() || "";
2505
+ const timezone: string = row.timezone?.toString() || "";
2506
+
2507
+ const info: MaterializedUserInfo = {
2508
+ userId,
2509
+ userName: name || email || OnCallCalendarFeedUtil.FALLBACK_USER_NAME,
2510
+ };
2511
+
2512
+ if (email) {
2513
+ info.email = email;
2514
+ }
2515
+
2516
+ if (timezone) {
2517
+ info.timezone = timezone;
2518
+ }
2519
+
2520
+ data.context.users.set(userId, info);
2521
+ }
2522
+ } catch (err) {
2523
+ logger.warn(
2524
+ `${SHIFT_REMINDER_LISTENER_NAME}: could not load ${missing.length} notice recipient(s); their messages fall back to the schedule's timezone: ${err}`,
2525
+ );
2526
+ }
2527
+ }
2528
+
2529
+ private static toUserMap(
2530
+ users: Array<MaterializedUserInfo>,
2531
+ ): Map<string, MaterializedUserInfo> {
2532
+ const map: Map<string, MaterializedUserInfo> = new Map<
2533
+ string,
2534
+ MaterializedUserInfo
2535
+ >();
2536
+
2537
+ for (const user of users) {
2538
+ map.set(user.userId, user);
2539
+ }
2540
+
2541
+ return map;
2542
+ }
2543
+
2544
+ private static dedupeIds(ids: Array<ObjectID>): Array<ObjectID> {
2545
+ const seen: Set<string> = new Set<string>();
2546
+ const result: Array<ObjectID> = [];
2547
+
2548
+ for (const id of ids) {
2549
+ if (!id) {
2550
+ continue;
2551
+ }
2552
+
2553
+ const key: string = id.toString();
2554
+
2555
+ if (!key || seen.has(key)) {
2556
+ continue;
2557
+ }
2558
+
2559
+ seen.add(key);
2560
+ result.push(id);
2561
+ }
2562
+
2563
+ return result;
2564
+ }
2565
+
2566
+ private static recordMetric(outcome: ShiftReminderOutcome): void {
2567
+ try {
2568
+ if (!OnCallShiftReminderRunner.counter) {
2569
+ OnCallShiftReminderRunner.counter = Telemetry.getCounter({
2570
+ name: METRIC_NAME,
2571
+ description:
2572
+ "On-call shift reminders and change notices by outcome (sent, skipped late, claim retry, ...).",
2573
+ unit: "1",
2574
+ });
2575
+ }
2576
+
2577
+ OnCallShiftReminderRunner.counter.add(1, {
2578
+ [METRIC_OUTCOME_ATTRIBUTE]: outcome,
2579
+ });
2580
+ } catch {
2581
+ // Metrics are best-effort; a reminder must never fail on telemetry.
2582
+ }
2583
+ }
2584
+ }