@oneuptime/common 12.0.8 → 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.
Files changed (769) hide show
  1. package/Models/AnalyticsModels/MetricItemAggMV1mByK8sCluster.ts +1 -1
  2. package/Models/AnalyticsModels/MetricItemAggMV1mByService.ts +1 -1
  3. package/Models/DatabaseModels/AIConversation.ts +32 -0
  4. package/Models/DatabaseModels/AIConversationMessage.ts +41 -0
  5. package/Models/DatabaseModels/AlertEpisodeMember.ts +27 -0
  6. package/Models/DatabaseModels/IncidentEpisodeMember.ts +28 -0
  7. package/Models/DatabaseModels/Index.ts +12 -4
  8. package/Models/DatabaseModels/{TelemetryEntity.ts → InventoryItem.ts} +139 -9
  9. package/Models/DatabaseModels/InventoryItemCustomField.ts +434 -0
  10. package/Models/DatabaseModels/{TelemetryEntityRelationship.ts → InventoryItemRelationship.ts} +8 -8
  11. package/Models/DatabaseModels/NetworkDevice.ts +141 -0
  12. package/Models/DatabaseModels/NetworkDeviceLink.ts +699 -0
  13. package/Models/DatabaseModels/NetworkDeviceLinkRule.ts +467 -0
  14. package/Models/DatabaseModels/NetworkTopologySuppression.ts +429 -0
  15. package/Models/DatabaseModels/OnCallDutyPolicyFeed.ts +9 -0
  16. package/Models/DatabaseModels/Project.ts +78 -0
  17. package/Models/DatabaseModels/UserCall.ts +42 -0
  18. package/Models/DatabaseModels/UserEmail.ts +44 -0
  19. package/Models/DatabaseModels/UserNotificationRule.ts +425 -55
  20. package/Models/DatabaseModels/UserOnCallLogTimeline.ts +24 -2
  21. package/Models/DatabaseModels/UserPush.ts +49 -0
  22. package/Models/DatabaseModels/UserSMS.ts +43 -0
  23. package/Models/DatabaseModels/UserTelegram.ts +59 -0
  24. package/Models/DatabaseModels/UserWebhook.ts +52 -0
  25. package/Models/DatabaseModels/UserWhatsApp.ts +41 -0
  26. package/Models/DatabaseModels/WorkflowVariable.ts +12 -0
  27. package/Server/API/AIChatAPI.ts +315 -1
  28. package/Server/API/DashboardAPI.ts +217 -1
  29. package/Server/API/OnCallReadinessAPI.ts +841 -0
  30. package/Server/API/TeamComplianceAPI.ts +69 -17
  31. package/Server/API/TelemetryAPI.ts +220 -12
  32. package/Server/EnvironmentConfig.ts +52 -0
  33. package/Server/Infrastructure/Postgres/DataSourceOptions.ts +22 -0
  34. package/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.ts +4 -4
  35. package/Server/Infrastructure/Postgres/SchemaMigrations/1786200000000-RestoreDroppedUniqueIndexes.ts +5 -5
  36. package/Server/Infrastructure/Postgres/SchemaMigrations/1786625176831-AddMonitoringMethodToNetworkDevice.ts +35 -0
  37. package/Server/Infrastructure/Postgres/SchemaMigrations/1786634985763-AddNetworkDeviceLink.ts +82 -0
  38. package/Server/Infrastructure/Postgres/SchemaMigrations/1786639512056-AddNetworkDeviceLinkRule.ts +91 -0
  39. package/Server/Infrastructure/Postgres/SchemaMigrations/1786639972982-AddNetworkTopologySuppression.ts +47 -0
  40. package/Server/Infrastructure/Postgres/SchemaMigrations/1786800000000-RenameTelemetryEntityToInventoryItem.ts +255 -0
  41. package/Server/Infrastructure/Postgres/SchemaMigrations/1786900000000-AddInventoryItemArchiveAndCustomFields.ts +107 -0
  42. package/Server/Infrastructure/Postgres/SchemaMigrations/1787000000000-AddOnCallNotificationFallbackColumns.ts +90 -0
  43. package/Server/Infrastructure/Postgres/SchemaMigrations/1787100000000-AddAIConversationPageContext.ts +39 -0
  44. package/Server/Infrastructure/Postgres/SchemaMigrations/1787200000000-AddAIChatMessageFeedback.ts +33 -0
  45. package/Server/Infrastructure/Postgres/SchemaMigrations/1787300000000-AddEpisodeMemberNotifyIndexes.ts +59 -0
  46. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +20 -0
  47. package/Server/Infrastructure/Queue.ts +78 -13
  48. package/Server/Middleware/PublicDashboardRateLimit.ts +593 -0
  49. package/Server/Services/AIService.ts +7 -0
  50. package/Server/Services/AlertEpisodeStateTimelineService.ts +29 -0
  51. package/Server/Services/AlertSeverityService.ts +63 -0
  52. package/Server/Services/DashboardService.ts +9 -10
  53. package/Server/Services/DatabaseService.ts +32 -2
  54. package/Server/Services/IncidentEpisodeStateTimelineService.ts +29 -0
  55. package/Server/Services/IncidentSeverityService.ts +76 -0
  56. package/Server/Services/Index.ts +12 -4
  57. package/Server/Services/InventoryItemCustomFieldService.ts +9 -0
  58. package/Server/Services/{TelemetryEntityRelationshipService.ts → InventoryItemRelationshipService.ts} +4 -4
  59. package/Server/Services/{TelemetryEntityService.ts → InventoryItemService.ts} +89 -20
  60. package/Server/Services/LogAggregationService.ts +45 -8
  61. package/Server/Services/MetricAggregationService.ts +121 -0
  62. package/Server/Services/MetricService.ts +7 -7
  63. package/Server/Services/NetworkDeviceLinkRuleService.ts +10 -0
  64. package/Server/Services/NetworkDeviceLinkService.ts +84 -0
  65. package/Server/Services/NetworkDeviceService.ts +140 -0
  66. package/Server/Services/NetworkSiteService.ts +77 -25
  67. package/Server/Services/NetworkTopologySuppressionService.ts +84 -0
  68. package/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.ts +41 -29
  69. package/Server/Services/OnCallDutyPolicyExecutionLogService.ts +8 -0
  70. package/Server/Services/OnCallDutyPolicyExecutionLogTimelineService.ts +62 -13
  71. package/Server/Services/OnCallDutyPolicyScheduleService.ts +61 -1
  72. package/Server/Services/OnCallNotificationAlertingService.ts +742 -0
  73. package/Server/Services/OnCallReadinessService.ts +2803 -0
  74. package/Server/Services/OnCallSetupReminderService.ts +955 -0
  75. package/Server/Services/ProfileAggregationService.ts +123 -0
  76. package/Server/Services/StatusPageService.ts +9 -10
  77. package/Server/Services/TeamComplianceService.ts +429 -252
  78. package/Server/Services/UserCallService.ts +26 -1
  79. package/Server/Services/UserEmailService.ts +26 -1
  80. package/Server/Services/UserNotificationRuleAdminService.ts +1183 -0
  81. package/Server/Services/UserNotificationRuleService.ts +3812 -333
  82. package/Server/Services/UserOnCallLogService.ts +561 -48
  83. package/Server/Services/UserPushService.ts +29 -0
  84. package/Server/Services/UserService.ts +11 -0
  85. package/Server/Services/UserSmsService.ts +26 -1
  86. package/Server/Services/UserTelegramService.ts +24 -1
  87. package/Server/Services/UserWebhookService.ts +28 -1
  88. package/Server/Services/UserWhatsAppService.ts +24 -1
  89. package/Server/Types/Database/Permissions/BasePermission.ts +19 -0
  90. package/Server/Types/Database/Permissions/CreatePermission.ts +164 -0
  91. package/Server/Types/Database/Permissions/OwnerOnlyColumnPermission.ts +340 -0
  92. package/Server/Types/Database/Permissions/QueryPermission.ts +48 -0
  93. package/Server/Types/Database/Permissions/TenantPermission.ts +8 -1
  94. package/Server/Types/Workflow/Components/API/Delete.ts +1 -1
  95. package/Server/Types/Workflow/Components/API/Get.ts +1 -1
  96. package/Server/Types/Workflow/Components/API/Patch.ts +1 -1
  97. package/Server/Types/Workflow/Components/API/Post.ts +1 -1
  98. package/Server/Types/Workflow/Components/API/Put.ts +1 -1
  99. package/Server/Types/Workflow/Components/BaseModel/CreateManyBaseModel.ts +29 -5
  100. package/Server/Types/Workflow/Components/BaseModel/CreateOneBaseModel.ts +18 -10
  101. package/Server/Types/Workflow/Components/BaseModel/ModelArguments.ts +55 -0
  102. package/Server/Types/Workflow/Components/Conditions/IfElse.ts +3 -17
  103. package/Server/Types/Workflow/Components/Email.ts +25 -7
  104. package/Server/Types/Workflow/Components/JavaScript.ts +10 -3
  105. package/Server/Types/Workflow/Components/MicrosoftTeams/SendMessageToChannel.ts +1 -1
  106. package/Server/Utils/AI/Chat/ChatAgentRunner.ts +643 -48
  107. package/Server/Utils/AI/Chat/ObservabilityAssistant.ts +32 -3
  108. package/Server/Utils/AI/Chat/ObservabilityChatPrompt.ts +20 -6
  109. package/Server/Utils/AI/SRE/AIInvestigationEngine.ts +7 -0
  110. package/Server/Utils/AI/Toolbox/AIActionTools.ts +2 -2
  111. package/Server/Utils/AI/Toolbox/AIMetaTools.ts +863 -0
  112. package/Server/Utils/AI/Toolbox/AlertTools.ts +177 -15
  113. package/Server/Utils/AI/Toolbox/IncidentTools.ts +191 -10
  114. package/Server/Utils/AI/Toolbox/Index.ts +48 -0
  115. package/Server/Utils/AI/Toolbox/MonitorTools.ts +298 -11
  116. package/Server/Utils/AI/Toolbox/NoteWriteTools.ts +295 -0
  117. package/Server/Utils/AI/Toolbox/OnCallTools.ts +1246 -0
  118. package/Server/Utils/AI/Toolbox/RunbookTools.ts +424 -0
  119. package/Server/Utils/AI/Toolbox/SloTools.ts +456 -0
  120. package/Server/Utils/AI/Toolbox/StatusPageTools.ts +559 -0
  121. package/Server/Utils/AI/Toolbox/TeamTools.ts +327 -0
  122. package/Server/Utils/AI/Toolbox/TimelineTools.ts +615 -0
  123. package/Server/Utils/AI/Toolbox/WorkflowProbeTools.ts +664 -0
  124. package/Server/Utils/ClientIp.ts +221 -0
  125. package/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.ts +47 -0
  126. package/Server/Utils/Dashboard/PublicDashboardSloHistoryPolicy.ts +163 -0
  127. package/Server/Utils/Dashboard/PublicDashboardSloWidget.ts +147 -0
  128. package/Server/Utils/Express.ts +12 -17
  129. package/Server/Utils/LLM/LLMService.ts +85 -8
  130. package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +204 -10
  131. package/Server/Utils/SSRFProtection.ts +98 -23
  132. package/Server/Utils/StartServer.ts +12 -3
  133. package/Server/Utils/Telemetry/EntityRegistry.ts +205 -18
  134. package/Server/Utils/Telemetry/InventoryEntityRegistry.ts +25 -25
  135. package/Server/Utils/Telemetry/TelemetryEntity.ts +160 -52
  136. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.ts +7 -3
  137. package/Tests/App/Dashboard/AdminNotificationRulesPage.test.tsx +2146 -0
  138. package/Tests/App/Dashboard/CreateWorkflowModal.test.tsx +561 -0
  139. package/Tests/App/Dashboard/EscalationRuleReadiness.test.tsx +2470 -0
  140. package/Tests/App/Dashboard/OnCallPreventionGuards.test.tsx +1897 -0
  141. package/Tests/App/Dashboard/OnCallReadinessSurfaces.test.tsx +3606 -0
  142. package/Tests/App/Dashboard/OnCallRulesDeleteGuard.test.tsx +784 -0
  143. package/Tests/App/Dashboard/OnCallRulesTable.test.tsx +1119 -0
  144. package/Tests/App/Dashboard/SloWidgetFetching.test.tsx +531 -0
  145. package/Tests/App/Dashboard/UserSettingsSetupChecklistModel.test.ts +1312 -0
  146. package/Tests/App/Dashboard/UserSettingsSetupChecklistPage.test.tsx +1390 -0
  147. package/Tests/Models/InventoryItemModel.test.ts +174 -0
  148. package/Tests/Models/InventoryItemNaming.test.ts +302 -0
  149. package/Tests/Server/API/AIChatCancelAndFeedback.test.ts +437 -0
  150. package/Tests/Server/API/DashboardPublicRateLimit.test.ts +659 -0
  151. package/Tests/Server/API/DashboardPublicResourceListAPI.test.ts +18 -0
  152. package/Tests/Server/API/DashboardPublicSloAPI.test.ts +880 -0
  153. package/Tests/Server/API/Helpers.ts +24 -15
  154. package/Tests/Server/API/OnCallReadinessAPI.test.ts +2680 -0
  155. package/Tests/Server/API/OnCallSetupReminderAPI.test.ts +915 -0
  156. package/Tests/Server/Infrastructure/Postgres/EpisodeMemberNotifyIndexesMigration.test.ts +533 -0
  157. package/Tests/Server/Infrastructure/Postgres/InventoryItemArchiveMigration.test.ts +213 -0
  158. package/Tests/Server/Infrastructure/Postgres/RenameInventoryItemMigration.test.ts +432 -0
  159. package/Tests/Server/Infrastructure/Queue.test.ts +293 -0
  160. package/Tests/Server/Middleware/PublicDashboardRateLimit.test.ts +1645 -0
  161. package/Tests/Server/Services/AdminRuleEditGuards.test.ts +2848 -0
  162. package/Tests/Server/Services/DeliverNotificationForRuleExtraction.test.ts +1393 -0
  163. package/Tests/Server/Services/EpisodeRuleSeverityRepair.test.ts +1802 -0
  164. package/Tests/Server/Services/EpisodeStateTimelineNote.test.ts +304 -0
  165. package/Tests/Server/Services/InventoryItemDisplayName.test.ts +339 -0
  166. package/Tests/Server/Services/{TelemetryEntityManualCreate.test.ts → InventoryItemManualCreate.test.ts} +27 -27
  167. package/Tests/Server/Services/IpAllowlistSpoofing.test.ts +450 -0
  168. package/Tests/Server/Services/LogAggregationService.test.ts +235 -1
  169. package/Tests/Server/Services/MetricAggregationService.test.ts +231 -0
  170. package/Tests/Server/Services/MetricEntityMVKeyParity.test.ts +80 -29
  171. package/Tests/Server/Services/MetricServiceAggregate.test.ts +30 -30
  172. package/Tests/Server/Services/NetworkSiteService.test.ts +18 -3
  173. package/Tests/Server/Services/NotificationChannelEventCoverage.test.ts +1728 -0
  174. package/Tests/Server/Services/NotificationDeletionImpact.test.ts +2402 -0
  175. package/Tests/Server/Services/OnCallDutyPolicyExecutionLogTimelineGapFeed.test.ts +394 -0
  176. package/Tests/Server/Services/OnCallNotificationFallback.test.ts +1744 -0
  177. package/Tests/Server/Services/OnCallReadinessService.test.ts +4295 -0
  178. package/Tests/Server/Services/OnCallSetupReminder.test.ts +1272 -0
  179. package/Tests/Server/Services/OnCallWeeklyReadinessDigest.test.ts +1021 -0
  180. package/Tests/Server/Services/ProfileAggregationService.test.ts +296 -0
  181. package/Tests/Server/Services/SeverityCreationRuleBackfill.test.ts +1536 -0
  182. package/Tests/Server/Services/SeverityRuleBackfill.test.ts +1818 -0
  183. package/Tests/Server/Services/TeamComplianceServiceBehaviour.test.ts +1845 -0
  184. package/Tests/Server/Services/UserNotificationRuleAdminGuards.test.ts +1394 -0
  185. package/Tests/Server/Services/UserNotificationRuleDefaultCreation.test.ts +1166 -0
  186. package/Tests/Server/Services/UserNotificationRuleExecuteItem.test.ts +1468 -0
  187. package/Tests/Server/Services/UserOnCallLogNoNotificationRules.test.ts +1457 -0
  188. package/Tests/Server/Types/Database/Permissions/AdminNotificationRuleAccess.test.ts +1546 -0
  189. package/Tests/Server/Types/Database/Permissions/CreateOwnershipScoping.test.ts +529 -0
  190. package/Tests/Server/Types/Database/Permissions/OwnerOnlyColumns.test.ts +1219 -0
  191. package/Tests/Server/Types/Database/Permissions/UserNotificationRuleScoping.test.ts +1089 -0
  192. package/Tests/Server/Types/Workflow/Components/ApiComponentErrorPort.test.ts +2 -1
  193. package/Tests/Server/Types/Workflow/Components/BaseModelDatabaseComponents.test.ts +190 -0
  194. package/Tests/Server/Types/Workflow/Components/ChatWebhookComponents.test.ts +44 -14
  195. package/Tests/Server/Types/Workflow/Components/Email.test.ts +151 -0
  196. package/Tests/Server/Types/Workflow/Components/IfElse.test.ts +98 -0
  197. package/Tests/Server/Types/Workflow/Components/JavaScript.test.ts +51 -0
  198. package/Tests/Server/Utils/AI/AIMetaTools.test.ts +586 -0
  199. package/Tests/Server/Utils/AI/AlertMonitorFilters.test.ts +582 -0
  200. package/Tests/Server/Utils/AI/ChatAgentRunner.test.ts +726 -0
  201. package/Tests/Server/Utils/AI/IncidentToolsFilters.test.ts +315 -0
  202. package/Tests/Server/Utils/AI/LLMServiceStopReason.test.ts +314 -0
  203. package/Tests/Server/Utils/AI/LLMServiceToolCalling.test.ts +26 -3
  204. package/Tests/Server/Utils/AI/NoteWriteTools.test.ts +268 -0
  205. package/Tests/Server/Utils/AI/ObservabilityChatPrompt.test.ts +169 -0
  206. package/Tests/Server/Utils/AI/OnCallTools.test.ts +664 -0
  207. package/Tests/Server/Utils/AI/RunbookTools.test.ts +325 -0
  208. package/Tests/Server/Utils/AI/SloTools.test.ts +306 -0
  209. package/Tests/Server/Utils/AI/StatusPageTools.test.ts +391 -0
  210. package/Tests/Server/Utils/AI/TeamTools.test.ts +257 -0
  211. package/Tests/Server/Utils/AI/TimelineTools.test.ts +472 -0
  212. package/Tests/Server/Utils/AI/WorkflowProbeTools.test.ts +428 -0
  213. package/Tests/Server/Utils/ClientIp.test.ts +438 -0
  214. package/Tests/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.test.ts +171 -0
  215. package/Tests/Server/Utils/Dashboard/PublicDashboardSloHistoryPolicy.test.ts +383 -0
  216. package/Tests/Server/Utils/EntityRegistryRowFence.test.ts +23 -25
  217. package/Tests/Server/Utils/MicrosoftTeamsWebhookUrlValidation.test.ts +6 -0
  218. package/Tests/Server/Utils/Monitor/Criteria/DnssecMonitorCriteria.test.ts +307 -0
  219. package/Tests/Server/Utils/Monitor/Criteria/SSLMonitorCriteria.test.ts +468 -0
  220. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorTelemetryDeepLinks.test.ts +460 -0
  221. package/Tests/Server/Utils/ResponseRateLimitStatusCodes.test.ts +137 -0
  222. package/Tests/Server/Utils/SSRFProtectionBypasses.test.ts +40 -8
  223. package/Tests/Server/Utils/SSRFProtectionUserInfo.test.ts +351 -0
  224. package/Tests/Server/Utils/Telemetry/EntityRegistryRetirement.test.ts +531 -0
  225. package/Tests/Server/Utils/Telemetry/InventoryEntityRegistry.test.ts +15 -15
  226. package/Tests/Server/Utils/Telemetry/TelemetryEntity.test.ts +356 -39
  227. package/Tests/Types/IP/IP.test.ts +263 -0
  228. package/Tests/Types/IP/IPWhitelist.test.ts +197 -0
  229. package/Tests/Types/IP/IPv6.test.ts +12 -1
  230. package/Tests/Types/Monitor/SnmpOid.test.ts +64 -0
  231. package/Tests/Types/NetworkDevice/NetworkDeviceMonitoringMethod.test.ts +110 -0
  232. package/Tests/Types/OnCallDutyPolicy/LayerUtilMergeAudit.test.ts +106 -0
  233. package/Tests/Types/OnCallDutyPolicy/LayerUtilMergeDifferential.test.ts +511 -0
  234. package/Tests/Types/OnCallDutyPolicy/ScheduleCoverageEndToEnd.test.ts +489 -0
  235. package/Tests/Types/OnCallDutyPolicy/ScheduleCoverageGapTolerance.test.ts +479 -0
  236. package/Tests/Types/OnCallDutyPolicy/ScheduleCoverageState.test.ts +822 -0
  237. package/Tests/Types/Telemetry/EntityTypeGroups.test.ts +6 -6
  238. package/Tests/Types/Workflow/BaseModelComponents.test.ts +429 -0
  239. package/Tests/Types/Workflow/Components/BaseModel.test.ts +225 -0
  240. package/Tests/Types/Workflow/IntegrationCredentialMetadata.test.ts +100 -0
  241. package/Tests/Types/Workflow/TemplateSyntax.test.ts +112 -0
  242. package/Tests/Types/Workflow/Templates.test.ts +916 -101
  243. package/Tests/UI/Components/ActiveFilterChipsOpenRoute.test.tsx +82 -0
  244. package/Tests/UI/Components/ComponentsModal.test.tsx +564 -7
  245. package/Tests/UI/Components/KeyboardShortcut.test.tsx +95 -0
  246. package/Tests/UI/Components/LogDetailsPanelCrossSignal.test.tsx +448 -0
  247. package/Tests/UI/Components/LogsTableCrossLinks.test.tsx +263 -0
  248. package/Tests/UI/Components/PendingProjectInvitations.test.tsx +913 -0
  249. package/Tests/UI/Components/SimpleLogViewer.test.tsx +228 -0
  250. package/Tests/UI/Components/TableRowSelectability.test.tsx +312 -0
  251. package/Tests/UI/Components/Workflow/GraphLint.test.ts +72 -0
  252. package/Tests/UI/Components/Workflow/GraphLintSummary.test.ts +755 -0
  253. package/Tests/UI/Components/Workflow/ModelColumnEditor.test.ts +242 -7
  254. package/Tests/UI/Components/Workflow/ModelColumnEditorServerContract.test.ts +15 -7
  255. package/Tests/UI/Components/Workflow/ModelSchema.test.ts +242 -28
  256. package/Tests/UI/Components/Workflow/RunStatusWatcher.test.ts +59 -1
  257. package/Tests/UI/Components/Workflow/StepTraceViewer.test.tsx +256 -0
  258. package/Tests/UI/Components/Workflow/UseRunWatch.test.tsx +665 -0
  259. package/Tests/UI/Components/Workflow/WorkflowIssuesModal.test.tsx +485 -0
  260. package/Tests/UI/Components/Workflow/WorkflowLogModal.test.tsx +478 -0
  261. package/Tests/UI/Components/Workflow/WorkflowStatusBar.test.tsx +379 -0
  262. package/Tests/UI/EsbuildConfig.test.ts +607 -0
  263. package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +5 -0
  264. package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +11 -4
  265. package/Tests/UI/Utils/ModelAPICreateMiscData.test.ts +94 -0
  266. package/Tests/UI/Utils/Platform.test.ts +147 -0
  267. package/Tests/UI/Utils/ProjectInvitationDisplay.test.ts +357 -0
  268. package/Tests/Utils/Monitor/NetworkDeviceLinkRuleUtil.test.ts +198 -0
  269. package/Tests/Utils/Monitor/NetworkTopologyUtil.test.ts +487 -0
  270. package/Tests/Utils/Telemetry/CrossSignalScope.test.ts +698 -0
  271. package/Tests/__mocks__/bullmq.js +55 -0
  272. package/Types/AI/AIChatMessageStatus.ts +8 -1
  273. package/Types/AI/AIChatTypes.ts +12 -0
  274. package/Types/Database/AccessControl/OwnerOnlyColumn.ts +88 -0
  275. package/Types/Exception/ExceptionCode.ts +2 -0
  276. package/Types/Exception/ServiceUnavailableException.ts +8 -0
  277. package/Types/Exception/TooManyRequestsException.ts +8 -0
  278. package/Types/IP/IP.ts +93 -47
  279. package/Types/Monitor/SnmpMonitor/NetworkTopology.ts +49 -3
  280. package/Types/NetworkDevice/NetworkDeviceMonitoringMethod.ts +55 -0
  281. package/Types/OnCallDutyPolicy/Layer.ts +203 -149
  282. package/Types/OnCallDutyPolicy/OnCallDutyPolicyStatus.ts +13 -0
  283. package/Types/OnCallDutyPolicy/ScheduleShiftUtil.ts +155 -10
  284. package/Types/Permission.ts +193 -0
  285. package/Types/Telemetry/EntityRelationshipType.ts +1 -1
  286. package/Types/Telemetry/EntitySource.ts +1 -1
  287. package/Types/Telemetry/EntityType.ts +1 -1
  288. package/Types/Telemetry/EntityTypeGroups.ts +1 -1
  289. package/Types/Workflow/Components/BaseModel.ts +75 -29
  290. package/Types/Workflow/Components/Discord.ts +1 -0
  291. package/Types/Workflow/Components/Email.ts +12 -3
  292. package/Types/Workflow/Components/JavaScript.ts +7 -0
  293. package/Types/Workflow/Components/MicrosoftTeams.ts +3 -2
  294. package/Types/Workflow/Components/Slack.ts +1 -0
  295. package/Types/Workflow/Components/Telegram.ts +1 -0
  296. package/Types/Workflow/TemplateSyntax.ts +44 -0
  297. package/Types/Workflow/Templates.ts +2097 -45
  298. package/UI/Components/Calendar/Calendar.css +43 -0
  299. package/UI/Components/Calendar/Calendar.tsx +8 -0
  300. package/UI/Components/Card/Card.tsx +2 -2
  301. package/UI/Components/Checkbox/Checkbox.tsx +16 -0
  302. package/UI/Components/Dictionary/Dictionary.tsx +48 -9
  303. package/UI/Components/FormModal/BasicFormModal.tsx +2 -1
  304. package/UI/Components/Header/HeaderIconDropdownButton.tsx +53 -3
  305. package/UI/Components/Input/Input.tsx +1 -0
  306. package/UI/Components/KeyboardShortcut/KeyboardKey.ts +185 -0
  307. package/UI/Components/KeyboardShortcut/KeyboardShortcut.tsx +87 -0
  308. package/UI/Components/LogsViewer/LogsViewer.tsx +28 -0
  309. package/UI/Components/LogsViewer/components/ActiveFilterChips.tsx +31 -0
  310. package/UI/Components/LogsViewer/components/KeyboardShortcutsHelp.tsx +18 -18
  311. package/UI/Components/LogsViewer/components/LogDetailsPanel.tsx +363 -14
  312. package/UI/Components/LogsViewer/components/LogsAnalyticsView.tsx +11 -0
  313. package/UI/Components/LogsViewer/components/LogsTable.tsx +155 -12
  314. package/UI/Components/LogsViewer/components/LogsViewerToolbar.tsx +29 -0
  315. package/UI/Components/LogsViewer/types.ts +23 -0
  316. package/UI/Components/Markdown.tsx/MarkdownEditor.tsx +9 -2
  317. package/UI/Components/Navbar/NavBarMenuModal.tsx +15 -30
  318. package/UI/Components/ProjectInvitations/PendingProjectInvitations.tsx +442 -0
  319. package/UI/Components/SimpleLogViewer/SimpleLogViewer.tsx +23 -1
  320. package/UI/Components/Table/Table.tsx +49 -16
  321. package/UI/Components/Table/TableBody.tsx +53 -28
  322. package/UI/Components/Table/TableHeader.tsx +13 -0
  323. package/UI/Components/Table/TableRow.tsx +58 -26
  324. package/UI/Components/Workflow/ArgumentsForm.tsx +155 -9
  325. package/UI/Components/Workflow/ComponentReturnValueViewer.tsx +26 -0
  326. package/UI/Components/Workflow/ComponentSettingsModal.tsx +10 -1
  327. package/UI/Components/Workflow/ComponentValuePickerModal.tsx +135 -7
  328. package/UI/Components/Workflow/ComponentsModal.tsx +116 -22
  329. package/UI/Components/Workflow/DocumentationViewer.tsx +59 -9
  330. package/UI/Components/Workflow/GraphLint.ts +42 -5
  331. package/UI/Components/Workflow/GraphLintSummary.ts +390 -0
  332. package/UI/Components/Workflow/ModelColumnEditor.tsx +154 -22
  333. package/UI/Components/Workflow/ModelSchema.ts +115 -33
  334. package/UI/Components/Workflow/RunStatusWatcher.ts +1 -1
  335. package/UI/Components/Workflow/StepTraceViewer.tsx +1 -1
  336. package/UI/Components/Workflow/UseRunWatch.ts +212 -0
  337. package/UI/Components/Workflow/VariableModal.tsx +6 -2
  338. package/UI/Components/Workflow/Workflow.tsx +49 -3
  339. package/UI/Components/Workflow/WorkflowIssuesModal.tsx +255 -0
  340. package/UI/Components/Workflow/WorkflowLogModal.tsx +128 -0
  341. package/UI/Components/Workflow/WorkflowStatusBar.tsx +224 -0
  342. package/UI/Utils/AIChatExport/ConversationMarkdown.ts +10 -0
  343. package/UI/Utils/ModelAPI/ModelAPI.ts +7 -1
  344. package/UI/Utils/Platform.ts +149 -0
  345. package/UI/Utils/ProjectInvitationDisplay.ts +118 -0
  346. package/UI/esbuild-config.js +22 -1
  347. package/Utils/Monitor/NetworkDeviceLinkRuleUtil.ts +187 -0
  348. package/Utils/Monitor/NetworkTopologyUtil.ts +888 -164
  349. package/Utils/Telemetry/CrossSignalScope.ts +502 -0
  350. package/Utils/Telemetry/EntityKey.ts +5 -5
  351. package/Utils/Telemetry/EntityRelationship.ts +1 -1
  352. package/build/dist/Models/AnalyticsModels/MetricItemAggMV1mByK8sCluster.js +1 -1
  353. package/build/dist/Models/AnalyticsModels/MetricItemAggMV1mByService.js +1 -1
  354. package/build/dist/Models/DatabaseModels/AIConversation.js +32 -0
  355. package/build/dist/Models/DatabaseModels/AIConversation.js.map +1 -1
  356. package/build/dist/Models/DatabaseModels/AIConversationMessage.js +42 -0
  357. package/build/dist/Models/DatabaseModels/AIConversationMessage.js.map +1 -1
  358. package/build/dist/Models/DatabaseModels/AlertEpisodeMember.js +28 -0
  359. package/build/dist/Models/DatabaseModels/AlertEpisodeMember.js.map +1 -1
  360. package/build/dist/Models/DatabaseModels/IncidentEpisodeMember.js +29 -0
  361. package/build/dist/Models/DatabaseModels/IncidentEpisodeMember.js.map +1 -1
  362. package/build/dist/Models/DatabaseModels/Index.js +12 -4
  363. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  364. package/build/dist/Models/DatabaseModels/{TelemetryEntity.js → InventoryItem.js} +167 -31
  365. package/build/dist/Models/DatabaseModels/InventoryItem.js.map +1 -0
  366. package/build/dist/Models/DatabaseModels/InventoryItemCustomField.js +454 -0
  367. package/build/dist/Models/DatabaseModels/InventoryItemCustomField.js.map +1 -0
  368. package/build/dist/Models/DatabaseModels/{TelemetryEntityRelationship.js → InventoryItemRelationship.js} +27 -27
  369. package/build/dist/Models/DatabaseModels/InventoryItemRelationship.js.map +1 -0
  370. package/build/dist/Models/DatabaseModels/NetworkDevice.js +141 -0
  371. package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
  372. package/build/dist/Models/DatabaseModels/NetworkDeviceLink.js +719 -0
  373. package/build/dist/Models/DatabaseModels/NetworkDeviceLink.js.map +1 -0
  374. package/build/dist/Models/DatabaseModels/NetworkDeviceLinkRule.js +475 -0
  375. package/build/dist/Models/DatabaseModels/NetworkDeviceLinkRule.js.map +1 -0
  376. package/build/dist/Models/DatabaseModels/NetworkTopologySuppression.js +446 -0
  377. package/build/dist/Models/DatabaseModels/NetworkTopologySuppression.js.map +1 -0
  378. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyFeed.js +9 -0
  379. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyFeed.js.map +1 -1
  380. package/build/dist/Models/DatabaseModels/Project.js +80 -0
  381. package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
  382. package/build/dist/Models/DatabaseModels/UserCall.js +46 -2
  383. package/build/dist/Models/DatabaseModels/UserCall.js.map +1 -1
  384. package/build/dist/Models/DatabaseModels/UserEmail.js +48 -2
  385. package/build/dist/Models/DatabaseModels/UserEmail.js.map +1 -1
  386. package/build/dist/Models/DatabaseModels/UserNotificationRule.js +424 -55
  387. package/build/dist/Models/DatabaseModels/UserNotificationRule.js.map +1 -1
  388. package/build/dist/Models/DatabaseModels/UserOnCallLogTimeline.js +24 -2
  389. package/build/dist/Models/DatabaseModels/UserOnCallLogTimeline.js.map +1 -1
  390. package/build/dist/Models/DatabaseModels/UserPush.js +51 -1
  391. package/build/dist/Models/DatabaseModels/UserPush.js.map +1 -1
  392. package/build/dist/Models/DatabaseModels/UserSMS.js +47 -2
  393. package/build/dist/Models/DatabaseModels/UserSMS.js.map +1 -1
  394. package/build/dist/Models/DatabaseModels/UserTelegram.js +65 -3
  395. package/build/dist/Models/DatabaseModels/UserTelegram.js.map +1 -1
  396. package/build/dist/Models/DatabaseModels/UserWebhook.js +56 -2
  397. package/build/dist/Models/DatabaseModels/UserWebhook.js.map +1 -1
  398. package/build/dist/Models/DatabaseModels/UserWhatsApp.js +45 -2
  399. package/build/dist/Models/DatabaseModels/UserWhatsApp.js.map +1 -1
  400. package/build/dist/Models/DatabaseModels/WorkflowVariable.js +12 -0
  401. package/build/dist/Models/DatabaseModels/WorkflowVariable.js.map +1 -1
  402. package/build/dist/Server/API/AIChatAPI.js +237 -1
  403. package/build/dist/Server/API/AIChatAPI.js.map +1 -1
  404. package/build/dist/Server/API/DashboardAPI.js +165 -13
  405. package/build/dist/Server/API/DashboardAPI.js.map +1 -1
  406. package/build/dist/Server/API/OnCallReadinessAPI.js +599 -0
  407. package/build/dist/Server/API/OnCallReadinessAPI.js.map +1 -0
  408. package/build/dist/Server/API/TeamComplianceAPI.js +68 -9
  409. package/build/dist/Server/API/TeamComplianceAPI.js.map +1 -1
  410. package/build/dist/Server/API/TelemetryAPI.js +129 -18
  411. package/build/dist/Server/API/TelemetryAPI.js.map +1 -1
  412. package/build/dist/Server/EnvironmentConfig.js +45 -0
  413. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  414. package/build/dist/Server/Infrastructure/Postgres/DataSourceOptions.js +22 -0
  415. package/build/dist/Server/Infrastructure/Postgres/DataSourceOptions.js.map +1 -1
  416. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.js +4 -4
  417. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786625176831-AddMonitoringMethodToNetworkDevice.js +18 -0
  418. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786625176831-AddMonitoringMethodToNetworkDevice.js.map +1 -0
  419. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786634985763-AddNetworkDeviceLink.js +39 -0
  420. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786634985763-AddNetworkDeviceLink.js.map +1 -0
  421. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786639512056-AddNetworkDeviceLinkRule.js +38 -0
  422. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786639512056-AddNetworkDeviceLinkRule.js.map +1 -0
  423. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786639972982-AddNetworkTopologySuppression.js +22 -0
  424. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786639972982-AddNetworkTopologySuppression.js.map +1 -0
  425. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786800000000-RenameTelemetryEntityToInventoryItem.js +150 -0
  426. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786800000000-RenameTelemetryEntityToInventoryItem.js.map +1 -0
  427. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786900000000-AddInventoryItemArchiveAndCustomFields.js +57 -0
  428. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786900000000-AddInventoryItemArchiveAndCustomFields.js.map +1 -0
  429. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787000000000-AddOnCallNotificationFallbackColumns.js +69 -0
  430. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787000000000-AddOnCallNotificationFallbackColumns.js.map +1 -0
  431. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787100000000-AddAIConversationPageContext.js +32 -0
  432. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787100000000-AddAIConversationPageContext.js.map +1 -0
  433. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787200000000-AddAIChatMessageFeedback.js +26 -0
  434. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787200000000-AddAIChatMessageFeedback.js.map +1 -0
  435. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787300000000-AddEpisodeMemberNotifyIndexes.js +48 -0
  436. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787300000000-AddEpisodeMemberNotifyIndexes.js.map +1 -0
  437. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +20 -0
  438. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  439. package/build/dist/Server/Infrastructure/Queue.js +72 -13
  440. package/build/dist/Server/Infrastructure/Queue.js.map +1 -1
  441. package/build/dist/Server/Middleware/PublicDashboardRateLimit.js +399 -0
  442. package/build/dist/Server/Middleware/PublicDashboardRateLimit.js.map +1 -0
  443. package/build/dist/Server/Services/AIService.js +1 -0
  444. package/build/dist/Server/Services/AIService.js.map +1 -1
  445. package/build/dist/Server/Services/AlertEpisodeStateTimelineService.js +24 -3
  446. package/build/dist/Server/Services/AlertEpisodeStateTimelineService.js.map +1 -1
  447. package/build/dist/Server/Services/AlertSeverityService.js +54 -0
  448. package/build/dist/Server/Services/AlertSeverityService.js.map +1 -1
  449. package/build/dist/Server/Services/DashboardService.js +10 -9
  450. package/build/dist/Server/Services/DashboardService.js.map +1 -1
  451. package/build/dist/Server/Services/DatabaseService.js +24 -2
  452. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  453. package/build/dist/Server/Services/IncidentEpisodeStateTimelineService.js +24 -3
  454. package/build/dist/Server/Services/IncidentEpisodeStateTimelineService.js.map +1 -1
  455. package/build/dist/Server/Services/IncidentSeverityService.js +67 -0
  456. package/build/dist/Server/Services/IncidentSeverityService.js.map +1 -1
  457. package/build/dist/Server/Services/Index.js +12 -4
  458. package/build/dist/Server/Services/Index.js.map +1 -1
  459. package/build/dist/Server/Services/InventoryItemCustomFieldService.js +9 -0
  460. package/build/dist/Server/Services/InventoryItemCustomFieldService.js.map +1 -0
  461. package/build/dist/Server/Services/{TelemetryEntityRelationshipService.js → InventoryItemRelationshipService.js} +6 -6
  462. package/build/dist/Server/Services/InventoryItemRelationshipService.js.map +1 -0
  463. package/build/dist/Server/Services/{TelemetryEntityService.js → InventoryItemService.js} +68 -23
  464. package/build/dist/Server/Services/InventoryItemService.js.map +1 -0
  465. package/build/dist/Server/Services/LogAggregationService.js +27 -8
  466. package/build/dist/Server/Services/LogAggregationService.js.map +1 -1
  467. package/build/dist/Server/Services/MetricAggregationService.js +80 -0
  468. package/build/dist/Server/Services/MetricAggregationService.js.map +1 -1
  469. package/build/dist/Server/Services/MetricService.js +6 -6
  470. package/build/dist/Server/Services/MetricService.js.map +1 -1
  471. package/build/dist/Server/Services/NetworkDeviceLinkRuleService.js +9 -0
  472. package/build/dist/Server/Services/NetworkDeviceLinkRuleService.js.map +1 -0
  473. package/build/dist/Server/Services/NetworkDeviceLinkService.js +71 -0
  474. package/build/dist/Server/Services/NetworkDeviceLinkService.js.map +1 -0
  475. package/build/dist/Server/Services/NetworkDeviceService.js +113 -0
  476. package/build/dist/Server/Services/NetworkDeviceService.js.map +1 -1
  477. package/build/dist/Server/Services/NetworkSiteService.js +53 -13
  478. package/build/dist/Server/Services/NetworkSiteService.js.map +1 -1
  479. package/build/dist/Server/Services/NetworkTopologySuppressionService.js +85 -0
  480. package/build/dist/Server/Services/NetworkTopologySuppressionService.js.map +1 -0
  481. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js +48 -32
  482. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js.map +1 -1
  483. package/build/dist/Server/Services/OnCallDutyPolicyExecutionLogService.js +8 -0
  484. package/build/dist/Server/Services/OnCallDutyPolicyExecutionLogService.js.map +1 -1
  485. package/build/dist/Server/Services/OnCallDutyPolicyExecutionLogTimelineService.js +51 -12
  486. package/build/dist/Server/Services/OnCallDutyPolicyExecutionLogTimelineService.js.map +1 -1
  487. package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js +57 -13
  488. package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js.map +1 -1
  489. package/build/dist/Server/Services/OnCallNotificationAlertingService.js +548 -0
  490. package/build/dist/Server/Services/OnCallNotificationAlertingService.js.map +1 -0
  491. package/build/dist/Server/Services/OnCallReadinessService.js +1961 -0
  492. package/build/dist/Server/Services/OnCallReadinessService.js.map +1 -0
  493. package/build/dist/Server/Services/OnCallSetupReminderService.js +738 -0
  494. package/build/dist/Server/Services/OnCallSetupReminderService.js.map +1 -0
  495. package/build/dist/Server/Services/ProfileAggregationService.js +68 -4
  496. package/build/dist/Server/Services/ProfileAggregationService.js.map +1 -1
  497. package/build/dist/Server/Services/StatusPageService.js +11 -10
  498. package/build/dist/Server/Services/StatusPageService.js.map +1 -1
  499. package/build/dist/Server/Services/TeamComplianceService.js +312 -160
  500. package/build/dist/Server/Services/TeamComplianceService.js.map +1 -1
  501. package/build/dist/Server/Services/UserCallService.js +24 -1
  502. package/build/dist/Server/Services/UserCallService.js.map +1 -1
  503. package/build/dist/Server/Services/UserEmailService.js +24 -1
  504. package/build/dist/Server/Services/UserEmailService.js.map +1 -1
  505. package/build/dist/Server/Services/UserNotificationRuleAdminService.js +858 -0
  506. package/build/dist/Server/Services/UserNotificationRuleAdminService.js.map +1 -0
  507. package/build/dist/Server/Services/UserNotificationRuleService.js +2830 -175
  508. package/build/dist/Server/Services/UserNotificationRuleService.js.map +1 -1
  509. package/build/dist/Server/Services/UserOnCallLogService.js +488 -43
  510. package/build/dist/Server/Services/UserOnCallLogService.js.map +1 -1
  511. package/build/dist/Server/Services/UserPushService.js +26 -0
  512. package/build/dist/Server/Services/UserPushService.js.map +1 -1
  513. package/build/dist/Server/Services/UserService.js +10 -0
  514. package/build/dist/Server/Services/UserService.js.map +1 -1
  515. package/build/dist/Server/Services/UserSmsService.js +24 -1
  516. package/build/dist/Server/Services/UserSmsService.js.map +1 -1
  517. package/build/dist/Server/Services/UserTelegramService.js +22 -1
  518. package/build/dist/Server/Services/UserTelegramService.js.map +1 -1
  519. package/build/dist/Server/Services/UserWebhookService.js +25 -1
  520. package/build/dist/Server/Services/UserWebhookService.js.map +1 -1
  521. package/build/dist/Server/Services/UserWhatsAppService.js +22 -1
  522. package/build/dist/Server/Services/UserWhatsAppService.js.map +1 -1
  523. package/build/dist/Server/Types/Database/Permissions/BasePermission.js +12 -1
  524. package/build/dist/Server/Types/Database/Permissions/BasePermission.js.map +1 -1
  525. package/build/dist/Server/Types/Database/Permissions/CreatePermission.js +126 -0
  526. package/build/dist/Server/Types/Database/Permissions/CreatePermission.js.map +1 -1
  527. package/build/dist/Server/Types/Database/Permissions/OwnerOnlyColumnPermission.js +254 -0
  528. package/build/dist/Server/Types/Database/Permissions/OwnerOnlyColumnPermission.js.map +1 -0
  529. package/build/dist/Server/Types/Database/Permissions/QueryPermission.js +47 -2
  530. package/build/dist/Server/Types/Database/Permissions/QueryPermission.js.map +1 -1
  531. package/build/dist/Server/Types/Database/Permissions/TenantPermission.js +7 -0
  532. package/build/dist/Server/Types/Database/Permissions/TenantPermission.js.map +1 -1
  533. package/build/dist/Server/Types/Workflow/Components/API/Delete.js +1 -1
  534. package/build/dist/Server/Types/Workflow/Components/API/Delete.js.map +1 -1
  535. package/build/dist/Server/Types/Workflow/Components/API/Get.js +1 -1
  536. package/build/dist/Server/Types/Workflow/Components/API/Get.js.map +1 -1
  537. package/build/dist/Server/Types/Workflow/Components/API/Patch.js +1 -1
  538. package/build/dist/Server/Types/Workflow/Components/API/Patch.js.map +1 -1
  539. package/build/dist/Server/Types/Workflow/Components/API/Post.js +1 -1
  540. package/build/dist/Server/Types/Workflow/Components/API/Post.js.map +1 -1
  541. package/build/dist/Server/Types/Workflow/Components/API/Put.js +1 -1
  542. package/build/dist/Server/Types/Workflow/Components/API/Put.js.map +1 -1
  543. package/build/dist/Server/Types/Workflow/Components/BaseModel/CreateManyBaseModel.js +21 -5
  544. package/build/dist/Server/Types/Workflow/Components/BaseModel/CreateManyBaseModel.js.map +1 -1
  545. package/build/dist/Server/Types/Workflow/Components/BaseModel/CreateOneBaseModel.js +14 -10
  546. package/build/dist/Server/Types/Workflow/Components/BaseModel/CreateOneBaseModel.js.map +1 -1
  547. package/build/dist/Server/Types/Workflow/Components/BaseModel/ModelArguments.js +31 -0
  548. package/build/dist/Server/Types/Workflow/Components/BaseModel/ModelArguments.js.map +1 -1
  549. package/build/dist/Server/Types/Workflow/Components/Conditions/IfElse.js +3 -9
  550. package/build/dist/Server/Types/Workflow/Components/Conditions/IfElse.js.map +1 -1
  551. package/build/dist/Server/Types/Workflow/Components/Email.js +15 -4
  552. package/build/dist/Server/Types/Workflow/Components/Email.js.map +1 -1
  553. package/build/dist/Server/Types/Workflow/Components/JavaScript.js +7 -2
  554. package/build/dist/Server/Types/Workflow/Components/JavaScript.js.map +1 -1
  555. package/build/dist/Server/Types/Workflow/Components/MicrosoftTeams/SendMessageToChannel.js +1 -1
  556. package/build/dist/Server/Types/Workflow/Components/MicrosoftTeams/SendMessageToChannel.js.map +1 -1
  557. package/build/dist/Server/Utils/AI/Chat/ChatAgentRunner.js +514 -56
  558. package/build/dist/Server/Utils/AI/Chat/ChatAgentRunner.js.map +1 -1
  559. package/build/dist/Server/Utils/AI/Chat/ObservabilityAssistant.js +20 -3
  560. package/build/dist/Server/Utils/AI/Chat/ObservabilityAssistant.js.map +1 -1
  561. package/build/dist/Server/Utils/AI/Chat/ObservabilityChatPrompt.js +19 -6
  562. package/build/dist/Server/Utils/AI/Chat/ObservabilityChatPrompt.js.map +1 -1
  563. package/build/dist/Server/Utils/AI/SRE/AIInvestigationEngine.js +7 -0
  564. package/build/dist/Server/Utils/AI/SRE/AIInvestigationEngine.js.map +1 -1
  565. package/build/dist/Server/Utils/AI/Toolbox/AIActionTools.js +2 -2
  566. package/build/dist/Server/Utils/AI/Toolbox/AIActionTools.js.map +1 -1
  567. package/build/dist/Server/Utils/AI/Toolbox/AIMetaTools.js +692 -0
  568. package/build/dist/Server/Utils/AI/Toolbox/AIMetaTools.js.map +1 -0
  569. package/build/dist/Server/Utils/AI/Toolbox/AlertTools.js +148 -12
  570. package/build/dist/Server/Utils/AI/Toolbox/AlertTools.js.map +1 -1
  571. package/build/dist/Server/Utils/AI/Toolbox/IncidentTools.js +157 -10
  572. package/build/dist/Server/Utils/AI/Toolbox/IncidentTools.js.map +1 -1
  573. package/build/dist/Server/Utils/AI/Toolbox/Index.js +37 -0
  574. package/build/dist/Server/Utils/AI/Toolbox/Index.js.map +1 -1
  575. package/build/dist/Server/Utils/AI/Toolbox/MonitorTools.js +259 -14
  576. package/build/dist/Server/Utils/AI/Toolbox/MonitorTools.js.map +1 -1
  577. package/build/dist/Server/Utils/AI/Toolbox/NoteWriteTools.js +235 -0
  578. package/build/dist/Server/Utils/AI/Toolbox/NoteWriteTools.js.map +1 -0
  579. package/build/dist/Server/Utils/AI/Toolbox/OnCallTools.js +1000 -0
  580. package/build/dist/Server/Utils/AI/Toolbox/OnCallTools.js.map +1 -0
  581. package/build/dist/Server/Utils/AI/Toolbox/RunbookTools.js +356 -0
  582. package/build/dist/Server/Utils/AI/Toolbox/RunbookTools.js.map +1 -0
  583. package/build/dist/Server/Utils/AI/Toolbox/SloTools.js +394 -0
  584. package/build/dist/Server/Utils/AI/Toolbox/SloTools.js.map +1 -0
  585. package/build/dist/Server/Utils/AI/Toolbox/StatusPageTools.js +465 -0
  586. package/build/dist/Server/Utils/AI/Toolbox/StatusPageTools.js.map +1 -0
  587. package/build/dist/Server/Utils/AI/Toolbox/TeamTools.js +280 -0
  588. package/build/dist/Server/Utils/AI/Toolbox/TeamTools.js.map +1 -0
  589. package/build/dist/Server/Utils/AI/Toolbox/TimelineTools.js +527 -0
  590. package/build/dist/Server/Utils/AI/Toolbox/TimelineTools.js.map +1 -0
  591. package/build/dist/Server/Utils/AI/Toolbox/WorkflowProbeTools.js +548 -0
  592. package/build/dist/Server/Utils/AI/Toolbox/WorkflowProbeTools.js.map +1 -0
  593. package/build/dist/Server/Utils/ClientIp.js +137 -0
  594. package/build/dist/Server/Utils/ClientIp.js.map +1 -0
  595. package/build/dist/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.js +38 -0
  596. package/build/dist/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.js.map +1 -1
  597. package/build/dist/Server/Utils/Dashboard/PublicDashboardSloHistoryPolicy.js +89 -0
  598. package/build/dist/Server/Utils/Dashboard/PublicDashboardSloHistoryPolicy.js.map +1 -0
  599. package/build/dist/Server/Utils/Dashboard/PublicDashboardSloWidget.js +77 -0
  600. package/build/dist/Server/Utils/Dashboard/PublicDashboardSloWidget.js.map +1 -0
  601. package/build/dist/Server/Utils/Express.js +12 -12
  602. package/build/dist/Server/Utils/Express.js.map +1 -1
  603. package/build/dist/Server/Utils/LLM/LLMService.js +70 -7
  604. package/build/dist/Server/Utils/LLM/LLMService.js.map +1 -1
  605. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +121 -11
  606. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
  607. package/build/dist/Server/Utils/SSRFProtection.js +82 -21
  608. package/build/dist/Server/Utils/SSRFProtection.js.map +1 -1
  609. package/build/dist/Server/Utils/StartServer.js +12 -4
  610. package/build/dist/Server/Utils/StartServer.js.map +1 -1
  611. package/build/dist/Server/Utils/Telemetry/EntityRegistry.js +165 -18
  612. package/build/dist/Server/Utils/Telemetry/EntityRegistry.js.map +1 -1
  613. package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js +11 -11
  614. package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js.map +1 -1
  615. package/build/dist/Server/Utils/Telemetry/TelemetryEntity.js +122 -47
  616. package/build/dist/Server/Utils/Telemetry/TelemetryEntity.js.map +1 -1
  617. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js +7 -3
  618. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js.map +1 -1
  619. package/build/dist/Types/AI/AIChatMessageStatus.js +8 -1
  620. package/build/dist/Types/AI/AIChatMessageStatus.js.map +1 -1
  621. package/build/dist/Types/AI/AIChatTypes.js +12 -0
  622. package/build/dist/Types/AI/AIChatTypes.js.map +1 -1
  623. package/build/dist/Types/Database/AccessControl/OwnerOnlyColumn.js +60 -0
  624. package/build/dist/Types/Database/AccessControl/OwnerOnlyColumn.js.map +1 -0
  625. package/build/dist/Types/Exception/ExceptionCode.js +2 -0
  626. package/build/dist/Types/Exception/ExceptionCode.js.map +1 -1
  627. package/build/dist/Types/Exception/ServiceUnavailableException.js +8 -0
  628. package/build/dist/Types/Exception/ServiceUnavailableException.js.map +1 -0
  629. package/build/dist/Types/Exception/TooManyRequestsException.js +8 -0
  630. package/build/dist/Types/Exception/TooManyRequestsException.js.map +1 -0
  631. package/build/dist/Types/IP/IP.js +87 -43
  632. package/build/dist/Types/IP/IP.js.map +1 -1
  633. package/build/dist/Types/NetworkDevice/NetworkDeviceMonitoringMethod.js +50 -0
  634. package/build/dist/Types/NetworkDevice/NetworkDeviceMonitoringMethod.js.map +1 -0
  635. package/build/dist/Types/OnCallDutyPolicy/Layer.js +186 -123
  636. package/build/dist/Types/OnCallDutyPolicy/Layer.js.map +1 -1
  637. package/build/dist/Types/OnCallDutyPolicy/OnCallDutyPolicyStatus.js +13 -0
  638. package/build/dist/Types/OnCallDutyPolicy/OnCallDutyPolicyStatus.js.map +1 -1
  639. package/build/dist/Types/OnCallDutyPolicy/ScheduleShiftUtil.js +105 -11
  640. package/build/dist/Types/OnCallDutyPolicy/ScheduleShiftUtil.js.map +1 -1
  641. package/build/dist/Types/Permission.js +174 -0
  642. package/build/dist/Types/Permission.js.map +1 -1
  643. package/build/dist/Types/Telemetry/EntityRelationshipType.js +1 -1
  644. package/build/dist/Types/Telemetry/EntitySource.js +1 -1
  645. package/build/dist/Types/Telemetry/EntityType.js +1 -1
  646. package/build/dist/Types/Telemetry/EntityTypeGroups.js +1 -1
  647. package/build/dist/Types/Telemetry/EntityTypeGroups.js.map +1 -1
  648. package/build/dist/Types/Workflow/Components/BaseModel.js +66 -28
  649. package/build/dist/Types/Workflow/Components/BaseModel.js.map +1 -1
  650. package/build/dist/Types/Workflow/Components/Discord.js +1 -0
  651. package/build/dist/Types/Workflow/Components/Discord.js.map +1 -1
  652. package/build/dist/Types/Workflow/Components/Email.js +12 -3
  653. package/build/dist/Types/Workflow/Components/Email.js.map +1 -1
  654. package/build/dist/Types/Workflow/Components/JavaScript.js +7 -0
  655. package/build/dist/Types/Workflow/Components/JavaScript.js.map +1 -1
  656. package/build/dist/Types/Workflow/Components/MicrosoftTeams.js +3 -2
  657. package/build/dist/Types/Workflow/Components/MicrosoftTeams.js.map +1 -1
  658. package/build/dist/Types/Workflow/Components/Slack.js +1 -0
  659. package/build/dist/Types/Workflow/Components/Slack.js.map +1 -1
  660. package/build/dist/Types/Workflow/Components/Telegram.js +1 -0
  661. package/build/dist/Types/Workflow/Components/Telegram.js.map +1 -1
  662. package/build/dist/Types/Workflow/TemplateSyntax.js +12 -0
  663. package/build/dist/Types/Workflow/TemplateSyntax.js.map +1 -1
  664. package/build/dist/Types/Workflow/Templates.js +1931 -38
  665. package/build/dist/Types/Workflow/Templates.js.map +1 -1
  666. package/build/dist/UI/Components/Calendar/Calendar.js +1 -1
  667. package/build/dist/UI/Components/Calendar/Calendar.js.map +1 -1
  668. package/build/dist/UI/Components/Checkbox/Checkbox.js +1 -1
  669. package/build/dist/UI/Components/Checkbox/Checkbox.js.map +1 -1
  670. package/build/dist/UI/Components/Dictionary/Dictionary.js +25 -11
  671. package/build/dist/UI/Components/Dictionary/Dictionary.js.map +1 -1
  672. package/build/dist/UI/Components/FormModal/BasicFormModal.js.map +1 -1
  673. package/build/dist/UI/Components/Header/HeaderIconDropdownButton.js +27 -4
  674. package/build/dist/UI/Components/Header/HeaderIconDropdownButton.js.map +1 -1
  675. package/build/dist/UI/Components/Input/Input.js +1 -0
  676. package/build/dist/UI/Components/Input/Input.js.map +1 -1
  677. package/build/dist/UI/Components/KeyboardShortcut/KeyboardKey.js +163 -0
  678. package/build/dist/UI/Components/KeyboardShortcut/KeyboardKey.js.map +1 -0
  679. package/build/dist/UI/Components/KeyboardShortcut/KeyboardShortcut.js +47 -0
  680. package/build/dist/UI/Components/KeyboardShortcut/KeyboardShortcut.js.map +1 -0
  681. package/build/dist/UI/Components/LogsViewer/LogsViewer.js +4 -4
  682. package/build/dist/UI/Components/LogsViewer/LogsViewer.js.map +1 -1
  683. package/build/dist/UI/Components/LogsViewer/components/ActiveFilterChips.js +11 -1
  684. package/build/dist/UI/Components/LogsViewer/components/ActiveFilterChips.js.map +1 -1
  685. package/build/dist/UI/Components/LogsViewer/components/KeyboardShortcutsHelp.js +10 -8
  686. package/build/dist/UI/Components/LogsViewer/components/KeyboardShortcutsHelp.js.map +1 -1
  687. package/build/dist/UI/Components/LogsViewer/components/LogDetailsPanel.js +227 -14
  688. package/build/dist/UI/Components/LogsViewer/components/LogDetailsPanel.js.map +1 -1
  689. package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js +5 -0
  690. package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js.map +1 -1
  691. package/build/dist/UI/Components/LogsViewer/components/LogsTable.js +69 -5
  692. package/build/dist/UI/Components/LogsViewer/components/LogsTable.js.map +1 -1
  693. package/build/dist/UI/Components/LogsViewer/components/LogsViewerToolbar.js +8 -0
  694. package/build/dist/UI/Components/LogsViewer/components/LogsViewerToolbar.js.map +1 -1
  695. package/build/dist/UI/Components/LogsViewer/types.js.map +1 -1
  696. package/build/dist/UI/Components/Markdown.tsx/MarkdownEditor.js +9 -2
  697. package/build/dist/UI/Components/Markdown.tsx/MarkdownEditor.js.map +1 -1
  698. package/build/dist/UI/Components/Navbar/NavBarMenuModal.js +8 -21
  699. package/build/dist/UI/Components/Navbar/NavBarMenuModal.js.map +1 -1
  700. package/build/dist/UI/Components/ProjectInvitations/PendingProjectInvitations.js +251 -0
  701. package/build/dist/UI/Components/ProjectInvitations/PendingProjectInvitations.js.map +1 -0
  702. package/build/dist/UI/Components/SimpleLogViewer/SimpleLogViewer.js +9 -2
  703. package/build/dist/UI/Components/SimpleLogViewer/SimpleLogViewer.js.map +1 -1
  704. package/build/dist/UI/Components/Table/Table.js +27 -15
  705. package/build/dist/UI/Components/Table/Table.js.map +1 -1
  706. package/build/dist/UI/Components/Table/TableBody.js +24 -18
  707. package/build/dist/UI/Components/Table/TableBody.js.map +1 -1
  708. package/build/dist/UI/Components/Table/TableHeader.js +9 -1
  709. package/build/dist/UI/Components/Table/TableHeader.js.map +1 -1
  710. package/build/dist/UI/Components/Table/TableRow.js +29 -21
  711. package/build/dist/UI/Components/Table/TableRow.js.map +1 -1
  712. package/build/dist/UI/Components/Workflow/ArgumentsForm.js +130 -12
  713. package/build/dist/UI/Components/Workflow/ArgumentsForm.js.map +1 -1
  714. package/build/dist/UI/Components/Workflow/ComponentReturnValueViewer.js +10 -1
  715. package/build/dist/UI/Components/Workflow/ComponentReturnValueViewer.js.map +1 -1
  716. package/build/dist/UI/Components/Workflow/ComponentSettingsModal.js +4 -4
  717. package/build/dist/UI/Components/Workflow/ComponentSettingsModal.js.map +1 -1
  718. package/build/dist/UI/Components/Workflow/ComponentValuePickerModal.js +57 -7
  719. package/build/dist/UI/Components/Workflow/ComponentValuePickerModal.js.map +1 -1
  720. package/build/dist/UI/Components/Workflow/ComponentsModal.js +53 -18
  721. package/build/dist/UI/Components/Workflow/ComponentsModal.js.map +1 -1
  722. package/build/dist/UI/Components/Workflow/DocumentationViewer.js +19 -6
  723. package/build/dist/UI/Components/Workflow/DocumentationViewer.js.map +1 -1
  724. package/build/dist/UI/Components/Workflow/GraphLint.js +33 -4
  725. package/build/dist/UI/Components/Workflow/GraphLint.js.map +1 -1
  726. package/build/dist/UI/Components/Workflow/GraphLintSummary.js +231 -0
  727. package/build/dist/UI/Components/Workflow/GraphLintSummary.js.map +1 -0
  728. package/build/dist/UI/Components/Workflow/ModelColumnEditor.js +116 -21
  729. package/build/dist/UI/Components/Workflow/ModelColumnEditor.js.map +1 -1
  730. package/build/dist/UI/Components/Workflow/ModelSchema.js +72 -34
  731. package/build/dist/UI/Components/Workflow/ModelSchema.js.map +1 -1
  732. package/build/dist/UI/Components/Workflow/RunStatusWatcher.js +1 -1
  733. package/build/dist/UI/Components/Workflow/RunStatusWatcher.js.map +1 -1
  734. package/build/dist/UI/Components/Workflow/StepTraceViewer.js +1 -1
  735. package/build/dist/UI/Components/Workflow/StepTraceViewer.js.map +1 -1
  736. package/build/dist/UI/Components/Workflow/UseRunWatch.js +123 -0
  737. package/build/dist/UI/Components/Workflow/UseRunWatch.js.map +1 -0
  738. package/build/dist/UI/Components/Workflow/VariableModal.js +3 -2
  739. package/build/dist/UI/Components/Workflow/VariableModal.js.map +1 -1
  740. package/build/dist/UI/Components/Workflow/Workflow.js +41 -1
  741. package/build/dist/UI/Components/Workflow/Workflow.js.map +1 -1
  742. package/build/dist/UI/Components/Workflow/WorkflowIssuesModal.js +99 -0
  743. package/build/dist/UI/Components/Workflow/WorkflowIssuesModal.js.map +1 -0
  744. package/build/dist/UI/Components/Workflow/WorkflowLogModal.js +56 -0
  745. package/build/dist/UI/Components/Workflow/WorkflowLogModal.js.map +1 -0
  746. package/build/dist/UI/Components/Workflow/WorkflowStatusBar.js +92 -0
  747. package/build/dist/UI/Components/Workflow/WorkflowStatusBar.js.map +1 -0
  748. package/build/dist/UI/Utils/AIChatExport/ConversationMarkdown.js +9 -0
  749. package/build/dist/UI/Utils/AIChatExport/ConversationMarkdown.js.map +1 -1
  750. package/build/dist/UI/Utils/ModelAPI/ModelAPI.js +1 -1
  751. package/build/dist/UI/Utils/ModelAPI/ModelAPI.js.map +1 -1
  752. package/build/dist/UI/Utils/Platform.js +118 -0
  753. package/build/dist/UI/Utils/Platform.js.map +1 -0
  754. package/build/dist/UI/Utils/ProjectInvitationDisplay.js +106 -0
  755. package/build/dist/UI/Utils/ProjectInvitationDisplay.js.map +1 -0
  756. package/build/dist/Utils/Monitor/NetworkDeviceLinkRuleUtil.js +108 -0
  757. package/build/dist/Utils/Monitor/NetworkDeviceLinkRuleUtil.js.map +1 -0
  758. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js +642 -136
  759. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js.map +1 -1
  760. package/build/dist/Utils/Telemetry/CrossSignalScope.js +328 -0
  761. package/build/dist/Utils/Telemetry/CrossSignalScope.js.map +1 -0
  762. package/build/dist/Utils/Telemetry/EntityKey.js +5 -5
  763. package/build/dist/Utils/Telemetry/EntityRelationship.js +1 -1
  764. package/jest.config.json +1 -0
  765. package/package.json +1 -1
  766. package/build/dist/Models/DatabaseModels/TelemetryEntity.js.map +0 -1
  767. package/build/dist/Models/DatabaseModels/TelemetryEntityRelationship.js.map +0 -1
  768. package/build/dist/Server/Services/TelemetryEntityRelationshipService.js.map +0 -1
  769. 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 { OnCreate } from "../Types/Database/Hooks";
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 { LIMIT_PER_PROJECT } from "../../Types/Database/LimitMax";
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
- * If the project has a default Twilio config set, use it for all
178
- * team-member SMS and Calls in this rule. Otherwise the global config
179
- * is used by the notification service.
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 projectTwilioConfig: TwilioConfig | undefined =
182
- await ProjectCallSMSConfigService.getProjectDefaultTwilioConfig(
183
- options.projectId,
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
- if (
886
- notificationRuleItem.userWhatsApp?.phone &&
887
- !notificationRuleItem.userWhatsApp?.isVerified
888
- ) {
889
- logTimelineItem.status = UserNotificationStatus.Error;
890
- logTimelineItem.statusMessage = `WhatsApp message not sent because phone ${notificationRuleItem.userWhatsApp?.phone.toString()} is not verified.`;
891
- logTimelineItem.userWhatsAppId = notificationRuleItem.userWhatsApp.id!;
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
- await UserOnCallLogTimelineService.create({
894
- data: logTimelineItem,
895
- props: {
896
- isRoot: true,
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
- @CaptureSpan()
1794
- public async generateCallTemplateForAlertCreated(
1795
- to: Phone,
1796
- alert: Alert,
1797
- userOnCallLogTimelineId: ObjectID,
1798
- ): Promise<CallRequest> {
1799
- const host: Hostname = await DatabaseConfig.getHost();
1800
-
1801
- const httpProtocol: Protocol = await DatabaseConfig.getHttpProtocol();
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
- const alertIdentifier: string =
1804
- alert.alertNumber !== undefined
1805
- ? `Alert number ${alert.alertNumber}, ${alert.title || "Alert"}`
1806
- : alert.title || "Alert";
2774
+ const fellThroughRow: UserOnCallLogTimeline = this.buildLogTimelineItem(
2775
+ notificationRuleItem,
2776
+ options,
2777
+ );
2778
+ fellThroughRow.status = UserNotificationStatus.Error;
2779
+ fellThroughRow.statusMessage = statusMessage;
1807
2780
 
1808
- const callRequest: CallRequest = {
1809
- to: to,
1810
- data: [
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
- return callRequest;
2788
+ logger.error(
2789
+ `${statusMessage} User on-call log: ${options.userNotificationLogId.toString()}`,
2790
+ );
2791
+ }
2792
+
2793
+ return deliveryAttempted;
1847
2794
  }
1848
2795
 
1849
- @CaptureSpan()
1850
- public async generateCallTemplateForIncidentCreated(
1851
- to: Phone,
1852
- incident: Incident,
1853
- userOnCallLogTimelineId: ObjectID,
1854
- ): Promise<CallRequest> {
1855
- const host: Hostname = await DatabaseConfig.getHost();
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
- const httpProtocol: Protocol = await DatabaseConfig.getHttpProtocol();
2810
+ if (
2811
+ notificationRuleItem.userEmail?.email &&
2812
+ notificationRuleItem.userEmail?.isVerified
2813
+ ) {
2814
+ channels.push("Email");
2815
+ }
1858
2816
 
1859
- const incidentIdentifier: string =
1860
- incident.incidentNumber !== undefined
1861
- ? `Incident number ${incident.incidentNumberWithPrefix || incident.incidentNumber}, ${incident.title || "Incident"}`
1862
- : incident.title || "Incident";
2817
+ if (
2818
+ notificationRuleItem.userSms?.phone &&
2819
+ notificationRuleItem.userSms?.isVerified
2820
+ ) {
2821
+ channels.push("SMS");
2822
+ }
1863
2823
 
1864
- const callRequest: CallRequest = {
1865
- to: to,
1866
- data: [
1867
- {
1868
- sayMessage: "This is a call from One Uptime",
1869
- },
1870
- {
1871
- sayMessage: "A new incident has been created",
1872
- },
1873
- {
1874
- sayMessage: incidentIdentifier,
1875
- },
1876
- {
1877
- introMessage: "To acknowledge this incident press 1",
1878
- numDigits: 1,
1879
- timeoutInSeconds: 10,
1880
- noInputMessage: "You have not entered any input. Good bye",
1881
- onInputCallRequest: {
1882
- "1": {
1883
- sayMessage: "You have acknowledged this incident. Good bye",
1884
- },
1885
- default: {
1886
- sayMessage: "Invalid input. Good bye",
1887
- },
1888
- },
1889
- responseUrl: new URL(
1890
- httpProtocol,
1891
- host,
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 generateCallTemplateForAlertEpisodeCreated(
3294
+ public async generateCallTemplateForIncidentCreated(
1907
3295
  to: Phone,
1908
- alertEpisode: AlertEpisode,
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 episodeIdentifier: string = alertEpisode.episodeNumberWithPrefix
1916
- ? `Alert episode ${alertEpisode.episodeNumberWithPrefix}, ${alertEpisode.title || "Alert Episode"}`
1917
- : alertEpisode.episodeNumber !== undefined
1918
- ? `Alert episode number ${alertEpisode.episodeNumber}, ${alertEpisode.title || "Alert Episode"}`
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 alert episode has been created",
3315
+ sayMessage: "A new incident has been created",
1929
3316
  },
1930
3317
  {
1931
- sayMessage: episodeIdentifier,
3318
+ sayMessage: incidentIdentifier,
1932
3319
  },
1933
3320
  {
1934
- introMessage: "To acknowledge this alert episode press 1",
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 alert episode. Good bye",
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 generateSmsTemplateForAlertCreated(
3350
+ public async generateCallTemplateForAlertEpisodeCreated(
1964
3351
  to: Phone,
1965
- alert: Alert,
3352
+ alertEpisode: AlertEpisode,
1966
3353
  userOnCallLogTimelineId: ObjectID,
1967
- ): Promise<SMS> {
3354
+ ): Promise<CallRequest> {
1968
3355
  const host: Hostname = await DatabaseConfig.getHost();
3356
+
1969
3357
  const httpProtocol: Protocol = await DatabaseConfig.getHttpProtocol();
1970
3358
 
1971
- const shortUrl: ShortLink = await ShortLinkService.saveShortLinkFor(
1972
- new URL(
1973
- httpProtocol,
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 startUserNotificationRulesExecution(
2652
- userId: ObjectID,
2653
- options: {
2654
- projectId: ObjectID;
2655
- triggeredByIncidentId?: ObjectID | undefined;
2656
- triggeredByAlertId?: ObjectID | undefined;
2657
- triggeredByAlertEpisodeId?: ObjectID | undefined;
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
- userOnCallLog.userId = userId;
2673
- userOnCallLog.projectId = options.projectId;
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
- if (options.triggeredByIncidentId) {
2676
- userOnCallLog.triggeredByIncidentId = options.triggeredByIncidentId;
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
- if (options.triggeredByAlertId) {
2680
- userOnCallLog.triggeredByAlertId = options.triggeredByAlertId;
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
- if (options.triggeredByAlertEpisodeId) {
2684
- userOnCallLog.triggeredByAlertEpisodeId =
2685
- options.triggeredByAlertEpisodeId;
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
- if (options.triggeredByIncidentEpisodeId) {
2689
- userOnCallLog.triggeredByIncidentEpisodeId =
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
- if (
2818
- !createBy.data.userCallId &&
2819
- !createBy.data.userCall &&
2820
- !createBy.data.userEmail &&
2821
- !createBy.data.userSms &&
2822
- !createBy.data.userSmsId &&
2823
- !createBy.data.userWhatsApp &&
2824
- !createBy.data.userWhatsAppId &&
2825
- !createBy.data.userTelegram &&
2826
- !createBy.data.userTelegramId &&
2827
- !createBy.data.userWebhook &&
2828
- !createBy.data.userWebhookId &&
2829
- !createBy.data.userEmailId &&
2830
- !createBy.data.userPushId &&
2831
- !createBy.data.userPush
2832
- ) {
2833
- throw new BadDataException(
2834
- "Call, SMS, WhatsApp, Telegram, Webhook, Email, or Push notification is required",
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
- @CaptureSpan()
2845
- public async addDefaultNotificationRulesForVerifiedMethod(data: {
2846
- projectId: ObjectID;
2847
- userId: ObjectID;
2848
- notificationMethod: NotificationMethodDescriptor;
2849
- }): Promise<void> {
2850
- const { projectId, userId, notificationMethod } = data;
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
- await this.createIncidentOnCallRules(projectId, userId, notificationMethod);
2853
- await this.createAlertOnCallRules(projectId, userId, notificationMethod);
2854
- await this.createSingleRule(
2855
- projectId,
2856
- userId,
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
- private applyNotificationMethod(
2881
- rule: Model,
2882
- descriptor: NotificationMethodDescriptor,
2883
- ): void {
2884
- if (descriptor.userEmailId) {
2885
- rule.userEmailId = descriptor.userEmailId;
2886
- }
2887
- if (descriptor.userSmsId) {
2888
- rule.userSmsId = descriptor.userSmsId;
2889
- }
2890
- if (descriptor.userCallId) {
2891
- rule.userCallId = descriptor.userCallId;
2892
- }
2893
- if (descriptor.userWhatsAppId) {
2894
- rule.userWhatsAppId = descriptor.userWhatsAppId;
2895
- }
2896
- if (descriptor.userTelegramId) {
2897
- rule.userTelegramId = descriptor.userTelegramId;
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
- if (descriptor.userWebhookId) {
2900
- rule.userWebhookId = descriptor.userWebhookId;
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
- if (descriptor.userPushId) {
2903
- rule.userPushId = descriptor.userPushId;
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
- private getNotificationMethodQuery(
2908
- descriptor: NotificationMethodDescriptor,
2909
- ): Record<string, ObjectID> {
2910
- const query: Record<string, ObjectID> = {};
2911
- if (descriptor.userEmailId) {
2912
- query["userEmailId"] = descriptor.userEmailId;
2913
- }
2914
- if (descriptor.userSmsId) {
2915
- query["userSmsId"] = descriptor.userSmsId;
2916
- }
2917
- if (descriptor.userCallId) {
2918
- query["userCallId"] = descriptor.userCallId;
2919
- }
2920
- if (descriptor.userWhatsAppId) {
2921
- query["userWhatsAppId"] = descriptor.userWhatsAppId;
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
- if (descriptor.userTelegramId) {
2924
- query["userTelegramId"] = descriptor.userTelegramId;
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
- if (descriptor.userWebhookId) {
2927
- query["userWebhookId"] = descriptor.userWebhookId;
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
- if (descriptor.userPushId) {
2930
- query["userPushId"] = descriptor.userPushId;
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
- return query;
4905
+
4906
+ return scopedQuery;
2933
4907
  }
2934
4908
 
2935
- private async createIncidentOnCallRules(
2936
- projectId: ObjectID,
2937
- userId: ObjectID,
2938
- notificationMethod: NotificationMethodDescriptor,
2939
- ): Promise<void> {
2940
- const incidentSeverities: Array<IncidentSeverity> =
2941
- await IncidentSeverityService.findBy({
2942
- query: {
2943
- projectId,
2944
- },
2945
- props: {
2946
- isRoot: true,
2947
- },
2948
- limit: LIMIT_PER_PROJECT,
2949
- skip: 0,
2950
- select: {
2951
- _id: true,
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
- for (const incidentSeverity of incidentSeverities) {
2956
- const existingRule: Model | null = await this.findOneBy({
2957
- query: {
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
- if (existingRule) {
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 rule: Model = new Model();
2974
- rule.projectId = projectId;
2975
- rule.userId = userId;
2976
- this.applyNotificationMethod(rule, notificationMethod);
2977
- rule.incidentSeverityId = incidentSeverity.id!;
2978
- rule.notifyAfterMinutes = 0;
2979
- rule.ruleType = NotificationRuleType.ON_CALL_EXECUTED_INCIDENT;
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
- await this.create({
2982
- data: rule,
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 createAlertOnCallRules(
5475
+ private async getAlertSeverityIds(
2991
5476
  projectId: ObjectID,
2992
- userId: ObjectID,
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
- for (const alertSeverity of alertSeverities) {
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
- alertSeverityId: alertSeverity.id!,
3017
- ruleType: NotificationRuleType.ON_CALL_EXECUTED_ALERT,
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.alertSeverityId = alertSeverity.id!;
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 = NotificationRuleType.ON_CALL_EXECUTED_ALERT;
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();