@oneuptime/common 12.0.7 → 12.0.9
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/MetricItemAggMV1mByK8sCluster.ts +1 -1
- package/Models/AnalyticsModels/MetricItemAggMV1mByService.ts +1 -1
- package/Models/DatabaseModels/AIConversation.ts +32 -0
- package/Models/DatabaseModels/AIConversationMessage.ts +41 -0
- package/Models/DatabaseModels/AlertEpisodeMember.ts +27 -0
- package/Models/DatabaseModels/IncidentEpisodeMember.ts +28 -0
- package/Models/DatabaseModels/Index.ts +12 -4
- package/Models/DatabaseModels/{TelemetryEntity.ts → InventoryItem.ts} +184 -9
- package/Models/DatabaseModels/InventoryItemCustomField.ts +434 -0
- package/Models/DatabaseModels/{TelemetryEntityRelationship.ts → InventoryItemRelationship.ts} +33 -7
- package/Models/DatabaseModels/NetworkDevice.ts +141 -0
- package/Models/DatabaseModels/NetworkDeviceLink.ts +699 -0
- package/Models/DatabaseModels/NetworkDeviceLinkRule.ts +467 -0
- package/Models/DatabaseModels/NetworkTopologySuppression.ts +429 -0
- package/Models/DatabaseModels/OnCallDutyPolicyFeed.ts +9 -0
- package/Models/DatabaseModels/Project.ts +78 -0
- package/Models/DatabaseModels/UserCall.ts +42 -0
- package/Models/DatabaseModels/UserEmail.ts +44 -0
- package/Models/DatabaseModels/UserNotificationRule.ts +425 -55
- package/Models/DatabaseModels/UserOnCallLogTimeline.ts +24 -2
- package/Models/DatabaseModels/UserPush.ts +49 -0
- package/Models/DatabaseModels/UserSMS.ts +43 -0
- package/Models/DatabaseModels/UserTelegram.ts +59 -0
- package/Models/DatabaseModels/UserWebhook.ts +52 -0
- package/Models/DatabaseModels/UserWhatsApp.ts +41 -0
- package/Models/DatabaseModels/WorkflowLog.ts +38 -0
- package/Models/DatabaseModels/WorkflowVariable.ts +12 -0
- package/Server/API/AIChatAPI.ts +315 -1
- package/Server/API/DashboardAPI.ts +217 -1
- package/Server/API/OnCallReadinessAPI.ts +841 -0
- package/Server/API/TeamComplianceAPI.ts +69 -17
- package/Server/API/TelemetryAPI.ts +220 -12
- package/Server/API/UserAPI.ts +16 -1
- package/Server/EnvironmentConfig.ts +52 -0
- package/Server/Infrastructure/Postgres/DataSourceOptions.ts +22 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.ts +4 -4
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786200000000-RestoreDroppedUniqueIndexes.ts +5 -5
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786551733814-MigrationName.ts +41 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786559879134-AddWorkflowLogStepTrace.ts +17 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786625176831-AddMonitoringMethodToNetworkDevice.ts +35 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786634985763-AddNetworkDeviceLink.ts +82 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786639512056-AddNetworkDeviceLinkRule.ts +91 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786639972982-AddNetworkTopologySuppression.ts +47 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786800000000-RenameTelemetryEntityToInventoryItem.ts +255 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1786900000000-AddInventoryItemArchiveAndCustomFields.ts +107 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1787000000000-AddOnCallNotificationFallbackColumns.ts +90 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1787100000000-AddAIConversationPageContext.ts +39 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1787200000000-AddAIChatMessageFeedback.ts +33 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1787300000000-AddEpisodeMemberNotifyIndexes.ts +59 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +24 -0
- package/Server/Infrastructure/Queue.ts +78 -13
- package/Server/Middleware/MasterAdminAuthorization.ts +11 -6
- package/Server/Middleware/PublicDashboardRateLimit.ts +593 -0
- package/Server/Services/AIService.ts +7 -0
- package/Server/Services/AlertEpisodeStateTimelineService.ts +29 -0
- package/Server/Services/AlertSeverityService.ts +63 -0
- package/Server/Services/DashboardService.ts +9 -10
- package/Server/Services/DatabaseService.ts +32 -2
- package/Server/Services/IncidentEpisodeStateTimelineService.ts +29 -0
- package/Server/Services/IncidentSeverityService.ts +76 -0
- package/Server/Services/Index.ts +12 -4
- package/Server/Services/InventoryItemCustomFieldService.ts +9 -0
- package/Server/Services/{TelemetryEntityRelationshipService.ts → InventoryItemRelationshipService.ts} +7 -4
- package/Server/Services/{TelemetryEntityService.ts → InventoryItemService.ts} +203 -21
- package/Server/Services/LogAggregationService.ts +45 -8
- package/Server/Services/MetricAggregationService.ts +121 -0
- package/Server/Services/MetricService.ts +7 -7
- package/Server/Services/NetworkDeviceLinkRuleService.ts +10 -0
- package/Server/Services/NetworkDeviceLinkService.ts +84 -0
- package/Server/Services/NetworkDeviceService.ts +140 -0
- package/Server/Services/NetworkSiteService.ts +77 -25
- package/Server/Services/NetworkTopologySuppressionService.ts +84 -0
- package/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.ts +41 -29
- package/Server/Services/OnCallDutyPolicyExecutionLogService.ts +8 -0
- package/Server/Services/OnCallDutyPolicyExecutionLogTimelineService.ts +62 -13
- package/Server/Services/OnCallDutyPolicyScheduleService.ts +61 -1
- package/Server/Services/OnCallNotificationAlertingService.ts +742 -0
- package/Server/Services/OnCallReadinessService.ts +2803 -0
- package/Server/Services/OnCallSetupReminderService.ts +955 -0
- package/Server/Services/ProfileAggregationService.ts +123 -0
- package/Server/Services/StatusPageService.ts +9 -10
- package/Server/Services/TeamComplianceService.ts +429 -252
- package/Server/Services/UserCallService.ts +26 -1
- package/Server/Services/UserEmailService.ts +26 -1
- package/Server/Services/UserNotificationRuleAdminService.ts +1183 -0
- package/Server/Services/UserNotificationRuleService.ts +3812 -333
- package/Server/Services/UserOnCallLogService.ts +561 -48
- package/Server/Services/UserPushService.ts +29 -0
- package/Server/Services/UserService.ts +11 -0
- package/Server/Services/UserSmsService.ts +26 -1
- package/Server/Services/UserTelegramService.ts +24 -1
- package/Server/Services/UserWebhookService.ts +28 -1
- package/Server/Services/UserWhatsAppService.ts +24 -1
- package/Server/Types/Database/Permissions/BasePermission.ts +19 -0
- package/Server/Types/Database/Permissions/CreatePermission.ts +164 -0
- package/Server/Types/Database/Permissions/OwnerOnlyColumnPermission.ts +340 -0
- package/Server/Types/Database/Permissions/QueryPermission.ts +48 -0
- package/Server/Types/Database/Permissions/TenantPermission.ts +8 -1
- package/Server/Types/Workflow/Components/API/Delete.ts +1 -1
- package/Server/Types/Workflow/Components/API/Get.ts +1 -1
- package/Server/Types/Workflow/Components/API/Patch.ts +1 -1
- package/Server/Types/Workflow/Components/API/Post.ts +1 -1
- package/Server/Types/Workflow/Components/API/Put.ts +1 -1
- package/Server/Types/Workflow/Components/API/Utils.ts +44 -1
- package/Server/Types/Workflow/Components/BaseModel/CreateManyBaseModel.ts +29 -5
- package/Server/Types/Workflow/Components/BaseModel/CreateOneBaseModel.ts +18 -10
- package/Server/Types/Workflow/Components/BaseModel/ModelArguments.ts +55 -0
- package/Server/Types/Workflow/Components/Conditions/IfElse.ts +3 -17
- package/Server/Types/Workflow/Components/Email.ts +25 -7
- package/Server/Types/Workflow/Components/JavaScript.ts +10 -3
- package/Server/Types/Workflow/Components/MicrosoftTeams/SendMessageToChannel.ts +1 -1
- package/Server/Types/Workflow/TriggerCode.ts +12 -0
- package/Server/Types/Workflow/Workflow.ts +5 -0
- package/Server/Utils/AI/Chat/ChatAgentRunner.ts +643 -48
- package/Server/Utils/AI/Chat/ObservabilityAssistant.ts +32 -3
- package/Server/Utils/AI/Chat/ObservabilityChatPrompt.ts +20 -6
- package/Server/Utils/AI/SRE/AIInvestigationEngine.ts +7 -0
- package/Server/Utils/AI/Toolbox/AIActionTools.ts +2 -2
- package/Server/Utils/AI/Toolbox/AIMetaTools.ts +863 -0
- package/Server/Utils/AI/Toolbox/AlertTools.ts +177 -15
- package/Server/Utils/AI/Toolbox/IncidentTools.ts +191 -10
- package/Server/Utils/AI/Toolbox/Index.ts +48 -0
- package/Server/Utils/AI/Toolbox/MonitorTools.ts +298 -11
- package/Server/Utils/AI/Toolbox/NoteWriteTools.ts +295 -0
- package/Server/Utils/AI/Toolbox/OnCallTools.ts +1246 -0
- package/Server/Utils/AI/Toolbox/RunbookTools.ts +424 -0
- package/Server/Utils/AI/Toolbox/SloTools.ts +456 -0
- package/Server/Utils/AI/Toolbox/StatusPageTools.ts +559 -0
- package/Server/Utils/AI/Toolbox/TeamTools.ts +327 -0
- package/Server/Utils/AI/Toolbox/TimelineTools.ts +615 -0
- package/Server/Utils/AI/Toolbox/WorkflowProbeTools.ts +664 -0
- package/Server/Utils/ClientIp.ts +221 -0
- package/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.ts +47 -0
- package/Server/Utils/Dashboard/PublicDashboardSloHistoryPolicy.ts +163 -0
- package/Server/Utils/Dashboard/PublicDashboardSloWidget.ts +147 -0
- package/Server/Utils/Express.ts +12 -17
- package/Server/Utils/LLM/LLMService.ts +85 -8
- package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +204 -10
- package/Server/Utils/SSRFProtection.ts +98 -23
- package/Server/Utils/StartServer.ts +12 -3
- package/Server/Utils/Telemetry/EntityRegistry.ts +205 -18
- package/Server/Utils/Telemetry/InventoryEntityRegistry.ts +689 -0
- package/Server/Utils/Telemetry/TelemetryEntity.ts +160 -52
- package/Server/Utils/VM/VMAPI.ts +56 -8
- package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.ts +7 -3
- package/Tests/App/Dashboard/AdminNotificationRulesPage.test.tsx +2146 -0
- package/Tests/App/Dashboard/CreateWorkflowModal.test.tsx +561 -0
- package/Tests/App/Dashboard/EscalationRuleReadiness.test.tsx +2470 -0
- package/Tests/App/Dashboard/MonitorCriteriaAttributeFilter.test.tsx +574 -0
- package/Tests/App/Dashboard/OnCallPreventionGuards.test.tsx +1897 -0
- package/Tests/App/Dashboard/OnCallReadinessSurfaces.test.tsx +3606 -0
- package/Tests/App/Dashboard/OnCallRulesDeleteGuard.test.tsx +784 -0
- package/Tests/App/Dashboard/OnCallRulesTable.test.tsx +1119 -0
- package/Tests/App/Dashboard/SloWidgetFetching.test.tsx +531 -0
- package/Tests/App/Dashboard/UserSettingsSetupChecklistModel.test.ts +1312 -0
- package/Tests/App/Dashboard/UserSettingsSetupChecklistPage.test.tsx +1390 -0
- package/Tests/Models/InventoryItemModel.test.ts +174 -0
- package/Tests/Models/InventoryItemNaming.test.ts +302 -0
- package/Tests/Server/API/AIChatCancelAndFeedback.test.ts +437 -0
- package/Tests/Server/API/DashboardPublicRateLimit.test.ts +659 -0
- package/Tests/Server/API/DashboardPublicResourceListAPI.test.ts +18 -0
- package/Tests/Server/API/DashboardPublicSloAPI.test.ts +880 -0
- package/Tests/Server/API/Helpers.ts +24 -15
- package/Tests/Server/API/OnCallReadinessAPI.test.ts +2680 -0
- package/Tests/Server/API/OnCallSetupReminderAPI.test.ts +915 -0
- package/Tests/Server/API/UserProjectsAPI.test.ts +478 -4
- package/Tests/Server/Infrastructure/Postgres/EpisodeMemberNotifyIndexesMigration.test.ts +533 -0
- package/Tests/Server/Infrastructure/Postgres/InventoryItemArchiveMigration.test.ts +213 -0
- package/Tests/Server/Infrastructure/Postgres/RenameInventoryItemMigration.test.ts +432 -0
- package/Tests/Server/Infrastructure/Queue.test.ts +293 -0
- package/Tests/Server/Middleware/PublicDashboardRateLimit.test.ts +1645 -0
- package/Tests/Server/Services/AdminRuleEditGuards.test.ts +2848 -0
- package/Tests/Server/Services/DeliverNotificationForRuleExtraction.test.ts +1393 -0
- package/Tests/Server/Services/EpisodeRuleSeverityRepair.test.ts +1802 -0
- package/Tests/Server/Services/EpisodeStateTimelineNote.test.ts +304 -0
- package/Tests/Server/Services/InventoryItemDisplayName.test.ts +339 -0
- package/Tests/Server/Services/InventoryItemManualCreate.test.ts +248 -0
- package/Tests/Server/Services/IpAllowlistSpoofing.test.ts +450 -0
- package/Tests/Server/Services/LogAggregationService.test.ts +235 -1
- package/Tests/Server/Services/MetricAggregationService.test.ts +231 -0
- package/Tests/Server/Services/MetricEntityMVKeyParity.test.ts +80 -29
- package/Tests/Server/Services/MetricServiceAggregate.test.ts +30 -30
- package/Tests/Server/Services/NetworkSiteService.test.ts +18 -3
- package/Tests/Server/Services/NotificationChannelEventCoverage.test.ts +1728 -0
- package/Tests/Server/Services/NotificationDeletionImpact.test.ts +2402 -0
- package/Tests/Server/Services/OnCallDutyPolicyExecutionLogTimelineGapFeed.test.ts +394 -0
- package/Tests/Server/Services/OnCallNotificationFallback.test.ts +1744 -0
- package/Tests/Server/Services/OnCallReadinessService.test.ts +4295 -0
- package/Tests/Server/Services/OnCallSetupReminder.test.ts +1272 -0
- package/Tests/Server/Services/OnCallWeeklyReadinessDigest.test.ts +1021 -0
- package/Tests/Server/Services/ProfileAggregationService.test.ts +296 -0
- package/Tests/Server/Services/SeverityCreationRuleBackfill.test.ts +1536 -0
- package/Tests/Server/Services/SeverityRuleBackfill.test.ts +1818 -0
- package/Tests/Server/Services/TeamComplianceServiceBehaviour.test.ts +1845 -0
- package/Tests/Server/Services/UserNotificationRuleAdminGuards.test.ts +1394 -0
- package/Tests/Server/Services/UserNotificationRuleDefaultCreation.test.ts +1166 -0
- package/Tests/Server/Services/UserNotificationRuleExecuteItem.test.ts +1468 -0
- package/Tests/Server/Services/UserOnCallLogNoNotificationRules.test.ts +1457 -0
- package/Tests/Server/Types/Database/Permissions/AdminNotificationRuleAccess.test.ts +1546 -0
- package/Tests/Server/Types/Database/Permissions/CreateOwnershipScoping.test.ts +529 -0
- package/Tests/Server/Types/Database/Permissions/OwnerOnlyColumns.test.ts +1219 -0
- package/Tests/Server/Types/Database/Permissions/UserNotificationRuleScoping.test.ts +1089 -0
- package/Tests/Server/Types/Workflow/Components/ApiComponentErrorPort.test.ts +2 -1
- package/Tests/Server/Types/Workflow/Components/ApiComponentHeaders.test.ts +192 -0
- package/Tests/Server/Types/Workflow/Components/BaseModelDatabaseComponents.test.ts +190 -0
- package/Tests/Server/Types/Workflow/Components/ChatWebhookComponents.test.ts +44 -14
- package/Tests/Server/Types/Workflow/Components/Email.test.ts +151 -0
- package/Tests/Server/Types/Workflow/Components/IfElse.test.ts +98 -0
- package/Tests/Server/Types/Workflow/Components/JavaScript.test.ts +51 -0
- package/Tests/Server/Utils/AI/AIMetaTools.test.ts +586 -0
- package/Tests/Server/Utils/AI/AlertMonitorFilters.test.ts +582 -0
- package/Tests/Server/Utils/AI/ChatAgentRunner.test.ts +726 -0
- package/Tests/Server/Utils/AI/IncidentToolsFilters.test.ts +315 -0
- package/Tests/Server/Utils/AI/LLMServiceStopReason.test.ts +314 -0
- package/Tests/Server/Utils/AI/LLMServiceToolCalling.test.ts +26 -3
- package/Tests/Server/Utils/AI/NoteWriteTools.test.ts +268 -0
- package/Tests/Server/Utils/AI/ObservabilityChatPrompt.test.ts +169 -0
- package/Tests/Server/Utils/AI/OnCallTools.test.ts +664 -0
- package/Tests/Server/Utils/AI/RunbookTools.test.ts +325 -0
- package/Tests/Server/Utils/AI/SloTools.test.ts +306 -0
- package/Tests/Server/Utils/AI/StatusPageTools.test.ts +391 -0
- package/Tests/Server/Utils/AI/TeamTools.test.ts +257 -0
- package/Tests/Server/Utils/AI/TimelineTools.test.ts +472 -0
- package/Tests/Server/Utils/AI/WorkflowProbeTools.test.ts +428 -0
- package/Tests/Server/Utils/AnalyticsDatabase/QuerySettingsHelper.test.ts +152 -0
- package/Tests/Server/Utils/ClientIp.test.ts +438 -0
- package/Tests/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.test.ts +171 -0
- package/Tests/Server/Utils/Dashboard/PublicDashboardSloHistoryPolicy.test.ts +383 -0
- package/Tests/Server/Utils/EntityRegistryRowFence.test.ts +23 -25
- package/Tests/Server/Utils/MicrosoftTeamsWebhookUrlValidation.test.ts +6 -0
- package/Tests/Server/Utils/Monitor/Criteria/DnssecMonitorCriteria.test.ts +307 -0
- package/Tests/Server/Utils/Monitor/Criteria/SSLMonitorCriteria.test.ts +468 -0
- package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorTelemetryDeepLinks.test.ts +460 -0
- package/Tests/Server/Utils/ResponseRateLimitStatusCodes.test.ts +137 -0
- package/Tests/Server/Utils/SSRFProtectionBypasses.test.ts +40 -8
- package/Tests/Server/Utils/SSRFProtectionUserInfo.test.ts +351 -0
- package/Tests/Server/Utils/Telemetry/EntityRegistryRetirement.test.ts +531 -0
- package/Tests/Server/Utils/Telemetry/InventoryEntityRegistry.test.ts +322 -0
- package/Tests/Server/Utils/Telemetry/TelemetryEntity.test.ts +356 -39
- package/Tests/Server/Utils/VM/VMAPISubstitution.test.ts +243 -0
- package/Tests/Types/IP/IP.test.ts +263 -0
- package/Tests/Types/IP/IPWhitelist.test.ts +197 -0
- package/Tests/Types/IP/IPv6.test.ts +12 -1
- package/Tests/Types/Monitor/SnmpOid.test.ts +64 -0
- package/Tests/Types/NetworkDevice/NetworkDeviceMonitoringMethod.test.ts +110 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilMergeAudit.test.ts +106 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilMergeDifferential.test.ts +511 -0
- package/Tests/Types/OnCallDutyPolicy/ScheduleCoverageEndToEnd.test.ts +489 -0
- package/Tests/Types/OnCallDutyPolicy/ScheduleCoverageGapTolerance.test.ts +479 -0
- package/Tests/Types/OnCallDutyPolicy/ScheduleCoverageState.test.ts +822 -0
- package/Tests/Types/SerializableObjectDictionaryImportCycle.test.ts +197 -0
- package/Tests/Types/Telemetry/EntityTypeGroups.test.ts +140 -0
- package/Tests/Types/Workflow/BaseModelComponents.test.ts +429 -0
- package/Tests/Types/Workflow/Components/BaseModel.test.ts +225 -0
- package/Tests/Types/Workflow/IntegrationCredentialMetadata.test.ts +100 -0
- package/Tests/Types/Workflow/StepTrace.test.ts +235 -0
- package/Tests/Types/Workflow/TemplateSyntax.test.ts +491 -0
- package/Tests/Types/Workflow/Templates.test.ts +1218 -0
- package/Tests/UI/Components/ActiveFilterChipsOpenRoute.test.tsx +82 -0
- package/Tests/UI/Components/ComponentsModal.test.tsx +564 -7
- package/Tests/UI/Components/CustomTimeRangeModal.test.tsx +459 -0
- package/Tests/UI/Components/DictionaryAttributeFilterRow.test.tsx +477 -0
- package/Tests/UI/Components/Forms/AnchoredFieldPopupKeyboard.test.tsx +382 -0
- package/Tests/UI/Components/Forms/ColorPickerPicking.test.tsx +569 -0
- package/Tests/UI/Components/Forms/ValidationJSON.test.ts +241 -0
- package/Tests/UI/Components/KeyboardShortcut.test.tsx +95 -0
- package/Tests/UI/Components/LogDetailsPanelCrossSignal.test.tsx +448 -0
- package/Tests/UI/Components/LogTimeRangePicker.test.tsx +42 -0
- package/Tests/UI/Components/LogsTableCrossLinks.test.tsx +263 -0
- package/Tests/UI/Components/ModalPortalDismissal.test.tsx +90 -0
- package/Tests/UI/Components/PendingProjectInvitations.test.tsx +913 -0
- package/Tests/UI/Components/SimpleLogViewer.test.tsx +228 -0
- package/Tests/UI/Components/TableRowSelectability.test.tsx +312 -0
- package/Tests/UI/Components/TelemetryTimeRangePicker.test.tsx +49 -7
- package/Tests/UI/Components/TimeRangePickerDropdown.test.tsx +325 -0
- package/Tests/UI/Components/Workflow/GraphLint.test.ts +893 -0
- package/Tests/UI/Components/Workflow/GraphLintSummary.test.ts +755 -0
- package/Tests/UI/Components/Workflow/ModelColumnEditor.test.ts +522 -0
- package/Tests/UI/Components/Workflow/ModelColumnEditorServerContract.test.ts +193 -0
- package/Tests/UI/Components/Workflow/ModelSchema.test.ts +388 -0
- package/Tests/UI/Components/Workflow/RunStatusWatcher.test.ts +210 -0
- package/Tests/UI/Components/Workflow/StepTraceViewer.test.tsx +256 -0
- package/Tests/UI/Components/Workflow/UseRunWatch.test.tsx +665 -0
- package/Tests/UI/Components/Workflow/Utils.test.ts +192 -0
- package/Tests/UI/Components/Workflow/WorkflowIssuesModal.test.tsx +485 -0
- package/Tests/UI/Components/Workflow/WorkflowLogModal.test.tsx +478 -0
- package/Tests/UI/Components/Workflow/WorkflowStatusBar.test.tsx +379 -0
- package/Tests/UI/EsbuildConfig.test.ts +607 -0
- package/Tests/UI/Monitor/MonitorStepCriteriaView.test.tsx +432 -0
- package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +5 -0
- package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +11 -4
- package/Tests/UI/Utils/ModelAPICreateMiscData.test.ts +94 -0
- package/Tests/UI/Utils/Platform.test.ts +147 -0
- package/Tests/UI/Utils/ProjectInvitationDisplay.test.ts +357 -0
- package/Tests/Utils/Monitor/NetworkDeviceLinkRuleUtil.test.ts +198 -0
- package/Tests/Utils/Monitor/NetworkDeviceRoleUtil.test.ts +514 -0
- package/Tests/Utils/Monitor/NetworkTopologyUtil.test.ts +727 -0
- package/Tests/Utils/Schema/AnalyticsModelSchema.test.ts +469 -0
- package/Tests/Utils/Schema/ModelSchema.test.ts +268 -0
- package/Tests/Utils/Telemetry/CrossSignalScope.test.ts +698 -0
- package/Tests/Utils/Telemetry/EntityKeyNonTelemetry.test.ts +193 -0
- package/Tests/__mocks__/bullmq.js +55 -0
- package/Types/AI/AIChatMessageStatus.ts +8 -1
- package/Types/AI/AIChatTypes.ts +12 -0
- package/Types/Database/AccessControl/OwnerOnlyColumn.ts +88 -0
- package/Types/Exception/ExceptionCode.ts +2 -0
- package/Types/Exception/ServiceUnavailableException.ts +8 -0
- package/Types/Exception/TooManyRequestsException.ts +8 -0
- package/Types/IP/IP.ts +93 -47
- package/Types/Monitor/SnmpMonitor/NetworkTopology.ts +80 -3
- package/Types/NetworkDevice/NetworkDeviceMonitoringMethod.ts +55 -0
- package/Types/OnCallDutyPolicy/Layer.ts +203 -149
- package/Types/OnCallDutyPolicy/OnCallDutyPolicyStatus.ts +13 -0
- package/Types/OnCallDutyPolicy/ScheduleShiftUtil.ts +155 -10
- package/Types/Permission.ts +193 -0
- package/Types/SerializableObjectDictionary.ts +133 -39
- package/Types/Telemetry/EntityRelationshipType.ts +1 -1
- package/Types/Telemetry/EntitySource.ts +40 -0
- package/Types/Telemetry/EntityType.ts +28 -1
- package/Types/Telemetry/EntityTypeGroups.ts +65 -0
- package/Types/Workflow/Component.ts +29 -0
- package/Types/Workflow/Components/API.ts +35 -0
- package/Types/Workflow/Components/BaseModel.ts +77 -27
- package/Types/Workflow/Components/Discord.ts +1 -0
- package/Types/Workflow/Components/Email.ts +12 -3
- package/Types/Workflow/Components/JavaScript.ts +7 -0
- package/Types/Workflow/Components/MicrosoftTeams.ts +3 -2
- package/Types/Workflow/Components/Slack.ts +1 -0
- package/Types/Workflow/Components/Telegram.ts +1 -0
- package/Types/Workflow/StepTrace.ts +181 -0
- package/Types/Workflow/TemplateSyntax.ts +543 -0
- package/Types/Workflow/Templates.ts +2368 -0
- package/UI/Components/Calendar/Calendar.css +43 -0
- package/UI/Components/Calendar/Calendar.tsx +8 -0
- package/UI/Components/Card/Card.tsx +2 -2
- package/UI/Components/Checkbox/Checkbox.tsx +16 -0
- package/UI/Components/Date/CustomTimeRangeModal.tsx +278 -0
- package/UI/Components/Date/TimeRangePickerDropdown.tsx +250 -0
- package/UI/Components/Dictionary/Dictionary.tsx +71 -15
- package/UI/Components/FormModal/BasicFormModal.tsx +2 -1
- package/UI/Components/Forms/Fields/ColorPicker.tsx +66 -10
- package/UI/Components/Forms/Fields/IconPicker.tsx +48 -10
- package/UI/Components/Forms/Types/Field.ts +7 -0
- package/UI/Components/Forms/Validation.ts +63 -1
- package/UI/Components/Header/HeaderIconDropdownButton.tsx +53 -3
- package/UI/Components/Input/Input.tsx +32 -3
- package/UI/Components/KeyboardShortcut/KeyboardKey.ts +185 -0
- package/UI/Components/KeyboardShortcut/KeyboardShortcut.tsx +87 -0
- package/UI/Components/LogsViewer/LogsViewer.tsx +28 -0
- package/UI/Components/LogsViewer/components/ActiveFilterChips.tsx +31 -0
- package/UI/Components/LogsViewer/components/KeyboardShortcutsHelp.tsx +18 -18
- package/UI/Components/LogsViewer/components/LogDetailsPanel.tsx +363 -14
- package/UI/Components/LogsViewer/components/LogTimeRangePicker.tsx +11 -216
- package/UI/Components/LogsViewer/components/LogsAnalyticsView.tsx +11 -0
- package/UI/Components/LogsViewer/components/LogsTable.tsx +155 -12
- package/UI/Components/LogsViewer/components/LogsViewerToolbar.tsx +29 -0
- package/UI/Components/LogsViewer/types.ts +23 -0
- package/UI/Components/Markdown.tsx/MarkdownEditor.tsx +9 -2
- package/UI/Components/Modal/Modal.tsx +37 -4
- package/UI/Components/Navbar/NavBarMenuModal.tsx +15 -30
- package/UI/Components/ProjectInvitations/PendingProjectInvitations.tsx +442 -0
- package/UI/Components/SimpleLogViewer/SimpleLogViewer.tsx +23 -1
- package/UI/Components/Table/Table.tsx +49 -16
- package/UI/Components/Table/TableBody.tsx +53 -28
- package/UI/Components/Table/TableHeader.tsx +13 -0
- package/UI/Components/Table/TableRow.tsx +58 -26
- package/UI/Components/TelemetryViewer/components/TelemetryTimeRangePicker.tsx +11 -210
- package/UI/Components/Workflow/ArgumentsForm.tsx +341 -8
- package/UI/Components/Workflow/Component.tsx +28 -26
- package/UI/Components/Workflow/ComponentReturnValueViewer.tsx +26 -0
- package/UI/Components/Workflow/ComponentSettingsModal.tsx +79 -9
- package/UI/Components/Workflow/ComponentValuePickerModal.tsx +135 -7
- package/UI/Components/Workflow/ComponentsModal.tsx +116 -22
- package/UI/Components/Workflow/DocumentationViewer.tsx +59 -9
- package/UI/Components/Workflow/GraphLint.ts +628 -0
- package/UI/Components/Workflow/GraphLintSummary.ts +390 -0
- package/UI/Components/Workflow/ModelColumnEditor.tsx +528 -0
- package/UI/Components/Workflow/ModelSchema.ts +278 -0
- package/UI/Components/Workflow/RunForm.tsx +41 -7
- package/UI/Components/Workflow/RunStatusWatcher.ts +122 -0
- package/UI/Components/Workflow/StepTraceViewer.tsx +186 -0
- package/UI/Components/Workflow/UseRunWatch.ts +212 -0
- package/UI/Components/Workflow/Utils.ts +93 -1
- package/UI/Components/Workflow/VariableModal.tsx +6 -2
- package/UI/Components/Workflow/Workflow.tsx +126 -7
- package/UI/Components/Workflow/WorkflowIssuesModal.tsx +255 -0
- package/UI/Components/Workflow/WorkflowLogModal.tsx +128 -0
- package/UI/Components/Workflow/WorkflowStatusBar.tsx +224 -0
- package/UI/Types/LayeredDismissal.ts +31 -0
- package/UI/Types/UseAnchoredFieldPopup.ts +99 -0
- package/UI/Utils/AIChatExport/ConversationMarkdown.ts +10 -0
- package/UI/Utils/ModelAPI/ModelAPI.ts +7 -1
- package/UI/Utils/Platform.ts +149 -0
- package/UI/Utils/ProjectInvitationDisplay.ts +118 -0
- package/UI/esbuild-config.js +22 -1
- package/Utils/Monitor/NetworkDeviceLinkRuleUtil.ts +187 -0
- package/Utils/Monitor/NetworkDeviceRoleUtil.ts +533 -0
- package/Utils/Monitor/NetworkTopologyUtil.ts +917 -155
- package/Utils/Telemetry/CrossSignalScope.ts +502 -0
- package/Utils/Telemetry/EntityKey.ts +71 -5
- package/Utils/Telemetry/EntityRelationship.ts +1 -1
- package/build/dist/Models/AnalyticsModels/MetricItemAggMV1mByK8sCluster.js +1 -1
- package/build/dist/Models/AnalyticsModels/MetricItemAggMV1mByService.js +1 -1
- package/build/dist/Models/DatabaseModels/AIConversation.js +32 -0
- package/build/dist/Models/DatabaseModels/AIConversation.js.map +1 -1
- package/build/dist/Models/DatabaseModels/AIConversationMessage.js +42 -0
- package/build/dist/Models/DatabaseModels/AIConversationMessage.js.map +1 -1
- package/build/dist/Models/DatabaseModels/AlertEpisodeMember.js +28 -0
- package/build/dist/Models/DatabaseModels/AlertEpisodeMember.js.map +1 -1
- package/build/dist/Models/DatabaseModels/IncidentEpisodeMember.js +29 -0
- package/build/dist/Models/DatabaseModels/IncidentEpisodeMember.js.map +1 -1
- package/build/dist/Models/DatabaseModels/Index.js +12 -4
- package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
- package/build/dist/Models/DatabaseModels/{TelemetryEntity.js → InventoryItem.js} +212 -29
- package/build/dist/Models/DatabaseModels/InventoryItem.js.map +1 -0
- package/build/dist/Models/DatabaseModels/InventoryItemCustomField.js +454 -0
- package/build/dist/Models/DatabaseModels/InventoryItemCustomField.js.map +1 -0
- package/build/dist/Models/DatabaseModels/{TelemetryEntityRelationship.js → InventoryItemRelationship.js} +52 -25
- package/build/dist/Models/DatabaseModels/InventoryItemRelationship.js.map +1 -0
- package/build/dist/Models/DatabaseModels/NetworkDevice.js +141 -0
- package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
- package/build/dist/Models/DatabaseModels/NetworkDeviceLink.js +719 -0
- package/build/dist/Models/DatabaseModels/NetworkDeviceLink.js.map +1 -0
- package/build/dist/Models/DatabaseModels/NetworkDeviceLinkRule.js +475 -0
- package/build/dist/Models/DatabaseModels/NetworkDeviceLinkRule.js.map +1 -0
- package/build/dist/Models/DatabaseModels/NetworkTopologySuppression.js +446 -0
- package/build/dist/Models/DatabaseModels/NetworkTopologySuppression.js.map +1 -0
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicyFeed.js +9 -0
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicyFeed.js.map +1 -1
- package/build/dist/Models/DatabaseModels/Project.js +80 -0
- package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
- package/build/dist/Models/DatabaseModels/UserCall.js +46 -2
- package/build/dist/Models/DatabaseModels/UserCall.js.map +1 -1
- package/build/dist/Models/DatabaseModels/UserEmail.js +48 -2
- package/build/dist/Models/DatabaseModels/UserEmail.js.map +1 -1
- package/build/dist/Models/DatabaseModels/UserNotificationRule.js +424 -55
- package/build/dist/Models/DatabaseModels/UserNotificationRule.js.map +1 -1
- package/build/dist/Models/DatabaseModels/UserOnCallLogTimeline.js +24 -2
- package/build/dist/Models/DatabaseModels/UserOnCallLogTimeline.js.map +1 -1
- package/build/dist/Models/DatabaseModels/UserPush.js +51 -1
- package/build/dist/Models/DatabaseModels/UserPush.js.map +1 -1
- package/build/dist/Models/DatabaseModels/UserSMS.js +47 -2
- package/build/dist/Models/DatabaseModels/UserSMS.js.map +1 -1
- package/build/dist/Models/DatabaseModels/UserTelegram.js +65 -3
- package/build/dist/Models/DatabaseModels/UserTelegram.js.map +1 -1
- package/build/dist/Models/DatabaseModels/UserWebhook.js +56 -2
- package/build/dist/Models/DatabaseModels/UserWebhook.js.map +1 -1
- package/build/dist/Models/DatabaseModels/UserWhatsApp.js +45 -2
- package/build/dist/Models/DatabaseModels/UserWhatsApp.js.map +1 -1
- package/build/dist/Models/DatabaseModels/WorkflowLog.js +39 -0
- package/build/dist/Models/DatabaseModels/WorkflowLog.js.map +1 -1
- package/build/dist/Models/DatabaseModels/WorkflowVariable.js +12 -0
- package/build/dist/Models/DatabaseModels/WorkflowVariable.js.map +1 -1
- package/build/dist/Server/API/AIChatAPI.js +237 -1
- package/build/dist/Server/API/AIChatAPI.js.map +1 -1
- package/build/dist/Server/API/DashboardAPI.js +165 -13
- package/build/dist/Server/API/DashboardAPI.js.map +1 -1
- package/build/dist/Server/API/OnCallReadinessAPI.js +599 -0
- package/build/dist/Server/API/OnCallReadinessAPI.js.map +1 -0
- package/build/dist/Server/API/TeamComplianceAPI.js +68 -9
- package/build/dist/Server/API/TeamComplianceAPI.js.map +1 -1
- package/build/dist/Server/API/TelemetryAPI.js +129 -18
- package/build/dist/Server/API/TelemetryAPI.js.map +1 -1
- package/build/dist/Server/API/UserAPI.js +16 -1
- package/build/dist/Server/API/UserAPI.js.map +1 -1
- package/build/dist/Server/EnvironmentConfig.js +45 -0
- package/build/dist/Server/EnvironmentConfig.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/DataSourceOptions.js +22 -0
- package/build/dist/Server/Infrastructure/Postgres/DataSourceOptions.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.js +4 -4
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786551733814-MigrationName.js +20 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786551733814-MigrationName.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786559879134-AddWorkflowLogStepTrace.js +12 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786559879134-AddWorkflowLogStepTrace.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786625176831-AddMonitoringMethodToNetworkDevice.js +18 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786625176831-AddMonitoringMethodToNetworkDevice.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786634985763-AddNetworkDeviceLink.js +39 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786634985763-AddNetworkDeviceLink.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786639512056-AddNetworkDeviceLinkRule.js +38 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786639512056-AddNetworkDeviceLinkRule.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786639972982-AddNetworkTopologySuppression.js +22 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786639972982-AddNetworkTopologySuppression.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786800000000-RenameTelemetryEntityToInventoryItem.js +150 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786800000000-RenameTelemetryEntityToInventoryItem.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786900000000-AddInventoryItemArchiveAndCustomFields.js +57 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786900000000-AddInventoryItemArchiveAndCustomFields.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787000000000-AddOnCallNotificationFallbackColumns.js +69 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787000000000-AddOnCallNotificationFallbackColumns.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787100000000-AddAIConversationPageContext.js +32 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787100000000-AddAIConversationPageContext.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787200000000-AddAIChatMessageFeedback.js +26 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787200000000-AddAIChatMessageFeedback.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787300000000-AddEpisodeMemberNotifyIndexes.js +48 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787300000000-AddEpisodeMemberNotifyIndexes.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +24 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Infrastructure/Queue.js +72 -13
- package/build/dist/Server/Infrastructure/Queue.js.map +1 -1
- package/build/dist/Server/Middleware/MasterAdminAuthorization.js +11 -6
- package/build/dist/Server/Middleware/MasterAdminAuthorization.js.map +1 -1
- package/build/dist/Server/Middleware/PublicDashboardRateLimit.js +399 -0
- package/build/dist/Server/Middleware/PublicDashboardRateLimit.js.map +1 -0
- package/build/dist/Server/Services/AIService.js +1 -0
- package/build/dist/Server/Services/AIService.js.map +1 -1
- package/build/dist/Server/Services/AlertEpisodeStateTimelineService.js +24 -3
- package/build/dist/Server/Services/AlertEpisodeStateTimelineService.js.map +1 -1
- package/build/dist/Server/Services/AlertSeverityService.js +54 -0
- package/build/dist/Server/Services/AlertSeverityService.js.map +1 -1
- package/build/dist/Server/Services/DashboardService.js +10 -9
- package/build/dist/Server/Services/DashboardService.js.map +1 -1
- package/build/dist/Server/Services/DatabaseService.js +24 -2
- package/build/dist/Server/Services/DatabaseService.js.map +1 -1
- package/build/dist/Server/Services/IncidentEpisodeStateTimelineService.js +24 -3
- package/build/dist/Server/Services/IncidentEpisodeStateTimelineService.js.map +1 -1
- package/build/dist/Server/Services/IncidentSeverityService.js +67 -0
- package/build/dist/Server/Services/IncidentSeverityService.js.map +1 -1
- package/build/dist/Server/Services/Index.js +12 -4
- package/build/dist/Server/Services/Index.js.map +1 -1
- package/build/dist/Server/Services/InventoryItemCustomFieldService.js +9 -0
- package/build/dist/Server/Services/InventoryItemCustomFieldService.js.map +1 -0
- package/build/dist/Server/Services/{TelemetryEntityRelationshipService.js → InventoryItemRelationshipService.js} +9 -6
- package/build/dist/Server/Services/InventoryItemRelationshipService.js.map +1 -0
- package/build/dist/Server/Services/{TelemetryEntityService.js → InventoryItemService.js} +158 -23
- package/build/dist/Server/Services/InventoryItemService.js.map +1 -0
- package/build/dist/Server/Services/LogAggregationService.js +27 -8
- package/build/dist/Server/Services/LogAggregationService.js.map +1 -1
- package/build/dist/Server/Services/MetricAggregationService.js +80 -0
- package/build/dist/Server/Services/MetricAggregationService.js.map +1 -1
- package/build/dist/Server/Services/MetricService.js +6 -6
- package/build/dist/Server/Services/MetricService.js.map +1 -1
- package/build/dist/Server/Services/NetworkDeviceLinkRuleService.js +9 -0
- package/build/dist/Server/Services/NetworkDeviceLinkRuleService.js.map +1 -0
- package/build/dist/Server/Services/NetworkDeviceLinkService.js +71 -0
- package/build/dist/Server/Services/NetworkDeviceLinkService.js.map +1 -0
- package/build/dist/Server/Services/NetworkDeviceService.js +113 -0
- package/build/dist/Server/Services/NetworkDeviceService.js.map +1 -1
- package/build/dist/Server/Services/NetworkSiteService.js +53 -13
- package/build/dist/Server/Services/NetworkSiteService.js.map +1 -1
- package/build/dist/Server/Services/NetworkTopologySuppressionService.js +85 -0
- package/build/dist/Server/Services/NetworkTopologySuppressionService.js.map +1 -0
- package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js +48 -32
- package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyExecutionLogService.js +8 -0
- package/build/dist/Server/Services/OnCallDutyPolicyExecutionLogService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyExecutionLogTimelineService.js +51 -12
- package/build/dist/Server/Services/OnCallDutyPolicyExecutionLogTimelineService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js +57 -13
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js.map +1 -1
- package/build/dist/Server/Services/OnCallNotificationAlertingService.js +548 -0
- package/build/dist/Server/Services/OnCallNotificationAlertingService.js.map +1 -0
- package/build/dist/Server/Services/OnCallReadinessService.js +1961 -0
- package/build/dist/Server/Services/OnCallReadinessService.js.map +1 -0
- package/build/dist/Server/Services/OnCallSetupReminderService.js +738 -0
- package/build/dist/Server/Services/OnCallSetupReminderService.js.map +1 -0
- package/build/dist/Server/Services/ProfileAggregationService.js +68 -4
- package/build/dist/Server/Services/ProfileAggregationService.js.map +1 -1
- package/build/dist/Server/Services/StatusPageService.js +11 -10
- package/build/dist/Server/Services/StatusPageService.js.map +1 -1
- package/build/dist/Server/Services/TeamComplianceService.js +312 -160
- package/build/dist/Server/Services/TeamComplianceService.js.map +1 -1
- package/build/dist/Server/Services/UserCallService.js +24 -1
- package/build/dist/Server/Services/UserCallService.js.map +1 -1
- package/build/dist/Server/Services/UserEmailService.js +24 -1
- package/build/dist/Server/Services/UserEmailService.js.map +1 -1
- package/build/dist/Server/Services/UserNotificationRuleAdminService.js +858 -0
- package/build/dist/Server/Services/UserNotificationRuleAdminService.js.map +1 -0
- package/build/dist/Server/Services/UserNotificationRuleService.js +2830 -175
- package/build/dist/Server/Services/UserNotificationRuleService.js.map +1 -1
- package/build/dist/Server/Services/UserOnCallLogService.js +488 -43
- package/build/dist/Server/Services/UserOnCallLogService.js.map +1 -1
- package/build/dist/Server/Services/UserPushService.js +26 -0
- package/build/dist/Server/Services/UserPushService.js.map +1 -1
- package/build/dist/Server/Services/UserService.js +10 -0
- package/build/dist/Server/Services/UserService.js.map +1 -1
- package/build/dist/Server/Services/UserSmsService.js +24 -1
- package/build/dist/Server/Services/UserSmsService.js.map +1 -1
- package/build/dist/Server/Services/UserTelegramService.js +22 -1
- package/build/dist/Server/Services/UserTelegramService.js.map +1 -1
- package/build/dist/Server/Services/UserWebhookService.js +25 -1
- package/build/dist/Server/Services/UserWebhookService.js.map +1 -1
- package/build/dist/Server/Services/UserWhatsAppService.js +22 -1
- package/build/dist/Server/Services/UserWhatsAppService.js.map +1 -1
- package/build/dist/Server/Types/Database/Permissions/BasePermission.js +12 -1
- package/build/dist/Server/Types/Database/Permissions/BasePermission.js.map +1 -1
- package/build/dist/Server/Types/Database/Permissions/CreatePermission.js +126 -0
- package/build/dist/Server/Types/Database/Permissions/CreatePermission.js.map +1 -1
- package/build/dist/Server/Types/Database/Permissions/OwnerOnlyColumnPermission.js +254 -0
- package/build/dist/Server/Types/Database/Permissions/OwnerOnlyColumnPermission.js.map +1 -0
- package/build/dist/Server/Types/Database/Permissions/QueryPermission.js +47 -2
- package/build/dist/Server/Types/Database/Permissions/QueryPermission.js.map +1 -1
- package/build/dist/Server/Types/Database/Permissions/TenantPermission.js +7 -0
- package/build/dist/Server/Types/Database/Permissions/TenantPermission.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/API/Delete.js +1 -1
- package/build/dist/Server/Types/Workflow/Components/API/Delete.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/API/Get.js +1 -1
- package/build/dist/Server/Types/Workflow/Components/API/Get.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/API/Patch.js +1 -1
- package/build/dist/Server/Types/Workflow/Components/API/Patch.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/API/Post.js +1 -1
- package/build/dist/Server/Types/Workflow/Components/API/Post.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/API/Put.js +1 -1
- package/build/dist/Server/Types/Workflow/Components/API/Put.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/API/Utils.js +28 -0
- package/build/dist/Server/Types/Workflow/Components/API/Utils.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/BaseModel/CreateManyBaseModel.js +21 -5
- package/build/dist/Server/Types/Workflow/Components/BaseModel/CreateManyBaseModel.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/BaseModel/CreateOneBaseModel.js +14 -10
- package/build/dist/Server/Types/Workflow/Components/BaseModel/CreateOneBaseModel.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/BaseModel/ModelArguments.js +31 -0
- package/build/dist/Server/Types/Workflow/Components/BaseModel/ModelArguments.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/Conditions/IfElse.js +3 -9
- package/build/dist/Server/Types/Workflow/Components/Conditions/IfElse.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/Email.js +15 -4
- package/build/dist/Server/Types/Workflow/Components/Email.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/JavaScript.js +7 -2
- package/build/dist/Server/Types/Workflow/Components/JavaScript.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/MicrosoftTeams/SendMessageToChannel.js +1 -1
- package/build/dist/Server/Types/Workflow/Components/MicrosoftTeams/SendMessageToChannel.js.map +1 -1
- package/build/dist/Server/Types/Workflow/TriggerCode.js.map +1 -1
- package/build/dist/Server/Utils/AI/Chat/ChatAgentRunner.js +514 -56
- package/build/dist/Server/Utils/AI/Chat/ChatAgentRunner.js.map +1 -1
- package/build/dist/Server/Utils/AI/Chat/ObservabilityAssistant.js +20 -3
- package/build/dist/Server/Utils/AI/Chat/ObservabilityAssistant.js.map +1 -1
- package/build/dist/Server/Utils/AI/Chat/ObservabilityChatPrompt.js +19 -6
- package/build/dist/Server/Utils/AI/Chat/ObservabilityChatPrompt.js.map +1 -1
- package/build/dist/Server/Utils/AI/SRE/AIInvestigationEngine.js +7 -0
- package/build/dist/Server/Utils/AI/SRE/AIInvestigationEngine.js.map +1 -1
- package/build/dist/Server/Utils/AI/Toolbox/AIActionTools.js +2 -2
- package/build/dist/Server/Utils/AI/Toolbox/AIActionTools.js.map +1 -1
- package/build/dist/Server/Utils/AI/Toolbox/AIMetaTools.js +692 -0
- package/build/dist/Server/Utils/AI/Toolbox/AIMetaTools.js.map +1 -0
- package/build/dist/Server/Utils/AI/Toolbox/AlertTools.js +148 -12
- package/build/dist/Server/Utils/AI/Toolbox/AlertTools.js.map +1 -1
- package/build/dist/Server/Utils/AI/Toolbox/IncidentTools.js +157 -10
- package/build/dist/Server/Utils/AI/Toolbox/IncidentTools.js.map +1 -1
- package/build/dist/Server/Utils/AI/Toolbox/Index.js +37 -0
- package/build/dist/Server/Utils/AI/Toolbox/Index.js.map +1 -1
- package/build/dist/Server/Utils/AI/Toolbox/MonitorTools.js +259 -14
- package/build/dist/Server/Utils/AI/Toolbox/MonitorTools.js.map +1 -1
- package/build/dist/Server/Utils/AI/Toolbox/NoteWriteTools.js +235 -0
- package/build/dist/Server/Utils/AI/Toolbox/NoteWriteTools.js.map +1 -0
- package/build/dist/Server/Utils/AI/Toolbox/OnCallTools.js +1000 -0
- package/build/dist/Server/Utils/AI/Toolbox/OnCallTools.js.map +1 -0
- package/build/dist/Server/Utils/AI/Toolbox/RunbookTools.js +356 -0
- package/build/dist/Server/Utils/AI/Toolbox/RunbookTools.js.map +1 -0
- package/build/dist/Server/Utils/AI/Toolbox/SloTools.js +394 -0
- package/build/dist/Server/Utils/AI/Toolbox/SloTools.js.map +1 -0
- package/build/dist/Server/Utils/AI/Toolbox/StatusPageTools.js +465 -0
- package/build/dist/Server/Utils/AI/Toolbox/StatusPageTools.js.map +1 -0
- package/build/dist/Server/Utils/AI/Toolbox/TeamTools.js +280 -0
- package/build/dist/Server/Utils/AI/Toolbox/TeamTools.js.map +1 -0
- package/build/dist/Server/Utils/AI/Toolbox/TimelineTools.js +527 -0
- package/build/dist/Server/Utils/AI/Toolbox/TimelineTools.js.map +1 -0
- package/build/dist/Server/Utils/AI/Toolbox/WorkflowProbeTools.js +548 -0
- package/build/dist/Server/Utils/AI/Toolbox/WorkflowProbeTools.js.map +1 -0
- package/build/dist/Server/Utils/ClientIp.js +137 -0
- package/build/dist/Server/Utils/ClientIp.js.map +1 -0
- package/build/dist/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.js +38 -0
- package/build/dist/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.js.map +1 -1
- package/build/dist/Server/Utils/Dashboard/PublicDashboardSloHistoryPolicy.js +89 -0
- package/build/dist/Server/Utils/Dashboard/PublicDashboardSloHistoryPolicy.js.map +1 -0
- package/build/dist/Server/Utils/Dashboard/PublicDashboardSloWidget.js +77 -0
- package/build/dist/Server/Utils/Dashboard/PublicDashboardSloWidget.js.map +1 -0
- package/build/dist/Server/Utils/Express.js +12 -12
- package/build/dist/Server/Utils/Express.js.map +1 -1
- package/build/dist/Server/Utils/LLM/LLMService.js +70 -7
- package/build/dist/Server/Utils/LLM/LLMService.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +121 -11
- package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
- package/build/dist/Server/Utils/SSRFProtection.js +82 -21
- package/build/dist/Server/Utils/SSRFProtection.js.map +1 -1
- package/build/dist/Server/Utils/StartServer.js +12 -4
- package/build/dist/Server/Utils/StartServer.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/EntityRegistry.js +165 -18
- package/build/dist/Server/Utils/Telemetry/EntityRegistry.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js +507 -0
- package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js.map +1 -0
- package/build/dist/Server/Utils/Telemetry/TelemetryEntity.js +122 -47
- package/build/dist/Server/Utils/Telemetry/TelemetryEntity.js.map +1 -1
- package/build/dist/Server/Utils/VM/VMAPI.js +51 -4
- package/build/dist/Server/Utils/VM/VMAPI.js.map +1 -1
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js +7 -3
- package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js.map +1 -1
- package/build/dist/Types/AI/AIChatMessageStatus.js +8 -1
- package/build/dist/Types/AI/AIChatMessageStatus.js.map +1 -1
- package/build/dist/Types/AI/AIChatTypes.js +12 -0
- package/build/dist/Types/AI/AIChatTypes.js.map +1 -1
- package/build/dist/Types/Database/AccessControl/OwnerOnlyColumn.js +60 -0
- package/build/dist/Types/Database/AccessControl/OwnerOnlyColumn.js.map +1 -0
- package/build/dist/Types/Exception/ExceptionCode.js +2 -0
- package/build/dist/Types/Exception/ExceptionCode.js.map +1 -1
- package/build/dist/Types/Exception/ServiceUnavailableException.js +8 -0
- package/build/dist/Types/Exception/ServiceUnavailableException.js.map +1 -0
- package/build/dist/Types/Exception/TooManyRequestsException.js +8 -0
- package/build/dist/Types/Exception/TooManyRequestsException.js.map +1 -0
- package/build/dist/Types/IP/IP.js +87 -43
- package/build/dist/Types/IP/IP.js.map +1 -1
- package/build/dist/Types/NetworkDevice/NetworkDeviceMonitoringMethod.js +50 -0
- package/build/dist/Types/NetworkDevice/NetworkDeviceMonitoringMethod.js.map +1 -0
- package/build/dist/Types/OnCallDutyPolicy/Layer.js +186 -123
- package/build/dist/Types/OnCallDutyPolicy/Layer.js.map +1 -1
- package/build/dist/Types/OnCallDutyPolicy/OnCallDutyPolicyStatus.js +13 -0
- package/build/dist/Types/OnCallDutyPolicy/OnCallDutyPolicyStatus.js.map +1 -1
- package/build/dist/Types/OnCallDutyPolicy/ScheduleShiftUtil.js +105 -11
- package/build/dist/Types/OnCallDutyPolicy/ScheduleShiftUtil.js.map +1 -1
- package/build/dist/Types/Permission.js +174 -0
- package/build/dist/Types/Permission.js.map +1 -1
- package/build/dist/Types/SerializableObjectDictionary.js +133 -39
- package/build/dist/Types/SerializableObjectDictionary.js.map +1 -1
- package/build/dist/Types/Telemetry/EntityRelationshipType.js +1 -1
- package/build/dist/Types/Telemetry/EntitySource.js +39 -0
- package/build/dist/Types/Telemetry/EntitySource.js.map +1 -0
- package/build/dist/Types/Telemetry/EntityType.js +28 -1
- package/build/dist/Types/Telemetry/EntityType.js.map +1 -1
- package/build/dist/Types/Telemetry/EntityTypeGroups.js +57 -0
- package/build/dist/Types/Telemetry/EntityTypeGroups.js.map +1 -0
- package/build/dist/Types/Workflow/Component.js +21 -0
- package/build/dist/Types/Workflow/Component.js.map +1 -1
- package/build/dist/Types/Workflow/Components/API.js +35 -0
- package/build/dist/Types/Workflow/Components/API.js.map +1 -1
- package/build/dist/Types/Workflow/Components/BaseModel.js +68 -26
- package/build/dist/Types/Workflow/Components/BaseModel.js.map +1 -1
- package/build/dist/Types/Workflow/Components/Discord.js +1 -0
- package/build/dist/Types/Workflow/Components/Discord.js.map +1 -1
- package/build/dist/Types/Workflow/Components/Email.js +12 -3
- package/build/dist/Types/Workflow/Components/Email.js.map +1 -1
- package/build/dist/Types/Workflow/Components/JavaScript.js +7 -0
- package/build/dist/Types/Workflow/Components/JavaScript.js.map +1 -1
- package/build/dist/Types/Workflow/Components/MicrosoftTeams.js +3 -2
- package/build/dist/Types/Workflow/Components/MicrosoftTeams.js.map +1 -1
- package/build/dist/Types/Workflow/Components/Slack.js +1 -0
- package/build/dist/Types/Workflow/Components/Slack.js.map +1 -1
- package/build/dist/Types/Workflow/Components/Telegram.js +1 -0
- package/build/dist/Types/Workflow/Components/Telegram.js.map +1 -1
- package/build/dist/Types/Workflow/StepTrace.js +104 -0
- package/build/dist/Types/Workflow/StepTrace.js.map +1 -0
- package/build/dist/Types/Workflow/TemplateSyntax.js +338 -0
- package/build/dist/Types/Workflow/TemplateSyntax.js.map +1 -0
- package/build/dist/Types/Workflow/Templates.js +2111 -0
- package/build/dist/Types/Workflow/Templates.js.map +1 -0
- package/build/dist/UI/Components/Calendar/Calendar.js +1 -1
- package/build/dist/UI/Components/Calendar/Calendar.js.map +1 -1
- package/build/dist/UI/Components/Checkbox/Checkbox.js +1 -1
- package/build/dist/UI/Components/Checkbox/Checkbox.js.map +1 -1
- package/build/dist/UI/Components/Date/CustomTimeRangeModal.js +126 -0
- package/build/dist/UI/Components/Date/CustomTimeRangeModal.js.map +1 -0
- package/build/dist/UI/Components/Date/TimeRangePickerDropdown.js +123 -0
- package/build/dist/UI/Components/Date/TimeRangePickerDropdown.js.map +1 -0
- package/build/dist/UI/Components/Dictionary/Dictionary.js +47 -17
- package/build/dist/UI/Components/Dictionary/Dictionary.js.map +1 -1
- package/build/dist/UI/Components/FormModal/BasicFormModal.js.map +1 -1
- package/build/dist/UI/Components/Forms/Fields/ColorPicker.js +52 -13
- package/build/dist/UI/Components/Forms/Fields/ColorPicker.js.map +1 -1
- package/build/dist/UI/Components/Forms/Fields/IconPicker.js +28 -12
- package/build/dist/UI/Components/Forms/Fields/IconPicker.js.map +1 -1
- package/build/dist/UI/Components/Forms/Validation.js +44 -0
- package/build/dist/UI/Components/Forms/Validation.js.map +1 -1
- package/build/dist/UI/Components/Header/HeaderIconDropdownButton.js +27 -4
- package/build/dist/UI/Components/Header/HeaderIconDropdownButton.js.map +1 -1
- package/build/dist/UI/Components/Input/Input.js +13 -4
- package/build/dist/UI/Components/Input/Input.js.map +1 -1
- package/build/dist/UI/Components/KeyboardShortcut/KeyboardKey.js +163 -0
- package/build/dist/UI/Components/KeyboardShortcut/KeyboardKey.js.map +1 -0
- package/build/dist/UI/Components/KeyboardShortcut/KeyboardShortcut.js +47 -0
- package/build/dist/UI/Components/KeyboardShortcut/KeyboardShortcut.js.map +1 -0
- package/build/dist/UI/Components/LogsViewer/LogsViewer.js +4 -4
- package/build/dist/UI/Components/LogsViewer/LogsViewer.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/ActiveFilterChips.js +11 -1
- package/build/dist/UI/Components/LogsViewer/components/ActiveFilterChips.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/KeyboardShortcutsHelp.js +10 -8
- package/build/dist/UI/Components/LogsViewer/components/KeyboardShortcutsHelp.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/LogDetailsPanel.js +227 -14
- package/build/dist/UI/Components/LogsViewer/components/LogDetailsPanel.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/LogTimeRangePicker.js +5 -108
- package/build/dist/UI/Components/LogsViewer/components/LogTimeRangePicker.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js +5 -0
- package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/LogsTable.js +69 -5
- package/build/dist/UI/Components/LogsViewer/components/LogsTable.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/LogsViewerToolbar.js +8 -0
- package/build/dist/UI/Components/LogsViewer/components/LogsViewerToolbar.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/types.js.map +1 -1
- package/build/dist/UI/Components/Markdown.tsx/MarkdownEditor.js +9 -2
- package/build/dist/UI/Components/Markdown.tsx/MarkdownEditor.js.map +1 -1
- package/build/dist/UI/Components/Modal/Modal.js +31 -5
- package/build/dist/UI/Components/Modal/Modal.js.map +1 -1
- package/build/dist/UI/Components/Navbar/NavBarMenuModal.js +8 -21
- package/build/dist/UI/Components/Navbar/NavBarMenuModal.js.map +1 -1
- package/build/dist/UI/Components/ProjectInvitations/PendingProjectInvitations.js +251 -0
- package/build/dist/UI/Components/ProjectInvitations/PendingProjectInvitations.js.map +1 -0
- package/build/dist/UI/Components/SimpleLogViewer/SimpleLogViewer.js +9 -2
- package/build/dist/UI/Components/SimpleLogViewer/SimpleLogViewer.js.map +1 -1
- package/build/dist/UI/Components/Table/Table.js +27 -15
- package/build/dist/UI/Components/Table/Table.js.map +1 -1
- package/build/dist/UI/Components/Table/TableBody.js +24 -18
- package/build/dist/UI/Components/Table/TableBody.js.map +1 -1
- package/build/dist/UI/Components/Table/TableHeader.js +9 -1
- package/build/dist/UI/Components/Table/TableHeader.js.map +1 -1
- package/build/dist/UI/Components/Table/TableRow.js +29 -21
- package/build/dist/UI/Components/Table/TableRow.js.map +1 -1
- package/build/dist/UI/Components/TelemetryViewer/components/TelemetryTimeRangePicker.js +5 -105
- package/build/dist/UI/Components/TelemetryViewer/components/TelemetryTimeRangePicker.js.map +1 -1
- package/build/dist/UI/Components/Workflow/ArgumentsForm.js +261 -14
- package/build/dist/UI/Components/Workflow/ArgumentsForm.js.map +1 -1
- package/build/dist/UI/Components/Workflow/Component.js +20 -19
- package/build/dist/UI/Components/Workflow/Component.js.map +1 -1
- package/build/dist/UI/Components/Workflow/ComponentReturnValueViewer.js +10 -1
- package/build/dist/UI/Components/Workflow/ComponentReturnValueViewer.js.map +1 -1
- package/build/dist/UI/Components/Workflow/ComponentSettingsModal.js +35 -7
- package/build/dist/UI/Components/Workflow/ComponentSettingsModal.js.map +1 -1
- package/build/dist/UI/Components/Workflow/ComponentValuePickerModal.js +57 -7
- package/build/dist/UI/Components/Workflow/ComponentValuePickerModal.js.map +1 -1
- package/build/dist/UI/Components/Workflow/ComponentsModal.js +53 -18
- package/build/dist/UI/Components/Workflow/ComponentsModal.js.map +1 -1
- package/build/dist/UI/Components/Workflow/DocumentationViewer.js +19 -6
- package/build/dist/UI/Components/Workflow/DocumentationViewer.js.map +1 -1
- package/build/dist/UI/Components/Workflow/GraphLint.js +425 -0
- package/build/dist/UI/Components/Workflow/GraphLint.js.map +1 -0
- package/build/dist/UI/Components/Workflow/GraphLintSummary.js +231 -0
- package/build/dist/UI/Components/Workflow/GraphLintSummary.js.map +1 -0
- package/build/dist/UI/Components/Workflow/ModelColumnEditor.js +320 -0
- package/build/dist/UI/Components/Workflow/ModelColumnEditor.js.map +1 -0
- package/build/dist/UI/Components/Workflow/ModelSchema.js +156 -0
- package/build/dist/UI/Components/Workflow/ModelSchema.js.map +1 -0
- package/build/dist/UI/Components/Workflow/RunForm.js +22 -6
- package/build/dist/UI/Components/Workflow/RunForm.js.map +1 -1
- package/build/dist/UI/Components/Workflow/RunStatusWatcher.js +76 -0
- package/build/dist/UI/Components/Workflow/RunStatusWatcher.js.map +1 -0
- package/build/dist/UI/Components/Workflow/StepTraceViewer.js +76 -0
- package/build/dist/UI/Components/Workflow/StepTraceViewer.js.map +1 -0
- package/build/dist/UI/Components/Workflow/UseRunWatch.js +123 -0
- package/build/dist/UI/Components/Workflow/UseRunWatch.js.map +1 -0
- package/build/dist/UI/Components/Workflow/Utils.js +73 -1
- package/build/dist/UI/Components/Workflow/Utils.js.map +1 -1
- package/build/dist/UI/Components/Workflow/VariableModal.js +3 -2
- package/build/dist/UI/Components/Workflow/VariableModal.js.map +1 -1
- package/build/dist/UI/Components/Workflow/Workflow.js +92 -7
- package/build/dist/UI/Components/Workflow/Workflow.js.map +1 -1
- package/build/dist/UI/Components/Workflow/WorkflowIssuesModal.js +99 -0
- package/build/dist/UI/Components/Workflow/WorkflowIssuesModal.js.map +1 -0
- package/build/dist/UI/Components/Workflow/WorkflowLogModal.js +56 -0
- package/build/dist/UI/Components/Workflow/WorkflowLogModal.js.map +1 -0
- package/build/dist/UI/Components/Workflow/WorkflowStatusBar.js +92 -0
- package/build/dist/UI/Components/Workflow/WorkflowStatusBar.js.map +1 -0
- package/build/dist/UI/Types/LayeredDismissal.js +21 -0
- package/build/dist/UI/Types/LayeredDismissal.js.map +1 -0
- package/build/dist/UI/Types/UseAnchoredFieldPopup.js +74 -1
- package/build/dist/UI/Types/UseAnchoredFieldPopup.js.map +1 -1
- package/build/dist/UI/Utils/AIChatExport/ConversationMarkdown.js +9 -0
- package/build/dist/UI/Utils/AIChatExport/ConversationMarkdown.js.map +1 -1
- package/build/dist/UI/Utils/ModelAPI/ModelAPI.js +1 -1
- package/build/dist/UI/Utils/ModelAPI/ModelAPI.js.map +1 -1
- package/build/dist/UI/Utils/Platform.js +118 -0
- package/build/dist/UI/Utils/Platform.js.map +1 -0
- package/build/dist/UI/Utils/ProjectInvitationDisplay.js +106 -0
- package/build/dist/UI/Utils/ProjectInvitationDisplay.js.map +1 -0
- package/build/dist/Utils/Monitor/NetworkDeviceLinkRuleUtil.js +108 -0
- package/build/dist/Utils/Monitor/NetworkDeviceLinkRuleUtil.js.map +1 -0
- package/build/dist/Utils/Monitor/NetworkDeviceRoleUtil.js +386 -0
- package/build/dist/Utils/Monitor/NetworkDeviceRoleUtil.js.map +1 -0
- package/build/dist/Utils/Monitor/NetworkTopologyUtil.js +661 -126
- package/build/dist/Utils/Monitor/NetworkTopologyUtil.js.map +1 -1
- package/build/dist/Utils/Telemetry/CrossSignalScope.js +328 -0
- package/build/dist/Utils/Telemetry/CrossSignalScope.js.map +1 -0
- package/build/dist/Utils/Telemetry/EntityKey.js +57 -5
- package/build/dist/Utils/Telemetry/EntityKey.js.map +1 -1
- package/build/dist/Utils/Telemetry/EntityRelationship.js +1 -1
- package/jest.config.json +1 -0
- package/package.json +1 -1
- package/build/dist/Models/DatabaseModels/TelemetryEntity.js.map +0 -1
- package/build/dist/Models/DatabaseModels/TelemetryEntityRelationship.js.map +0 -1
- package/build/dist/Server/Services/TelemetryEntityRelationshipService.js.map +0 -1
- package/build/dist/Server/Services/TelemetryEntityService.js.map +0 -1
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import DatabaseConfig from "../DatabaseConfig";
|
|
2
2
|
import CreateBy from "../Types/Database/CreateBy";
|
|
3
|
-
import
|
|
3
|
+
import DeleteBy from "../Types/Database/DeleteBy";
|
|
4
|
+
import Query from "../Types/Database/Query";
|
|
5
|
+
import UpdateBy from "../Types/Database/UpdateBy";
|
|
6
|
+
import { OnCreate, OnDelete, OnUpdate } from "../Types/Database/Hooks";
|
|
7
|
+
import DatabaseRequestType from "../Types/BaseDatabase/DatabaseRequestType";
|
|
8
|
+
import TenantPermission from "../Types/Database/Permissions/TenantPermission";
|
|
4
9
|
import Markdown, { MarkdownContentType } from "../Types/Markdown";
|
|
5
10
|
import CallService from "./CallService";
|
|
6
11
|
import DatabaseService from "./DatabaseService";
|
|
@@ -14,6 +19,24 @@ import TelegramService from "./TelegramService";
|
|
|
14
19
|
import WebhookService from "./WebhookService";
|
|
15
20
|
import WhatsAppService from "./WhatsAppService";
|
|
16
21
|
import UserEmailService from "./UserEmailService";
|
|
22
|
+
import UserCallService from "./UserCallService";
|
|
23
|
+
import UserPushService from "./UserPushService";
|
|
24
|
+
import UserSmsService from "./UserSmsService";
|
|
25
|
+
import UserTelegramService from "./UserTelegramService";
|
|
26
|
+
import UserWebhookService from "./UserWebhookService";
|
|
27
|
+
import UserWhatsAppService from "./UserWhatsAppService";
|
|
28
|
+
import ProjectService from "./ProjectService";
|
|
29
|
+
import UserNotificationRuleAdminService, {
|
|
30
|
+
RuleColumnCarrier,
|
|
31
|
+
NotificationMethodReference,
|
|
32
|
+
} from "./UserNotificationRuleAdminService";
|
|
33
|
+
import OnCallReadinessService, {
|
|
34
|
+
ReadinessMethod,
|
|
35
|
+
ReadinessMethodType,
|
|
36
|
+
ReadinessStatus,
|
|
37
|
+
ResponderSource,
|
|
38
|
+
UserReadiness,
|
|
39
|
+
} from "./OnCallReadinessService";
|
|
17
40
|
import UserOnCallLogService from "./UserOnCallLogService";
|
|
18
41
|
import UserOnCallLogTimelineService from "./UserOnCallLogTimelineService";
|
|
19
42
|
import { AppApiRoute } from "../../ServiceRoute";
|
|
@@ -21,9 +44,13 @@ import Hostname from "../../Types/API/Hostname";
|
|
|
21
44
|
import Protocol from "../../Types/API/Protocol";
|
|
22
45
|
import Route from "../../Types/API/Route";
|
|
23
46
|
import URL from "../../Types/API/URL";
|
|
47
|
+
import AuditLogAction from "../../Types/AuditLog/AuditLogAction";
|
|
48
|
+
import DatabaseCommonInteractionProps from "../../Types/BaseDatabase/DatabaseCommonInteractionProps";
|
|
24
49
|
import CallRequest from "../../Types/Call/CallRequest";
|
|
25
|
-
import
|
|
50
|
+
import SortOrder from "../../Types/BaseDatabase/SortOrder";
|
|
51
|
+
import LIMIT_MAX, { LIMIT_PER_PROJECT } from "../../Types/Database/LimitMax";
|
|
26
52
|
import QueryHelper from "../Types/Database/QueryHelper";
|
|
53
|
+
import Sort from "../Types/Database/Sort";
|
|
27
54
|
import Dictionary from "../../Types/Dictionary";
|
|
28
55
|
import Email from "../../Types/Email";
|
|
29
56
|
import EmailMessage from "../../Types/Email/EmailMessage";
|
|
@@ -49,8 +76,16 @@ import UserNotificationExecutionStatus from "../../Types/UserNotification/UserNo
|
|
|
49
76
|
import UserNotificationStatus from "../../Types/UserNotification/UserNotificationStatus";
|
|
50
77
|
import Incident from "../../Models/DatabaseModels/Incident";
|
|
51
78
|
import IncidentSeverity from "../../Models/DatabaseModels/IncidentSeverity";
|
|
79
|
+
import Monitor from "../../Models/DatabaseModels/Monitor";
|
|
80
|
+
import Project from "../../Models/DatabaseModels/Project";
|
|
52
81
|
import ShortLink from "../../Models/DatabaseModels/ShortLink";
|
|
82
|
+
import UserCall from "../../Models/DatabaseModels/UserCall";
|
|
53
83
|
import UserEmail from "../../Models/DatabaseModels/UserEmail";
|
|
84
|
+
import UserPush from "../../Models/DatabaseModels/UserPush";
|
|
85
|
+
import UserSMS from "../../Models/DatabaseModels/UserSMS";
|
|
86
|
+
import UserTelegram from "../../Models/DatabaseModels/UserTelegram";
|
|
87
|
+
import UserWebhook from "../../Models/DatabaseModels/UserWebhook";
|
|
88
|
+
import UserWhatsApp from "../../Models/DatabaseModels/UserWhatsApp";
|
|
54
89
|
import Model from "../../Models/DatabaseModels/UserNotificationRule";
|
|
55
90
|
import UserOnCallLog from "../../Models/DatabaseModels/UserOnCallLog";
|
|
56
91
|
import UserOnCallLogTimeline from "../../Models/DatabaseModels/UserOnCallLogTimeline";
|
|
@@ -64,6 +99,8 @@ import AlertEpisodeMember from "../../Models/DatabaseModels/AlertEpisodeMember";
|
|
|
64
99
|
import AlertEpisodeMemberService from "./AlertEpisodeMemberService";
|
|
65
100
|
import IncidentEpisode from "../../Models/DatabaseModels/IncidentEpisode";
|
|
66
101
|
import IncidentEpisodeService from "./IncidentEpisodeService";
|
|
102
|
+
import IncidentEpisodeMember from "../../Models/DatabaseModels/IncidentEpisodeMember";
|
|
103
|
+
import IncidentEpisodeMemberService from "./IncidentEpisodeMemberService";
|
|
67
104
|
import WorkspaceNotificationRule from "../../Models/DatabaseModels/WorkspaceNotificationRule";
|
|
68
105
|
import WorkspaceNotificationRuleService from "./WorkspaceNotificationRuleService";
|
|
69
106
|
import PushNotificationService from "./PushNotificationService";
|
|
@@ -84,6 +121,450 @@ export interface NotificationMethodDescriptor {
|
|
|
84
121
|
userWebhookId?: ObjectID;
|
|
85
122
|
}
|
|
86
123
|
|
|
124
|
+
/*
|
|
125
|
+
* Everything a single delivery attempt needs to know about the page it is
|
|
126
|
+
* carrying: which project, which entity fired it, which escalation produced it,
|
|
127
|
+
* and which UserOnCallLog row it must reconcile against. This used to be an
|
|
128
|
+
* inline object literal on executeNotificationRuleItem; it is named here because
|
|
129
|
+
* the fallback path (executeFallbackNotification) hands the very same bundle to
|
|
130
|
+
* the very same delivery code, and because callers outside this file now need to
|
|
131
|
+
* be able to type a variable against it.
|
|
132
|
+
*/
|
|
133
|
+
export interface ExecuteNotificationRuleOptions {
|
|
134
|
+
projectId: ObjectID;
|
|
135
|
+
triggeredByIncidentId?: ObjectID | undefined;
|
|
136
|
+
triggeredByAlertId?: ObjectID | undefined;
|
|
137
|
+
triggeredByAlertEpisodeId?: ObjectID | undefined;
|
|
138
|
+
triggeredByIncidentEpisodeId?: ObjectID | undefined;
|
|
139
|
+
userNotificationEventType: UserNotificationEventType;
|
|
140
|
+
onCallPolicyExecutionLogId?: ObjectID | undefined;
|
|
141
|
+
onCallPolicyId: ObjectID | undefined;
|
|
142
|
+
onCallPolicyEscalationRuleId?: ObjectID | undefined;
|
|
143
|
+
userNotificationLogId: ObjectID;
|
|
144
|
+
userBelongsToTeamId?: ObjectID | undefined;
|
|
145
|
+
onCallDutyPolicyExecutionLogTimelineId?: ObjectID | undefined;
|
|
146
|
+
onCallScheduleId?: ObjectID | undefined;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface ExecuteFallbackNotificationOptions
|
|
150
|
+
extends ExecuteNotificationRuleOptions {
|
|
151
|
+
userId: ObjectID;
|
|
152
|
+
userOnCallLogId: ObjectID;
|
|
153
|
+
ruleType: NotificationRuleType;
|
|
154
|
+
// Only used to explain, in prose, which severity had no rule configured.
|
|
155
|
+
severityName: string;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/*
|
|
159
|
+
* Why the fallback returns an outcome and not just a boolean.
|
|
160
|
+
*
|
|
161
|
+
* Its caller (UserOnCallLogService.onCreateSuccess) has to pick a
|
|
162
|
+
* UserNotificationExecutionStatus out of the answer, and
|
|
163
|
+
* UserNotificationExecutionStatus.Error is TERMINAL — ExecutePendingExecutions
|
|
164
|
+
* selects Executing and TimeoutStuckExecutions selects Started, so nothing
|
|
165
|
+
* anywhere re-selects an Error log. That makes the two ways of not notifying
|
|
166
|
+
* somebody opposites rather than synonyms: "this responder has nothing we can
|
|
167
|
+
* page them on" is a real, permanent misconfiguration worth burning the log
|
|
168
|
+
* for, while "the send raised" is a bad minute that a terminal status would
|
|
169
|
+
* turn into a permanently dropped page. Both are `notified: false`, so the
|
|
170
|
+
* difference has to survive the return or the caller cannot act on it.
|
|
171
|
+
*/
|
|
172
|
+
export enum FallbackNotificationOutcome {
|
|
173
|
+
/*
|
|
174
|
+
* A page was handed to at least one sender. Nothing below observes what the
|
|
175
|
+
* sender then did with it — every send in deliverNotificationForRule is
|
|
176
|
+
* fire-and-forget — so this means dispatched, not received.
|
|
177
|
+
*/
|
|
178
|
+
Delivered = "Delivered",
|
|
179
|
+
|
|
180
|
+
/*
|
|
181
|
+
* There was nothing to try. The responder has no verified method the
|
|
182
|
+
* fallback may use and no webhook, or the only paid channels they have are
|
|
183
|
+
* switched off at the project level. Permanent: a retry finds the same
|
|
184
|
+
* nothing, and only a human adding a notification method changes it.
|
|
185
|
+
*/
|
|
186
|
+
NoUsableNotificationMethod = "NoUsableNotificationMethod",
|
|
187
|
+
|
|
188
|
+
/*
|
|
189
|
+
* There was something to try and none of it went out: a send raised, or a
|
|
190
|
+
* chosen channel had no template for this event type, or another run already
|
|
191
|
+
* holds the fallback claim on this log and owns the outcome. All three are
|
|
192
|
+
* transient from the caller's point of view — none of them is evidence that
|
|
193
|
+
* the responder is unreachable, so none of them justifies a terminal status.
|
|
194
|
+
*/
|
|
195
|
+
DeliveryFailed = "DeliveryFailed",
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export interface FallbackNotificationResult {
|
|
199
|
+
outcome: FallbackNotificationOutcome;
|
|
200
|
+
/*
|
|
201
|
+
* Mirror of `outcome === FallbackNotificationOutcome.Delivered`, kept because
|
|
202
|
+
* most read sites only want the yes/no and re-deriving the comparison at each
|
|
203
|
+
* one is how a caller ends up asserting the wrong half of the enum.
|
|
204
|
+
*/
|
|
205
|
+
notified: boolean;
|
|
206
|
+
channelsUsed: Array<string>;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/*
|
|
210
|
+
* The fallback is not tied to any UserNotificationRule row — there is no rule,
|
|
211
|
+
* which is the whole reason it runs — so it claims the on-call log under this
|
|
212
|
+
* reserved literal instead of a rule id. `executedNotificationRules` is a jsonb
|
|
213
|
+
* map keyed by arbitrary text, so the literal sits beside real rule uuids and
|
|
214
|
+
* can never collide with one.
|
|
215
|
+
*/
|
|
216
|
+
export const FALLBACK_NOTIFICATION_CLAIM_KEY: string = "__fallback__";
|
|
217
|
+
|
|
218
|
+
/*
|
|
219
|
+
* ---------------------------------------------------------------------------
|
|
220
|
+
* DELETION IMPACT — "what would I lose by deleting this?", asked BEFORE the
|
|
221
|
+
* delete.
|
|
222
|
+
*
|
|
223
|
+
* Two writes a responder makes about their own configuration can take away the
|
|
224
|
+
* only thing standing between a page and nobody hearing it, and neither one
|
|
225
|
+
* looks like that from the screen it is made on:
|
|
226
|
+
*
|
|
227
|
+
* - Deleting a RULE can remove the LAST rule covering one
|
|
228
|
+
* (ruleType x severity) cell. The rule table is a list of rows, not a
|
|
229
|
+
* coverage grid, so "this is the only thing left for Sev1 incidents" is
|
|
230
|
+
* visible nowhere at the moment somebody clicks delete.
|
|
231
|
+
*
|
|
232
|
+
* - Deleting a METHOD CASCADES. Every method foreign key on
|
|
233
|
+
* UserNotificationRule is onDelete: "CASCADE" — and each method service
|
|
234
|
+
* deletes the rows in its own onBeforeDelete as well, so the cascade
|
|
235
|
+
* happens whether or not the database does it — which means removing one
|
|
236
|
+
* phone number destroys every rule that pointed at it. The delete dialog
|
|
237
|
+
* for a phone number mentions notification rules nowhere at all. This is
|
|
238
|
+
* the more dangerous of the two by a distance, because the loss is not even
|
|
239
|
+
* the thing being deleted.
|
|
240
|
+
*
|
|
241
|
+
* Everything here is ADVISORY and is deliberately shaped as a QUESTION the
|
|
242
|
+
* caller asks first, not as a hook that throws. The deletion still goes through
|
|
243
|
+
* the ordinary CRUD path afterwards and nothing below can stop it, for two
|
|
244
|
+
* reasons. The first is that this is the user's own configuration and they are
|
|
245
|
+
* entitled to it — turning "I do not want to be woken by Sev4 alerts" into
|
|
246
|
+
* something a human needs permission for is a worse product than the accident
|
|
247
|
+
* it prevents. The second is that a throwing hook would break the LEGITIMATE
|
|
248
|
+
* deletes too: a user leaving a project, an admin retiring a decommissioned
|
|
249
|
+
* number, a team cleaning up after a migration. The goal is not that nobody
|
|
250
|
+
* does this. It is that nobody does it by accident.
|
|
251
|
+
*
|
|
252
|
+
* "Is this person on call anywhere" is answered by OnCallReadinessService and
|
|
253
|
+
* is never re-derived here. A second answer to that question that disagreed
|
|
254
|
+
* with the readiness page would be worse than no answer: an admin who is told
|
|
255
|
+
* "you are not on call" by a delete dialog and "NotReachable on 3 policies" by
|
|
256
|
+
* the readiness table has no way to know which one to believe, and will end up
|
|
257
|
+
* believing the reassuring one.
|
|
258
|
+
* ---------------------------------------------------------------------------
|
|
259
|
+
*/
|
|
260
|
+
|
|
261
|
+
/*
|
|
262
|
+
* The channel vocabulary is ReadinessMethodType, re-exported under the name the
|
|
263
|
+
* deletion API uses. Sharing one enum with readiness (which in turn shares its
|
|
264
|
+
* literals with the fallback's `channelsUsed`) means an operator reading
|
|
265
|
+
* "Telegram" in a delete warning, "Telegram" in the readiness table and
|
|
266
|
+
* "notified via fallback (Telegram)" in an execution log is reading the same
|
|
267
|
+
* word about the same thing. It is also what lets a notification-method service
|
|
268
|
+
* name its own channel without importing the readiness module.
|
|
269
|
+
*/
|
|
270
|
+
export { ReadinessMethodType as NotificationMethodChannel };
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* One (ruleType x severity) cell that has at least one rule now and would have
|
|
274
|
+
* none after the deletion.
|
|
275
|
+
*
|
|
276
|
+
* A cell that merely loses SOME of its rules is not here. That is deliberate
|
|
277
|
+
* and it matches the coverage model readiness renders: a cell with two rules on
|
|
278
|
+
* two different methods is covered, a cell with one rule is covered, and only a
|
|
279
|
+
* cell with zero is a gap. Reporting "you will be paged on one fewer channel"
|
|
280
|
+
* with the same weight as "you will not be paged at all" is how a warning
|
|
281
|
+
* surface trains people to click through it.
|
|
282
|
+
*/
|
|
283
|
+
export interface CoverageLossCell {
|
|
284
|
+
ruleType: NotificationRuleType;
|
|
285
|
+
/**
|
|
286
|
+
* Undefined only for the two handoff rule types, which carry no severity.
|
|
287
|
+
* Those are reported separately (handoffNotificationsLost), so in practice
|
|
288
|
+
* every cell in `coverageLost` has one.
|
|
289
|
+
*/
|
|
290
|
+
severityId?: ObjectID | undefined;
|
|
291
|
+
severityName?: string | undefined;
|
|
292
|
+
/** How many rules this deletion takes out of this cell. Always >= 1. */
|
|
293
|
+
rulesRemoved: number;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Whether anything will still be able to page this user once the deletion has
|
|
298
|
+
* happened.
|
|
299
|
+
*
|
|
300
|
+
* Four values rather than a boolean because two of the four are things this
|
|
301
|
+
* preview knows FOR CERTAIN and two are not, and collapsing them would mean
|
|
302
|
+
* either inventing a false green or crying wolf at everybody.
|
|
303
|
+
*
|
|
304
|
+
* The certainty comes from one structural fact: a method must be verified to be
|
|
305
|
+
* used at all, and of the seven channels, Push, Email and Webhook have no
|
|
306
|
+
* project switch that can turn them off (see OnCallReadinessService.
|
|
307
|
+
* isChannelEnabled — the first two are zero-cost and the third is somebody
|
|
308
|
+
* else's endpoint). So "no verified method survives" is definitely unreachable,
|
|
309
|
+
* and "a verified Push/Email/Webhook survives" is definitely reachable. What is
|
|
310
|
+
* left over — a user whose surviving methods are all on the four paid channels
|
|
311
|
+
* — depends on project settings this preview does not read, and says so.
|
|
312
|
+
*/
|
|
313
|
+
export enum PostDeletionReachability {
|
|
314
|
+
/** A verified method on a channel no project setting can disable survives. */
|
|
315
|
+
Reachable = "Reachable",
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Verified methods survive, but every one of them is on a paid channel
|
|
319
|
+
* (SMS, Call, WhatsApp, Telegram) that the project can switch off. Whether
|
|
320
|
+
* this user can still be paged is a project setting, and the readiness page
|
|
321
|
+
* is the surface that knows.
|
|
322
|
+
*/
|
|
323
|
+
DependsOnProjectSettings = "DependsOnProjectSettings",
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Nothing verified survives. This deletion is the one that takes away the
|
|
327
|
+
* last way of reaching this person — no rule, and no fallback either, since
|
|
328
|
+
* the fallback needs a verified method too.
|
|
329
|
+
*/
|
|
330
|
+
NotReachable = "NotReachable",
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* They could not be paged before this deletion either. Worth its own value
|
|
334
|
+
* rather than being folded into NotReachable: the sentence an admin needs is
|
|
335
|
+
* "this was already broken", not "you are about to break it".
|
|
336
|
+
*/
|
|
337
|
+
AlreadyNotReachable = "AlreadyNotReachable",
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Readiness had no answer — the user is not a member of this project, or has
|
|
341
|
+
* no User row. Never guessed at, because a guess here is exactly the false
|
|
342
|
+
* green this whole feature exists to prevent.
|
|
343
|
+
*/
|
|
344
|
+
Unknown = "Unknown",
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export interface NotificationDeletionImpact {
|
|
348
|
+
projectId: ObjectID;
|
|
349
|
+
userId: ObjectID;
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Whether this user is reachable by ANY on-call policy in the project, and by
|
|
353
|
+
* which doors. Straight from OnCallReadinessService, never re-derived.
|
|
354
|
+
*/
|
|
355
|
+
isOnCallResponder: boolean;
|
|
356
|
+
reachedVia: Array<ResponderSource>;
|
|
357
|
+
|
|
358
|
+
/** How many UserNotificationRule rows this deletion would remove in total. */
|
|
359
|
+
rulesDeletedCount: number;
|
|
360
|
+
|
|
361
|
+
coverageLost: Array<CoverageLossCell>;
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* The shift-change notifications ("you are now on call") that would stop.
|
|
365
|
+
* Kept apart from coverageLost because nobody is waiting on one of these and
|
|
366
|
+
* mixing them in would put a missed Sev1 page and a missed courtesy note in
|
|
367
|
+
* the same list at the same weight.
|
|
368
|
+
*/
|
|
369
|
+
handoffNotificationsLost: Array<NotificationRuleType>;
|
|
370
|
+
|
|
371
|
+
reachability: PostDeletionReachability;
|
|
372
|
+
|
|
373
|
+
/** Verified methods that would remain. Zero is what makes NotReachable true. */
|
|
374
|
+
verifiedMethodCountAfterDeletion: number;
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Whether a page with no matching rule still falls back to this user's
|
|
378
|
+
* verified methods. It decides whether losing a cell means "the page arrives
|
|
379
|
+
* on the wrong channel" or "the page is dropped", which is the difference
|
|
380
|
+
* between an annoyance and an outage.
|
|
381
|
+
*/
|
|
382
|
+
isFallbackEnabled: boolean;
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* TRUE means this answer is INCOMPLETE — the read of this user's rules hit
|
|
386
|
+
* its page ceiling. Reported rather than swallowed because the two directions
|
|
387
|
+
* it can be wrong in are not symmetric: unread rules that would have SURVIVED
|
|
388
|
+
* make this over-warn (harmless), unread rules that would have been DELETED
|
|
389
|
+
* make it under-warn, which is the failure mode that matters.
|
|
390
|
+
*/
|
|
391
|
+
isTruncated: boolean;
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Sentences to show the human, most consequential first. Each one names a
|
|
395
|
+
* specific thing that is lost and what happens because of it — the same
|
|
396
|
+
* contract as OnCallReadinessService's `reasons`, because these two surfaces
|
|
397
|
+
* are read by the same person about the same configuration and must not
|
|
398
|
+
* sound like two different products.
|
|
399
|
+
*/
|
|
400
|
+
warnings: Array<string>;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** The notification method row a deletion preview is about, once resolved. */
|
|
404
|
+
interface DeletedNotificationMethod {
|
|
405
|
+
methodType: ReadinessMethodType;
|
|
406
|
+
userId: ObjectID;
|
|
407
|
+
/**
|
|
408
|
+
* Whether the row being deleted was itself usable. An unverified method is
|
|
409
|
+
* never used by anything, so deleting one cannot change reachability — and
|
|
410
|
+
* counting it as a loss would put a scary sentence in front of somebody
|
|
411
|
+
* cleaning up a typo'd phone number they never confirmed.
|
|
412
|
+
*/
|
|
413
|
+
isVerified: boolean;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** One (ruleType x severity) cell while the before/after picture is built. */
|
|
417
|
+
interface DeletionCellState {
|
|
418
|
+
ruleType: NotificationRuleType;
|
|
419
|
+
severityKind: SeverityKind;
|
|
420
|
+
severityId: string;
|
|
421
|
+
rulesBefore: number;
|
|
422
|
+
rulesRemoved: number;
|
|
423
|
+
/**
|
|
424
|
+
* Whether an opt-out row for this cell SURVIVES the deletion. A surviving
|
|
425
|
+
* opt-out means the silence is deliberate and losing the last rule is not a
|
|
426
|
+
* gap; an opt-out that is itself being deleted must not suppress the warning,
|
|
427
|
+
* because after the write there is neither a rule nor a stated intention.
|
|
428
|
+
*/
|
|
429
|
+
hasOptOut: boolean;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** The same before/after picture for a rule type that carries no severity. */
|
|
433
|
+
interface DeletionHandoffState {
|
|
434
|
+
rulesBefore: number;
|
|
435
|
+
rulesRemoved: number;
|
|
436
|
+
hasOptOut: boolean;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** A severity's display name and its position in the project's own ordering. */
|
|
440
|
+
interface DeletionSeverityRef {
|
|
441
|
+
name: string;
|
|
442
|
+
rank: number;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
enum SeverityKind {
|
|
446
|
+
Incident = "Incident",
|
|
447
|
+
Alert = "Alert",
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
interface PagingRuleTypeScope {
|
|
451
|
+
ruleType: NotificationRuleType;
|
|
452
|
+
severityKind: SeverityKind;
|
|
453
|
+
severityColumn: "incidentSeverityId" | "alertSeverityId";
|
|
454
|
+
/** Plural noun for the warning sentence: "no rule covers Sev1 incidents". */
|
|
455
|
+
subjectNoun: string;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/*
|
|
459
|
+
* Which severity column scopes which rule type. This is the same table
|
|
460
|
+
* OnCallReadinessService keeps as RULE_TYPE_SCOPES and it has to stay in
|
|
461
|
+
* agreement with it: an alert rule matched against an incident severity id
|
|
462
|
+
* matches nothing at runtime, so a preview that paired them would report a cell
|
|
463
|
+
* as covered by a rule that can never fire — the exact shape of Gap G, where
|
|
464
|
+
* episode rules were written with a NULL severity and were unreachable and
|
|
465
|
+
* invisible at the same time. The severity is always taken from the column the
|
|
466
|
+
* RULE TYPE dictates, never from whichever one happens to be populated.
|
|
467
|
+
*/
|
|
468
|
+
const PAGING_RULE_TYPE_SCOPES: Array<PagingRuleTypeScope> = [
|
|
469
|
+
{
|
|
470
|
+
ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
|
|
471
|
+
severityKind: SeverityKind.Incident,
|
|
472
|
+
severityColumn: "incidentSeverityId",
|
|
473
|
+
subjectNoun: "incidents",
|
|
474
|
+
},
|
|
475
|
+
{
|
|
476
|
+
ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT_EPISODE,
|
|
477
|
+
severityKind: SeverityKind.Incident,
|
|
478
|
+
severityColumn: "incidentSeverityId",
|
|
479
|
+
subjectNoun: "incident episodes",
|
|
480
|
+
},
|
|
481
|
+
{
|
|
482
|
+
ruleType: NotificationRuleType.ON_CALL_EXECUTED_ALERT,
|
|
483
|
+
severityKind: SeverityKind.Alert,
|
|
484
|
+
severityColumn: "alertSeverityId",
|
|
485
|
+
subjectNoun: "alerts",
|
|
486
|
+
},
|
|
487
|
+
{
|
|
488
|
+
ruleType: NotificationRuleType.ON_CALL_EXECUTED_ALERT_EPISODE,
|
|
489
|
+
severityKind: SeverityKind.Alert,
|
|
490
|
+
severityColumn: "alertSeverityId",
|
|
491
|
+
subjectNoun: "alert episodes",
|
|
492
|
+
},
|
|
493
|
+
];
|
|
494
|
+
|
|
495
|
+
/*
|
|
496
|
+
* The two rule types that are about the user's shift rather than about anything
|
|
497
|
+
* that fired. They carry no severity, so they are one cell each.
|
|
498
|
+
*/
|
|
499
|
+
const HANDOFF_RULE_TYPES: Array<NotificationRuleType> = [
|
|
500
|
+
NotificationRuleType.WHEN_USER_GOES_ON_CALL,
|
|
501
|
+
NotificationRuleType.WHEN_USER_GOES_OFF_CALL,
|
|
502
|
+
];
|
|
503
|
+
|
|
504
|
+
type ChannelListFunction = () => Array<string>;
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* The three channels no project setting can switch off. Push and Email are
|
|
508
|
+
* zero-cost and Webhook is somebody else's endpoint, so nothing gates them —
|
|
509
|
+
* which is what makes "a verified one of these survives" a CERTAIN answer to
|
|
510
|
+
* "can this person still be paged" rather than a hopeful one. Kept in step with
|
|
511
|
+
* OnCallReadinessService.isChannelEnabled, which returns true for exactly these
|
|
512
|
+
* three unconditionally.
|
|
513
|
+
*
|
|
514
|
+
* A FUNCTION rather than a module-level constant, and that is load-bearing
|
|
515
|
+
* rather than stylistic: this module and OnCallReadinessService import each
|
|
516
|
+
* other, so whichever one is loaded second sees the other's exports still
|
|
517
|
+
* empty. Reading ReadinessMethodType while this module is being evaluated
|
|
518
|
+
* therefore throws on ONE of the two load orders and not the other — a crash
|
|
519
|
+
* that depends on which file some unrelated caller happened to import first,
|
|
520
|
+
* which is about the worst possible failure to debug. Read on call, both orders
|
|
521
|
+
* are long since settled. The same rule applies to every enum below that comes
|
|
522
|
+
* from OnCallReadinessService.
|
|
523
|
+
*/
|
|
524
|
+
const channelsWithNoProjectSwitch: ChannelListFunction = (): Array<string> => {
|
|
525
|
+
return [
|
|
526
|
+
ReadinessMethodType.Push,
|
|
527
|
+
ReadinessMethodType.Email,
|
|
528
|
+
ReadinessMethodType.Webhook,
|
|
529
|
+
];
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
type ResponderSourceProseFunction = (source: ResponderSource) => string;
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* How each responder source reads in a sentence. The enum values are single
|
|
536
|
+
* words chosen for a chip; a warning has room to say what they mean, and
|
|
537
|
+
* "Override" on its own tells a user nothing about why they are on call.
|
|
538
|
+
*
|
|
539
|
+
* The map is built inside the call for the module-evaluation reason above —
|
|
540
|
+
* a computed key is read at definition time, so a module-level Record would
|
|
541
|
+
* carry exactly the same load-order crash. Typed as a full Record so that a new
|
|
542
|
+
* ResponderSource fails to compile here rather than rendering as a blank.
|
|
543
|
+
*/
|
|
544
|
+
const responderSourceProse: ResponderSourceProseFunction = (
|
|
545
|
+
source: ResponderSource,
|
|
546
|
+
): string => {
|
|
547
|
+
const prose: Record<ResponderSource, string> = {
|
|
548
|
+
[ResponderSource.Direct]: "directly on an escalation rule",
|
|
549
|
+
[ResponderSource.Team]: "through a team",
|
|
550
|
+
[ResponderSource.Schedule]: "through a schedule",
|
|
551
|
+
[ResponderSource.Override]: "through an override",
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
return prose[source];
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
/*
|
|
558
|
+
* Rows per page for the rule read, and a ceiling on how many pages one preview
|
|
559
|
+
* may take. LIMIT_PER_PROJECT is the largest read the database layer will
|
|
560
|
+
* serve, so it is the biggest page that survives a round trip. One user's rules
|
|
561
|
+
* are bounded by (rule types x severities x methods) and land far below one
|
|
562
|
+
* page in any real project; the loop exists so that the one project where that
|
|
563
|
+
* is not true gets a truthful answer instead of a silently truncated one.
|
|
564
|
+
*/
|
|
565
|
+
const DELETION_IMPACT_PAGE_SIZE: number = LIMIT_PER_PROJECT;
|
|
566
|
+
const MAX_DELETION_IMPACT_PAGES: number = 50;
|
|
567
|
+
|
|
87
568
|
export class Service extends DatabaseService<Model> {
|
|
88
569
|
public constructor() {
|
|
89
570
|
super(Model);
|
|
@@ -92,21 +573,7 @@ export class Service extends DatabaseService<Model> {
|
|
|
92
573
|
@CaptureSpan()
|
|
93
574
|
public async executeNotificationRuleItem(
|
|
94
575
|
userNotificationRuleId: ObjectID,
|
|
95
|
-
options:
|
|
96
|
-
projectId: ObjectID;
|
|
97
|
-
triggeredByIncidentId?: ObjectID | undefined;
|
|
98
|
-
triggeredByAlertId?: ObjectID | undefined;
|
|
99
|
-
triggeredByAlertEpisodeId?: ObjectID | undefined;
|
|
100
|
-
triggeredByIncidentEpisodeId?: ObjectID | undefined;
|
|
101
|
-
userNotificationEventType: UserNotificationEventType;
|
|
102
|
-
onCallPolicyExecutionLogId?: ObjectID | undefined;
|
|
103
|
-
onCallPolicyId: ObjectID | undefined;
|
|
104
|
-
onCallPolicyEscalationRuleId?: ObjectID | undefined;
|
|
105
|
-
userNotificationLogId: ObjectID;
|
|
106
|
-
userBelongsToTeamId?: ObjectID | undefined;
|
|
107
|
-
onCallDutyPolicyExecutionLogTimelineId?: ObjectID | undefined;
|
|
108
|
-
onCallScheduleId?: ObjectID | undefined;
|
|
109
|
-
},
|
|
576
|
+
options: ExecuteNotificationRuleOptions,
|
|
110
577
|
): Promise<void> {
|
|
111
578
|
/*
|
|
112
579
|
* Atomically claim this rule for this on-call log BEFORE sending, so two
|
|
@@ -132,36 +599,51 @@ export class Service extends DatabaseService<Model> {
|
|
|
132
599
|
select: {
|
|
133
600
|
_id: true,
|
|
134
601
|
userId: true,
|
|
602
|
+
/*
|
|
603
|
+
* Every method relation also selects its OWN userId, which none of the
|
|
604
|
+
* channel blocks below read. It is here for the ownership check that
|
|
605
|
+
* runs before delivery: the address a page is sent to comes from these
|
|
606
|
+
* relations, while whose page it is comes from the rule's userId, and
|
|
607
|
+
* nothing in the ORM ever compares the two. See
|
|
608
|
+
* getNotificationMethodsNotOwnedByRuleOwner.
|
|
609
|
+
*/
|
|
135
610
|
userCall: {
|
|
136
611
|
phone: true,
|
|
137
612
|
isVerified: true,
|
|
613
|
+
userId: true,
|
|
138
614
|
},
|
|
139
615
|
userSms: {
|
|
140
616
|
phone: true,
|
|
141
617
|
isVerified: true,
|
|
618
|
+
userId: true,
|
|
142
619
|
},
|
|
143
620
|
userWhatsApp: {
|
|
144
621
|
phone: true,
|
|
145
622
|
isVerified: true,
|
|
623
|
+
userId: true,
|
|
146
624
|
},
|
|
147
625
|
userTelegram: {
|
|
148
626
|
telegramChatId: true,
|
|
149
627
|
telegramUserHandle: true,
|
|
150
628
|
isVerified: true,
|
|
629
|
+
userId: true,
|
|
151
630
|
},
|
|
152
631
|
userWebhook: {
|
|
153
632
|
webhookUrl: true,
|
|
154
633
|
name: true,
|
|
155
634
|
secret: true,
|
|
635
|
+
userId: true,
|
|
156
636
|
},
|
|
157
637
|
userEmail: {
|
|
158
638
|
email: true,
|
|
159
639
|
isVerified: true,
|
|
640
|
+
userId: true,
|
|
160
641
|
},
|
|
161
642
|
userPush: {
|
|
162
643
|
deviceToken: true,
|
|
163
644
|
deviceType: true,
|
|
164
645
|
isVerified: true,
|
|
646
|
+
userId: true,
|
|
165
647
|
},
|
|
166
648
|
},
|
|
167
649
|
props: {
|
|
@@ -174,24 +656,163 @@ export class Service extends DatabaseService<Model> {
|
|
|
174
656
|
}
|
|
175
657
|
|
|
176
658
|
/*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
659
|
+
* The last line of defence, and the only one that survives every write path
|
|
660
|
+
* — including ones that do not exist yet.
|
|
661
|
+
*
|
|
662
|
+
* The write-side guards in UserNotificationRuleAdminService stop a rule
|
|
663
|
+
* whose ownership column and method relation name different people from
|
|
664
|
+
* being SAVED. This stops one that somehow exists from being ACTED ON: a
|
|
665
|
+
* row written before those guards landed, one written by internal code
|
|
666
|
+
* running as root, or one written through a path a future change forgets to
|
|
667
|
+
* route through them. Without it, a single bad row silently redirects a
|
|
668
|
+
* responder's pages for as long as nobody thinks to compare two columns
|
|
669
|
+
* that no screen shows side by side.
|
|
180
670
|
*/
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
671
|
+
const mismatchedChannels: Array<string> =
|
|
672
|
+
this.getNotificationMethodsNotOwnedByRuleOwner(notificationRuleItem);
|
|
673
|
+
|
|
674
|
+
if (mismatchedChannels.length > 0) {
|
|
675
|
+
await this.recordMismatchedNotificationMethod(
|
|
676
|
+
notificationRuleItem,
|
|
677
|
+
options,
|
|
678
|
+
mismatchedChannels,
|
|
184
679
|
);
|
|
185
680
|
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
await this.deliverNotificationForRule(notificationRuleItem, options);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/*
|
|
688
|
+
* Which of a rule's method relations are owned by somebody other than the
|
|
689
|
+
* rule itself.
|
|
690
|
+
*
|
|
691
|
+
* Only a method whose userId was actually LOADED and actually DISAGREES is
|
|
692
|
+
* reported. An unselected column arrives as `undefined`, and reading absence
|
|
693
|
+
* as disagreement would turn this guard into a page-dropping machine on every
|
|
694
|
+
* caller that does not select userId — precisely the failure this whole epic
|
|
695
|
+
* exists to eliminate. Silence here means "no evidence of a mismatch", which
|
|
696
|
+
* is the only safe default for a check that can suppress a page.
|
|
697
|
+
*/
|
|
698
|
+
private getNotificationMethodsNotOwnedByRuleOwner(
|
|
699
|
+
notificationRuleItem: Model,
|
|
700
|
+
): Array<string> {
|
|
701
|
+
const ruleOwnerUserId: ObjectID | undefined = notificationRuleItem.userId;
|
|
702
|
+
|
|
703
|
+
if (!ruleOwnerUserId) {
|
|
704
|
+
/*
|
|
705
|
+
* An unowned rule cannot be paged for anybody in the first place — the
|
|
706
|
+
* caller found it by id, not by owner — so there is no owner to compare
|
|
707
|
+
* against and nothing to report.
|
|
708
|
+
*/
|
|
709
|
+
return [];
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const methodOwners: Array<{
|
|
713
|
+
label: string;
|
|
714
|
+
ownerUserId: ObjectID | undefined;
|
|
715
|
+
}> = [
|
|
716
|
+
{ label: "Email", ownerUserId: notificationRuleItem.userEmail?.userId },
|
|
717
|
+
{ label: "SMS", ownerUserId: notificationRuleItem.userSms?.userId },
|
|
718
|
+
{ label: "Call", ownerUserId: notificationRuleItem.userCall?.userId },
|
|
719
|
+
{
|
|
720
|
+
label: "WhatsApp",
|
|
721
|
+
ownerUserId: notificationRuleItem.userWhatsApp?.userId,
|
|
722
|
+
},
|
|
723
|
+
{
|
|
724
|
+
label: "Telegram",
|
|
725
|
+
ownerUserId: notificationRuleItem.userTelegram?.userId,
|
|
726
|
+
},
|
|
727
|
+
{ label: "Push", ownerUserId: notificationRuleItem.userPush?.userId },
|
|
728
|
+
{
|
|
729
|
+
label: "Webhook",
|
|
730
|
+
ownerUserId: notificationRuleItem.userWebhook?.userId,
|
|
731
|
+
},
|
|
732
|
+
];
|
|
733
|
+
|
|
734
|
+
const mismatched: Array<string> = [];
|
|
735
|
+
|
|
736
|
+
for (const methodOwner of methodOwners) {
|
|
737
|
+
if (
|
|
738
|
+
methodOwner.ownerUserId &&
|
|
739
|
+
methodOwner.ownerUserId.toString() !== ruleOwnerUserId.toString()
|
|
740
|
+
) {
|
|
741
|
+
mismatched.push(methodOwner.label);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
return mismatched;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/*
|
|
749
|
+
* Refuse the whole rule, not merely the offending channel.
|
|
750
|
+
*
|
|
751
|
+
* A rule with a foreign method on it is not a rule with one bad field; it is
|
|
752
|
+
* a row somebody wrote to redirect a page, and delivering its other channels
|
|
753
|
+
* would let the row keep working well enough to escape notice. The timeline
|
|
754
|
+
* row is the point: it is the surface a responder and an operator both read,
|
|
755
|
+
* and it names the channel so the mismatch can be found and repaired rather
|
|
756
|
+
* than merely felt as a page that never arrived.
|
|
757
|
+
*/
|
|
758
|
+
private async recordMismatchedNotificationMethod(
|
|
759
|
+
notificationRuleItem: Model,
|
|
760
|
+
options: ExecuteNotificationRuleOptions,
|
|
761
|
+
mismatchedChannels: Array<string>,
|
|
762
|
+
): Promise<void> {
|
|
763
|
+
logger.error(
|
|
764
|
+
`Notification rule ${notificationRuleItem.id?.toString()} was not executed: its ${mismatchedChannels.join(
|
|
765
|
+
", ",
|
|
766
|
+
)} notification method does not belong to the user the rule belongs to (${notificationRuleItem.userId?.toString()}).`,
|
|
767
|
+
);
|
|
768
|
+
|
|
769
|
+
const logTimelineItem: UserOnCallLogTimeline = this.buildLogTimelineItem(
|
|
770
|
+
notificationRuleItem,
|
|
771
|
+
options,
|
|
772
|
+
);
|
|
773
|
+
|
|
774
|
+
logTimelineItem.status = UserNotificationStatus.Error;
|
|
775
|
+
logTimelineItem.statusMessage = `Notification not sent because the ${mismatchedChannels.join(
|
|
776
|
+
", ",
|
|
777
|
+
)} notification method on this rule belongs to a different user. Please review this notification rule.`;
|
|
778
|
+
|
|
779
|
+
await UserOnCallLogTimelineService.create({
|
|
780
|
+
data: logTimelineItem,
|
|
781
|
+
props: {
|
|
782
|
+
isRoot: true,
|
|
783
|
+
},
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/*
|
|
788
|
+
* Build the timeline row every channel block stamps its status onto.
|
|
789
|
+
*
|
|
790
|
+
* Callers keep ONE instance and mutate it, because after the first create()
|
|
791
|
+
* the instance carries an _id and a second create() with it UPDATEs the row
|
|
792
|
+
* it already wrote instead of inserting a new one. Anything that needs a row
|
|
793
|
+
* genuinely independent of the delivery attempts (the fell-through guard
|
|
794
|
+
* below) must therefore call this again for a fresh instance rather than
|
|
795
|
+
* reuse the one the channel blocks have been writing to.
|
|
796
|
+
*/
|
|
797
|
+
private buildLogTimelineItem(
|
|
798
|
+
notificationRuleItem: Model,
|
|
799
|
+
options: ExecuteNotificationRuleOptions,
|
|
800
|
+
): UserOnCallLogTimeline {
|
|
186
801
|
const logTimelineItem: UserOnCallLogTimeline = new UserOnCallLogTimeline();
|
|
187
802
|
logTimelineItem.projectId = options.projectId;
|
|
188
803
|
logTimelineItem.userNotificationLogId = options.userNotificationLogId;
|
|
189
|
-
logTimelineItem.userNotificationRuleId = userNotificationRuleId;
|
|
190
|
-
logTimelineItem.userNotificationLogId = options.userNotificationLogId;
|
|
191
804
|
logTimelineItem.userId = notificationRuleItem.userId!;
|
|
192
805
|
logTimelineItem.userNotificationEventType =
|
|
193
806
|
options.userNotificationEventType;
|
|
194
807
|
|
|
808
|
+
/*
|
|
809
|
+
* The fallback delivers through rules it builds in memory and never saves,
|
|
810
|
+
* so there is not always a rule id to point the row at.
|
|
811
|
+
*/
|
|
812
|
+
if (notificationRuleItem.id) {
|
|
813
|
+
logTimelineItem.userNotificationRuleId = notificationRuleItem.id;
|
|
814
|
+
}
|
|
815
|
+
|
|
195
816
|
if (options.userBelongsToTeamId) {
|
|
196
817
|
logTimelineItem.userBelongsToTeamId = options.userBelongsToTeamId;
|
|
197
818
|
}
|
|
@@ -233,6 +854,56 @@ export class Service extends DatabaseService<Model> {
|
|
|
233
854
|
options.onCallDutyPolicyExecutionLogTimelineId;
|
|
234
855
|
}
|
|
235
856
|
|
|
857
|
+
return logTimelineItem;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/*
|
|
861
|
+
* The delivery half of executeNotificationRuleItem: given a rule that is
|
|
862
|
+
* already loaded with its method relations, decide what to send on which
|
|
863
|
+
* channel and hand it to the senders.
|
|
864
|
+
*
|
|
865
|
+
* It is split out from the public method so executeFallbackNotification can
|
|
866
|
+
* reuse it with a rule it assembled in memory and never persisted. The claim
|
|
867
|
+
* and the rule lookup that the public method does first are meaningless for a
|
|
868
|
+
* rule that does not exist in the database; everything from here down is
|
|
869
|
+
* exactly what the fallback needs.
|
|
870
|
+
*
|
|
871
|
+
* Returns whether a page was actually handed to a sender, which the fallback
|
|
872
|
+
* needs and the normal path ignores. Resolving without throwing is NOT the
|
|
873
|
+
* same as having sent something: a rule whose channel has no block for this
|
|
874
|
+
* event type falls all the way through to the guard at the bottom, writes an
|
|
875
|
+
* Error row and sends nothing. A caller that read "did not throw" as "paged"
|
|
876
|
+
* would name a channel the responder never heard from.
|
|
877
|
+
*/
|
|
878
|
+
private async deliverNotificationForRule(
|
|
879
|
+
notificationRuleItem: Model,
|
|
880
|
+
options: ExecuteNotificationRuleOptions,
|
|
881
|
+
): Promise<boolean> {
|
|
882
|
+
/*
|
|
883
|
+
* If the project has a default Twilio config set, use it for all
|
|
884
|
+
* team-member SMS and Calls in this rule. Otherwise the global config
|
|
885
|
+
* is used by the notification service.
|
|
886
|
+
*/
|
|
887
|
+
const projectTwilioConfig: TwilioConfig | undefined =
|
|
888
|
+
await ProjectCallSMSConfigService.getProjectDefaultTwilioConfig(
|
|
889
|
+
options.projectId,
|
|
890
|
+
);
|
|
891
|
+
|
|
892
|
+
const logTimelineItem: UserOnCallLogTimeline = this.buildLogTimelineItem(
|
|
893
|
+
notificationRuleItem,
|
|
894
|
+
options,
|
|
895
|
+
);
|
|
896
|
+
|
|
897
|
+
/*
|
|
898
|
+
* Which channels this rule could actually deliver on, and whether any block
|
|
899
|
+
* below matched the event type. If a channel is contactable but no branch
|
|
900
|
+
* claimed the event, the page vanishes without a trace — the guard at the
|
|
901
|
+
* end of this method turns that into a visible Error row.
|
|
902
|
+
*/
|
|
903
|
+
const contactableChannels: Array<string> =
|
|
904
|
+
this.getContactableChannelNames(notificationRuleItem);
|
|
905
|
+
let deliveryAttempted: boolean = false;
|
|
906
|
+
|
|
236
907
|
// add status and status message and save.
|
|
237
908
|
|
|
238
909
|
let incident: Incident | null = null;
|
|
@@ -382,6 +1053,7 @@ export class Service extends DatabaseService<Model> {
|
|
|
382
1053
|
alert
|
|
383
1054
|
) {
|
|
384
1055
|
// create an error log.
|
|
1056
|
+
deliveryAttempted = true;
|
|
385
1057
|
logTimelineItem.status = UserNotificationStatus.Sending;
|
|
386
1058
|
logTimelineItem.statusMessage = `Sending email to ${notificationRuleItem.userEmail?.email.toString()}`;
|
|
387
1059
|
logTimelineItem.userEmailId = notificationRuleItem.userEmail.id!;
|
|
@@ -435,6 +1107,7 @@ export class Service extends DatabaseService<Model> {
|
|
|
435
1107
|
incident
|
|
436
1108
|
) {
|
|
437
1109
|
// create an error log.
|
|
1110
|
+
deliveryAttempted = true;
|
|
438
1111
|
logTimelineItem.status = UserNotificationStatus.Sending;
|
|
439
1112
|
logTimelineItem.statusMessage = `Sending email to ${notificationRuleItem.userEmail?.email.toString()}`;
|
|
440
1113
|
logTimelineItem.userEmailId = notificationRuleItem.userEmail.id!;
|
|
@@ -487,6 +1160,7 @@ export class Service extends DatabaseService<Model> {
|
|
|
487
1160
|
UserNotificationEventType.AlertEpisodeCreated &&
|
|
488
1161
|
alertEpisode
|
|
489
1162
|
) {
|
|
1163
|
+
deliveryAttempted = true;
|
|
490
1164
|
logTimelineItem.status = UserNotificationStatus.Sending;
|
|
491
1165
|
logTimelineItem.statusMessage = `Sending email to ${notificationRuleItem.userEmail?.email.toString()}`;
|
|
492
1166
|
logTimelineItem.userEmailId = notificationRuleItem.userEmail.id!;
|
|
@@ -530,6 +1204,61 @@ export class Service extends DatabaseService<Model> {
|
|
|
530
1204
|
});
|
|
531
1205
|
});
|
|
532
1206
|
}
|
|
1207
|
+
|
|
1208
|
+
// send email for incident episode
|
|
1209
|
+
if (
|
|
1210
|
+
options.userNotificationEventType ===
|
|
1211
|
+
UserNotificationEventType.IncidentEpisodeCreated &&
|
|
1212
|
+
incidentEpisode
|
|
1213
|
+
) {
|
|
1214
|
+
deliveryAttempted = true;
|
|
1215
|
+
logTimelineItem.status = UserNotificationStatus.Sending;
|
|
1216
|
+
logTimelineItem.statusMessage = `Sending email to ${notificationRuleItem.userEmail?.email.toString()}`;
|
|
1217
|
+
logTimelineItem.userEmailId = notificationRuleItem.userEmail.id!;
|
|
1218
|
+
|
|
1219
|
+
const updatedLog: UserOnCallLogTimeline =
|
|
1220
|
+
await UserOnCallLogTimelineService.create({
|
|
1221
|
+
data: logTimelineItem,
|
|
1222
|
+
props: {
|
|
1223
|
+
isRoot: true,
|
|
1224
|
+
},
|
|
1225
|
+
});
|
|
1226
|
+
|
|
1227
|
+
const emailMessage: EmailMessage =
|
|
1228
|
+
await this.generateEmailTemplateForIncidentEpisodeCreated(
|
|
1229
|
+
notificationRuleItem.userEmail?.email,
|
|
1230
|
+
incidentEpisode,
|
|
1231
|
+
updatedLog.id!,
|
|
1232
|
+
);
|
|
1233
|
+
|
|
1234
|
+
/*
|
|
1235
|
+
* No incidentEpisodeId is passed: MailService.sendMail accepts the key
|
|
1236
|
+
* in its options type but never serialises it onto the request body, so
|
|
1237
|
+
* passing it would look like a link that does not exist.
|
|
1238
|
+
*/
|
|
1239
|
+
MailService.sendMail(emailMessage, {
|
|
1240
|
+
userOnCallLogTimelineId: updatedLog.id!,
|
|
1241
|
+
projectId: options.projectId,
|
|
1242
|
+
userId: notificationRuleItem.userId!,
|
|
1243
|
+
onCallPolicyId: options.onCallPolicyId,
|
|
1244
|
+
onCallPolicyEscalationRuleId: options.onCallPolicyEscalationRuleId,
|
|
1245
|
+
teamId: options.userBelongsToTeamId,
|
|
1246
|
+
onCallDutyPolicyExecutionLogTimelineId:
|
|
1247
|
+
options.onCallDutyPolicyExecutionLogTimelineId,
|
|
1248
|
+
onCallScheduleId: options.onCallScheduleId,
|
|
1249
|
+
}).catch(async (err: Error) => {
|
|
1250
|
+
await UserOnCallLogTimelineService.updateOneById({
|
|
1251
|
+
id: updatedLog.id!,
|
|
1252
|
+
data: {
|
|
1253
|
+
status: UserNotificationStatus.Error,
|
|
1254
|
+
statusMessage: err.message || "Error sending email.",
|
|
1255
|
+
},
|
|
1256
|
+
props: {
|
|
1257
|
+
isRoot: true,
|
|
1258
|
+
},
|
|
1259
|
+
});
|
|
1260
|
+
});
|
|
1261
|
+
}
|
|
533
1262
|
}
|
|
534
1263
|
|
|
535
1264
|
// if you have an email but is not verified, then create a log.
|
|
@@ -561,6 +1290,7 @@ export class Service extends DatabaseService<Model> {
|
|
|
561
1290
|
alert
|
|
562
1291
|
) {
|
|
563
1292
|
// create an error log.
|
|
1293
|
+
deliveryAttempted = true;
|
|
564
1294
|
logTimelineItem.status = UserNotificationStatus.Sending;
|
|
565
1295
|
logTimelineItem.statusMessage = `Sending SMS to ${notificationRuleItem.userSms?.phone.toString()}.`;
|
|
566
1296
|
logTimelineItem.userSmsId = notificationRuleItem.userSms.id!;
|
|
@@ -614,6 +1344,7 @@ export class Service extends DatabaseService<Model> {
|
|
|
614
1344
|
incident
|
|
615
1345
|
) {
|
|
616
1346
|
// create an error log.
|
|
1347
|
+
deliveryAttempted = true;
|
|
617
1348
|
logTimelineItem.status = UserNotificationStatus.Sending;
|
|
618
1349
|
logTimelineItem.statusMessage = `Sending SMS to ${notificationRuleItem.userSms?.phone.toString()}.`;
|
|
619
1350
|
logTimelineItem.userSmsId = notificationRuleItem.userSms.id!;
|
|
@@ -667,6 +1398,7 @@ export class Service extends DatabaseService<Model> {
|
|
|
667
1398
|
UserNotificationEventType.AlertEpisodeCreated &&
|
|
668
1399
|
alertEpisode
|
|
669
1400
|
) {
|
|
1401
|
+
deliveryAttempted = true;
|
|
670
1402
|
logTimelineItem.status = UserNotificationStatus.Sending;
|
|
671
1403
|
logTimelineItem.statusMessage = `Sending SMS to ${notificationRuleItem.userSms?.phone.toString()}.`;
|
|
672
1404
|
logTimelineItem.userSmsId = notificationRuleItem.userSms.id!;
|
|
@@ -711,6 +1443,61 @@ export class Service extends DatabaseService<Model> {
|
|
|
711
1443
|
});
|
|
712
1444
|
});
|
|
713
1445
|
}
|
|
1446
|
+
|
|
1447
|
+
// send sms for incident episode
|
|
1448
|
+
if (
|
|
1449
|
+
options.userNotificationEventType ===
|
|
1450
|
+
UserNotificationEventType.IncidentEpisodeCreated &&
|
|
1451
|
+
incidentEpisode
|
|
1452
|
+
) {
|
|
1453
|
+
deliveryAttempted = true;
|
|
1454
|
+
logTimelineItem.status = UserNotificationStatus.Sending;
|
|
1455
|
+
logTimelineItem.statusMessage = `Sending SMS to ${notificationRuleItem.userSms?.phone.toString()}.`;
|
|
1456
|
+
logTimelineItem.userSmsId = notificationRuleItem.userSms.id!;
|
|
1457
|
+
|
|
1458
|
+
const updatedLog: UserOnCallLogTimeline =
|
|
1459
|
+
await UserOnCallLogTimelineService.create({
|
|
1460
|
+
data: logTimelineItem,
|
|
1461
|
+
props: {
|
|
1462
|
+
isRoot: true,
|
|
1463
|
+
},
|
|
1464
|
+
});
|
|
1465
|
+
|
|
1466
|
+
const smsMessage: SMS =
|
|
1467
|
+
await this.generateSmsTemplateForIncidentEpisodeCreated(
|
|
1468
|
+
notificationRuleItem.userSms.phone,
|
|
1469
|
+
incidentEpisode,
|
|
1470
|
+
updatedLog.id!,
|
|
1471
|
+
);
|
|
1472
|
+
|
|
1473
|
+
/*
|
|
1474
|
+
* SmsService accepts incidentEpisodeId but drops it on the floor when
|
|
1475
|
+
* building the request body, so it is deliberately not passed here.
|
|
1476
|
+
*/
|
|
1477
|
+
SmsService.sendSms(smsMessage, {
|
|
1478
|
+
projectId: incidentEpisode.projectId,
|
|
1479
|
+
customTwilioConfig: projectTwilioConfig,
|
|
1480
|
+
userOnCallLogTimelineId: updatedLog.id!,
|
|
1481
|
+
userId: notificationRuleItem.userId!,
|
|
1482
|
+
onCallPolicyId: options.onCallPolicyId,
|
|
1483
|
+
onCallPolicyEscalationRuleId: options.onCallPolicyEscalationRuleId,
|
|
1484
|
+
teamId: options.userBelongsToTeamId,
|
|
1485
|
+
onCallDutyPolicyExecutionLogTimelineId:
|
|
1486
|
+
options.onCallDutyPolicyExecutionLogTimelineId,
|
|
1487
|
+
onCallScheduleId: options.onCallScheduleId,
|
|
1488
|
+
}).catch(async (err: Error) => {
|
|
1489
|
+
await UserOnCallLogTimelineService.updateOneById({
|
|
1490
|
+
id: updatedLog.id!,
|
|
1491
|
+
data: {
|
|
1492
|
+
status: UserNotificationStatus.Error,
|
|
1493
|
+
statusMessage: err.message || "Error sending SMS.",
|
|
1494
|
+
},
|
|
1495
|
+
props: {
|
|
1496
|
+
isRoot: true,
|
|
1497
|
+
},
|
|
1498
|
+
});
|
|
1499
|
+
});
|
|
1500
|
+
}
|
|
714
1501
|
}
|
|
715
1502
|
|
|
716
1503
|
if (
|
|
@@ -738,6 +1525,7 @@ export class Service extends DatabaseService<Model> {
|
|
|
738
1525
|
UserNotificationEventType.AlertCreated &&
|
|
739
1526
|
alert
|
|
740
1527
|
) {
|
|
1528
|
+
deliveryAttempted = true;
|
|
741
1529
|
logTimelineItem.status = UserNotificationStatus.Sending;
|
|
742
1530
|
logTimelineItem.statusMessage = `Sending WhatsApp message to ${notificationRuleItem.userWhatsApp?.phone.toString()}.`;
|
|
743
1531
|
logTimelineItem.userWhatsAppId = notificationRuleItem.userWhatsApp.id!;
|
|
@@ -787,6 +1575,7 @@ export class Service extends DatabaseService<Model> {
|
|
|
787
1575
|
UserNotificationEventType.IncidentCreated &&
|
|
788
1576
|
incident
|
|
789
1577
|
) {
|
|
1578
|
+
deliveryAttempted = true;
|
|
790
1579
|
logTimelineItem.status = UserNotificationStatus.Sending;
|
|
791
1580
|
logTimelineItem.statusMessage = `Sending WhatsApp message to ${notificationRuleItem.userWhatsApp?.phone.toString()}.`;
|
|
792
1581
|
logTimelineItem.userWhatsAppId = notificationRuleItem.userWhatsApp.id!;
|
|
@@ -837,6 +1626,7 @@ export class Service extends DatabaseService<Model> {
|
|
|
837
1626
|
UserNotificationEventType.AlertEpisodeCreated &&
|
|
838
1627
|
alertEpisode
|
|
839
1628
|
) {
|
|
1629
|
+
deliveryAttempted = true;
|
|
840
1630
|
logTimelineItem.status = UserNotificationStatus.Sending;
|
|
841
1631
|
logTimelineItem.statusMessage = `Sending WhatsApp message to ${notificationRuleItem.userWhatsApp?.phone.toString()}.`;
|
|
842
1632
|
logTimelineItem.userWhatsAppId = notificationRuleItem.userWhatsApp.id!;
|
|
@@ -880,20 +1670,74 @@ export class Service extends DatabaseService<Model> {
|
|
|
880
1670
|
});
|
|
881
1671
|
});
|
|
882
1672
|
}
|
|
883
|
-
}
|
|
884
1673
|
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
1674
|
+
// send WhatsApp for incident episode
|
|
1675
|
+
if (
|
|
1676
|
+
options.userNotificationEventType ===
|
|
1677
|
+
UserNotificationEventType.IncidentEpisodeCreated &&
|
|
1678
|
+
incidentEpisode
|
|
1679
|
+
) {
|
|
1680
|
+
deliveryAttempted = true;
|
|
1681
|
+
logTimelineItem.status = UserNotificationStatus.Sending;
|
|
1682
|
+
logTimelineItem.statusMessage = `Sending WhatsApp message to ${notificationRuleItem.userWhatsApp?.phone.toString()}.`;
|
|
1683
|
+
logTimelineItem.userWhatsAppId = notificationRuleItem.userWhatsApp.id!;
|
|
892
1684
|
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
1685
|
+
const updatedLog: UserOnCallLogTimeline =
|
|
1686
|
+
await UserOnCallLogTimelineService.create({
|
|
1687
|
+
data: logTimelineItem,
|
|
1688
|
+
props: {
|
|
1689
|
+
isRoot: true,
|
|
1690
|
+
},
|
|
1691
|
+
});
|
|
1692
|
+
|
|
1693
|
+
const whatsAppMessage: WhatsAppMessage =
|
|
1694
|
+
await this.generateWhatsAppTemplateForIncidentEpisodeCreated(
|
|
1695
|
+
notificationRuleItem.userWhatsApp.phone,
|
|
1696
|
+
incidentEpisode,
|
|
1697
|
+
updatedLog.id!,
|
|
1698
|
+
);
|
|
1699
|
+
|
|
1700
|
+
/*
|
|
1701
|
+
* WhatsAppService accepts incidentEpisodeId but never writes it onto
|
|
1702
|
+
* the request body, so it is deliberately not passed here.
|
|
1703
|
+
*/
|
|
1704
|
+
WhatsAppService.sendWhatsAppMessage(whatsAppMessage, {
|
|
1705
|
+
projectId: incidentEpisode.projectId,
|
|
1706
|
+
userOnCallLogTimelineId: updatedLog.id!,
|
|
1707
|
+
userId: notificationRuleItem.userId!,
|
|
1708
|
+
onCallPolicyId: options.onCallPolicyId,
|
|
1709
|
+
onCallPolicyEscalationRuleId: options.onCallPolicyEscalationRuleId,
|
|
1710
|
+
teamId: options.userBelongsToTeamId,
|
|
1711
|
+
onCallDutyPolicyExecutionLogTimelineId:
|
|
1712
|
+
options.onCallDutyPolicyExecutionLogTimelineId,
|
|
1713
|
+
onCallScheduleId: options.onCallScheduleId,
|
|
1714
|
+
}).catch(async (err: Error) => {
|
|
1715
|
+
await UserOnCallLogTimelineService.updateOneById({
|
|
1716
|
+
id: updatedLog.id!,
|
|
1717
|
+
data: {
|
|
1718
|
+
status: UserNotificationStatus.Error,
|
|
1719
|
+
statusMessage: err.message || "Error sending WhatsApp message.",
|
|
1720
|
+
},
|
|
1721
|
+
props: {
|
|
1722
|
+
isRoot: true,
|
|
1723
|
+
},
|
|
1724
|
+
});
|
|
1725
|
+
});
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
if (
|
|
1730
|
+
notificationRuleItem.userWhatsApp?.phone &&
|
|
1731
|
+
!notificationRuleItem.userWhatsApp?.isVerified
|
|
1732
|
+
) {
|
|
1733
|
+
logTimelineItem.status = UserNotificationStatus.Error;
|
|
1734
|
+
logTimelineItem.statusMessage = `WhatsApp message not sent because phone ${notificationRuleItem.userWhatsApp?.phone.toString()} is not verified.`;
|
|
1735
|
+
logTimelineItem.userWhatsAppId = notificationRuleItem.userWhatsApp.id!;
|
|
1736
|
+
|
|
1737
|
+
await UserOnCallLogTimelineService.create({
|
|
1738
|
+
data: logTimelineItem,
|
|
1739
|
+
props: {
|
|
1740
|
+
isRoot: true,
|
|
897
1741
|
},
|
|
898
1742
|
});
|
|
899
1743
|
}
|
|
@@ -908,6 +1752,7 @@ export class Service extends DatabaseService<Model> {
|
|
|
908
1752
|
UserNotificationEventType.AlertCreated &&
|
|
909
1753
|
alert
|
|
910
1754
|
) {
|
|
1755
|
+
deliveryAttempted = true;
|
|
911
1756
|
logTimelineItem.status = UserNotificationStatus.Sending;
|
|
912
1757
|
logTimelineItem.statusMessage = `Sending Telegram message.`;
|
|
913
1758
|
logTimelineItem.userTelegramId = notificationRuleItem.userTelegram.id!;
|
|
@@ -960,6 +1805,7 @@ export class Service extends DatabaseService<Model> {
|
|
|
960
1805
|
UserNotificationEventType.IncidentCreated &&
|
|
961
1806
|
incident
|
|
962
1807
|
) {
|
|
1808
|
+
deliveryAttempted = true;
|
|
963
1809
|
logTimelineItem.status = UserNotificationStatus.Sending;
|
|
964
1810
|
logTimelineItem.statusMessage = `Sending Telegram message.`;
|
|
965
1811
|
logTimelineItem.userTelegramId = notificationRuleItem.userTelegram.id!;
|
|
@@ -1012,6 +1858,7 @@ export class Service extends DatabaseService<Model> {
|
|
|
1012
1858
|
UserNotificationEventType.AlertEpisodeCreated &&
|
|
1013
1859
|
alertEpisode
|
|
1014
1860
|
) {
|
|
1861
|
+
deliveryAttempted = true;
|
|
1015
1862
|
logTimelineItem.status = UserNotificationStatus.Sending;
|
|
1016
1863
|
logTimelineItem.statusMessage = `Sending Telegram message.`;
|
|
1017
1864
|
logTimelineItem.userTelegramId = notificationRuleItem.userTelegram.id!;
|
|
@@ -1058,6 +1905,62 @@ export class Service extends DatabaseService<Model> {
|
|
|
1058
1905
|
});
|
|
1059
1906
|
});
|
|
1060
1907
|
}
|
|
1908
|
+
|
|
1909
|
+
if (
|
|
1910
|
+
options.userNotificationEventType ===
|
|
1911
|
+
UserNotificationEventType.IncidentEpisodeCreated &&
|
|
1912
|
+
incidentEpisode
|
|
1913
|
+
) {
|
|
1914
|
+
deliveryAttempted = true;
|
|
1915
|
+
logTimelineItem.status = UserNotificationStatus.Sending;
|
|
1916
|
+
logTimelineItem.statusMessage = `Sending Telegram message.`;
|
|
1917
|
+
logTimelineItem.userTelegramId = notificationRuleItem.userTelegram.id!;
|
|
1918
|
+
|
|
1919
|
+
const updatedLog: UserOnCallLogTimeline =
|
|
1920
|
+
await UserOnCallLogTimelineService.create({
|
|
1921
|
+
data: logTimelineItem,
|
|
1922
|
+
props: {
|
|
1923
|
+
isRoot: true,
|
|
1924
|
+
},
|
|
1925
|
+
});
|
|
1926
|
+
|
|
1927
|
+
const telegramMessage: TelegramMessage = {
|
|
1928
|
+
to: notificationRuleItem.userTelegram.telegramChatId,
|
|
1929
|
+
body: await this.generateTelegramBodyForIncidentEpisodeCreated(
|
|
1930
|
+
incidentEpisode,
|
|
1931
|
+
updatedLog.id!,
|
|
1932
|
+
),
|
|
1933
|
+
parseMode: "HTML",
|
|
1934
|
+
disableWebPagePreview: true,
|
|
1935
|
+
};
|
|
1936
|
+
|
|
1937
|
+
/*
|
|
1938
|
+
* TelegramService accepts incidentEpisodeId but never writes it onto
|
|
1939
|
+
* the request body, so it is deliberately not passed here.
|
|
1940
|
+
*/
|
|
1941
|
+
TelegramService.sendTelegramMessage(telegramMessage, {
|
|
1942
|
+
projectId: incidentEpisode.projectId,
|
|
1943
|
+
userOnCallLogTimelineId: updatedLog.id!,
|
|
1944
|
+
userId: notificationRuleItem.userId!,
|
|
1945
|
+
onCallPolicyId: options.onCallPolicyId,
|
|
1946
|
+
onCallPolicyEscalationRuleId: options.onCallPolicyEscalationRuleId,
|
|
1947
|
+
teamId: options.userBelongsToTeamId,
|
|
1948
|
+
onCallDutyPolicyExecutionLogTimelineId:
|
|
1949
|
+
options.onCallDutyPolicyExecutionLogTimelineId,
|
|
1950
|
+
onCallScheduleId: options.onCallScheduleId,
|
|
1951
|
+
}).catch(async (err: Error) => {
|
|
1952
|
+
await UserOnCallLogTimelineService.updateOneById({
|
|
1953
|
+
id: updatedLog.id!,
|
|
1954
|
+
data: {
|
|
1955
|
+
status: UserNotificationStatus.Error,
|
|
1956
|
+
statusMessage: err.message || "Error sending Telegram message.",
|
|
1957
|
+
},
|
|
1958
|
+
props: {
|
|
1959
|
+
isRoot: true,
|
|
1960
|
+
},
|
|
1961
|
+
});
|
|
1962
|
+
});
|
|
1963
|
+
}
|
|
1061
1964
|
}
|
|
1062
1965
|
|
|
1063
1966
|
if (
|
|
@@ -1094,6 +1997,7 @@ export class Service extends DatabaseService<Model> {
|
|
|
1094
1997
|
entityId?: ObjectID;
|
|
1095
1998
|
entityKind: "alert" | "incident" | "alertEpisode" | "incidentEpisode";
|
|
1096
1999
|
}): Promise<void> => {
|
|
2000
|
+
deliveryAttempted = true;
|
|
1097
2001
|
logTimelineItem.status = UserNotificationStatus.Sending;
|
|
1098
2002
|
logTimelineItem.statusMessage = `Sending webhook to ${webhookUrl}.`;
|
|
1099
2003
|
logTimelineItem.userWebhookId = userWebhookId;
|
|
@@ -1284,6 +2188,7 @@ export class Service extends DatabaseService<Model> {
|
|
|
1284
2188
|
alert
|
|
1285
2189
|
) {
|
|
1286
2190
|
// create an error log.
|
|
2191
|
+
deliveryAttempted = true;
|
|
1287
2192
|
logTimelineItem.status = UserNotificationStatus.Sending;
|
|
1288
2193
|
logTimelineItem.statusMessage = `Making a call to ${notificationRuleItem.userCall?.phone.toString()}.`;
|
|
1289
2194
|
logTimelineItem.userCallId = notificationRuleItem.userCall.id!;
|
|
@@ -1337,6 +2242,7 @@ export class Service extends DatabaseService<Model> {
|
|
|
1337
2242
|
incident
|
|
1338
2243
|
) {
|
|
1339
2244
|
// send call for incident
|
|
2245
|
+
deliveryAttempted = true;
|
|
1340
2246
|
logTimelineItem.status = UserNotificationStatus.Sending;
|
|
1341
2247
|
logTimelineItem.statusMessage = `Making a call to ${notificationRuleItem.userCall?.phone.toString()}.`;
|
|
1342
2248
|
logTimelineItem.userCallId = notificationRuleItem.userCall.id!;
|
|
@@ -1390,6 +2296,7 @@ export class Service extends DatabaseService<Model> {
|
|
|
1390
2296
|
UserNotificationEventType.AlertEpisodeCreated &&
|
|
1391
2297
|
alertEpisode
|
|
1392
2298
|
) {
|
|
2299
|
+
deliveryAttempted = true;
|
|
1393
2300
|
logTimelineItem.status = UserNotificationStatus.Sending;
|
|
1394
2301
|
logTimelineItem.statusMessage = `Making a call to ${notificationRuleItem.userCall?.phone.toString()}.`;
|
|
1395
2302
|
logTimelineItem.userCallId = notificationRuleItem.userCall.id!;
|
|
@@ -1434,6 +2341,61 @@ export class Service extends DatabaseService<Model> {
|
|
|
1434
2341
|
});
|
|
1435
2342
|
});
|
|
1436
2343
|
}
|
|
2344
|
+
|
|
2345
|
+
// send call for incident episode
|
|
2346
|
+
if (
|
|
2347
|
+
options.userNotificationEventType ===
|
|
2348
|
+
UserNotificationEventType.IncidentEpisodeCreated &&
|
|
2349
|
+
incidentEpisode
|
|
2350
|
+
) {
|
|
2351
|
+
deliveryAttempted = true;
|
|
2352
|
+
logTimelineItem.status = UserNotificationStatus.Sending;
|
|
2353
|
+
logTimelineItem.statusMessage = `Making a call to ${notificationRuleItem.userCall?.phone.toString()}.`;
|
|
2354
|
+
logTimelineItem.userCallId = notificationRuleItem.userCall.id!;
|
|
2355
|
+
|
|
2356
|
+
const updatedLog: UserOnCallLogTimeline =
|
|
2357
|
+
await UserOnCallLogTimelineService.create({
|
|
2358
|
+
data: logTimelineItem,
|
|
2359
|
+
props: {
|
|
2360
|
+
isRoot: true,
|
|
2361
|
+
},
|
|
2362
|
+
});
|
|
2363
|
+
|
|
2364
|
+
const callRequest: CallRequest =
|
|
2365
|
+
await this.generateCallTemplateForIncidentEpisodeCreated(
|
|
2366
|
+
notificationRuleItem.userCall?.phone,
|
|
2367
|
+
incidentEpisode,
|
|
2368
|
+
updatedLog.id!,
|
|
2369
|
+
);
|
|
2370
|
+
|
|
2371
|
+
/*
|
|
2372
|
+
* CallService accepts incidentEpisodeId but never writes it onto the
|
|
2373
|
+
* request body, so it is deliberately not passed here.
|
|
2374
|
+
*/
|
|
2375
|
+
CallService.makeCall(callRequest, {
|
|
2376
|
+
projectId: incidentEpisode.projectId,
|
|
2377
|
+
customTwilioConfig: projectTwilioConfig,
|
|
2378
|
+
userOnCallLogTimelineId: updatedLog.id!,
|
|
2379
|
+
userId: notificationRuleItem.userId!,
|
|
2380
|
+
onCallPolicyId: options.onCallPolicyId,
|
|
2381
|
+
onCallPolicyEscalationRuleId: options.onCallPolicyEscalationRuleId,
|
|
2382
|
+
teamId: options.userBelongsToTeamId,
|
|
2383
|
+
onCallDutyPolicyExecutionLogTimelineId:
|
|
2384
|
+
options.onCallDutyPolicyExecutionLogTimelineId,
|
|
2385
|
+
onCallScheduleId: options.onCallScheduleId,
|
|
2386
|
+
}).catch(async (err: Error) => {
|
|
2387
|
+
await UserOnCallLogTimelineService.updateOneById({
|
|
2388
|
+
id: updatedLog.id!,
|
|
2389
|
+
data: {
|
|
2390
|
+
status: UserNotificationStatus.Error,
|
|
2391
|
+
statusMessage: err.message || "Error making call.",
|
|
2392
|
+
},
|
|
2393
|
+
props: {
|
|
2394
|
+
isRoot: true,
|
|
2395
|
+
},
|
|
2396
|
+
});
|
|
2397
|
+
});
|
|
2398
|
+
}
|
|
1437
2399
|
}
|
|
1438
2400
|
|
|
1439
2401
|
if (
|
|
@@ -1464,6 +2426,7 @@ export class Service extends DatabaseService<Model> {
|
|
|
1464
2426
|
alert
|
|
1465
2427
|
) {
|
|
1466
2428
|
// create a log.
|
|
2429
|
+
deliveryAttempted = true;
|
|
1467
2430
|
logTimelineItem.status = UserNotificationStatus.Sending;
|
|
1468
2431
|
logTimelineItem.statusMessage = `Sending push notification to device.`;
|
|
1469
2432
|
logTimelineItem.userPushId = notificationRuleItem.userPush.id!;
|
|
@@ -1544,6 +2507,7 @@ export class Service extends DatabaseService<Model> {
|
|
|
1544
2507
|
incident
|
|
1545
2508
|
) {
|
|
1546
2509
|
// create a log.
|
|
2510
|
+
deliveryAttempted = true;
|
|
1547
2511
|
logTimelineItem.status = UserNotificationStatus.Sending;
|
|
1548
2512
|
logTimelineItem.statusMessage = `Sending push notification to device.`;
|
|
1549
2513
|
logTimelineItem.userPushId = notificationRuleItem.userPush.id!;
|
|
@@ -1623,6 +2587,7 @@ export class Service extends DatabaseService<Model> {
|
|
|
1623
2587
|
UserNotificationEventType.AlertEpisodeCreated &&
|
|
1624
2588
|
alertEpisode
|
|
1625
2589
|
) {
|
|
2590
|
+
deliveryAttempted = true;
|
|
1626
2591
|
logTimelineItem.status = UserNotificationStatus.Sending;
|
|
1627
2592
|
logTimelineItem.statusMessage = `Sending push notification to device.`;
|
|
1628
2593
|
logTimelineItem.userPushId = notificationRuleItem.userPush.id!;
|
|
@@ -1701,6 +2666,7 @@ export class Service extends DatabaseService<Model> {
|
|
|
1701
2666
|
UserNotificationEventType.IncidentEpisodeCreated &&
|
|
1702
2667
|
incidentEpisode
|
|
1703
2668
|
) {
|
|
2669
|
+
deliveryAttempted = true;
|
|
1704
2670
|
logTimelineItem.status = UserNotificationStatus.Sending;
|
|
1705
2671
|
logTimelineItem.statusMessage = `Sending push notification to device.`;
|
|
1706
2672
|
logTimelineItem.userPushId = notificationRuleItem.userPush.id!;
|
|
@@ -1788,107 +2754,529 @@ export class Service extends DatabaseService<Model> {
|
|
|
1788
2754
|
},
|
|
1789
2755
|
});
|
|
1790
2756
|
}
|
|
1791
|
-
}
|
|
1792
2757
|
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
2758
|
+
/*
|
|
2759
|
+
* The fell-through guard.
|
|
2760
|
+
*
|
|
2761
|
+
* Gap F was a whole class of lost pages: a contactable channel, an event
|
|
2762
|
+
* type that no block in that channel branched on, and therefore neither a
|
|
2763
|
+
* send nor an error row — the responder was simply never told, and nothing
|
|
2764
|
+
* anywhere recorded that. Rather than trust that every future event type
|
|
2765
|
+
* gets wired into all seven blocks, make the omission loud.
|
|
2766
|
+
*
|
|
2767
|
+
* The row is built fresh instead of reusing logTimelineItem: that instance
|
|
2768
|
+
* picks up an _id as soon as any block has created a row with it, and a
|
|
2769
|
+
* second create() with it would UPDATE that row rather than insert this one.
|
|
2770
|
+
*/
|
|
2771
|
+
if (contactableChannels.length > 0 && !deliveryAttempted) {
|
|
2772
|
+
const statusMessage: string = `No notification template for ${options.userNotificationEventType} on ${contactableChannels.join(", ")}.`;
|
|
1802
2773
|
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
2774
|
+
const fellThroughRow: UserOnCallLogTimeline = this.buildLogTimelineItem(
|
|
2775
|
+
notificationRuleItem,
|
|
2776
|
+
options,
|
|
2777
|
+
);
|
|
2778
|
+
fellThroughRow.status = UserNotificationStatus.Error;
|
|
2779
|
+
fellThroughRow.statusMessage = statusMessage;
|
|
1807
2780
|
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
sayMessage: "This is a call from One Uptime",
|
|
1813
|
-
},
|
|
1814
|
-
{
|
|
1815
|
-
sayMessage: "A new alert has been created",
|
|
1816
|
-
},
|
|
1817
|
-
{
|
|
1818
|
-
sayMessage: alertIdentifier,
|
|
1819
|
-
},
|
|
1820
|
-
{
|
|
1821
|
-
introMessage: "To acknowledge this alert press 1",
|
|
1822
|
-
numDigits: 1,
|
|
1823
|
-
timeoutInSeconds: 10,
|
|
1824
|
-
noInputMessage: "You have not entered any input. Good bye",
|
|
1825
|
-
onInputCallRequest: {
|
|
1826
|
-
"1": {
|
|
1827
|
-
sayMessage: "You have acknowledged this alert. Good bye",
|
|
1828
|
-
},
|
|
1829
|
-
default: {
|
|
1830
|
-
sayMessage: "Invalid input. Good bye",
|
|
1831
|
-
},
|
|
1832
|
-
},
|
|
1833
|
-
responseUrl: new URL(
|
|
1834
|
-
httpProtocol,
|
|
1835
|
-
host,
|
|
1836
|
-
new Route(AppApiRoute.toString())
|
|
1837
|
-
.addRoute(new UserOnCallLogTimeline().crudApiPath!)
|
|
1838
|
-
.addRoute(
|
|
1839
|
-
"/call/gather-input/" + userOnCallLogTimelineId.toString(),
|
|
1840
|
-
),
|
|
1841
|
-
),
|
|
2781
|
+
await UserOnCallLogTimelineService.create({
|
|
2782
|
+
data: fellThroughRow,
|
|
2783
|
+
props: {
|
|
2784
|
+
isRoot: true,
|
|
1842
2785
|
},
|
|
1843
|
-
|
|
1844
|
-
};
|
|
2786
|
+
});
|
|
1845
2787
|
|
|
1846
|
-
|
|
2788
|
+
logger.error(
|
|
2789
|
+
`${statusMessage} User on-call log: ${options.userNotificationLogId.toString()}`,
|
|
2790
|
+
);
|
|
2791
|
+
}
|
|
2792
|
+
|
|
2793
|
+
return deliveryAttempted;
|
|
1847
2794
|
}
|
|
1848
2795
|
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
2796
|
+
/*
|
|
2797
|
+
* The channels this rule could actually reach the user on, by display name.
|
|
2798
|
+
*
|
|
2799
|
+
* These are the same gates each channel block opens with, so an empty list
|
|
2800
|
+
* means "this rule can contact nobody" — a rule whose method was
|
|
2801
|
+
* cascade-deleted, say — and a non-empty one means a page was expected to go
|
|
2802
|
+
* out. Webhooks have no verification concept at all (UserWebhook has no
|
|
2803
|
+
* isVerified column), so presence of a URL is the whole gate there.
|
|
2804
|
+
*/
|
|
2805
|
+
private getContactableChannelNames(
|
|
2806
|
+
notificationRuleItem: Model,
|
|
2807
|
+
): Array<string> {
|
|
2808
|
+
const channels: Array<string> = [];
|
|
1856
2809
|
|
|
1857
|
-
|
|
2810
|
+
if (
|
|
2811
|
+
notificationRuleItem.userEmail?.email &&
|
|
2812
|
+
notificationRuleItem.userEmail?.isVerified
|
|
2813
|
+
) {
|
|
2814
|
+
channels.push("Email");
|
|
2815
|
+
}
|
|
1858
2816
|
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
2817
|
+
if (
|
|
2818
|
+
notificationRuleItem.userSms?.phone &&
|
|
2819
|
+
notificationRuleItem.userSms?.isVerified
|
|
2820
|
+
) {
|
|
2821
|
+
channels.push("SMS");
|
|
2822
|
+
}
|
|
1863
2823
|
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
2824
|
+
if (
|
|
2825
|
+
notificationRuleItem.userWhatsApp?.phone &&
|
|
2826
|
+
notificationRuleItem.userWhatsApp?.isVerified
|
|
2827
|
+
) {
|
|
2828
|
+
channels.push("WhatsApp");
|
|
2829
|
+
}
|
|
2830
|
+
|
|
2831
|
+
if (
|
|
2832
|
+
notificationRuleItem.userTelegram?.telegramChatId &&
|
|
2833
|
+
notificationRuleItem.userTelegram?.isVerified
|
|
2834
|
+
) {
|
|
2835
|
+
channels.push("Telegram");
|
|
2836
|
+
}
|
|
2837
|
+
|
|
2838
|
+
if (notificationRuleItem.userWebhook?.webhookUrl) {
|
|
2839
|
+
channels.push("Webhook");
|
|
2840
|
+
}
|
|
2841
|
+
|
|
2842
|
+
if (
|
|
2843
|
+
notificationRuleItem.userCall?.phone &&
|
|
2844
|
+
notificationRuleItem.userCall?.isVerified
|
|
2845
|
+
) {
|
|
2846
|
+
channels.push("Call");
|
|
2847
|
+
}
|
|
2848
|
+
|
|
2849
|
+
if (
|
|
2850
|
+
notificationRuleItem.userPush?.deviceToken &&
|
|
2851
|
+
notificationRuleItem.userPush?.isVerified
|
|
2852
|
+
) {
|
|
2853
|
+
channels.push("Push");
|
|
2854
|
+
}
|
|
2855
|
+
|
|
2856
|
+
return channels;
|
|
2857
|
+
}
|
|
2858
|
+
|
|
2859
|
+
/*
|
|
2860
|
+
* Page a responder who has NO notification rule matching what just fired.
|
|
2861
|
+
*
|
|
2862
|
+
* Zero matching rules is indistinguishable from "never configured" unless the
|
|
2863
|
+
* user said otherwise, so the caller (UserOnCallLogService.onCreateSuccess)
|
|
2864
|
+
* checks for an explicit opt-out row first and only reaches here when the
|
|
2865
|
+
* silence looks accidental. Reaching a human on whatever they have verified
|
|
2866
|
+
* beats honouring a configuration they never made.
|
|
2867
|
+
*
|
|
2868
|
+
* Nothing here observes delivery success: every send below is fire-and-forget
|
|
2869
|
+
* (see deliverNotificationForRule), so `notified` means "a page was handed to
|
|
2870
|
+
* the sender", not "a phone rang".
|
|
2871
|
+
*
|
|
2872
|
+
* The three ways this can end are spelled out in FallbackNotificationOutcome,
|
|
2873
|
+
* and the caller must branch on them rather than on `notified` alone: only
|
|
2874
|
+
* NoUsableNotificationMethod describes a responder who cannot be reached, and
|
|
2875
|
+
* only that one is safe to record as a terminal status.
|
|
2876
|
+
*/
|
|
2877
|
+
@CaptureSpan()
|
|
2878
|
+
public async executeFallbackNotification(
|
|
2879
|
+
options: ExecuteFallbackNotificationOptions,
|
|
2880
|
+
): Promise<FallbackNotificationResult> {
|
|
2881
|
+
/*
|
|
2882
|
+
* Claim the log under the reserved fallback key before doing anything, so
|
|
2883
|
+
* two overlapping cron ticks cannot both fall back and double-page the same
|
|
2884
|
+
* responder for one escalation.
|
|
2885
|
+
*/
|
|
2886
|
+
const claimed: boolean =
|
|
2887
|
+
await UserOnCallLogService.claimNotificationExecution({
|
|
2888
|
+
userOnCallLogId: options.userOnCallLogId,
|
|
2889
|
+
claimKey: FALLBACK_NOTIFICATION_CLAIM_KEY,
|
|
2890
|
+
});
|
|
2891
|
+
|
|
2892
|
+
if (!claimed) {
|
|
2893
|
+
/*
|
|
2894
|
+
* A concurrent run already fell back for this log; it owns everything
|
|
2895
|
+
* that happens next, including the log's final status. Reported as the
|
|
2896
|
+
* transient outcome rather than as "no usable method", because the
|
|
2897
|
+
* caller's response to the latter is a terminal Error — which would
|
|
2898
|
+
* stamp "this responder is unreachable" over a page that is in flight.
|
|
2899
|
+
*/
|
|
2900
|
+
return {
|
|
2901
|
+
outcome: FallbackNotificationOutcome.DeliveryFailed,
|
|
2902
|
+
notified: false,
|
|
2903
|
+
channelsUsed: [],
|
|
2904
|
+
};
|
|
2905
|
+
}
|
|
2906
|
+
|
|
2907
|
+
const fallbackRules: Array<{ channelName: string; rule: Model }> =
|
|
2908
|
+
await this.chooseFallbackChannels(options);
|
|
2909
|
+
|
|
2910
|
+
if (fallbackRules.length === 0) {
|
|
2911
|
+
logger.warn(
|
|
2912
|
+
`On-call fallback found no usable notification method for user ${options.userId.toString()} in project ${options.projectId.toString()} (${options.severityName} ${options.ruleType}). The page cannot be delivered.`,
|
|
2913
|
+
);
|
|
2914
|
+
|
|
2915
|
+
return {
|
|
2916
|
+
outcome: FallbackNotificationOutcome.NoUsableNotificationMethod,
|
|
2917
|
+
notified: false,
|
|
2918
|
+
channelsUsed: [],
|
|
2919
|
+
};
|
|
2920
|
+
}
|
|
2921
|
+
|
|
2922
|
+
const channelsUsed: Array<string> = [];
|
|
2923
|
+
let anAttemptFailed: boolean = false;
|
|
2924
|
+
|
|
2925
|
+
/*
|
|
2926
|
+
* One delivery call per channel, never a loop inside one call: the timeline
|
|
2927
|
+
* row is a single mutable object inside deliverNotificationForRule, and a
|
|
2928
|
+
* second create() with it would UPDATE the row the first channel wrote
|
|
2929
|
+
* instead of inserting a second one — the second page would vanish from the
|
|
2930
|
+
* timeline and, worse, overwrite the first one's status.
|
|
2931
|
+
*/
|
|
2932
|
+
for (const fallbackRule of fallbackRules) {
|
|
2933
|
+
try {
|
|
2934
|
+
const dispatched: boolean = await this.deliverNotificationForRule(
|
|
2935
|
+
fallbackRule.rule,
|
|
2936
|
+
options,
|
|
2937
|
+
);
|
|
2938
|
+
|
|
2939
|
+
/*
|
|
2940
|
+
* Only a genuine dispatch earns a place in channelsUsed. The channel
|
|
2941
|
+
* names in here are read back to the operator as "notified via fallback
|
|
2942
|
+
* (Push, Email)", so a name added merely because the call resolved is a
|
|
2943
|
+
* lie in the one place somebody looks to find out whether the responder
|
|
2944
|
+
* was reached — and deliverNotificationForRule resolves perfectly
|
|
2945
|
+
* happily when no block claimed the event type.
|
|
2946
|
+
*/
|
|
2947
|
+
if (dispatched) {
|
|
2948
|
+
channelsUsed.push(fallbackRule.channelName);
|
|
2949
|
+
} else {
|
|
2950
|
+
anAttemptFailed = true;
|
|
2951
|
+
|
|
2952
|
+
logger.error(
|
|
2953
|
+
`On-call fallback dispatched nothing on ${fallbackRule.channelName} for user ${options.userId.toString()}: no notification template matched ${options.userNotificationEventType}.`,
|
|
2954
|
+
);
|
|
2955
|
+
}
|
|
2956
|
+
} catch (err) {
|
|
2957
|
+
anAttemptFailed = true;
|
|
2958
|
+
|
|
2959
|
+
logger.error(
|
|
2960
|
+
`On-call fallback failed to deliver on ${fallbackRule.channelName} for user ${options.userId.toString()}.`,
|
|
2961
|
+
);
|
|
2962
|
+
logger.error(err);
|
|
2963
|
+
}
|
|
2964
|
+
}
|
|
2965
|
+
|
|
2966
|
+
if (channelsUsed.length > 0) {
|
|
2967
|
+
return {
|
|
2968
|
+
outcome: FallbackNotificationOutcome.Delivered,
|
|
2969
|
+
notified: true,
|
|
2970
|
+
channelsUsed: channelsUsed,
|
|
2971
|
+
};
|
|
2972
|
+
}
|
|
2973
|
+
|
|
2974
|
+
/*
|
|
2975
|
+
* There were channels to try and not one of them carried a page. That is
|
|
2976
|
+
* emphatically not the "responder has no notification method" case —
|
|
2977
|
+
* chooseFallbackChannels returns only verified, project-enabled methods, so
|
|
2978
|
+
* the responder is reachable and today simply failed to be reached.
|
|
2979
|
+
*
|
|
2980
|
+
* anAttemptFailed is necessarily true on this line, since every path
|
|
2981
|
+
* through the loop that does not push a channel sets it. It is read rather
|
|
2982
|
+
* than assumed so that a future channel that can finish without either
|
|
2983
|
+
* dispatching or failing degrades into the transient outcome instead of
|
|
2984
|
+
* silently telling the operator the responder has nothing configured.
|
|
2985
|
+
*/
|
|
2986
|
+
return {
|
|
2987
|
+
outcome: anAttemptFailed
|
|
2988
|
+
? FallbackNotificationOutcome.DeliveryFailed
|
|
2989
|
+
: FallbackNotificationOutcome.NoUsableNotificationMethod,
|
|
2990
|
+
notified: false,
|
|
2991
|
+
channelsUsed: [],
|
|
2992
|
+
};
|
|
2993
|
+
}
|
|
2994
|
+
|
|
2995
|
+
/*
|
|
2996
|
+
* Pick what to page the user on, and build an unsaved rule for each choice.
|
|
2997
|
+
*
|
|
2998
|
+
* Zero-cost channels win: push and email reach the most people for no money
|
|
2999
|
+
* and no billing surprise, and there is no reason to pick between them, so a
|
|
3000
|
+
* user who has both gets both. Only a user with neither is worth spending on,
|
|
3001
|
+
* and then just once, in escalating-intrusiveness order.
|
|
3002
|
+
*
|
|
3003
|
+
* Paid channels are additionally gated on the project's own enable flags.
|
|
3004
|
+
* SmsService and CallService enforce those at send time, but WhatsApp and
|
|
3005
|
+
* Telegram only check them when a method is created — so a project that
|
|
3006
|
+
* switched WhatsApp off would still be billed by a fallback that did not look.
|
|
3007
|
+
*/
|
|
3008
|
+
private async chooseFallbackChannels(
|
|
3009
|
+
options: ExecuteFallbackNotificationOptions,
|
|
3010
|
+
): Promise<Array<{ channelName: string; rule: Model }>> {
|
|
3011
|
+
const chosen: Array<{ channelName: string; rule: Model }> = [];
|
|
3012
|
+
|
|
3013
|
+
const userPush: UserPush | null = await UserPushService.findOneBy({
|
|
3014
|
+
query: {
|
|
3015
|
+
projectId: options.projectId,
|
|
3016
|
+
userId: options.userId,
|
|
3017
|
+
isVerified: true,
|
|
3018
|
+
},
|
|
3019
|
+
select: {
|
|
3020
|
+
_id: true,
|
|
3021
|
+
deviceToken: true,
|
|
3022
|
+
deviceType: true,
|
|
3023
|
+
isVerified: true,
|
|
3024
|
+
},
|
|
3025
|
+
props: {
|
|
3026
|
+
isRoot: true,
|
|
3027
|
+
},
|
|
3028
|
+
});
|
|
3029
|
+
|
|
3030
|
+
if (userPush) {
|
|
3031
|
+
const rule: Model = this.buildUnsavedFallbackRule(options);
|
|
3032
|
+
rule.userPush = userPush;
|
|
3033
|
+
rule.userPushId = userPush.id!;
|
|
3034
|
+
chosen.push({ channelName: "Push", rule: rule });
|
|
3035
|
+
}
|
|
3036
|
+
|
|
3037
|
+
const userEmail: UserEmail | null = await UserEmailService.findOneBy({
|
|
3038
|
+
query: {
|
|
3039
|
+
projectId: options.projectId,
|
|
3040
|
+
userId: options.userId,
|
|
3041
|
+
isVerified: true,
|
|
3042
|
+
},
|
|
3043
|
+
select: {
|
|
3044
|
+
_id: true,
|
|
3045
|
+
email: true,
|
|
3046
|
+
isVerified: true,
|
|
3047
|
+
},
|
|
3048
|
+
props: {
|
|
3049
|
+
isRoot: true,
|
|
3050
|
+
},
|
|
3051
|
+
});
|
|
3052
|
+
|
|
3053
|
+
if (userEmail) {
|
|
3054
|
+
const rule: Model = this.buildUnsavedFallbackRule(options);
|
|
3055
|
+
rule.userEmail = userEmail;
|
|
3056
|
+
rule.userEmailId = userEmail.id!;
|
|
3057
|
+
chosen.push({ channelName: "Email", rule: rule });
|
|
3058
|
+
}
|
|
3059
|
+
|
|
3060
|
+
if (chosen.length > 0) {
|
|
3061
|
+
return chosen;
|
|
3062
|
+
}
|
|
3063
|
+
|
|
3064
|
+
const project: Project | null = await ProjectService.findOneById({
|
|
3065
|
+
id: options.projectId,
|
|
3066
|
+
select: {
|
|
3067
|
+
enableSmsNotifications: true,
|
|
3068
|
+
enableCallNotifications: true,
|
|
3069
|
+
enableWhatsAppNotifications: true,
|
|
3070
|
+
enableTelegramNotifications: true,
|
|
3071
|
+
},
|
|
3072
|
+
props: {
|
|
3073
|
+
isRoot: true,
|
|
3074
|
+
},
|
|
3075
|
+
});
|
|
3076
|
+
|
|
3077
|
+
if (project?.enableSmsNotifications) {
|
|
3078
|
+
const userSms: UserSMS | null = await UserSmsService.findOneBy({
|
|
3079
|
+
query: {
|
|
3080
|
+
projectId: options.projectId,
|
|
3081
|
+
userId: options.userId,
|
|
3082
|
+
isVerified: true,
|
|
3083
|
+
},
|
|
3084
|
+
select: {
|
|
3085
|
+
_id: true,
|
|
3086
|
+
phone: true,
|
|
3087
|
+
isVerified: true,
|
|
3088
|
+
},
|
|
3089
|
+
props: {
|
|
3090
|
+
isRoot: true,
|
|
3091
|
+
},
|
|
3092
|
+
});
|
|
3093
|
+
|
|
3094
|
+
if (userSms) {
|
|
3095
|
+
const rule: Model = this.buildUnsavedFallbackRule(options);
|
|
3096
|
+
rule.userSms = userSms;
|
|
3097
|
+
rule.userSmsId = userSms.id!;
|
|
3098
|
+
|
|
3099
|
+
return [{ channelName: "SMS", rule: rule }];
|
|
3100
|
+
}
|
|
3101
|
+
}
|
|
3102
|
+
|
|
3103
|
+
if (project?.enableCallNotifications) {
|
|
3104
|
+
const userCall: UserCall | null = await UserCallService.findOneBy({
|
|
3105
|
+
query: {
|
|
3106
|
+
projectId: options.projectId,
|
|
3107
|
+
userId: options.userId,
|
|
3108
|
+
isVerified: true,
|
|
3109
|
+
},
|
|
3110
|
+
select: {
|
|
3111
|
+
_id: true,
|
|
3112
|
+
phone: true,
|
|
3113
|
+
isVerified: true,
|
|
3114
|
+
},
|
|
3115
|
+
props: {
|
|
3116
|
+
isRoot: true,
|
|
3117
|
+
},
|
|
3118
|
+
});
|
|
3119
|
+
|
|
3120
|
+
if (userCall) {
|
|
3121
|
+
const rule: Model = this.buildUnsavedFallbackRule(options);
|
|
3122
|
+
rule.userCall = userCall;
|
|
3123
|
+
rule.userCallId = userCall.id!;
|
|
3124
|
+
|
|
3125
|
+
return [{ channelName: "Call", rule: rule }];
|
|
3126
|
+
}
|
|
3127
|
+
}
|
|
3128
|
+
|
|
3129
|
+
if (project?.enableWhatsAppNotifications) {
|
|
3130
|
+
const userWhatsApp: UserWhatsApp | null =
|
|
3131
|
+
await UserWhatsAppService.findOneBy({
|
|
3132
|
+
query: {
|
|
3133
|
+
projectId: options.projectId,
|
|
3134
|
+
userId: options.userId,
|
|
3135
|
+
isVerified: true,
|
|
3136
|
+
},
|
|
3137
|
+
select: {
|
|
3138
|
+
_id: true,
|
|
3139
|
+
phone: true,
|
|
3140
|
+
isVerified: true,
|
|
3141
|
+
},
|
|
3142
|
+
props: {
|
|
3143
|
+
isRoot: true,
|
|
3144
|
+
},
|
|
3145
|
+
});
|
|
3146
|
+
|
|
3147
|
+
if (userWhatsApp) {
|
|
3148
|
+
const rule: Model = this.buildUnsavedFallbackRule(options);
|
|
3149
|
+
rule.userWhatsApp = userWhatsApp;
|
|
3150
|
+
rule.userWhatsAppId = userWhatsApp.id!;
|
|
3151
|
+
|
|
3152
|
+
return [{ channelName: "WhatsApp", rule: rule }];
|
|
3153
|
+
}
|
|
3154
|
+
}
|
|
3155
|
+
|
|
3156
|
+
if (project?.enableTelegramNotifications) {
|
|
3157
|
+
const userTelegram: UserTelegram | null =
|
|
3158
|
+
await UserTelegramService.findOneBy({
|
|
3159
|
+
query: {
|
|
3160
|
+
projectId: options.projectId,
|
|
3161
|
+
userId: options.userId,
|
|
3162
|
+
isVerified: true,
|
|
3163
|
+
},
|
|
3164
|
+
select: {
|
|
3165
|
+
_id: true,
|
|
3166
|
+
telegramChatId: true,
|
|
3167
|
+
telegramUserHandle: true,
|
|
3168
|
+
isVerified: true,
|
|
3169
|
+
},
|
|
3170
|
+
props: {
|
|
3171
|
+
isRoot: true,
|
|
3172
|
+
},
|
|
3173
|
+
});
|
|
3174
|
+
|
|
3175
|
+
if (userTelegram) {
|
|
3176
|
+
const rule: Model = this.buildUnsavedFallbackRule(options);
|
|
3177
|
+
rule.userTelegram = userTelegram;
|
|
3178
|
+
rule.userTelegramId = userTelegram.id!;
|
|
3179
|
+
|
|
3180
|
+
return [{ channelName: "Telegram", rule: rule }];
|
|
3181
|
+
}
|
|
3182
|
+
}
|
|
3183
|
+
|
|
3184
|
+
/*
|
|
3185
|
+
* A webhook costs the project nothing and has no verification concept at
|
|
3186
|
+
* all (UserWebhook has no isVerified column), so its presence is the whole
|
|
3187
|
+
* test, and there is no project flag to consult.
|
|
3188
|
+
*/
|
|
3189
|
+
const userWebhook: UserWebhook | null = await UserWebhookService.findOneBy({
|
|
3190
|
+
query: {
|
|
3191
|
+
projectId: options.projectId,
|
|
3192
|
+
userId: options.userId,
|
|
3193
|
+
},
|
|
3194
|
+
select: {
|
|
3195
|
+
_id: true,
|
|
3196
|
+
webhookUrl: true,
|
|
3197
|
+
name: true,
|
|
3198
|
+
secret: true,
|
|
3199
|
+
},
|
|
3200
|
+
props: {
|
|
3201
|
+
isRoot: true,
|
|
3202
|
+
},
|
|
3203
|
+
});
|
|
3204
|
+
|
|
3205
|
+
if (userWebhook) {
|
|
3206
|
+
const rule: Model = this.buildUnsavedFallbackRule(options);
|
|
3207
|
+
rule.userWebhook = userWebhook;
|
|
3208
|
+
rule.userWebhookId = userWebhook.id!;
|
|
3209
|
+
|
|
3210
|
+
return [{ channelName: "Webhook", rule: rule }];
|
|
3211
|
+
}
|
|
3212
|
+
|
|
3213
|
+
return chosen;
|
|
3214
|
+
}
|
|
3215
|
+
|
|
3216
|
+
/*
|
|
3217
|
+
* A UserNotificationRule that exists only for the length of one delivery.
|
|
3218
|
+
*
|
|
3219
|
+
* It is never saved: the user did not ask for this rule, and persisting it
|
|
3220
|
+
* would silently rewrite their configuration behind their back. The method
|
|
3221
|
+
* relation is populated as a loaded entity rather than just its FK because
|
|
3222
|
+
* deliverNotificationForRule reads the relation (userEmail.email,
|
|
3223
|
+
* userEmail.isVerified) and never dereferences the id.
|
|
3224
|
+
*/
|
|
3225
|
+
private buildUnsavedFallbackRule(
|
|
3226
|
+
options: ExecuteFallbackNotificationOptions,
|
|
3227
|
+
): Model {
|
|
3228
|
+
const rule: Model = new Model();
|
|
3229
|
+
rule.projectId = options.projectId;
|
|
3230
|
+
rule.userId = options.userId;
|
|
3231
|
+
rule.ruleType = options.ruleType;
|
|
3232
|
+
rule.notifyAfterMinutes = 0;
|
|
3233
|
+
|
|
3234
|
+
return rule;
|
|
3235
|
+
}
|
|
3236
|
+
|
|
3237
|
+
@CaptureSpan()
|
|
3238
|
+
public async generateCallTemplateForAlertCreated(
|
|
3239
|
+
to: Phone,
|
|
3240
|
+
alert: Alert,
|
|
3241
|
+
userOnCallLogTimelineId: ObjectID,
|
|
3242
|
+
): Promise<CallRequest> {
|
|
3243
|
+
const host: Hostname = await DatabaseConfig.getHost();
|
|
3244
|
+
|
|
3245
|
+
const httpProtocol: Protocol = await DatabaseConfig.getHttpProtocol();
|
|
3246
|
+
|
|
3247
|
+
const alertIdentifier: string =
|
|
3248
|
+
alert.alertNumber !== undefined
|
|
3249
|
+
? `Alert number ${alert.alertNumber}, ${alert.title || "Alert"}`
|
|
3250
|
+
: alert.title || "Alert";
|
|
3251
|
+
|
|
3252
|
+
const callRequest: CallRequest = {
|
|
3253
|
+
to: to,
|
|
3254
|
+
data: [
|
|
3255
|
+
{
|
|
3256
|
+
sayMessage: "This is a call from One Uptime",
|
|
3257
|
+
},
|
|
3258
|
+
{
|
|
3259
|
+
sayMessage: "A new alert has been created",
|
|
3260
|
+
},
|
|
3261
|
+
{
|
|
3262
|
+
sayMessage: alertIdentifier,
|
|
3263
|
+
},
|
|
3264
|
+
{
|
|
3265
|
+
introMessage: "To acknowledge this alert press 1",
|
|
3266
|
+
numDigits: 1,
|
|
3267
|
+
timeoutInSeconds: 10,
|
|
3268
|
+
noInputMessage: "You have not entered any input. Good bye",
|
|
3269
|
+
onInputCallRequest: {
|
|
3270
|
+
"1": {
|
|
3271
|
+
sayMessage: "You have acknowledged this alert. Good bye",
|
|
3272
|
+
},
|
|
3273
|
+
default: {
|
|
3274
|
+
sayMessage: "Invalid input. Good bye",
|
|
3275
|
+
},
|
|
3276
|
+
},
|
|
3277
|
+
responseUrl: new URL(
|
|
3278
|
+
httpProtocol,
|
|
3279
|
+
host,
|
|
1892
3280
|
new Route(AppApiRoute.toString())
|
|
1893
3281
|
.addRoute(new UserOnCallLogTimeline().crudApiPath!)
|
|
1894
3282
|
.addRoute(
|
|
@@ -1903,20 +3291,19 @@ export class Service extends DatabaseService<Model> {
|
|
|
1903
3291
|
}
|
|
1904
3292
|
|
|
1905
3293
|
@CaptureSpan()
|
|
1906
|
-
public async
|
|
3294
|
+
public async generateCallTemplateForIncidentCreated(
|
|
1907
3295
|
to: Phone,
|
|
1908
|
-
|
|
3296
|
+
incident: Incident,
|
|
1909
3297
|
userOnCallLogTimelineId: ObjectID,
|
|
1910
3298
|
): Promise<CallRequest> {
|
|
1911
3299
|
const host: Hostname = await DatabaseConfig.getHost();
|
|
1912
3300
|
|
|
1913
3301
|
const httpProtocol: Protocol = await DatabaseConfig.getHttpProtocol();
|
|
1914
3302
|
|
|
1915
|
-
const
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
: alertEpisode.title || "Alert Episode";
|
|
3303
|
+
const incidentIdentifier: string =
|
|
3304
|
+
incident.incidentNumber !== undefined
|
|
3305
|
+
? `Incident number ${incident.incidentNumberWithPrefix || incident.incidentNumber}, ${incident.title || "Incident"}`
|
|
3306
|
+
: incident.title || "Incident";
|
|
1920
3307
|
|
|
1921
3308
|
const callRequest: CallRequest = {
|
|
1922
3309
|
to: to,
|
|
@@ -1925,19 +3312,19 @@ export class Service extends DatabaseService<Model> {
|
|
|
1925
3312
|
sayMessage: "This is a call from One Uptime",
|
|
1926
3313
|
},
|
|
1927
3314
|
{
|
|
1928
|
-
sayMessage: "A new
|
|
3315
|
+
sayMessage: "A new incident has been created",
|
|
1929
3316
|
},
|
|
1930
3317
|
{
|
|
1931
|
-
sayMessage:
|
|
3318
|
+
sayMessage: incidentIdentifier,
|
|
1932
3319
|
},
|
|
1933
3320
|
{
|
|
1934
|
-
introMessage: "To acknowledge this
|
|
3321
|
+
introMessage: "To acknowledge this incident press 1",
|
|
1935
3322
|
numDigits: 1,
|
|
1936
3323
|
timeoutInSeconds: 10,
|
|
1937
3324
|
noInputMessage: "You have not entered any input. Good bye",
|
|
1938
3325
|
onInputCallRequest: {
|
|
1939
3326
|
"1": {
|
|
1940
|
-
sayMessage: "You have acknowledged this
|
|
3327
|
+
sayMessage: "You have acknowledged this incident. Good bye",
|
|
1941
3328
|
},
|
|
1942
3329
|
default: {
|
|
1943
3330
|
sayMessage: "Invalid input. Good bye",
|
|
@@ -1960,17 +3347,132 @@ export class Service extends DatabaseService<Model> {
|
|
|
1960
3347
|
}
|
|
1961
3348
|
|
|
1962
3349
|
@CaptureSpan()
|
|
1963
|
-
public async
|
|
3350
|
+
public async generateCallTemplateForAlertEpisodeCreated(
|
|
1964
3351
|
to: Phone,
|
|
1965
|
-
|
|
3352
|
+
alertEpisode: AlertEpisode,
|
|
1966
3353
|
userOnCallLogTimelineId: ObjectID,
|
|
1967
|
-
): Promise<
|
|
3354
|
+
): Promise<CallRequest> {
|
|
1968
3355
|
const host: Hostname = await DatabaseConfig.getHost();
|
|
3356
|
+
|
|
1969
3357
|
const httpProtocol: Protocol = await DatabaseConfig.getHttpProtocol();
|
|
1970
3358
|
|
|
1971
|
-
const
|
|
1972
|
-
|
|
1973
|
-
|
|
3359
|
+
const episodeIdentifier: string = alertEpisode.episodeNumberWithPrefix
|
|
3360
|
+
? `Alert episode ${alertEpisode.episodeNumberWithPrefix}, ${alertEpisode.title || "Alert Episode"}`
|
|
3361
|
+
: alertEpisode.episodeNumber !== undefined
|
|
3362
|
+
? `Alert episode number ${alertEpisode.episodeNumber}, ${alertEpisode.title || "Alert Episode"}`
|
|
3363
|
+
: alertEpisode.title || "Alert Episode";
|
|
3364
|
+
|
|
3365
|
+
const callRequest: CallRequest = {
|
|
3366
|
+
to: to,
|
|
3367
|
+
data: [
|
|
3368
|
+
{
|
|
3369
|
+
sayMessage: "This is a call from One Uptime",
|
|
3370
|
+
},
|
|
3371
|
+
{
|
|
3372
|
+
sayMessage: "A new alert episode has been created",
|
|
3373
|
+
},
|
|
3374
|
+
{
|
|
3375
|
+
sayMessage: episodeIdentifier,
|
|
3376
|
+
},
|
|
3377
|
+
{
|
|
3378
|
+
introMessage: "To acknowledge this alert episode press 1",
|
|
3379
|
+
numDigits: 1,
|
|
3380
|
+
timeoutInSeconds: 10,
|
|
3381
|
+
noInputMessage: "You have not entered any input. Good bye",
|
|
3382
|
+
onInputCallRequest: {
|
|
3383
|
+
"1": {
|
|
3384
|
+
sayMessage: "You have acknowledged this alert episode. Good bye",
|
|
3385
|
+
},
|
|
3386
|
+
default: {
|
|
3387
|
+
sayMessage: "Invalid input. Good bye",
|
|
3388
|
+
},
|
|
3389
|
+
},
|
|
3390
|
+
responseUrl: new URL(
|
|
3391
|
+
httpProtocol,
|
|
3392
|
+
host,
|
|
3393
|
+
new Route(AppApiRoute.toString())
|
|
3394
|
+
.addRoute(new UserOnCallLogTimeline().crudApiPath!)
|
|
3395
|
+
.addRoute(
|
|
3396
|
+
"/call/gather-input/" + userOnCallLogTimelineId.toString(),
|
|
3397
|
+
),
|
|
3398
|
+
),
|
|
3399
|
+
},
|
|
3400
|
+
],
|
|
3401
|
+
};
|
|
3402
|
+
|
|
3403
|
+
return callRequest;
|
|
3404
|
+
}
|
|
3405
|
+
|
|
3406
|
+
@CaptureSpan()
|
|
3407
|
+
public async generateCallTemplateForIncidentEpisodeCreated(
|
|
3408
|
+
to: Phone,
|
|
3409
|
+
incidentEpisode: IncidentEpisode,
|
|
3410
|
+
userOnCallLogTimelineId: ObjectID,
|
|
3411
|
+
): Promise<CallRequest> {
|
|
3412
|
+
const host: Hostname = await DatabaseConfig.getHost();
|
|
3413
|
+
|
|
3414
|
+
const httpProtocol: Protocol = await DatabaseConfig.getHttpProtocol();
|
|
3415
|
+
|
|
3416
|
+
const episodeIdentifier: string = incidentEpisode.episodeNumberWithPrefix
|
|
3417
|
+
? `Incident episode ${incidentEpisode.episodeNumberWithPrefix}, ${incidentEpisode.title || "Incident Episode"}`
|
|
3418
|
+
: incidentEpisode.episodeNumber !== undefined
|
|
3419
|
+
? `Incident episode number ${incidentEpisode.episodeNumber}, ${incidentEpisode.title || "Incident Episode"}`
|
|
3420
|
+
: incidentEpisode.title || "Incident Episode";
|
|
3421
|
+
|
|
3422
|
+
const callRequest: CallRequest = {
|
|
3423
|
+
to: to,
|
|
3424
|
+
data: [
|
|
3425
|
+
{
|
|
3426
|
+
sayMessage: "This is a call from One Uptime",
|
|
3427
|
+
},
|
|
3428
|
+
{
|
|
3429
|
+
sayMessage: "A new incident episode has been created",
|
|
3430
|
+
},
|
|
3431
|
+
{
|
|
3432
|
+
sayMessage: episodeIdentifier,
|
|
3433
|
+
},
|
|
3434
|
+
{
|
|
3435
|
+
introMessage: "To acknowledge this incident episode press 1",
|
|
3436
|
+
numDigits: 1,
|
|
3437
|
+
timeoutInSeconds: 10,
|
|
3438
|
+
noInputMessage: "You have not entered any input. Good bye",
|
|
3439
|
+
onInputCallRequest: {
|
|
3440
|
+
"1": {
|
|
3441
|
+
sayMessage:
|
|
3442
|
+
"You have acknowledged this incident episode. Good bye",
|
|
3443
|
+
},
|
|
3444
|
+
default: {
|
|
3445
|
+
sayMessage: "Invalid input. Good bye",
|
|
3446
|
+
},
|
|
3447
|
+
},
|
|
3448
|
+
responseUrl: new URL(
|
|
3449
|
+
httpProtocol,
|
|
3450
|
+
host,
|
|
3451
|
+
new Route(AppApiRoute.toString())
|
|
3452
|
+
.addRoute(new UserOnCallLogTimeline().crudApiPath!)
|
|
3453
|
+
.addRoute(
|
|
3454
|
+
"/call/gather-input/" + userOnCallLogTimelineId.toString(),
|
|
3455
|
+
),
|
|
3456
|
+
),
|
|
3457
|
+
},
|
|
3458
|
+
],
|
|
3459
|
+
};
|
|
3460
|
+
|
|
3461
|
+
return callRequest;
|
|
3462
|
+
}
|
|
3463
|
+
|
|
3464
|
+
@CaptureSpan()
|
|
3465
|
+
public async generateSmsTemplateForAlertCreated(
|
|
3466
|
+
to: Phone,
|
|
3467
|
+
alert: Alert,
|
|
3468
|
+
userOnCallLogTimelineId: ObjectID,
|
|
3469
|
+
): Promise<SMS> {
|
|
3470
|
+
const host: Hostname = await DatabaseConfig.getHost();
|
|
3471
|
+
const httpProtocol: Protocol = await DatabaseConfig.getHttpProtocol();
|
|
3472
|
+
|
|
3473
|
+
const shortUrl: ShortLink = await ShortLinkService.saveShortLinkFor(
|
|
3474
|
+
new URL(
|
|
3475
|
+
httpProtocol,
|
|
1974
3476
|
host,
|
|
1975
3477
|
new Route(AppApiRoute.toString())
|
|
1976
3478
|
.addRoute(new UserOnCallLogTimeline().crudApiPath!)
|
|
@@ -2059,6 +3561,30 @@ export class Service extends DatabaseService<Model> {
|
|
|
2059
3561
|
return sms;
|
|
2060
3562
|
}
|
|
2061
3563
|
|
|
3564
|
+
@CaptureSpan()
|
|
3565
|
+
public async generateSmsTemplateForIncidentEpisodeCreated(
|
|
3566
|
+
to: Phone,
|
|
3567
|
+
incidentEpisode: IncidentEpisode,
|
|
3568
|
+
userOnCallLogTimelineId: ObjectID,
|
|
3569
|
+
): Promise<SMS> {
|
|
3570
|
+
const url: URL = await this.buildOnCallAcknowledgeShortUrl(
|
|
3571
|
+
userOnCallLogTimelineId,
|
|
3572
|
+
);
|
|
3573
|
+
|
|
3574
|
+
const episodeIdentifier: string = incidentEpisode.episodeNumberWithPrefix
|
|
3575
|
+
? `${incidentEpisode.episodeNumberWithPrefix} (${incidentEpisode.title || "Incident Episode"})`
|
|
3576
|
+
: incidentEpisode.episodeNumber !== undefined
|
|
3577
|
+
? `#${incidentEpisode.episodeNumber} (${incidentEpisode.title || "Incident Episode"})`
|
|
3578
|
+
: incidentEpisode.title || "Incident Episode";
|
|
3579
|
+
|
|
3580
|
+
const sms: SMS = {
|
|
3581
|
+
to,
|
|
3582
|
+
message: `This is a message from OneUptime. A new incident episode has been created: ${episodeIdentifier}. To acknowledge this incident episode, please click on the following link ${url.toString()}`,
|
|
3583
|
+
};
|
|
3584
|
+
|
|
3585
|
+
return sms;
|
|
3586
|
+
}
|
|
3587
|
+
|
|
2062
3588
|
private async buildOnCallAcknowledgeShortUrl(
|
|
2063
3589
|
userOnCallLogTimelineId: ObjectID,
|
|
2064
3590
|
): Promise<URL> {
|
|
@@ -2214,6 +3740,49 @@ export class Service extends DatabaseService<Model> {
|
|
|
2214
3740
|
return lines.join("\n");
|
|
2215
3741
|
}
|
|
2216
3742
|
|
|
3743
|
+
@CaptureSpan()
|
|
3744
|
+
public async generateTelegramBodyForIncidentEpisodeCreated(
|
|
3745
|
+
incidentEpisode: IncidentEpisode,
|
|
3746
|
+
userOnCallLogTimelineId: ObjectID,
|
|
3747
|
+
): Promise<string> {
|
|
3748
|
+
const ackUrl: URL = await this.buildOnCallAcknowledgeShortUrl(
|
|
3749
|
+
userOnCallLogTimelineId,
|
|
3750
|
+
);
|
|
3751
|
+
|
|
3752
|
+
const episodeIdentifier: string = incidentEpisode.episodeNumberWithPrefix
|
|
3753
|
+
? `${incidentEpisode.episodeNumberWithPrefix} — ${incidentEpisode.title || "Incident Episode"}`
|
|
3754
|
+
: incidentEpisode.episodeNumber !== undefined
|
|
3755
|
+
? `#${incidentEpisode.episodeNumber} — ${incidentEpisode.title || "Incident Episode"}`
|
|
3756
|
+
: incidentEpisode.title || "Incident Episode";
|
|
3757
|
+
|
|
3758
|
+
const lines: Array<string> = [
|
|
3759
|
+
"🔥 <b>New incident episode assigned to you</b>",
|
|
3760
|
+
"",
|
|
3761
|
+
`📋 <b>${this.escapeTelegramHtml(episodeIdentifier)}</b>`,
|
|
3762
|
+
"",
|
|
3763
|
+
"👤 You're getting this because you're on call.",
|
|
3764
|
+
];
|
|
3765
|
+
|
|
3766
|
+
if (incidentEpisode.projectId && incidentEpisode.id) {
|
|
3767
|
+
const dashboardUrl: URL =
|
|
3768
|
+
await IncidentEpisodeService.getEpisodeLinkInDashboard(
|
|
3769
|
+
incidentEpisode.projectId,
|
|
3770
|
+
incidentEpisode.id,
|
|
3771
|
+
);
|
|
3772
|
+
lines.push(
|
|
3773
|
+
"",
|
|
3774
|
+
`🔎 <a href="${this.escapeTelegramHtml(dashboardUrl.toString())}">View incident episode in OneUptime</a>`,
|
|
3775
|
+
);
|
|
3776
|
+
}
|
|
3777
|
+
|
|
3778
|
+
lines.push(
|
|
3779
|
+
"",
|
|
3780
|
+
`✅ <a href="${this.escapeTelegramHtml(ackUrl.toString())}">Tap to acknowledge</a>`,
|
|
3781
|
+
);
|
|
3782
|
+
|
|
3783
|
+
return lines.join("\n");
|
|
3784
|
+
}
|
|
3785
|
+
|
|
2217
3786
|
@CaptureSpan()
|
|
2218
3787
|
public async generateWhatsAppTemplateForAlertCreated(
|
|
2219
3788
|
to: Phone,
|
|
@@ -2388,6 +3957,51 @@ export class Service extends DatabaseService<Model> {
|
|
|
2388
3957
|
};
|
|
2389
3958
|
}
|
|
2390
3959
|
|
|
3960
|
+
@CaptureSpan()
|
|
3961
|
+
public async generateWhatsAppTemplateForIncidentEpisodeCreated(
|
|
3962
|
+
to: Phone,
|
|
3963
|
+
incidentEpisode: IncidentEpisode,
|
|
3964
|
+
userOnCallLogTimelineId: ObjectID,
|
|
3965
|
+
): Promise<WhatsAppMessage> {
|
|
3966
|
+
const acknowledgeUrl: URL = await this.buildOnCallAcknowledgeShortUrl(
|
|
3967
|
+
userOnCallLogTimelineId,
|
|
3968
|
+
);
|
|
3969
|
+
|
|
3970
|
+
const episodeLinkOnDashboard: string =
|
|
3971
|
+
incidentEpisode.projectId && incidentEpisode.id
|
|
3972
|
+
? (
|
|
3973
|
+
await IncidentEpisodeService.getEpisodeLinkInDashboard(
|
|
3974
|
+
incidentEpisode.projectId,
|
|
3975
|
+
incidentEpisode.id,
|
|
3976
|
+
)
|
|
3977
|
+
).toString()
|
|
3978
|
+
: acknowledgeUrl.toString();
|
|
3979
|
+
|
|
3980
|
+
const templateKey: WhatsAppTemplateId =
|
|
3981
|
+
WhatsAppTemplateIds.IncidentEpisodeCreated;
|
|
3982
|
+
const templateVariables: Record<string, string> = {
|
|
3983
|
+
project_name: incidentEpisode.project?.name || "OneUptime",
|
|
3984
|
+
episode_title: incidentEpisode.title || "",
|
|
3985
|
+
acknowledge_url: acknowledgeUrl.toString(),
|
|
3986
|
+
episode_number:
|
|
3987
|
+
incidentEpisode.episodeNumberWithPrefix ||
|
|
3988
|
+
(incidentEpisode.episodeNumber !== undefined
|
|
3989
|
+
? incidentEpisode.episodeNumber.toString()
|
|
3990
|
+
: ""),
|
|
3991
|
+
episode_link: episodeLinkOnDashboard,
|
|
3992
|
+
};
|
|
3993
|
+
|
|
3994
|
+
const body: string = renderWhatsAppTemplate(templateKey, templateVariables);
|
|
3995
|
+
|
|
3996
|
+
return {
|
|
3997
|
+
to,
|
|
3998
|
+
body,
|
|
3999
|
+
templateKey,
|
|
4000
|
+
templateVariables,
|
|
4001
|
+
templateLanguageCode: WhatsAppTemplateLanguage[templateKey],
|
|
4002
|
+
};
|
|
4003
|
+
}
|
|
4004
|
+
|
|
2391
4005
|
@CaptureSpan()
|
|
2392
4006
|
public async generateEmailTemplateForAlertCreated(
|
|
2393
4007
|
to: Email,
|
|
@@ -2648,45 +4262,225 @@ export class Service extends DatabaseService<Model> {
|
|
|
2648
4262
|
}
|
|
2649
4263
|
|
|
2650
4264
|
@CaptureSpan()
|
|
2651
|
-
public async
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
triggeredByIncidentEpisodeId?: ObjectID | undefined;
|
|
2659
|
-
userNotificationEventType: UserNotificationEventType;
|
|
2660
|
-
onCallPolicyExecutionLogId?: ObjectID | undefined;
|
|
2661
|
-
onCallPolicyId: ObjectID | undefined;
|
|
2662
|
-
onCallPolicyEscalationRuleId?: ObjectID | undefined;
|
|
2663
|
-
userBelongsToTeamId?: ObjectID | undefined;
|
|
2664
|
-
onCallDutyPolicyExecutionLogTimelineId?: ObjectID | undefined;
|
|
2665
|
-
onCallScheduleId?: ObjectID | undefined;
|
|
2666
|
-
overridedByUserId?: ObjectID | undefined;
|
|
2667
|
-
},
|
|
2668
|
-
): Promise<void> {
|
|
2669
|
-
// add user notification log.
|
|
2670
|
-
const userOnCallLog: UserOnCallLog = new UserOnCallLog();
|
|
4265
|
+
public async generateEmailTemplateForIncidentEpisodeCreated(
|
|
4266
|
+
to: Email,
|
|
4267
|
+
incidentEpisode: IncidentEpisode,
|
|
4268
|
+
userOnCallLogTimelineId: ObjectID,
|
|
4269
|
+
): Promise<EmailMessage> {
|
|
4270
|
+
const host: Hostname = await DatabaseConfig.getHost();
|
|
4271
|
+
const httpProtocol: Protocol = await DatabaseConfig.getHttpProtocol();
|
|
2671
4272
|
|
|
2672
|
-
|
|
2673
|
-
|
|
4273
|
+
// Fetch incidents that are members of this episode
|
|
4274
|
+
const episodeMembers: Array<IncidentEpisodeMember> =
|
|
4275
|
+
await IncidentEpisodeMemberService.findBy({
|
|
4276
|
+
query: {
|
|
4277
|
+
incidentEpisodeId: incidentEpisode.id!,
|
|
4278
|
+
},
|
|
4279
|
+
select: {
|
|
4280
|
+
incidentId: true,
|
|
4281
|
+
},
|
|
4282
|
+
props: {
|
|
4283
|
+
isRoot: true,
|
|
4284
|
+
},
|
|
4285
|
+
limit: LIMIT_PER_PROJECT,
|
|
4286
|
+
skip: 0,
|
|
4287
|
+
});
|
|
2674
4288
|
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
4289
|
+
// Get the incident IDs
|
|
4290
|
+
const incidentIds: Array<ObjectID> = episodeMembers
|
|
4291
|
+
.map((member: IncidentEpisodeMember) => {
|
|
4292
|
+
return member.incidentId;
|
|
4293
|
+
})
|
|
4294
|
+
.filter((id: ObjectID | undefined): id is ObjectID => {
|
|
4295
|
+
return id !== undefined;
|
|
4296
|
+
});
|
|
2678
4297
|
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
4298
|
+
// Fetch full incident data with monitors
|
|
4299
|
+
const incidents: Array<Incident> =
|
|
4300
|
+
incidentIds.length > 0
|
|
4301
|
+
? await IncidentService.findBy({
|
|
4302
|
+
query: {
|
|
4303
|
+
_id: QueryHelper.any(incidentIds),
|
|
4304
|
+
},
|
|
4305
|
+
select: {
|
|
4306
|
+
_id: true,
|
|
4307
|
+
title: true,
|
|
4308
|
+
incidentNumber: true,
|
|
4309
|
+
incidentNumberWithPrefix: true,
|
|
4310
|
+
monitors: {
|
|
4311
|
+
_id: true,
|
|
4312
|
+
name: true,
|
|
4313
|
+
},
|
|
4314
|
+
},
|
|
4315
|
+
props: {
|
|
4316
|
+
isRoot: true,
|
|
4317
|
+
},
|
|
4318
|
+
limit: LIMIT_PER_PROJECT,
|
|
4319
|
+
skip: 0,
|
|
4320
|
+
})
|
|
4321
|
+
: [];
|
|
2682
4322
|
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
4323
|
+
/*
|
|
4324
|
+
* Unique monitors across every incident in the episode. An incident carries
|
|
4325
|
+
* a list of monitors (unlike an alert, which has exactly one), so this
|
|
4326
|
+
* flattens rather than reading a single relation.
|
|
4327
|
+
*/
|
|
4328
|
+
const monitorNames: Set<string> = new Set();
|
|
4329
|
+
for (const incident of incidents) {
|
|
4330
|
+
for (const monitor of incident.monitors || []) {
|
|
4331
|
+
if (monitor.name) {
|
|
4332
|
+
monitorNames.add(monitor.name);
|
|
4333
|
+
}
|
|
4334
|
+
}
|
|
2686
4335
|
}
|
|
2687
4336
|
|
|
2688
|
-
|
|
2689
|
-
|
|
4337
|
+
const resourcesAffected: string =
|
|
4338
|
+
monitorNames.size > 0
|
|
4339
|
+
? Array.from(monitorNames).join(", ")
|
|
4340
|
+
: "No resources identified";
|
|
4341
|
+
|
|
4342
|
+
// Build incidents list HTML with proper email styling
|
|
4343
|
+
let incidentsListHtml: string = "";
|
|
4344
|
+
if (incidents.length > 0) {
|
|
4345
|
+
const incidentRows: string[] = [];
|
|
4346
|
+
for (const incident of incidents) {
|
|
4347
|
+
const incidentTitle: string = incident.title || "Untitled Incident";
|
|
4348
|
+
const incidentNumber: string =
|
|
4349
|
+
incident.incidentNumberWithPrefix ||
|
|
4350
|
+
(incident.incidentNumber ? `#${incident.incidentNumber}` : "");
|
|
4351
|
+
const incidentLink: string = (
|
|
4352
|
+
await IncidentService.getIncidentLinkInDashboard(
|
|
4353
|
+
incidentEpisode.projectId!,
|
|
4354
|
+
incident.id!,
|
|
4355
|
+
)
|
|
4356
|
+
).toString();
|
|
4357
|
+
const monitorName: string =
|
|
4358
|
+
(incident.monitors || [])
|
|
4359
|
+
.map((monitor: Monitor): string => {
|
|
4360
|
+
return monitor.name || "";
|
|
4361
|
+
})
|
|
4362
|
+
.filter((name: string): boolean => {
|
|
4363
|
+
return name.length > 0;
|
|
4364
|
+
})
|
|
4365
|
+
.join(", ") || "";
|
|
4366
|
+
|
|
4367
|
+
incidentRows.push(`
|
|
4368
|
+
<tr>
|
|
4369
|
+
<td style="padding: 12px 16px; border-bottom: 1px solid #e2e8f0;">
|
|
4370
|
+
<table cellpadding="0" cellspacing="0" width="100%">
|
|
4371
|
+
<tr>
|
|
4372
|
+
<td style="vertical-align: middle;">
|
|
4373
|
+
<span style="display: inline-block; background-color: #fee2e2; color: #991b1b; font-size: 12px; font-weight: 600; padding: 2px 8px; border-radius: 4px; margin-right: 8px;">${incidentNumber}</span>
|
|
4374
|
+
<a href="${incidentLink}" style="color: #2563eb; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; font-weight: 500; text-decoration: none;">${incidentTitle}</a>
|
|
4375
|
+
${monitorName ? `<span style="display: block; color: #64748b; font-size: 12px; margin-top: 4px;">Monitor: ${monitorName}</span>` : ""}
|
|
4376
|
+
</td>
|
|
4377
|
+
<td style="text-align: right; vertical-align: middle;">
|
|
4378
|
+
<a href="${incidentLink}" style="color: #2563eb; font-size: 12px; text-decoration: none;">View →</a>
|
|
4379
|
+
</td>
|
|
4380
|
+
</tr>
|
|
4381
|
+
</table>
|
|
4382
|
+
</td>
|
|
4383
|
+
</tr>
|
|
4384
|
+
`);
|
|
4385
|
+
}
|
|
4386
|
+
if (incidentRows.length > 0) {
|
|
4387
|
+
incidentsListHtml = `
|
|
4388
|
+
<table cellpadding="0" cellspacing="0" width="100%" style="background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%); border-radius: 8px; border: 1px solid #e2e8f0; margin: 8px 0 16px 0;">
|
|
4389
|
+
<tbody>
|
|
4390
|
+
${incidentRows.join("")}
|
|
4391
|
+
</tbody>
|
|
4392
|
+
</table>
|
|
4393
|
+
`;
|
|
4394
|
+
}
|
|
4395
|
+
}
|
|
4396
|
+
|
|
4397
|
+
const episodeNumber: string =
|
|
4398
|
+
incidentEpisode.episodeNumberWithPrefix ||
|
|
4399
|
+
(incidentEpisode.episodeNumber
|
|
4400
|
+
? `#${incidentEpisode.episodeNumber}`
|
|
4401
|
+
: "");
|
|
4402
|
+
|
|
4403
|
+
const vars: Dictionary<string> = {
|
|
4404
|
+
incidentEpisodeTitle: incidentEpisode.title!,
|
|
4405
|
+
episodeNumber: episodeNumber,
|
|
4406
|
+
projectName: incidentEpisode.project!.name!,
|
|
4407
|
+
currentState: incidentEpisode.currentIncidentState!.name!,
|
|
4408
|
+
incidentEpisodeDescription: await Markdown.convertToHTML(
|
|
4409
|
+
incidentEpisode.description! || "",
|
|
4410
|
+
MarkdownContentType.Email,
|
|
4411
|
+
),
|
|
4412
|
+
incidentEpisodeSeverity: incidentEpisode.incidentSeverity!.name!,
|
|
4413
|
+
resourcesAffected: resourcesAffected,
|
|
4414
|
+
rootCause:
|
|
4415
|
+
incidentEpisode.rootCause ||
|
|
4416
|
+
"No root cause identified for this incident episode",
|
|
4417
|
+
incidentsList: incidentsListHtml,
|
|
4418
|
+
incidentsCount: incidents.length.toString(),
|
|
4419
|
+
incidentEpisodeViewLink: (
|
|
4420
|
+
await IncidentEpisodeService.getEpisodeLinkInDashboard(
|
|
4421
|
+
incidentEpisode.projectId!,
|
|
4422
|
+
incidentEpisode.id!,
|
|
4423
|
+
)
|
|
4424
|
+
).toString(),
|
|
4425
|
+
acknowledgeIncidentEpisodeLink: new URL(
|
|
4426
|
+
httpProtocol,
|
|
4427
|
+
host,
|
|
4428
|
+
new Route(AppApiRoute.toString())
|
|
4429
|
+
.addRoute(new UserOnCallLogTimeline().crudApiPath!)
|
|
4430
|
+
.addRoute("/acknowledge-page/" + userOnCallLogTimelineId.toString()),
|
|
4431
|
+
).toString(),
|
|
4432
|
+
};
|
|
4433
|
+
|
|
4434
|
+
const emailMessage: EmailMessage = {
|
|
4435
|
+
toEmail: to!,
|
|
4436
|
+
templateType: EmailTemplateType.AcknowledgeIncidentEpisode,
|
|
4437
|
+
vars: vars,
|
|
4438
|
+
subject: `ACTION REQUIRED: Incident Episode ${episodeNumber} created - ${incidentEpisode.title!}`,
|
|
4439
|
+
};
|
|
4440
|
+
|
|
4441
|
+
return emailMessage;
|
|
4442
|
+
}
|
|
4443
|
+
|
|
4444
|
+
@CaptureSpan()
|
|
4445
|
+
public async startUserNotificationRulesExecution(
|
|
4446
|
+
userId: ObjectID,
|
|
4447
|
+
options: {
|
|
4448
|
+
projectId: ObjectID;
|
|
4449
|
+
triggeredByIncidentId?: ObjectID | undefined;
|
|
4450
|
+
triggeredByAlertId?: ObjectID | undefined;
|
|
4451
|
+
triggeredByAlertEpisodeId?: ObjectID | undefined;
|
|
4452
|
+
triggeredByIncidentEpisodeId?: ObjectID | undefined;
|
|
4453
|
+
userNotificationEventType: UserNotificationEventType;
|
|
4454
|
+
onCallPolicyExecutionLogId?: ObjectID | undefined;
|
|
4455
|
+
onCallPolicyId: ObjectID | undefined;
|
|
4456
|
+
onCallPolicyEscalationRuleId?: ObjectID | undefined;
|
|
4457
|
+
userBelongsToTeamId?: ObjectID | undefined;
|
|
4458
|
+
onCallDutyPolicyExecutionLogTimelineId?: ObjectID | undefined;
|
|
4459
|
+
onCallScheduleId?: ObjectID | undefined;
|
|
4460
|
+
overridedByUserId?: ObjectID | undefined;
|
|
4461
|
+
},
|
|
4462
|
+
): Promise<void> {
|
|
4463
|
+
// add user notification log.
|
|
4464
|
+
const userOnCallLog: UserOnCallLog = new UserOnCallLog();
|
|
4465
|
+
|
|
4466
|
+
userOnCallLog.userId = userId;
|
|
4467
|
+
userOnCallLog.projectId = options.projectId;
|
|
4468
|
+
|
|
4469
|
+
if (options.triggeredByIncidentId) {
|
|
4470
|
+
userOnCallLog.triggeredByIncidentId = options.triggeredByIncidentId;
|
|
4471
|
+
}
|
|
4472
|
+
|
|
4473
|
+
if (options.triggeredByAlertId) {
|
|
4474
|
+
userOnCallLog.triggeredByAlertId = options.triggeredByAlertId;
|
|
4475
|
+
}
|
|
4476
|
+
|
|
4477
|
+
if (options.triggeredByAlertEpisodeId) {
|
|
4478
|
+
userOnCallLog.triggeredByAlertEpisodeId =
|
|
4479
|
+
options.triggeredByAlertEpisodeId;
|
|
4480
|
+
}
|
|
4481
|
+
|
|
4482
|
+
if (options.triggeredByIncidentEpisodeId) {
|
|
4483
|
+
userOnCallLog.triggeredByIncidentEpisodeId =
|
|
2690
4484
|
options.triggeredByIncidentEpisodeId;
|
|
2691
4485
|
}
|
|
2692
4486
|
|
|
@@ -2814,26 +4608,59 @@ export class Service extends DatabaseService<Model> {
|
|
|
2814
4608
|
protected override async onBeforeCreate(
|
|
2815
4609
|
createBy: CreateBy<Model>,
|
|
2816
4610
|
): Promise<OnCreate<Model>> {
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
4611
|
+
const carrier: RuleColumnCarrier =
|
|
4612
|
+
createBy.data as unknown as RuleColumnCarrier;
|
|
4613
|
+
|
|
4614
|
+
/*
|
|
4615
|
+
* THE OWNERSHIP COLUMN IS REDUCED TO ONE SPELLING BEFORE ANYTHING READS IT,
|
|
4616
|
+
* and it has to happen here, first, rather than inside the guard below.
|
|
4617
|
+
*
|
|
4618
|
+
* `userId` and `user` are two decorated members over one join column. Every
|
|
4619
|
+
* check from this line down — the roster check, the method-ownership check,
|
|
4620
|
+
* the create invariants, CreatePermission's own ownership gate, and the
|
|
4621
|
+
* audit line after the write — asks "who does this row belong to", and each
|
|
4622
|
+
* of them would otherwise have to answer it from two disagreeing sources.
|
|
4623
|
+
* A payload carrying `user: { _id: <somebody else> }` and no `userId` is
|
|
4624
|
+
* the concrete failure: the guard reads the scalar, finds nothing, falls
|
|
4625
|
+
* back to the actor and validates a self-write, while TypeORM writes the
|
|
4626
|
+
* relation's id and the row belongs to somebody else entirely.
|
|
4627
|
+
*
|
|
4628
|
+
* So: refuse a payload whose two spellings disagree, then fold the survivor
|
|
4629
|
+
* into the scalar. After these two lines `createBy.data.userId` is the
|
|
4630
|
+
* single, authoritative owner, and it is the value that will be persisted.
|
|
4631
|
+
*
|
|
4632
|
+
* Deliberately NOT behind the root short-circuit that guards the checks
|
|
4633
|
+
* below. This is a reduction of the payload rather than a permission
|
|
4634
|
+
* decision, and an internal caller writing an ambiguous row would be just
|
|
4635
|
+
* as ambiguous a row.
|
|
4636
|
+
*/
|
|
4637
|
+
UserNotificationRuleAdminService.assertOneRuleOwner(carrier);
|
|
4638
|
+
UserNotificationRuleAdminService.collapseRuleOwnerRelationOnCreate(carrier);
|
|
4639
|
+
|
|
4640
|
+
await this.assertWriteIsPermittedForRuleOwner(createBy);
|
|
4641
|
+
|
|
4642
|
+
/*
|
|
4643
|
+
* Ambiguity is refused, then removed — in that order, and only after the
|
|
4644
|
+
* ownership guard above has had its say. A payload that names two different
|
|
4645
|
+
* methods for one channel is a payload nobody legitimately sends, and one
|
|
4646
|
+
* that names the same method twice is folded down to a single spelling so
|
|
4647
|
+
* that the invariants below, and the ORM after them, are reading the one
|
|
4648
|
+
* value that will actually be written.
|
|
4649
|
+
*/
|
|
4650
|
+
UserNotificationRuleAdminService.assertOneMethodPerNotificationChannel(
|
|
4651
|
+
carrier,
|
|
4652
|
+
);
|
|
4653
|
+
UserNotificationRuleAdminService.collapseNotificationMethodRelationsOnCreate(
|
|
4654
|
+
carrier,
|
|
4655
|
+
);
|
|
4656
|
+
|
|
4657
|
+
const hasNotificationMethod: boolean =
|
|
4658
|
+
UserNotificationRuleAdminService.carriesAnyNotificationMethod(carrier);
|
|
4659
|
+
|
|
4660
|
+
this.assertRuleIsCoherent({
|
|
4661
|
+
isOptOut: Boolean(createBy.data.isOptOut),
|
|
4662
|
+
hasNotificationMethod: hasNotificationMethod,
|
|
4663
|
+
});
|
|
2837
4664
|
|
|
2838
4665
|
return {
|
|
2839
4666
|
createBy,
|
|
@@ -2841,157 +4668,813 @@ export class Service extends DatabaseService<Model> {
|
|
|
2841
4668
|
};
|
|
2842
4669
|
}
|
|
2843
4670
|
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
4671
|
+
/**
|
|
4672
|
+
* The two invariants that decide whether a rule row means anything, enforced
|
|
4673
|
+
* from one place because create and update can each break both of them.
|
|
4674
|
+
*
|
|
4675
|
+
* An opt-out row is how a user says "deliberately do not page me for this rule
|
|
4676
|
+
* type at this severity". It carries the rule type and the severity and
|
|
4677
|
+
* nothing else — a method on it would be self-contradictory (reach me here;
|
|
4678
|
+
* also never reach me), and its whole purpose is to make silence explicit so
|
|
4679
|
+
* that every OTHER zero-rule case can be treated as misconfiguration and
|
|
4680
|
+
* rescued by the fallback. A rule that is NOT opt-out and names no method is
|
|
4681
|
+
* the mirror failure: it looks like coverage on every screen and delivers
|
|
4682
|
+
* nothing.
|
|
4683
|
+
*
|
|
4684
|
+
* The wording of both messages is load-bearing — the dashboard and the API
|
|
4685
|
+
* docs quote them — so they are written once here rather than once per path.
|
|
4686
|
+
*/
|
|
4687
|
+
private assertRuleIsCoherent(data: {
|
|
4688
|
+
isOptOut: boolean;
|
|
4689
|
+
hasNotificationMethod: boolean;
|
|
4690
|
+
}): void {
|
|
4691
|
+
if (data.isOptOut && data.hasNotificationMethod) {
|
|
4692
|
+
throw new BadDataException(
|
|
4693
|
+
"An opt-out notification rule cannot have a notification method. Remove the notification method, or turn off opt-out.",
|
|
4694
|
+
);
|
|
4695
|
+
}
|
|
2851
4696
|
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
notificationMethod,
|
|
2858
|
-
NotificationRuleType.ON_CALL_EXECUTED_ALERT_EPISODE,
|
|
2859
|
-
);
|
|
2860
|
-
await this.createSingleRule(
|
|
2861
|
-
projectId,
|
|
2862
|
-
userId,
|
|
2863
|
-
notificationMethod,
|
|
2864
|
-
NotificationRuleType.ON_CALL_EXECUTED_INCIDENT_EPISODE,
|
|
2865
|
-
);
|
|
2866
|
-
await this.createSingleRule(
|
|
2867
|
-
projectId,
|
|
2868
|
-
userId,
|
|
2869
|
-
notificationMethod,
|
|
2870
|
-
NotificationRuleType.WHEN_USER_GOES_ON_CALL,
|
|
2871
|
-
);
|
|
2872
|
-
await this.createSingleRule(
|
|
2873
|
-
projectId,
|
|
2874
|
-
userId,
|
|
2875
|
-
notificationMethod,
|
|
2876
|
-
NotificationRuleType.WHEN_USER_GOES_OFF_CALL,
|
|
2877
|
-
);
|
|
4697
|
+
if (!data.isOptOut && !data.hasNotificationMethod) {
|
|
4698
|
+
throw new BadDataException(
|
|
4699
|
+
"Call, SMS, WhatsApp, Telegram, Webhook, Email, or Push notification is required",
|
|
4700
|
+
);
|
|
4701
|
+
}
|
|
2878
4702
|
}
|
|
2879
4703
|
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
4704
|
+
/**
|
|
4705
|
+
* The create-path half of the on-behalf-of guards (audit R1 and R3).
|
|
4706
|
+
*
|
|
4707
|
+
* CreatePermission.checkCreateOwnership decides WHETHER a caller may name
|
|
4708
|
+
* somebody else in the ownership column: CurrentUser-only callers may not, a
|
|
4709
|
+
* caller holding a real role permission in the model's create list may. That
|
|
4710
|
+
* check is deliberately permission-shaped and value-blind past that point —
|
|
4711
|
+
* it has no notion of a project roster and no notion of what the rest of the
|
|
4712
|
+
* row says. Both of those are checked here, because both of them are how the
|
|
4713
|
+
* widened permission turns into somebody else's pages.
|
|
4714
|
+
*
|
|
4715
|
+
* Root and master-admin writes are exempt, matching the short-circuit at the
|
|
4716
|
+
* top of CreatePermission. Every internal seeder (default rules on method
|
|
4717
|
+
* verification, invitation acceptance, migrations) builds the ownership
|
|
4718
|
+
* column and the method reference from one and the same userId, so the guard
|
|
4719
|
+
* could only ever cost them a query per row; and the delivery-time check in
|
|
4720
|
+
* executeNotificationRuleItem is the backstop that keeps even an internally
|
|
4721
|
+
* written bad row from being acted on.
|
|
4722
|
+
*/
|
|
4723
|
+
private async assertWriteIsPermittedForRuleOwner(
|
|
4724
|
+
createBy: CreateBy<Model>,
|
|
4725
|
+
): Promise<void> {
|
|
4726
|
+
if (createBy.props.isRoot || createBy.props.isMasterAdmin) {
|
|
4727
|
+
return;
|
|
2898
4728
|
}
|
|
2899
|
-
|
|
2900
|
-
|
|
4729
|
+
|
|
4730
|
+
/*
|
|
4731
|
+
* `createBy.data.userId` alone is enough HERE, and only because
|
|
4732
|
+
* onBeforeCreate has already folded the `user` relation into it. Read on
|
|
4733
|
+
* its own — before that reduction existed — this line was a bypass: a
|
|
4734
|
+
* payload spelling the owner as `user: { _id: <somebody else> }` left the
|
|
4735
|
+
* scalar empty, fell through to props.userId, and every check below was
|
|
4736
|
+
* answered about the actor while the row was written for the victim. If
|
|
4737
|
+
* that fold is ever moved or removed, this line becomes wrong again.
|
|
4738
|
+
*
|
|
4739
|
+
* The fallback to props.userId is a different thing and stays: an omitted
|
|
4740
|
+
* ownership column means "for myself". CreatePermission stamps props.userId
|
|
4741
|
+
* onto it, but it does so AFTER this hook has run, so the value is not on
|
|
4742
|
+
* the model yet and reading data.userId alone would treat every ordinary
|
|
4743
|
+
* self-service create as an unowned row.
|
|
4744
|
+
*/
|
|
4745
|
+
const ruleOwnerUserId: ObjectID | undefined =
|
|
4746
|
+
createBy.data.userId || createBy.props.userId;
|
|
4747
|
+
|
|
4748
|
+
if (!ruleOwnerUserId) {
|
|
4749
|
+
throw new BadDataException(
|
|
4750
|
+
"A notification rule must belong to a user. Sign in as the user this rule is for, or name the user the rule belongs to.",
|
|
4751
|
+
);
|
|
2901
4752
|
}
|
|
2902
|
-
|
|
2903
|
-
|
|
4753
|
+
|
|
4754
|
+
const actorUserId: ObjectID | undefined = createBy.props.userId;
|
|
4755
|
+
|
|
4756
|
+
const isWritingForSomebodyElse: boolean =
|
|
4757
|
+
!actorUserId || actorUserId.toString() !== ruleOwnerUserId.toString();
|
|
4758
|
+
|
|
4759
|
+
if (isWritingForSomebodyElse) {
|
|
4760
|
+
/*
|
|
4761
|
+
* R1. Holding an administrative permission is a claim about a PROJECT, so
|
|
4762
|
+
* it can only ever authorise writing for users of that project. Without
|
|
4763
|
+
* this, one throwaway project where the caller is an admin would license
|
|
4764
|
+
* writing notification rules for any user id in the installation.
|
|
4765
|
+
*/
|
|
4766
|
+
await UserNotificationRuleAdminService.assertTargetUserIsProjectMember({
|
|
4767
|
+
targetUserId: ruleOwnerUserId,
|
|
4768
|
+
props: createBy.props,
|
|
4769
|
+
});
|
|
2904
4770
|
}
|
|
4771
|
+
|
|
4772
|
+
/*
|
|
4773
|
+
* R3, and note that it runs for a self-write too. "userId is me, but the
|
|
4774
|
+
* email row I am pointing at is yours" is the mirror image of the hijack —
|
|
4775
|
+
* it does not steal my pages, it copies them to your inbox — and it was
|
|
4776
|
+
* writable long before this phase widened anything.
|
|
4777
|
+
*/
|
|
4778
|
+
const references: Array<NotificationMethodReference> =
|
|
4779
|
+
UserNotificationRuleAdminService.collectNotificationMethodReferences(
|
|
4780
|
+
createBy.data as unknown as RuleColumnCarrier,
|
|
4781
|
+
);
|
|
4782
|
+
|
|
4783
|
+
await UserNotificationRuleAdminService.assertNotificationMethodsBelongToUser(
|
|
4784
|
+
{
|
|
4785
|
+
ownerUserId: ruleOwnerUserId,
|
|
4786
|
+
references: references,
|
|
4787
|
+
},
|
|
4788
|
+
);
|
|
2905
4789
|
}
|
|
2906
4790
|
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
2914
|
-
|
|
2915
|
-
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
4791
|
+
/*
|
|
4792
|
+
* R6 for the create path.
|
|
4793
|
+
*
|
|
4794
|
+
* Keyed on the actor the SERVER resolved (props.userId) against the userId
|
|
4795
|
+
* the row was actually PERSISTED with — read off createdItem, after
|
|
4796
|
+
* CreatePermission has had its say and after the insert. Nothing in the
|
|
4797
|
+
* request body reaches this comparison, because the body is the thing being
|
|
4798
|
+
* audited.
|
|
4799
|
+
*/
|
|
4800
|
+
@CaptureSpan()
|
|
4801
|
+
protected override async onCreateSuccess(
|
|
4802
|
+
onCreate: OnCreate<Model>,
|
|
4803
|
+
createdItem: Model,
|
|
4804
|
+
): Promise<Model> {
|
|
4805
|
+
const actorUserId: ObjectID | undefined = onCreate.createBy.props.userId;
|
|
4806
|
+
const ruleOwnerUserId: ObjectID | undefined = createdItem.userId;
|
|
4807
|
+
|
|
4808
|
+
if (
|
|
4809
|
+
actorUserId &&
|
|
4810
|
+
ruleOwnerUserId &&
|
|
4811
|
+
actorUserId.toString() !== ruleOwnerUserId.toString()
|
|
4812
|
+
) {
|
|
4813
|
+
await UserNotificationRuleAdminService.recordAdminRuleChange({
|
|
4814
|
+
action: AuditLogAction.Create,
|
|
4815
|
+
actorUserId: actorUserId,
|
|
4816
|
+
ownerUserId: ruleOwnerUserId,
|
|
4817
|
+
projectId: createdItem.projectId || onCreate.createBy.props.tenantId,
|
|
4818
|
+
ruleId: createdItem.id,
|
|
4819
|
+
after: createdItem,
|
|
4820
|
+
notifyOwner: true,
|
|
4821
|
+
props: onCreate.createBy.props,
|
|
4822
|
+
});
|
|
2922
4823
|
}
|
|
2923
|
-
|
|
2924
|
-
|
|
4824
|
+
|
|
4825
|
+
return createdItem;
|
|
4826
|
+
}
|
|
4827
|
+
|
|
4828
|
+
/**
|
|
4829
|
+
* Narrow a caller-supplied query to the rows that caller is actually entitled
|
|
4830
|
+
* to write, for use by the write hooks.
|
|
4831
|
+
*
|
|
4832
|
+
* WHY THIS EXISTS AT ALL. DatabaseService runs the hooks BEFORE the permission
|
|
4833
|
+
* layer: _updateBy calls onBeforeUpdate and only then
|
|
4834
|
+
* ModelPermission.checkUpdateQueryPermissions; _deleteBy calls onBeforeDelete
|
|
4835
|
+
* and only then checkDeleteQueryPermission. So a hook that reads
|
|
4836
|
+
* `updateBy.query` is reading the RAW request — no tenant predicate, no
|
|
4837
|
+
* ownership predicate — and the hooks below read it with `isRoot` props on
|
|
4838
|
+
* top, because the question they ask is a question about the database's state
|
|
4839
|
+
* rather than about the caller's visibility. Left there, a caller could point
|
|
4840
|
+
* the guard at rows in another project entirely: the guard would validate
|
|
4841
|
+
* against them, the audit trail would name their owners, and the write itself
|
|
4842
|
+
* would touch a completely different set.
|
|
4843
|
+
*
|
|
4844
|
+
* WHY NOT JUST CALL ModelPermission. That is the obvious fix and it is the
|
|
4845
|
+
* wrong one. checkUpdateQueryPermissions does two jobs — it narrows the query
|
|
4846
|
+
* AND it authorises the request — and running it here would run the second
|
|
4847
|
+
* job twice, moving every table- and column-level rejection into the hook and
|
|
4848
|
+
* duplicating the team lookups behind the tenant scope on every write. The
|
|
4849
|
+
* hook does not need to authorise anything; _updateBy authorises it a few
|
|
4850
|
+
* lines later and is the authority. What the hook needs is only that the row
|
|
4851
|
+
* set it reasons about is no wider than the row set the write can reach.
|
|
4852
|
+
*
|
|
4853
|
+
* WHAT IS REPRODUCED, AND WHY THAT IS THE WHOLE OF IT. For this model the
|
|
4854
|
+
* narrowing is exactly two predicates: the tenant column
|
|
4855
|
+
* (TenantPermission.addTenantScopeToQuery for a member,
|
|
4856
|
+
* PermissionUtil.addTenantScopeToQueryAsRoot on the delete path for root) and,
|
|
4857
|
+
* when Permission.CurrentUser is the ONLY thing letting the caller through,
|
|
4858
|
+
* the ownership column. Nothing else applies: UserNotificationRule declares no
|
|
4859
|
+
* access-control column, is not an operational resource and has no
|
|
4860
|
+
* @OwnedThrough, so addAccessControlIdsToQuery and addOwnedScopeToQuery are
|
|
4861
|
+
* both no-ops on it. IF ANY OF THAT CHANGES ON THE MODEL, THIS MUST CHANGE
|
|
4862
|
+
* WITH IT — a narrowing the permission layer applies and this does not is a
|
|
4863
|
+
* guard validating rows the write never touches.
|
|
4864
|
+
*
|
|
4865
|
+
* Root and master-admin queries are returned untouched. They are entitled to
|
|
4866
|
+
* every row, and narrowing them would make the guard read FEWER rows than the
|
|
4867
|
+
* write reaches, which is the one direction it must never be wrong in.
|
|
4868
|
+
*/
|
|
4869
|
+
private narrowQueryToCallerEntitlement(
|
|
4870
|
+
query: Query<Model>,
|
|
4871
|
+
props: DatabaseCommonInteractionProps,
|
|
4872
|
+
requestType: DatabaseRequestType,
|
|
4873
|
+
): Query<Model> {
|
|
4874
|
+
if (props.isRoot || props.isMasterAdmin) {
|
|
4875
|
+
return query;
|
|
2925
4876
|
}
|
|
2926
|
-
|
|
2927
|
-
|
|
4877
|
+
|
|
4878
|
+
const scopedQuery: Query<Model> = { ...query };
|
|
4879
|
+
|
|
4880
|
+
const tenantColumn: string | null = this.getModel().getTenantColumn();
|
|
4881
|
+
|
|
4882
|
+
if (tenantColumn && props.tenantId && !props.isMultiTenantRequest) {
|
|
4883
|
+
(scopedQuery as Dictionary<unknown>)[tenantColumn] = props.tenantId;
|
|
2928
4884
|
}
|
|
2929
|
-
|
|
2930
|
-
|
|
4885
|
+
|
|
4886
|
+
const userColumn: string | null = this.getModel().getUserColumn();
|
|
4887
|
+
|
|
4888
|
+
if (
|
|
4889
|
+
userColumn &&
|
|
4890
|
+
props.userId &&
|
|
4891
|
+
TenantPermission.isAccessGrantedOnlyByCurrentUser(
|
|
4892
|
+
this.modelType,
|
|
4893
|
+
props,
|
|
4894
|
+
requestType,
|
|
4895
|
+
)
|
|
4896
|
+
) {
|
|
4897
|
+
/*
|
|
4898
|
+
* Set rather than merged. A CurrentUser-only caller whose query names
|
|
4899
|
+
* somebody else is rejected outright by addCurrentUserScopeToQuery a
|
|
4900
|
+
* moment from now, so the only thing that matters here is that the guard
|
|
4901
|
+
* never reads rows that rejection would have protected.
|
|
4902
|
+
*/
|
|
4903
|
+
(scopedQuery as Dictionary<unknown>)[userColumn] = props.userId;
|
|
2931
4904
|
}
|
|
2932
|
-
|
|
4905
|
+
|
|
4906
|
+
return scopedQuery;
|
|
2933
4907
|
}
|
|
2934
4908
|
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
|
|
2951
|
-
|
|
2952
|
-
|
|
2953
|
-
|
|
4909
|
+
/**
|
|
4910
|
+
* The update-path half of R3, plus the read that R6 needs.
|
|
4911
|
+
*
|
|
4912
|
+
* The rule's owner is re-read FROM THE DATABASE here and never taken from
|
|
4913
|
+
* updateBy.data. That is the whole point of the hook: on update the caller
|
|
4914
|
+
* controls the body, so a userId in it is a claim ("this row is mine") made
|
|
4915
|
+
* by exactly the party the guard exists to doubt. The persisted value is the
|
|
4916
|
+
* only one that decides whose pages the row selects, so it is the only one
|
|
4917
|
+
* worth comparing a method's owner against.
|
|
4918
|
+
*
|
|
4919
|
+
* The lookup runs with isRoot rather than the caller's own props on purpose.
|
|
4920
|
+
* Scoping it to what the CALLER can READ would let a caller who cannot see a
|
|
4921
|
+
* row edit it unchecked — the query would simply return nothing and the loop
|
|
4922
|
+
* below would have nothing to reject. Read permission and write permission are
|
|
4923
|
+
* different lists, and it is the write one that decides what this hook has to
|
|
4924
|
+
* answer for.
|
|
4925
|
+
*
|
|
4926
|
+
* The QUERY, on the other hand, is narrowed first. Root props remove the
|
|
4927
|
+
* caller's visibility from the answer; they must not also remove the caller's
|
|
4928
|
+
* ENTITLEMENT from it, and this hook runs before ModelPermission has applied
|
|
4929
|
+
* either. See narrowQueryToCallerEntitlement for why the narrowing is
|
|
4930
|
+
* reproduced here rather than delegated.
|
|
4931
|
+
*
|
|
4932
|
+
* The rows are carried forward so onUpdateSuccess can audit against the
|
|
4933
|
+
* owner as it stood BEFORE the write, without a second read and without
|
|
4934
|
+
* trusting anything the request said.
|
|
4935
|
+
*/
|
|
4936
|
+
@CaptureSpan()
|
|
4937
|
+
protected override async onBeforeUpdate(
|
|
4938
|
+
updateBy: UpdateBy<Model>,
|
|
4939
|
+
): Promise<OnUpdate<Model>> {
|
|
4940
|
+
const patch: RuleColumnCarrier =
|
|
4941
|
+
updateBy.data as unknown as RuleColumnCarrier;
|
|
4942
|
+
|
|
4943
|
+
const references: Array<NotificationMethodReference> =
|
|
4944
|
+
UserNotificationRuleAdminService.collectNotificationMethodReferences(
|
|
4945
|
+
patch,
|
|
4946
|
+
);
|
|
2954
4947
|
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
projectId,
|
|
2959
|
-
userId,
|
|
2960
|
-
...this.getNotificationMethodQuery(notificationMethod),
|
|
2961
|
-
incidentSeverityId: incidentSeverity.id!,
|
|
2962
|
-
ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
|
|
2963
|
-
} as any,
|
|
2964
|
-
props: {
|
|
2965
|
-
isRoot: true,
|
|
2966
|
-
},
|
|
2967
|
-
});
|
|
4948
|
+
const isInternalWrite: boolean = Boolean(
|
|
4949
|
+
updateBy.props.isRoot || updateBy.props.isMasterAdmin,
|
|
4950
|
+
);
|
|
2968
4951
|
|
|
2969
|
-
|
|
4952
|
+
/*
|
|
4953
|
+
* Three reasons to read the affected rows, and only one of them is R3. An
|
|
4954
|
+
* actor id means this write might be somebody editing somebody else's
|
|
4955
|
+
* configuration, which R6 has to be able to report on even when no method FK
|
|
4956
|
+
* is being touched; and a patch that touches `isOptOut` or any method column
|
|
4957
|
+
* can break a row-level invariant that is only visible once the patch is
|
|
4958
|
+
* laid over the row it is being applied to.
|
|
4959
|
+
*/
|
|
4960
|
+
const touchesRuleCoherence: boolean =
|
|
4961
|
+
patch["isOptOut"] !== undefined ||
|
|
4962
|
+
UserNotificationRuleAdminService.mentionsAnyNotificationMethodColumn(
|
|
4963
|
+
patch,
|
|
4964
|
+
);
|
|
4965
|
+
|
|
4966
|
+
const needsAffectedRules: boolean =
|
|
4967
|
+
(references.length > 0 && !isInternalWrite) ||
|
|
4968
|
+
touchesRuleCoherence ||
|
|
4969
|
+
Boolean(updateBy.props.userId);
|
|
4970
|
+
|
|
4971
|
+
if (!needsAffectedRules) {
|
|
4972
|
+
return {
|
|
4973
|
+
updateBy,
|
|
4974
|
+
carryForward: null,
|
|
4975
|
+
};
|
|
4976
|
+
}
|
|
4977
|
+
|
|
4978
|
+
const affectedRules: Array<Model> = await this.findBy({
|
|
4979
|
+
query: this.narrowQueryToCallerEntitlement(
|
|
4980
|
+
updateBy.query,
|
|
4981
|
+
updateBy.props,
|
|
4982
|
+
DatabaseRequestType.Update,
|
|
4983
|
+
),
|
|
4984
|
+
select: {
|
|
4985
|
+
_id: true,
|
|
4986
|
+
userId: true,
|
|
4987
|
+
projectId: true,
|
|
4988
|
+
ruleType: true,
|
|
4989
|
+
notifyAfterMinutes: true,
|
|
4990
|
+
isOptOut: true,
|
|
4991
|
+
incidentSeverityId: true,
|
|
4992
|
+
alertSeverityId: true,
|
|
4993
|
+
userEmailId: true,
|
|
4994
|
+
userSmsId: true,
|
|
4995
|
+
userCallId: true,
|
|
4996
|
+
userWhatsAppId: true,
|
|
4997
|
+
userTelegramId: true,
|
|
4998
|
+
userPushId: true,
|
|
4999
|
+
userWebhookId: true,
|
|
5000
|
+
},
|
|
5001
|
+
limit: LIMIT_MAX,
|
|
5002
|
+
skip: 0,
|
|
5003
|
+
props: {
|
|
5004
|
+
isRoot: true,
|
|
5005
|
+
ignoreHooks: true,
|
|
5006
|
+
},
|
|
5007
|
+
});
|
|
5008
|
+
|
|
5009
|
+
if (references.length > 0 && !isInternalWrite) {
|
|
5010
|
+
/*
|
|
5011
|
+
* One validation per DISTINCT owner rather than per row. A bulk update
|
|
5012
|
+
* across twenty of one user's rules asks the same question twenty times,
|
|
5013
|
+
* and each question costs a lookup per referenced method.
|
|
5014
|
+
*/
|
|
5015
|
+
const validatedOwnerIds: Set<string> = new Set<string>();
|
|
5016
|
+
|
|
5017
|
+
for (const affectedRule of affectedRules) {
|
|
5018
|
+
const ownerKey: string = affectedRule.userId?.toString() || "";
|
|
5019
|
+
|
|
5020
|
+
if (validatedOwnerIds.has(ownerKey)) {
|
|
5021
|
+
continue;
|
|
5022
|
+
}
|
|
5023
|
+
|
|
5024
|
+
validatedOwnerIds.add(ownerKey);
|
|
5025
|
+
|
|
5026
|
+
await UserNotificationRuleAdminService.assertNotificationMethodsBelongToUser(
|
|
5027
|
+
{
|
|
5028
|
+
ownerUserId: affectedRule.userId,
|
|
5029
|
+
references: references,
|
|
5030
|
+
},
|
|
5031
|
+
);
|
|
5032
|
+
}
|
|
5033
|
+
}
|
|
5034
|
+
|
|
5035
|
+
/*
|
|
5036
|
+
* Ambiguity is refused here as it is on create, but NOT folded away. The
|
|
5037
|
+
* relation members are `update: []` on this model while the `*Id` members
|
|
5038
|
+
* are open to an administrator, so rewriting one spelling into the other
|
|
5039
|
+
* would smuggle a column write past the very ColumnPermission check that
|
|
5040
|
+
* runs immediately after this hook. Refusal leaves nothing for the ORM to
|
|
5041
|
+
* choose between without moving a value across a permission boundary.
|
|
5042
|
+
*/
|
|
5043
|
+
UserNotificationRuleAdminService.assertOneMethodPerNotificationChannel(
|
|
5044
|
+
patch,
|
|
5045
|
+
);
|
|
5046
|
+
|
|
5047
|
+
if (touchesRuleCoherence) {
|
|
5048
|
+
/*
|
|
5049
|
+
* The create-time invariants, re-checked per affected row.
|
|
5050
|
+
*
|
|
5051
|
+
* They were enforced only on create, which left update as a way to reach
|
|
5052
|
+
* the states create refuses: flip `isOptOut` on a rule that carries an
|
|
5053
|
+
* email and you have a row that says both "reach me here" and "never
|
|
5054
|
+
* reach me"; null the last method on a rule that is not opt-out and you
|
|
5055
|
+
* have a row that looks like coverage on every screen and delivers
|
|
5056
|
+
* nothing — indistinguishable, to the fallback, from a deliberate choice
|
|
5057
|
+
* to stay silent. Neither is visible from the patch alone, which is why
|
|
5058
|
+
* this waits until the affected rows have been read.
|
|
5059
|
+
*/
|
|
5060
|
+
for (const affectedRule of affectedRules) {
|
|
5061
|
+
const methodIdsAfterPatch: Array<ObjectID> =
|
|
5062
|
+
UserNotificationRuleAdminService.getNotificationMethodIdsAfterPatch({
|
|
5063
|
+
patch: patch,
|
|
5064
|
+
currentRow: affectedRule as unknown as RuleColumnCarrier,
|
|
5065
|
+
});
|
|
5066
|
+
|
|
5067
|
+
const isOptOutAfterPatch: boolean =
|
|
5068
|
+
patch["isOptOut"] !== undefined
|
|
5069
|
+
? Boolean(patch["isOptOut"])
|
|
5070
|
+
: Boolean(affectedRule.isOptOut);
|
|
5071
|
+
|
|
5072
|
+
this.assertRuleIsCoherent({
|
|
5073
|
+
isOptOut: isOptOutAfterPatch,
|
|
5074
|
+
hasNotificationMethod: methodIdsAfterPatch.length > 0,
|
|
5075
|
+
});
|
|
5076
|
+
}
|
|
5077
|
+
}
|
|
5078
|
+
|
|
5079
|
+
return {
|
|
5080
|
+
updateBy,
|
|
5081
|
+
carryForward: {
|
|
5082
|
+
affectedRules: affectedRules,
|
|
5083
|
+
},
|
|
5084
|
+
};
|
|
5085
|
+
}
|
|
5086
|
+
|
|
5087
|
+
/*
|
|
5088
|
+
* R6 for the update path.
|
|
5089
|
+
*
|
|
5090
|
+
* Every row whose PERSISTED owner is somebody other than the actor gets an
|
|
5091
|
+
* audit entry; the owner gets at most one mail no matter how many of their
|
|
5092
|
+
* rules one request touched, because twenty copies of "an admin changed your
|
|
5093
|
+
* rules" is a message people learn to delete rather than read.
|
|
5094
|
+
*
|
|
5095
|
+
* `updatedItemIds` is the set of rows the write ACTUALLY touched, and the
|
|
5096
|
+
* carried-forward rows are filtered down to it. The two can differ: the hook
|
|
5097
|
+
* read every row the (narrowed) query matched, while _updateBy applies the
|
|
5098
|
+
* caller's own skip/limit and drops rows that were hard-deleted between the
|
|
5099
|
+
* two. Reporting an unchanged row would put a change in the audit trail that
|
|
5100
|
+
* never happened and mail somebody about it.
|
|
5101
|
+
*/
|
|
5102
|
+
@CaptureSpan()
|
|
5103
|
+
protected override async onUpdateSuccess(
|
|
5104
|
+
onUpdate: OnUpdate<Model>,
|
|
5105
|
+
updatedItemIds: Array<ObjectID>,
|
|
5106
|
+
): Promise<OnUpdate<Model>> {
|
|
5107
|
+
const actorUserId: ObjectID | undefined = onUpdate.updateBy.props.userId;
|
|
5108
|
+
|
|
5109
|
+
if (!actorUserId) {
|
|
5110
|
+
return onUpdate;
|
|
5111
|
+
}
|
|
5112
|
+
|
|
5113
|
+
const affectedRules: Array<Model> =
|
|
5114
|
+
(onUpdate.carryForward?.affectedRules as Array<Model> | undefined) || [];
|
|
5115
|
+
|
|
5116
|
+
const updatedIds: Set<string> = new Set<string>(
|
|
5117
|
+
updatedItemIds.map((id: ObjectID): string => {
|
|
5118
|
+
return id.toString();
|
|
5119
|
+
}),
|
|
5120
|
+
);
|
|
5121
|
+
|
|
5122
|
+
await this.reportAdministrativeChange({
|
|
5123
|
+
action: AuditLogAction.Update,
|
|
5124
|
+
actorUserId: actorUserId,
|
|
5125
|
+
rules: affectedRules.filter((rule: Model): boolean => {
|
|
5126
|
+
return Boolean(rule.id && updatedIds.has(rule.id.toString()));
|
|
5127
|
+
}),
|
|
5128
|
+
updatedFields: onUpdate.updateBy.data as unknown as JSONObject,
|
|
5129
|
+
props: onUpdate.updateBy.props,
|
|
5130
|
+
});
|
|
5131
|
+
|
|
5132
|
+
return onUpdate;
|
|
5133
|
+
}
|
|
5134
|
+
|
|
5135
|
+
/**
|
|
5136
|
+
* R6, factored out because create, update and delete all owe the same debt.
|
|
5137
|
+
*
|
|
5138
|
+
* One audit entry per ROW, because that is what an investigator reconstructs
|
|
5139
|
+
* a timeline from, and at most one mail per PERSON, because one request that
|
|
5140
|
+
* touches twenty of somebody's rules is still one thing that happened to
|
|
5141
|
+
* them.
|
|
5142
|
+
*
|
|
5143
|
+
* Rules the actor owns are skipped: configuring your own paging is not an
|
|
5144
|
+
* administrative act and does not need announcing to yourself.
|
|
5145
|
+
*/
|
|
5146
|
+
private async reportAdministrativeChange(data: {
|
|
5147
|
+
action: AuditLogAction;
|
|
5148
|
+
actorUserId: ObjectID;
|
|
5149
|
+
rules: Array<Model>;
|
|
5150
|
+
updatedFields?: JSONObject | undefined;
|
|
5151
|
+
props: DatabaseCommonInteractionProps;
|
|
5152
|
+
}): Promise<void> {
|
|
5153
|
+
const notifiedOwnerIds: Set<string> = new Set<string>();
|
|
5154
|
+
|
|
5155
|
+
for (const rule of data.rules) {
|
|
5156
|
+
const ruleOwnerUserId: ObjectID | undefined = rule.userId;
|
|
5157
|
+
|
|
5158
|
+
if (
|
|
5159
|
+
!ruleOwnerUserId ||
|
|
5160
|
+
ruleOwnerUserId.toString() === data.actorUserId.toString()
|
|
5161
|
+
) {
|
|
2970
5162
|
continue;
|
|
2971
5163
|
}
|
|
2972
5164
|
|
|
2973
|
-
const
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
5165
|
+
const ownerKey: string = ruleOwnerUserId.toString();
|
|
5166
|
+
const isFirstRuleForThisOwner: boolean = !notifiedOwnerIds.has(ownerKey);
|
|
5167
|
+
notifiedOwnerIds.add(ownerKey);
|
|
5168
|
+
|
|
5169
|
+
await UserNotificationRuleAdminService.recordAdminRuleChange({
|
|
5170
|
+
action: data.action,
|
|
5171
|
+
actorUserId: data.actorUserId,
|
|
5172
|
+
ownerUserId: ruleOwnerUserId,
|
|
5173
|
+
projectId: rule.projectId || data.props.tenantId,
|
|
5174
|
+
ruleId: rule.id,
|
|
5175
|
+
before: rule,
|
|
5176
|
+
updatedFields: data.updatedFields,
|
|
5177
|
+
notifyOwner: isFirstRuleForThisOwner,
|
|
5178
|
+
props: data.props,
|
|
5179
|
+
});
|
|
5180
|
+
}
|
|
5181
|
+
}
|
|
2980
5182
|
|
|
2981
|
-
|
|
2982
|
-
|
|
5183
|
+
/**
|
|
5184
|
+
* The delete-path guard, and the reason it is a guard at all.
|
|
5185
|
+
*
|
|
5186
|
+
* Deleting somebody's notification rules is the most destructive of the three
|
|
5187
|
+
* write verbs and, until this hook existed, the only unguarded one: the model
|
|
5188
|
+
* opened `delete` to the administrative permissions, and nothing here noticed.
|
|
5189
|
+
* An admin — or anyone who had got hold of an admin session — could remove a
|
|
5190
|
+
* responder's entire paging configuration and leave no record and no warning.
|
|
5191
|
+
* The person it happened to would find out during an incident.
|
|
5192
|
+
*
|
|
5193
|
+
* What a delete guard can and cannot be. There is no R3 analogue: a deleted
|
|
5194
|
+
* row routes nothing anywhere, so there is no method-versus-owner pair left to
|
|
5195
|
+
* disagree. Nor is there an R1 analogue: refusing to delete the rules of
|
|
5196
|
+
* somebody who is no longer on the roster would block exactly the cleanup an
|
|
5197
|
+
* admin performs after a member leaves. What is left, and what actually
|
|
5198
|
+
* matters, is EVIDENTIARY — establish from the database who owned these rows
|
|
5199
|
+
* before they cease to exist, because after the write nothing can answer that
|
|
5200
|
+
* question and the request body was never allowed to.
|
|
5201
|
+
*
|
|
5202
|
+
* The rows must be read here rather than in onDeleteSuccess for the same
|
|
5203
|
+
* reason: _deleteBy hands the success hook only the ids it deleted, and by
|
|
5204
|
+
* then the rows are gone.
|
|
5205
|
+
*
|
|
5206
|
+
* The query is narrowed first. onBeforeDelete runs BEFORE
|
|
5207
|
+
* ModelPermission.checkDeleteQueryPermission, exactly as onBeforeUpdate runs
|
|
5208
|
+
* before its update counterpart, so the raw query carries neither the tenant
|
|
5209
|
+
* predicate nor the ownership predicate — see narrowQueryToCallerEntitlement.
|
|
5210
|
+
* Without it, a member could point this read at another project's rules and
|
|
5211
|
+
* have their owners written into the audit trail and mailed a warning about a
|
|
5212
|
+
* deletion that never touched them.
|
|
5213
|
+
*/
|
|
5214
|
+
@CaptureSpan()
|
|
5215
|
+
protected override async onBeforeDelete(
|
|
5216
|
+
deleteBy: DeleteBy<Model>,
|
|
5217
|
+
): Promise<OnDelete<Model>> {
|
|
5218
|
+
if (!deleteBy.props.userId) {
|
|
5219
|
+
/*
|
|
5220
|
+
* No actor, nothing to attribute. Workers and migrations delete rules
|
|
5221
|
+
* (project teardown, method removal cascades) and an "an administrator
|
|
5222
|
+
* deleted your rules" mail for every one of those is how the message
|
|
5223
|
+
* that matters gets filtered away.
|
|
5224
|
+
*/
|
|
5225
|
+
return {
|
|
5226
|
+
deleteBy,
|
|
5227
|
+
carryForward: null,
|
|
5228
|
+
};
|
|
5229
|
+
}
|
|
5230
|
+
|
|
5231
|
+
const deletedRules: Array<Model> = await this.findBy({
|
|
5232
|
+
query: this.narrowQueryToCallerEntitlement(
|
|
5233
|
+
deleteBy.query,
|
|
5234
|
+
deleteBy.props,
|
|
5235
|
+
DatabaseRequestType.Delete,
|
|
5236
|
+
),
|
|
5237
|
+
select: {
|
|
5238
|
+
_id: true,
|
|
5239
|
+
userId: true,
|
|
5240
|
+
projectId: true,
|
|
5241
|
+
ruleType: true,
|
|
5242
|
+
notifyAfterMinutes: true,
|
|
5243
|
+
isOptOut: true,
|
|
5244
|
+
incidentSeverityId: true,
|
|
5245
|
+
alertSeverityId: true,
|
|
5246
|
+
userEmailId: true,
|
|
5247
|
+
userSmsId: true,
|
|
5248
|
+
userCallId: true,
|
|
5249
|
+
userWhatsAppId: true,
|
|
5250
|
+
userTelegramId: true,
|
|
5251
|
+
userPushId: true,
|
|
5252
|
+
userWebhookId: true,
|
|
5253
|
+
},
|
|
5254
|
+
limit: LIMIT_MAX,
|
|
5255
|
+
skip: 0,
|
|
5256
|
+
props: {
|
|
5257
|
+
isRoot: true,
|
|
5258
|
+
ignoreHooks: true,
|
|
5259
|
+
},
|
|
5260
|
+
});
|
|
5261
|
+
|
|
5262
|
+
return {
|
|
5263
|
+
deleteBy,
|
|
5264
|
+
carryForward: {
|
|
5265
|
+
deletedRules: deletedRules,
|
|
5266
|
+
},
|
|
5267
|
+
};
|
|
5268
|
+
}
|
|
5269
|
+
|
|
5270
|
+
/*
|
|
5271
|
+
* R6 for the delete path.
|
|
5272
|
+
*
|
|
5273
|
+
* Keyed the same way as the other two: the actor the SERVER resolved against
|
|
5274
|
+
* the owner the DATABASE recorded, read before the rows were removed. The
|
|
5275
|
+
* snapshot carried forward is now the only description of what those rules
|
|
5276
|
+
* were, so it is what the audit entry is built from.
|
|
5277
|
+
*/
|
|
5278
|
+
@CaptureSpan()
|
|
5279
|
+
protected override async onDeleteSuccess(
|
|
5280
|
+
onDelete: OnDelete<Model>,
|
|
5281
|
+
deletedItemIds: Array<ObjectID>,
|
|
5282
|
+
): Promise<OnDelete<Model>> {
|
|
5283
|
+
const actorUserId: ObjectID | undefined = onDelete.deleteBy.props.userId;
|
|
5284
|
+
|
|
5285
|
+
if (!actorUserId) {
|
|
5286
|
+
return onDelete;
|
|
5287
|
+
}
|
|
5288
|
+
|
|
5289
|
+
const deletedRules: Array<Model> =
|
|
5290
|
+
(onDelete.carryForward?.deletedRules as Array<Model> | undefined) || [];
|
|
5291
|
+
|
|
5292
|
+
const deletedIds: Set<string> = new Set<string>(
|
|
5293
|
+
deletedItemIds.map((id: ObjectID): string => {
|
|
5294
|
+
return id.toString();
|
|
5295
|
+
}),
|
|
5296
|
+
);
|
|
5297
|
+
|
|
5298
|
+
await this.reportAdministrativeChange({
|
|
5299
|
+
action: AuditLogAction.Delete,
|
|
5300
|
+
actorUserId: actorUserId,
|
|
5301
|
+
/*
|
|
5302
|
+
* Only rows the delete actually removed. The hook read every row the
|
|
5303
|
+
* narrowed query matched; _deleteBy then applied the caller's own
|
|
5304
|
+
* skip/limit on top, so the two sets are not always the same and a
|
|
5305
|
+
* warning about a rule that still exists is a false alarm.
|
|
5306
|
+
*/
|
|
5307
|
+
rules: deletedRules.filter((rule: Model): boolean => {
|
|
5308
|
+
return Boolean(rule.id && deletedIds.has(rule.id.toString()));
|
|
5309
|
+
}),
|
|
5310
|
+
props: onDelete.deleteBy.props,
|
|
5311
|
+
});
|
|
5312
|
+
|
|
5313
|
+
return onDelete;
|
|
5314
|
+
}
|
|
5315
|
+
|
|
5316
|
+
@CaptureSpan()
|
|
5317
|
+
public async addDefaultNotificationRulesForVerifiedMethod(data: {
|
|
5318
|
+
projectId: ObjectID;
|
|
5319
|
+
userId: ObjectID;
|
|
5320
|
+
notificationMethod: NotificationMethodDescriptor;
|
|
5321
|
+
}): Promise<void> {
|
|
5322
|
+
const { projectId, userId, notificationMethod } = data;
|
|
5323
|
+
|
|
5324
|
+
/*
|
|
5325
|
+
* Read each severity list once and reuse it for both rule types it drives.
|
|
5326
|
+
* Incident severities scope both ON_CALL_EXECUTED_INCIDENT and
|
|
5327
|
+
* ON_CALL_EXECUTED_INCIDENT_EPISODE; alert severities do the same for their
|
|
5328
|
+
* two.
|
|
5329
|
+
*/
|
|
5330
|
+
const incidentSeverityIds: Array<ObjectID> =
|
|
5331
|
+
await this.getIncidentSeverityIds(projectId);
|
|
5332
|
+
const alertSeverityIds: Array<ObjectID> =
|
|
5333
|
+
await this.getAlertSeverityIds(projectId);
|
|
5334
|
+
|
|
5335
|
+
await this.createSeverityScopedRules({
|
|
5336
|
+
projectId,
|
|
5337
|
+
userId,
|
|
5338
|
+
notificationMethod,
|
|
5339
|
+
ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT,
|
|
5340
|
+
severityIds: incidentSeverityIds,
|
|
5341
|
+
severityColumn: "incidentSeverityId",
|
|
5342
|
+
});
|
|
5343
|
+
|
|
5344
|
+
await this.createSeverityScopedRules({
|
|
5345
|
+
projectId,
|
|
5346
|
+
userId,
|
|
5347
|
+
notificationMethod,
|
|
5348
|
+
ruleType: NotificationRuleType.ON_CALL_EXECUTED_ALERT,
|
|
5349
|
+
severityIds: alertSeverityIds,
|
|
5350
|
+
severityColumn: "alertSeverityId",
|
|
5351
|
+
});
|
|
5352
|
+
|
|
5353
|
+
/*
|
|
5354
|
+
* The two episode rule types are severity-scoped as well, and used not to
|
|
5355
|
+
* be. UserOnCallLogService counts episode rules filtered by a concrete
|
|
5356
|
+
* severity id, and the episode rule pages in User Settings scope their
|
|
5357
|
+
* tables the same way — so a NULL-severity episode rule matched no page and
|
|
5358
|
+
* appeared in no table. Users got "defaults" that were unreachable and
|
|
5359
|
+
* invisible at the same time.
|
|
5360
|
+
*/
|
|
5361
|
+
await this.createSeverityScopedRules({
|
|
5362
|
+
projectId,
|
|
5363
|
+
userId,
|
|
5364
|
+
notificationMethod,
|
|
5365
|
+
ruleType: NotificationRuleType.ON_CALL_EXECUTED_ALERT_EPISODE,
|
|
5366
|
+
severityIds: alertSeverityIds,
|
|
5367
|
+
severityColumn: "alertSeverityId",
|
|
5368
|
+
});
|
|
5369
|
+
|
|
5370
|
+
await this.createSeverityScopedRules({
|
|
5371
|
+
projectId,
|
|
5372
|
+
userId,
|
|
5373
|
+
notificationMethod,
|
|
5374
|
+
ruleType: NotificationRuleType.ON_CALL_EXECUTED_INCIDENT_EPISODE,
|
|
5375
|
+
severityIds: incidentSeverityIds,
|
|
5376
|
+
severityColumn: "incidentSeverityId",
|
|
5377
|
+
});
|
|
5378
|
+
|
|
5379
|
+
/*
|
|
5380
|
+
* These two are about the user's shift, not about anything that fired, so
|
|
5381
|
+
* they legitimately have no severity and stay single rules.
|
|
5382
|
+
*/
|
|
5383
|
+
await this.createSingleRule(
|
|
5384
|
+
projectId,
|
|
5385
|
+
userId,
|
|
5386
|
+
notificationMethod,
|
|
5387
|
+
NotificationRuleType.WHEN_USER_GOES_ON_CALL,
|
|
5388
|
+
);
|
|
5389
|
+
await this.createSingleRule(
|
|
5390
|
+
projectId,
|
|
5391
|
+
userId,
|
|
5392
|
+
notificationMethod,
|
|
5393
|
+
NotificationRuleType.WHEN_USER_GOES_OFF_CALL,
|
|
5394
|
+
);
|
|
5395
|
+
}
|
|
5396
|
+
|
|
5397
|
+
private applyNotificationMethod(
|
|
5398
|
+
rule: Model,
|
|
5399
|
+
descriptor: NotificationMethodDescriptor,
|
|
5400
|
+
): void {
|
|
5401
|
+
if (descriptor.userEmailId) {
|
|
5402
|
+
rule.userEmailId = descriptor.userEmailId;
|
|
5403
|
+
}
|
|
5404
|
+
if (descriptor.userSmsId) {
|
|
5405
|
+
rule.userSmsId = descriptor.userSmsId;
|
|
5406
|
+
}
|
|
5407
|
+
if (descriptor.userCallId) {
|
|
5408
|
+
rule.userCallId = descriptor.userCallId;
|
|
5409
|
+
}
|
|
5410
|
+
if (descriptor.userWhatsAppId) {
|
|
5411
|
+
rule.userWhatsAppId = descriptor.userWhatsAppId;
|
|
5412
|
+
}
|
|
5413
|
+
if (descriptor.userTelegramId) {
|
|
5414
|
+
rule.userTelegramId = descriptor.userTelegramId;
|
|
5415
|
+
}
|
|
5416
|
+
if (descriptor.userWebhookId) {
|
|
5417
|
+
rule.userWebhookId = descriptor.userWebhookId;
|
|
5418
|
+
}
|
|
5419
|
+
if (descriptor.userPushId) {
|
|
5420
|
+
rule.userPushId = descriptor.userPushId;
|
|
5421
|
+
}
|
|
5422
|
+
}
|
|
5423
|
+
|
|
5424
|
+
private getNotificationMethodQuery(
|
|
5425
|
+
descriptor: NotificationMethodDescriptor,
|
|
5426
|
+
): Record<string, ObjectID> {
|
|
5427
|
+
const query: Record<string, ObjectID> = {};
|
|
5428
|
+
if (descriptor.userEmailId) {
|
|
5429
|
+
query["userEmailId"] = descriptor.userEmailId;
|
|
5430
|
+
}
|
|
5431
|
+
if (descriptor.userSmsId) {
|
|
5432
|
+
query["userSmsId"] = descriptor.userSmsId;
|
|
5433
|
+
}
|
|
5434
|
+
if (descriptor.userCallId) {
|
|
5435
|
+
query["userCallId"] = descriptor.userCallId;
|
|
5436
|
+
}
|
|
5437
|
+
if (descriptor.userWhatsAppId) {
|
|
5438
|
+
query["userWhatsAppId"] = descriptor.userWhatsAppId;
|
|
5439
|
+
}
|
|
5440
|
+
if (descriptor.userTelegramId) {
|
|
5441
|
+
query["userTelegramId"] = descriptor.userTelegramId;
|
|
5442
|
+
}
|
|
5443
|
+
if (descriptor.userWebhookId) {
|
|
5444
|
+
query["userWebhookId"] = descriptor.userWebhookId;
|
|
5445
|
+
}
|
|
5446
|
+
if (descriptor.userPushId) {
|
|
5447
|
+
query["userPushId"] = descriptor.userPushId;
|
|
5448
|
+
}
|
|
5449
|
+
return query;
|
|
5450
|
+
}
|
|
5451
|
+
|
|
5452
|
+
private async getIncidentSeverityIds(
|
|
5453
|
+
projectId: ObjectID,
|
|
5454
|
+
): Promise<Array<ObjectID>> {
|
|
5455
|
+
const incidentSeverities: Array<IncidentSeverity> =
|
|
5456
|
+
await IncidentSeverityService.findBy({
|
|
5457
|
+
query: {
|
|
5458
|
+
projectId,
|
|
5459
|
+
},
|
|
2983
5460
|
props: {
|
|
2984
5461
|
isRoot: true,
|
|
2985
5462
|
},
|
|
5463
|
+
limit: LIMIT_PER_PROJECT,
|
|
5464
|
+
skip: 0,
|
|
5465
|
+
select: {
|
|
5466
|
+
_id: true,
|
|
5467
|
+
},
|
|
2986
5468
|
});
|
|
2987
|
-
|
|
5469
|
+
|
|
5470
|
+
return incidentSeverities.map((severity: IncidentSeverity): ObjectID => {
|
|
5471
|
+
return severity.id!;
|
|
5472
|
+
});
|
|
2988
5473
|
}
|
|
2989
5474
|
|
|
2990
|
-
private async
|
|
5475
|
+
private async getAlertSeverityIds(
|
|
2991
5476
|
projectId: ObjectID,
|
|
2992
|
-
|
|
2993
|
-
notificationMethod: NotificationMethodDescriptor,
|
|
2994
|
-
): Promise<void> {
|
|
5477
|
+
): Promise<Array<ObjectID>> {
|
|
2995
5478
|
const alertSeverities: Array<AlertSeverity> =
|
|
2996
5479
|
await AlertSeverityService.findBy({
|
|
2997
5480
|
query: {
|
|
@@ -3007,14 +5490,33 @@ export class Service extends DatabaseService<Model> {
|
|
|
3007
5490
|
},
|
|
3008
5491
|
});
|
|
3009
5492
|
|
|
3010
|
-
|
|
5493
|
+
return alertSeverities.map((severity: AlertSeverity): ObjectID => {
|
|
5494
|
+
return severity.id!;
|
|
5495
|
+
});
|
|
5496
|
+
}
|
|
5497
|
+
|
|
5498
|
+
/*
|
|
5499
|
+
* Seed one rule per severity for a severity-scoped rule type, skipping any
|
|
5500
|
+
* (method, severity, ruleType) triple the user already has. The duplicate
|
|
5501
|
+
* check is keyed on the same columns the write sets, so re-verifying a method
|
|
5502
|
+
* never doubles a user's rules — and therefore never doubles their pages.
|
|
5503
|
+
*/
|
|
5504
|
+
private async createSeverityScopedRules(data: {
|
|
5505
|
+
projectId: ObjectID;
|
|
5506
|
+
userId: ObjectID;
|
|
5507
|
+
notificationMethod: NotificationMethodDescriptor;
|
|
5508
|
+
ruleType: NotificationRuleType;
|
|
5509
|
+
severityIds: Array<ObjectID>;
|
|
5510
|
+
severityColumn: "incidentSeverityId" | "alertSeverityId";
|
|
5511
|
+
}): Promise<void> {
|
|
5512
|
+
for (const severityId of data.severityIds) {
|
|
3011
5513
|
const existingRule: Model | null = await this.findOneBy({
|
|
3012
5514
|
query: {
|
|
3013
|
-
projectId,
|
|
3014
|
-
userId,
|
|
3015
|
-
...this.getNotificationMethodQuery(notificationMethod),
|
|
3016
|
-
|
|
3017
|
-
ruleType:
|
|
5515
|
+
projectId: data.projectId,
|
|
5516
|
+
userId: data.userId,
|
|
5517
|
+
...this.getNotificationMethodQuery(data.notificationMethod),
|
|
5518
|
+
[data.severityColumn]: severityId,
|
|
5519
|
+
ruleType: data.ruleType,
|
|
3018
5520
|
} as any,
|
|
3019
5521
|
props: {
|
|
3020
5522
|
isRoot: true,
|
|
@@ -3026,12 +5528,12 @@ export class Service extends DatabaseService<Model> {
|
|
|
3026
5528
|
}
|
|
3027
5529
|
|
|
3028
5530
|
const rule: Model = new Model();
|
|
3029
|
-
rule.projectId = projectId;
|
|
3030
|
-
rule.userId = userId;
|
|
3031
|
-
this.applyNotificationMethod(rule, notificationMethod);
|
|
3032
|
-
rule.
|
|
5531
|
+
rule.projectId = data.projectId;
|
|
5532
|
+
rule.userId = data.userId;
|
|
5533
|
+
this.applyNotificationMethod(rule, data.notificationMethod);
|
|
5534
|
+
rule[data.severityColumn] = severityId;
|
|
3033
5535
|
rule.notifyAfterMinutes = 0;
|
|
3034
|
-
rule.ruleType =
|
|
5536
|
+
rule.ruleType = data.ruleType;
|
|
3035
5537
|
|
|
3036
5538
|
await this.create({
|
|
3037
5539
|
data: rule,
|
|
@@ -3119,5 +5621,982 @@ export class Service extends DatabaseService<Model> {
|
|
|
3119
5621
|
},
|
|
3120
5622
|
});
|
|
3121
5623
|
}
|
|
5624
|
+
|
|
5625
|
+
/**
|
|
5626
|
+
* What this user would lose by deleting these notification rules.
|
|
5627
|
+
*
|
|
5628
|
+
* Ask this BEFORE deleting. It reads and returns; it writes nothing and
|
|
5629
|
+
* refuses nothing, and the caller is expected to go ahead and delete anyway
|
|
5630
|
+
* if that is what the human wants after reading it.
|
|
5631
|
+
*
|
|
5632
|
+
* The rule ids are INTERSECTED with the rules this user actually has in this
|
|
5633
|
+
* project rather than trusted — the read is scoped by (projectId, userId) in
|
|
5634
|
+
* the query itself, so an id belonging to somebody else, or to another
|
|
5635
|
+
* project, simply matches nothing and contributes nothing to the answer.
|
|
5636
|
+
* That is the only place row scoping can come from: a column access list
|
|
5637
|
+
* cannot restrict WHICH ROWS a caller sees, because Permission.CurrentUser is
|
|
5638
|
+
* auto-granted to every authenticated caller and so never means "only my own
|
|
5639
|
+
* row".
|
|
5640
|
+
*
|
|
5641
|
+
* An empty id list is legal and returns a zero-deletion impact, which is
|
|
5642
|
+
* still worth something: it carries this user's responder status, their
|
|
5643
|
+
* reachability and whether the project's fallback is on.
|
|
5644
|
+
*
|
|
5645
|
+
* ---------------------------------------------------------------------------
|
|
5646
|
+
* NOT WIRED TODAY. READ THIS BEFORE ASSUMING IT GUARDS ANYTHING.
|
|
5647
|
+
*
|
|
5648
|
+
* This method and getNotificationMethodDeletionImpact below have NO production
|
|
5649
|
+
* caller. The delete guard that actually ships is client-side, in
|
|
5650
|
+
* App/FeatureSet/Dashboard/src/Components/NotificationMethods/NotificationMethod.tsx
|
|
5651
|
+
* (useNotificationMethodDeleteGuard + DeletionImpactModal), and it computes the
|
|
5652
|
+
* same answer in the browser from the deleting user's OWN rules.
|
|
5653
|
+
*
|
|
5654
|
+
* That is adequate for the case that ships: a person deleting their own
|
|
5655
|
+
* notification method or their own rule can read all of their own rules, and
|
|
5656
|
+
* one user's rule set is small and bounded. It is NOT adequate for an
|
|
5657
|
+
* administrator computing the impact of deleting somebody ELSE's
|
|
5658
|
+
* configuration, which is what this exists for - and that is Phase 3, which is
|
|
5659
|
+
* not merged.
|
|
5660
|
+
*
|
|
5661
|
+
* So this is deliberately-retained, currently-unreachable code, kept because
|
|
5662
|
+
* the admin path needs exactly it and because it holds one thing the browser
|
|
5663
|
+
* copy cannot: it reads EVERY rule for the user rather than a page, and it
|
|
5664
|
+
* derives a rule's severity from the column its RULE TYPE dictates rather than
|
|
5665
|
+
* whichever column happens to be populated.
|
|
5666
|
+
*
|
|
5667
|
+
* Retaining unreachable code is a real cost and this comment is the price of
|
|
5668
|
+
* it: nothing here enforces anything server-side today. A deletion is not
|
|
5669
|
+
* validated, refused or even observed by this service. If you are reading this
|
|
5670
|
+
* because you assumed the server checked, it does not.
|
|
5671
|
+
* ---------------------------------------------------------------------------
|
|
5672
|
+
*/
|
|
5673
|
+
@CaptureSpan()
|
|
5674
|
+
public async getRuleDeletionImpact(data: {
|
|
5675
|
+
projectId: ObjectID;
|
|
5676
|
+
userId: ObjectID;
|
|
5677
|
+
notificationRuleIds: Array<ObjectID>;
|
|
5678
|
+
}): Promise<NotificationDeletionImpact> {
|
|
5679
|
+
const targetRuleIds: Set<string> = new Set<string>(
|
|
5680
|
+
data.notificationRuleIds.map((ruleId: ObjectID): string => {
|
|
5681
|
+
return ruleId.toString();
|
|
5682
|
+
}),
|
|
5683
|
+
);
|
|
5684
|
+
|
|
5685
|
+
return this.computeDeletionImpact({
|
|
5686
|
+
projectId: data.projectId,
|
|
5687
|
+
userId: data.userId,
|
|
5688
|
+
isBeingDeleted: (rule: Model): boolean => {
|
|
5689
|
+
return targetRuleIds.has(rule.id?.toString() || "");
|
|
5690
|
+
},
|
|
5691
|
+
deletedMethod: undefined,
|
|
5692
|
+
});
|
|
5693
|
+
}
|
|
5694
|
+
|
|
5695
|
+
/**
|
|
5696
|
+
* What this user would lose by deleting one notification method.
|
|
5697
|
+
*
|
|
5698
|
+
* This is the dangerous one. Deleting a method is not a small write: every
|
|
5699
|
+
* method foreign key on UserNotificationRule is onDelete: "CASCADE", and each
|
|
5700
|
+
* method service deletes the same rows itself in onBeforeDelete, so removing
|
|
5701
|
+
* one phone number takes every rule that pointed at it with it. Somebody
|
|
5702
|
+
* tidying up an old number has no reason to expect that, and nothing on the
|
|
5703
|
+
* screen tells them.
|
|
5704
|
+
*
|
|
5705
|
+
* The method row is looked up scoped by projectId, and the userId comes from
|
|
5706
|
+
* the ROW rather than from the caller. Both matter: the projectId scope is
|
|
5707
|
+
* what stops a caller probing method ids from other projects, and taking the
|
|
5708
|
+
* userId from the row is what stops a caller asking for one user's method
|
|
5709
|
+
* under another user's name and getting an answer that belongs to neither.
|
|
5710
|
+
*/
|
|
5711
|
+
@CaptureSpan()
|
|
5712
|
+
public async getNotificationMethodDeletionImpact(data: {
|
|
5713
|
+
projectId: ObjectID;
|
|
5714
|
+
methodType: ReadinessMethodType;
|
|
5715
|
+
methodId: ObjectID;
|
|
5716
|
+
}): Promise<NotificationDeletionImpact> {
|
|
5717
|
+
const method: DeletedNotificationMethod =
|
|
5718
|
+
await this.resolveNotificationMethod(data);
|
|
5719
|
+
|
|
5720
|
+
return this.computeDeletionImpact({
|
|
5721
|
+
projectId: data.projectId,
|
|
5722
|
+
userId: method.userId,
|
|
5723
|
+
isBeingDeleted: (rule: Model): boolean => {
|
|
5724
|
+
return (
|
|
5725
|
+
this.getRuleMethodId(rule, data.methodType)?.toString() ===
|
|
5726
|
+
data.methodId.toString()
|
|
5727
|
+
);
|
|
5728
|
+
},
|
|
5729
|
+
deletedMethod: method,
|
|
5730
|
+
});
|
|
5731
|
+
}
|
|
5732
|
+
|
|
5733
|
+
/**
|
|
5734
|
+
* Read the method row being deleted, scoped to the project, and reduce it to
|
|
5735
|
+
* the three things the preview needs: whose it is, which channel it is on,
|
|
5736
|
+
* and whether it was ever verified.
|
|
5737
|
+
*
|
|
5738
|
+
* Webhooks report isVerified: true with no column behind it. UserWebhook has
|
|
5739
|
+
* no verification concept at all — its presence IS the whole test, which is
|
|
5740
|
+
* how the fallback treats it and how readiness reports it — so calling it
|
|
5741
|
+
* unverified here would tell somebody that deleting the one channel
|
|
5742
|
+
* guaranteed to work costs them nothing.
|
|
5743
|
+
*/
|
|
5744
|
+
private async resolveNotificationMethod(data: {
|
|
5745
|
+
projectId: ObjectID;
|
|
5746
|
+
methodType: ReadinessMethodType;
|
|
5747
|
+
methodId: ObjectID;
|
|
5748
|
+
}): Promise<DeletedNotificationMethod> {
|
|
5749
|
+
const query: {
|
|
5750
|
+
_id: ObjectID;
|
|
5751
|
+
projectId: ObjectID;
|
|
5752
|
+
} = {
|
|
5753
|
+
_id: data.methodId,
|
|
5754
|
+
projectId: data.projectId,
|
|
5755
|
+
};
|
|
5756
|
+
|
|
5757
|
+
const props: { isRoot: boolean } = {
|
|
5758
|
+
isRoot: true,
|
|
5759
|
+
};
|
|
5760
|
+
|
|
5761
|
+
type ResolvedRow = {
|
|
5762
|
+
userId?: ObjectID | undefined;
|
|
5763
|
+
isVerified?: boolean | undefined;
|
|
5764
|
+
} | null;
|
|
5765
|
+
|
|
5766
|
+
let row: ResolvedRow = null;
|
|
5767
|
+
let isVerified: boolean = false;
|
|
5768
|
+
|
|
5769
|
+
if (data.methodType === ReadinessMethodType.Email) {
|
|
5770
|
+
row = await UserEmailService.findOneBy({
|
|
5771
|
+
query: query,
|
|
5772
|
+
select: { _id: true, userId: true, isVerified: true },
|
|
5773
|
+
props: props,
|
|
5774
|
+
});
|
|
5775
|
+
isVerified = Boolean(row?.isVerified);
|
|
5776
|
+
} else if (data.methodType === ReadinessMethodType.SMS) {
|
|
5777
|
+
row = await UserSmsService.findOneBy({
|
|
5778
|
+
query: query,
|
|
5779
|
+
select: { _id: true, userId: true, isVerified: true },
|
|
5780
|
+
props: props,
|
|
5781
|
+
});
|
|
5782
|
+
isVerified = Boolean(row?.isVerified);
|
|
5783
|
+
} else if (data.methodType === ReadinessMethodType.Call) {
|
|
5784
|
+
row = await UserCallService.findOneBy({
|
|
5785
|
+
query: query,
|
|
5786
|
+
select: { _id: true, userId: true, isVerified: true },
|
|
5787
|
+
props: props,
|
|
5788
|
+
});
|
|
5789
|
+
isVerified = Boolean(row?.isVerified);
|
|
5790
|
+
} else if (data.methodType === ReadinessMethodType.Push) {
|
|
5791
|
+
row = await UserPushService.findOneBy({
|
|
5792
|
+
query: query,
|
|
5793
|
+
select: { _id: true, userId: true, isVerified: true },
|
|
5794
|
+
props: props,
|
|
5795
|
+
});
|
|
5796
|
+
isVerified = Boolean(row?.isVerified);
|
|
5797
|
+
} else if (data.methodType === ReadinessMethodType.WhatsApp) {
|
|
5798
|
+
row = await UserWhatsAppService.findOneBy({
|
|
5799
|
+
query: query,
|
|
5800
|
+
select: { _id: true, userId: true, isVerified: true },
|
|
5801
|
+
props: props,
|
|
5802
|
+
});
|
|
5803
|
+
isVerified = Boolean(row?.isVerified);
|
|
5804
|
+
} else if (data.methodType === ReadinessMethodType.Telegram) {
|
|
5805
|
+
row = await UserTelegramService.findOneBy({
|
|
5806
|
+
query: query,
|
|
5807
|
+
select: { _id: true, userId: true, isVerified: true },
|
|
5808
|
+
props: props,
|
|
5809
|
+
});
|
|
5810
|
+
isVerified = Boolean(row?.isVerified);
|
|
5811
|
+
} else if (data.methodType === ReadinessMethodType.Webhook) {
|
|
5812
|
+
row = await UserWebhookService.findOneBy({
|
|
5813
|
+
query: query,
|
|
5814
|
+
select: { _id: true, userId: true },
|
|
5815
|
+
props: props,
|
|
5816
|
+
});
|
|
5817
|
+
isVerified = Boolean(row);
|
|
5818
|
+
} else {
|
|
5819
|
+
throw new BadDataException(
|
|
5820
|
+
`${data.methodType} is not a notification method`,
|
|
5821
|
+
);
|
|
5822
|
+
}
|
|
5823
|
+
|
|
5824
|
+
if (!row || !row.userId) {
|
|
5825
|
+
throw new BadDataException("Notification method not found");
|
|
5826
|
+
}
|
|
5827
|
+
|
|
5828
|
+
return {
|
|
5829
|
+
methodType: data.methodType,
|
|
5830
|
+
userId: row.userId,
|
|
5831
|
+
isVerified: isVerified,
|
|
5832
|
+
};
|
|
5833
|
+
}
|
|
5834
|
+
|
|
5835
|
+
/**
|
|
5836
|
+
* The foreign key a rule uses to point at a method of this channel. One
|
|
5837
|
+
* lookup table rather than seven inline comparisons, so a rule can never be
|
|
5838
|
+
* tested against the wrong column — which would report a WhatsApp rule as
|
|
5839
|
+
* surviving the deletion of the SMS number it does not use, or worse, the
|
|
5840
|
+
* reverse.
|
|
5841
|
+
*/
|
|
5842
|
+
private getRuleMethodId(
|
|
5843
|
+
rule: Model,
|
|
5844
|
+
methodType: ReadinessMethodType,
|
|
5845
|
+
): ObjectID | undefined {
|
|
5846
|
+
if (methodType === ReadinessMethodType.Email) {
|
|
5847
|
+
return rule.userEmailId;
|
|
5848
|
+
}
|
|
5849
|
+
|
|
5850
|
+
if (methodType === ReadinessMethodType.SMS) {
|
|
5851
|
+
return rule.userSmsId;
|
|
5852
|
+
}
|
|
5853
|
+
|
|
5854
|
+
if (methodType === ReadinessMethodType.Call) {
|
|
5855
|
+
return rule.userCallId;
|
|
5856
|
+
}
|
|
5857
|
+
|
|
5858
|
+
if (methodType === ReadinessMethodType.Push) {
|
|
5859
|
+
return rule.userPushId;
|
|
5860
|
+
}
|
|
5861
|
+
|
|
5862
|
+
if (methodType === ReadinessMethodType.WhatsApp) {
|
|
5863
|
+
return rule.userWhatsAppId;
|
|
5864
|
+
}
|
|
5865
|
+
|
|
5866
|
+
if (methodType === ReadinessMethodType.Telegram) {
|
|
5867
|
+
return rule.userTelegramId;
|
|
5868
|
+
}
|
|
5869
|
+
|
|
5870
|
+
if (methodType === ReadinessMethodType.Webhook) {
|
|
5871
|
+
return rule.userWebhookId;
|
|
5872
|
+
}
|
|
5873
|
+
|
|
5874
|
+
return undefined;
|
|
5875
|
+
}
|
|
5876
|
+
|
|
5877
|
+
/**
|
|
5878
|
+
* The before/after picture both entry points share.
|
|
5879
|
+
*
|
|
5880
|
+
* Deliberately shaped as "read every rule this user has, then ask a predicate
|
|
5881
|
+
* which of them go" rather than "count the rules that go". Coverage is a
|
|
5882
|
+
* property of what is LEFT, so the rules that survive are as load-bearing as
|
|
5883
|
+
* the ones that do not: a cell with two rules on two methods loses nothing
|
|
5884
|
+
* when one of them goes, and the only way to know that is to have read both.
|
|
5885
|
+
*/
|
|
5886
|
+
private async computeDeletionImpact(data: {
|
|
5887
|
+
projectId: ObjectID;
|
|
5888
|
+
userId: ObjectID;
|
|
5889
|
+
isBeingDeleted: (rule: Model) => boolean;
|
|
5890
|
+
deletedMethod: DeletedNotificationMethod | undefined;
|
|
5891
|
+
}): Promise<NotificationDeletionImpact> {
|
|
5892
|
+
const cellStates: Map<string, DeletionCellState> = new Map<
|
|
5893
|
+
string,
|
|
5894
|
+
DeletionCellState
|
|
5895
|
+
>();
|
|
5896
|
+
const handoffStates: Map<NotificationRuleType, DeletionHandoffState> =
|
|
5897
|
+
new Map<NotificationRuleType, DeletionHandoffState>();
|
|
5898
|
+
|
|
5899
|
+
let rulesDeletedCount: number = 0;
|
|
5900
|
+
|
|
5901
|
+
type FoldRuleFunction = (rule: Model) => void;
|
|
5902
|
+
|
|
5903
|
+
/*
|
|
5904
|
+
* Folded as each page arrives rather than accumulated and folded after.
|
|
5905
|
+
* Every rule collapses into one of a few dozen cells, so holding the rows
|
|
5906
|
+
* would mean carrying the whole table in memory to produce a map orders of
|
|
5907
|
+
* magnitude smaller — and the one project where that matters is exactly the
|
|
5908
|
+
* project where this read takes more than one page.
|
|
5909
|
+
*/
|
|
5910
|
+
const foldRule: FoldRuleFunction = (rule: Model): void => {
|
|
5911
|
+
const isDeleted: boolean = data.isBeingDeleted(rule);
|
|
5912
|
+
|
|
5913
|
+
if (isDeleted) {
|
|
5914
|
+
rulesDeletedCount++;
|
|
5915
|
+
}
|
|
5916
|
+
|
|
5917
|
+
/*
|
|
5918
|
+
* `isOptOut === true`, never `=== false`. The column is nullable and was
|
|
5919
|
+
* added long after these rows started existing, so it is NULL on every
|
|
5920
|
+
* rule in every existing install; testing for false would classify all of
|
|
5921
|
+
* them as neither rules nor opt-outs and report a fully configured user
|
|
5922
|
+
* as having nothing to lose. This is the exact dual of the predicate
|
|
5923
|
+
* readiness folds with, and it has to stay that way.
|
|
5924
|
+
*/
|
|
5925
|
+
const isOptOut: boolean = rule.isOptOut === true;
|
|
5926
|
+
|
|
5927
|
+
const scope: PagingRuleTypeScope | undefined =
|
|
5928
|
+
PAGING_RULE_TYPE_SCOPES.find(
|
|
5929
|
+
(candidate: PagingRuleTypeScope): boolean => {
|
|
5930
|
+
return candidate.ruleType === rule.ruleType;
|
|
5931
|
+
},
|
|
5932
|
+
);
|
|
5933
|
+
|
|
5934
|
+
if (scope) {
|
|
5935
|
+
const severityId: ObjectID | undefined = rule[scope.severityColumn];
|
|
5936
|
+
|
|
5937
|
+
/*
|
|
5938
|
+
* A severity-scoped rule with a NULL severity matches no page at
|
|
5939
|
+
* runtime, so it covers no cell and losing it costs nothing. Counting
|
|
5940
|
+
* it would promise coverage that never existed.
|
|
5941
|
+
*/
|
|
5942
|
+
if (!severityId) {
|
|
5943
|
+
return;
|
|
5944
|
+
}
|
|
5945
|
+
|
|
5946
|
+
const key: string = `${scope.ruleType}|${severityId.toString()}`;
|
|
5947
|
+
|
|
5948
|
+
const state: DeletionCellState = cellStates.get(key) || {
|
|
5949
|
+
ruleType: scope.ruleType,
|
|
5950
|
+
severityKind: scope.severityKind,
|
|
5951
|
+
severityId: severityId.toString(),
|
|
5952
|
+
rulesBefore: 0,
|
|
5953
|
+
rulesRemoved: 0,
|
|
5954
|
+
hasOptOut: false,
|
|
5955
|
+
};
|
|
5956
|
+
|
|
5957
|
+
if (isOptOut) {
|
|
5958
|
+
/*
|
|
5959
|
+
* Only a SURVIVING opt-out makes the silence deliberate. Deleting the
|
|
5960
|
+
* opt-out along with the last rule leaves neither, and that cell is a
|
|
5961
|
+
* real gap however it was created.
|
|
5962
|
+
*/
|
|
5963
|
+
if (!isDeleted) {
|
|
5964
|
+
state.hasOptOut = true;
|
|
5965
|
+
}
|
|
5966
|
+
} else {
|
|
5967
|
+
state.rulesBefore++;
|
|
5968
|
+
|
|
5969
|
+
if (isDeleted) {
|
|
5970
|
+
state.rulesRemoved++;
|
|
5971
|
+
}
|
|
5972
|
+
}
|
|
5973
|
+
|
|
5974
|
+
cellStates.set(key, state);
|
|
5975
|
+
|
|
5976
|
+
return;
|
|
5977
|
+
}
|
|
5978
|
+
|
|
5979
|
+
const handoffType: NotificationRuleType | undefined =
|
|
5980
|
+
HANDOFF_RULE_TYPES.find((candidate: NotificationRuleType): boolean => {
|
|
5981
|
+
return candidate === rule.ruleType;
|
|
5982
|
+
});
|
|
5983
|
+
|
|
5984
|
+
if (!handoffType) {
|
|
5985
|
+
// Not a rule type anything pages on. It covers nothing, so it loses nothing.
|
|
5986
|
+
return;
|
|
5987
|
+
}
|
|
5988
|
+
|
|
5989
|
+
const handoffState: DeletionHandoffState = handoffStates.get(
|
|
5990
|
+
handoffType,
|
|
5991
|
+
) || {
|
|
5992
|
+
rulesBefore: 0,
|
|
5993
|
+
rulesRemoved: 0,
|
|
5994
|
+
hasOptOut: false,
|
|
5995
|
+
};
|
|
5996
|
+
|
|
5997
|
+
if (isOptOut) {
|
|
5998
|
+
if (!isDeleted) {
|
|
5999
|
+
handoffState.hasOptOut = true;
|
|
6000
|
+
}
|
|
6001
|
+
} else {
|
|
6002
|
+
handoffState.rulesBefore++;
|
|
6003
|
+
|
|
6004
|
+
if (isDeleted) {
|
|
6005
|
+
handoffState.rulesRemoved++;
|
|
6006
|
+
}
|
|
6007
|
+
}
|
|
6008
|
+
|
|
6009
|
+
handoffStates.set(handoffType, handoffState);
|
|
6010
|
+
};
|
|
6011
|
+
|
|
6012
|
+
const isTruncated: boolean = await this.readEveryNotificationRuleForUser({
|
|
6013
|
+
projectId: data.projectId,
|
|
6014
|
+
userId: data.userId,
|
|
6015
|
+
consume: (rows: Array<Model>): void => {
|
|
6016
|
+
for (const rule of rows) {
|
|
6017
|
+
foldRule(rule);
|
|
6018
|
+
}
|
|
6019
|
+
},
|
|
6020
|
+
});
|
|
6021
|
+
|
|
6022
|
+
const lostCells: Array<DeletionCellState> = Array.from(
|
|
6023
|
+
cellStates.values(),
|
|
6024
|
+
).filter((state: DeletionCellState): boolean => {
|
|
6025
|
+
return (
|
|
6026
|
+
state.rulesBefore > 0 &&
|
|
6027
|
+
state.rulesRemoved === state.rulesBefore &&
|
|
6028
|
+
!state.hasOptOut
|
|
6029
|
+
);
|
|
6030
|
+
});
|
|
6031
|
+
|
|
6032
|
+
const handoffNotificationsLost: Array<NotificationRuleType> =
|
|
6033
|
+
HANDOFF_RULE_TYPES.filter((ruleType: NotificationRuleType): boolean => {
|
|
6034
|
+
const state: DeletionHandoffState | undefined =
|
|
6035
|
+
handoffStates.get(ruleType);
|
|
6036
|
+
|
|
6037
|
+
return Boolean(
|
|
6038
|
+
state &&
|
|
6039
|
+
state.rulesBefore > 0 &&
|
|
6040
|
+
state.rulesRemoved === state.rulesBefore &&
|
|
6041
|
+
!state.hasOptOut,
|
|
6042
|
+
);
|
|
6043
|
+
});
|
|
6044
|
+
|
|
6045
|
+
/*
|
|
6046
|
+
* Severity names are read only when something was actually lost. The common
|
|
6047
|
+
* case for this call is "nothing you care about goes", and that case should
|
|
6048
|
+
* not cost two extra round trips to name cells nobody will be shown.
|
|
6049
|
+
*/
|
|
6050
|
+
const severityNames: Map<string, DeletionSeverityRef> =
|
|
6051
|
+
await this.loadSeverityNamesForCells(data.projectId, lostCells);
|
|
6052
|
+
|
|
6053
|
+
const coverageLost: Array<CoverageLossCell> = this.buildCoverageLossCells(
|
|
6054
|
+
lostCells,
|
|
6055
|
+
severityNames,
|
|
6056
|
+
);
|
|
6057
|
+
|
|
6058
|
+
/*
|
|
6059
|
+
* "Is this person on call anywhere" comes from OnCallReadinessService and
|
|
6060
|
+
* from nowhere else. getReadinessForUsers rather than getReadinessForUser
|
|
6061
|
+
* because the plural form OMITS a user who is not a member of the project
|
|
6062
|
+
* instead of throwing for them: somebody being removed from a project is
|
|
6063
|
+
* one of the perfectly legitimate reasons their methods are being deleted,
|
|
6064
|
+
* and that must produce an honest "unknown" rather than an exception in
|
|
6065
|
+
* front of an admin doing housekeeping.
|
|
6066
|
+
*/
|
|
6067
|
+
const readinessList: Array<UserReadiness> =
|
|
6068
|
+
await OnCallReadinessService.getReadinessForUsers(
|
|
6069
|
+
[data.userId],
|
|
6070
|
+
data.projectId,
|
|
6071
|
+
);
|
|
6072
|
+
|
|
6073
|
+
const readiness: UserReadiness | undefined = readinessList[0];
|
|
6074
|
+
|
|
6075
|
+
const verifiedMethods: Array<ReadinessMethod> = (
|
|
6076
|
+
readiness?.methods || []
|
|
6077
|
+
).filter((method: ReadinessMethod): boolean => {
|
|
6078
|
+
return method.isVerified;
|
|
6079
|
+
});
|
|
6080
|
+
|
|
6081
|
+
const remainingVerifiedMethods: Array<ReadinessMethod> = [
|
|
6082
|
+
...verifiedMethods,
|
|
6083
|
+
];
|
|
6084
|
+
|
|
6085
|
+
if (data.deletedMethod && data.deletedMethod.isVerified) {
|
|
6086
|
+
/*
|
|
6087
|
+
* Readiness returns one entry per method ROW, and the row being deleted is
|
|
6088
|
+
* identified by its CHANNEL rather than by its id. Removing exactly ONE
|
|
6089
|
+
* entry of that channel is what makes the count right for a user with two
|
|
6090
|
+
* verified SMS numbers who is deleting one of them: the other survives,
|
|
6091
|
+
* and so does the entry.
|
|
6092
|
+
*
|
|
6093
|
+
* ReadinessMethod does now carry methodId, so an exact match is available;
|
|
6094
|
+
* matching on the channel is kept because it is equivalent HERE and not
|
|
6095
|
+
* because the id is missing. The only consumer of this list is
|
|
6096
|
+
* resolveReachability, which reads its LENGTH and its methodTypes and
|
|
6097
|
+
* nothing else, so which of two same-channel entries is dropped cannot
|
|
6098
|
+
* change the answer. Anything added below that cares about a specific row
|
|
6099
|
+
* must match on methodId instead — this equivalence is a property of the
|
|
6100
|
+
* current consumer, not a licence.
|
|
6101
|
+
*/
|
|
6102
|
+
const index: number = remainingVerifiedMethods.findIndex(
|
|
6103
|
+
(method: ReadinessMethod): boolean => {
|
|
6104
|
+
return method.methodType === data.deletedMethod?.methodType;
|
|
6105
|
+
},
|
|
6106
|
+
);
|
|
6107
|
+
|
|
6108
|
+
if (index >= 0) {
|
|
6109
|
+
remainingVerifiedMethods.splice(index, 1);
|
|
6110
|
+
}
|
|
6111
|
+
}
|
|
6112
|
+
|
|
6113
|
+
const reachability: PostDeletionReachability = this.resolveReachability(
|
|
6114
|
+
readiness,
|
|
6115
|
+
remainingVerifiedMethods,
|
|
6116
|
+
);
|
|
6117
|
+
|
|
6118
|
+
const project: Project | null = await ProjectService.findOneById({
|
|
6119
|
+
id: data.projectId,
|
|
6120
|
+
select: {
|
|
6121
|
+
_id: true,
|
|
6122
|
+
disableOnCallNotificationFallback: true,
|
|
6123
|
+
},
|
|
6124
|
+
props: {
|
|
6125
|
+
isRoot: true,
|
|
6126
|
+
},
|
|
6127
|
+
});
|
|
6128
|
+
|
|
6129
|
+
/*
|
|
6130
|
+
* Read exactly as readiness reads it, including what a missing project row
|
|
6131
|
+
* means. Agreeing with the readiness page matters more here than picking
|
|
6132
|
+
* the louder default independently would: two surfaces that disagree about
|
|
6133
|
+
* whether pages are dropped teach people to trust neither.
|
|
6134
|
+
*/
|
|
6135
|
+
const isFallbackEnabled: boolean =
|
|
6136
|
+
!project?.disableOnCallNotificationFallback;
|
|
6137
|
+
|
|
6138
|
+
return {
|
|
6139
|
+
projectId: data.projectId,
|
|
6140
|
+
userId: data.userId,
|
|
6141
|
+
isOnCallResponder: Boolean(readiness && readiness.reachedVia.length > 0),
|
|
6142
|
+
reachedVia: readiness?.reachedVia || [],
|
|
6143
|
+
rulesDeletedCount: rulesDeletedCount,
|
|
6144
|
+
coverageLost: coverageLost,
|
|
6145
|
+
handoffNotificationsLost: handoffNotificationsLost,
|
|
6146
|
+
reachability: reachability,
|
|
6147
|
+
verifiedMethodCountAfterDeletion: remainingVerifiedMethods.length,
|
|
6148
|
+
isFallbackEnabled: isFallbackEnabled,
|
|
6149
|
+
isTruncated: isTruncated,
|
|
6150
|
+
warnings: this.buildDeletionWarnings({
|
|
6151
|
+
deletedMethod: data.deletedMethod,
|
|
6152
|
+
rulesDeletedCount: rulesDeletedCount,
|
|
6153
|
+
readiness: readiness,
|
|
6154
|
+
reachability: reachability,
|
|
6155
|
+
remainingVerifiedMethods: remainingVerifiedMethods,
|
|
6156
|
+
coverageLost: coverageLost,
|
|
6157
|
+
handoffNotificationsLost: handoffNotificationsLost,
|
|
6158
|
+
isFallbackEnabled: isFallbackEnabled,
|
|
6159
|
+
isTruncated: isTruncated,
|
|
6160
|
+
}),
|
|
6161
|
+
};
|
|
6162
|
+
}
|
|
6163
|
+
|
|
6164
|
+
/**
|
|
6165
|
+
* Every notification rule this user has in this project, one page at a time.
|
|
6166
|
+
*
|
|
6167
|
+
* Returns whether the read was TRUNCATED rather than throwing or silently
|
|
6168
|
+
* stopping. A truncated read here is not symmetric in its consequences:
|
|
6169
|
+
* unread rules that would have survived make this over-warn, which costs a
|
|
6170
|
+
* moment of an admin's attention, while unread rules that would have been
|
|
6171
|
+
* DELETED make it under-warn, which is the whole failure this feature exists
|
|
6172
|
+
* to prevent. Either way the caller is told.
|
|
6173
|
+
*
|
|
6174
|
+
* The sort is `_id` ascending because OFFSET paging over a query with no
|
|
6175
|
+
* total order can return one row twice and skip another — and the default
|
|
6176
|
+
* sort would be `createdAt DESC`, which is emphatically not unique for a
|
|
6177
|
+
* user whose default rules were all written in one transaction.
|
|
6178
|
+
*/
|
|
6179
|
+
private async readEveryNotificationRuleForUser(data: {
|
|
6180
|
+
projectId: ObjectID;
|
|
6181
|
+
userId: ObjectID;
|
|
6182
|
+
consume: (rules: Array<Model>) => void;
|
|
6183
|
+
}): Promise<boolean> {
|
|
6184
|
+
let skip: number = 0;
|
|
6185
|
+
|
|
6186
|
+
for (let page: number = 0; page < MAX_DELETION_IMPACT_PAGES; page++) {
|
|
6187
|
+
const rows: Array<Model> = await this.findBy({
|
|
6188
|
+
query: {
|
|
6189
|
+
projectId: data.projectId,
|
|
6190
|
+
userId: data.userId,
|
|
6191
|
+
},
|
|
6192
|
+
select: {
|
|
6193
|
+
_id: true,
|
|
6194
|
+
ruleType: true,
|
|
6195
|
+
incidentSeverityId: true,
|
|
6196
|
+
alertSeverityId: true,
|
|
6197
|
+
isOptOut: true,
|
|
6198
|
+
userEmailId: true,
|
|
6199
|
+
userSmsId: true,
|
|
6200
|
+
userCallId: true,
|
|
6201
|
+
userPushId: true,
|
|
6202
|
+
userWhatsAppId: true,
|
|
6203
|
+
userTelegramId: true,
|
|
6204
|
+
userWebhookId: true,
|
|
6205
|
+
},
|
|
6206
|
+
sort: {
|
|
6207
|
+
_id: SortOrder.Ascending,
|
|
6208
|
+
} as Sort<Model>,
|
|
6209
|
+
limit: DELETION_IMPACT_PAGE_SIZE,
|
|
6210
|
+
skip: skip,
|
|
6211
|
+
props: {
|
|
6212
|
+
isRoot: true,
|
|
6213
|
+
},
|
|
6214
|
+
});
|
|
6215
|
+
|
|
6216
|
+
data.consume(rows);
|
|
6217
|
+
|
|
6218
|
+
if (rows.length < DELETION_IMPACT_PAGE_SIZE) {
|
|
6219
|
+
return false;
|
|
6220
|
+
}
|
|
6221
|
+
|
|
6222
|
+
skip += rows.length;
|
|
6223
|
+
}
|
|
6224
|
+
|
|
6225
|
+
logger.error(
|
|
6226
|
+
`UserNotificationRuleService stopped reading notification rules for user ${data.userId.toString()} in project ${data.projectId.toString()} after ${MAX_DELETION_IMPACT_PAGES} pages of ${DELETION_IMPACT_PAGE_SIZE} rows. The deletion impact for this user is INCOMPLETE and may understate what the deletion removes.`,
|
|
6227
|
+
);
|
|
6228
|
+
|
|
6229
|
+
return true;
|
|
6230
|
+
}
|
|
6231
|
+
|
|
6232
|
+
/**
|
|
6233
|
+
* Display names for the severities of the cells that are actually lost, in
|
|
6234
|
+
* the project's own severity order.
|
|
6235
|
+
*
|
|
6236
|
+
* Not paged, unlike the rule read: severity lists are a handful of rows per
|
|
6237
|
+
* project by construction (they are a UI-managed enumeration, not user data),
|
|
6238
|
+
* and LIMIT_PER_PROJECT is three orders of magnitude past any of them.
|
|
6239
|
+
*/
|
|
6240
|
+
private async loadSeverityNamesForCells(
|
|
6241
|
+
projectId: ObjectID,
|
|
6242
|
+
lostCells: Array<DeletionCellState>,
|
|
6243
|
+
): Promise<Map<string, DeletionSeverityRef>> {
|
|
6244
|
+
const severityNames: Map<string, DeletionSeverityRef> = new Map<
|
|
6245
|
+
string,
|
|
6246
|
+
DeletionSeverityRef
|
|
6247
|
+
>();
|
|
6248
|
+
|
|
6249
|
+
const needsIncident: boolean = lostCells.some(
|
|
6250
|
+
(cell: DeletionCellState): boolean => {
|
|
6251
|
+
return cell.severityKind === SeverityKind.Incident;
|
|
6252
|
+
},
|
|
6253
|
+
);
|
|
6254
|
+
|
|
6255
|
+
const needsAlert: boolean = lostCells.some(
|
|
6256
|
+
(cell: DeletionCellState): boolean => {
|
|
6257
|
+
return cell.severityKind === SeverityKind.Alert;
|
|
6258
|
+
},
|
|
6259
|
+
);
|
|
6260
|
+
|
|
6261
|
+
if (needsIncident) {
|
|
6262
|
+
const incidentSeverities: Array<IncidentSeverity> =
|
|
6263
|
+
await IncidentSeverityService.findBy({
|
|
6264
|
+
query: {
|
|
6265
|
+
projectId: projectId,
|
|
6266
|
+
},
|
|
6267
|
+
select: {
|
|
6268
|
+
_id: true,
|
|
6269
|
+
name: true,
|
|
6270
|
+
},
|
|
6271
|
+
sort: {
|
|
6272
|
+
order: SortOrder.Ascending,
|
|
6273
|
+
},
|
|
6274
|
+
limit: LIMIT_PER_PROJECT,
|
|
6275
|
+
skip: 0,
|
|
6276
|
+
props: {
|
|
6277
|
+
isRoot: true,
|
|
6278
|
+
},
|
|
6279
|
+
});
|
|
6280
|
+
|
|
6281
|
+
incidentSeverities.forEach(
|
|
6282
|
+
(severity: IncidentSeverity, index: number): void => {
|
|
6283
|
+
if (!severity.id) {
|
|
6284
|
+
return;
|
|
6285
|
+
}
|
|
6286
|
+
|
|
6287
|
+
severityNames.set(
|
|
6288
|
+
this.severityNameKey(SeverityKind.Incident, severity.id.toString()),
|
|
6289
|
+
{
|
|
6290
|
+
name: severity.name || "Unnamed Severity",
|
|
6291
|
+
rank: index,
|
|
6292
|
+
},
|
|
6293
|
+
);
|
|
6294
|
+
},
|
|
6295
|
+
);
|
|
6296
|
+
}
|
|
6297
|
+
|
|
6298
|
+
if (needsAlert) {
|
|
6299
|
+
const alertSeverities: Array<AlertSeverity> =
|
|
6300
|
+
await AlertSeverityService.findBy({
|
|
6301
|
+
query: {
|
|
6302
|
+
projectId: projectId,
|
|
6303
|
+
},
|
|
6304
|
+
select: {
|
|
6305
|
+
_id: true,
|
|
6306
|
+
name: true,
|
|
6307
|
+
},
|
|
6308
|
+
sort: {
|
|
6309
|
+
order: SortOrder.Ascending,
|
|
6310
|
+
},
|
|
6311
|
+
limit: LIMIT_PER_PROJECT,
|
|
6312
|
+
skip: 0,
|
|
6313
|
+
props: {
|
|
6314
|
+
isRoot: true,
|
|
6315
|
+
},
|
|
6316
|
+
});
|
|
6317
|
+
|
|
6318
|
+
alertSeverities.forEach(
|
|
6319
|
+
(severity: AlertSeverity, index: number): void => {
|
|
6320
|
+
if (!severity.id) {
|
|
6321
|
+
return;
|
|
6322
|
+
}
|
|
6323
|
+
|
|
6324
|
+
severityNames.set(
|
|
6325
|
+
this.severityNameKey(SeverityKind.Alert, severity.id.toString()),
|
|
6326
|
+
{
|
|
6327
|
+
name: severity.name || "Unnamed Severity",
|
|
6328
|
+
rank: index,
|
|
6329
|
+
},
|
|
6330
|
+
);
|
|
6331
|
+
},
|
|
6332
|
+
);
|
|
6333
|
+
}
|
|
6334
|
+
|
|
6335
|
+
return severityNames;
|
|
6336
|
+
}
|
|
6337
|
+
|
|
6338
|
+
private severityNameKey(kind: SeverityKind, severityId: string): string {
|
|
6339
|
+
/*
|
|
6340
|
+
* Keyed by KIND as well as by id. Incident and alert severities are
|
|
6341
|
+
* different tables with independently generated ids, and a map keyed on the
|
|
6342
|
+
* id alone would let one name a cell of the other kind if the two ever
|
|
6343
|
+
* collided — a one-in-a-uuid event that would be indistinguishable from a
|
|
6344
|
+
* mislabelled warning if it happened.
|
|
6345
|
+
*/
|
|
6346
|
+
return `${kind}|${severityId}`;
|
|
6347
|
+
}
|
|
6348
|
+
|
|
6349
|
+
private buildCoverageLossCells(
|
|
6350
|
+
lostCells: Array<DeletionCellState>,
|
|
6351
|
+
severityNames: Map<string, DeletionSeverityRef>,
|
|
6352
|
+
): Array<CoverageLossCell> {
|
|
6353
|
+
const ordered: Array<DeletionCellState> = [...lostCells].sort(
|
|
6354
|
+
(a: DeletionCellState, b: DeletionCellState): number => {
|
|
6355
|
+
const ruleTypeDifference: number =
|
|
6356
|
+
this.pagingRuleTypeRank(a.ruleType) -
|
|
6357
|
+
this.pagingRuleTypeRank(b.ruleType);
|
|
6358
|
+
|
|
6359
|
+
if (ruleTypeDifference !== 0) {
|
|
6360
|
+
return ruleTypeDifference;
|
|
6361
|
+
}
|
|
6362
|
+
|
|
6363
|
+
/*
|
|
6364
|
+
* Severity order, not alphabetical. "Sev1, Sev2, Sev3" happens to sort
|
|
6365
|
+
* both ways; "Critical, High, Low" does not, and a warning that lists
|
|
6366
|
+
* severities in an order the user has never seen them in reads as a
|
|
6367
|
+
* different set of severities.
|
|
6368
|
+
*/
|
|
6369
|
+
return (
|
|
6370
|
+
this.severityRank(a, severityNames) -
|
|
6371
|
+
this.severityRank(b, severityNames)
|
|
6372
|
+
);
|
|
6373
|
+
},
|
|
6374
|
+
);
|
|
6375
|
+
|
|
6376
|
+
return ordered.map((cell: DeletionCellState): CoverageLossCell => {
|
|
6377
|
+
const ref: DeletionSeverityRef | undefined = severityNames.get(
|
|
6378
|
+
this.severityNameKey(cell.severityKind, cell.severityId),
|
|
6379
|
+
);
|
|
6380
|
+
|
|
6381
|
+
return {
|
|
6382
|
+
ruleType: cell.ruleType,
|
|
6383
|
+
severityId: new ObjectID(cell.severityId),
|
|
6384
|
+
severityName: ref?.name,
|
|
6385
|
+
rulesRemoved: cell.rulesRemoved,
|
|
6386
|
+
};
|
|
6387
|
+
});
|
|
6388
|
+
}
|
|
6389
|
+
|
|
6390
|
+
private pagingRuleTypeRank(ruleType: NotificationRuleType): number {
|
|
6391
|
+
const index: number = PAGING_RULE_TYPE_SCOPES.findIndex(
|
|
6392
|
+
(scope: PagingRuleTypeScope): boolean => {
|
|
6393
|
+
return scope.ruleType === ruleType;
|
|
6394
|
+
},
|
|
6395
|
+
);
|
|
6396
|
+
|
|
6397
|
+
return index < 0 ? PAGING_RULE_TYPE_SCOPES.length : index;
|
|
6398
|
+
}
|
|
6399
|
+
|
|
6400
|
+
private severityRank(
|
|
6401
|
+
cell: DeletionCellState,
|
|
6402
|
+
severityNames: Map<string, DeletionSeverityRef>,
|
|
6403
|
+
): number {
|
|
6404
|
+
const ref: DeletionSeverityRef | undefined = severityNames.get(
|
|
6405
|
+
this.severityNameKey(cell.severityKind, cell.severityId),
|
|
6406
|
+
);
|
|
6407
|
+
|
|
6408
|
+
/*
|
|
6409
|
+
* A severity we could not name sorts last rather than first. It is the one
|
|
6410
|
+
* entry whose sentence will read "this severity", and burying it under the
|
|
6411
|
+
* ones that read properly costs nothing; leading with it looks like a bug.
|
|
6412
|
+
*/
|
|
6413
|
+
return ref ? ref.rank : Number.MAX_SAFE_INTEGER;
|
|
6414
|
+
}
|
|
6415
|
+
|
|
6416
|
+
private resolveReachability(
|
|
6417
|
+
readiness: UserReadiness | undefined,
|
|
6418
|
+
remainingVerifiedMethods: Array<ReadinessMethod>,
|
|
6419
|
+
): PostDeletionReachability {
|
|
6420
|
+
if (!readiness) {
|
|
6421
|
+
return PostDeletionReachability.Unknown;
|
|
6422
|
+
}
|
|
6423
|
+
|
|
6424
|
+
/*
|
|
6425
|
+
* Checked first, and it is not merely a nicety of wording. Readiness says
|
|
6426
|
+
* NotReachable when the user has no USABLE method — which includes the case
|
|
6427
|
+
* where every verified method they own is on a channel the project has
|
|
6428
|
+
* switched off — so this branch is also what keeps the branches below from
|
|
6429
|
+
* promising "still reachable" on the strength of a verified method that
|
|
6430
|
+
* nothing can send on.
|
|
6431
|
+
*/
|
|
6432
|
+
if (readiness.status === ReadinessStatus.NotReachable) {
|
|
6433
|
+
return PostDeletionReachability.AlreadyNotReachable;
|
|
6434
|
+
}
|
|
6435
|
+
|
|
6436
|
+
if (remainingVerifiedMethods.length === 0) {
|
|
6437
|
+
return PostDeletionReachability.NotReachable;
|
|
6438
|
+
}
|
|
6439
|
+
|
|
6440
|
+
const unswitchableChannels: Array<string> = channelsWithNoProjectSwitch();
|
|
6441
|
+
|
|
6442
|
+
const hasUnswitchableChannel: boolean = remainingVerifiedMethods.some(
|
|
6443
|
+
(method: ReadinessMethod): boolean => {
|
|
6444
|
+
return unswitchableChannels.includes(method.methodType);
|
|
6445
|
+
},
|
|
6446
|
+
);
|
|
6447
|
+
|
|
6448
|
+
if (hasUnswitchableChannel) {
|
|
6449
|
+
return PostDeletionReachability.Reachable;
|
|
6450
|
+
}
|
|
6451
|
+
|
|
6452
|
+
return PostDeletionReachability.DependsOnProjectSettings;
|
|
6453
|
+
}
|
|
6454
|
+
|
|
6455
|
+
/**
|
|
6456
|
+
* The sentences a human reads in the confirmation dialog, most consequential
|
|
6457
|
+
* first.
|
|
6458
|
+
*
|
|
6459
|
+
* Every line names a specific thing that is lost AND what happens because of
|
|
6460
|
+
* it, which is the same contract OnCallReadinessService.buildReasons keeps
|
|
6461
|
+
* and for the same reason: "no rule for Sev4" is a shrug, "no rule for Sev4,
|
|
6462
|
+
* and those pages are dropped" is a decision. Nothing here says "are you
|
|
6463
|
+
* sure?" — the caller owns the question, and this owns the facts it is asked
|
|
6464
|
+
* about.
|
|
6465
|
+
*/
|
|
6466
|
+
private buildDeletionWarnings(data: {
|
|
6467
|
+
deletedMethod: DeletedNotificationMethod | undefined;
|
|
6468
|
+
rulesDeletedCount: number;
|
|
6469
|
+
readiness: UserReadiness | undefined;
|
|
6470
|
+
reachability: PostDeletionReachability;
|
|
6471
|
+
remainingVerifiedMethods: Array<ReadinessMethod>;
|
|
6472
|
+
coverageLost: Array<CoverageLossCell>;
|
|
6473
|
+
handoffNotificationsLost: Array<NotificationRuleType>;
|
|
6474
|
+
isFallbackEnabled: boolean;
|
|
6475
|
+
isTruncated: boolean;
|
|
6476
|
+
}): Array<string> {
|
|
6477
|
+
const warnings: Array<string> = [];
|
|
6478
|
+
|
|
6479
|
+
/*
|
|
6480
|
+
* The cascade goes first because it is the part nobody clicked. Everything
|
|
6481
|
+
* below is a consequence of it, and a list that started with the
|
|
6482
|
+
* consequences would read as though the notification rules were being
|
|
6483
|
+
* deleted for no reason at all.
|
|
6484
|
+
*/
|
|
6485
|
+
if (data.deletedMethod && data.rulesDeletedCount > 0) {
|
|
6486
|
+
warnings.push(
|
|
6487
|
+
`Deleting this ${data.deletedMethod.methodType} notification method also deletes ${data.rulesDeletedCount} notification ${data.rulesDeletedCount === 1 ? "rule" : "rules"} that use it - a notification rule cannot outlive the method it sends on.`,
|
|
6488
|
+
);
|
|
6489
|
+
}
|
|
6490
|
+
|
|
6491
|
+
if (data.reachability === PostDeletionReachability.NotReachable) {
|
|
6492
|
+
warnings.push(
|
|
6493
|
+
"This is the last verified notification method on this account - after it there is nothing left to page this user on, and the on-call fallback has nothing to fall back to either.",
|
|
6494
|
+
);
|
|
6495
|
+
}
|
|
6496
|
+
|
|
6497
|
+
if (!data.readiness) {
|
|
6498
|
+
warnings.push(
|
|
6499
|
+
"Whether this user is on call could not be determined - they may no longer be a member of this project.",
|
|
6500
|
+
);
|
|
6501
|
+
} else if (data.readiness.reachedVia.length > 0) {
|
|
6502
|
+
const sources: Array<string> = data.readiness.reachedVia.map(
|
|
6503
|
+
(source: ResponderSource): string => {
|
|
6504
|
+
return responderSourceProse(source);
|
|
6505
|
+
},
|
|
6506
|
+
);
|
|
6507
|
+
|
|
6508
|
+
warnings.push(
|
|
6509
|
+
`This user is on call in this project (${sources.join(", ")}), so anything lost here is a page that does not arrive.`,
|
|
6510
|
+
);
|
|
6511
|
+
} else {
|
|
6512
|
+
/*
|
|
6513
|
+
* Said out loud rather than left as silence. "Not on call" is the one
|
|
6514
|
+
* answer that makes this whole dialog safe to click through, and an admin
|
|
6515
|
+
* who has to infer it from the absence of a warning will infer it wrongly
|
|
6516
|
+
* at least once.
|
|
6517
|
+
*/
|
|
6518
|
+
warnings.push(
|
|
6519
|
+
"This user is not on any on-call policy right now, so nothing here can cost a page today - it will if they are ever added to one.",
|
|
6520
|
+
);
|
|
6521
|
+
}
|
|
6522
|
+
|
|
6523
|
+
for (const scope of PAGING_RULE_TYPE_SCOPES) {
|
|
6524
|
+
const severityNames: Array<string> = data.coverageLost
|
|
6525
|
+
.filter((cell: CoverageLossCell): boolean => {
|
|
6526
|
+
return cell.ruleType === scope.ruleType;
|
|
6527
|
+
})
|
|
6528
|
+
.map((cell: CoverageLossCell): string => {
|
|
6529
|
+
return cell.severityName || "this severity";
|
|
6530
|
+
});
|
|
6531
|
+
|
|
6532
|
+
if (severityNames.length === 0) {
|
|
6533
|
+
continue;
|
|
6534
|
+
}
|
|
6535
|
+
|
|
6536
|
+
/*
|
|
6537
|
+
* One sentence per rule type listing its severities, not one per cell. A
|
|
6538
|
+
* user deleting a method that carried all their default rules would
|
|
6539
|
+
* otherwise be handed one line per (rule type x severity) — sixteen
|
|
6540
|
+
* near-identical sentences that nobody reads to the end of.
|
|
6541
|
+
*/
|
|
6542
|
+
const subject: string = `After this, no rule covers ${severityNames.join(", ")} ${scope.subjectNoun}`;
|
|
6543
|
+
|
|
6544
|
+
if (data.isFallbackEnabled) {
|
|
6545
|
+
warnings.push(
|
|
6546
|
+
`${subject} - those pages fall back to whatever this user has verified, which is not what they configured`,
|
|
6547
|
+
);
|
|
6548
|
+
} else {
|
|
6549
|
+
warnings.push(
|
|
6550
|
+
`${subject} - those pages are dropped, because on-call fallback is disabled for this project`,
|
|
6551
|
+
);
|
|
6552
|
+
}
|
|
6553
|
+
}
|
|
6554
|
+
|
|
6555
|
+
if (
|
|
6556
|
+
data.handoffNotificationsLost.includes(
|
|
6557
|
+
NotificationRuleType.WHEN_USER_GOES_ON_CALL,
|
|
6558
|
+
)
|
|
6559
|
+
) {
|
|
6560
|
+
warnings.push("This user will no longer be told when they go on call.");
|
|
6561
|
+
}
|
|
6562
|
+
|
|
6563
|
+
if (
|
|
6564
|
+
data.handoffNotificationsLost.includes(
|
|
6565
|
+
NotificationRuleType.WHEN_USER_GOES_OFF_CALL,
|
|
6566
|
+
)
|
|
6567
|
+
) {
|
|
6568
|
+
warnings.push("This user will no longer be told when they go off call.");
|
|
6569
|
+
}
|
|
6570
|
+
|
|
6571
|
+
if (data.reachability === PostDeletionReachability.AlreadyNotReachable) {
|
|
6572
|
+
warnings.push(
|
|
6573
|
+
"This user already has no usable notification method, so nothing can page them today either - this deletion is not what breaks it.",
|
|
6574
|
+
);
|
|
6575
|
+
}
|
|
6576
|
+
|
|
6577
|
+
if (
|
|
6578
|
+
data.reachability === PostDeletionReachability.DependsOnProjectSettings
|
|
6579
|
+
) {
|
|
6580
|
+
const channels: Array<string> = [];
|
|
6581
|
+
|
|
6582
|
+
for (const method of data.remainingVerifiedMethods) {
|
|
6583
|
+
if (!channels.includes(method.methodType)) {
|
|
6584
|
+
channels.push(method.methodType);
|
|
6585
|
+
}
|
|
6586
|
+
}
|
|
6587
|
+
|
|
6588
|
+
warnings.push(
|
|
6589
|
+
`Every verified method left on this account is on a channel the project can switch off (${channels.join(", ")}) - check On-Call > Readiness to confirm this user can still be paged.`,
|
|
6590
|
+
);
|
|
6591
|
+
}
|
|
6592
|
+
|
|
6593
|
+
if (data.isTruncated) {
|
|
6594
|
+
warnings.push(
|
|
6595
|
+
"This preview is incomplete - there are more notification rules on this account than it could read, so the real loss may be larger than what is listed here.",
|
|
6596
|
+
);
|
|
6597
|
+
}
|
|
6598
|
+
|
|
6599
|
+
return warnings;
|
|
6600
|
+
}
|
|
3122
6601
|
}
|
|
3123
6602
|
export default new Service();
|