@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.
- package/Models/AnalyticsModels/SecurityEvent.ts +48 -16
- package/Models/AnalyticsModels/ThreatIntelIndicator.ts +10 -5
- package/Models/DatabaseModels/AlertSeverity.ts +44 -0
- package/Models/DatabaseModels/DetectionRule.ts +16 -5
- package/Models/DatabaseModels/GoogleSecOpsConnection.ts +14 -5
- package/Models/DatabaseModels/IncidentSeverity.ts +44 -0
- package/Models/DatabaseModels/Index.ts +13 -0
- package/Models/DatabaseModels/NetworkDevice.ts +113 -3
- package/Models/DatabaseModels/NetworkDeviceAutoImportRule.ts +88 -0
- package/Models/DatabaseModels/NetworkDeviceDiscoveryScan.ts +22 -0
- package/Models/DatabaseModels/NetworkDeviceOidTemplate.ts +499 -0
- package/Models/DatabaseModels/OnCallDutyPolicySchedule.ts +43 -0
- package/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.ts +662 -0
- package/Models/DatabaseModels/ProjectOnCallCalendarFeed.ts +584 -0
- package/Models/DatabaseModels/ThreatIntelFeed.ts +16 -5
- package/Models/DatabaseModels/UserOnCallCalendarFeed.ts +546 -0
- package/Models/DatabaseModels/UserOnCallShiftReminder.ts +231 -0
- package/Models/DatabaseModels/UserOnCallShiftReminderLog.ts +331 -0
- package/Server/API/GlobalConfigAPI.ts +362 -183
- package/Server/API/OnCallCalendarAPI.ts +2194 -0
- package/Server/API/TelemetryAPI.ts +42 -4
- package/Server/EnvironmentConfig.ts +250 -0
- package/Server/Infrastructure/OnCallCalendarFeedCache.ts +1050 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1790400000000-AddOnCallCalendarFeeds.ts +253 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1790500000000-AddNetworkDeviceOidTemplate.ts +59 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1790600000000-AddAutoImportRuleOidTemplate.ts +25 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +6 -0
- package/Server/Middleware/MultipartFormData.ts +118 -32
- package/Server/Middleware/OnCallCalendarFeedRateLimit.ts +487 -0
- package/Server/Services/AlertEpisodeLabelRuleEngineService.ts +9 -1
- package/Server/Services/AlertEpisodeOnCallRuleEngineService.ts +9 -1
- package/Server/Services/AlertEpisodeOwnerRuleEngineService.ts +9 -1
- package/Server/Services/AlertEpisodePrivacyRuleEngineService.ts +9 -1
- package/Server/Services/AlertGroupingEngineService.ts +9 -1
- package/Server/Services/AlertLabelRuleEngineService.ts +9 -1
- package/Server/Services/AlertOnCallRuleEngineService.ts +9 -1
- package/Server/Services/AlertOwnerRuleEngineService.ts +9 -1
- package/Server/Services/AlertPrivacyRuleEngineService.ts +9 -1
- package/Server/Services/AlertReminderRuleService.ts +9 -1
- package/Server/Services/AutoRemediationRuleEngineService.ts +9 -1
- package/Server/Services/CephClusterLabelRuleEngineService.ts +9 -1
- package/Server/Services/CephClusterOwnerRuleEngineService.ts +9 -1
- package/Server/Services/CloudResourceLabelRuleEngineService.ts +9 -1
- package/Server/Services/CloudResourceOwnerRuleEngineService.ts +9 -1
- package/Server/Services/DashboardLabelRuleEngineService.ts +9 -1
- package/Server/Services/DashboardOwnerRuleEngineService.ts +9 -1
- package/Server/Services/DockerHostLabelRuleEngineService.ts +9 -1
- package/Server/Services/DockerHostOwnerRuleEngineService.ts +9 -1
- package/Server/Services/DockerSwarmClusterLabelRuleEngineService.ts +9 -1
- package/Server/Services/DockerSwarmClusterOwnerRuleEngineService.ts +9 -1
- package/Server/Services/HostLabelRuleEngineService.ts +9 -1
- package/Server/Services/HostOwnerRuleEngineService.ts +9 -1
- package/Server/Services/IncidentEpisodeLabelRuleEngineService.ts +9 -1
- package/Server/Services/IncidentEpisodeOnCallRuleEngineService.ts +9 -1
- package/Server/Services/IncidentEpisodeOwnerRuleEngineService.ts +9 -1
- package/Server/Services/IncidentEpisodePrivacyRuleEngineService.ts +9 -1
- package/Server/Services/IncidentGroupingEngineService.ts +9 -1
- package/Server/Services/IncidentLabelRuleEngineService.ts +9 -1
- package/Server/Services/IncidentOnCallRuleEngineService.ts +9 -1
- package/Server/Services/IncidentOwnerRuleEngineService.ts +9 -1
- package/Server/Services/IncidentPrivacyRuleEngineService.ts +9 -1
- package/Server/Services/IncidentReminderRuleService.ts +9 -1
- package/Server/Services/IncidentSlaRuleService.ts +9 -1
- package/Server/Services/IncomingCallPolicyLabelRuleEngineService.ts +9 -1
- package/Server/Services/IncomingCallPolicyOwnerRuleEngineService.ts +9 -1
- package/Server/Services/Index.ts +14 -0
- package/Server/Services/IoTFleetLabelRuleEngineService.ts +9 -1
- package/Server/Services/IoTFleetOwnerRuleEngineService.ts +9 -1
- package/Server/Services/KubernetesClusterLabelRuleEngineService.ts +9 -1
- package/Server/Services/KubernetesClusterOwnerRuleEngineService.ts +9 -1
- package/Server/Services/KubernetesResourceService.ts +5 -1
- package/Server/Services/MonitorLabelRuleEngineService.ts +9 -1
- package/Server/Services/MonitorOwnerRuleEngineService.ts +9 -1
- package/Server/Services/NetworkDeviceAutoImportRuleEngineService.ts +35 -0
- package/Server/Services/NetworkDeviceAutoImportRuleService.ts +96 -0
- package/Server/Services/NetworkDeviceLabelRuleEngineService.ts +9 -1
- package/Server/Services/NetworkDeviceOidTemplateService.ts +201 -0
- package/Server/Services/NetworkDeviceOwnerRuleEngineService.ts +9 -1
- package/Server/Services/NetworkDeviceService.ts +209 -1
- package/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.ts +32 -0
- package/Server/Services/OnCallDutyPolicyLabelRuleEngineService.ts +9 -1
- package/Server/Services/OnCallDutyPolicyOwnerRuleEngineService.ts +9 -1
- package/Server/Services/OnCallDutyPolicyScheduleCalendarFeedService.ts +260 -0
- package/Server/Services/OnCallDutyPolicyScheduleLabelRuleEngineService.ts +9 -1
- package/Server/Services/OnCallDutyPolicyScheduleLayerService.ts +70 -3
- package/Server/Services/OnCallDutyPolicyScheduleLayerUserService.ts +138 -3
- package/Server/Services/OnCallDutyPolicyScheduleOwnerRuleEngineService.ts +9 -1
- package/Server/Services/OnCallDutyPolicyScheduleService.ts +1465 -134
- package/Server/Services/OnCallDutyPolicyUserOverrideService.ts +167 -31
- package/Server/Services/PodmanHostLabelRuleEngineService.ts +9 -1
- package/Server/Services/PodmanHostOwnerRuleEngineService.ts +9 -1
- package/Server/Services/ProjectOnCallCalendarFeedService.ts +192 -0
- package/Server/Services/ProxmoxClusterLabelRuleEngineService.ts +9 -1
- package/Server/Services/ProxmoxClusterOwnerRuleEngineService.ts +9 -1
- package/Server/Services/RumApplicationLabelRuleEngineService.ts +9 -1
- package/Server/Services/RumApplicationOwnerRuleEngineService.ts +9 -1
- package/Server/Services/RunbookLabelRuleEngineService.ts +9 -1
- package/Server/Services/RunbookOwnerRuleEngineService.ts +9 -1
- package/Server/Services/RunbookRuleEngineService.ts +9 -1
- package/Server/Services/ScheduledMaintenanceLabelRuleEngineService.ts +9 -1
- package/Server/Services/ScheduledMaintenanceOwnerRuleEngineService.ts +9 -1
- package/Server/Services/ScheduledMaintenanceReminderRuleService.ts +9 -1
- package/Server/Services/ServerlessFunctionLabelRuleEngineService.ts +9 -1
- package/Server/Services/ServerlessFunctionOwnerRuleEngineService.ts +9 -1
- package/Server/Services/ServiceLabelRuleEngineService.ts +9 -1
- package/Server/Services/ServiceOwnerRuleEngineService.ts +9 -1
- package/Server/Services/StatusPageLabelRuleEngineService.ts +9 -1
- package/Server/Services/StatusPageMonitorRuleEngineService.ts +7 -0
- package/Server/Services/StatusPageOwnerRuleEngineService.ts +9 -1
- package/Server/Services/TeamMemberService.ts +489 -1
- package/Server/Services/TeamService.ts +50 -0
- package/Server/Services/TelemetrySourceMapService.ts +266 -23
- package/Server/Services/UserNotificationSettingService.ts +44 -1
- package/Server/Services/UserOnCallCalendarFeedService.ts +159 -0
- package/Server/Services/UserOnCallShiftReminderLogService.ts +151 -0
- package/Server/Services/UserOnCallShiftReminderService.ts +112 -0
- package/Server/Services/UserService.ts +27 -0
- package/Server/Services/WorkflowLabelRuleEngineService.ts +9 -1
- package/Server/Services/WorkflowOwnerRuleEngineService.ts +9 -1
- package/Server/Utils/EnterpriseLicense/EnterpriseLicenseSeatUtil.ts +186 -0
- package/Server/Utils/Monitor/CapturedMetricAttributeUtil.ts +245 -0
- package/Server/Utils/Monitor/Criteria/SnmpMonitorCriteria.ts +21 -2
- package/Server/Utils/Monitor/MonitorMetricUtil.ts +55 -21
- package/Server/Utils/Monitor/MonitorTemplateUtil.ts +32 -0
- package/Server/Utils/Monitor/NetworkDeviceMetricUtil.ts +15 -2
- package/Server/Utils/Monitor/NetworkDeviceWalkUtil.ts +18 -1
- package/Server/Utils/Monitor/NetworkInventoryUtil.ts +10 -0
- package/Server/Utils/Monitor/SeriesResourceLabels.ts +34 -0
- package/Server/Utils/OnCall/CalendarFeedToken.ts +241 -0
- package/Server/Utils/OnCall/OnCallCalendarFeedRenderer.ts +1488 -0
- package/Server/Utils/OnCall/OnCallCalendarFeedUrls.ts +230 -0
- package/Server/Utils/OnCall/OnCallShiftChangeListeners.ts +227 -0
- package/Server/Utils/OnCall/OnCallShiftMaterializer.ts +1127 -0
- package/Server/Utils/OnCall/OnCallShiftReminderListener.ts +174 -0
- package/Server/Utils/OnCall/OnCallShiftReminderRunner.ts +2584 -0
- package/Server/Utils/Response.ts +134 -0
- package/Server/Utils/Rules/RuleEngineRuleRead.ts +48 -0
- package/Server/Utils/StartServer.ts +10 -0
- package/Server/Utils/Telemetry/AppMetrics.ts +51 -0
- package/Server/Utils/Telemetry/ExceptionSanitizer.ts +14 -1
- package/Server/Utils/Telemetry/LogExceptionExtractor.ts +14 -2
- package/Server/Utils/Telemetry/SourceMapResolver.ts +16 -5
- package/Server/Utils/Telemetry/StackTraceParser.ts +399 -0
- package/Tests/App/Dashboard/EventOverlayHook.test.tsx +125 -0
- package/Tests/App/Dashboard/HeaderImageAssets.test.tsx +93 -0
- package/Tests/App/Dashboard/OnCallCalendarFeedLinks.test.tsx +229 -0
- package/Tests/App/Dashboard/OnCallCalendarFeedPlanGate.test.tsx +489 -0
- package/Tests/App/Dashboard/OnCallCalendarFeedSideMenus.test.tsx +149 -0
- package/Tests/App/Dashboard/OnCallCalendarFeedUtil.test.ts +855 -0
- package/Tests/App/Dashboard/OnCallPersonalCalendarFeed.test.tsx +1209 -0
- package/Tests/App/Dashboard/OnCallReadinessSurfaces.test.tsx +3 -3
- package/Tests/App/Dashboard/OnCallSharedCalendarFeedCard.test.tsx +1019 -0
- package/Tests/App/Dashboard/OnCallShiftRemindersAndUpcomingShifts.test.tsx +655 -0
- package/Tests/App/Dashboard/UserElementEmail.test.tsx +12 -4
- package/Tests/App/Dashboard/UserEmailCallSites.test.tsx +1 -1
- package/Tests/App/Dashboard/UserSettingsSetupChecklistModel.test.ts +73 -0
- package/Tests/Models/DatabaseModels/DomainRoleTierCoverage.test.ts +14 -0
- package/Tests/Models/DatabaseModels/ModelRegistryInvariants.test.ts +469 -0
- package/Tests/Models/OnCallCalendarFeedModels.test.ts +642 -0
- package/Tests/Models/OnCallDutyPolicyScheduleCalendarFeed.test.ts +288 -0
- package/Tests/Models/OnCallDutyPolicyScheduleShiftConfigVersion.test.ts +88 -0
- package/Tests/Models/SecurityDomainAccessControl.test.ts +429 -0
- package/Tests/Models/UserOnCallShiftReminderModels.test.ts +368 -0
- package/Tests/Server/API/GlobalConfigLicense.test.ts +657 -0
- package/Tests/Server/API/Helpers.ts +8 -0
- package/Tests/Server/API/OnCallCalendarAPI.test.ts +4461 -0
- package/Tests/Server/API/SecurityEventAttributesAPI.test.ts +84 -6
- package/Tests/Server/API/TelemetryResolveStackTraceAPI.test.ts +805 -0
- package/Tests/Server/EnvironmentConfigOnCallCalendarFeed.test.ts +357 -0
- package/Tests/Server/Infrastructure/OnCallCalendarFeedCache.test.ts +1589 -0
- package/Tests/Server/Infrastructure/Postgres/AddOnCallCalendarFeedsMigration.test.ts +394 -0
- package/Tests/Server/Middleware/IdentityRateLimit.test.ts +1144 -0
- package/Tests/Server/Middleware/MultipartFormData.test.ts +618 -3
- package/Tests/Server/Middleware/OnCallCalendarFeedRateLimit.test.ts +1365 -0
- package/Tests/Server/Services/FeedRetentionConsistency.test.ts +70 -0
- package/Tests/Server/Services/MonitorLabelRuleEngineScale.test.ts +520 -0
- package/Tests/Server/Services/NetworkDeviceOidTemplateService.test.ts +332 -0
- package/Tests/Server/Services/NetworkDeviceOidTemplateWriteGuards.test.ts +409 -0
- package/Tests/Server/Services/OnCallCalendarFeedServices.test.ts +1103 -0
- package/Tests/Server/Services/OnCallDutyPolicyScheduleResolver.test.ts +1037 -0
- package/Tests/Server/Services/OnCallShiftConfigPropagation.test.ts +1270 -0
- package/Tests/Server/Services/RuleEngineRuleFetchLimit.test.ts +239 -0
- package/Tests/Server/Services/TeamDeleteMemberCleanup.test.ts +156 -0
- package/Tests/Server/Services/TeamMemberOnCallCleanup.test.ts +755 -0
- package/Tests/Server/Services/TelemetrySourceMapService.test.ts +1210 -2
- package/Tests/Server/Services/UserNotificationSettingShiftReminderDefaults.test.ts +267 -0
- package/Tests/Server/Services/UserOnCallShiftReminderServices.test.ts +583 -0
- package/Tests/Server/Services/UserServiceSeatLimit.test.ts +182 -0
- package/Tests/Server/Types/AnalyticsDatabase/SecurityEventModelPermission.test.ts +255 -0
- package/Tests/Server/Types/Database/Permissions/OnCallCalendarFeedPermissions.test.ts +254 -0
- package/Tests/Server/Types/Database/Permissions/ReadBlockPermission.test.ts +212 -0
- package/Tests/Server/Types/Database/Permissions/SecurityRoleAccess.test.ts +413 -0
- package/Tests/Server/Utils/CodeRepository.test.ts +689 -0
- package/Tests/Server/Utils/EnterpriseLicense/EnterpriseLicenseSeatUtil.test.ts +495 -0
- package/Tests/Server/Utils/ExceptionSanitizer.test.ts +77 -0
- package/Tests/Server/Utils/Monitor/CapturedMetricAttributeUtil.test.ts +284 -0
- package/Tests/Server/Utils/Monitor/MonitorCustomMetricAttributes.test.ts +503 -0
- package/Tests/Server/Utils/Monitor/MonitorTemplatePrototypePollution.test.ts +106 -0
- package/Tests/Server/Utils/Monitor/NetworkDeviceWalkLogWritePath.test.ts +33 -4
- package/Tests/Server/Utils/Monitor/NetworkDeviceWalkUtil.test.ts +14 -4
- package/Tests/Server/Utils/Monitor/NetworkInventoryUtil.test.ts +44 -0
- package/Tests/Server/Utils/OnCall/CalendarFeedToken.test.ts +467 -0
- package/Tests/Server/Utils/OnCall/OnCallCalendarFeedRenderer.test.ts +2142 -0
- package/Tests/Server/Utils/OnCall/OnCallCalendarFeedUrls.test.ts +278 -0
- package/Tests/Server/Utils/OnCall/OnCallResolverTestHarness.ts +440 -0
- package/Tests/Server/Utils/OnCall/OnCallShiftChangeListeners.test.ts +314 -0
- package/Tests/Server/Utils/OnCall/OnCallShiftMaterializer.test.ts +1502 -0
- package/Tests/Server/Utils/OnCall/OnCallShiftReminderListener.test.ts +491 -0
- package/Tests/Server/Utils/OnCall/OnCallShiftReminderRunner.test.ts +3088 -0
- package/Tests/Server/Utils/OnCall/OnCallShiftReminderTestHarness.ts +910 -0
- package/Tests/Server/Utils/OpenAPISpec.test.ts +536 -0
- package/Tests/Server/Utils/ResponseSendCalendarResponse.test.ts +561 -0
- package/Tests/Server/Utils/Rules/RuleEngineRuleRead.test.ts +115 -0
- package/Tests/Server/Utils/StackTraceParser.test.ts +610 -0
- package/Tests/Server/Utils/StartServerEncryptionSecretWarning.test.ts +64 -0
- package/Tests/Server/Utils/Telemetry/AppMetrics.test.ts +29 -0
- package/Tests/Server/Utils/Telemetry/LogExceptionExtractor.test.ts +0 -0
- package/Tests/Server/Utils/Telemetry/SourceMapLimits.test.ts +656 -0
- package/Tests/Server/Utils/Telemetry/SourceMapResolver.test.ts +100 -0
- package/Tests/Types/Calendar/ICalendar.test.ts +488 -0
- package/Tests/Types/Monitor/SnmpMonitor/SnmpOidListUtil.test.ts +454 -0
- package/Tests/Types/NotificationSetting/NotificationSettingEventType.test.ts +53 -0
- package/Tests/Types/OnCallDutyPolicy/CalendarFeedTestFixtures.ts +310 -0
- package/Tests/Types/OnCallDutyPolicy/CalendarFeedWindow.test.ts +205 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilLayerMetaAndIterationCap.test.ts +458 -0
- package/Tests/Types/OnCallDutyPolicy/MaterializedShift.test.ts +197 -0
- package/Tests/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.test.ts +1986 -0
- package/Tests/Types/OnCallDutyPolicy/ScheduleShiftUtilGroupKey.test.ts +678 -0
- package/Tests/Types/OnCallDutyPolicy/ShiftSeamUtil.test.ts +417 -0
- package/Tests/Types/SecurityRolePermissions.test.ts +193 -0
- package/Tests/UI/Components/Charts/AnnotationLayout.test.ts +773 -0
- package/Tests/UI/Components/Charts/ChartAnnotationRail.test.tsx +789 -0
- package/Tests/UI/Components/Charts/ChartBucketIdentity.test.ts +254 -0
- package/Tests/UI/Components/Charts/TimeAnnotation.test.ts +471 -0
- package/Tests/UI/Components/EditionLabelLicenseRefresh.test.tsx +405 -0
- package/Tests/UI/Components/ErrorMessageRefreshControl.test.tsx +83 -0
- package/Tests/UI/Components/MarkdownConverters.test.ts +686 -0
- package/Tests/UI/Components/ModelTable/BaseModelTableDeleteAndEmptyStates.test.tsx +341 -0
- package/Tests/UI/Components/OrderedStatesList.test.tsx +5 -5
- package/Tests/UI/Components/PendingProjectInvitations.test.tsx +1 -1
- package/Tests/UI/Components/TableSortAndMobileColumns.test.tsx +195 -0
- package/Tests/UI/Components/XAxis.test.ts +270 -5
- package/Tests/UI/Components/XAxisDaylightSaving.test.ts +113 -0
- package/Tests/UI/Styles/ChartFocusRing.test.ts +153 -0
- package/Tests/UI/Styles/ChartFocusRingCoverage.test.tsx +377 -0
- package/Tests/UI/Styles/ThemeStylesheet.ts +162 -0
- package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +10 -0
- package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +2 -0
- package/Tests/Utils/EnterpriseLicenseSeats.test.ts +507 -0
- package/Tests/Utils/NetworkDiscovery/DiscoveredDeviceBuilder.test.ts +951 -1
- package/Tests/Utils/NetworkDiscovery/DiscoveredHostUtil.test.ts +238 -0
- package/Tests/Utils/NetworkDiscovery/DiscoveryReverseDnsChain.test.ts +1056 -0
- package/Tests/Utils/NetworkDiscovery/ReverseDnsNameUtil.test.ts +882 -0
- package/Types/Calendar/ICalendar.ts +442 -0
- package/Types/Date.ts +39 -0
- package/Types/Email/EmailTemplateType.ts +2 -0
- package/Types/Monitor/SnmpMonitor/SnmpOidListUtil.ts +333 -0
- package/Types/NotificationSetting/NotificationSettingEventType.ts +4 -0
- package/Types/OnCallDutyPolicy/CalendarFeedWindow.ts +140 -0
- package/Types/OnCallDutyPolicy/Layer.ts +190 -35
- package/Types/OnCallDutyPolicy/MaterializedShift.ts +274 -0
- package/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.ts +1415 -0
- package/Types/OnCallDutyPolicy/ScheduleShiftUtil.ts +159 -4
- package/Types/OnCallDutyPolicy/ShiftSeamUtil.ts +106 -0
- package/Types/Permission.ts +96 -0
- package/Types/Rum/SessionReplay.ts +21 -0
- package/Types/Telemetry/SourceMap.ts +8 -0
- package/UI/Components/Charts/ChartLibrary/Annotations/AnnotationHoverCard.tsx +176 -0
- package/UI/Components/Charts/ChartLibrary/Annotations/AnnotationLayout.ts +745 -0
- package/UI/Components/Charts/ChartLibrary/Annotations/ChartAnnotationLayer.tsx +576 -0
- package/UI/Components/Charts/ChartLibrary/Annotations/UseChartAnnotations.tsx +204 -0
- package/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.tsx +491 -544
- package/UI/Components/Charts/ChartLibrary/BarChart/BarChart.tsx +324 -372
- package/UI/Components/Charts/ChartLibrary/LineChart/LineChart.tsx +487 -540
- package/UI/Components/Charts/ChartLibrary/Types/FormattedReferenceRegion.ts +7 -3
- package/UI/Components/Charts/ChartLibrary/Types/FormattedTimeReferenceLine.ts +14 -3
- package/UI/Components/Charts/Types/ChartEventKind.ts +13 -0
- package/UI/Components/Charts/Types/ReferenceRegionProps.ts +6 -0
- package/UI/Components/Charts/Types/TimeReferenceLineProps.ts +20 -3
- package/UI/Components/Charts/Utils/TimeAnnotation.ts +69 -35
- package/UI/Components/Charts/Utils/XAxis.ts +125 -18
- package/UI/Components/EditionLabel/EditionLabel.tsx +280 -26
- package/UI/Components/ErrorMessage/ErrorMessage.tsx +21 -10
- package/UI/Components/Feed/FeedItem.tsx +4 -5
- package/UI/Components/Header/HeaderIconDropdownButton.tsx +1 -2
- package/UI/Components/Header/UserProfile/UserProfile.tsx +1 -2
- package/UI/Components/Image/Image.tsx +20 -3
- package/UI/Components/Markdown.tsx/MarkdownConverters.ts +0 -0
- package/UI/Components/MemberRoleAssignment/MemberRoleAssignment.tsx +1 -2
- package/UI/Components/ModelTable/BaseModelTable.tsx +150 -24
- package/UI/Components/Table/TableHeader.tsx +64 -48
- package/UI/Components/Table/TableRow.tsx +13 -3
- package/UI/Styles/Theme.css +38 -0
- package/Utils/EnterpriseLicense/EnterpriseLicenseSeats.ts +271 -0
- package/Utils/NetworkDiscovery/AutoImportRuleMatcher.ts +6 -0
- package/Utils/NetworkDiscovery/DiscoveredDeviceBuilder.ts +141 -10
- package/Utils/NetworkDiscovery/DiscoveredHostUtil.ts +55 -3
- package/Utils/NetworkDiscovery/ReverseDnsNameUtil.ts +151 -0
- package/Utils/Rules/RuleEngineLimits.ts +32 -0
- package/build/dist/Models/AnalyticsModels/SecurityEvent.js +48 -16
- package/build/dist/Models/AnalyticsModels/SecurityEvent.js.map +1 -1
- package/build/dist/Models/AnalyticsModels/ThreatIntelIndicator.js +10 -5
- package/build/dist/Models/AnalyticsModels/ThreatIntelIndicator.js.map +1 -1
- package/build/dist/Models/DatabaseModels/AlertSeverity.js +46 -1
- package/build/dist/Models/DatabaseModels/AlertSeverity.js.map +1 -1
- package/build/dist/Models/DatabaseModels/DetectionRule.js +16 -5
- package/build/dist/Models/DatabaseModels/DetectionRule.js.map +1 -1
- package/build/dist/Models/DatabaseModels/GoogleSecOpsConnection.js +14 -5
- package/build/dist/Models/DatabaseModels/GoogleSecOpsConnection.js.map +1 -1
- package/build/dist/Models/DatabaseModels/IncidentSeverity.js +46 -1
- package/build/dist/Models/DatabaseModels/IncidentSeverity.js.map +1 -1
- package/build/dist/Models/DatabaseModels/Index.js +13 -0
- package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
- package/build/dist/Models/DatabaseModels/NetworkDevice.js +115 -3
- package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
- package/build/dist/Models/DatabaseModels/NetworkDeviceAutoImportRule.js +88 -0
- package/build/dist/Models/DatabaseModels/NetworkDeviceAutoImportRule.js.map +1 -1
- package/build/dist/Models/DatabaseModels/NetworkDeviceDiscoveryScan.js.map +1 -1
- package/build/dist/Models/DatabaseModels/NetworkDeviceOidTemplate.js +519 -0
- package/build/dist/Models/DatabaseModels/NetworkDeviceOidTemplate.js.map +1 -0
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js +44 -0
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js.map +1 -1
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.js +699 -0
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.js.map +1 -0
- package/build/dist/Models/DatabaseModels/ProjectOnCallCalendarFeed.js +620 -0
- package/build/dist/Models/DatabaseModels/ProjectOnCallCalendarFeed.js.map +1 -0
- package/build/dist/Models/DatabaseModels/ThreatIntelFeed.js +16 -5
- package/build/dist/Models/DatabaseModels/ThreatIntelFeed.js.map +1 -1
- package/build/dist/Models/DatabaseModels/UserOnCallCalendarFeed.js +585 -0
- package/build/dist/Models/DatabaseModels/UserOnCallCalendarFeed.js.map +1 -0
- package/build/dist/Models/DatabaseModels/UserOnCallShiftReminder.js +247 -0
- package/build/dist/Models/DatabaseModels/UserOnCallShiftReminder.js.map +1 -0
- package/build/dist/Models/DatabaseModels/UserOnCallShiftReminderLog.js +355 -0
- package/build/dist/Models/DatabaseModels/UserOnCallShiftReminderLog.js.map +1 -0
- package/build/dist/Server/API/GlobalConfigAPI.js +293 -176
- package/build/dist/Server/API/GlobalConfigAPI.js.map +1 -1
- package/build/dist/Server/API/OnCallCalendarAPI.js +1371 -0
- package/build/dist/Server/API/OnCallCalendarAPI.js.map +1 -0
- package/build/dist/Server/API/TelemetryAPI.js +31 -3
- package/build/dist/Server/API/TelemetryAPI.js.map +1 -1
- package/build/dist/Server/EnvironmentConfig.js +185 -0
- package/build/dist/Server/EnvironmentConfig.js.map +1 -1
- package/build/dist/Server/Infrastructure/OnCallCalendarFeedCache.js +663 -0
- package/build/dist/Server/Infrastructure/OnCallCalendarFeedCache.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790400000000-AddOnCallCalendarFeeds.js +103 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790400000000-AddOnCallCalendarFeeds.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790500000000-AddNetworkDeviceOidTemplate.js +26 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790500000000-AddNetworkDeviceOidTemplate.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790600000000-AddAutoImportRuleOidTemplate.js +14 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790600000000-AddAutoImportRuleOidTemplate.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +6 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Middleware/MultipartFormData.js +93 -28
- package/build/dist/Server/Middleware/MultipartFormData.js.map +1 -1
- package/build/dist/Server/Middleware/OnCallCalendarFeedRateLimit.js +342 -0
- package/build/dist/Server/Middleware/OnCallCalendarFeedRateLimit.js.map +1 -0
- package/build/dist/Server/Services/AlertEpisodeLabelRuleEngineService.js +8 -1
- package/build/dist/Server/Services/AlertEpisodeLabelRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/AlertEpisodeOnCallRuleEngineService.js +8 -1
- package/build/dist/Server/Services/AlertEpisodeOnCallRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/AlertEpisodeOwnerRuleEngineService.js +8 -1
- package/build/dist/Server/Services/AlertEpisodeOwnerRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/AlertEpisodePrivacyRuleEngineService.js +8 -1
- package/build/dist/Server/Services/AlertEpisodePrivacyRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/AlertGroupingEngineService.js +8 -1
- package/build/dist/Server/Services/AlertGroupingEngineService.js.map +1 -1
- package/build/dist/Server/Services/AlertLabelRuleEngineService.js +8 -1
- package/build/dist/Server/Services/AlertLabelRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/AlertOnCallRuleEngineService.js +8 -1
- package/build/dist/Server/Services/AlertOnCallRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/AlertOwnerRuleEngineService.js +8 -1
- package/build/dist/Server/Services/AlertOwnerRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/AlertPrivacyRuleEngineService.js +8 -1
- package/build/dist/Server/Services/AlertPrivacyRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/AlertReminderRuleService.js +8 -1
- package/build/dist/Server/Services/AlertReminderRuleService.js.map +1 -1
- package/build/dist/Server/Services/AutoRemediationRuleEngineService.js +8 -1
- package/build/dist/Server/Services/AutoRemediationRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/CephClusterLabelRuleEngineService.js +8 -1
- package/build/dist/Server/Services/CephClusterLabelRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/CephClusterOwnerRuleEngineService.js +8 -1
- package/build/dist/Server/Services/CephClusterOwnerRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/CloudResourceLabelRuleEngineService.js +8 -1
- package/build/dist/Server/Services/CloudResourceLabelRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/CloudResourceOwnerRuleEngineService.js +8 -1
- package/build/dist/Server/Services/CloudResourceOwnerRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/DashboardLabelRuleEngineService.js +8 -1
- package/build/dist/Server/Services/DashboardLabelRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/DashboardOwnerRuleEngineService.js +8 -1
- package/build/dist/Server/Services/DashboardOwnerRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/DockerHostLabelRuleEngineService.js +8 -1
- package/build/dist/Server/Services/DockerHostLabelRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/DockerHostOwnerRuleEngineService.js +8 -1
- package/build/dist/Server/Services/DockerHostOwnerRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/DockerSwarmClusterLabelRuleEngineService.js +8 -1
- package/build/dist/Server/Services/DockerSwarmClusterLabelRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/DockerSwarmClusterOwnerRuleEngineService.js +8 -1
- package/build/dist/Server/Services/DockerSwarmClusterOwnerRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/HostLabelRuleEngineService.js +8 -1
- package/build/dist/Server/Services/HostLabelRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/HostOwnerRuleEngineService.js +8 -1
- package/build/dist/Server/Services/HostOwnerRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/IncidentEpisodeLabelRuleEngineService.js +8 -1
- package/build/dist/Server/Services/IncidentEpisodeLabelRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/IncidentEpisodeOnCallRuleEngineService.js +8 -1
- package/build/dist/Server/Services/IncidentEpisodeOnCallRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/IncidentEpisodeOwnerRuleEngineService.js +8 -1
- package/build/dist/Server/Services/IncidentEpisodeOwnerRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/IncidentEpisodePrivacyRuleEngineService.js +8 -1
- package/build/dist/Server/Services/IncidentEpisodePrivacyRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/IncidentGroupingEngineService.js +8 -1
- package/build/dist/Server/Services/IncidentGroupingEngineService.js.map +1 -1
- package/build/dist/Server/Services/IncidentLabelRuleEngineService.js +8 -1
- package/build/dist/Server/Services/IncidentLabelRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/IncidentOnCallRuleEngineService.js +8 -1
- package/build/dist/Server/Services/IncidentOnCallRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/IncidentOwnerRuleEngineService.js +8 -1
- package/build/dist/Server/Services/IncidentOwnerRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/IncidentPrivacyRuleEngineService.js +8 -1
- package/build/dist/Server/Services/IncidentPrivacyRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/IncidentReminderRuleService.js +8 -1
- package/build/dist/Server/Services/IncidentReminderRuleService.js.map +1 -1
- package/build/dist/Server/Services/IncidentSlaRuleService.js +8 -1
- package/build/dist/Server/Services/IncidentSlaRuleService.js.map +1 -1
- package/build/dist/Server/Services/IncomingCallPolicyLabelRuleEngineService.js +8 -1
- package/build/dist/Server/Services/IncomingCallPolicyLabelRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/IncomingCallPolicyOwnerRuleEngineService.js +8 -1
- package/build/dist/Server/Services/IncomingCallPolicyOwnerRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/Index.js +14 -0
- package/build/dist/Server/Services/Index.js.map +1 -1
- package/build/dist/Server/Services/IoTFleetLabelRuleEngineService.js +8 -1
- package/build/dist/Server/Services/IoTFleetLabelRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/IoTFleetOwnerRuleEngineService.js +8 -1
- package/build/dist/Server/Services/IoTFleetOwnerRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/KubernetesClusterLabelRuleEngineService.js +8 -1
- package/build/dist/Server/Services/KubernetesClusterLabelRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/KubernetesClusterOwnerRuleEngineService.js +8 -1
- package/build/dist/Server/Services/KubernetesClusterOwnerRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/KubernetesResourceService.js +5 -1
- package/build/dist/Server/Services/KubernetesResourceService.js.map +1 -1
- package/build/dist/Server/Services/MonitorLabelRuleEngineService.js +8 -1
- package/build/dist/Server/Services/MonitorLabelRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/MonitorOwnerRuleEngineService.js +8 -1
- package/build/dist/Server/Services/MonitorOwnerRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/NetworkDeviceAutoImportRuleEngineService.js +30 -21
- package/build/dist/Server/Services/NetworkDeviceAutoImportRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/NetworkDeviceAutoImportRuleService.js +64 -0
- package/build/dist/Server/Services/NetworkDeviceAutoImportRuleService.js.map +1 -1
- package/build/dist/Server/Services/NetworkDeviceLabelRuleEngineService.js +8 -1
- package/build/dist/Server/Services/NetworkDeviceLabelRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/NetworkDeviceOidTemplateService.js +180 -0
- package/build/dist/Server/Services/NetworkDeviceOidTemplateService.js.map +1 -0
- package/build/dist/Server/Services/NetworkDeviceOwnerRuleEngineService.js +8 -1
- package/build/dist/Server/Services/NetworkDeviceOwnerRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/NetworkDeviceService.js +154 -1
- package/build/dist/Server/Services/NetworkDeviceService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js +20 -0
- package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyLabelRuleEngineService.js +8 -1
- package/build/dist/Server/Services/OnCallDutyPolicyLabelRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyOwnerRuleEngineService.js +8 -1
- package/build/dist/Server/Services/OnCallDutyPolicyOwnerRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleCalendarFeedService.js +219 -0
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleCalendarFeedService.js.map +1 -0
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleLabelRuleEngineService.js +8 -1
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleLabelRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerService.js +47 -3
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerUserService.js +112 -3
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerUserService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleOwnerRuleEngineService.js +8 -1
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleOwnerRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js +955 -9
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyUserOverrideService.js +93 -6
- package/build/dist/Server/Services/OnCallDutyPolicyUserOverrideService.js.map +1 -1
- package/build/dist/Server/Services/PodmanHostLabelRuleEngineService.js +8 -1
- package/build/dist/Server/Services/PodmanHostLabelRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/PodmanHostOwnerRuleEngineService.js +8 -1
- package/build/dist/Server/Services/PodmanHostOwnerRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/ProjectOnCallCalendarFeedService.js +157 -0
- package/build/dist/Server/Services/ProjectOnCallCalendarFeedService.js.map +1 -0
- package/build/dist/Server/Services/ProxmoxClusterLabelRuleEngineService.js +8 -1
- package/build/dist/Server/Services/ProxmoxClusterLabelRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/ProxmoxClusterOwnerRuleEngineService.js +8 -1
- package/build/dist/Server/Services/ProxmoxClusterOwnerRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/RumApplicationLabelRuleEngineService.js +8 -1
- package/build/dist/Server/Services/RumApplicationLabelRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/RumApplicationOwnerRuleEngineService.js +8 -1
- package/build/dist/Server/Services/RumApplicationOwnerRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/RunbookLabelRuleEngineService.js +8 -1
- package/build/dist/Server/Services/RunbookLabelRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/RunbookOwnerRuleEngineService.js +8 -1
- package/build/dist/Server/Services/RunbookOwnerRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/RunbookRuleEngineService.js +8 -1
- package/build/dist/Server/Services/RunbookRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/ScheduledMaintenanceLabelRuleEngineService.js +8 -1
- package/build/dist/Server/Services/ScheduledMaintenanceLabelRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/ScheduledMaintenanceOwnerRuleEngineService.js +8 -1
- package/build/dist/Server/Services/ScheduledMaintenanceOwnerRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/ScheduledMaintenanceReminderRuleService.js +8 -1
- package/build/dist/Server/Services/ScheduledMaintenanceReminderRuleService.js.map +1 -1
- package/build/dist/Server/Services/ServerlessFunctionLabelRuleEngineService.js +8 -1
- package/build/dist/Server/Services/ServerlessFunctionLabelRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/ServerlessFunctionOwnerRuleEngineService.js +8 -1
- package/build/dist/Server/Services/ServerlessFunctionOwnerRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/ServiceLabelRuleEngineService.js +8 -1
- package/build/dist/Server/Services/ServiceLabelRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/ServiceOwnerRuleEngineService.js +8 -1
- package/build/dist/Server/Services/ServiceOwnerRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/StatusPageLabelRuleEngineService.js +8 -1
- package/build/dist/Server/Services/StatusPageLabelRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/StatusPageMonitorRuleEngineService.js +6 -0
- package/build/dist/Server/Services/StatusPageMonitorRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/StatusPageOwnerRuleEngineService.js +8 -1
- package/build/dist/Server/Services/StatusPageOwnerRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/TeamMemberService.js +396 -1
- package/build/dist/Server/Services/TeamMemberService.js.map +1 -1
- package/build/dist/Server/Services/TeamService.js +43 -0
- package/build/dist/Server/Services/TeamService.js.map +1 -1
- package/build/dist/Server/Services/TelemetrySourceMapService.js +213 -19
- package/build/dist/Server/Services/TelemetrySourceMapService.js.map +1 -1
- package/build/dist/Server/Services/UserNotificationSettingService.js +26 -1
- package/build/dist/Server/Services/UserNotificationSettingService.js.map +1 -1
- package/build/dist/Server/Services/UserOnCallCalendarFeedService.js +140 -0
- package/build/dist/Server/Services/UserOnCallCalendarFeedService.js.map +1 -0
- package/build/dist/Server/Services/UserOnCallShiftReminderLogService.js +116 -0
- package/build/dist/Server/Services/UserOnCallShiftReminderLogService.js.map +1 -0
- package/build/dist/Server/Services/UserOnCallShiftReminderService.js +92 -0
- package/build/dist/Server/Services/UserOnCallShiftReminderService.js.map +1 -0
- package/build/dist/Server/Services/UserService.js +25 -0
- package/build/dist/Server/Services/UserService.js.map +1 -1
- package/build/dist/Server/Services/WorkflowLabelRuleEngineService.js +8 -1
- package/build/dist/Server/Services/WorkflowLabelRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/WorkflowOwnerRuleEngineService.js +8 -1
- package/build/dist/Server/Services/WorkflowOwnerRuleEngineService.js.map +1 -1
- package/build/dist/Server/Utils/EnterpriseLicense/EnterpriseLicenseSeatUtil.js +145 -0
- package/build/dist/Server/Utils/EnterpriseLicense/EnterpriseLicenseSeatUtil.js.map +1 -0
- package/build/dist/Server/Utils/Monitor/CapturedMetricAttributeUtil.js +201 -0
- package/build/dist/Server/Utils/Monitor/CapturedMetricAttributeUtil.js.map +1 -0
- package/build/dist/Server/Utils/Monitor/Criteria/SnmpMonitorCriteria.js +19 -2
- package/build/dist/Server/Utils/Monitor/Criteria/SnmpMonitorCriteria.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorMetricUtil.js +42 -23
- package/build/dist/Server/Utils/Monitor/MonitorMetricUtil.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorTemplateUtil.js +29 -0
- package/build/dist/Server/Utils/Monitor/MonitorTemplateUtil.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/NetworkDeviceMetricUtil.js +11 -2
- package/build/dist/Server/Utils/Monitor/NetworkDeviceMetricUtil.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/NetworkDeviceWalkUtil.js +16 -1
- package/build/dist/Server/Utils/Monitor/NetworkDeviceWalkUtil.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/NetworkInventoryUtil.js +10 -0
- package/build/dist/Server/Utils/Monitor/NetworkInventoryUtil.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/SeriesResourceLabels.js +33 -0
- package/build/dist/Server/Utils/Monitor/SeriesResourceLabels.js.map +1 -1
- package/build/dist/Server/Utils/OnCall/CalendarFeedToken.js +161 -0
- package/build/dist/Server/Utils/OnCall/CalendarFeedToken.js.map +1 -0
- package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedRenderer.js +983 -0
- package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedRenderer.js.map +1 -0
- package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedUrls.js +155 -0
- package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedUrls.js.map +1 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftChangeListeners.js +145 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftChangeListeners.js.map +1 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftMaterializer.js +752 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftMaterializer.js.map +1 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftReminderListener.js +92 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftReminderListener.js.map +1 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftReminderRunner.js +1786 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftReminderRunner.js.map +1 -0
- package/build/dist/Server/Utils/Response.js +97 -0
- package/build/dist/Server/Utils/Response.js.map +1 -1
- package/build/dist/Server/Utils/Rules/RuleEngineRuleRead.js +37 -0
- package/build/dist/Server/Utils/Rules/RuleEngineRuleRead.js.map +1 -0
- package/build/dist/Server/Utils/StartServer.js +9 -1
- package/build/dist/Server/Utils/StartServer.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/AppMetrics.js +41 -0
- package/build/dist/Server/Utils/Telemetry/AppMetrics.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/ExceptionSanitizer.js +12 -1
- package/build/dist/Server/Utils/Telemetry/ExceptionSanitizer.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/LogExceptionExtractor.js +14 -2
- package/build/dist/Server/Utils/Telemetry/LogExceptionExtractor.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/SourceMapResolver.js +15 -5
- package/build/dist/Server/Utils/Telemetry/SourceMapResolver.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/StackTraceParser.js +307 -0
- package/build/dist/Server/Utils/Telemetry/StackTraceParser.js.map +1 -1
- package/build/dist/Types/Calendar/ICalendar.js +290 -0
- package/build/dist/Types/Calendar/ICalendar.js.map +1 -0
- package/build/dist/Types/Date.js +27 -0
- package/build/dist/Types/Date.js.map +1 -1
- package/build/dist/Types/Email/EmailTemplateType.js +2 -0
- package/build/dist/Types/Email/EmailTemplateType.js.map +1 -1
- package/build/dist/Types/Monitor/SnmpMonitor/SnmpOidListUtil.js +253 -0
- package/build/dist/Types/Monitor/SnmpMonitor/SnmpOidListUtil.js.map +1 -0
- package/build/dist/Types/NotificationSetting/NotificationSettingEventType.js +3 -0
- package/build/dist/Types/NotificationSetting/NotificationSettingEventType.js.map +1 -1
- package/build/dist/Types/OnCallDutyPolicy/CalendarFeedWindow.js +97 -0
- package/build/dist/Types/OnCallDutyPolicy/CalendarFeedWindow.js.map +1 -0
- package/build/dist/Types/OnCallDutyPolicy/Layer.js +113 -19
- package/build/dist/Types/OnCallDutyPolicy/Layer.js.map +1 -1
- package/build/dist/Types/OnCallDutyPolicy/MaterializedShift.js +134 -0
- package/build/dist/Types/OnCallDutyPolicy/MaterializedShift.js.map +1 -0
- package/build/dist/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.js +800 -0
- package/build/dist/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.js.map +1 -0
- package/build/dist/Types/OnCallDutyPolicy/ScheduleShiftUtil.js +97 -3
- package/build/dist/Types/OnCallDutyPolicy/ScheduleShiftUtil.js.map +1 -1
- package/build/dist/Types/OnCallDutyPolicy/ShiftSeamUtil.js +77 -0
- package/build/dist/Types/OnCallDutyPolicy/ShiftSeamUtil.js.map +1 -0
- package/build/dist/Types/Permission.js +86 -0
- package/build/dist/Types/Permission.js.map +1 -1
- package/build/dist/Types/Rum/SessionReplay.js +19 -0
- package/build/dist/Types/Rum/SessionReplay.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/Annotations/AnnotationHoverCard.js +83 -0
- package/build/dist/UI/Components/Charts/ChartLibrary/Annotations/AnnotationHoverCard.js.map +1 -0
- package/build/dist/UI/Components/Charts/ChartLibrary/Annotations/AnnotationLayout.js +531 -0
- package/build/dist/UI/Components/Charts/ChartLibrary/Annotations/AnnotationLayout.js.map +1 -0
- package/build/dist/UI/Components/Charts/ChartLibrary/Annotations/ChartAnnotationLayer.js +264 -0
- package/build/dist/UI/Components/Charts/ChartLibrary/Annotations/ChartAnnotationLayer.js.map +1 -0
- package/build/dist/UI/Components/Charts/ChartLibrary/Annotations/UseChartAnnotations.js +122 -0
- package/build/dist/UI/Components/Charts/ChartLibrary/Annotations/UseChartAnnotations.js.map +1 -0
- package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js +213 -233
- package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js +139 -152
- package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js +212 -232
- package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/Types/ChartEventKind.js +14 -0
- package/build/dist/UI/Components/Charts/Types/ChartEventKind.js.map +1 -0
- package/build/dist/UI/Components/Charts/Utils/TimeAnnotation.js +57 -28
- package/build/dist/UI/Components/Charts/Utils/TimeAnnotation.js.map +1 -1
- package/build/dist/UI/Components/Charts/Utils/XAxis.js +103 -17
- package/build/dist/UI/Components/Charts/Utils/XAxis.js.map +1 -1
- package/build/dist/UI/Components/EditionLabel/EditionLabel.js +198 -23
- package/build/dist/UI/Components/EditionLabel/EditionLabel.js.map +1 -1
- package/build/dist/UI/Components/ErrorMessage/ErrorMessage.js +15 -5
- package/build/dist/UI/Components/ErrorMessage/ErrorMessage.js.map +1 -1
- package/build/dist/UI/Components/Feed/FeedItem.js +4 -5
- package/build/dist/UI/Components/Feed/FeedItem.js.map +1 -1
- package/build/dist/UI/Components/Header/HeaderIconDropdownButton.js +1 -2
- package/build/dist/UI/Components/Header/HeaderIconDropdownButton.js.map +1 -1
- package/build/dist/UI/Components/Header/UserProfile/UserProfile.js.map +1 -1
- package/build/dist/UI/Components/Image/Image.js +6 -1
- package/build/dist/UI/Components/Image/Image.js.map +1 -1
- package/build/dist/UI/Components/Markdown.tsx/MarkdownConverters.js +0 -0
- package/build/dist/UI/Components/Markdown.tsx/MarkdownConverters.js.map +1 -1
- package/build/dist/UI/Components/MemberRoleAssignment/MemberRoleAssignment.js +1 -2
- package/build/dist/UI/Components/MemberRoleAssignment/MemberRoleAssignment.js.map +1 -1
- package/build/dist/UI/Components/ModelTable/BaseModelTable.js +104 -23
- package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
- package/build/dist/UI/Components/Table/TableHeader.js +20 -22
- package/build/dist/UI/Components/Table/TableHeader.js.map +1 -1
- package/build/dist/UI/Components/Table/TableRow.js +13 -4
- package/build/dist/UI/Components/Table/TableRow.js.map +1 -1
- package/build/dist/Utils/EnterpriseLicense/EnterpriseLicenseSeats.js +153 -0
- package/build/dist/Utils/EnterpriseLicense/EnterpriseLicenseSeats.js.map +1 -0
- package/build/dist/Utils/NetworkDiscovery/AutoImportRuleMatcher.js.map +1 -1
- package/build/dist/Utils/NetworkDiscovery/DiscoveredDeviceBuilder.js +127 -7
- package/build/dist/Utils/NetworkDiscovery/DiscoveredDeviceBuilder.js.map +1 -1
- package/build/dist/Utils/NetworkDiscovery/DiscoveredHostUtil.js +50 -3
- package/build/dist/Utils/NetworkDiscovery/DiscoveredHostUtil.js.map +1 -1
- package/build/dist/Utils/NetworkDiscovery/ReverseDnsNameUtil.js +132 -0
- package/build/dist/Utils/NetworkDiscovery/ReverseDnsNameUtil.js.map +1 -0
- package/build/dist/Utils/Rules/RuleEngineLimits.js +31 -0
- package/build/dist/Utils/Rules/RuleEngineLimits.js.map +1 -0
- package/jest.config.json +1 -0
- 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
|
+
}
|