@oneuptime/common 12.0.29 → 12.0.30

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 (189) hide show
  1. package/Models/DatabaseModels/Index.ts +11 -0
  2. package/Models/DatabaseModels/OnCallDutyPolicySchedule.ts +43 -0
  3. package/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.ts +662 -0
  4. package/Models/DatabaseModels/ProjectOnCallCalendarFeed.ts +584 -0
  5. package/Models/DatabaseModels/UserOnCallCalendarFeed.ts +546 -0
  6. package/Models/DatabaseModels/UserOnCallShiftReminder.ts +231 -0
  7. package/Models/DatabaseModels/UserOnCallShiftReminderLog.ts +331 -0
  8. package/Server/API/OnCallCalendarAPI.ts +2194 -0
  9. package/Server/EnvironmentConfig.ts +138 -0
  10. package/Server/Infrastructure/OnCallCalendarFeedCache.ts +1050 -0
  11. package/Server/Infrastructure/Postgres/SchemaMigrations/1790400000000-AddOnCallCalendarFeeds.ts +253 -0
  12. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  13. package/Server/Middleware/OnCallCalendarFeedRateLimit.ts +487 -0
  14. package/Server/Services/Index.ts +12 -0
  15. package/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.ts +32 -0
  16. package/Server/Services/OnCallDutyPolicyScheduleCalendarFeedService.ts +260 -0
  17. package/Server/Services/OnCallDutyPolicyScheduleLayerService.ts +70 -3
  18. package/Server/Services/OnCallDutyPolicyScheduleLayerUserService.ts +138 -3
  19. package/Server/Services/OnCallDutyPolicyScheduleService.ts +1465 -134
  20. package/Server/Services/OnCallDutyPolicyUserOverrideService.ts +167 -31
  21. package/Server/Services/ProjectOnCallCalendarFeedService.ts +192 -0
  22. package/Server/Services/TeamMemberService.ts +489 -1
  23. package/Server/Services/TeamService.ts +50 -0
  24. package/Server/Services/UserNotificationSettingService.ts +44 -1
  25. package/Server/Services/UserOnCallCalendarFeedService.ts +159 -0
  26. package/Server/Services/UserOnCallShiftReminderLogService.ts +151 -0
  27. package/Server/Services/UserOnCallShiftReminderService.ts +112 -0
  28. package/Server/Utils/OnCall/CalendarFeedToken.ts +241 -0
  29. package/Server/Utils/OnCall/OnCallCalendarFeedRenderer.ts +1488 -0
  30. package/Server/Utils/OnCall/OnCallCalendarFeedUrls.ts +230 -0
  31. package/Server/Utils/OnCall/OnCallShiftChangeListeners.ts +227 -0
  32. package/Server/Utils/OnCall/OnCallShiftMaterializer.ts +1127 -0
  33. package/Server/Utils/OnCall/OnCallShiftReminderListener.ts +174 -0
  34. package/Server/Utils/OnCall/OnCallShiftReminderRunner.ts +2584 -0
  35. package/Server/Utils/Response.ts +134 -0
  36. package/Server/Utils/StartServer.ts +10 -0
  37. package/Server/Utils/Telemetry/AppMetrics.ts +51 -0
  38. package/Tests/App/Dashboard/OnCallCalendarFeedLinks.test.tsx +229 -0
  39. package/Tests/App/Dashboard/OnCallCalendarFeedPlanGate.test.tsx +489 -0
  40. package/Tests/App/Dashboard/OnCallCalendarFeedSideMenus.test.tsx +149 -0
  41. package/Tests/App/Dashboard/OnCallCalendarFeedUtil.test.ts +855 -0
  42. package/Tests/App/Dashboard/OnCallPersonalCalendarFeed.test.tsx +1209 -0
  43. package/Tests/App/Dashboard/OnCallSharedCalendarFeedCard.test.tsx +1019 -0
  44. package/Tests/App/Dashboard/OnCallShiftRemindersAndUpcomingShifts.test.tsx +655 -0
  45. package/Tests/App/Dashboard/UserSettingsSetupChecklistModel.test.ts +73 -0
  46. package/Tests/Models/OnCallCalendarFeedModels.test.ts +642 -0
  47. package/Tests/Models/OnCallDutyPolicyScheduleCalendarFeed.test.ts +288 -0
  48. package/Tests/Models/OnCallDutyPolicyScheduleShiftConfigVersion.test.ts +88 -0
  49. package/Tests/Models/UserOnCallShiftReminderModels.test.ts +368 -0
  50. package/Tests/Server/API/Helpers.ts +8 -0
  51. package/Tests/Server/API/OnCallCalendarAPI.test.ts +4461 -0
  52. package/Tests/Server/EnvironmentConfigOnCallCalendarFeed.test.ts +357 -0
  53. package/Tests/Server/Infrastructure/OnCallCalendarFeedCache.test.ts +1589 -0
  54. package/Tests/Server/Infrastructure/Postgres/AddOnCallCalendarFeedsMigration.test.ts +369 -0
  55. package/Tests/Server/Middleware/OnCallCalendarFeedRateLimit.test.ts +1365 -0
  56. package/Tests/Server/Services/FeedRetentionConsistency.test.ts +70 -0
  57. package/Tests/Server/Services/OnCallCalendarFeedServices.test.ts +1103 -0
  58. package/Tests/Server/Services/OnCallDutyPolicyScheduleResolver.test.ts +1037 -0
  59. package/Tests/Server/Services/OnCallShiftConfigPropagation.test.ts +1270 -0
  60. package/Tests/Server/Services/TeamDeleteMemberCleanup.test.ts +156 -0
  61. package/Tests/Server/Services/TeamMemberOnCallCleanup.test.ts +755 -0
  62. package/Tests/Server/Services/UserNotificationSettingShiftReminderDefaults.test.ts +267 -0
  63. package/Tests/Server/Services/UserOnCallShiftReminderServices.test.ts +583 -0
  64. package/Tests/Server/Types/Database/Permissions/OnCallCalendarFeedPermissions.test.ts +254 -0
  65. package/Tests/Server/Utils/OnCall/CalendarFeedToken.test.ts +467 -0
  66. package/Tests/Server/Utils/OnCall/OnCallCalendarFeedRenderer.test.ts +2142 -0
  67. package/Tests/Server/Utils/OnCall/OnCallCalendarFeedUrls.test.ts +278 -0
  68. package/Tests/Server/Utils/OnCall/OnCallResolverTestHarness.ts +440 -0
  69. package/Tests/Server/Utils/OnCall/OnCallShiftChangeListeners.test.ts +314 -0
  70. package/Tests/Server/Utils/OnCall/OnCallShiftMaterializer.test.ts +1502 -0
  71. package/Tests/Server/Utils/OnCall/OnCallShiftReminderListener.test.ts +491 -0
  72. package/Tests/Server/Utils/OnCall/OnCallShiftReminderRunner.test.ts +3088 -0
  73. package/Tests/Server/Utils/OnCall/OnCallShiftReminderTestHarness.ts +910 -0
  74. package/Tests/Server/Utils/ResponseSendCalendarResponse.test.ts +561 -0
  75. package/Tests/Server/Utils/StartServerEncryptionSecretWarning.test.ts +64 -0
  76. package/Tests/Server/Utils/Telemetry/AppMetrics.test.ts +29 -0
  77. package/Tests/Types/Calendar/ICalendar.test.ts +488 -0
  78. package/Tests/Types/NotificationSetting/NotificationSettingEventType.test.ts +53 -0
  79. package/Tests/Types/OnCallDutyPolicy/CalendarFeedTestFixtures.ts +310 -0
  80. package/Tests/Types/OnCallDutyPolicy/CalendarFeedWindow.test.ts +205 -0
  81. package/Tests/Types/OnCallDutyPolicy/LayerUtilLayerMetaAndIterationCap.test.ts +458 -0
  82. package/Tests/Types/OnCallDutyPolicy/MaterializedShift.test.ts +197 -0
  83. package/Tests/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.test.ts +1986 -0
  84. package/Tests/Types/OnCallDutyPolicy/ScheduleShiftUtilGroupKey.test.ts +678 -0
  85. package/Tests/Types/OnCallDutyPolicy/ShiftSeamUtil.test.ts +417 -0
  86. package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +10 -0
  87. package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +2 -0
  88. package/Types/Calendar/ICalendar.ts +442 -0
  89. package/Types/Email/EmailTemplateType.ts +2 -0
  90. package/Types/NotificationSetting/NotificationSettingEventType.ts +4 -0
  91. package/Types/OnCallDutyPolicy/CalendarFeedWindow.ts +140 -0
  92. package/Types/OnCallDutyPolicy/Layer.ts +190 -35
  93. package/Types/OnCallDutyPolicy/MaterializedShift.ts +274 -0
  94. package/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.ts +1415 -0
  95. package/Types/OnCallDutyPolicy/ScheduleShiftUtil.ts +159 -4
  96. package/Types/OnCallDutyPolicy/ShiftSeamUtil.ts +106 -0
  97. package/build/dist/Models/DatabaseModels/Index.js +11 -0
  98. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  99. package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js +44 -0
  100. package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js.map +1 -1
  101. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.js +699 -0
  102. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.js.map +1 -0
  103. package/build/dist/Models/DatabaseModels/ProjectOnCallCalendarFeed.js +620 -0
  104. package/build/dist/Models/DatabaseModels/ProjectOnCallCalendarFeed.js.map +1 -0
  105. package/build/dist/Models/DatabaseModels/UserOnCallCalendarFeed.js +585 -0
  106. package/build/dist/Models/DatabaseModels/UserOnCallCalendarFeed.js.map +1 -0
  107. package/build/dist/Models/DatabaseModels/UserOnCallShiftReminder.js +247 -0
  108. package/build/dist/Models/DatabaseModels/UserOnCallShiftReminder.js.map +1 -0
  109. package/build/dist/Models/DatabaseModels/UserOnCallShiftReminderLog.js +355 -0
  110. package/build/dist/Models/DatabaseModels/UserOnCallShiftReminderLog.js.map +1 -0
  111. package/build/dist/Server/API/OnCallCalendarAPI.js +1371 -0
  112. package/build/dist/Server/API/OnCallCalendarAPI.js.map +1 -0
  113. package/build/dist/Server/EnvironmentConfig.js +102 -0
  114. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  115. package/build/dist/Server/Infrastructure/OnCallCalendarFeedCache.js +663 -0
  116. package/build/dist/Server/Infrastructure/OnCallCalendarFeedCache.js.map +1 -0
  117. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790400000000-AddOnCallCalendarFeeds.js +103 -0
  118. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790400000000-AddOnCallCalendarFeeds.js.map +1 -0
  119. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  120. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  121. package/build/dist/Server/Middleware/OnCallCalendarFeedRateLimit.js +342 -0
  122. package/build/dist/Server/Middleware/OnCallCalendarFeedRateLimit.js.map +1 -0
  123. package/build/dist/Server/Services/Index.js +12 -0
  124. package/build/dist/Server/Services/Index.js.map +1 -1
  125. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js +20 -0
  126. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js.map +1 -1
  127. package/build/dist/Server/Services/OnCallDutyPolicyScheduleCalendarFeedService.js +219 -0
  128. package/build/dist/Server/Services/OnCallDutyPolicyScheduleCalendarFeedService.js.map +1 -0
  129. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerService.js +47 -3
  130. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerService.js.map +1 -1
  131. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerUserService.js +112 -3
  132. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerUserService.js.map +1 -1
  133. package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js +955 -9
  134. package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js.map +1 -1
  135. package/build/dist/Server/Services/OnCallDutyPolicyUserOverrideService.js +93 -6
  136. package/build/dist/Server/Services/OnCallDutyPolicyUserOverrideService.js.map +1 -1
  137. package/build/dist/Server/Services/ProjectOnCallCalendarFeedService.js +157 -0
  138. package/build/dist/Server/Services/ProjectOnCallCalendarFeedService.js.map +1 -0
  139. package/build/dist/Server/Services/TeamMemberService.js +396 -1
  140. package/build/dist/Server/Services/TeamMemberService.js.map +1 -1
  141. package/build/dist/Server/Services/TeamService.js +43 -0
  142. package/build/dist/Server/Services/TeamService.js.map +1 -1
  143. package/build/dist/Server/Services/UserNotificationSettingService.js +26 -1
  144. package/build/dist/Server/Services/UserNotificationSettingService.js.map +1 -1
  145. package/build/dist/Server/Services/UserOnCallCalendarFeedService.js +140 -0
  146. package/build/dist/Server/Services/UserOnCallCalendarFeedService.js.map +1 -0
  147. package/build/dist/Server/Services/UserOnCallShiftReminderLogService.js +116 -0
  148. package/build/dist/Server/Services/UserOnCallShiftReminderLogService.js.map +1 -0
  149. package/build/dist/Server/Services/UserOnCallShiftReminderService.js +92 -0
  150. package/build/dist/Server/Services/UserOnCallShiftReminderService.js.map +1 -0
  151. package/build/dist/Server/Utils/OnCall/CalendarFeedToken.js +161 -0
  152. package/build/dist/Server/Utils/OnCall/CalendarFeedToken.js.map +1 -0
  153. package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedRenderer.js +983 -0
  154. package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedRenderer.js.map +1 -0
  155. package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedUrls.js +155 -0
  156. package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedUrls.js.map +1 -0
  157. package/build/dist/Server/Utils/OnCall/OnCallShiftChangeListeners.js +145 -0
  158. package/build/dist/Server/Utils/OnCall/OnCallShiftChangeListeners.js.map +1 -0
  159. package/build/dist/Server/Utils/OnCall/OnCallShiftMaterializer.js +752 -0
  160. package/build/dist/Server/Utils/OnCall/OnCallShiftMaterializer.js.map +1 -0
  161. package/build/dist/Server/Utils/OnCall/OnCallShiftReminderListener.js +92 -0
  162. package/build/dist/Server/Utils/OnCall/OnCallShiftReminderListener.js.map +1 -0
  163. package/build/dist/Server/Utils/OnCall/OnCallShiftReminderRunner.js +1786 -0
  164. package/build/dist/Server/Utils/OnCall/OnCallShiftReminderRunner.js.map +1 -0
  165. package/build/dist/Server/Utils/Response.js +97 -0
  166. package/build/dist/Server/Utils/Response.js.map +1 -1
  167. package/build/dist/Server/Utils/StartServer.js +9 -1
  168. package/build/dist/Server/Utils/StartServer.js.map +1 -1
  169. package/build/dist/Server/Utils/Telemetry/AppMetrics.js +41 -0
  170. package/build/dist/Server/Utils/Telemetry/AppMetrics.js.map +1 -1
  171. package/build/dist/Types/Calendar/ICalendar.js +290 -0
  172. package/build/dist/Types/Calendar/ICalendar.js.map +1 -0
  173. package/build/dist/Types/Email/EmailTemplateType.js +2 -0
  174. package/build/dist/Types/Email/EmailTemplateType.js.map +1 -1
  175. package/build/dist/Types/NotificationSetting/NotificationSettingEventType.js +3 -0
  176. package/build/dist/Types/NotificationSetting/NotificationSettingEventType.js.map +1 -1
  177. package/build/dist/Types/OnCallDutyPolicy/CalendarFeedWindow.js +97 -0
  178. package/build/dist/Types/OnCallDutyPolicy/CalendarFeedWindow.js.map +1 -0
  179. package/build/dist/Types/OnCallDutyPolicy/Layer.js +113 -19
  180. package/build/dist/Types/OnCallDutyPolicy/Layer.js.map +1 -1
  181. package/build/dist/Types/OnCallDutyPolicy/MaterializedShift.js +134 -0
  182. package/build/dist/Types/OnCallDutyPolicy/MaterializedShift.js.map +1 -0
  183. package/build/dist/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.js +800 -0
  184. package/build/dist/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.js.map +1 -0
  185. package/build/dist/Types/OnCallDutyPolicy/ScheduleShiftUtil.js +97 -3
  186. package/build/dist/Types/OnCallDutyPolicy/ScheduleShiftUtil.js.map +1 -1
  187. package/build/dist/Types/OnCallDutyPolicy/ShiftSeamUtil.js +77 -0
  188. package/build/dist/Types/OnCallDutyPolicy/ShiftSeamUtil.js.map +1 -0
  189. package/package.json +1 -1
@@ -0,0 +1,1786 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ var __metadata = (this && this.__metadata) || function (k, v) {
8
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
9
+ };
10
+ import OnCallDutyPolicyScheduleService from "../../Services/OnCallDutyPolicyScheduleService";
11
+ import UserNotificationSettingService from "../../Services/UserNotificationSettingService";
12
+ import UserOnCallShiftReminderService from "../../Services/UserOnCallShiftReminderService";
13
+ import UserService from "../../Services/UserService";
14
+ import UserOnCallShiftReminderLogService, { Service as UserOnCallShiftReminderLogServiceClass, } from "../../Services/UserOnCallShiftReminderLogService";
15
+ import DatabaseConfig from "../../DatabaseConfig";
16
+ import GlobalCache from "../../Infrastructure/GlobalCache";
17
+ import Semaphore from "../../Infrastructure/Semaphore";
18
+ import QueryHelper from "../../Types/Database/QueryHelper";
19
+ import PostgresErrorTranslator from "../Database/PostgresErrorTranslator";
20
+ import logger from "../Logger";
21
+ import PushNotificationUtil from "../PushNotificationUtil";
22
+ import { createWhatsAppMessageFromTemplate } from "../WhatsAppTemplateUtil";
23
+ import Telemetry from "../Telemetry";
24
+ import CaptureSpan from "../Telemetry/CaptureSpan";
25
+ import OnCallShiftMaterializer from "./OnCallShiftMaterializer";
26
+ import UserOnCallShiftReminderLog, { UserOnCallShiftReminderLogKind, } from "../../../Models/DatabaseModels/UserOnCallShiftReminderLog";
27
+ import LIMIT_MAX, { LIMIT_PER_PROJECT } from "../../../Types/Database/LimitMax";
28
+ import OneUptimeDate from "../../../Types/Date";
29
+ import EmailTemplateType from "../../../Types/Email/EmailTemplateType";
30
+ import NotificationSettingEventType from "../../../Types/NotificationSetting/NotificationSettingEventType";
31
+ import ObjectID from "../../../Types/ObjectID";
32
+ import OnCallCalendarFeedUtil from "../../../Types/OnCallDutyPolicy/OnCallCalendarFeedUtil";
33
+ import Timezone from "../../../Types/Timezone";
34
+ import { WhatsAppTemplateIds } from "../../../Types/WhatsApp/WhatsAppTemplates";
35
+ /*
36
+ * Shift reminders ("your on-call shift on Payments starts in 1 hour") and the
37
+ * change notices that keep them honest when a shift changes hands inside a
38
+ * reminder window.
39
+ *
40
+ * Two entry points, one ledger:
41
+ *
42
+ * - runSweep: the five-minute cron body. Reads the lead times every user
43
+ * configured (UserOnCallShiftReminder), materializes the next
44
+ * [now, now + longest lead + 30 min] of every schedule those users can
45
+ * hold a shift on, and sends one reminder per (user, schedule, shift
46
+ * start, lead) whose "start minus lead" instant fell inside this tick's
47
+ * window. The window is a WATERMARK, not a run-time slice: it runs from
48
+ * the previous completed tick (capped at 30 minutes back) to now, so a
49
+ * skipped or late tick is caught up by the next one, while a long outage
50
+ * never floods (and never reminds anyone about a shift that already
51
+ * started — the lateness cap).
52
+ *
53
+ * - runChangePass: called from the on-call configuration hooks through
54
+ * OnCallShiftChangeListeners. Re-materializes the affected schedules and
55
+ * (a) sends ONE catch-up to a user who now holds a shift that starts
56
+ * inside one of their leads but was never reminded about it (a late
57
+ * override, a rotation edit), and (b) sends ONE "reassigned" notice to a
58
+ * user who was reminded about a shift they no longer hold.
59
+ *
60
+ * The ledger is UserOnCallShiftReminderLog with a UNIQUE index over
61
+ * (user, schedule, shiftStartsAt, minutesBeforeShift, kind). Every send is
62
+ * claim -> send -> stamp: the row is inserted with sentAt NULL first (a
63
+ * unique violation means another replica already has it), the notification
64
+ * goes out, then sentAt is stamped. A thrown send deletes the claim so the
65
+ * next tick retries while the shift is still ahead; a claim that is older
66
+ * than RECLAIM_AFTER_MINUTES with sentAt still NULL belonged to a worker
67
+ * that died mid-send and is re-claimed with a conditional update. Postgres,
68
+ * not Redis: the compose Redis is non-persistent, and a flush must never
69
+ * re-page anyone.
70
+ *
71
+ * shiftStartsAt is the seam-normalised, minute-aligned MaterializedShift
72
+ * start, so a shift whose engine start moves by a second (a layer edit that
73
+ * re-cuts the seams) still maps onto the same ledger row.
74
+ */
75
+ // Enqueued/scheduled by App/FeatureSet/Workers/Jobs/OnCallDutySchedule/*.
76
+ export const SHIFT_REMINDER_JOB_NAME = "OnCallDutySchedule:SendShiftReminders";
77
+ export const SHIFT_REMINDER_LOG_RETENTION_JOB_NAME = "OnCallDutySchedule:DeleteOldShiftReminderLogs";
78
+ // Listener name under which the change pass registers (idempotent).
79
+ export const SHIFT_REMINDER_LISTENER_NAME = "shift-reminders";
80
+ // Watermark of the last COMPLETED sweep, in GlobalCache (Redis).
81
+ export const SHIFT_REMINDER_WATERMARK_NAMESPACE = "OnCallShiftReminders";
82
+ export const SHIFT_REMINDER_WATERMARK_KEY = "watermark";
83
+ export const SHIFT_REMINDER_WATERMARK_TTL_SECONDS = OneUptimeDate.getSecondsInDays(1);
84
+ /*
85
+ * How far back a sweep may reach when the watermark is missing (Redis
86
+ * restart) or older than this. A longer outage deliberately loses the
87
+ * reminders whose lead instant fell inside it: re-sending an hour of
88
+ * "starts in 15 minutes" messages after the fact helps nobody.
89
+ */
90
+ export const SHIFT_REMINDER_MAX_LOOKBACK_MINUTES = 30;
91
+ // A claim with sentAt NULL older than this is assumed orphaned and retried.
92
+ export const SHIFT_REMINDER_RECLAIM_AFTER_MINUTES = 10;
93
+ // Materialization window = [now, now + longest lead + this].
94
+ export const SHIFT_REMINDER_WINDOW_PADDING_MINUTES = 30;
95
+ /*
96
+ * When the sweep is at most one tick behind the lead instant the message says
97
+ * the lead ("starts in 1 hour"); further behind, it says the true remaining
98
+ * time so a late tick never claims an hour that is already half gone.
99
+ */
100
+ export const SHIFT_REMINDER_LEAD_TEXT_TOLERANCE_MINUTES = 5;
101
+ // Retention of ledger rows, measured from the shift's start.
102
+ export const SHIFT_REMINDER_LOG_RETENTION_DAYS = 30;
103
+ export const SHIFT_REMINDER_LOG_DELETE_BATCH_SIZE = 100;
104
+ // Sweep lock. The lock must outlive the job timeout (runJobWithTimeout races).
105
+ export const SHIFT_REMINDER_SWEEP_LOCK_NAMESPACE = "Workers.Cron";
106
+ export const SHIFT_REMINDER_JOB_TIMEOUT_MS = OneUptimeDate.convertMinutesToMilliseconds(10);
107
+ export const SHIFT_REMINDER_SWEEP_LOCK_TIMEOUT_MS = OneUptimeDate.convertMinutesToMilliseconds(12);
108
+ const MILLISECONDS_PER_MINUTE = 60 * 1000;
109
+ const METRIC_NAME = "oncall_shift_reminders";
110
+ const METRIC_OUTCOME_ATTRIBUTE = "oneuptime.oncall_shift_reminder.outcome";
111
+ export var ShiftReminderOutcome;
112
+ (function (ShiftReminderOutcome) {
113
+ ShiftReminderOutcome["Sent"] = "sent";
114
+ ShiftReminderOutcome["SkippedLate"] = "skipped_late";
115
+ ShiftReminderOutcome["SkippedAlreadySent"] = "skipped_already_sent";
116
+ ShiftReminderOutcome["SkippedInFlight"] = "skipped_in_flight";
117
+ ShiftReminderOutcome["SkippedNoPolicy"] = "skipped_no_policy";
118
+ ShiftReminderOutcome["ClaimCollision"] = "claim_collision";
119
+ ShiftReminderOutcome["ClaimRetry"] = "claim_retry";
120
+ ShiftReminderOutcome["SendFailed"] = "send_failed";
121
+ ShiftReminderOutcome["MissingSettings"] = "missing_settings";
122
+ ShiftReminderOutcome["CatchUpSent"] = "catch_up_sent";
123
+ ShiftReminderOutcome["ReassignedSent"] = "reassigned_sent";
124
+ })(ShiftReminderOutcome || (ShiftReminderOutcome = {}));
125
+ class OnCallShiftReminderRunner {
126
+ // -- Cron entry points ------------------------------------------------
127
+ /**
128
+ * The five-minute cron body: one sweep under a cross-replica lock. A held
129
+ * lock (or an unreachable Redis) skips the tick — the holder is already
130
+ * covering the same window, and the watermark makes the next tick catch
131
+ * up anything this one would have done.
132
+ */
133
+ static async runSweepUnderLock(options) {
134
+ let mutex = null;
135
+ try {
136
+ mutex = await Semaphore.lock({
137
+ key: SHIFT_REMINDER_JOB_NAME,
138
+ namespace: SHIFT_REMINDER_SWEEP_LOCK_NAMESPACE,
139
+ lockTimeout: SHIFT_REMINDER_SWEEP_LOCK_TIMEOUT_MS,
140
+ acquireAttemptsLimit: 1,
141
+ });
142
+ }
143
+ catch (err) {
144
+ logger.debug(`${SHIFT_REMINDER_JOB_NAME}: could not acquire the sweep lock; a sweep is already in flight (or Redis is unavailable). Skipping this run: ${err}`);
145
+ return null;
146
+ }
147
+ try {
148
+ return await OnCallShiftReminderRunner.runSweep(options);
149
+ }
150
+ finally {
151
+ try {
152
+ await Semaphore.release(mutex);
153
+ }
154
+ catch (err) {
155
+ logger.error(`${SHIFT_REMINDER_JOB_NAME}: error releasing the sweep lock: ${err}`);
156
+ }
157
+ }
158
+ }
159
+ /**
160
+ * One sweep: every due (user, shift, lead) inside
161
+ * (lookbackFrom + lead, now + lead], claimed and sent once. Never throws
162
+ * for one user's or one project's bad data; throws only when the reminder
163
+ * table itself cannot be read, in which case the watermark is NOT advanced
164
+ * and the next tick covers the same window.
165
+ *
166
+ * A project or user that threw (a DB timeout inside the materializer, a
167
+ * transient Redis error) is isolated the same way, but its window would
168
+ * otherwise be lost for good: the next tick starts where this one ended
169
+ * and `isDue` never looks back at a lead instant again. So whenever
170
+ * anything failed the watermark is stamped with `lookbackFrom` instead of
171
+ * `now` and the next tick re-covers the identical window. The re-run is a
172
+ * no-op for everything that did send — the UNIQUE ledger turns it into
173
+ * "already sent" — and the 30-minute lookback cap bounds a project that
174
+ * keeps failing.
175
+ */
176
+ static async runSweep(options) {
177
+ const now = (options === null || options === void 0 ? void 0 : options.now) || OneUptimeDate.getCurrentDate();
178
+ const watermark = await OnCallShiftReminderRunner.readWatermark();
179
+ const lookbackFrom = OnCallShiftReminderRunner.computeLookbackFrom(now, watermark);
180
+ const stats = {
181
+ now,
182
+ lookbackFrom,
183
+ watermarkFound: watermark !== null,
184
+ watermarkWrittenAt: now,
185
+ projects: 0,
186
+ usersWithReminders: 0,
187
+ shiftsConsidered: 0,
188
+ sent: 0,
189
+ skippedLate: 0,
190
+ skippedAlreadySent: 0,
191
+ skippedInFlight: 0,
192
+ skippedNoPolicy: 0,
193
+ claimCollisions: 0,
194
+ claimRetries: 0,
195
+ sendFailures: 0,
196
+ missingSettings: 0,
197
+ truncatedSchedules: 0,
198
+ errors: 0,
199
+ };
200
+ const reminders = await UserOnCallShiftReminderService.findAllBy({
201
+ query: {},
202
+ select: {
203
+ _id: true,
204
+ projectId: true,
205
+ userId: true,
206
+ minutesBeforeShift: true,
207
+ },
208
+ props: {
209
+ isRoot: true,
210
+ },
211
+ });
212
+ const plans = OnCallShiftReminderRunner.buildProjectPlans(reminders);
213
+ stats.projects = plans.size;
214
+ for (const plan of plans.values()) {
215
+ stats.usersWithReminders += plan.users.size;
216
+ }
217
+ if (plans.size === 0) {
218
+ await OnCallShiftReminderRunner.writeWatermark(now);
219
+ return stats;
220
+ }
221
+ const dashboardUrl = await OnCallShiftReminderRunner.getDashboardUrl();
222
+ for (const plan of plans.values()) {
223
+ try {
224
+ await OnCallShiftReminderRunner.sweepProject({
225
+ plan,
226
+ now,
227
+ lookbackFrom,
228
+ dashboardUrl,
229
+ stats,
230
+ });
231
+ }
232
+ catch (err) {
233
+ stats.errors++;
234
+ logger.error(`${SHIFT_REMINDER_JOB_NAME}: failed to process project ${plan.projectId.toString()}`);
235
+ logger.error(err);
236
+ }
237
+ }
238
+ /*
239
+ * Hold the window open when anything failed. Re-covering it costs
240
+ * nothing: every reminder that DID go out has a stamped ledger row and
241
+ * comes back as "already sent", and an isolated failure always happens
242
+ * before its send (a failed stamp is caught inside deliver, precisely so
243
+ * that "sent" and "swept" cannot disagree here).
244
+ */
245
+ stats.watermarkWrittenAt = stats.errors > 0 ? lookbackFrom : now;
246
+ if (stats.errors > 0) {
247
+ logger.warn(`${SHIFT_REMINDER_JOB_NAME}: ${stats.errors} failure(s) this tick; holding the watermark at ${lookbackFrom.toISOString()} so the next tick re-covers this window.`);
248
+ }
249
+ await OnCallShiftReminderRunner.writeWatermark(stats.watermarkWrittenAt);
250
+ logger.debug(`${SHIFT_REMINDER_JOB_NAME}: sweep complete — ${stats.sent} sent, ${stats.skippedLate} skipped (late), ${stats.claimRetries} claim retries, ${stats.claimCollisions} claim collisions, ${stats.sendFailures} send failures, ${stats.errors} errors.`);
251
+ return stats;
252
+ }
253
+ /**
254
+ * Ledger retention: rows for shifts that started more than
255
+ * SHIFT_REMINDER_LOG_RETENTION_DAYS ago are deleted in batches. Keyed on
256
+ * the SHIFT start (not the claim time) so a row can never disappear while
257
+ * the shift it de-duplicates is still ahead.
258
+ */
259
+ static async deleteOldLogs(options) {
260
+ var _a, _b;
261
+ const now = (options === null || options === void 0 ? void 0 : options.now) || OneUptimeDate.getCurrentDate();
262
+ const retentionDays = (_a = options === null || options === void 0 ? void 0 : options.retentionDays) !== null && _a !== void 0 ? _a : SHIFT_REMINDER_LOG_RETENTION_DAYS;
263
+ const batchSize = (_b = options === null || options === void 0 ? void 0 : options.batchSize) !== null && _b !== void 0 ? _b : SHIFT_REMINDER_LOG_DELETE_BATCH_SIZE;
264
+ const cutoff = OneUptimeDate.addRemoveDays(now, retentionDays * -1);
265
+ let deleted = 0;
266
+ while (true) {
267
+ const count = await UserOnCallShiftReminderLogService.deleteBy({
268
+ query: {
269
+ shiftStartsAt: QueryHelper.lessThanEqualTo(cutoff),
270
+ },
271
+ limit: batchSize,
272
+ skip: 0,
273
+ props: {
274
+ isRoot: true,
275
+ },
276
+ });
277
+ deleted += count;
278
+ if (count === 0) {
279
+ break;
280
+ }
281
+ }
282
+ return { deleted, cutoff };
283
+ }
284
+ // -- Change pass (hook-triggered) ---------------------------------------
285
+ /**
286
+ * Catch-up + reassigned notices for one shift-change event. Fire-and-forget
287
+ * from the hooks (through OnCallShiftChangeListeners); never throws.
288
+ */
289
+ static async runChangePass(event, options) {
290
+ const now = (options === null || options === void 0 ? void 0 : options.now) || OneUptimeDate.getCurrentDate();
291
+ const stats = {
292
+ now,
293
+ projectId: null,
294
+ users: 0,
295
+ catchUpsSent: 0,
296
+ reassignedSent: 0,
297
+ claimCollisions: 0,
298
+ sendFailures: 0,
299
+ missingSettings: 0,
300
+ errors: 0,
301
+ skippedReason: null,
302
+ };
303
+ try {
304
+ const projectId = event.projectId ||
305
+ (await OnCallShiftReminderRunner.resolveProjectIdFromSchedules(event.scheduleIds));
306
+ if (!projectId) {
307
+ stats.skippedReason = "no-project";
308
+ return stats;
309
+ }
310
+ stats.projectId = projectId.toString();
311
+ const eventScheduleIds = OnCallShiftReminderRunner.dedupeIds(event.scheduleIds);
312
+ /*
313
+ * Users to look at: everyone the hook named, plus everyone holding a
314
+ * ledger row for a future shift on an affected schedule (they may need
315
+ * a "reassigned" notice even if the hook did not name them).
316
+ */
317
+ const scheduleRows = eventScheduleIds.length > 0
318
+ ? await OnCallShiftReminderRunner.loadLedgerRows({
319
+ projectId,
320
+ scheduleIds: eventScheduleIds,
321
+ from: now,
322
+ })
323
+ : [];
324
+ const userIds = OnCallShiftReminderRunner.dedupeIds([
325
+ ...event.userIds,
326
+ ...scheduleRows.map((row) => {
327
+ return new ObjectID(row.userId);
328
+ }),
329
+ ]);
330
+ if (userIds.length === 0) {
331
+ stats.skippedReason = "no-users";
332
+ return stats;
333
+ }
334
+ const reminders = await UserOnCallShiftReminderService.findBy({
335
+ query: {
336
+ projectId: projectId,
337
+ userId: QueryHelper.any(userIds),
338
+ },
339
+ select: {
340
+ _id: true,
341
+ projectId: true,
342
+ userId: true,
343
+ minutesBeforeShift: true,
344
+ },
345
+ limit: LIMIT_PER_PROJECT,
346
+ skip: 0,
347
+ props: {
348
+ isRoot: true,
349
+ },
350
+ });
351
+ const plans = OnCallShiftReminderRunner.buildProjectPlans(reminders);
352
+ const plan = plans.get(projectId.toString());
353
+ // Every ledger row of those users for shifts still ahead.
354
+ const ledger = OnCallShiftReminderRunner.buildLedger(await OnCallShiftReminderRunner.loadLedgerRows({
355
+ projectId,
356
+ userIds,
357
+ from: now,
358
+ }));
359
+ const futureRowsExist = ledger.rows.length > 0;
360
+ if (!plan && !futureRowsExist) {
361
+ stats.skippedReason = "nothing-to-do";
362
+ return stats;
363
+ }
364
+ /*
365
+ * The window has to reach the farthest lead (for catch-ups) AND the
366
+ * farthest shift anyone was already reminded about (to judge whether
367
+ * they still hold it).
368
+ */
369
+ let windowMinutes = plan ? plan.maxLead : 0;
370
+ for (const row of ledger.rows) {
371
+ const minutesAhead = Math.ceil((row.shiftStartsAt.getTime() - now.getTime()) /
372
+ MILLISECONDS_PER_MINUTE);
373
+ windowMinutes = Math.max(windowMinutes, minutesAhead);
374
+ }
375
+ const windowEnd = OneUptimeDate.addRemoveMinutes(now, windowMinutes + SHIFT_REMINDER_WINDOW_PADDING_MINUTES);
376
+ /*
377
+ * Schedules to materialize: the ones the hook named, the ones the
378
+ * reminded users can hold shifts on, and the ones their ledger rows
379
+ * point at.
380
+ */
381
+ const scheduleIds = [...eventScheduleIds];
382
+ for (const row of ledger.rows) {
383
+ scheduleIds.push(new ObjectID(row.scheduleId));
384
+ }
385
+ if (plan) {
386
+ // One batched lookup for every reminded user of this project.
387
+ const candidatesByUser = await OnCallShiftMaterializer.getCandidateScheduleIdsForUsers({
388
+ userIds: Array.from(plan.users.values()).map((userPlan) => {
389
+ return userPlan.userId;
390
+ }),
391
+ projectIds: [projectId],
392
+ windowStart: now,
393
+ windowEnd,
394
+ includeCoveringShifts: true,
395
+ });
396
+ for (const candidates of candidatesByUser.values()) {
397
+ scheduleIds.push(...candidates);
398
+ }
399
+ }
400
+ const distinctScheduleIds = OnCallShiftReminderRunner.dedupeIds(scheduleIds);
401
+ if (distinctScheduleIds.length === 0) {
402
+ stats.skippedReason = "no-schedules";
403
+ return stats;
404
+ }
405
+ const result = await OnCallShiftMaterializer.materializeForSchedules({
406
+ scheduleIds: distinctScheduleIds,
407
+ windowStart: now,
408
+ windowEnd,
409
+ now,
410
+ });
411
+ const resolvedSchedules = new Map();
412
+ for (const schedule of result.schedules) {
413
+ resolvedSchedules.set(schedule.scheduleId, schedule);
414
+ }
415
+ const context = {
416
+ now,
417
+ projectId,
418
+ dashboardUrl: await OnCallShiftReminderRunner.getDashboardUrl(),
419
+ users: OnCallShiftReminderRunner.toUserMap(result.users),
420
+ };
421
+ await OnCallShiftReminderRunner.backfillRecipients({
422
+ userIds,
423
+ ledger,
424
+ context,
425
+ });
426
+ stats.users = userIds.length;
427
+ for (const userId of userIds) {
428
+ try {
429
+ const userKey = userId.toString();
430
+ const userPlan = plan === null || plan === void 0 ? void 0 : plan.users.get(userKey);
431
+ const heldShifts = OnCallShiftMaterializer.filterShiftsForUser(result.shifts, userId).filter((shift) => {
432
+ return shift.start.getTime() > now.getTime();
433
+ });
434
+ if (userPlan) {
435
+ await OnCallShiftReminderRunner.sendCatchUps({
436
+ userPlan,
437
+ heldShifts,
438
+ ledger,
439
+ context,
440
+ stats,
441
+ });
442
+ }
443
+ await OnCallShiftReminderRunner.sendReassignedNotices({
444
+ userId,
445
+ heldShifts,
446
+ allShifts: result.shifts,
447
+ ledger,
448
+ resolvedSchedules,
449
+ context,
450
+ stats,
451
+ });
452
+ }
453
+ catch (err) {
454
+ stats.errors++;
455
+ logger.error(`${SHIFT_REMINDER_LISTENER_NAME}: change pass failed for user ${userId.toString()} in project ${projectId.toString()}`);
456
+ logger.error(err);
457
+ }
458
+ }
459
+ }
460
+ catch (err) {
461
+ stats.errors++;
462
+ logger.error(`${SHIFT_REMINDER_LISTENER_NAME}: change pass failed (reason ${event.reason})`);
463
+ logger.error(err);
464
+ }
465
+ return stats;
466
+ }
467
+ // -- Watermark ----------------------------------------------------------
468
+ static async readWatermark() {
469
+ try {
470
+ const value = await GlobalCache.getString(SHIFT_REMINDER_WATERMARK_NAMESPACE, SHIFT_REMINDER_WATERMARK_KEY);
471
+ if (!value) {
472
+ return null;
473
+ }
474
+ const parsed = new Date(value);
475
+ if (Number.isNaN(parsed.getTime())) {
476
+ return null;
477
+ }
478
+ return parsed;
479
+ }
480
+ catch (err) {
481
+ logger.warn(`${SHIFT_REMINDER_JOB_NAME}: could not read the watermark; falling back to a ${SHIFT_REMINDER_MAX_LOOKBACK_MINUTES}-minute lookback: ${err}`);
482
+ return null;
483
+ }
484
+ }
485
+ static async writeWatermark(now) {
486
+ try {
487
+ await GlobalCache.setString(SHIFT_REMINDER_WATERMARK_NAMESPACE, SHIFT_REMINDER_WATERMARK_KEY, now.toISOString(), { expiresInSeconds: SHIFT_REMINDER_WATERMARK_TTL_SECONDS });
488
+ }
489
+ catch (err) {
490
+ logger.warn(`${SHIFT_REMINDER_JOB_NAME}: could not write the watermark; the next tick falls back to a ${SHIFT_REMINDER_MAX_LOOKBACK_MINUTES}-minute lookback: ${err}`);
491
+ }
492
+ }
493
+ /**
494
+ * Lower bound of this tick's window: the previous completed tick, but
495
+ * never more than SHIFT_REMINDER_MAX_LOOKBACK_MINUTES ago and never in the
496
+ * future (clock skew between replicas).
497
+ */
498
+ static computeLookbackFrom(now, watermark) {
499
+ const floor = OneUptimeDate.addRemoveMinutes(now, SHIFT_REMINDER_MAX_LOOKBACK_MINUTES * -1);
500
+ if (!watermark) {
501
+ return floor;
502
+ }
503
+ const clamped = Math.min(watermark.getTime(), now.getTime());
504
+ return new Date(Math.max(floor.getTime(), clamped));
505
+ }
506
+ // -- Pure helpers (exported for tests) -----------------------------------
507
+ /**
508
+ * True when a shift with this start is due for this lead inside the
509
+ * window (lookbackFrom + lead, now + lead].
510
+ */
511
+ static isDue(data) {
512
+ const leadMs = data.lead * MILLISECONDS_PER_MINUTE;
513
+ const startMs = data.start.getTime();
514
+ return (startMs > data.lookbackFrom.getTime() + leadMs &&
515
+ startMs <= data.now.getTime() + leadMs);
516
+ }
517
+ /** "1 hour", "1 hour 30 minutes", "2 weeks", "15 minutes". */
518
+ static describeMinutes(minutes) {
519
+ const whole = Math.round(minutes);
520
+ if (whole <= 0) {
521
+ return "less than a minute";
522
+ }
523
+ const units = [
524
+ ["week", 7 * 24 * 60],
525
+ ["day", 24 * 60],
526
+ ["hour", 60],
527
+ ["minute", 1],
528
+ ];
529
+ const parts = [];
530
+ let remaining = whole;
531
+ for (const [name, size] of units) {
532
+ const count = Math.floor(remaining / size);
533
+ if (count > 0) {
534
+ parts.push(`${count} ${name}${count === 1 ? "" : "s"}`);
535
+ remaining -= count * size;
536
+ }
537
+ }
538
+ return parts.slice(0, 2).join(" ");
539
+ }
540
+ /**
541
+ * The "starts in …" text of a regular reminder: the configured lead when
542
+ * the sweep is at most one tick behind it, the real remaining time
543
+ * otherwise.
544
+ */
545
+ static describeRemaining(data) {
546
+ const remainingMinutes = (data.start.getTime() - data.now.getTime()) / MILLISECONDS_PER_MINUTE;
547
+ if (remainingMinutes <= data.lead &&
548
+ data.lead - remainingMinutes <= SHIFT_REMINDER_LEAD_TEXT_TOLERANCE_MINUTES) {
549
+ return OnCallShiftReminderRunner.describeMinutes(data.lead);
550
+ }
551
+ return OnCallShiftReminderRunner.describeMinutes(remainingMinutes);
552
+ }
553
+ /** The zone a recipient's wall clock is rendered in. */
554
+ static resolveTimezone(data) {
555
+ if (OnCallCalendarFeedUtil.isValidTimezone(data.userTimezone)) {
556
+ return data.userTimezone;
557
+ }
558
+ if (OnCallCalendarFeedUtil.isValidTimezone(data.scheduleTimezone)) {
559
+ return data.scheduleTimezone;
560
+ }
561
+ return Timezone.UTC;
562
+ }
563
+ /** "Thu 3 Sep 18:00 Europe/Berlin" */
564
+ static formatInstant(date, timezone) {
565
+ return `${OneUptimeDate.getDateAsCustomFormattedStringInTimezone({
566
+ date,
567
+ format: "ddd D MMM HH:mm",
568
+ timezone,
569
+ })} ${timezone}`;
570
+ }
571
+ /** Distinct policy names, alphabetical, joined with ", ". */
572
+ static describePolicyNames(policies) {
573
+ return OnCallCalendarFeedUtil.getDistinctPolicies(policies)
574
+ .map((policy) => {
575
+ return policy.policyName;
576
+ })
577
+ .join(", ");
578
+ }
579
+ /** The ledger key = the UNIQUE index of UserOnCallShiftReminderLog. */
580
+ static ledgerKey(data) {
581
+ const start = UserOnCallShiftReminderLogServiceClass.truncateToMinute(data.shiftStartsAt).getTime();
582
+ return `${data.userId.toString()}|${data.scheduleId.toString()}|${start}|${data.minutesBeforeShift}|${data.kind}`;
583
+ }
584
+ /** Builds the regular reminder message for one shift and lead. */
585
+ static buildReminderMessage(data) {
586
+ const { shift } = data;
587
+ const whenText = OnCallShiftReminderRunner.formatInstant(shift.start, data.timezone);
588
+ const endsText = OnCallShiftReminderRunner.formatInstant(shift.end, data.timezone);
589
+ const remainingText = OnCallShiftReminderRunner.describeRemaining({
590
+ lead: data.lead,
591
+ start: shift.start,
592
+ now: data.now,
593
+ });
594
+ const policyNames = OnCallShiftReminderRunner.describePolicyNames(shift.policies);
595
+ const coveringFor = shift.override && shift.override.originalUserId !== shift.userId
596
+ ? shift.override.originalUserName
597
+ : null;
598
+ const coveringClause = coveringFor
599
+ ? ` (you are covering for ${coveringFor})`
600
+ : "";
601
+ const sentence = `Your on-call shift on ${shift.scheduleName} for ${policyNames} starts in ${remainingText} (${whenText})${coveringClause}.`;
602
+ const scheduleViewLink = OnCallCalendarFeedUtil.getScheduleUrl(data.dashboardUrl, shift.projectId, shift.scheduleId);
603
+ const vars = {
604
+ scheduleName: shift.scheduleName,
605
+ policyNames,
606
+ leadText: OnCallShiftReminderRunner.describeMinutes(data.lead),
607
+ remainingText,
608
+ startsAt: whenText,
609
+ endsAt: endsText,
610
+ timezone: data.timezone,
611
+ description: sentence,
612
+ coveringFor: coveringFor || "",
613
+ scheduleViewLink,
614
+ };
615
+ return {
616
+ subject: `Reminder: your on-call shift on ${shift.scheduleName} starts in ${remainingText}`,
617
+ text: `This is a message from OneUptime. ${sentence} To change these reminders go to User Settings in the OneUptime Dashboard.`,
618
+ pushTitle: "On-call shift reminder",
619
+ pushBody: `Your on-call shift on ${shift.scheduleName} starts in ${remainingText} (${whenText}).`,
620
+ vars,
621
+ templateType: EmailTemplateType.UserOnCallShiftReminder,
622
+ eventType: NotificationSettingEventType.SEND_BEFORE_USER_ON_CALL_SHIFT_STARTS,
623
+ timezone: data.timezone,
624
+ whenText,
625
+ };
626
+ }
627
+ /**
628
+ * The catch-up message: the same shape as a reminder, prefixed so it reads
629
+ * as the late notice it is.
630
+ *
631
+ * It deliberately does NOT say the shift "now" starts in X. A catch-up
632
+ * goes to anyone holding a shift inside one of their leads with no
633
+ * reminder row, and that includes shifts that did not move at all — the
634
+ * user configured the lead after its instant had passed, or the worker was
635
+ * down for it — so the message would claim a change that never happened
636
+ * the next time a colleague edits an unrelated layer on the schedule. What
637
+ * IS always true is that they have not been told yet; when the shift did
638
+ * change hands the covering clause says so.
639
+ */
640
+ static buildCatchUpMessage(data) {
641
+ const { shift } = data;
642
+ const whenText = OnCallShiftReminderRunner.formatInstant(shift.start, data.timezone);
643
+ const endsText = OnCallShiftReminderRunner.formatInstant(shift.end, data.timezone);
644
+ const remainingMinutes = (shift.start.getTime() - data.now.getTime()) / MILLISECONDS_PER_MINUTE;
645
+ const remainingText = OnCallShiftReminderRunner.describeMinutes(remainingMinutes);
646
+ const policyNames = OnCallShiftReminderRunner.describePolicyNames(shift.policies);
647
+ const coveringFor = shift.override && shift.override.originalUserId !== shift.userId
648
+ ? shift.override.originalUserName
649
+ : null;
650
+ const coveringClause = coveringFor
651
+ ? ` (you are covering for ${coveringFor})`
652
+ : "";
653
+ const sentence = `Heads up: your on-call shift on ${shift.scheduleName} for ${policyNames} starts in ${remainingText} (${whenText})${coveringClause}.`;
654
+ const scheduleViewLink = OnCallCalendarFeedUtil.getScheduleUrl(data.dashboardUrl, shift.projectId, shift.scheduleId);
655
+ const vars = {
656
+ scheduleName: shift.scheduleName,
657
+ policyNames,
658
+ leadText: remainingText,
659
+ remainingText,
660
+ startsAt: whenText,
661
+ endsAt: endsText,
662
+ timezone: data.timezone,
663
+ description: sentence,
664
+ coveringFor: coveringFor || "",
665
+ scheduleViewLink,
666
+ };
667
+ return {
668
+ subject: `Heads up: your on-call shift on ${shift.scheduleName} starts in ${remainingText}`,
669
+ text: `This is a message from OneUptime. ${sentence} To change these reminders go to User Settings in the OneUptime Dashboard.`,
670
+ pushTitle: "On-call shift reminder",
671
+ pushBody: `Your on-call shift on ${shift.scheduleName} starts in ${remainingText} (${whenText})${coveringClause}.`,
672
+ vars,
673
+ templateType: EmailTemplateType.UserOnCallShiftReminder,
674
+ eventType: NotificationSettingEventType.SEND_BEFORE_USER_ON_CALL_SHIFT_STARTS,
675
+ timezone: data.timezone,
676
+ whenText,
677
+ };
678
+ }
679
+ /** "Your shift on Payments at Thu 3 Sep 18:00 Europe/Berlin is now covered by Bob." */
680
+ static buildReassignedMessage(data) {
681
+ const whenText = OnCallShiftReminderRunner.formatInstant(data.shiftStartsAt, data.timezone);
682
+ const outcome = data.coveredBy
683
+ ? `is now covered by ${data.coveredBy}`
684
+ : "is no longer assigned to you";
685
+ const sentence = `Your on-call shift on ${data.scheduleName} at ${whenText} ${outcome}.`;
686
+ const scheduleViewLink = OnCallCalendarFeedUtil.getScheduleUrl(data.dashboardUrl, data.projectId, data.scheduleId);
687
+ const vars = {
688
+ scheduleName: data.scheduleName,
689
+ startsAt: whenText,
690
+ timezone: data.timezone,
691
+ coveredBy: data.coveredBy || "",
692
+ description: sentence,
693
+ scheduleViewLink,
694
+ };
695
+ return {
696
+ subject: `Your on-call shift on ${data.scheduleName} ${outcome}`,
697
+ text: `This is a message from OneUptime. ${sentence} To change these notices go to User Settings in the OneUptime Dashboard.`,
698
+ pushTitle: "On-call shift reassigned",
699
+ pushBody: sentence,
700
+ vars,
701
+ templateType: EmailTemplateType.UserOnCallShiftReassigned,
702
+ eventType: NotificationSettingEventType.SEND_WHEN_USER_ON_CALL_SHIFT_IS_REASSIGNED,
703
+ timezone: data.timezone,
704
+ whenText,
705
+ };
706
+ }
707
+ /** For tests: forget which (user, project, day) were already warned about. */
708
+ static resetMissingSettingsWarnings() {
709
+ OnCallShiftReminderRunner.missingSettingsWarned = new Set();
710
+ }
711
+ // -- Sweep internals ----------------------------------------------------
712
+ static async sweepProject(data) {
713
+ const { plan, now, lookbackFrom, stats } = data;
714
+ const windowEnd = OneUptimeDate.addRemoveMinutes(now, plan.maxLead + SHIFT_REMINDER_WINDOW_PADDING_MINUTES);
715
+ const userIds = Array.from(plan.users.values()).map((userPlan) => {
716
+ return userPlan.userId;
717
+ });
718
+ /*
719
+ * Candidate schedules of every reminded user in ONE batched lookup —
720
+ * two or three queries for the whole project rather than per user —
721
+ * and then a single materialization per tick.
722
+ */
723
+ const candidatesByUser = await OnCallShiftMaterializer.getCandidateScheduleIdsForUsers({
724
+ userIds,
725
+ projectIds: [plan.projectId],
726
+ windowStart: now,
727
+ windowEnd,
728
+ includeCoveringShifts: true,
729
+ });
730
+ const scheduleIds = [];
731
+ for (const candidates of candidatesByUser.values()) {
732
+ scheduleIds.push(...candidates);
733
+ }
734
+ const distinctScheduleIds = OnCallShiftReminderRunner.dedupeIds(scheduleIds);
735
+ if (distinctScheduleIds.length === 0) {
736
+ return;
737
+ }
738
+ const result = await OnCallShiftMaterializer.materializeForSchedules({
739
+ scheduleIds: distinctScheduleIds,
740
+ windowStart: now,
741
+ windowEnd,
742
+ now,
743
+ });
744
+ for (const schedule of result.schedules) {
745
+ if (schedule.truncated) {
746
+ stats.truncatedSchedules++;
747
+ logger.warn(`${SHIFT_REMINDER_JOB_NAME}: schedule ${schedule.scheduleId} hit the simulation cap; reminders for it may be incomplete this tick.`);
748
+ }
749
+ }
750
+ const ledger = OnCallShiftReminderRunner.buildLedger(await OnCallShiftReminderRunner.loadLedgerRows({
751
+ projectId: plan.projectId,
752
+ userIds,
753
+ from: now,
754
+ }));
755
+ const context = {
756
+ now,
757
+ projectId: plan.projectId,
758
+ dashboardUrl: data.dashboardUrl,
759
+ users: OnCallShiftReminderRunner.toUserMap(result.users),
760
+ };
761
+ for (const userPlan of plan.users.values()) {
762
+ try {
763
+ await OnCallShiftReminderRunner.sweepUser({
764
+ userPlan,
765
+ shifts: OnCallShiftMaterializer.filterShiftsForUser(result.shifts, userPlan.userId),
766
+ lookbackFrom,
767
+ ledger,
768
+ context,
769
+ stats,
770
+ });
771
+ }
772
+ catch (err) {
773
+ stats.errors++;
774
+ logger.error(`${SHIFT_REMINDER_JOB_NAME}: failed to process user ${userPlan.userId.toString()} in project ${plan.projectId.toString()}`);
775
+ logger.error(err);
776
+ }
777
+ }
778
+ }
779
+ static async sweepUser(data) {
780
+ const { userPlan, lookbackFrom, ledger, context, stats } = data;
781
+ const now = context.now;
782
+ for (const shift of data.shifts) {
783
+ stats.shiftsConsidered++;
784
+ for (const lead of userPlan.leads) {
785
+ if (!OnCallShiftReminderRunner.isDue({
786
+ start: shift.start,
787
+ lead,
788
+ now,
789
+ lookbackFrom,
790
+ })) {
791
+ continue;
792
+ }
793
+ // Lateness cap: never "starts in 15 minutes" after it started.
794
+ if (shift.start.getTime() <= now.getTime()) {
795
+ stats.skippedLate++;
796
+ OnCallShiftReminderRunner.recordMetric(ShiftReminderOutcome.SkippedLate);
797
+ continue;
798
+ }
799
+ // A schedule attached to no policy cannot page anyone.
800
+ if (shift.policies.length === 0) {
801
+ stats.skippedNoPolicy++;
802
+ OnCallShiftReminderRunner.recordMetric(ShiftReminderOutcome.SkippedNoPolicy);
803
+ continue;
804
+ }
805
+ await OnCallShiftReminderRunner.sendRegularReminder({
806
+ shift,
807
+ lead,
808
+ userPlan,
809
+ ledger,
810
+ context,
811
+ stats,
812
+ });
813
+ }
814
+ }
815
+ }
816
+ static async sendRegularReminder(data) {
817
+ const { shift, lead, userPlan, ledger, context, stats } = data;
818
+ /*
819
+ * Does a catch-up already cover this lead? The change pass keys its
820
+ * catch-up with the LARGEST matching lead, so an exact-key check alone
821
+ * would let a smaller lead fire a near-identical message minutes later
822
+ * (an override at T-17 with leads [60, 15]: "now starts in 17 minutes"
823
+ * from the catch-up, then "starts in 15 minutes" from the sweep). A
824
+ * catch-up claimed at or after this lead's instant — minus the same
825
+ * tolerance the "starts in" text uses — already WAS this lead's
826
+ * reminder; an older one (a catch-up at T-30 against a 15-minute lead)
827
+ * was not, and the configured reminder still goes out.
828
+ */
829
+ const shiftStartsAt = UserOnCallShiftReminderLogServiceClass.truncateToMinute(shift.start);
830
+ const coveredFrom = shiftStartsAt.getTime() -
831
+ (lead + SHIFT_REMINDER_LEAD_TEXT_TOLERANCE_MINUTES) *
832
+ MILLISECONDS_PER_MINUTE;
833
+ const coveredByCatchUp = ledger.rows.some((row) => {
834
+ return (row.kind === UserOnCallShiftReminderLogKind.CatchUp &&
835
+ row.userId === userPlan.userId.toString() &&
836
+ row.scheduleId === shift.scheduleId &&
837
+ row.shiftStartsAt.getTime() === shiftStartsAt.getTime() &&
838
+ row.claimedAt.getTime() >= coveredFrom);
839
+ });
840
+ if (coveredByCatchUp) {
841
+ stats.skippedAlreadySent++;
842
+ OnCallShiftReminderRunner.recordMetric(ShiftReminderOutcome.SkippedAlreadySent);
843
+ return;
844
+ }
845
+ const claim = await OnCallShiftReminderRunner.claim({
846
+ projectId: context.projectId,
847
+ userId: userPlan.userId,
848
+ scheduleId: shift.scheduleId,
849
+ shiftStartsAt: shift.start,
850
+ minutesBeforeShift: lead,
851
+ kind: UserOnCallShiftReminderLogKind.Reminder,
852
+ ledger,
853
+ now: context.now,
854
+ });
855
+ switch (claim.outcome) {
856
+ case "already-sent":
857
+ stats.skippedAlreadySent++;
858
+ OnCallShiftReminderRunner.recordMetric(ShiftReminderOutcome.SkippedAlreadySent);
859
+ return;
860
+ case "in-flight":
861
+ stats.skippedInFlight++;
862
+ OnCallShiftReminderRunner.recordMetric(ShiftReminderOutcome.SkippedInFlight);
863
+ return;
864
+ case "collision":
865
+ stats.claimCollisions++;
866
+ OnCallShiftReminderRunner.recordMetric(ShiftReminderOutcome.ClaimCollision);
867
+ return;
868
+ case "reclaimed":
869
+ stats.claimRetries++;
870
+ OnCallShiftReminderRunner.recordMetric(ShiftReminderOutcome.ClaimRetry);
871
+ break;
872
+ case "claimed":
873
+ break;
874
+ }
875
+ if (!claim.claimId) {
876
+ return;
877
+ }
878
+ // Another pass claimed the same shift from its own snapshot: it wins.
879
+ const conflicted = await OnCallShiftReminderRunner.yieldToConcurrentSibling({
880
+ projectId: context.projectId,
881
+ userId: userPlan.userId,
882
+ scheduleId: shift.scheduleId,
883
+ shiftStartsAt,
884
+ claimId: claim.claimId,
885
+ claimedAt: context.now,
886
+ ledger,
887
+ });
888
+ if (conflicted) {
889
+ stats.claimCollisions++;
890
+ OnCallShiftReminderRunner.recordMetric(ShiftReminderOutcome.ClaimCollision);
891
+ return;
892
+ }
893
+ const recipient = context.users.get(userPlan.userId.toString());
894
+ const message = OnCallShiftReminderRunner.buildReminderMessage({
895
+ shift,
896
+ lead,
897
+ now: context.now,
898
+ timezone: OnCallShiftReminderRunner.resolveTimezone({
899
+ userTimezone: recipient === null || recipient === void 0 ? void 0 : recipient.timezone,
900
+ scheduleTimezone: shift.scheduleTimezone,
901
+ }),
902
+ dashboardUrl: context.dashboardUrl,
903
+ });
904
+ const delivered = await OnCallShiftReminderRunner.deliver({
905
+ claimId: claim.claimId,
906
+ userId: userPlan.userId,
907
+ projectId: context.projectId,
908
+ scheduleId: new ObjectID(shift.scheduleId),
909
+ policies: shift.policies,
910
+ message,
911
+ now: context.now,
912
+ onMissingSettings: () => {
913
+ stats.missingSettings++;
914
+ },
915
+ });
916
+ if (delivered) {
917
+ stats.sent++;
918
+ OnCallShiftReminderRunner.recordMetric(ShiftReminderOutcome.Sent);
919
+ }
920
+ else {
921
+ stats.sendFailures++;
922
+ OnCallShiftReminderRunner.recordMetric(ShiftReminderOutcome.SendFailed);
923
+ }
924
+ }
925
+ // -- Change-pass internals ------------------------------------------------
926
+ static async sendCatchUps(data) {
927
+ const { userPlan, ledger, context, stats } = data;
928
+ const now = context.now;
929
+ const userKey = userPlan.userId.toString();
930
+ for (const shift of data.heldShifts) {
931
+ if (shift.policies.length === 0) {
932
+ continue;
933
+ }
934
+ const remainingMs = shift.start.getTime() - now.getTime();
935
+ const matchingLeads = userPlan.leads.filter((lead) => {
936
+ return remainingMs <= lead * MILLISECONDS_PER_MINUTE;
937
+ });
938
+ if (matchingLeads.length === 0) {
939
+ continue;
940
+ }
941
+ const lead = Math.max(...matchingLeads);
942
+ /*
943
+ * Already told about this shift (any lead, reminder or catch-up)?
944
+ * Then no catch-up — unless a LATER "reassigned" notice took it away
945
+ * again, in which case the user must hear that it is theirs after
946
+ * all.
947
+ */
948
+ const told = ledger.rows.filter((row) => {
949
+ return (row.userId === userKey &&
950
+ row.scheduleId === shift.scheduleId &&
951
+ row.shiftStartsAt.getTime() === shift.start.getTime() &&
952
+ (row.kind === UserOnCallShiftReminderLogKind.Reminder ||
953
+ row.kind === UserOnCallShiftReminderLogKind.CatchUp));
954
+ });
955
+ const reassignedRow = ledger.rows.find((row) => {
956
+ return (row.userId === userKey &&
957
+ row.scheduleId === shift.scheduleId &&
958
+ row.shiftStartsAt.getTime() === shift.start.getTime() &&
959
+ row.kind === UserOnCallShiftReminderLogKind.Reassigned);
960
+ });
961
+ const latestTold = told.reduce((max, row) => {
962
+ return Math.max(max, row.claimedAt.getTime());
963
+ }, 0);
964
+ const takenBackLater = reassignedRow !== undefined &&
965
+ reassignedRow.claimedAt.getTime() >= latestTold;
966
+ if (told.length > 0 && !takenBackLater) {
967
+ continue;
968
+ }
969
+ const claim = await OnCallShiftReminderRunner.claim({
970
+ projectId: context.projectId,
971
+ userId: userPlan.userId,
972
+ scheduleId: shift.scheduleId,
973
+ shiftStartsAt: shift.start,
974
+ minutesBeforeShift: lead,
975
+ kind: UserOnCallShiftReminderLogKind.CatchUp,
976
+ ledger,
977
+ now,
978
+ });
979
+ if (claim.outcome === "collision") {
980
+ stats.claimCollisions++;
981
+ }
982
+ if (!claim.claimId || claim.outcome === "already-sent") {
983
+ continue;
984
+ }
985
+ // A sweep tick claimed the same shift meanwhile: the older claim wins.
986
+ const conflicted = await OnCallShiftReminderRunner.yieldToConcurrentSibling({
987
+ projectId: context.projectId,
988
+ userId: userPlan.userId,
989
+ scheduleId: shift.scheduleId,
990
+ shiftStartsAt: UserOnCallShiftReminderLogServiceClass.truncateToMinute(shift.start),
991
+ claimId: claim.claimId,
992
+ claimedAt: now,
993
+ ledger,
994
+ });
995
+ if (conflicted) {
996
+ stats.claimCollisions++;
997
+ OnCallShiftReminderRunner.recordMetric(ShiftReminderOutcome.ClaimCollision);
998
+ continue;
999
+ }
1000
+ const recipient = context.users.get(userKey);
1001
+ const message = OnCallShiftReminderRunner.buildCatchUpMessage({
1002
+ shift,
1003
+ now,
1004
+ timezone: OnCallShiftReminderRunner.resolveTimezone({
1005
+ userTimezone: recipient === null || recipient === void 0 ? void 0 : recipient.timezone,
1006
+ scheduleTimezone: shift.scheduleTimezone,
1007
+ }),
1008
+ dashboardUrl: context.dashboardUrl,
1009
+ });
1010
+ const delivered = await OnCallShiftReminderRunner.deliver({
1011
+ claimId: claim.claimId,
1012
+ userId: userPlan.userId,
1013
+ projectId: context.projectId,
1014
+ scheduleId: new ObjectID(shift.scheduleId),
1015
+ policies: shift.policies,
1016
+ message,
1017
+ now,
1018
+ onMissingSettings: () => {
1019
+ stats.missingSettings++;
1020
+ },
1021
+ });
1022
+ if (!delivered) {
1023
+ stats.sendFailures++;
1024
+ continue;
1025
+ }
1026
+ stats.catchUpsSent++;
1027
+ OnCallShiftReminderRunner.recordMetric(ShiftReminderOutcome.CatchUpSent);
1028
+ // The shift is theirs again; let a future flip produce a fresh notice.
1029
+ if (reassignedRow && takenBackLater) {
1030
+ await OnCallShiftReminderRunner.deleteLedgerRow(reassignedRow.id);
1031
+ ledger.rows = ledger.rows.filter((row) => {
1032
+ return row.id.toString() !== reassignedRow.id.toString();
1033
+ });
1034
+ ledger.byKey.delete(OnCallShiftReminderRunner.ledgerKey({
1035
+ userId: reassignedRow.userId,
1036
+ scheduleId: reassignedRow.scheduleId,
1037
+ shiftStartsAt: reassignedRow.shiftStartsAt,
1038
+ minutesBeforeShift: reassignedRow.minutesBeforeShift,
1039
+ kind: reassignedRow.kind,
1040
+ }));
1041
+ }
1042
+ }
1043
+ }
1044
+ static async sendReassignedNotices(data) {
1045
+ const { ledger, context, stats } = data;
1046
+ const now = context.now;
1047
+ const userKey = data.userId.toString();
1048
+ // One notice per (schedule, start), however many leads were reminded.
1049
+ const seen = new Set();
1050
+ for (const row of ledger.rows) {
1051
+ if (row.userId !== userKey ||
1052
+ (row.kind !== UserOnCallShiftReminderLogKind.Reminder &&
1053
+ row.kind !== UserOnCallShiftReminderLogKind.CatchUp) ||
1054
+ row.shiftStartsAt.getTime() <= now.getTime()) {
1055
+ continue;
1056
+ }
1057
+ const shiftKey = `${row.scheduleId}|${row.shiftStartsAt.getTime()}`;
1058
+ if (seen.has(shiftKey)) {
1059
+ continue;
1060
+ }
1061
+ seen.add(shiftKey);
1062
+ const schedule = data.resolvedSchedules.get(row.scheduleId);
1063
+ // Not resolved this pass (or unreliable): cannot judge, say nothing.
1064
+ if (!schedule || schedule.truncated) {
1065
+ continue;
1066
+ }
1067
+ const stillHolds = data.heldShifts.some((shift) => {
1068
+ return (shift.scheduleId === row.scheduleId &&
1069
+ shift.start.getTime() === row.shiftStartsAt.getTime());
1070
+ });
1071
+ if (stillHolds) {
1072
+ continue;
1073
+ }
1074
+ /*
1075
+ * A reassigned notice newer than the last reminder/catch-up was
1076
+ * already sent for this shift.
1077
+ */
1078
+ const latestTold = ledger.rows.reduce((max, candidate) => {
1079
+ if (candidate.userId === userKey &&
1080
+ candidate.scheduleId === row.scheduleId &&
1081
+ candidate.shiftStartsAt.getTime() === row.shiftStartsAt.getTime() &&
1082
+ (candidate.kind === UserOnCallShiftReminderLogKind.Reminder ||
1083
+ candidate.kind === UserOnCallShiftReminderLogKind.CatchUp)) {
1084
+ return Math.max(max, candidate.claimedAt.getTime());
1085
+ }
1086
+ return max;
1087
+ }, 0);
1088
+ const existingNotice = ledger.byKey.get(OnCallShiftReminderRunner.ledgerKey({
1089
+ userId: userKey,
1090
+ scheduleId: row.scheduleId,
1091
+ shiftStartsAt: row.shiftStartsAt,
1092
+ minutesBeforeShift: 0,
1093
+ kind: UserOnCallShiftReminderLogKind.Reassigned,
1094
+ }));
1095
+ if (existingNotice && existingNotice.claimedAt.getTime() >= latestTold) {
1096
+ continue;
1097
+ }
1098
+ const claim = await OnCallShiftReminderRunner.claim({
1099
+ projectId: context.projectId,
1100
+ userId: data.userId,
1101
+ scheduleId: row.scheduleId,
1102
+ shiftStartsAt: row.shiftStartsAt,
1103
+ minutesBeforeShift: 0,
1104
+ kind: UserOnCallShiftReminderLogKind.Reassigned,
1105
+ ledger,
1106
+ now,
1107
+ });
1108
+ if (claim.outcome === "collision") {
1109
+ stats.claimCollisions++;
1110
+ }
1111
+ if (!claim.claimId || claim.outcome === "already-sent") {
1112
+ continue;
1113
+ }
1114
+ const replacement = data.allShifts.find((shift) => {
1115
+ return (shift.scheduleId === row.scheduleId &&
1116
+ shift.start.getTime() === row.shiftStartsAt.getTime() &&
1117
+ shift.userId !== userKey &&
1118
+ !shift.policyVariantOf);
1119
+ });
1120
+ const recipient = context.users.get(userKey);
1121
+ const message = OnCallShiftReminderRunner.buildReassignedMessage({
1122
+ scheduleName: schedule.scheduleName,
1123
+ projectId: schedule.projectId,
1124
+ scheduleId: schedule.scheduleId,
1125
+ shiftStartsAt: row.shiftStartsAt,
1126
+ coveredBy: replacement ? replacement.userName : null,
1127
+ timezone: OnCallShiftReminderRunner.resolveTimezone({
1128
+ userTimezone: recipient === null || recipient === void 0 ? void 0 : recipient.timezone,
1129
+ scheduleTimezone: schedule.scheduleTimezone,
1130
+ }),
1131
+ dashboardUrl: context.dashboardUrl,
1132
+ });
1133
+ const delivered = await OnCallShiftReminderRunner.deliver({
1134
+ claimId: claim.claimId,
1135
+ userId: data.userId,
1136
+ projectId: context.projectId,
1137
+ scheduleId: new ObjectID(schedule.scheduleId),
1138
+ policies: schedule.attachedPolicies,
1139
+ message,
1140
+ now,
1141
+ onMissingSettings: () => {
1142
+ stats.missingSettings++;
1143
+ },
1144
+ });
1145
+ if (!delivered) {
1146
+ stats.sendFailures++;
1147
+ continue;
1148
+ }
1149
+ stats.reassignedSent++;
1150
+ OnCallShiftReminderRunner.recordMetric(ShiftReminderOutcome.ReassignedSent);
1151
+ }
1152
+ }
1153
+ // -- Ledger -------------------------------------------------------------
1154
+ /**
1155
+ * Claim the ledger row for one (user, schedule, start, lead, kind):
1156
+ * insert with sentAt NULL; on a unique violation, look at the existing
1157
+ * row — sent means done, a fresh claim means another worker is on it, a
1158
+ * stale claim is re-claimed with a conditional update so exactly one
1159
+ * worker wins.
1160
+ */
1161
+ static async claim(data) {
1162
+ const shiftStartsAt = UserOnCallShiftReminderLogServiceClass.truncateToMinute(data.shiftStartsAt);
1163
+ const key = OnCallShiftReminderRunner.ledgerKey({
1164
+ userId: data.userId,
1165
+ scheduleId: data.scheduleId,
1166
+ shiftStartsAt,
1167
+ minutesBeforeShift: data.minutesBeforeShift,
1168
+ kind: data.kind,
1169
+ });
1170
+ const existing = data.ledger.byKey.get(key);
1171
+ if (existing) {
1172
+ return await OnCallShiftReminderRunner.reclaim(existing, data.now);
1173
+ }
1174
+ const row = new UserOnCallShiftReminderLog();
1175
+ row.projectId = data.projectId;
1176
+ row.userId = data.userId;
1177
+ row.onCallDutyPolicyScheduleId = new ObjectID(data.scheduleId);
1178
+ row.shiftStartsAt = shiftStartsAt;
1179
+ row.minutesBeforeShift = data.minutesBeforeShift;
1180
+ row.kind = data.kind;
1181
+ row.claimedAt = data.now;
1182
+ try {
1183
+ const created = await UserOnCallShiftReminderLogService.create({
1184
+ data: row,
1185
+ props: {
1186
+ isRoot: true,
1187
+ },
1188
+ });
1189
+ if (!created.id) {
1190
+ return { claimId: null, outcome: "collision" };
1191
+ }
1192
+ const ledgerRow = {
1193
+ id: created.id,
1194
+ userId: data.userId.toString(),
1195
+ scheduleId: data.scheduleId,
1196
+ shiftStartsAt,
1197
+ minutesBeforeShift: data.minutesBeforeShift,
1198
+ kind: data.kind,
1199
+ claimedAt: data.now,
1200
+ sentAt: null,
1201
+ };
1202
+ data.ledger.byKey.set(key, ledgerRow);
1203
+ data.ledger.rows.push(ledgerRow);
1204
+ data.ledger.knownIds.add(created.id.toString());
1205
+ return { claimId: created.id, outcome: "claimed" };
1206
+ }
1207
+ catch (err) {
1208
+ if (PostgresErrorTranslator.isUniqueViolation(err)) {
1209
+ return { claimId: null, outcome: "collision" };
1210
+ }
1211
+ throw err;
1212
+ }
1213
+ }
1214
+ static async reclaim(existing, now) {
1215
+ if (existing.sentAt) {
1216
+ return { claimId: null, outcome: "already-sent" };
1217
+ }
1218
+ const reclaimBefore = OneUptimeDate.addRemoveMinutes(now, SHIFT_REMINDER_RECLAIM_AFTER_MINUTES * -1);
1219
+ if (existing.claimedAt.getTime() > reclaimBefore.getTime()) {
1220
+ return { claimId: null, outcome: "in-flight" };
1221
+ }
1222
+ // Conditional: only the worker whose UPDATE matches the stale row wins.
1223
+ const updated = await UserOnCallShiftReminderLogService.updateOneBy({
1224
+ query: {
1225
+ _id: existing.id,
1226
+ sentAt: QueryHelper.isNull(),
1227
+ claimedAt: QueryHelper.lessThanEqualTo(reclaimBefore),
1228
+ },
1229
+ data: {
1230
+ claimedAt: now,
1231
+ },
1232
+ props: {
1233
+ isRoot: true,
1234
+ },
1235
+ });
1236
+ if (updated !== 1) {
1237
+ return { claimId: null, outcome: "in-flight" };
1238
+ }
1239
+ existing.claimedAt = now;
1240
+ return { claimId: existing.id, outcome: "reclaimed" };
1241
+ }
1242
+ static async stampSent(claimId, sentAt) {
1243
+ await UserOnCallShiftReminderLogService.updateOneById({
1244
+ id: claimId,
1245
+ data: {
1246
+ sentAt,
1247
+ },
1248
+ props: {
1249
+ isRoot: true,
1250
+ },
1251
+ });
1252
+ }
1253
+ static async deleteLedgerRow(claimId) {
1254
+ await UserOnCallShiftReminderLogService.deleteOneBy({
1255
+ query: {
1256
+ _id: claimId,
1257
+ },
1258
+ props: {
1259
+ isRoot: true,
1260
+ },
1261
+ });
1262
+ }
1263
+ /**
1264
+ * The one duplicate the UNIQUE index cannot catch: the sweep and a
1265
+ * hook-triggered change pass deciding about the SAME (user, schedule,
1266
+ * shift start) at the same moment, in two processes. Both decide from a
1267
+ * snapshot taken before either claimed anything, and `reminder|lead` and
1268
+ * `catch-up|lead` are different keys, so both inserts succeed and both
1269
+ * messages go out seconds apart.
1270
+ *
1271
+ * So after claiming, re-read this shift's rows: a reminder/catch-up row
1272
+ * this pass did not write (not in the snapshot, not claimed by it) means
1273
+ * somebody else is notifying about the same shift. The OLDER claim wins —
1274
+ * ties broken by id, so the two sides always agree on the winner — and
1275
+ * the loser releases its claim and says nothing. Returns true when THIS
1276
+ * pass is the loser.
1277
+ *
1278
+ * A failed re-read never blocks a reminder: it logs and sends.
1279
+ */
1280
+ static async yieldToConcurrentSibling(data) {
1281
+ const mine = data.claimId.toString();
1282
+ let rows = [];
1283
+ try {
1284
+ rows = await OnCallShiftReminderRunner.loadLedgerRows({
1285
+ projectId: data.projectId,
1286
+ userIds: [data.userId],
1287
+ scheduleIds: [new ObjectID(data.scheduleId)],
1288
+ from: data.shiftStartsAt,
1289
+ });
1290
+ }
1291
+ catch (err) {
1292
+ logger.warn(`${SHIFT_REMINDER_JOB_NAME}: could not re-read the ledger after claiming ${mine}; sending anyway: ${err}`);
1293
+ return false;
1294
+ }
1295
+ const winner = rows.find((row) => {
1296
+ const id = row.id.toString();
1297
+ if (id === mine ||
1298
+ data.ledger.knownIds.has(id) ||
1299
+ row.shiftStartsAt.getTime() !== data.shiftStartsAt.getTime() ||
1300
+ (row.kind !== UserOnCallShiftReminderLogKind.Reminder &&
1301
+ row.kind !== UserOnCallShiftReminderLogKind.CatchUp)) {
1302
+ return false;
1303
+ }
1304
+ return (row.claimedAt.getTime() < data.claimedAt.getTime() ||
1305
+ (row.claimedAt.getTime() === data.claimedAt.getTime() && id < mine));
1306
+ });
1307
+ if (!winner) {
1308
+ return false;
1309
+ }
1310
+ logger.debug(`${SHIFT_REMINDER_JOB_NAME}: another pass is already notifying user ${data.userId.toString()} about the shift on schedule ${data.scheduleId} starting ${data.shiftStartsAt.toISOString()}; releasing claim ${mine}.`);
1311
+ await OnCallShiftReminderRunner.releaseClaim(data.claimId, data.ledger);
1312
+ return true;
1313
+ }
1314
+ /** Delete a claim this pass made and forget it, best-effort. */
1315
+ static async releaseClaim(claimId, ledger) {
1316
+ try {
1317
+ await OnCallShiftReminderRunner.deleteLedgerRow(claimId);
1318
+ }
1319
+ catch (err) {
1320
+ logger.error(`${SHIFT_REMINDER_JOB_NAME}: could not release claim ${claimId.toString()}; it becomes re-claimable after ${SHIFT_REMINDER_RECLAIM_AFTER_MINUTES} minutes.`);
1321
+ logger.error(err);
1322
+ }
1323
+ const id = claimId.toString();
1324
+ ledger.rows = ledger.rows.filter((row) => {
1325
+ return row.id.toString() !== id;
1326
+ });
1327
+ for (const [key, row] of ledger.byKey) {
1328
+ if (row.id.toString() === id) {
1329
+ ledger.byKey.delete(key);
1330
+ }
1331
+ }
1332
+ }
1333
+ /**
1334
+ * Send through the user's notification settings, then stamp the claim.
1335
+ * A thrown send deletes the claim so the next tick retries; returns
1336
+ * whether the row was stamped.
1337
+ */
1338
+ static async deliver(data) {
1339
+ const { message } = data;
1340
+ try {
1341
+ const hasSettings = await OnCallShiftReminderRunner.warnIfSettingsMissing({
1342
+ userId: data.userId,
1343
+ projectId: data.projectId,
1344
+ eventType: message.eventType,
1345
+ now: data.now,
1346
+ });
1347
+ if (!hasSettings) {
1348
+ data.onMissingSettings();
1349
+ OnCallShiftReminderRunner.recordMetric(ShiftReminderOutcome.MissingSettings);
1350
+ }
1351
+ const emailEnvelope = {
1352
+ templateType: message.templateType,
1353
+ vars: message.vars,
1354
+ subject: message.subject,
1355
+ };
1356
+ const smsMessage = {
1357
+ message: message.text,
1358
+ };
1359
+ const callRequestMessage = {
1360
+ data: [
1361
+ {
1362
+ sayMessage: `${message.text} Good bye.`,
1363
+ },
1364
+ ],
1365
+ };
1366
+ const pushNotificationMessage = PushNotificationUtil.createGenericNotification({
1367
+ title: message.pushTitle,
1368
+ body: message.pushBody,
1369
+ clickAction: message.vars["scheduleViewLink"] || "",
1370
+ tag: "on-call-shift-reminder",
1371
+ requireInteraction: false,
1372
+ });
1373
+ const whatsAppMessage = OnCallShiftReminderRunner.buildWhatsAppMessage(message);
1374
+ const firstPolicy = data.policies[0];
1375
+ await UserNotificationSettingService.sendUserNotification({
1376
+ userId: data.userId,
1377
+ projectId: data.projectId,
1378
+ eventType: message.eventType,
1379
+ emailEnvelope,
1380
+ smsMessage,
1381
+ callRequestMessage,
1382
+ pushNotificationMessage,
1383
+ whatsAppMessage,
1384
+ onCallScheduleId: data.scheduleId,
1385
+ onCallPolicyId: firstPolicy
1386
+ ? new ObjectID(firstPolicy.policyId)
1387
+ : undefined,
1388
+ onCallPolicyEscalationRuleId: firstPolicy
1389
+ ? new ObjectID(firstPolicy.ruleId)
1390
+ : undefined,
1391
+ });
1392
+ }
1393
+ catch (err) {
1394
+ logger.error(`${SHIFT_REMINDER_JOB_NAME}: sending "${message.eventType}" to user ${data.userId.toString()} failed; releasing the claim so the next tick retries.`);
1395
+ logger.error(err);
1396
+ try {
1397
+ await OnCallShiftReminderRunner.deleteLedgerRow(data.claimId);
1398
+ }
1399
+ catch (deleteErr) {
1400
+ logger.error(`${SHIFT_REMINDER_JOB_NAME}: could not release claim ${data.claimId.toString()}; it becomes re-claimable after ${SHIFT_REMINDER_RECLAIM_AFTER_MINUTES} minutes.`);
1401
+ logger.error(deleteErr);
1402
+ }
1403
+ return false;
1404
+ }
1405
+ /*
1406
+ * The message is out. A failed stamp (a database blip between the send
1407
+ * and the UPDATE) must not throw: it would abort the rest of this
1408
+ * user's tick — every other shift and lead they are due — over a
1409
+ * notification that WAS delivered. Log it instead; the claim row stays
1410
+ * unstamped and may be re-claimed later, which is the safe direction.
1411
+ */
1412
+ try {
1413
+ await OnCallShiftReminderRunner.stampSent(data.claimId, OneUptimeDate.getCurrentDate());
1414
+ }
1415
+ catch (err) {
1416
+ logger.error(`${SHIFT_REMINDER_JOB_NAME}: "${message.eventType}" was delivered to user ${data.userId.toString()}, but claim ${data.claimId.toString()} could not be stamped as sent; it may be re-sent after ${SHIFT_REMINDER_RECLAIM_AFTER_MINUTES} minutes.`);
1417
+ logger.error(err);
1418
+ }
1419
+ return true;
1420
+ }
1421
+ /**
1422
+ * WhatsApp delivers Meta-approved TEMPLATE messages only: a body-only
1423
+ * payload is rejected by the notification service before it ever reaches
1424
+ * Meta, which leaves a failed WhatsAppLog row and delivers nothing. No
1425
+ * template exists for the two new event types, and registering one is a
1426
+ * Meta approval rather than a code change — so:
1427
+ *
1428
+ * - a shift reminder (and its catch-up) reuses the approved
1429
+ * "you are next on-call for <policy> on <schedule>" template, whose
1430
+ * wording fits it exactly;
1431
+ * - a reassignment, which no approved template describes, sends no
1432
+ * WhatsApp payload at all, so the notification service skips the
1433
+ * channel cleanly instead of failing it.
1434
+ *
1435
+ * Email, SMS, call and push always carry the full text either way.
1436
+ */
1437
+ static buildWhatsAppMessage(message) {
1438
+ if (message.eventType !==
1439
+ NotificationSettingEventType.SEND_BEFORE_USER_ON_CALL_SHIFT_STARTS) {
1440
+ return undefined;
1441
+ }
1442
+ const scheduleLink = message.vars["scheduleViewLink"] || "";
1443
+ try {
1444
+ return createWhatsAppMessageFromTemplate({
1445
+ templateKey: WhatsAppTemplateIds.OnCallUserIsNextNotification,
1446
+ actionLink: scheduleLink || undefined,
1447
+ templateVariables: {
1448
+ on_call_policy_name: message.vars["policyNames"] || "",
1449
+ schedule_name: message.vars["scheduleName"] || "",
1450
+ schedule_link: scheduleLink,
1451
+ },
1452
+ });
1453
+ }
1454
+ catch (err) {
1455
+ logger.warn(`${SHIFT_REMINDER_JOB_NAME}: could not build the WhatsApp template message for "${message.eventType}"; the channel is skipped: ${err}`);
1456
+ return undefined;
1457
+ }
1458
+ }
1459
+ /**
1460
+ * sendUserNotification silently sends nothing without a
1461
+ * UserNotificationSetting row. The DataMigration backfills one for every
1462
+ * member, so this should be unreachable — log loudly (once per user, project
1463
+ * and day) if it is not, so a silent zero is impossible.
1464
+ */
1465
+ static async warnIfSettingsMissing(data) {
1466
+ const setting = await UserNotificationSettingService.findOneBy({
1467
+ query: {
1468
+ userId: data.userId,
1469
+ projectId: data.projectId,
1470
+ eventType: data.eventType,
1471
+ },
1472
+ select: {
1473
+ _id: true,
1474
+ },
1475
+ props: {
1476
+ isRoot: true,
1477
+ },
1478
+ });
1479
+ if (setting) {
1480
+ return true;
1481
+ }
1482
+ const day = data.now.toISOString().slice(0, 10);
1483
+ const warnKey = `${data.userId.toString()}|${data.projectId.toString()}|${data.eventType}|${day}`;
1484
+ if (!OnCallShiftReminderRunner.missingSettingsWarned.has(warnKey)) {
1485
+ // Keep the set bounded: forget other days' entries.
1486
+ for (const key of OnCallShiftReminderRunner.missingSettingsWarned) {
1487
+ if (!key.endsWith(`|${day}`)) {
1488
+ OnCallShiftReminderRunner.missingSettingsWarned.delete(key);
1489
+ }
1490
+ }
1491
+ OnCallShiftReminderRunner.missingSettingsWarned.add(warnKey);
1492
+ logger.warn(`${SHIFT_REMINDER_JOB_NAME}: user ${data.userId.toString()} in project ${data.projectId.toString()} has no UserNotificationSetting row for "${data.eventType}", so nothing will be delivered. Run the AddShiftReminderNotificationSettingsForUsers data migration (or have the user re-save their notification settings).`);
1493
+ }
1494
+ return false;
1495
+ }
1496
+ static async loadLedgerRows(data) {
1497
+ var _a;
1498
+ if ((data.userIds && data.userIds.length === 0) ||
1499
+ (data.scheduleIds && data.scheduleIds.length === 0)) {
1500
+ return [];
1501
+ }
1502
+ const query = {
1503
+ projectId: data.projectId,
1504
+ shiftStartsAt: QueryHelper.greaterThanEqualTo(data.from),
1505
+ };
1506
+ if (data.userIds) {
1507
+ query["userId"] = QueryHelper.any(data.userIds);
1508
+ }
1509
+ if (data.scheduleIds) {
1510
+ query["onCallDutyPolicyScheduleId"] = QueryHelper.any(data.scheduleIds);
1511
+ }
1512
+ const rows = await UserOnCallShiftReminderLogService.findBy({
1513
+ query: query,
1514
+ select: {
1515
+ _id: true,
1516
+ userId: true,
1517
+ onCallDutyPolicyScheduleId: true,
1518
+ shiftStartsAt: true,
1519
+ minutesBeforeShift: true,
1520
+ kind: true,
1521
+ claimedAt: true,
1522
+ sentAt: true,
1523
+ },
1524
+ limit: LIMIT_MAX,
1525
+ skip: 0,
1526
+ props: {
1527
+ isRoot: true,
1528
+ },
1529
+ });
1530
+ const ledgerRows = [];
1531
+ for (const row of rows) {
1532
+ if (!row.id ||
1533
+ !row.userId ||
1534
+ !row.onCallDutyPolicyScheduleId ||
1535
+ !row.shiftStartsAt ||
1536
+ !row.kind) {
1537
+ continue;
1538
+ }
1539
+ ledgerRows.push({
1540
+ id: row.id,
1541
+ userId: row.userId.toString(),
1542
+ scheduleId: row.onCallDutyPolicyScheduleId.toString(),
1543
+ shiftStartsAt: UserOnCallShiftReminderLogServiceClass.truncateToMinute(OneUptimeDate.fromString(row.shiftStartsAt)),
1544
+ minutesBeforeShift: (_a = row.minutesBeforeShift) !== null && _a !== void 0 ? _a : 0,
1545
+ kind: row.kind,
1546
+ claimedAt: row.claimedAt
1547
+ ? OneUptimeDate.fromString(row.claimedAt)
1548
+ : new Date(0),
1549
+ sentAt: row.sentAt ? OneUptimeDate.fromString(row.sentAt) : null,
1550
+ });
1551
+ }
1552
+ return ledgerRows;
1553
+ }
1554
+ static buildLedger(rows) {
1555
+ const byKey = new Map();
1556
+ const knownIds = new Set();
1557
+ for (const row of rows) {
1558
+ byKey.set(OnCallShiftReminderRunner.ledgerKey({
1559
+ userId: row.userId,
1560
+ scheduleId: row.scheduleId,
1561
+ shiftStartsAt: row.shiftStartsAt,
1562
+ minutesBeforeShift: row.minutesBeforeShift,
1563
+ kind: row.kind,
1564
+ }), row);
1565
+ knownIds.add(row.id.toString());
1566
+ }
1567
+ return { byKey, rows: [...rows], knownIds };
1568
+ }
1569
+ // -- Plans ----------------------------------------------------------------
1570
+ static buildProjectPlans(reminders) {
1571
+ const plans = new Map();
1572
+ for (const reminder of reminders) {
1573
+ if (!reminder.projectId ||
1574
+ !reminder.userId ||
1575
+ typeof reminder.minutesBeforeShift !== "number" ||
1576
+ !Number.isFinite(reminder.minutesBeforeShift) ||
1577
+ reminder.minutesBeforeShift <= 0) {
1578
+ continue;
1579
+ }
1580
+ const projectKey = reminder.projectId.toString();
1581
+ const userKey = reminder.userId.toString();
1582
+ let plan = plans.get(projectKey);
1583
+ if (!plan) {
1584
+ plan = {
1585
+ projectId: reminder.projectId,
1586
+ users: new Map(),
1587
+ maxLead: 0,
1588
+ };
1589
+ plans.set(projectKey, plan);
1590
+ }
1591
+ let userPlan = plan.users.get(userKey);
1592
+ if (!userPlan) {
1593
+ userPlan = {
1594
+ userId: reminder.userId,
1595
+ projectId: reminder.projectId,
1596
+ leads: [],
1597
+ maxLead: 0,
1598
+ };
1599
+ plan.users.set(userKey, userPlan);
1600
+ }
1601
+ const lead = Math.round(reminder.minutesBeforeShift);
1602
+ if (!userPlan.leads.includes(lead)) {
1603
+ userPlan.leads.push(lead);
1604
+ userPlan.leads.sort((a, b) => {
1605
+ return b - a;
1606
+ });
1607
+ }
1608
+ userPlan.maxLead = Math.max(userPlan.maxLead, lead);
1609
+ plan.maxLead = Math.max(plan.maxLead, lead);
1610
+ }
1611
+ return plans;
1612
+ }
1613
+ // -- Misc -----------------------------------------------------------------
1614
+ static async resolveProjectIdFromSchedules(scheduleIds) {
1615
+ const ids = OnCallShiftReminderRunner.dedupeIds(scheduleIds);
1616
+ if (ids.length === 0) {
1617
+ return null;
1618
+ }
1619
+ const schedules = await OnCallDutyPolicyScheduleService.findBy({
1620
+ query: {
1621
+ _id: QueryHelper.any(ids),
1622
+ },
1623
+ select: {
1624
+ _id: true,
1625
+ projectId: true,
1626
+ },
1627
+ limit: LIMIT_PER_PROJECT,
1628
+ skip: 0,
1629
+ props: {
1630
+ isRoot: true,
1631
+ },
1632
+ });
1633
+ for (const schedule of schedules) {
1634
+ if (schedule.projectId) {
1635
+ return schedule.projectId;
1636
+ }
1637
+ }
1638
+ return null;
1639
+ }
1640
+ static async getDashboardUrl() {
1641
+ try {
1642
+ const url = await DatabaseConfig.getDashboardUrl();
1643
+ return url.toString();
1644
+ }
1645
+ catch (err) {
1646
+ logger.warn(`${SHIFT_REMINDER_JOB_NAME}: could not resolve the dashboard URL; links in reminders will be relative: ${err}`);
1647
+ return "/dashboard";
1648
+ }
1649
+ }
1650
+ /**
1651
+ * The materializer only looks up users it can see in the resolution: the
1652
+ * segment holders, the parties of an override, and the schedule's CURRENT
1653
+ * layer members. A user who was just removed from the layer — or from the
1654
+ * project — is none of those, yet they are exactly who a "reassigned"
1655
+ * notice goes to. Without them the notice falls back to the SCHEDULE's
1656
+ * timezone, so a Berlin engineer would read a New York wall clock. One
1657
+ * root lookup fills in the recipients that are missing AND actually have a
1658
+ * ledger row (nobody else can receive a notice), so the common pass adds
1659
+ * no query at all.
1660
+ */
1661
+ static async backfillRecipients(data) {
1662
+ var _a, _b, _c, _d;
1663
+ const missing = data.userIds.filter((userId) => {
1664
+ const key = userId.toString();
1665
+ if (data.context.users.has(key)) {
1666
+ return false;
1667
+ }
1668
+ return data.ledger.rows.some((row) => {
1669
+ return row.userId === key;
1670
+ });
1671
+ });
1672
+ if (missing.length === 0) {
1673
+ return;
1674
+ }
1675
+ try {
1676
+ const rows = await UserService.findBy({
1677
+ query: {
1678
+ _id: QueryHelper.any(missing),
1679
+ },
1680
+ select: {
1681
+ _id: true,
1682
+ name: true,
1683
+ email: true,
1684
+ timezone: true,
1685
+ },
1686
+ limit: LIMIT_PER_PROJECT,
1687
+ skip: 0,
1688
+ props: {
1689
+ isRoot: true,
1690
+ },
1691
+ });
1692
+ for (const row of rows) {
1693
+ const userId = (_a = row.id) === null || _a === void 0 ? void 0 : _a.toString();
1694
+ if (!userId) {
1695
+ continue;
1696
+ }
1697
+ const name = ((_b = row.name) === null || _b === void 0 ? void 0 : _b.toString().trim()) || "";
1698
+ const email = ((_c = row.email) === null || _c === void 0 ? void 0 : _c.toString().trim()) || "";
1699
+ const timezone = ((_d = row.timezone) === null || _d === void 0 ? void 0 : _d.toString()) || "";
1700
+ const info = {
1701
+ userId,
1702
+ userName: name || email || OnCallCalendarFeedUtil.FALLBACK_USER_NAME,
1703
+ };
1704
+ if (email) {
1705
+ info.email = email;
1706
+ }
1707
+ if (timezone) {
1708
+ info.timezone = timezone;
1709
+ }
1710
+ data.context.users.set(userId, info);
1711
+ }
1712
+ }
1713
+ catch (err) {
1714
+ logger.warn(`${SHIFT_REMINDER_LISTENER_NAME}: could not load ${missing.length} notice recipient(s); their messages fall back to the schedule's timezone: ${err}`);
1715
+ }
1716
+ }
1717
+ static toUserMap(users) {
1718
+ const map = new Map();
1719
+ for (const user of users) {
1720
+ map.set(user.userId, user);
1721
+ }
1722
+ return map;
1723
+ }
1724
+ static dedupeIds(ids) {
1725
+ const seen = new Set();
1726
+ const result = [];
1727
+ for (const id of ids) {
1728
+ if (!id) {
1729
+ continue;
1730
+ }
1731
+ const key = id.toString();
1732
+ if (!key || seen.has(key)) {
1733
+ continue;
1734
+ }
1735
+ seen.add(key);
1736
+ result.push(id);
1737
+ }
1738
+ return result;
1739
+ }
1740
+ static recordMetric(outcome) {
1741
+ try {
1742
+ if (!OnCallShiftReminderRunner.counter) {
1743
+ OnCallShiftReminderRunner.counter = Telemetry.getCounter({
1744
+ name: METRIC_NAME,
1745
+ description: "On-call shift reminders and change notices by outcome (sent, skipped late, claim retry, ...).",
1746
+ unit: "1",
1747
+ });
1748
+ }
1749
+ OnCallShiftReminderRunner.counter.add(1, {
1750
+ [METRIC_OUTCOME_ATTRIBUTE]: outcome,
1751
+ });
1752
+ }
1753
+ catch (_a) {
1754
+ // Metrics are best-effort; a reminder must never fail on telemetry.
1755
+ }
1756
+ }
1757
+ }
1758
+ OnCallShiftReminderRunner.counter = null;
1759
+ // (userId|projectId|YYYY-MM-DD) already warned about a missing settings row.
1760
+ OnCallShiftReminderRunner.missingSettingsWarned = new Set();
1761
+ export default OnCallShiftReminderRunner;
1762
+ __decorate([
1763
+ CaptureSpan(),
1764
+ __metadata("design:type", Function),
1765
+ __metadata("design:paramtypes", [Object]),
1766
+ __metadata("design:returntype", Promise)
1767
+ ], OnCallShiftReminderRunner, "runSweepUnderLock", null);
1768
+ __decorate([
1769
+ CaptureSpan(),
1770
+ __metadata("design:type", Function),
1771
+ __metadata("design:paramtypes", [Object]),
1772
+ __metadata("design:returntype", Promise)
1773
+ ], OnCallShiftReminderRunner, "runSweep", null);
1774
+ __decorate([
1775
+ CaptureSpan(),
1776
+ __metadata("design:type", Function),
1777
+ __metadata("design:paramtypes", [Object]),
1778
+ __metadata("design:returntype", Promise)
1779
+ ], OnCallShiftReminderRunner, "deleteOldLogs", null);
1780
+ __decorate([
1781
+ CaptureSpan(),
1782
+ __metadata("design:type", Function),
1783
+ __metadata("design:paramtypes", [Object, Object]),
1784
+ __metadata("design:returntype", Promise)
1785
+ ], OnCallShiftReminderRunner, "runChangePass", null);
1786
+ //# sourceMappingURL=OnCallShiftReminderRunner.js.map