@oneuptime/common 11.3.26 → 11.3.28

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 (241) hide show
  1. package/Models/DatabaseModels/IncidentEpisodeRoleMember.ts +1 -1
  2. package/Models/DatabaseModels/IncidentMember.ts +1 -1
  3. package/Models/DatabaseModels/IncomingCallPolicy.ts +13 -0
  4. package/Models/DatabaseModels/Index.ts +3 -0
  5. package/Models/DatabaseModels/MigrationFailure.ts +170 -0
  6. package/Models/DatabaseModels/OnCallDutyPolicyExecutionLog.ts +51 -2
  7. package/Models/DatabaseModels/OnCallDutyPolicySchedule.ts +44 -0
  8. package/Models/DatabaseModels/OnCallDutyPolicyScheduleLayer.ts +6 -0
  9. package/Models/DatabaseModels/OnCallDutyPolicyScheduleLayerUser.ts +6 -0
  10. package/Models/DatabaseModels/OnCallDutyPolicyTimeLog.ts +1 -1
  11. package/Models/DatabaseModels/OnCallDutyPolicyUserOverride.ts +2 -2
  12. package/Server/API/AlertAPI.ts +1 -1
  13. package/Server/API/IncidentAPI.ts +2 -2
  14. package/Server/API/IncidentEpisodeAPI.ts +1 -1
  15. package/Server/API/LlmProviderAPI.ts +1 -0
  16. package/Server/API/ScheduledMaintenanceAPI.ts +1 -1
  17. package/Server/Infrastructure/Postgres/SchemaMigrations/1783470000000-AddIncomingCallPolicyRoutingPhoneNumberUnique.ts +25 -0
  18. package/Server/Infrastructure/Postgres/SchemaMigrations/1783510935686-FixNotNullForeignKeysOnDelete.ts +81 -0
  19. package/Server/Infrastructure/Postgres/SchemaMigrations/1783515836148-MigrationName.ts +25 -0
  20. package/Server/Infrastructure/Postgres/SchemaMigrations/1783523076215-AddMigrationFailureTable.ts +35 -0
  21. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +8 -0
  22. package/Server/Infrastructure/PostgresDatabase.ts +11 -0
  23. package/Server/Services/AIService.ts +74 -2
  24. package/Server/Services/AnalyticsDatabaseService.ts +10 -1
  25. package/Server/Services/DatabaseService.ts +10 -1
  26. package/Server/Services/IncidentService.ts +1 -1
  27. package/Server/Services/IncomingCallPolicyEscalationRuleService.ts +75 -2
  28. package/Server/Services/IncomingCallPolicyService.ts +42 -1
  29. package/Server/Services/Index.ts +2 -0
  30. package/Server/Services/MigrationFailureService.ts +9 -0
  31. package/Server/Services/MonitorProbeService.ts +33 -7
  32. package/Server/Services/MonitorService.ts +19 -7
  33. package/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.ts +65 -0
  34. package/Server/Services/OnCallDutyPolicyEscalationRuleService.ts +185 -41
  35. package/Server/Services/OnCallDutyPolicyEscalationRuleUserService.ts +23 -15
  36. package/Server/Services/OnCallDutyPolicyExecutionLogService.ts +39 -0
  37. package/Server/Services/OnCallDutyPolicyScheduleLayerService.ts +148 -0
  38. package/Server/Services/OnCallDutyPolicyScheduleLayerUserService.ts +11 -2
  39. package/Server/Services/OnCallDutyPolicyScheduleService.ts +502 -26
  40. package/Server/Services/OnCallDutyPolicyTimeLogService.ts +49 -7
  41. package/Server/Services/OnCallDutyPolicyUserOverrideService.ts +218 -4
  42. package/Server/Services/ProjectCallSMSConfigService.ts +82 -1
  43. package/Server/Services/TeamMemberService.ts +6 -0
  44. package/Server/Services/UserNotificationRuleService.ts +13 -42
  45. package/Server/Services/UserOnCallLogService.ts +90 -0
  46. package/Server/Services/UserOnCallLogTimelineService.ts +43 -1
  47. package/Server/Types/Database/QueryUtil.ts +57 -0
  48. package/Server/Utils/AI/AlertAIContextBuilder.ts +6 -1
  49. package/Server/Utils/AI/Chat/ChatAgentRunner.ts +14 -2
  50. package/Server/Utils/AI/IncidentAIContextBuilder.ts +18 -3
  51. package/Server/Utils/AI/IncidentEpisodeAIContextBuilder.ts +6 -1
  52. package/Server/Utils/AI/ScheduledMaintenanceAIContextBuilder.ts +12 -2
  53. package/Server/Utils/AI/Toolbox/IncidentTools.ts +183 -1
  54. package/Server/Utils/AI/Toolbox/Index.ts +4 -1
  55. package/Server/Utils/AI/Toolbox/RecentChangesTools.ts +208 -0
  56. package/Server/Utils/AI/Toolbox/Serializer.ts +42 -0
  57. package/Server/Utils/Database/MigrationFailureLog.ts +240 -0
  58. package/Server/Utils/IncomingCallPhoneNumber.ts +41 -0
  59. package/Server/Utils/LLM/LLMService.ts +110 -11
  60. package/Server/Utils/Telemetry/IoTSnapshotScan.ts +12 -9
  61. package/Server/Utils/Telemetry/ProxmoxCephSnapshotScan.ts +12 -9
  62. package/Tests/Server/Services/AnalyticsDatabaseService.test.ts +0 -8
  63. package/Tests/Server/Services/OnCallDutyPolicyScheduleLayerUserReorder.test.ts +233 -0
  64. package/Tests/Server/Services/OnCallDutyPolicyTimeLogServiceScoping.test.ts +242 -0
  65. package/Tests/Server/Services/OnCallDutyPolicyUserOverrideEdit.test.ts +198 -0
  66. package/Tests/Server/Services/UserOnCallLogClaimNotificationRule.test.ts +495 -0
  67. package/Tests/Server/Types/Database/QueryUtil.test.ts +74 -0
  68. package/Tests/Server/Utils/AI/LLMServiceToolCalling.test.ts +78 -0
  69. package/Tests/Server/Utils/AI/ToolResultSerializer.test.ts +68 -0
  70. package/Tests/Types/DateExhaustiveTimezone.test.ts +700 -0
  71. package/Tests/Types/DateTimezoneWallClock.test.ts +99 -0
  72. package/Tests/Types/Events/Recurring.test.ts +22 -9
  73. package/Tests/Types/OnCallDutyPolicy/LayerUtilAuditFixes.test.ts +291 -0
  74. package/Tests/Types/OnCallDutyPolicy/LayerUtilAuditFixesRound2.test.ts +398 -0
  75. package/Tests/Types/OnCallDutyPolicy/LayerUtilDSTWeekendGap.test.ts +114 -0
  76. package/Tests/Types/OnCallDutyPolicy/LayerUtilDSTWeeklyDayShift.test.ts +76 -0
  77. package/Tests/Types/OnCallDutyPolicy/LayerUtilDailyMutation.test.ts +128 -0
  78. package/Tests/Types/OnCallDutyPolicy/LayerUtilDailyProbe.test.ts +203 -0
  79. package/Tests/Types/OnCallDutyPolicy/LayerUtilDiffFuzz.test.ts +322 -0
  80. package/Tests/Types/OnCallDutyPolicy/LayerUtilEdgeCases.test.ts +280 -0
  81. package/Tests/Types/OnCallDutyPolicy/LayerUtilExhaustiveCurrentUser.test.ts +863 -0
  82. package/Tests/Types/OnCallDutyPolicy/LayerUtilExhaustiveDaily.test.ts +900 -0
  83. package/Tests/Types/OnCallDutyPolicy/LayerUtilExhaustiveMultiLayer.test.ts +1104 -0
  84. package/Tests/Types/OnCallDutyPolicy/LayerUtilExhaustiveRotation.test.ts +957 -0
  85. package/Tests/Types/OnCallDutyPolicy/LayerUtilExhaustiveWeekly.test.ts +1095 -0
  86. package/Tests/Types/OnCallDutyPolicy/LayerUtilInvariants.test.ts +232 -0
  87. package/Tests/Types/OnCallDutyPolicy/LayerUtilMergeAudit.test.ts +227 -0
  88. package/Tests/Types/OnCallDutyPolicy/LayerUtilMonthYearAudit.test.ts +255 -0
  89. package/Tests/Types/OnCallDutyPolicy/LayerUtilMultiLayerAudit.test.ts +388 -0
  90. package/Tests/Types/OnCallDutyPolicy/LayerUtilMultiLayerEdge.test.ts +300 -0
  91. package/Tests/Types/OnCallDutyPolicy/LayerUtilOvernightDSTAudit.test.ts +158 -0
  92. package/Tests/Types/OnCallDutyPolicy/LayerUtilRestrictedGapFix.test.ts +253 -0
  93. package/Tests/Types/OnCallDutyPolicy/LayerUtilRestrictedGapRepro.test.ts +157 -0
  94. package/Tests/Types/OnCallDutyPolicy/LayerUtilRotationFixes.test.ts +414 -0
  95. package/Tests/Types/OnCallDutyPolicy/LayerUtilTimezone.test.ts +243 -0
  96. package/Tests/Types/OnCallDutyPolicy/LayerUtilWeekendGapRepro.test.ts +112 -0
  97. package/Tests/Types/OnCallDutyPolicy/OverrideLensAudit.test.ts +117 -0
  98. package/Tests/Types/OnCallDutyPolicy/RestrictionTimes.test.ts +8 -6
  99. package/Tests/Types/OnCallDutyPolicy/RestrictionTimesDefaultDayAudit.test.ts +112 -0
  100. package/Tests/Types/OnCallDutyPolicy/RestrictionTimesExhaustive.test.ts +821 -0
  101. package/Tests/Types/OnCallDutyPolicy/RestrictionTimesMutationInvariant.test.ts +886 -0
  102. package/Tests/Types/OnCallDutyPolicy/UserOverrideUtil.test.ts +255 -0
  103. package/Tests/Types/OnCallDutyPolicy/UserOverrideUtilExhaustive.test.ts +1126 -0
  104. package/Types/Date.ts +200 -15
  105. package/Types/OnCallDutyPolicy/Layer.ts +790 -260
  106. package/Types/OnCallDutyPolicy/RestrictionTimes.ts +39 -13
  107. package/Types/OnCallDutyPolicy/UserOverrideUtil.ts +21 -5
  108. package/UI/Components/Events/RecurringFieldElement.tsx +16 -0
  109. package/UI/Components/ModelFormModal/ModelFormModal.tsx +8 -0
  110. package/UI/Components/RadioButtons/BasicRadioButtons.tsx +1 -1
  111. package/build/dist/Models/DatabaseModels/IncidentEpisodeRoleMember.js +1 -1
  112. package/build/dist/Models/DatabaseModels/IncidentEpisodeRoleMember.js.map +1 -1
  113. package/build/dist/Models/DatabaseModels/IncidentMember.js +1 -1
  114. package/build/dist/Models/DatabaseModels/IncidentMember.js.map +1 -1
  115. package/build/dist/Models/DatabaseModels/IncomingCallPolicy.js +10 -0
  116. package/build/dist/Models/DatabaseModels/IncomingCallPolicy.js.map +1 -1
  117. package/build/dist/Models/DatabaseModels/Index.js +2 -0
  118. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  119. package/build/dist/Models/DatabaseModels/MigrationFailure.js +196 -0
  120. package/build/dist/Models/DatabaseModels/MigrationFailure.js.map +1 -0
  121. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyExecutionLog.js +52 -2
  122. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyExecutionLog.js.map +1 -1
  123. package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js +45 -0
  124. package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js.map +1 -1
  125. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleLayer.js +6 -0
  126. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleLayer.js.map +1 -1
  127. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleLayerUser.js +6 -0
  128. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleLayerUser.js.map +1 -1
  129. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyTimeLog.js +1 -1
  130. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyTimeLog.js.map +1 -1
  131. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyUserOverride.js +2 -2
  132. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyUserOverride.js.map +1 -1
  133. package/build/dist/Server/API/AlertAPI.js +1 -1
  134. package/build/dist/Server/API/IncidentAPI.js +2 -2
  135. package/build/dist/Server/API/IncidentEpisodeAPI.js +1 -1
  136. package/build/dist/Server/API/LlmProviderAPI.js +1 -0
  137. package/build/dist/Server/API/LlmProviderAPI.js.map +1 -1
  138. package/build/dist/Server/API/ScheduledMaintenanceAPI.js +1 -1
  139. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783470000000-AddIncomingCallPolicyRoutingPhoneNumberUnique.js +18 -0
  140. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783470000000-AddIncomingCallPolicyRoutingPhoneNumberUnique.js.map +1 -0
  141. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783510935686-FixNotNullForeignKeysOnDelete.js +38 -0
  142. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783510935686-FixNotNullForeignKeysOnDelete.js.map +1 -0
  143. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783515836148-MigrationName.js +16 -0
  144. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783515836148-MigrationName.js.map +1 -0
  145. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783523076215-AddMigrationFailureTable.js +18 -0
  146. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1783523076215-AddMigrationFailureTable.js.map +1 -0
  147. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +8 -0
  148. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  149. package/build/dist/Server/Infrastructure/PostgresDatabase.js +9 -0
  150. package/build/dist/Server/Infrastructure/PostgresDatabase.js.map +1 -1
  151. package/build/dist/Server/Services/AIService.js +53 -2
  152. package/build/dist/Server/Services/AIService.js.map +1 -1
  153. package/build/dist/Server/Services/AnalyticsDatabaseService.js +10 -1
  154. package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
  155. package/build/dist/Server/Services/DatabaseService.js +10 -1
  156. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  157. package/build/dist/Server/Services/IncidentService.js +1 -1
  158. package/build/dist/Server/Services/IncomingCallPolicyEscalationRuleService.js +55 -2
  159. package/build/dist/Server/Services/IncomingCallPolicyEscalationRuleService.js.map +1 -1
  160. package/build/dist/Server/Services/IncomingCallPolicyService.js +40 -0
  161. package/build/dist/Server/Services/IncomingCallPolicyService.js.map +1 -1
  162. package/build/dist/Server/Services/Index.js +2 -0
  163. package/build/dist/Server/Services/Index.js.map +1 -1
  164. package/build/dist/Server/Services/MigrationFailureService.js +9 -0
  165. package/build/dist/Server/Services/MigrationFailureService.js.map +1 -0
  166. package/build/dist/Server/Services/MonitorProbeService.js +33 -7
  167. package/build/dist/Server/Services/MonitorProbeService.js.map +1 -1
  168. package/build/dist/Server/Services/MonitorService.js +12 -5
  169. package/build/dist/Server/Services/MonitorService.js.map +1 -1
  170. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js +81 -22
  171. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js.map +1 -1
  172. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleService.js +134 -31
  173. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleService.js.map +1 -1
  174. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleUserService.js +21 -13
  175. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleUserService.js.map +1 -1
  176. package/build/dist/Server/Services/OnCallDutyPolicyExecutionLogService.js +36 -0
  177. package/build/dist/Server/Services/OnCallDutyPolicyExecutionLogService.js.map +1 -1
  178. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerService.js +121 -0
  179. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerService.js.map +1 -1
  180. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerUserService.js +11 -2
  181. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerUserService.js.map +1 -1
  182. package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js +409 -36
  183. package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js.map +1 -1
  184. package/build/dist/Server/Services/OnCallDutyPolicyTimeLogService.js +52 -8
  185. package/build/dist/Server/Services/OnCallDutyPolicyTimeLogService.js.map +1 -1
  186. package/build/dist/Server/Services/OnCallDutyPolicyUserOverrideService.js +170 -3
  187. package/build/dist/Server/Services/OnCallDutyPolicyUserOverrideService.js.map +1 -1
  188. package/build/dist/Server/Services/ProjectCallSMSConfigService.js +72 -0
  189. package/build/dist/Server/Services/ProjectCallSMSConfigService.js.map +1 -1
  190. package/build/dist/Server/Services/TeamMemberService.js +6 -0
  191. package/build/dist/Server/Services/TeamMemberService.js.map +1 -1
  192. package/build/dist/Server/Services/UserNotificationRuleService.js +12 -30
  193. package/build/dist/Server/Services/UserNotificationRuleService.js.map +1 -1
  194. package/build/dist/Server/Services/UserOnCallLogService.js +75 -0
  195. package/build/dist/Server/Services/UserOnCallLogService.js.map +1 -1
  196. package/build/dist/Server/Services/UserOnCallLogTimelineService.js +31 -1
  197. package/build/dist/Server/Services/UserOnCallLogTimelineService.js.map +1 -1
  198. package/build/dist/Server/Types/Database/QueryUtil.js +45 -0
  199. package/build/dist/Server/Types/Database/QueryUtil.js.map +1 -1
  200. package/build/dist/Server/Utils/AI/AlertAIContextBuilder.js +6 -1
  201. package/build/dist/Server/Utils/AI/AlertAIContextBuilder.js.map +1 -1
  202. package/build/dist/Server/Utils/AI/Chat/ChatAgentRunner.js +13 -2
  203. package/build/dist/Server/Utils/AI/Chat/ChatAgentRunner.js.map +1 -1
  204. package/build/dist/Server/Utils/AI/IncidentAIContextBuilder.js +18 -3
  205. package/build/dist/Server/Utils/AI/IncidentAIContextBuilder.js.map +1 -1
  206. package/build/dist/Server/Utils/AI/IncidentEpisodeAIContextBuilder.js +6 -1
  207. package/build/dist/Server/Utils/AI/IncidentEpisodeAIContextBuilder.js.map +1 -1
  208. package/build/dist/Server/Utils/AI/ScheduledMaintenanceAIContextBuilder.js +12 -2
  209. package/build/dist/Server/Utils/AI/ScheduledMaintenanceAIContextBuilder.js.map +1 -1
  210. package/build/dist/Server/Utils/AI/Toolbox/IncidentTools.js +152 -1
  211. package/build/dist/Server/Utils/AI/Toolbox/IncidentTools.js.map +1 -1
  212. package/build/dist/Server/Utils/AI/Toolbox/Index.js +4 -1
  213. package/build/dist/Server/Utils/AI/Toolbox/Index.js.map +1 -1
  214. package/build/dist/Server/Utils/AI/Toolbox/RecentChangesTools.js +165 -0
  215. package/build/dist/Server/Utils/AI/Toolbox/RecentChangesTools.js.map +1 -0
  216. package/build/dist/Server/Utils/AI/Toolbox/Serializer.js +40 -0
  217. package/build/dist/Server/Utils/AI/Toolbox/Serializer.js.map +1 -1
  218. package/build/dist/Server/Utils/Database/MigrationFailureLog.js +174 -0
  219. package/build/dist/Server/Utils/Database/MigrationFailureLog.js.map +1 -0
  220. package/build/dist/Server/Utils/IncomingCallPhoneNumber.js +30 -0
  221. package/build/dist/Server/Utils/IncomingCallPhoneNumber.js.map +1 -0
  222. package/build/dist/Server/Utils/LLM/LLMService.js +81 -5
  223. package/build/dist/Server/Utils/LLM/LLMService.js.map +1 -1
  224. package/build/dist/Server/Utils/Telemetry/IoTSnapshotScan.js +11 -8
  225. package/build/dist/Server/Utils/Telemetry/IoTSnapshotScan.js.map +1 -1
  226. package/build/dist/Server/Utils/Telemetry/ProxmoxCephSnapshotScan.js +11 -8
  227. package/build/dist/Server/Utils/Telemetry/ProxmoxCephSnapshotScan.js.map +1 -1
  228. package/build/dist/Types/Date.js +153 -15
  229. package/build/dist/Types/Date.js.map +1 -1
  230. package/build/dist/Types/OnCallDutyPolicy/Layer.js +602 -171
  231. package/build/dist/Types/OnCallDutyPolicy/Layer.js.map +1 -1
  232. package/build/dist/Types/OnCallDutyPolicy/RestrictionTimes.js +27 -13
  233. package/build/dist/Types/OnCallDutyPolicy/RestrictionTimes.js.map +1 -1
  234. package/build/dist/Types/OnCallDutyPolicy/UserOverrideUtil.js +20 -5
  235. package/build/dist/Types/OnCallDutyPolicy/UserOverrideUtil.js.map +1 -1
  236. package/build/dist/UI/Components/Events/RecurringFieldElement.js +13 -0
  237. package/build/dist/UI/Components/Events/RecurringFieldElement.js.map +1 -1
  238. package/build/dist/UI/Components/ModelFormModal/ModelFormModal.js.map +1 -1
  239. package/build/dist/UI/Components/RadioButtons/BasicRadioButtons.js +1 -1
  240. package/build/dist/UI/Components/RadioButtons/BasicRadioButtons.js.map +1 -1
  241. package/package.json +1 -1
@@ -2,6 +2,7 @@ import { LIMIT_PER_PROJECT } from "../../Types/Database/LimitMax";
2
2
  import ObjectID from "../../Types/ObjectID";
3
3
  import DatabaseService from "./DatabaseService";
4
4
  import Model from "../../Models/DatabaseModels/OnCallDutyPolicyTimeLog";
5
+ import QueryHelper from "../Types/Database/QueryHelper";
5
6
 
6
7
  export class Service extends DatabaseService<Model> {
7
8
  public constructor() {
@@ -25,16 +26,33 @@ export class Service extends DatabaseService<Model> {
25
26
  startsAt,
26
27
  } = data;
27
28
 
28
- // check if the time log already exists for the user.
29
-
29
+ /*
30
+ * Check if an OPEN (not-yet-ended) time log already exists for the user.
31
+ * The endsAt IS NULL filter is essential: without it, a user who rotated
32
+ * off and later rotates back on would match their already-CLOSED prior log
33
+ * and skip creating a new one, so their later on-call stints would never be
34
+ * recorded (and a "who is on call now" query filtering endsAt IS NULL would
35
+ * miss them). Each distinct on-call stint must be its own row.
36
+ */
30
37
  const existingTimeLog: Model | null = await this.findOneBy({
31
38
  query: {
32
39
  projectId: data.projectId,
33
40
  onCallDutyPolicyId,
34
41
  onCallDutyPolicyEscalationRuleId: data.onCallDutyPolicyEscalationRuleId,
35
42
  userId,
36
- ...(teamId && { teamId }),
37
- ...(onCallDutyPolicyScheduleId && { onCallDutyPolicyScheduleId }),
43
+ /*
44
+ * Scope the context columns explicitly so a direct-user log dedups only
45
+ * against other direct logs, a team log only against team logs, and a
46
+ * schedule log only against schedule logs. The old `...(x && {x})`
47
+ * OMITTED the column when absent, so an absent column matched ANY value:
48
+ * a direct-user start then deduped against an already-open schedule/team
49
+ * log and never created the direct log, leaving the user with zero open
50
+ * logs once that schedule/team log closed (audit L4).
51
+ */
52
+ teamId: teamId ?? QueryHelper.isNull(),
53
+ onCallDutyPolicyScheduleId:
54
+ onCallDutyPolicyScheduleId ?? QueryHelper.isNull(),
55
+ endsAt: QueryHelper.isNull(),
38
56
  },
39
57
  props: {
40
58
  isRoot: true,
@@ -74,9 +92,11 @@ export class Service extends DatabaseService<Model> {
74
92
  }): Promise<void> {
75
93
  const { endsAt, onCallDutyPolicyScheduleId } = data;
76
94
  await this.updateBy({
95
+ // only close still-open logs; never overwrite historical closed intervals.
77
96
  query: {
78
97
  projectId: data.projectId,
79
98
  onCallDutyPolicyScheduleId,
99
+ endsAt: QueryHelper.isNull(),
80
100
  },
81
101
  data: {
82
102
  endsAt: endsAt,
@@ -96,9 +116,11 @@ export class Service extends DatabaseService<Model> {
96
116
  }): Promise<void> {
97
117
  const { endsAt, teamId } = data;
98
118
  await this.updateBy({
119
+ // only close still-open logs; never overwrite historical closed intervals.
99
120
  query: {
100
121
  projectId: data.projectId,
101
122
  teamId,
123
+ endsAt: QueryHelper.isNull(),
102
124
  },
103
125
 
104
126
  limit: LIMIT_PER_PROJECT,
@@ -115,13 +137,22 @@ export class Service extends DatabaseService<Model> {
115
137
  public async endTimeForUser(data: {
116
138
  projectId: ObjectID;
117
139
  userId: ObjectID;
140
+ /*
141
+ * When set, only close logs derived from this team. Leaving one team must
142
+ * not close a user's still-active logs from other teams, direct escalation
143
+ * assignments, or schedule rosters (audit F17).
144
+ */
145
+ teamId?: ObjectID | undefined;
118
146
  endsAt: Date;
119
147
  }): Promise<void> {
120
- const { endsAt, userId } = data;
148
+ const { endsAt, userId, teamId } = data;
121
149
  await this.updateBy({
150
+ // only close still-open logs; never overwrite historical closed intervals.
122
151
  query: {
123
152
  projectId: data.projectId,
124
153
  userId,
154
+ ...(teamId && { teamId }),
155
+ endsAt: QueryHelper.isNull(),
125
156
  },
126
157
  limit: LIMIT_PER_PROJECT,
127
158
  skip: 0,
@@ -152,13 +183,24 @@ export class Service extends DatabaseService<Model> {
152
183
  } = data;
153
184
 
154
185
  await this.updateBy({
186
+ // only close the still-open stint; leave prior closed stints intact.
155
187
  query: {
156
188
  projectId: data.projectId,
157
189
  onCallDutyPolicyId,
158
190
  onCallDutyPolicyEscalationRuleId: data.onCallDutyPolicyEscalationRuleId,
159
191
  userId,
160
- ...(teamId && { teamId }),
161
- ...(onCallDutyPolicyScheduleId && { onCallDutyPolicyScheduleId }),
192
+ /*
193
+ * Constrain the context columns to IS NULL when not supplied so each
194
+ * context closes ONLY its own stint. The old `...(x && {x})` omitted the
195
+ * column, so ending a direct-user assignment (no teamId/scheduleId)
196
+ * matched — and closed — that user's still-open SCHEDULE and TEAM logs
197
+ * for the same rule too, leaving a permanent hole in the "who is on call
198
+ * now" view until the next roster change (audit M3).
199
+ */
200
+ teamId: teamId ?? QueryHelper.isNull(),
201
+ onCallDutyPolicyScheduleId:
202
+ onCallDutyPolicyScheduleId ?? QueryHelper.isNull(),
203
+ endsAt: QueryHelper.isNull(),
162
204
  },
163
205
  limit: LIMIT_PER_PROJECT,
164
206
  skip: 0,
@@ -4,7 +4,8 @@ import DatabaseConfig from "../DatabaseConfig";
4
4
  import URL from "../../Types/API/URL";
5
5
  import OnCallDutyPolicyUserOverride from "../../Models/DatabaseModels/OnCallDutyPolicyUserOverride";
6
6
  import CreateBy from "../Types/Database/CreateBy";
7
- import { OnCreate, OnDelete } from "../Types/Database/Hooks";
7
+ import UpdateBy from "../Types/Database/UpdateBy";
8
+ import { OnCreate, OnDelete, OnUpdate } from "../Types/Database/Hooks";
8
9
  import OneUptimeDate from "../../Types/Date";
9
10
  import BadDataException from "../../Types/Exception/BadDataException";
10
11
  import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
@@ -16,12 +17,50 @@ import OnCallDutyPolicyService from "./OnCallDutyPolicyService";
16
17
  import Timezone from "../../Types/Timezone";
17
18
  import DeleteBy from "../Types/Database/DeleteBy";
18
19
  import { LIMIT_PER_PROJECT } from "../../Types/Database/LimitMax";
20
+ import logger from "../Utils/Logger";
19
21
 
20
22
  export class Service extends DatabaseService<OnCallDutyPolicyUserOverride> {
21
23
  public constructor() {
22
24
  super(OnCallDutyPolicyUserOverride);
23
25
  }
24
26
 
27
+ /**
28
+ * Re-resolve the persisted roster of every schedule in the project that
29
+ * contains the override user, so a created / updated / deleted override is
30
+ * reflected in the dashboard roster, handoff notifications and on-call time
31
+ * logs mid-period instead of only at the next natural handoff (audit F4).
32
+ * Best-effort: never throws into the CRUD path. Lazy require avoids a static
33
+ * circular import (OnCallDutyPolicyScheduleService imports this service).
34
+ */
35
+ private async refreshRostersForOverrideUser(data: {
36
+ projectId: ObjectID | null | undefined;
37
+ overrideUserId: ObjectID | null | undefined;
38
+ }): Promise<void> {
39
+ if (!data.projectId || !data.overrideUserId) {
40
+ return;
41
+ }
42
+
43
+ try {
44
+ const scheduleService: {
45
+ refreshRostersForUserInProject: (d: {
46
+ projectId: ObjectID;
47
+ userId: ObjectID;
48
+ }) => Promise<void>;
49
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires
50
+ } = require("./OnCallDutyPolicyScheduleService").default;
51
+
52
+ await scheduleService.refreshRostersForUserInProject({
53
+ projectId: data.projectId,
54
+ userId: data.overrideUserId,
55
+ });
56
+ } catch (err) {
57
+ logger.error(
58
+ "Error refreshing rosters after a user override change (best-effort).",
59
+ );
60
+ logger.error(err);
61
+ }
62
+ }
63
+
25
64
  @CaptureSpan()
26
65
  protected override async onBeforeCreate(
27
66
  createBy: CreateBy<OnCallDutyPolicyUserOverride>,
@@ -30,8 +69,14 @@ export class Service extends DatabaseService<OnCallDutyPolicyUserOverride> {
30
69
  throw new BadDataException("Start time and end time are required");
31
70
  }
32
71
 
33
- // make sure start time is before end time
34
- if (OneUptimeDate.isAfter(createBy.data.startsAt, createBy.data.endsAt)) {
72
+ /*
73
+ * make sure start time is STRICTLY before end time. Using !isBefore (rather
74
+ * than isAfter) also rejects equal start/end and same-second reversed
75
+ * sub-second values, which the downstream event splitter would otherwise
76
+ * turn into a zero-length / inverted substitution segment and a spurious
77
+ * "you are next on-call" notification.
78
+ */
79
+ if (!OneUptimeDate.isBefore(createBy.data.startsAt, createBy.data.endsAt)) {
35
80
  throw new BadDataException("Start time must be before end time");
36
81
  }
37
82
 
@@ -139,9 +184,164 @@ export class Service extends DatabaseService<OnCallDutyPolicyUserOverride> {
139
184
  });
140
185
  }
141
186
 
187
+ // Reflect the new override in the persisted roster immediately (audit F4).
188
+ await this.refreshRostersForOverrideUser({
189
+ projectId,
190
+ overrideUserId,
191
+ });
192
+
142
193
  return createdItem;
143
194
  }
144
195
 
196
+ @CaptureSpan()
197
+ protected override async onBeforeUpdate(
198
+ updateBy: UpdateBy<OnCallDutyPolicyUserOverride>,
199
+ ): Promise<OnUpdate<OnCallDutyPolicyUserOverride>> {
200
+ /*
201
+ * Capture each affected override's PRE-update project + overrideUserId so
202
+ * onUpdateSuccess can also refresh the OLD user's schedules. onUpdateSuccess
203
+ * otherwise only sees the post-update overrideUserId; when an edit changes
204
+ * overrideUserId from A to A2, schedules that contained A (but not A2) would
205
+ * keep showing the substitute forever — until the next natural handoff the
206
+ * cron happens to re-select — because refreshRostersForUserInProject selects
207
+ * schedules by the NEW user only (audit M2). Mirrors the onBeforeDelete
208
+ * carry-forward pattern used by the delete path.
209
+ */
210
+ const previous: Array<{
211
+ projectId: ObjectID;
212
+ overrideUserId: ObjectID;
213
+ }> = [];
214
+
215
+ try {
216
+ const rows: Array<OnCallDutyPolicyUserOverride> = await this.findBy({
217
+ query: updateBy.query,
218
+ select: {
219
+ projectId: true,
220
+ overrideUserId: true,
221
+ },
222
+ props: {
223
+ isRoot: true,
224
+ },
225
+ skip: 0,
226
+ limit: LIMIT_PER_PROJECT,
227
+ });
228
+
229
+ for (const row of rows) {
230
+ const projectId: ObjectID | undefined | null =
231
+ row.projectId || row.project?.id;
232
+ const overrideUserId: ObjectID | undefined | null =
233
+ row.overrideUserId || row.overrideUser?.id;
234
+ if (projectId && overrideUserId) {
235
+ previous.push({ projectId, overrideUserId });
236
+ }
237
+ }
238
+ } catch (err) {
239
+ logger.error(err);
240
+ }
241
+
242
+ return {
243
+ updateBy,
244
+ carryForward: previous,
245
+ };
246
+ }
247
+
248
+ @CaptureSpan()
249
+ protected override async onUpdateSuccess(
250
+ onUpdate: OnUpdate<OnCallDutyPolicyUserOverride>,
251
+ updatedItemIds: Array<ObjectID>,
252
+ ): Promise<OnUpdate<OnCallDutyPolicyUserOverride>> {
253
+ /*
254
+ * An override edit (times, route target, or the override user) must be
255
+ * reflected in the persisted roster (audit F4). Refresh the schedules of
256
+ * BOTH the NEW override user (post-update) and the OLD override user captured
257
+ * in onBeforeUpdate, so a change of overrideUserId also restores the roster
258
+ * of schedules that only contained the previous user (audit M2). Deduped so
259
+ * an unchanged overrideUserId refreshes each schedule set only once.
260
+ */
261
+ const refreshed: Set<string> = new Set<string>();
262
+
263
+ const refreshPair: (
264
+ projectId: ObjectID | null | undefined,
265
+ overrideUserId: ObjectID | null | undefined,
266
+ ) => Promise<void> = async (
267
+ projectId: ObjectID | null | undefined,
268
+ overrideUserId: ObjectID | null | undefined,
269
+ ): Promise<void> => {
270
+ if (!projectId || !overrideUserId) {
271
+ return;
272
+ }
273
+ const key: string = `${projectId.toString()}:${overrideUserId.toString()}`;
274
+ if (refreshed.has(key)) {
275
+ return;
276
+ }
277
+ refreshed.add(key);
278
+ await this.refreshRostersForOverrideUser({ projectId, overrideUserId });
279
+ };
280
+
281
+ // OLD override users captured before the update.
282
+ const previous: Array<{ projectId: ObjectID; overrideUserId: ObjectID }> =
283
+ (onUpdate.carryForward as Array<{
284
+ projectId: ObjectID;
285
+ overrideUserId: ObjectID;
286
+ }>) || [];
287
+
288
+ for (const entry of previous) {
289
+ await refreshPair(entry.projectId, entry.overrideUserId);
290
+ }
291
+
292
+ // NEW override users (post-update).
293
+ for (const id of updatedItemIds) {
294
+ const item: OnCallDutyPolicyUserOverride | null = await this.findOneById({
295
+ id: id,
296
+ select: {
297
+ projectId: true,
298
+ overrideUserId: true,
299
+ },
300
+ props: {
301
+ isRoot: true,
302
+ },
303
+ });
304
+
305
+ if (!item) {
306
+ continue;
307
+ }
308
+
309
+ await refreshPair(
310
+ item.projectId || item.project?.id,
311
+ item.overrideUserId || item.overrideUser?.id,
312
+ );
313
+ }
314
+
315
+ return onUpdate;
316
+ }
317
+
318
+ @CaptureSpan()
319
+ protected override async onDeleteSuccess(
320
+ onDelete: OnDelete<OnCallDutyPolicyUserOverride>,
321
+ _itemIdsBeforeDelete: ObjectID[],
322
+ ): Promise<OnDelete<OnCallDutyPolicyUserOverride>> {
323
+ /*
324
+ * After an override is removed, recompute the roster so the original user
325
+ * (or whoever the schedule now resolves to) is reflected immediately, rather
326
+ * than waiting for the next natural handoff (audit F4). The affected
327
+ * project/user pairs were captured in onBeforeDelete (the rows are gone now).
328
+ */
329
+ const affected: Array<{ projectId: ObjectID; overrideUserId: ObjectID }> =
330
+ (onDelete.carryForward as Array<{
331
+ projectId: ObjectID;
332
+ overrideUserId: ObjectID;
333
+ }>) || [];
334
+
335
+ for (const entry of affected) {
336
+ await this.refreshRostersForOverrideUser({
337
+ projectId: entry.projectId,
338
+ overrideUserId: entry.overrideUserId,
339
+ });
340
+ }
341
+
342
+ return onDelete;
343
+ }
344
+
145
345
  protected override async onBeforeDelete(
146
346
  deleteBy: DeleteBy<OnCallDutyPolicyUserOverride>,
147
347
  ): Promise<OnDelete<OnCallDutyPolicyUserOverride>> {
@@ -163,6 +363,16 @@ export class Service extends DatabaseService<OnCallDutyPolicyUserOverride> {
163
363
  limit: LIMIT_PER_PROJECT,
164
364
  });
165
365
 
366
+ /*
367
+ * Capture the project + override-user of each row being deleted so
368
+ * onDeleteSuccess can refresh those schedules' rosters AFTER the rows are
369
+ * gone (audit F4). Collected for global overrides too (no policy id).
370
+ */
371
+ const affectedRosters: Array<{
372
+ projectId: ObjectID;
373
+ overrideUserId: ObjectID;
374
+ }> = [];
375
+
166
376
  for (const item of itemsToDelete) {
167
377
  const onCallDutyPolicyId: ObjectID | undefined | null =
168
378
  item.onCallDutyPolicyId || item.onCallDutyPolicy?.id;
@@ -176,6 +386,10 @@ export class Service extends DatabaseService<OnCallDutyPolicyUserOverride> {
176
386
  const routeAlertsToUserId: ObjectID | undefined | null =
177
387
  item.routeAlertsToUserId || item.routeAlertsToUser?.id;
178
388
 
389
+ if (projectId && overrideUserId) {
390
+ affectedRosters.push({ projectId, overrideUserId });
391
+ }
392
+
179
393
  if (
180
394
  onCallDutyPolicyId &&
181
395
  projectId &&
@@ -233,7 +447,7 @@ export class Service extends DatabaseService<OnCallDutyPolicyUserOverride> {
233
447
 
234
448
  return {
235
449
  deleteBy,
236
- carryForward: null,
450
+ carryForward: affectedRosters,
237
451
  };
238
452
  }
239
453
 
@@ -5,10 +5,14 @@ import Model from "../../Models/DatabaseModels/ProjectCallSMSConfig";
5
5
  import Phone from "../../Types/Phone";
6
6
  import CreateBy from "../Types/Database/CreateBy";
7
7
  import UpdateBy from "../Types/Database/UpdateBy";
8
- import { OnCreate, OnUpdate } from "../Types/Database/Hooks";
8
+ import DeleteBy from "../Types/Database/DeleteBy";
9
+ import { OnCreate, OnDelete, OnUpdate } from "../Types/Database/Hooks";
9
10
  import LIMIT_MAX from "../../Types/Database/LimitMax";
10
11
  import ObjectID from "../../Types/ObjectID";
11
12
  import QueryHelper from "../Types/Database/QueryHelper";
13
+ import IncomingCallPolicyService from "./IncomingCallPolicyService";
14
+ import IncomingCallPolicy from "../../Models/DatabaseModels/IncomingCallPolicy";
15
+ import releaseIncomingCallPhoneNumber from "../Utils/IncomingCallPhoneNumber";
12
16
  import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
13
17
 
14
18
  export class Service extends DatabaseService<Model> {
@@ -92,6 +96,83 @@ export class Service extends DatabaseService<Model> {
92
96
  return { updateBy, carryForward: null };
93
97
  }
94
98
 
99
+ @CaptureSpan()
100
+ protected override async onBeforeDelete(
101
+ deleteBy: DeleteBy<Model>,
102
+ ): Promise<OnDelete<Model>> {
103
+ /*
104
+ * When a Call/SMS config is deleted, release any incoming-call numbers
105
+ * provisioned through it (so they don't keep billing on the provider) and
106
+ * clear the now-dangling number fields on those policies. The config still
107
+ * exists at this point, so the provider can be built to release the numbers.
108
+ */
109
+ const configs: Array<Model> = await this.findBy({
110
+ query: deleteBy.query,
111
+ select: {
112
+ _id: true,
113
+ },
114
+ limit: LIMIT_MAX,
115
+ skip: 0,
116
+ props: {
117
+ isRoot: true,
118
+ },
119
+ });
120
+
121
+ for (const config of configs) {
122
+ if (!config.id) {
123
+ continue;
124
+ }
125
+
126
+ const policies: Array<IncomingCallPolicy> =
127
+ await IncomingCallPolicyService.findBy({
128
+ query: {
129
+ projectCallSMSConfigId: config.id,
130
+ },
131
+ select: {
132
+ _id: true,
133
+ callProviderPhoneNumberId: true,
134
+ projectCallSMSConfigId: true,
135
+ },
136
+ limit: LIMIT_MAX,
137
+ skip: 0,
138
+ props: {
139
+ isRoot: true,
140
+ },
141
+ });
142
+
143
+ for (const policy of policies) {
144
+ if (
145
+ !policy.callProviderPhoneNumberId ||
146
+ !policy.projectCallSMSConfigId
147
+ ) {
148
+ continue;
149
+ }
150
+
151
+ await releaseIncomingCallPhoneNumber({
152
+ projectCallSMSConfigId: policy.projectCallSMSConfigId,
153
+ callProviderPhoneNumberId: policy.callProviderPhoneNumberId,
154
+ });
155
+
156
+ await IncomingCallPolicyService.updateOneById({
157
+ id: policy.id!,
158
+ data: {
159
+ routingPhoneNumber: null,
160
+ callProviderPhoneNumberId: null,
161
+ phoneNumberCountryCode: null,
162
+ phoneNumberAreaCode: null,
163
+ phoneNumberPurchasedAt: null,
164
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
165
+ } as any,
166
+ props: {
167
+ isRoot: true,
168
+ },
169
+ });
170
+ }
171
+ }
172
+
173
+ return { deleteBy, carryForward: null };
174
+ }
175
+
95
176
  @CaptureSpan()
96
177
  public async getProjectDefaultTwilioConfig(
97
178
  projectId: ObjectID | undefined,
@@ -383,6 +383,12 @@ export class TeamMemberService extends DatabaseService<TeamMember> {
383
383
  OnCallDutyPolicyTimeLogService.endTimeForUser({
384
384
  projectId: member.projectId!,
385
385
  userId: member.userId!,
386
+ /*
387
+ * scope to the team being left so the user's still-active logs from
388
+ * other teams, direct escalation assignments, and schedule rosters stay
389
+ * open (audit F17).
390
+ */
391
+ teamId: member.teamId!,
386
392
  endsAt: OneUptimeDate.getCurrentDate(),
387
393
  }).catch((err: Error) => {
388
394
  logger.error(err, {
@@ -24,7 +24,6 @@ import URL from "../../Types/API/URL";
24
24
  import CallRequest from "../../Types/Call/CallRequest";
25
25
  import { LIMIT_PER_PROJECT } from "../../Types/Database/LimitMax";
26
26
  import QueryHelper from "../Types/Database/QueryHelper";
27
- import OneUptimeDate from "../../Types/Date";
28
27
  import Dictionary from "../../Types/Dictionary";
29
28
  import Email from "../../Types/Email";
30
29
  import EmailMessage from "../../Types/Email/EmailMessage";
@@ -109,52 +108,24 @@ export class Service extends DatabaseService<Model> {
109
108
  onCallScheduleId?: ObjectID | undefined;
110
109
  },
111
110
  ): Promise<void> {
112
- // get user notification log and see if this rule has already been executed. If so then skip.
113
-
114
- const userOnCallLog: UserOnCallLog | null =
115
- await UserOnCallLogService.findOneById({
116
- id: options.userNotificationLogId,
117
- props: {
118
- isRoot: true,
119
- },
120
- select: {
121
- _id: true,
122
- executedNotificationRules: true,
123
- },
111
+ /*
112
+ * Atomically claim this rule for this on-call log BEFORE sending, so two
113
+ * overlapping cron runs cannot both mark the rule un-executed and both
114
+ * notify — double-paging the responder for one escalation (audit F7). The
115
+ * previous read-check-then-blind-save was a non-atomic TOCTOU. If the claim
116
+ * was already taken (or the log is gone), skip.
117
+ */
118
+ const claimedRuleExecution: boolean =
119
+ await UserOnCallLogService.claimNotificationRuleExecution({
120
+ userOnCallLogId: options.userNotificationLogId,
121
+ userNotificationRuleId: userNotificationRuleId,
124
122
  });
125
123
 
126
- if (!userOnCallLog) {
127
- throw new BadDataException("User notification log not found.");
128
- }
129
-
130
- if (
131
- Object.keys(userOnCallLog.executedNotificationRules || {}).includes(
132
- userNotificationRuleId.toString(),
133
- )
134
- ) {
135
- // already executed.
124
+ if (!claimedRuleExecution) {
125
+ // already executed by this or a concurrent run.
136
126
  return;
137
127
  }
138
128
 
139
- if (!userOnCallLog.executedNotificationRules) {
140
- userOnCallLog.executedNotificationRules = {};
141
- }
142
-
143
- userOnCallLog.executedNotificationRules[userNotificationRuleId.toString()] =
144
- OneUptimeDate.getCurrentDate();
145
-
146
- await UserOnCallLogService.updateOneById({
147
- id: userOnCallLog.id!,
148
- data: {
149
- executedNotificationRules: {
150
- ...userOnCallLog.executedNotificationRules,
151
- },
152
- } as any,
153
- props: {
154
- isRoot: true,
155
- },
156
- });
157
-
158
129
  // find notification rule item.
159
130
  const notificationRuleItem: Model | null = await this.findOneById({
160
131
  id: userNotificationRuleId!,
@@ -23,6 +23,9 @@ import AlertEpisode from "../../Models/DatabaseModels/AlertEpisode";
23
23
  import AlertEpisodeService from "./AlertEpisodeService";
24
24
  import IncidentEpisode from "../../Models/DatabaseModels/IncidentEpisode";
25
25
  import IncidentEpisodeService from "./IncidentEpisodeService";
26
+ import OneUptimeDate from "../../Types/Date";
27
+ import { JSONObject } from "../../Types/JSON";
28
+ import logger from "../Utils/Logger";
26
29
 
27
30
  export class Service extends DatabaseService<Model> {
28
31
  public constructor() {
@@ -32,6 +35,93 @@ export class Service extends DatabaseService<Model> {
32
35
  }
33
36
  }
34
37
 
38
+ /**
39
+ * Atomically claim the right to execute ONE notification rule for this on-call
40
+ * log. Overlapping UserOnCallLog:ExecutePendingExecutions runs (a tick that
41
+ * exceeds the 1-minute cadence, a stalled-job re-delivery, or a burst release
42
+ * across worker replicas) could both read executedNotificationRules without a
43
+ * given delayed rule's key, both write it, and both send — double-paging the
44
+ * responder for a single escalation (audit F7). This mirrors
45
+ * OnCallDutyPolicyExecutionLogService.claimEscalationAdvance: a single
46
+ * conditional UPDATE that sets executedNotificationRules[ruleId] only when the
47
+ * key is absent and RETURNs the affected row, so exactly one concurrent run
48
+ * wins and the loser skips.
49
+ *
50
+ * Fail-safe: if the atomic statement errors for any reason, fall back to the
51
+ * previous non-atomic read-modify-write so the rule is still marked and sent.
52
+ * For an on-call system a rare duplicate page (the pre-fix behavior) is far
53
+ * better than a missed page, so this never makes paging less reliable.
54
+ *
55
+ * Returns true when THIS call claimed the rule (caller should send), false when
56
+ * it was already executed/claimed (caller should skip) or the log is missing.
57
+ */
58
+ @CaptureSpan()
59
+ public async claimNotificationRuleExecution(data: {
60
+ userOnCallLogId: ObjectID;
61
+ userNotificationRuleId: ObjectID;
62
+ }): Promise<boolean> {
63
+ const ruleKey: string = data.userNotificationRuleId.toString();
64
+
65
+ try {
66
+ const nowIso: string = OneUptimeDate.getCurrentDate().toISOString();
67
+
68
+ const rows: Array<{ _id: string }> =
69
+ await this.getRepository().manager.query(
70
+ `UPDATE "UserOnCallLog"
71
+ SET "executedNotificationRules" = jsonb_set(
72
+ COALESCE("executedNotificationRules", '{}')::jsonb,
73
+ ARRAY[$2::text],
74
+ to_jsonb($3::text),
75
+ true
76
+ )::json
77
+ WHERE "_id" = $1::uuid
78
+ AND NOT (COALESCE("executedNotificationRules", '{}')::jsonb ? $2::text)
79
+ RETURNING "_id"`,
80
+ [data.userOnCallLogId.toString(), ruleKey, nowIso],
81
+ );
82
+
83
+ return Array.isArray(rows) && rows.length > 0;
84
+ } catch (err) {
85
+ logger.error(
86
+ "claimNotificationRuleExecution atomic claim failed; falling back to non-atomic marking.",
87
+ );
88
+ logger.error(err);
89
+
90
+ // Fallback: previous non-atomic read-modify-write (reliable marking).
91
+ const log: Model | null = await this.findOneById({
92
+ id: data.userOnCallLogId,
93
+ props: { isRoot: true },
94
+ select: {
95
+ _id: true,
96
+ executedNotificationRules: true,
97
+ },
98
+ });
99
+
100
+ if (!log) {
101
+ return false;
102
+ }
103
+
104
+ const executed: JSONObject = log.executedNotificationRules || {};
105
+
106
+ if (Object.keys(executed).includes(ruleKey)) {
107
+ return false;
108
+ }
109
+
110
+ executed[ruleKey] = OneUptimeDate.getCurrentDate();
111
+
112
+ await this.updateOneById({
113
+ id: log.id!,
114
+ data: {
115
+ executedNotificationRules: { ...executed },
116
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
117
+ } as any,
118
+ props: { isRoot: true },
119
+ });
120
+
121
+ return true;
122
+ }
123
+ }
124
+
35
125
  @CaptureSpan()
36
126
  protected override async onBeforeCreate(
37
127
  createBy: CreateBy<Model>,