@oneuptime/common 12.0.13 → 12.0.14

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 (376) hide show
  1. package/Models/DatabaseModels/OnCallDutyPolicySchedule.ts +14 -0
  2. package/Models/DatabaseModels/UserCall.ts +94 -3
  3. package/Models/DatabaseModels/UserEmail.ts +94 -3
  4. package/Models/DatabaseModels/UserIncomingCallNumber.ts +94 -3
  5. package/Models/DatabaseModels/UserPush.ts +37 -0
  6. package/Models/DatabaseModels/UserSMS.ts +94 -3
  7. package/Models/DatabaseModels/UserWhatsApp.ts +93 -2
  8. package/Server/API/BaseAPI.ts +79 -14
  9. package/Server/API/UserCallAPI.ts +53 -45
  10. package/Server/API/UserEmailAPI.ts +53 -44
  11. package/Server/API/UserIncomingCallNumberAPI.ts +48 -38
  12. package/Server/API/UserNotificationMethodAdminAPI.ts +481 -0
  13. package/Server/API/UserPushAPI.ts +126 -2
  14. package/Server/API/UserSmsAPI.ts +53 -44
  15. package/Server/API/UserWhatsAppAPI.ts +53 -50
  16. package/Server/Infrastructure/Postgres/SchemaMigrations/1787142779538-MigrationName.ts +154 -0
  17. package/Server/Infrastructure/Postgres/SchemaMigrations/1787156982416-MigrationName.ts +17 -0
  18. package/Server/Infrastructure/Postgres/SchemaMigrations/1787700000000-FixTotpOtpUrlAlgorithm.ts +45 -0
  19. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +6 -0
  20. package/Server/Middleware/VerificationCodeRateLimit.ts +586 -0
  21. package/Server/Services/AnalyticsDatabaseService.ts +169 -0
  22. package/Server/Services/DatabaseService.ts +101 -0
  23. package/Server/Services/LogAggregationService.ts +5 -0
  24. package/Server/Services/PushNotificationService.ts +127 -12
  25. package/Server/Services/TeamMemberService.ts +53 -13
  26. package/Server/Services/UserCallService.ts +55 -24
  27. package/Server/Services/UserEmailService.ts +83 -17
  28. package/Server/Services/UserIncomingCallNumberService.ts +49 -85
  29. package/Server/Services/UserNotificationMethodAdminService.ts +1527 -0
  30. package/Server/Services/UserNotificationRuleService.ts +26 -0
  31. package/Server/Services/UserPushService.ts +89 -0
  32. package/Server/Services/UserSmsService.ts +50 -17
  33. package/Server/Services/UserWhatsAppService.ts +48 -15
  34. package/Server/Types/Database/QueryHelper.ts +30 -0
  35. package/Server/Utils/AnalyticsDatabase/QuerySettingsHelper.ts +59 -0
  36. package/Server/Utils/ChannelVerification.ts +510 -0
  37. package/Server/Utils/Monitor/Criteria/IncomingRequestCriteria.ts +13 -16
  38. package/Server/Utils/Monitor/MonitorAlert.ts +24 -21
  39. package/Server/Utils/Monitor/MonitorIncident.ts +21 -18
  40. package/Server/Utils/Monitor/MonitorResourceContext.ts +107 -0
  41. package/Server/Utils/Monitor/MonitorStepResourceIdentity.ts +442 -0
  42. package/Server/Utils/Monitor/SeriesResourceLabels.ts +3 -3
  43. package/Server/Utils/Monitor/SeriesResourceLinker.ts +96 -28
  44. package/Server/Utils/TotpAuth.ts +137 -9
  45. package/Server/Utils/UserRegistrationToken.ts +182 -0
  46. package/Server/Utils/VerificationCode.ts +134 -0
  47. package/Tests/App/Dashboard/AdminNotificationRulesPage.test.tsx +173 -94
  48. package/Tests/App/Dashboard/AdminUserNotificationMethodsPage.test.tsx +1151 -0
  49. package/Tests/App/Dashboard/AdminUserOnCallPages.test.tsx +905 -0
  50. package/Tests/App/Dashboard/DashboardCommandPalette.test.tsx +338 -0
  51. package/Tests/App/Dashboard/InvestigationPanel.test.tsx +1 -1
  52. package/Tests/App/Dashboard/MonitorTypePicker.test.tsx +28 -0
  53. package/Tests/Models/UserPushCriticalAlertColumn.test.ts +172 -0
  54. package/Tests/Server/API/BaseAPIGetListParallel.test.ts +519 -0
  55. package/Tests/Server/API/NotificationChannelVerificationAPI.test.ts +543 -0
  56. package/Tests/Server/API/UserIncomingCallNumberApi.test.ts +108 -18
  57. package/Tests/Server/API/UserNotificationMethodAdminAPI.test.ts +963 -0
  58. package/Tests/Server/API/UserPushCriticalAlertsApi.test.ts +114 -0
  59. package/Tests/Server/API/UserSmsApi.test.ts +107 -14
  60. package/Tests/Server/API/UserTotpAuthAPI.test.ts +427 -0
  61. package/Tests/Server/Infrastructure/Postgres/FixTotpOtpUrlAlgorithmMigration.test.ts +315 -0
  62. package/Tests/Server/Middleware/VerificationCodeRateLimit.test.ts +775 -0
  63. package/Tests/Server/Services/AddNetworkDeviceReachabilityColumnsMigration.test.ts +10 -62
  64. package/Tests/Server/Services/AnalyticsDatabasePaginationStability.test.ts +33 -13
  65. package/Tests/Server/Services/AnalyticsDatabaseSortKeyBoundary.test.ts +658 -0
  66. package/Tests/Server/Services/CriticalOnCallAlertDelivery.test.ts +458 -0
  67. package/Tests/Server/Services/CriticalPushAlertPayload.test.ts +301 -0
  68. package/Tests/Server/Services/DatabaseServiceAtomicIncrement.test.ts +143 -0
  69. package/Tests/Server/Services/DeliverNotificationForRuleExtraction.test.ts +1 -0
  70. package/Tests/Server/Services/LogAggregationScanMemory.test.ts +421 -0
  71. package/Tests/Server/Services/LogAggregationService.test.ts +2 -2
  72. package/Tests/Server/Services/NotificationChannelCodeIssuance.test.ts +401 -0
  73. package/Tests/Server/Services/PushNotificationDeliveryOptions.test.ts +204 -0
  74. package/Tests/Server/Services/TeamMemberAutoAcceptInvitation.test.ts +21 -1
  75. package/Tests/Server/Services/TeamMemberInviteRegistrationToken.test.ts +290 -0
  76. package/Tests/Server/Services/UserNotificationMethodAdminService.test.ts +948 -0
  77. package/Tests/Server/Services/UserNotificationRuleExecuteItem.test.ts +1 -0
  78. package/Tests/Server/Services/UserPushCriticalAlertToggle.test.ts +255 -0
  79. package/Tests/Server/Services/UserTotpAuthEnrolment.test.ts +218 -0
  80. package/Tests/Server/TestingUtils/AuthenticatorApp.ts +248 -0
  81. package/Tests/Server/Types/Database/Permissions/NotificationChannelVerificationColumns.test.ts +135 -0
  82. package/Tests/Server/Types/Database/Permissions/OnCallScheduleRelationSelect.test.ts +504 -0
  83. package/Tests/Server/Types/Database/QueryHelperFindWithSameTextAnyOf.test.ts +91 -0
  84. package/Tests/Server/Types/Workflow/Components/JsonToText.test.ts +384 -0
  85. package/Tests/Server/Types/Workflow/Components/MergeJson.test.ts +403 -0
  86. package/Tests/Server/Utils/Browser.test.ts +162 -0
  87. package/Tests/Server/Utils/ChannelVerification.test.ts +689 -0
  88. package/Tests/Server/Utils/Express.test.ts +371 -0
  89. package/Tests/Server/Utils/Monitor/Criteria/CustomCodeMonitorCriteria.test.ts +707 -0
  90. package/Tests/Server/Utils/Monitor/Criteria/DnsMonitorCriteria.test.ts +698 -0
  91. package/Tests/Server/Utils/Monitor/Criteria/IncomingEmailBodyCriteria.test.ts +243 -0
  92. package/Tests/Server/Utils/Monitor/Criteria/IncomingRequestBodyCriteria.test.ts +278 -0
  93. package/Tests/Server/Utils/Monitor/MonitorAlertResourceLinking.test.ts +160 -20
  94. package/Tests/Server/Utils/Monitor/MonitorDependencySuppressionCreatorSkip.test.ts +13 -8
  95. package/Tests/Server/Utils/Monitor/MonitorIncidentResourceLinking.test.ts +162 -18
  96. package/Tests/Server/Utils/Monitor/MonitorResourceContext.test.ts +349 -0
  97. package/Tests/Server/Utils/Monitor/MonitorStepResourceIdentity.test.ts +690 -0
  98. package/Tests/Server/Utils/Monitor/MonitorSummaryPersistence.test.ts +7 -2
  99. package/Tests/Server/Utils/Monitor/SeriesResourceLinker.test.ts +92 -18
  100. package/Tests/Server/Utils/PushNotificationUtilCreators.test.ts +267 -0
  101. package/Tests/Server/Utils/Telemetry/OneuptimeLabel.test.ts +439 -0
  102. package/Tests/Server/Utils/TotpAuth.test.ts +719 -0
  103. package/Tests/Server/Utils/UserRegistrationToken.test.ts +322 -0
  104. package/Tests/Server/Utils/VerificationCode.test.ts +296 -0
  105. package/Tests/Types/AutoRemediation/AutoRemediationSuggestionStatus.test.ts +192 -0
  106. package/Tests/Types/Call/CallRequest.test.ts +189 -0
  107. package/Tests/Types/Dashboard/DashboardSize.test.ts +226 -0
  108. package/Tests/Types/Incident/IncidentSlaStatus.test.ts +191 -0
  109. package/Tests/Types/Metrics/MetricDashboardMetricType.test.ts +278 -0
  110. package/Tests/Types/Metrics/MetricPipelineRuleType.test.ts +517 -0
  111. package/Tests/Types/Monitor/IncomingMonitorDefaultCriteria.test.ts +470 -0
  112. package/Tests/Types/Monitor/MonitorCriteriaInstance.test.ts +6 -4
  113. package/Tests/Types/Monitor/Recommendation/MonitorRecommendationCatalog.test.ts +262 -5
  114. package/Tests/Types/Monitor/Recommendation/MonitorRecommendationCoverage.test.ts +1 -1
  115. package/Tests/Types/Monitor/Recommendation/MonitorRecommendationNotificationMode.test.ts +1 -1
  116. package/Tests/Types/Monitor/ServiceAlertTemplates.test.ts +905 -0
  117. package/Tests/Types/Service/ServiceLanguage.test.ts +255 -0
  118. package/Tests/Types/TextRandomGeneration.test.ts +211 -0
  119. package/Tests/UI/Components/Button.test.tsx +175 -1
  120. package/Tests/UI/Components/CodeBlockLanguages.test.tsx +236 -0
  121. package/Tests/UI/Components/CommandPalette/CommandPalette.test.tsx +568 -0
  122. package/Tests/UI/Components/CommandPalette/CommandPaletteKeyboard.test.tsx +239 -0
  123. package/Tests/UI/Components/CommandPalette/CommandPaletteProviders.test.tsx +400 -0
  124. package/Tests/UI/Components/CommandPalette/PaletteFilter.test.ts +234 -0
  125. package/Tests/UI/Components/CommandPalette/RecentCommands.test.ts +97 -0
  126. package/Tests/UI/Components/CountModelSideMenuItemGuard.test.tsx +198 -0
  127. package/Tests/UI/Components/FeedItemSafeMode.test.tsx +1 -1
  128. package/Tests/UI/Components/Forms/Utils/FormFieldSchemaTypeUtil.test.ts +267 -0
  129. package/Tests/UI/Components/List.test.tsx +9 -2
  130. package/Tests/UI/Components/ListLoadingStates.test.tsx +173 -0
  131. package/Tests/UI/Components/MarkdownLazy.test.tsx +113 -0
  132. package/Tests/UI/Components/MarkdownMermaidRetry.test.tsx +110 -0
  133. package/Tests/UI/Components/ModelTable/useCustomFieldColumns.test.tsx +8 -0
  134. package/Tests/UI/Components/MonitorTemplateVariables/TemplateVariablesCatalog.test.ts +692 -0
  135. package/Tests/UI/Components/MoreMenuMotion.test.tsx +118 -0
  136. package/Tests/UI/Components/NavBarCommandKGating.test.tsx +178 -0
  137. package/Tests/UI/Components/OrderedStatesList.test.tsx +19 -2
  138. package/Tests/UI/Components/SideOverMotion.test.tsx +109 -0
  139. package/Tests/UI/Components/Skeleton.test.tsx +71 -0
  140. package/Tests/UI/Components/TableLoadingStates.test.tsx +279 -0
  141. package/Tests/UI/Components/Tabs.test.tsx +24 -0
  142. package/Tests/UI/Components/Toast.test.tsx +130 -7
  143. package/Tests/UI/Components/ToastStacking.test.tsx +198 -0
  144. package/Tests/UI/Utils/Dropdown.test.ts +423 -0
  145. package/Tests/UI/Utils/ModelAPITenantHeader.test.ts +122 -0
  146. package/Tests/UI/Utils/ModelListCache.test.ts +338 -0
  147. package/Tests/UI/Utils/ProjectListCacheInvalidation.test.ts +225 -0
  148. package/Tests/Utils/API.test.ts +16 -2
  149. package/Tests/Utils/Dashboard/Components/DashboardKubernetesResourceListShared.test.ts +347 -0
  150. package/Tests/Utils/Dashboard/Components/DashboardListSharedArgs.test.ts +259 -0
  151. package/Tests/Utils/Dashboard/Components/DashboardMonitorListComponent.test.ts +466 -0
  152. package/Tests/Utils/Rum/UrlScrubberSegments.test.ts +362 -0
  153. package/Types/Email/EmailTemplateType.ts +1 -0
  154. package/Types/Monitor/MonitorCriteriaInstance.ts +41 -20
  155. package/Types/Monitor/Recommendation/MonitorRecommendationCatalog.ts +131 -8
  156. package/Types/Monitor/Recommendation/MonitorRecommendationTypes.ts +48 -8
  157. package/Types/Monitor/ServiceAlertTemplates.ts +1352 -0
  158. package/Types/Permission.ts +35 -0
  159. package/Types/PushNotification/AndroidNotificationChannel.ts +26 -0
  160. package/Types/PushNotification/PushNotificationMessage.ts +15 -0
  161. package/Types/PushNotification/PushNotificationRequest.ts +8 -16
  162. package/Types/Service/ServiceLanguage.ts +144 -0
  163. package/Types/Text.ts +111 -19
  164. package/UI/Components/Button/Button.tsx +27 -14
  165. package/UI/Components/CodeBlock/CodeBlock.tsx +54 -7
  166. package/UI/Components/CodeBlock/LanguageRegistry.ts +103 -0
  167. package/UI/Components/CommandPalette/CommandPalette.tsx +790 -0
  168. package/UI/Components/CommandPalette/PaletteFilter.ts +196 -0
  169. package/UI/Components/CommandPalette/PaletteRow.tsx +173 -0
  170. package/UI/Components/CommandPalette/RecentCommands.ts +57 -0
  171. package/UI/Components/CommandPalette/Types.ts +49 -0
  172. package/UI/Components/CommandPalette/UseProviderSearch.ts +129 -0
  173. package/UI/Components/Dropdown/Dropdown.tsx +6 -1
  174. package/UI/Components/Feed/FeedItem.tsx +1 -1
  175. package/UI/Components/List/List.tsx +37 -5
  176. package/UI/Components/List/ListSkeleton.tsx +54 -0
  177. package/UI/Components/Markdown.tsx/LazyMarkdownViewer.tsx +6 -1
  178. package/UI/Components/Markdown.tsx/MarkdownViewer.tsx +71 -26
  179. package/UI/Components/Modal/Modal.tsx +2 -2
  180. package/UI/Components/ModelTable/BaseModelTable.tsx +1 -1
  181. package/UI/Components/ModelTable/useCustomFieldColumns.ts +11 -5
  182. package/UI/Components/MoreMenu/MoreMenu.tsx +39 -1
  183. package/UI/Components/Navbar/NavBar.tsx +14 -1
  184. package/UI/Components/OrderedStatesList/OrderedStatesList.tsx +50 -5
  185. package/UI/Components/SideMenu/CountModelSideMenuItem.tsx +40 -1
  186. package/UI/Components/SideOver/SideOver.tsx +37 -2
  187. package/UI/Components/Skeleton/Skeleton.tsx +53 -0
  188. package/UI/Components/Table/Table.tsx +74 -44
  189. package/UI/Components/Table/TableSkeletonRows.tsx +141 -0
  190. package/UI/Components/Tabs/Tab.tsx +1 -1
  191. package/UI/Components/Toast/Toast.tsx +118 -53
  192. package/UI/Components/Toast/ToastInit.tsx +82 -32
  193. package/UI/Styles/Theme.css +40 -0
  194. package/UI/Utils/ModelAPI/ModelAPI.ts +20 -10
  195. package/UI/Utils/ModelListCache.ts +188 -0
  196. package/UI/Utils/Project.ts +29 -0
  197. package/Utils/API.ts +6 -1
  198. package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js +14 -0
  199. package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js.map +1 -1
  200. package/build/dist/Models/DatabaseModels/UserCall.js +101 -3
  201. package/build/dist/Models/DatabaseModels/UserCall.js.map +1 -1
  202. package/build/dist/Models/DatabaseModels/UserEmail.js +101 -3
  203. package/build/dist/Models/DatabaseModels/UserEmail.js.map +1 -1
  204. package/build/dist/Models/DatabaseModels/UserIncomingCallNumber.js +101 -3
  205. package/build/dist/Models/DatabaseModels/UserIncomingCallNumber.js.map +1 -1
  206. package/build/dist/Models/DatabaseModels/UserPush.js +38 -0
  207. package/build/dist/Models/DatabaseModels/UserPush.js.map +1 -1
  208. package/build/dist/Models/DatabaseModels/UserSMS.js +101 -3
  209. package/build/dist/Models/DatabaseModels/UserSMS.js.map +1 -1
  210. package/build/dist/Models/DatabaseModels/UserWhatsApp.js +100 -2
  211. package/build/dist/Models/DatabaseModels/UserWhatsApp.js.map +1 -1
  212. package/build/dist/Server/API/BaseAPI.js +69 -13
  213. package/build/dist/Server/API/BaseAPI.js.map +1 -1
  214. package/build/dist/Server/API/UserCallAPI.js +42 -38
  215. package/build/dist/Server/API/UserCallAPI.js.map +1 -1
  216. package/build/dist/Server/API/UserEmailAPI.js +42 -38
  217. package/build/dist/Server/API/UserEmailAPI.js.map +1 -1
  218. package/build/dist/Server/API/UserIncomingCallNumberAPI.js +37 -32
  219. package/build/dist/Server/API/UserIncomingCallNumberAPI.js.map +1 -1
  220. package/build/dist/Server/API/UserNotificationMethodAdminAPI.js +341 -0
  221. package/build/dist/Server/API/UserNotificationMethodAdminAPI.js.map +1 -0
  222. package/build/dist/Server/API/UserPushAPI.js +95 -2
  223. package/build/dist/Server/API/UserPushAPI.js.map +1 -1
  224. package/build/dist/Server/API/UserSmsAPI.js +42 -38
  225. package/build/dist/Server/API/UserSmsAPI.js.map +1 -1
  226. package/build/dist/Server/API/UserWhatsAppAPI.js +42 -40
  227. package/build/dist/Server/API/UserWhatsAppAPI.js.map +1 -1
  228. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787142779538-MigrationName.js +89 -0
  229. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787142779538-MigrationName.js.map +1 -0
  230. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787156982416-MigrationName.js +12 -0
  231. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787156982416-MigrationName.js.map +1 -0
  232. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787700000000-FixTotpOtpUrlAlgorithm.js +40 -0
  233. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787700000000-FixTotpOtpUrlAlgorithm.js.map +1 -0
  234. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +6 -0
  235. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  236. package/build/dist/Server/Middleware/VerificationCodeRateLimit.js +393 -0
  237. package/build/dist/Server/Middleware/VerificationCodeRateLimit.js.map +1 -0
  238. package/build/dist/Server/Services/AnalyticsDatabaseService.js +136 -0
  239. package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
  240. package/build/dist/Server/Services/DatabaseService.js +74 -0
  241. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  242. package/build/dist/Server/Services/LogAggregationService.js +5 -0
  243. package/build/dist/Server/Services/LogAggregationService.js.map +1 -1
  244. package/build/dist/Server/Services/PushNotificationService.js +69 -30
  245. package/build/dist/Server/Services/PushNotificationService.js.map +1 -1
  246. package/build/dist/Server/Services/TeamMemberService.js +43 -7
  247. package/build/dist/Server/Services/TeamMemberService.js.map +1 -1
  248. package/build/dist/Server/Services/UserCallService.js +54 -22
  249. package/build/dist/Server/Services/UserCallService.js.map +1 -1
  250. package/build/dist/Server/Services/UserEmailService.js +78 -17
  251. package/build/dist/Server/Services/UserEmailService.js.map +1 -1
  252. package/build/dist/Server/Services/UserIncomingCallNumberService.js +45 -74
  253. package/build/dist/Server/Services/UserIncomingCallNumberService.js.map +1 -1
  254. package/build/dist/Server/Services/UserNotificationMethodAdminService.js +1117 -0
  255. package/build/dist/Server/Services/UserNotificationMethodAdminService.js.map +1 -0
  256. package/build/dist/Server/Services/UserNotificationRuleService.js +20 -0
  257. package/build/dist/Server/Services/UserNotificationRuleService.js.map +1 -1
  258. package/build/dist/Server/Services/UserPushService.js +77 -0
  259. package/build/dist/Server/Services/UserPushService.js.map +1 -1
  260. package/build/dist/Server/Services/UserSmsService.js +50 -17
  261. package/build/dist/Server/Services/UserSmsService.js.map +1 -1
  262. package/build/dist/Server/Services/UserWhatsAppService.js +48 -15
  263. package/build/dist/Server/Services/UserWhatsAppService.js.map +1 -1
  264. package/build/dist/Server/Types/Database/QueryHelper.js +27 -0
  265. package/build/dist/Server/Types/Database/QueryHelper.js.map +1 -1
  266. package/build/dist/Server/Utils/AnalyticsDatabase/QuerySettingsHelper.js +46 -0
  267. package/build/dist/Server/Utils/AnalyticsDatabase/QuerySettingsHelper.js.map +1 -1
  268. package/build/dist/Server/Utils/ChannelVerification.js +348 -0
  269. package/build/dist/Server/Utils/ChannelVerification.js.map +1 -0
  270. package/build/dist/Server/Utils/Monitor/Criteria/IncomingRequestCriteria.js +13 -12
  271. package/build/dist/Server/Utils/Monitor/Criteria/IncomingRequestCriteria.js.map +1 -1
  272. package/build/dist/Server/Utils/Monitor/MonitorAlert.js +20 -17
  273. package/build/dist/Server/Utils/Monitor/MonitorAlert.js.map +1 -1
  274. package/build/dist/Server/Utils/Monitor/MonitorIncident.js +17 -14
  275. package/build/dist/Server/Utils/Monitor/MonitorIncident.js.map +1 -1
  276. package/build/dist/Server/Utils/Monitor/MonitorResourceContext.js +95 -0
  277. package/build/dist/Server/Utils/Monitor/MonitorResourceContext.js.map +1 -0
  278. package/build/dist/Server/Utils/Monitor/MonitorStepResourceIdentity.js +313 -0
  279. package/build/dist/Server/Utils/Monitor/MonitorStepResourceIdentity.js.map +1 -0
  280. package/build/dist/Server/Utils/Monitor/SeriesResourceLabels.js +3 -3
  281. package/build/dist/Server/Utils/Monitor/SeriesResourceLinker.js +67 -26
  282. package/build/dist/Server/Utils/Monitor/SeriesResourceLinker.js.map +1 -1
  283. package/build/dist/Server/Utils/TotpAuth.js +127 -8
  284. package/build/dist/Server/Utils/TotpAuth.js.map +1 -1
  285. package/build/dist/Server/Utils/UserRegistrationToken.js +170 -0
  286. package/build/dist/Server/Utils/UserRegistrationToken.js.map +1 -0
  287. package/build/dist/Server/Utils/VerificationCode.js +120 -0
  288. package/build/dist/Server/Utils/VerificationCode.js.map +1 -0
  289. package/build/dist/Types/Email/EmailTemplateType.js +1 -0
  290. package/build/dist/Types/Email/EmailTemplateType.js.map +1 -1
  291. package/build/dist/Types/Monitor/MonitorCriteriaInstance.js +42 -21
  292. package/build/dist/Types/Monitor/MonitorCriteriaInstance.js.map +1 -1
  293. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationCatalog.js +77 -5
  294. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationCatalog.js.map +1 -1
  295. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationTypes.js +18 -6
  296. package/build/dist/Types/Monitor/Recommendation/MonitorRecommendationTypes.js.map +1 -1
  297. package/build/dist/Types/Monitor/ServiceAlertTemplates.js +1028 -0
  298. package/build/dist/Types/Monitor/ServiceAlertTemplates.js.map +1 -0
  299. package/build/dist/Types/Permission.js +33 -0
  300. package/build/dist/Types/Permission.js.map +1 -1
  301. package/build/dist/Types/PushNotification/AndroidNotificationChannel.js +27 -0
  302. package/build/dist/Types/PushNotification/AndroidNotificationChannel.js.map +1 -0
  303. package/build/dist/Types/Service/ServiceLanguage.js +96 -0
  304. package/build/dist/Types/Service/ServiceLanguage.js.map +1 -0
  305. package/build/dist/Types/Text.js +82 -17
  306. package/build/dist/Types/Text.js.map +1 -1
  307. package/build/dist/UI/Components/Button/Button.js +22 -14
  308. package/build/dist/UI/Components/Button/Button.js.map +1 -1
  309. package/build/dist/UI/Components/CodeBlock/CodeBlock.js +35 -3
  310. package/build/dist/UI/Components/CodeBlock/CodeBlock.js.map +1 -1
  311. package/build/dist/UI/Components/CodeBlock/LanguageRegistry.js +57 -0
  312. package/build/dist/UI/Components/CodeBlock/LanguageRegistry.js.map +1 -0
  313. package/build/dist/UI/Components/CommandPalette/CommandPalette.js +439 -0
  314. package/build/dist/UI/Components/CommandPalette/CommandPalette.js.map +1 -0
  315. package/build/dist/UI/Components/CommandPalette/PaletteFilter.js +135 -0
  316. package/build/dist/UI/Components/CommandPalette/PaletteFilter.js.map +1 -0
  317. package/build/dist/UI/Components/CommandPalette/PaletteRow.js +83 -0
  318. package/build/dist/UI/Components/CommandPalette/PaletteRow.js.map +1 -0
  319. package/build/dist/UI/Components/CommandPalette/RecentCommands.js +47 -0
  320. package/build/dist/UI/Components/CommandPalette/RecentCommands.js.map +1 -0
  321. package/build/dist/UI/Components/CommandPalette/Types.js +2 -0
  322. package/build/dist/UI/Components/CommandPalette/Types.js.map +1 -0
  323. package/build/dist/UI/Components/CommandPalette/UseProviderSearch.js +88 -0
  324. package/build/dist/UI/Components/CommandPalette/UseProviderSearch.js.map +1 -0
  325. package/build/dist/UI/Components/Dropdown/Dropdown.js +6 -1
  326. package/build/dist/UI/Components/Dropdown/Dropdown.js.map +1 -1
  327. package/build/dist/UI/Components/Feed/FeedItem.js +1 -1
  328. package/build/dist/UI/Components/Feed/FeedItem.js.map +1 -1
  329. package/build/dist/UI/Components/List/List.js +19 -5
  330. package/build/dist/UI/Components/List/List.js.map +1 -1
  331. package/build/dist/UI/Components/List/ListSkeleton.js +21 -0
  332. package/build/dist/UI/Components/List/ListSkeleton.js.map +1 -0
  333. package/build/dist/UI/Components/Markdown.tsx/LazyMarkdownViewer.js.map +1 -1
  334. package/build/dist/UI/Components/Markdown.tsx/MarkdownViewer.js +52 -25
  335. package/build/dist/UI/Components/Markdown.tsx/MarkdownViewer.js.map +1 -1
  336. package/build/dist/UI/Components/Modal/Modal.js +2 -2
  337. package/build/dist/UI/Components/ModelTable/BaseModelTable.js +1 -1
  338. package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
  339. package/build/dist/UI/Components/ModelTable/useCustomFieldColumns.js +9 -2
  340. package/build/dist/UI/Components/ModelTable/useCustomFieldColumns.js.map +1 -1
  341. package/build/dist/UI/Components/MoreMenu/MoreMenu.js +32 -1
  342. package/build/dist/UI/Components/MoreMenu/MoreMenu.js.map +1 -1
  343. package/build/dist/UI/Components/Navbar/NavBar.js +5 -1
  344. package/build/dist/UI/Components/Navbar/NavBar.js.map +1 -1
  345. package/build/dist/UI/Components/OrderedStatesList/OrderedStatesList.js +29 -5
  346. package/build/dist/UI/Components/OrderedStatesList/OrderedStatesList.js.map +1 -1
  347. package/build/dist/UI/Components/SideMenu/CountModelSideMenuItem.js +23 -1
  348. package/build/dist/UI/Components/SideMenu/CountModelSideMenuItem.js.map +1 -1
  349. package/build/dist/UI/Components/SideOver/SideOver.js +23 -2
  350. package/build/dist/UI/Components/SideOver/SideOver.js.map +1 -1
  351. package/build/dist/UI/Components/Skeleton/Skeleton.js +23 -0
  352. package/build/dist/UI/Components/Skeleton/Skeleton.js.map +1 -0
  353. package/build/dist/UI/Components/Table/Table.js +35 -24
  354. package/build/dist/UI/Components/Table/Table.js.map +1 -1
  355. package/build/dist/UI/Components/Table/TableSkeletonRows.js +52 -0
  356. package/build/dist/UI/Components/Table/TableSkeletonRows.js.map +1 -0
  357. package/build/dist/UI/Components/Tabs/Tab.js +1 -1
  358. package/build/dist/UI/Components/Tabs/Tab.js.map +1 -1
  359. package/build/dist/UI/Components/Toast/Toast.js +77 -23
  360. package/build/dist/UI/Components/Toast/Toast.js.map +1 -1
  361. package/build/dist/UI/Components/Toast/ToastInit.js +37 -15
  362. package/build/dist/UI/Components/Toast/ToastInit.js.map +1 -1
  363. package/build/dist/UI/Utils/ModelAPI/ModelAPI.js +18 -9
  364. package/build/dist/UI/Utils/ModelAPI/ModelAPI.js.map +1 -1
  365. package/build/dist/UI/Utils/ModelListCache.js +126 -0
  366. package/build/dist/UI/Utils/ModelListCache.js.map +1 -0
  367. package/build/dist/UI/Utils/Project.js +24 -1
  368. package/build/dist/UI/Utils/Project.js.map +1 -1
  369. package/build/dist/Utils/API.js +6 -1
  370. package/build/dist/Utils/API.js.map +1 -1
  371. package/jest.config.json +1 -1
  372. package/package.json +2 -3
  373. package/Server/Utils/Monitor/MonitorClusterContext.ts +0 -182
  374. package/Tests/__mocks__/otpauth.js +0 -30
  375. package/build/dist/Server/Utils/Monitor/MonitorClusterContext.js +0 -141
  376. package/build/dist/Server/Utils/Monitor/MonitorClusterContext.js.map +0 -1
@@ -0,0 +1,1527 @@
1
+ import DatabaseConfig from "../DatabaseConfig";
2
+ import BaseService from "./BaseService";
3
+ import MailService from "./MailService";
4
+ import OnCallReadinessService, {
5
+ MaskedIdentifierKind,
6
+ ReadinessMethodType,
7
+ maskIdentifier,
8
+ } from "./OnCallReadinessService";
9
+ import ProjectService from "./ProjectService";
10
+ import TeamMemberService from "./TeamMemberService";
11
+ import UserCallService from "./UserCallService";
12
+ import UserEmailService from "./UserEmailService";
13
+ import UserNotificationRuleService, {
14
+ NotificationDeletionImpact,
15
+ } from "./UserNotificationRuleService";
16
+ import UserPushService from "./UserPushService";
17
+ import UserService from "./UserService";
18
+ import UserSmsService from "./UserSmsService";
19
+ import UserTelegramService from "./UserTelegramService";
20
+ import UserWebhookService from "./UserWebhookService";
21
+ import UserWhatsAppService from "./UserWhatsAppService";
22
+ import type AuditLogServiceType from "./AuditLogService";
23
+ import logger, { LogAttributes } from "../Utils/Logger";
24
+ import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
25
+ import URL from "../../Types/API/URL";
26
+ import AuditLogAction from "../../Types/AuditLog/AuditLogAction";
27
+ import DatabaseCommonInteractionProps from "../../Types/BaseDatabase/DatabaseCommonInteractionProps";
28
+ import SortOrder from "../../Types/BaseDatabase/SortOrder";
29
+ import { LIMIT_PER_PROJECT } from "../../Types/Database/LimitMax";
30
+ import Dictionary from "../../Types/Dictionary";
31
+ import Email from "../../Types/Email";
32
+ import EmailTemplateType from "../../Types/Email/EmailTemplateType";
33
+ import BadDataException from "../../Types/Exception/BadDataException";
34
+ import ObjectID from "../../Types/ObjectID";
35
+ import Phone from "../../Types/Phone";
36
+ import BaseModel from "../../Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel";
37
+ import Project from "../../Models/DatabaseModels/Project";
38
+ import TeamMember from "../../Models/DatabaseModels/TeamMember";
39
+ import User from "../../Models/DatabaseModels/User";
40
+ import UserCall from "../../Models/DatabaseModels/UserCall";
41
+ import UserEmail from "../../Models/DatabaseModels/UserEmail";
42
+ import UserPush from "../../Models/DatabaseModels/UserPush";
43
+ import UserSMS from "../../Models/DatabaseModels/UserSMS";
44
+ import UserTelegram from "../../Models/DatabaseModels/UserTelegram";
45
+ import UserWebhook from "../../Models/DatabaseModels/UserWebhook";
46
+ import UserWhatsApp from "../../Models/DatabaseModels/UserWhatsApp";
47
+
48
+ /*
49
+ * ADDING A NOTIFICATION METHOD TO SOMEBODY ELSE'S ACCOUNT.
50
+ *
51
+ * The readiness work that came before this one could tell a project owner that
52
+ * a responder was unreachable, and the admin rules page let them repair that
53
+ * responder's RULES. Neither could do anything about the case that produces
54
+ * most unreachable responders in practice: somebody who has no notification
55
+ * method at all. A rule is a pointer; with nothing to point at, an owner
56
+ * looking at a brand-new engineer's page could only send them a reminder and
57
+ * hope. This service is what closes that last gap.
58
+ *
59
+ * It is deliberately NOT "widen the seven method models so admins can write
60
+ * them". That was tried, and the essay at the top of UserEmail.ts is the record
61
+ * of how it failed: those models are scoped to their owner by
62
+ * TenantPermission.isAccessGrantedOnlyByCurrentUser, which holds exactly while
63
+ * their table access control lists Permission.CurrentUser and nothing else, and
64
+ * every attempt to keep the raw columns fenced off once that stamp stopped
65
+ * being applied was walked past by something — nested relation selects, `query`
66
+ * filters, sort columns appended after the guard had run. The models are still
67
+ * CurrentUser-only. Nothing in this file changes that, and an administrator's
68
+ * every direct read or write of UserEmail, UserSMS, UserCall, UserWhatsApp,
69
+ * UserTelegram, UserPush and UserWebhook is still refused.
70
+ *
71
+ * What this file does instead is a NARROW, SERVER-SIDE, AUDITED capability
72
+ * that runs as root on the admin's behalf and never hands them the row:
73
+ *
74
+ * - LIST returns masked identifiers only, through the same maskIdentifier
75
+ * the readiness payload uses. There is no unmask-for-anybody branch, and
76
+ * the raw column never enters a response body.
77
+ *
78
+ * - ADD writes the row with the target user's id and isVerified left at its
79
+ * default of false, which makes the method service send a verification code
80
+ * TO THE DEVICE. That is the whole safety property, and it is worth being
81
+ * precise about: an administrator can type a phone number in, but they
82
+ * cannot make it live. /user-email/verify, /user-sms/verify and their
83
+ * siblings all compare the row's userId against the SIGNED-IN caller and
84
+ * refuse anybody else (UserEmailAPI.ts and friends), so the code that was
85
+ * just sent is only usable by the person holding the device. An admin who
86
+ * types their own number in has created a row that will never be verified,
87
+ * will never be selected by the fallback, and shows up on the owner's own
88
+ * notification methods page as an unverified method they did not add — on
89
+ * top of the mail this service sends them.
90
+ *
91
+ * - REMOVE is allowed on all seven channels, because offboarding a stale
92
+ * device is a real administrative job and the owner may well be gone. It is
93
+ * the destructive direction, so it is the one with a preview attached:
94
+ * UserNotificationRuleService.getNotificationMethodDeletionImpact says how
95
+ * many rules would go with it and whether the person is reachable
96
+ * afterwards. That method was written for this caller and had none until
97
+ * now.
98
+ *
99
+ * - ADD is allowed on FOUR channels — Email, SMS, Call, WhatsApp — and the
100
+ * omissions are not oversights. Push is a device token minted by a browser
101
+ * or a phone at registration time; there is nothing an admin could type.
102
+ * Telegram needs the account holder to message the bot before a chat id
103
+ * exists. Webhook is the one that is deliberately refused rather than
104
+ * merely impractical: UserWebhook has no isVerified column at all, so an
105
+ * admin-created webhook would be live the moment it was written, which is
106
+ * exactly the silent-redirect this whole design is built to prevent.
107
+ *
108
+ * EVERY WRITE TELLS THE OWNER. Same reasoning as the rules path: an admin
109
+ * adding a colleague's work phone and an attacker adding their own are the same
110
+ * request from the server's point of view, and the only party who can always
111
+ * tell them apart is the person whose account it is.
112
+ */
113
+
114
+ /**
115
+ * A method as an administrator is allowed to see it: enough to recognise, never
116
+ * enough to reach.
117
+ *
118
+ * `methodId` is a foreign key, not a secret — it is already stored in plain
119
+ * sight on every rule the owner has created, and it is what lets an admin point
120
+ * a rule at a method (or remove one) without reading the row behind it.
121
+ *
122
+ * Nothing else belongs on this interface. Every field added here is a field
123
+ * that ships to every administrator of the project.
124
+ */
125
+ export interface AdminNotificationMethodView {
126
+ methodId: ObjectID;
127
+ methodType: ReadinessMethodType;
128
+ /** Already masked by maskIdentifier. Never the raw value, for any caller. */
129
+ maskedIdentifier: string;
130
+ isVerified: boolean;
131
+ /**
132
+ * Whether an administrator could have created this kind of method at all.
133
+ * Drives the page's copy, so a responder with only a webhook is told why the
134
+ * "Add" control does not offer one rather than being left to guess.
135
+ */
136
+ isAdminAddable: boolean;
137
+ createdAt?: Date | undefined;
138
+ }
139
+
140
+ /**
141
+ * The four channels an administrator may CREATE. See the header for why the
142
+ * other three are absent; this enum is the single place that decides, and both
143
+ * the API's validation and the service's own dispatch read it.
144
+ */
145
+ export enum AdminAddableChannel {
146
+ Email = "Email",
147
+ SMS = "SMS",
148
+ Call = "Call",
149
+ WhatsApp = "WhatsApp",
150
+ }
151
+
152
+ /**
153
+ * A compact deletion preview, for the confirmation an admin sees before
154
+ * removing somebody else's method.
155
+ *
156
+ * It is a projection of NotificationDeletionImpact rather than that whole
157
+ * structure: the full one carries per-cell severity names, and an admin
158
+ * confirming a removal needs the counts and the one sentence about whether this
159
+ * is the deletion that makes a person unreachable.
160
+ */
161
+ export interface AdminNotificationMethodDeletionPreview {
162
+ rulesDeletedCount: number;
163
+ coverageLostCount: number;
164
+ verifiedMethodCountAfterDeletion: number;
165
+ reachability: string;
166
+ isFallbackEnabled: boolean;
167
+ /**
168
+ * TRUE means the read behind these numbers hit its ceiling, so they are
169
+ * floors rather than totals. Carried through rather than swallowed: a
170
+ * confirmation that undercounts silently is worse than one that says it is
171
+ * unsure.
172
+ */
173
+ isTruncated: boolean;
174
+ }
175
+
176
+ /*
177
+ * One channel, and everything this service needs to do to it. Built per call
178
+ * rather than hoisted to module scope for the same reason
179
+ * UserNotificationRuleAdminService builds its descriptors per call: the seven
180
+ * method services import UserNotificationRuleService, which imports this
181
+ * file's siblings, and a module-level constant would capture `default` while
182
+ * one of those modules was still mid-evaluation and freeze `undefined` into the
183
+ * table.
184
+ */
185
+ interface ChannelDescriptor {
186
+ methodType: ReadinessMethodType;
187
+ isAdminAddable: boolean;
188
+ /*
189
+ * The model class behind this channel, used for ONE thing: naming the table
190
+ * an audit entry is about. Never instantiated with a real row's values — see
191
+ * writeAuditLog for why the entry is built from a blank instance.
192
+ */
193
+ modelType: { new (): BaseModel };
194
+ /** Reads every one of this user's rows on this channel, already masked. */
195
+ list: (data: {
196
+ projectId: ObjectID;
197
+ userId: ObjectID;
198
+ }) => Promise<Array<AdminNotificationMethodView>>;
199
+ /** Confirms one row exists AND belongs to this user in this project. */
200
+ findOwnedRow: (data: {
201
+ methodId: ObjectID;
202
+ projectId: ObjectID;
203
+ userId: ObjectID;
204
+ }) => Promise<AdminNotificationMethodView | null>;
205
+ deleteRow: (data: { methodId: ObjectID }) => Promise<void>;
206
+ /** Absent on the three channels an administrator may not create. */
207
+ create?:
208
+ | ((data: {
209
+ projectId: ObjectID;
210
+ userId: ObjectID;
211
+ value: string;
212
+ }) => Promise<AdminNotificationMethodView>)
213
+ | undefined;
214
+ /** Absent on Webhook, which has no verification concept at all. */
215
+ resendVerificationCode?: ((methodId: ObjectID) => Promise<void>) | undefined;
216
+ }
217
+
218
+ export class UserNotificationMethodAdminService extends BaseService {
219
+ /*
220
+ * The seven channels. Every one of them is listable and deletable; four are
221
+ * creatable. A channel missing from this table is a channel this service
222
+ * cannot see at all, which is why the list is exhaustive rather than
223
+ * restricted to the interesting ones — an admin looking at a responder whose
224
+ * only method is a webhook must be told the webhook is there, or they will
225
+ * conclude the person has nothing and go and add a phone number that
226
+ * duplicates a working channel.
227
+ */
228
+ private getChannelDescriptors(): Array<ChannelDescriptor> {
229
+ return [
230
+ {
231
+ methodType: ReadinessMethodType.Email,
232
+ isAdminAddable: true,
233
+ modelType: UserEmail,
234
+ list: async (data: {
235
+ projectId: ObjectID;
236
+ userId: ObjectID;
237
+ }): Promise<Array<AdminNotificationMethodView>> => {
238
+ const rows: Array<UserEmail> = await UserEmailService.findBy({
239
+ query: { projectId: data.projectId, userId: data.userId },
240
+ select: {
241
+ _id: true,
242
+ email: true,
243
+ isVerified: true,
244
+ createdAt: true,
245
+ },
246
+ sort: { createdAt: SortOrder.Ascending },
247
+ skip: 0,
248
+ limit: LIMIT_PER_PROJECT,
249
+ props: { isRoot: true },
250
+ });
251
+
252
+ return rows.map((row: UserEmail): AdminNotificationMethodView => {
253
+ return this.toView({
254
+ row: row,
255
+ methodType: ReadinessMethodType.Email,
256
+ isAdminAddable: true,
257
+ identifier: row.email?.toString(),
258
+ kind: MaskedIdentifierKind.Email,
259
+ isVerified: row.isVerified,
260
+ });
261
+ });
262
+ },
263
+ findOwnedRow: async (data: {
264
+ methodId: ObjectID;
265
+ projectId: ObjectID;
266
+ userId: ObjectID;
267
+ }): Promise<AdminNotificationMethodView | null> => {
268
+ const row: UserEmail | null = await UserEmailService.findOneBy({
269
+ query: {
270
+ _id: data.methodId,
271
+ projectId: data.projectId,
272
+ userId: data.userId,
273
+ },
274
+ select: { _id: true, email: true, isVerified: true },
275
+ props: { isRoot: true },
276
+ });
277
+
278
+ return row
279
+ ? this.toView({
280
+ row: row,
281
+ methodType: ReadinessMethodType.Email,
282
+ isAdminAddable: true,
283
+ identifier: row.email?.toString(),
284
+ kind: MaskedIdentifierKind.Email,
285
+ isVerified: row.isVerified,
286
+ })
287
+ : null;
288
+ },
289
+ deleteRow: async (data: { methodId: ObjectID }): Promise<void> => {
290
+ await UserEmailService.deleteOneById({
291
+ id: data.methodId,
292
+ props: { isRoot: true },
293
+ });
294
+ },
295
+ create: async (data: {
296
+ projectId: ObjectID;
297
+ userId: ObjectID;
298
+ value: string;
299
+ }): Promise<AdminNotificationMethodView> => {
300
+ const model: UserEmail = new UserEmail();
301
+ model.projectId = data.projectId;
302
+ model.userId = data.userId;
303
+ model.email = new Email(data.value);
304
+
305
+ const created: UserEmail = await UserEmailService.create({
306
+ data: model,
307
+ props: { isRoot: true },
308
+ });
309
+
310
+ return this.toView({
311
+ row: created,
312
+ methodType: ReadinessMethodType.Email,
313
+ isAdminAddable: true,
314
+ identifier: created.email?.toString(),
315
+ kind: MaskedIdentifierKind.Email,
316
+ isVerified: created.isVerified,
317
+ });
318
+ },
319
+ resendVerificationCode: async (methodId: ObjectID): Promise<void> => {
320
+ await UserEmailService.resendVerificationCode(methodId);
321
+ },
322
+ },
323
+ {
324
+ methodType: ReadinessMethodType.SMS,
325
+ isAdminAddable: true,
326
+ modelType: UserSMS,
327
+ list: async (data: {
328
+ projectId: ObjectID;
329
+ userId: ObjectID;
330
+ }): Promise<Array<AdminNotificationMethodView>> => {
331
+ const rows: Array<UserSMS> = await UserSmsService.findBy({
332
+ query: { projectId: data.projectId, userId: data.userId },
333
+ select: {
334
+ _id: true,
335
+ phone: true,
336
+ isVerified: true,
337
+ createdAt: true,
338
+ },
339
+ sort: { createdAt: SortOrder.Ascending },
340
+ skip: 0,
341
+ limit: LIMIT_PER_PROJECT,
342
+ props: { isRoot: true },
343
+ });
344
+
345
+ return rows.map((row: UserSMS): AdminNotificationMethodView => {
346
+ return this.toView({
347
+ row: row,
348
+ methodType: ReadinessMethodType.SMS,
349
+ isAdminAddable: true,
350
+ identifier: row.phone?.toString(),
351
+ kind: MaskedIdentifierKind.Phone,
352
+ isVerified: row.isVerified,
353
+ });
354
+ });
355
+ },
356
+ findOwnedRow: async (data: {
357
+ methodId: ObjectID;
358
+ projectId: ObjectID;
359
+ userId: ObjectID;
360
+ }): Promise<AdminNotificationMethodView | null> => {
361
+ const row: UserSMS | null = await UserSmsService.findOneBy({
362
+ query: {
363
+ _id: data.methodId,
364
+ projectId: data.projectId,
365
+ userId: data.userId,
366
+ },
367
+ select: { _id: true, phone: true, isVerified: true },
368
+ props: { isRoot: true },
369
+ });
370
+
371
+ return row
372
+ ? this.toView({
373
+ row: row,
374
+ methodType: ReadinessMethodType.SMS,
375
+ isAdminAddable: true,
376
+ identifier: row.phone?.toString(),
377
+ kind: MaskedIdentifierKind.Phone,
378
+ isVerified: row.isVerified,
379
+ })
380
+ : null;
381
+ },
382
+ deleteRow: async (data: { methodId: ObjectID }): Promise<void> => {
383
+ await UserSmsService.deleteOneById({
384
+ id: data.methodId,
385
+ props: { isRoot: true },
386
+ });
387
+ },
388
+ create: async (data: {
389
+ projectId: ObjectID;
390
+ userId: ObjectID;
391
+ value: string;
392
+ }): Promise<AdminNotificationMethodView> => {
393
+ const model: UserSMS = new UserSMS();
394
+ model.projectId = data.projectId;
395
+ model.userId = data.userId;
396
+ model.phone = new Phone(data.value);
397
+
398
+ const created: UserSMS = await UserSmsService.create({
399
+ data: model,
400
+ props: { isRoot: true },
401
+ });
402
+
403
+ return this.toView({
404
+ row: created,
405
+ methodType: ReadinessMethodType.SMS,
406
+ isAdminAddable: true,
407
+ identifier: created.phone?.toString(),
408
+ kind: MaskedIdentifierKind.Phone,
409
+ isVerified: created.isVerified,
410
+ });
411
+ },
412
+ resendVerificationCode: async (methodId: ObjectID): Promise<void> => {
413
+ await UserSmsService.resendVerificationCode(methodId);
414
+ },
415
+ },
416
+ {
417
+ methodType: ReadinessMethodType.Call,
418
+ isAdminAddable: true,
419
+ modelType: UserCall,
420
+ list: async (data: {
421
+ projectId: ObjectID;
422
+ userId: ObjectID;
423
+ }): Promise<Array<AdminNotificationMethodView>> => {
424
+ const rows: Array<UserCall> = await UserCallService.findBy({
425
+ query: { projectId: data.projectId, userId: data.userId },
426
+ select: {
427
+ _id: true,
428
+ phone: true,
429
+ isVerified: true,
430
+ createdAt: true,
431
+ },
432
+ sort: { createdAt: SortOrder.Ascending },
433
+ skip: 0,
434
+ limit: LIMIT_PER_PROJECT,
435
+ props: { isRoot: true },
436
+ });
437
+
438
+ return rows.map((row: UserCall): AdminNotificationMethodView => {
439
+ return this.toView({
440
+ row: row,
441
+ methodType: ReadinessMethodType.Call,
442
+ isAdminAddable: true,
443
+ identifier: row.phone?.toString(),
444
+ kind: MaskedIdentifierKind.Phone,
445
+ isVerified: row.isVerified,
446
+ });
447
+ });
448
+ },
449
+ findOwnedRow: async (data: {
450
+ methodId: ObjectID;
451
+ projectId: ObjectID;
452
+ userId: ObjectID;
453
+ }): Promise<AdminNotificationMethodView | null> => {
454
+ const row: UserCall | null = await UserCallService.findOneBy({
455
+ query: {
456
+ _id: data.methodId,
457
+ projectId: data.projectId,
458
+ userId: data.userId,
459
+ },
460
+ select: { _id: true, phone: true, isVerified: true },
461
+ props: { isRoot: true },
462
+ });
463
+
464
+ return row
465
+ ? this.toView({
466
+ row: row,
467
+ methodType: ReadinessMethodType.Call,
468
+ isAdminAddable: true,
469
+ identifier: row.phone?.toString(),
470
+ kind: MaskedIdentifierKind.Phone,
471
+ isVerified: row.isVerified,
472
+ })
473
+ : null;
474
+ },
475
+ deleteRow: async (data: { methodId: ObjectID }): Promise<void> => {
476
+ await UserCallService.deleteOneById({
477
+ id: data.methodId,
478
+ props: { isRoot: true },
479
+ });
480
+ },
481
+ create: async (data: {
482
+ projectId: ObjectID;
483
+ userId: ObjectID;
484
+ value: string;
485
+ }): Promise<AdminNotificationMethodView> => {
486
+ const model: UserCall = new UserCall();
487
+ model.projectId = data.projectId;
488
+ model.userId = data.userId;
489
+ model.phone = new Phone(data.value);
490
+
491
+ const created: UserCall = await UserCallService.create({
492
+ data: model,
493
+ props: { isRoot: true },
494
+ });
495
+
496
+ return this.toView({
497
+ row: created,
498
+ methodType: ReadinessMethodType.Call,
499
+ isAdminAddable: true,
500
+ identifier: created.phone?.toString(),
501
+ kind: MaskedIdentifierKind.Phone,
502
+ isVerified: created.isVerified,
503
+ });
504
+ },
505
+ resendVerificationCode: async (methodId: ObjectID): Promise<void> => {
506
+ await UserCallService.resendVerificationCode(methodId);
507
+ },
508
+ },
509
+ {
510
+ methodType: ReadinessMethodType.WhatsApp,
511
+ isAdminAddable: true,
512
+ modelType: UserWhatsApp,
513
+ list: async (data: {
514
+ projectId: ObjectID;
515
+ userId: ObjectID;
516
+ }): Promise<Array<AdminNotificationMethodView>> => {
517
+ const rows: Array<UserWhatsApp> = await UserWhatsAppService.findBy({
518
+ query: { projectId: data.projectId, userId: data.userId },
519
+ select: {
520
+ _id: true,
521
+ phone: true,
522
+ isVerified: true,
523
+ createdAt: true,
524
+ },
525
+ sort: { createdAt: SortOrder.Ascending },
526
+ skip: 0,
527
+ limit: LIMIT_PER_PROJECT,
528
+ props: { isRoot: true },
529
+ });
530
+
531
+ return rows.map((row: UserWhatsApp): AdminNotificationMethodView => {
532
+ return this.toView({
533
+ row: row,
534
+ methodType: ReadinessMethodType.WhatsApp,
535
+ isAdminAddable: true,
536
+ identifier: row.phone?.toString(),
537
+ kind: MaskedIdentifierKind.Phone,
538
+ isVerified: row.isVerified,
539
+ });
540
+ });
541
+ },
542
+ findOwnedRow: async (data: {
543
+ methodId: ObjectID;
544
+ projectId: ObjectID;
545
+ userId: ObjectID;
546
+ }): Promise<AdminNotificationMethodView | null> => {
547
+ const row: UserWhatsApp | null = await UserWhatsAppService.findOneBy({
548
+ query: {
549
+ _id: data.methodId,
550
+ projectId: data.projectId,
551
+ userId: data.userId,
552
+ },
553
+ select: { _id: true, phone: true, isVerified: true },
554
+ props: { isRoot: true },
555
+ });
556
+
557
+ return row
558
+ ? this.toView({
559
+ row: row,
560
+ methodType: ReadinessMethodType.WhatsApp,
561
+ isAdminAddable: true,
562
+ identifier: row.phone?.toString(),
563
+ kind: MaskedIdentifierKind.Phone,
564
+ isVerified: row.isVerified,
565
+ })
566
+ : null;
567
+ },
568
+ deleteRow: async (data: { methodId: ObjectID }): Promise<void> => {
569
+ await UserWhatsAppService.deleteOneById({
570
+ id: data.methodId,
571
+ props: { isRoot: true },
572
+ });
573
+ },
574
+ create: async (data: {
575
+ projectId: ObjectID;
576
+ userId: ObjectID;
577
+ value: string;
578
+ }): Promise<AdminNotificationMethodView> => {
579
+ const model: UserWhatsApp = new UserWhatsApp();
580
+ model.projectId = data.projectId;
581
+ model.userId = data.userId;
582
+ model.phone = new Phone(data.value);
583
+
584
+ const created: UserWhatsApp = await UserWhatsAppService.create({
585
+ data: model,
586
+ props: { isRoot: true },
587
+ });
588
+
589
+ return this.toView({
590
+ row: created,
591
+ methodType: ReadinessMethodType.WhatsApp,
592
+ isAdminAddable: true,
593
+ identifier: created.phone?.toString(),
594
+ kind: MaskedIdentifierKind.Phone,
595
+ isVerified: created.isVerified,
596
+ });
597
+ },
598
+ resendVerificationCode: async (methodId: ObjectID): Promise<void> => {
599
+ await UserWhatsAppService.resendVerificationCode(methodId);
600
+ },
601
+ },
602
+ /*
603
+ * The three read-and-remove-only channels. They carry no `create`, which
604
+ * is what the API's "this channel cannot be added by an administrator"
605
+ * refusal is derived from — the refusal is a consequence of the table
606
+ * rather than a second list that could drift out of step with it.
607
+ */
608
+ {
609
+ methodType: ReadinessMethodType.Push,
610
+ isAdminAddable: false,
611
+ modelType: UserPush,
612
+ list: async (data: {
613
+ projectId: ObjectID;
614
+ userId: ObjectID;
615
+ }): Promise<Array<AdminNotificationMethodView>> => {
616
+ const rows: Array<UserPush> = await UserPushService.findBy({
617
+ query: { projectId: data.projectId, userId: data.userId },
618
+ select: {
619
+ _id: true,
620
+ deviceName: true,
621
+ isVerified: true,
622
+ createdAt: true,
623
+ },
624
+ sort: { createdAt: SortOrder.Ascending },
625
+ skip: 0,
626
+ limit: LIMIT_PER_PROJECT,
627
+ props: { isRoot: true },
628
+ });
629
+
630
+ /*
631
+ * `deviceName` and never `deviceToken`. The token is the addressable
632
+ * secret behind a push method — anyone holding it can push to that
633
+ * device — and this select is the only thing standing between it and
634
+ * a response body.
635
+ */
636
+ return rows.map((row: UserPush): AdminNotificationMethodView => {
637
+ return this.toView({
638
+ row: row,
639
+ methodType: ReadinessMethodType.Push,
640
+ isAdminAddable: false,
641
+ identifier: row.deviceName,
642
+ kind: MaskedIdentifierKind.Handle,
643
+ isVerified: row.isVerified,
644
+ });
645
+ });
646
+ },
647
+ findOwnedRow: async (data: {
648
+ methodId: ObjectID;
649
+ projectId: ObjectID;
650
+ userId: ObjectID;
651
+ }): Promise<AdminNotificationMethodView | null> => {
652
+ const row: UserPush | null = await UserPushService.findOneBy({
653
+ query: {
654
+ _id: data.methodId,
655
+ projectId: data.projectId,
656
+ userId: data.userId,
657
+ },
658
+ select: { _id: true, deviceName: true, isVerified: true },
659
+ props: { isRoot: true },
660
+ });
661
+
662
+ return row
663
+ ? this.toView({
664
+ row: row,
665
+ methodType: ReadinessMethodType.Push,
666
+ isAdminAddable: false,
667
+ identifier: row.deviceName,
668
+ kind: MaskedIdentifierKind.Handle,
669
+ isVerified: row.isVerified,
670
+ })
671
+ : null;
672
+ },
673
+ deleteRow: async (data: { methodId: ObjectID }): Promise<void> => {
674
+ await UserPushService.deleteOneById({
675
+ id: data.methodId,
676
+ props: { isRoot: true },
677
+ });
678
+ },
679
+ },
680
+ {
681
+ methodType: ReadinessMethodType.Telegram,
682
+ isAdminAddable: false,
683
+ modelType: UserTelegram,
684
+ list: async (data: {
685
+ projectId: ObjectID;
686
+ userId: ObjectID;
687
+ }): Promise<Array<AdminNotificationMethodView>> => {
688
+ const rows: Array<UserTelegram> = await UserTelegramService.findBy({
689
+ query: { projectId: data.projectId, userId: data.userId },
690
+ /*
691
+ * The handle only — never telegramChatId, which is the addressable
692
+ * target a bot sends to. Same rule OnCallReadinessService follows.
693
+ */
694
+ select: {
695
+ _id: true,
696
+ telegramUserHandle: true,
697
+ isVerified: true,
698
+ createdAt: true,
699
+ },
700
+ sort: { createdAt: SortOrder.Ascending },
701
+ skip: 0,
702
+ limit: LIMIT_PER_PROJECT,
703
+ props: { isRoot: true },
704
+ });
705
+
706
+ return rows.map((row: UserTelegram): AdminNotificationMethodView => {
707
+ return this.toView({
708
+ row: row,
709
+ methodType: ReadinessMethodType.Telegram,
710
+ isAdminAddable: false,
711
+ identifier: row.telegramUserHandle,
712
+ kind: MaskedIdentifierKind.Handle,
713
+ isVerified: row.isVerified,
714
+ });
715
+ });
716
+ },
717
+ findOwnedRow: async (data: {
718
+ methodId: ObjectID;
719
+ projectId: ObjectID;
720
+ userId: ObjectID;
721
+ }): Promise<AdminNotificationMethodView | null> => {
722
+ const row: UserTelegram | null = await UserTelegramService.findOneBy({
723
+ query: {
724
+ _id: data.methodId,
725
+ projectId: data.projectId,
726
+ userId: data.userId,
727
+ },
728
+ select: {
729
+ _id: true,
730
+ telegramUserHandle: true,
731
+ isVerified: true,
732
+ },
733
+ props: { isRoot: true },
734
+ });
735
+
736
+ return row
737
+ ? this.toView({
738
+ row: row,
739
+ methodType: ReadinessMethodType.Telegram,
740
+ isAdminAddable: false,
741
+ identifier: row.telegramUserHandle,
742
+ kind: MaskedIdentifierKind.Handle,
743
+ isVerified: row.isVerified,
744
+ })
745
+ : null;
746
+ },
747
+ deleteRow: async (data: { methodId: ObjectID }): Promise<void> => {
748
+ await UserTelegramService.deleteOneById({
749
+ id: data.methodId,
750
+ props: { isRoot: true },
751
+ });
752
+ },
753
+ },
754
+ {
755
+ methodType: ReadinessMethodType.Webhook,
756
+ isAdminAddable: false,
757
+ modelType: UserWebhook,
758
+ list: async (data: {
759
+ projectId: ObjectID;
760
+ userId: ObjectID;
761
+ }): Promise<Array<AdminNotificationMethodView>> => {
762
+ const rows: Array<UserWebhook> = await UserWebhookService.findBy({
763
+ query: { projectId: data.projectId, userId: data.userId },
764
+ /*
765
+ * `name` ONLY. UserWebhook.webhookUrl is a bearer credential —
766
+ * anyone holding a Slack/Discord/Teams hook url can post as the
767
+ * integration — so it never leaves the server on this path.
768
+ */
769
+ select: { _id: true, name: true, createdAt: true },
770
+ sort: { createdAt: SortOrder.Ascending },
771
+ skip: 0,
772
+ limit: LIMIT_PER_PROJECT,
773
+ props: { isRoot: true },
774
+ });
775
+
776
+ return rows.map((row: UserWebhook): AdminNotificationMethodView => {
777
+ return this.toView({
778
+ row: row,
779
+ methodType: ReadinessMethodType.Webhook,
780
+ isAdminAddable: false,
781
+ identifier: row.name,
782
+ kind: MaskedIdentifierKind.Handle,
783
+ /*
784
+ * UserWebhook has no isVerified column: a webhook is live from
785
+ * the moment it is written. Reported as verified because that is
786
+ * what it behaves like, and reporting it as unverified would put
787
+ * a "needs setup" badge on a channel that is already delivering.
788
+ */
789
+ isVerified: true,
790
+ });
791
+ });
792
+ },
793
+ findOwnedRow: async (data: {
794
+ methodId: ObjectID;
795
+ projectId: ObjectID;
796
+ userId: ObjectID;
797
+ }): Promise<AdminNotificationMethodView | null> => {
798
+ const row: UserWebhook | null = await UserWebhookService.findOneBy({
799
+ query: {
800
+ _id: data.methodId,
801
+ projectId: data.projectId,
802
+ userId: data.userId,
803
+ },
804
+ select: { _id: true, name: true },
805
+ props: { isRoot: true },
806
+ });
807
+
808
+ return row
809
+ ? this.toView({
810
+ row: row,
811
+ methodType: ReadinessMethodType.Webhook,
812
+ isAdminAddable: false,
813
+ identifier: row.name,
814
+ kind: MaskedIdentifierKind.Handle,
815
+ isVerified: true,
816
+ })
817
+ : null;
818
+ },
819
+ deleteRow: async (data: { methodId: ObjectID }): Promise<void> => {
820
+ await UserWebhookService.deleteOneById({
821
+ id: data.methodId,
822
+ props: { isRoot: true },
823
+ });
824
+ },
825
+ },
826
+ ];
827
+ }
828
+
829
+ /*
830
+ * The ONLY place a raw identifier is turned into something this service will
831
+ * return. Every descriptor above routes through it, so there is no code path
832
+ * on which an unmasked value can reach a view object by accident — reaching
833
+ * one would mean deliberately constructing the interface by hand.
834
+ */
835
+ private toView(data: {
836
+ row: BaseModel;
837
+ methodType: ReadinessMethodType;
838
+ isAdminAddable: boolean;
839
+ identifier: string | undefined;
840
+ kind: MaskedIdentifierKind;
841
+ isVerified: boolean | undefined;
842
+ }): AdminNotificationMethodView {
843
+ return {
844
+ methodId: data.row.id!,
845
+ methodType: data.methodType,
846
+ maskedIdentifier: maskIdentifier(data.identifier, data.kind),
847
+ isVerified: Boolean(data.isVerified),
848
+ isAdminAddable: data.isAdminAddable,
849
+ createdAt: data.row.createdAt,
850
+ };
851
+ }
852
+
853
+ private getChannelDescriptor(
854
+ methodType: string,
855
+ ): ChannelDescriptor | undefined {
856
+ return this.getChannelDescriptors().find(
857
+ (descriptor: ChannelDescriptor): boolean => {
858
+ return descriptor.methodType === methodType;
859
+ },
860
+ );
861
+ }
862
+
863
+ /**
864
+ * Every notification method this user has in this project, masked.
865
+ *
866
+ * Ordered by channel and then by age, so the list an admin reads is stable
867
+ * between visits — a list that reshuffles is a list somebody misreads when
868
+ * they come back to remove the row they saw a moment ago.
869
+ */
870
+ @CaptureSpan()
871
+ public async listMethodsForUser(data: {
872
+ projectId: ObjectID;
873
+ userId: ObjectID;
874
+ }): Promise<Array<AdminNotificationMethodView>> {
875
+ const methods: Array<AdminNotificationMethodView> = [];
876
+
877
+ for (const descriptor of this.getChannelDescriptors()) {
878
+ methods.push(
879
+ ...(await descriptor.list({
880
+ projectId: data.projectId,
881
+ userId: data.userId,
882
+ })),
883
+ );
884
+ }
885
+
886
+ return methods;
887
+ }
888
+
889
+ /**
890
+ * Membership, read straight from TeamMember with root props and no cache.
891
+ *
892
+ * Holding an administrative permission is a claim about a PROJECT, so it can
893
+ * only ever authorise writing for users of that project. Without this, one
894
+ * throwaway project where the caller is an admin would license adding a phone
895
+ * number to any user id in the installation.
896
+ *
897
+ * TeamMemberService.getTeamIdsForUser would answer the same question with one
898
+ * fewer query, but it memoises for 60 seconds — and a security decision that
899
+ * keeps saying "yes" for a minute after somebody was removed from the project
900
+ * is not a security decision.
901
+ *
902
+ * `hasAcceptedInvitation` is part of the test for the same reason
903
+ * UserNotificationRuleAdminService includes it: a pending invitation is an
904
+ * admin-created row for an arbitrary email address, so treating one as
905
+ * membership would hand back most of what this guard takes away.
906
+ */
907
+ @CaptureSpan()
908
+ public async assertTargetUserIsProjectMember(data: {
909
+ targetUserId: ObjectID;
910
+ projectId: ObjectID;
911
+ }): Promise<void> {
912
+ const membership: TeamMember | null = await TeamMemberService.findOneBy({
913
+ query: {
914
+ userId: data.targetUserId,
915
+ projectId: data.projectId,
916
+ hasAcceptedInvitation: true,
917
+ },
918
+ select: { _id: true },
919
+ props: { isRoot: true },
920
+ });
921
+
922
+ if (!membership) {
923
+ throw new BadDataException(
924
+ "This user is not a member of this project, so their notification methods cannot be managed here.",
925
+ );
926
+ }
927
+ }
928
+
929
+ /**
930
+ * Add a notification method to another member's account.
931
+ *
932
+ * The row is written with the TARGET's userId and with isVerified untouched,
933
+ * which is what makes the method service mail or message a verification code
934
+ * to the address itself. Nothing here can mark it verified, and neither can
935
+ * the administrator afterwards: the verify endpoints compare the row's owner
936
+ * against the signed-in caller.
937
+ *
938
+ * `value` is validated by the type it becomes — Email and Phone both throw
939
+ * BadDataException from their constructors — so a malformed number is
940
+ * refused before any row is written rather than stored and discovered later
941
+ * by a page that fails to deliver.
942
+ */
943
+ @CaptureSpan()
944
+ public async addMethodForUser(data: {
945
+ projectId: ObjectID;
946
+ targetUserId: ObjectID;
947
+ actorUserId: ObjectID | undefined;
948
+ methodType: string;
949
+ value: string;
950
+ props: DatabaseCommonInteractionProps;
951
+ }): Promise<AdminNotificationMethodView> {
952
+ await this.assertTargetUserIsProjectMember({
953
+ targetUserId: data.targetUserId,
954
+ projectId: data.projectId,
955
+ });
956
+
957
+ const descriptor: ChannelDescriptor | undefined = this.getChannelDescriptor(
958
+ data.methodType,
959
+ );
960
+
961
+ if (!descriptor || !descriptor.create) {
962
+ /*
963
+ * One sentence for "no such channel" and for "that channel exists but an
964
+ * administrator may not create it", because the two are the same answer
965
+ * to the caller and naming which one it was would describe the shape of
966
+ * the feature to somebody probing it.
967
+ */
968
+ throw new BadDataException(
969
+ `A project administrator can only add Email, SMS, Call and WhatsApp notification methods for another user. Push, Telegram and webhook methods have to be added by the person who owns the device.`,
970
+ );
971
+ }
972
+
973
+ const trimmed: string = (data.value || "").trim();
974
+
975
+ if (!trimmed) {
976
+ throw new BadDataException(
977
+ `A ${descriptor.methodType} notification method needs a value.`,
978
+ );
979
+ }
980
+
981
+ /*
982
+ * Adding the same address twice is refused rather than deduplicated. Two
983
+ * identical rows are not merely untidy: they show up as two entries an
984
+ * admin has to tell apart on a masked list, each with its own verification
985
+ * state, and a rule pointed at the unverified one looks correct and pages
986
+ * nobody.
987
+ */
988
+ const existing: Array<AdminNotificationMethodView> = await descriptor.list({
989
+ projectId: data.projectId,
990
+ userId: data.targetUserId,
991
+ });
992
+
993
+ const candidateMask: string = maskIdentifier(
994
+ trimmed,
995
+ descriptor.methodType === ReadinessMethodType.Email
996
+ ? MaskedIdentifierKind.Email
997
+ : MaskedIdentifierKind.Phone,
998
+ );
999
+
1000
+ /*
1001
+ * Compared on the MASK, deliberately. The raw values are on the server and
1002
+ * could be compared directly, but a duplicate check that reports "this
1003
+ * already exists" for a value the admin cannot see is only useful if the
1004
+ * thing it matched is the thing they are looking at on screen — and what
1005
+ * they are looking at is the mask.
1006
+ */
1007
+ const isDuplicate: boolean = existing.some(
1008
+ (method: AdminNotificationMethodView): boolean => {
1009
+ return method.maskedIdentifier === candidateMask;
1010
+ },
1011
+ );
1012
+
1013
+ if (isDuplicate) {
1014
+ throw new BadDataException(
1015
+ `This user already has a ${descriptor.methodType} notification method that looks like ${candidateMask}.`,
1016
+ );
1017
+ }
1018
+
1019
+ const created: AdminNotificationMethodView = await descriptor.create({
1020
+ projectId: data.projectId,
1021
+ userId: data.targetUserId,
1022
+ value: trimmed,
1023
+ });
1024
+
1025
+ await this.recordAdminMethodChange({
1026
+ action: AuditLogAction.Create,
1027
+ actorUserId: data.actorUserId,
1028
+ ownerUserId: data.targetUserId,
1029
+ projectId: data.projectId,
1030
+ methodType: descriptor.methodType,
1031
+ modelType: descriptor.modelType,
1032
+ methodId: created.methodId,
1033
+ isVerified: created.isVerified,
1034
+ maskedIdentifier: created.maskedIdentifier,
1035
+ props: data.props,
1036
+ });
1037
+
1038
+ return created;
1039
+ }
1040
+
1041
+ /**
1042
+ * What removing this method would cost the person who owns it.
1043
+ *
1044
+ * Every method foreign key on UserNotificationRule is onDelete: "CASCADE",
1045
+ * and each method service deletes the same rows itself in onBeforeDelete, so
1046
+ * removing one phone number takes every rule that pointed at it with it. An
1047
+ * administrator has even less reason to expect that than the owner does —
1048
+ * they are usually tidying up a number they know is dead — so the numbers are
1049
+ * put in front of them before they confirm.
1050
+ *
1051
+ * The row is resolved through the ownership check first, so a caller cannot
1052
+ * use this preview to learn anything about a method id belonging to another
1053
+ * user or another project.
1054
+ */
1055
+ @CaptureSpan()
1056
+ public async getDeletionPreview(data: {
1057
+ projectId: ObjectID;
1058
+ targetUserId: ObjectID;
1059
+ methodType: string;
1060
+ methodId: ObjectID;
1061
+ }): Promise<AdminNotificationMethodDeletionPreview> {
1062
+ const descriptor: ChannelDescriptor =
1063
+ await this.resolveOwnedMethodDescriptor(data);
1064
+
1065
+ const impact: NotificationDeletionImpact =
1066
+ await UserNotificationRuleService.getNotificationMethodDeletionImpact({
1067
+ projectId: data.projectId,
1068
+ methodType: descriptor.methodType,
1069
+ methodId: data.methodId,
1070
+ });
1071
+
1072
+ return {
1073
+ rulesDeletedCount: impact.rulesDeletedCount,
1074
+ coverageLostCount: impact.coverageLost.length,
1075
+ verifiedMethodCountAfterDeletion: impact.verifiedMethodCountAfterDeletion,
1076
+ reachability: impact.reachability,
1077
+ isFallbackEnabled: impact.isFallbackEnabled,
1078
+ isTruncated: impact.isTruncated,
1079
+ };
1080
+ }
1081
+
1082
+ /**
1083
+ * Remove one of another member's notification methods.
1084
+ *
1085
+ * Allowed on all seven channels: a device that has been handed back, a number
1086
+ * that has been reassigned or a webhook pointing at a decommissioned endpoint
1087
+ * are all things an administrator has to be able to clear up, frequently
1088
+ * without the owner around to do it.
1089
+ *
1090
+ * It is also the one operation here that can make somebody LESS reachable, so
1091
+ * it is the one that always mails them — including when it leaves them with
1092
+ * nothing.
1093
+ */
1094
+ @CaptureSpan()
1095
+ public async deleteMethodForUser(data: {
1096
+ projectId: ObjectID;
1097
+ targetUserId: ObjectID;
1098
+ actorUserId: ObjectID | undefined;
1099
+ methodType: string;
1100
+ methodId: ObjectID;
1101
+ props: DatabaseCommonInteractionProps;
1102
+ }): Promise<void> {
1103
+ await this.assertTargetUserIsProjectMember({
1104
+ targetUserId: data.targetUserId,
1105
+ projectId: data.projectId,
1106
+ });
1107
+
1108
+ const descriptor: ChannelDescriptor =
1109
+ await this.resolveOwnedMethodDescriptor(data);
1110
+
1111
+ /*
1112
+ * Read before the delete, because afterwards there is nothing to read. The
1113
+ * mask is the whole description of what the project just lost, and it is
1114
+ * the only thing the owner's mail can name.
1115
+ */
1116
+ const before: AdminNotificationMethodView | null =
1117
+ await descriptor.findOwnedRow({
1118
+ methodId: data.methodId,
1119
+ projectId: data.projectId,
1120
+ userId: data.targetUserId,
1121
+ });
1122
+
1123
+ await descriptor.deleteRow({ methodId: data.methodId });
1124
+
1125
+ await this.recordAdminMethodChange({
1126
+ action: AuditLogAction.Delete,
1127
+ actorUserId: data.actorUserId,
1128
+ ownerUserId: data.targetUserId,
1129
+ projectId: data.projectId,
1130
+ methodType: descriptor.methodType,
1131
+ modelType: descriptor.modelType,
1132
+ methodId: data.methodId,
1133
+ isVerified: Boolean(before?.isVerified),
1134
+ maskedIdentifier: before?.maskedIdentifier || "",
1135
+ props: data.props,
1136
+ });
1137
+ }
1138
+
1139
+ /**
1140
+ * Send the verification code again, to the device.
1141
+ *
1142
+ * This is the administrator's most useful lever after adding a method, and it
1143
+ * discloses nothing: the code goes to the address on the row, which is the
1144
+ * one place the admin cannot read. It is how "I added your work mobile,
1145
+ * please confirm it" becomes something they can nudge rather than re-type.
1146
+ */
1147
+ @CaptureSpan()
1148
+ public async resendVerificationCodeForUser(data: {
1149
+ projectId: ObjectID;
1150
+ targetUserId: ObjectID;
1151
+ methodType: string;
1152
+ methodId: ObjectID;
1153
+ }): Promise<void> {
1154
+ await this.assertTargetUserIsProjectMember({
1155
+ targetUserId: data.targetUserId,
1156
+ projectId: data.projectId,
1157
+ });
1158
+
1159
+ const descriptor: ChannelDescriptor =
1160
+ await this.resolveOwnedMethodDescriptor(data);
1161
+
1162
+ if (!descriptor.resendVerificationCode) {
1163
+ throw new BadDataException(
1164
+ `${descriptor.methodType} notification methods do not use a verification code.`,
1165
+ );
1166
+ }
1167
+
1168
+ await descriptor.resendVerificationCode(data.methodId);
1169
+ }
1170
+
1171
+ /*
1172
+ * "Does this method exist, is it on a channel we know, and does it belong to
1173
+ * THIS user in THIS project?" — asked once, by every write path.
1174
+ *
1175
+ * A row that does not exist and a row belonging to somebody else get the same
1176
+ * refusal. In both cases the caller named a row they have no business naming,
1177
+ * and a distinguishable answer turns this into a probe for which method ids
1178
+ * are real.
1179
+ */
1180
+ private async resolveOwnedMethodDescriptor(data: {
1181
+ projectId: ObjectID;
1182
+ targetUserId: ObjectID;
1183
+ methodType: string;
1184
+ methodId: ObjectID;
1185
+ }): Promise<ChannelDescriptor> {
1186
+ const descriptor: ChannelDescriptor | undefined = this.getChannelDescriptor(
1187
+ data.methodType,
1188
+ );
1189
+
1190
+ if (!descriptor) {
1191
+ throw new BadDataException(
1192
+ "This notification method could not be found for this user.",
1193
+ );
1194
+ }
1195
+
1196
+ const row: AdminNotificationMethodView | null =
1197
+ await descriptor.findOwnedRow({
1198
+ methodId: data.methodId,
1199
+ projectId: data.projectId,
1200
+ userId: data.targetUserId,
1201
+ });
1202
+
1203
+ if (!row) {
1204
+ throw new BadDataException(
1205
+ "This notification method could not be found for this user.",
1206
+ );
1207
+ }
1208
+
1209
+ return descriptor;
1210
+ }
1211
+
1212
+ /*
1213
+ * The trail, and the mail. Nothing in here may break the write: the row is
1214
+ * already committed by the time this runs, so throwing now would report a
1215
+ * failure for a change that happened and the caller would be entitled to
1216
+ * believe it did not.
1217
+ */
1218
+ private async recordAdminMethodChange(data: {
1219
+ action: AuditLogAction;
1220
+ actorUserId: ObjectID | undefined;
1221
+ ownerUserId: ObjectID;
1222
+ projectId: ObjectID;
1223
+ methodType: ReadinessMethodType;
1224
+ modelType: { new (): BaseModel };
1225
+ methodId: ObjectID | null | undefined;
1226
+ isVerified: boolean;
1227
+ maskedIdentifier: string;
1228
+ props: DatabaseCommonInteractionProps;
1229
+ }): Promise<void> {
1230
+ try {
1231
+ /*
1232
+ * The dependable half. AuditLogService writes only on enterprise builds
1233
+ * with audit logs switched on for the project, and "an administrator
1234
+ * added a phone number to somebody's account" is exactly the event an
1235
+ * operator needs to be able to find on the other installs too.
1236
+ */
1237
+ logger.info(
1238
+ `Notification method ${data.action.toLowerCase()}d for user ${data.ownerUserId.toString()} by ${
1239
+ data.actorUserId ? data.actorUserId.toString() : "an unknown actor"
1240
+ }.`,
1241
+ {
1242
+ projectId: data.projectId.toString(),
1243
+ userId: data.ownerUserId.toString(),
1244
+ actorUserId: data.actorUserId?.toString(),
1245
+ methodType: data.methodType,
1246
+ action: data.action,
1247
+ } as LogAttributes,
1248
+ );
1249
+
1250
+ await this.writeAuditLog({
1251
+ action: data.action,
1252
+ modelType: data.modelType,
1253
+ methodId: data.methodId,
1254
+ ownerUserId: data.ownerUserId,
1255
+ projectId: data.projectId,
1256
+ isVerified: data.isVerified,
1257
+ props: data.props,
1258
+ });
1259
+
1260
+ /*
1261
+ * The owner is not mailed about their own action. A person managing their
1262
+ * own methods from this page — an owner looking at their own row — has
1263
+ * just done the thing the mail would be warning them about.
1264
+ */
1265
+ if (
1266
+ data.actorUserId &&
1267
+ data.actorUserId.toString() === data.ownerUserId.toString()
1268
+ ) {
1269
+ return;
1270
+ }
1271
+
1272
+ /*
1273
+ * The readiness cache holds this user's method list for 60 seconds, and
1274
+ * the page the admin is looking at re-reads readiness the moment this
1275
+ * returns. Left alone it would redraw the responder exactly as they were
1276
+ * before the change, which reads as "the add did not work".
1277
+ */
1278
+ OnCallReadinessService.clearCache();
1279
+
1280
+ /*
1281
+ * Fire-and-forget by construction: the owner's mail involves three more
1282
+ * reads and an SMTP round trip, none of which the writing request should
1283
+ * wait on and none of which may surface as a failed write.
1284
+ */
1285
+ this.notifyOwnerOfAdminChange(data).catch((error: Error): void => {
1286
+ logger.error(
1287
+ `UserNotificationMethodAdminService: failed to tell user ${data.ownerUserId.toString()} that an administrator changed their notification methods.`,
1288
+ );
1289
+ logger.error(error);
1290
+ });
1291
+ } catch (error) {
1292
+ logger.error(
1293
+ "UserNotificationMethodAdminService: failed to record an administrative notification method change.",
1294
+ );
1295
+ logger.error(error);
1296
+ }
1297
+ }
1298
+
1299
+ /**
1300
+ * The AuditLog row, written from a BLANK model rather than from the row that
1301
+ * changed. That is the whole subtlety of this method.
1302
+ *
1303
+ * AuditLogService snapshots every column it is handed and redacts only the
1304
+ * ones whose column-level `read` list is EMPTY. On UserEmail the address is
1305
+ * `read: [Permission.CurrentUser]`, not empty — so handing it the real row
1306
+ * would write the raw email address, phone number or webhook name into an
1307
+ * analytics table that every project administrator can query. For a removal
1308
+ * that address is one the acting admin never saw, which would make the audit
1309
+ * trail leak precisely what the page it records was built to hide.
1310
+ *
1311
+ * So the snapshot carries the three facts that make the entry useful and
1312
+ * nothing else: which project, whose account, and whether the method was
1313
+ * live. `buildSnapshotChanges` skips undefined columns, so everything else
1314
+ * simply is not in the row. WHAT was added or removed is in the message
1315
+ * logged above it, masked; WHO did it comes from `props` by way of
1316
+ * resolveActor.
1317
+ *
1318
+ * AuditLogService is required lazily for the same reason DatabaseService
1319
+ * requires it lazily: it depends on ProjectService and UserService, both of
1320
+ * which extend DatabaseService, so a top-level import can leave the base
1321
+ * class undefined at class-extension time.
1322
+ */
1323
+ private async writeAuditLog(data: {
1324
+ action: AuditLogAction;
1325
+ modelType: { new (): BaseModel };
1326
+ methodId: ObjectID | null | undefined;
1327
+ ownerUserId: ObjectID;
1328
+ projectId: ObjectID;
1329
+ isVerified: boolean;
1330
+ props: DatabaseCommonInteractionProps;
1331
+ }): Promise<void> {
1332
+ const auditLogService: typeof AuditLogServiceType =
1333
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires
1334
+ require("./AuditLogService").default;
1335
+
1336
+ if (!auditLogService) {
1337
+ return;
1338
+ }
1339
+
1340
+ const snapshot: BaseModel = new data.modelType();
1341
+
1342
+ /*
1343
+ * Assigned through an index rather than through typed properties because
1344
+ * `snapshot` is a BaseModel here — the three columns are declared on each
1345
+ * of the seven concrete classes, not on the base, and casting to one of
1346
+ * them would be claiming a type this method deliberately does not know.
1347
+ */
1348
+ const columns: Record<string, unknown> = snapshot as unknown as Record<
1349
+ string,
1350
+ unknown
1351
+ >;
1352
+
1353
+ if (data.methodId) {
1354
+ columns["_id"] = data.methodId.toString();
1355
+ }
1356
+
1357
+ columns["projectId"] = data.projectId;
1358
+ columns["userId"] = data.ownerUserId;
1359
+ columns["isVerified"] = data.isVerified;
1360
+
1361
+ if (data.action === AuditLogAction.Create) {
1362
+ await auditLogService.recordCreate({
1363
+ model: new data.modelType(),
1364
+ createdItem: snapshot,
1365
+ props: data.props,
1366
+ });
1367
+
1368
+ return;
1369
+ }
1370
+
1371
+ /*
1372
+ * A deleted method cannot be re-read, so this snapshot is the whole of what
1373
+ * the project has left of it. It is also the reason the id is set above: an
1374
+ * entry with no resourceId cannot be tied back to the rules that went with
1375
+ * it.
1376
+ */
1377
+ if (data.methodId) {
1378
+ await auditLogService.recordDelete({
1379
+ model: new data.modelType(),
1380
+ deletedItem: snapshot,
1381
+ itemId: data.methodId,
1382
+ props: data.props,
1383
+ });
1384
+ }
1385
+ }
1386
+
1387
+ /*
1388
+ * Tell the person whose account just changed.
1389
+ *
1390
+ * This is what makes the capability safe to hand out rather than merely
1391
+ * possible. An admin adding a colleague's work mobile and an attacker adding
1392
+ * a number of their own are the same request from the server's side; the only
1393
+ * party who always knows which it was is the account holder.
1394
+ */
1395
+ private async notifyOwnerOfAdminChange(data: {
1396
+ action: AuditLogAction;
1397
+ actorUserId: ObjectID | undefined;
1398
+ ownerUserId: ObjectID;
1399
+ projectId: ObjectID;
1400
+ methodType: ReadinessMethodType;
1401
+ maskedIdentifier: string;
1402
+ }): Promise<void> {
1403
+ const owner: User | null = await UserService.findOneById({
1404
+ id: data.ownerUserId,
1405
+ select: { _id: true, name: true, email: true },
1406
+ props: { isRoot: true },
1407
+ });
1408
+
1409
+ if (!owner || !owner.email) {
1410
+ return;
1411
+ }
1412
+
1413
+ let actorDescription: string = "A project administrator";
1414
+
1415
+ if (data.actorUserId) {
1416
+ const actor: User | null = await UserService.findOneById({
1417
+ id: data.actorUserId,
1418
+ select: { _id: true, name: true, email: true },
1419
+ props: { isRoot: true },
1420
+ });
1421
+
1422
+ /*
1423
+ * Named by email as well as by name, deliberately. Display names are not
1424
+ * unique and are user-editable, so "Alex added a phone number" is not
1425
+ * something the reader can act on; an address is.
1426
+ */
1427
+ actorDescription = actor
1428
+ ? `${actor.name?.toString() || "A project administrator"} (${
1429
+ actor.email?.toString() || data.actorUserId.toString()
1430
+ })`
1431
+ : `A project administrator (${data.actorUserId.toString()})`;
1432
+ }
1433
+
1434
+ let projectName: string = "your project";
1435
+
1436
+ const project: Project | null = await ProjectService.findOneById({
1437
+ id: data.projectId,
1438
+ select: { _id: true, name: true },
1439
+ props: { isRoot: true },
1440
+ });
1441
+
1442
+ if (project?.name) {
1443
+ projectName = project.name;
1444
+ }
1445
+
1446
+ const isAdd: boolean = data.action === AuditLogAction.Create;
1447
+
1448
+ const subject: string = isAdd
1449
+ ? "A notification method was added to your account"
1450
+ : "A notification method was removed from your account";
1451
+
1452
+ /*
1453
+ * The two consequences are genuinely different and are worth separate
1454
+ * sentences. An addition is inert until the owner verifies it, and saying
1455
+ * so is what turns "somebody touched my account" into an instruction. A
1456
+ * removal has already happened and may have taken rules with it, which is
1457
+ * the only one of the two that can leave a responder silently unreachable.
1458
+ */
1459
+ const consequence: string = isAdd
1460
+ ? "It cannot be used to notify you until you verify it, and only you can do that — a verification code has been sent to the address or device itself. If you were not expecting this, do not verify it, remove it from your notification methods, and tell your project owners."
1461
+ : "Any notification rules that used it were removed with it, so you may no longer be paged the way you were. Check your notification methods and rules now, and tell your project owners if you were not expecting this.";
1462
+
1463
+ const message: string = `${this.escapeHtml(actorDescription)} ${
1464
+ isAdd ? "added" : "removed"
1465
+ } a ${this.escapeHtml(data.methodType)} notification method (${this.escapeHtml(
1466
+ data.maskedIdentifier,
1467
+ )}) ${isAdd ? "to" : "from"} your account in ${this.escapeHtml(
1468
+ projectName,
1469
+ )}.<br/><br/>${consequence}`;
1470
+
1471
+ const settingsLink: string = (
1472
+ await this.getNotificationMethodsLinkInDashboard(data.projectId)
1473
+ ).toString();
1474
+
1475
+ const vars: Dictionary<string> = {
1476
+ subject: subject,
1477
+ message: `${message}<br/><br/><a href="${this.escapeHtml(
1478
+ settingsLink,
1479
+ )}">Review your notification methods</a>`,
1480
+ };
1481
+
1482
+ await MailService.sendMail(
1483
+ {
1484
+ toEmail: owner.email,
1485
+ templateType: EmailTemplateType.SimpleMessage,
1486
+ vars: vars,
1487
+ subject: subject,
1488
+ },
1489
+ {
1490
+ projectId: data.projectId,
1491
+ userId: owner.id!,
1492
+ },
1493
+ );
1494
+ }
1495
+
1496
+ private async getNotificationMethodsLinkInDashboard(
1497
+ projectId: ObjectID,
1498
+ ): Promise<URL> {
1499
+ const dashboardUrl: URL = await DatabaseConfig.getDashboardUrl();
1500
+
1501
+ /*
1502
+ * Spelled out rather than imported: Common/Server cannot reach the
1503
+ * Dashboard's RouteMap, the same reason
1504
+ * UserNotificationRuleAdminService.getNotificationRulesLinkInDashboard
1505
+ * duplicates its path segments.
1506
+ */
1507
+ return URL.fromString(dashboardUrl.toString()).addRoute(
1508
+ `/${projectId.toString()}/user-settings/notification-methods`,
1509
+ );
1510
+ }
1511
+
1512
+ /*
1513
+ * SimpleMessage.hbs renders `message` through a triple-stache InfoBlock
1514
+ * partial, so nothing between here and the recipient's inbox escapes
1515
+ * anything. Project names and user names are attacker-influenced free text.
1516
+ */
1517
+ private escapeHtml(value: string): string {
1518
+ return value
1519
+ .replace(/&/g, "&amp;")
1520
+ .replace(/</g, "&lt;")
1521
+ .replace(/>/g, "&gt;")
1522
+ .replace(/"/g, "&quot;")
1523
+ .replace(/'/g, "&#39;");
1524
+ }
1525
+ }
1526
+
1527
+ export default new UserNotificationMethodAdminService();