@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.
- package/Models/DatabaseModels/Index.ts +11 -0
- package/Models/DatabaseModels/OnCallDutyPolicySchedule.ts +43 -0
- package/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.ts +662 -0
- package/Models/DatabaseModels/ProjectOnCallCalendarFeed.ts +584 -0
- package/Models/DatabaseModels/UserOnCallCalendarFeed.ts +546 -0
- package/Models/DatabaseModels/UserOnCallShiftReminder.ts +231 -0
- package/Models/DatabaseModels/UserOnCallShiftReminderLog.ts +331 -0
- package/Server/API/OnCallCalendarAPI.ts +2194 -0
- package/Server/EnvironmentConfig.ts +138 -0
- package/Server/Infrastructure/OnCallCalendarFeedCache.ts +1050 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1790400000000-AddOnCallCalendarFeeds.ts +253 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
- package/Server/Middleware/OnCallCalendarFeedRateLimit.ts +487 -0
- package/Server/Services/Index.ts +12 -0
- package/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.ts +32 -0
- package/Server/Services/OnCallDutyPolicyScheduleCalendarFeedService.ts +260 -0
- package/Server/Services/OnCallDutyPolicyScheduleLayerService.ts +70 -3
- package/Server/Services/OnCallDutyPolicyScheduleLayerUserService.ts +138 -3
- package/Server/Services/OnCallDutyPolicyScheduleService.ts +1465 -134
- package/Server/Services/OnCallDutyPolicyUserOverrideService.ts +167 -31
- package/Server/Services/ProjectOnCallCalendarFeedService.ts +192 -0
- package/Server/Services/TeamMemberService.ts +489 -1
- package/Server/Services/TeamService.ts +50 -0
- package/Server/Services/UserNotificationSettingService.ts +44 -1
- package/Server/Services/UserOnCallCalendarFeedService.ts +159 -0
- package/Server/Services/UserOnCallShiftReminderLogService.ts +151 -0
- package/Server/Services/UserOnCallShiftReminderService.ts +112 -0
- package/Server/Utils/OnCall/CalendarFeedToken.ts +241 -0
- package/Server/Utils/OnCall/OnCallCalendarFeedRenderer.ts +1488 -0
- package/Server/Utils/OnCall/OnCallCalendarFeedUrls.ts +230 -0
- package/Server/Utils/OnCall/OnCallShiftChangeListeners.ts +227 -0
- package/Server/Utils/OnCall/OnCallShiftMaterializer.ts +1127 -0
- package/Server/Utils/OnCall/OnCallShiftReminderListener.ts +174 -0
- package/Server/Utils/OnCall/OnCallShiftReminderRunner.ts +2584 -0
- package/Server/Utils/Response.ts +134 -0
- package/Server/Utils/StartServer.ts +10 -0
- package/Server/Utils/Telemetry/AppMetrics.ts +51 -0
- package/Tests/App/Dashboard/OnCallCalendarFeedLinks.test.tsx +229 -0
- package/Tests/App/Dashboard/OnCallCalendarFeedPlanGate.test.tsx +489 -0
- package/Tests/App/Dashboard/OnCallCalendarFeedSideMenus.test.tsx +149 -0
- package/Tests/App/Dashboard/OnCallCalendarFeedUtil.test.ts +855 -0
- package/Tests/App/Dashboard/OnCallPersonalCalendarFeed.test.tsx +1209 -0
- package/Tests/App/Dashboard/OnCallSharedCalendarFeedCard.test.tsx +1019 -0
- package/Tests/App/Dashboard/OnCallShiftRemindersAndUpcomingShifts.test.tsx +655 -0
- package/Tests/App/Dashboard/UserSettingsSetupChecklistModel.test.ts +73 -0
- package/Tests/Models/OnCallCalendarFeedModels.test.ts +642 -0
- package/Tests/Models/OnCallDutyPolicyScheduleCalendarFeed.test.ts +288 -0
- package/Tests/Models/OnCallDutyPolicyScheduleShiftConfigVersion.test.ts +88 -0
- package/Tests/Models/UserOnCallShiftReminderModels.test.ts +368 -0
- package/Tests/Server/API/Helpers.ts +8 -0
- package/Tests/Server/API/OnCallCalendarAPI.test.ts +4461 -0
- package/Tests/Server/EnvironmentConfigOnCallCalendarFeed.test.ts +357 -0
- package/Tests/Server/Infrastructure/OnCallCalendarFeedCache.test.ts +1589 -0
- package/Tests/Server/Infrastructure/Postgres/AddOnCallCalendarFeedsMigration.test.ts +369 -0
- package/Tests/Server/Middleware/OnCallCalendarFeedRateLimit.test.ts +1365 -0
- package/Tests/Server/Services/FeedRetentionConsistency.test.ts +70 -0
- package/Tests/Server/Services/OnCallCalendarFeedServices.test.ts +1103 -0
- package/Tests/Server/Services/OnCallDutyPolicyScheduleResolver.test.ts +1037 -0
- package/Tests/Server/Services/OnCallShiftConfigPropagation.test.ts +1270 -0
- package/Tests/Server/Services/TeamDeleteMemberCleanup.test.ts +156 -0
- package/Tests/Server/Services/TeamMemberOnCallCleanup.test.ts +755 -0
- package/Tests/Server/Services/UserNotificationSettingShiftReminderDefaults.test.ts +267 -0
- package/Tests/Server/Services/UserOnCallShiftReminderServices.test.ts +583 -0
- package/Tests/Server/Types/Database/Permissions/OnCallCalendarFeedPermissions.test.ts +254 -0
- package/Tests/Server/Utils/OnCall/CalendarFeedToken.test.ts +467 -0
- package/Tests/Server/Utils/OnCall/OnCallCalendarFeedRenderer.test.ts +2142 -0
- package/Tests/Server/Utils/OnCall/OnCallCalendarFeedUrls.test.ts +278 -0
- package/Tests/Server/Utils/OnCall/OnCallResolverTestHarness.ts +440 -0
- package/Tests/Server/Utils/OnCall/OnCallShiftChangeListeners.test.ts +314 -0
- package/Tests/Server/Utils/OnCall/OnCallShiftMaterializer.test.ts +1502 -0
- package/Tests/Server/Utils/OnCall/OnCallShiftReminderListener.test.ts +491 -0
- package/Tests/Server/Utils/OnCall/OnCallShiftReminderRunner.test.ts +3088 -0
- package/Tests/Server/Utils/OnCall/OnCallShiftReminderTestHarness.ts +910 -0
- package/Tests/Server/Utils/ResponseSendCalendarResponse.test.ts +561 -0
- package/Tests/Server/Utils/StartServerEncryptionSecretWarning.test.ts +64 -0
- package/Tests/Server/Utils/Telemetry/AppMetrics.test.ts +29 -0
- package/Tests/Types/Calendar/ICalendar.test.ts +488 -0
- package/Tests/Types/NotificationSetting/NotificationSettingEventType.test.ts +53 -0
- package/Tests/Types/OnCallDutyPolicy/CalendarFeedTestFixtures.ts +310 -0
- package/Tests/Types/OnCallDutyPolicy/CalendarFeedWindow.test.ts +205 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilLayerMetaAndIterationCap.test.ts +458 -0
- package/Tests/Types/OnCallDutyPolicy/MaterializedShift.test.ts +197 -0
- package/Tests/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.test.ts +1986 -0
- package/Tests/Types/OnCallDutyPolicy/ScheduleShiftUtilGroupKey.test.ts +678 -0
- package/Tests/Types/OnCallDutyPolicy/ShiftSeamUtil.test.ts +417 -0
- package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +10 -0
- package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +2 -0
- package/Types/Calendar/ICalendar.ts +442 -0
- package/Types/Email/EmailTemplateType.ts +2 -0
- package/Types/NotificationSetting/NotificationSettingEventType.ts +4 -0
- package/Types/OnCallDutyPolicy/CalendarFeedWindow.ts +140 -0
- package/Types/OnCallDutyPolicy/Layer.ts +190 -35
- package/Types/OnCallDutyPolicy/MaterializedShift.ts +274 -0
- package/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.ts +1415 -0
- package/Types/OnCallDutyPolicy/ScheduleShiftUtil.ts +159 -4
- package/Types/OnCallDutyPolicy/ShiftSeamUtil.ts +106 -0
- package/build/dist/Models/DatabaseModels/Index.js +11 -0
- package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js +44 -0
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js.map +1 -1
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.js +699 -0
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.js.map +1 -0
- package/build/dist/Models/DatabaseModels/ProjectOnCallCalendarFeed.js +620 -0
- package/build/dist/Models/DatabaseModels/ProjectOnCallCalendarFeed.js.map +1 -0
- package/build/dist/Models/DatabaseModels/UserOnCallCalendarFeed.js +585 -0
- package/build/dist/Models/DatabaseModels/UserOnCallCalendarFeed.js.map +1 -0
- package/build/dist/Models/DatabaseModels/UserOnCallShiftReminder.js +247 -0
- package/build/dist/Models/DatabaseModels/UserOnCallShiftReminder.js.map +1 -0
- package/build/dist/Models/DatabaseModels/UserOnCallShiftReminderLog.js +355 -0
- package/build/dist/Models/DatabaseModels/UserOnCallShiftReminderLog.js.map +1 -0
- package/build/dist/Server/API/OnCallCalendarAPI.js +1371 -0
- package/build/dist/Server/API/OnCallCalendarAPI.js.map +1 -0
- package/build/dist/Server/EnvironmentConfig.js +102 -0
- package/build/dist/Server/EnvironmentConfig.js.map +1 -1
- package/build/dist/Server/Infrastructure/OnCallCalendarFeedCache.js +663 -0
- package/build/dist/Server/Infrastructure/OnCallCalendarFeedCache.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790400000000-AddOnCallCalendarFeeds.js +103 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790400000000-AddOnCallCalendarFeeds.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Middleware/OnCallCalendarFeedRateLimit.js +342 -0
- package/build/dist/Server/Middleware/OnCallCalendarFeedRateLimit.js.map +1 -0
- package/build/dist/Server/Services/Index.js +12 -0
- package/build/dist/Server/Services/Index.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js +20 -0
- package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleCalendarFeedService.js +219 -0
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleCalendarFeedService.js.map +1 -0
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerService.js +47 -3
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerUserService.js +112 -3
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerUserService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js +955 -9
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyUserOverrideService.js +93 -6
- package/build/dist/Server/Services/OnCallDutyPolicyUserOverrideService.js.map +1 -1
- package/build/dist/Server/Services/ProjectOnCallCalendarFeedService.js +157 -0
- package/build/dist/Server/Services/ProjectOnCallCalendarFeedService.js.map +1 -0
- package/build/dist/Server/Services/TeamMemberService.js +396 -1
- package/build/dist/Server/Services/TeamMemberService.js.map +1 -1
- package/build/dist/Server/Services/TeamService.js +43 -0
- package/build/dist/Server/Services/TeamService.js.map +1 -1
- package/build/dist/Server/Services/UserNotificationSettingService.js +26 -1
- package/build/dist/Server/Services/UserNotificationSettingService.js.map +1 -1
- package/build/dist/Server/Services/UserOnCallCalendarFeedService.js +140 -0
- package/build/dist/Server/Services/UserOnCallCalendarFeedService.js.map +1 -0
- package/build/dist/Server/Services/UserOnCallShiftReminderLogService.js +116 -0
- package/build/dist/Server/Services/UserOnCallShiftReminderLogService.js.map +1 -0
- package/build/dist/Server/Services/UserOnCallShiftReminderService.js +92 -0
- package/build/dist/Server/Services/UserOnCallShiftReminderService.js.map +1 -0
- package/build/dist/Server/Utils/OnCall/CalendarFeedToken.js +161 -0
- package/build/dist/Server/Utils/OnCall/CalendarFeedToken.js.map +1 -0
- package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedRenderer.js +983 -0
- package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedRenderer.js.map +1 -0
- package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedUrls.js +155 -0
- package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedUrls.js.map +1 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftChangeListeners.js +145 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftChangeListeners.js.map +1 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftMaterializer.js +752 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftMaterializer.js.map +1 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftReminderListener.js +92 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftReminderListener.js.map +1 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftReminderRunner.js +1786 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftReminderRunner.js.map +1 -0
- package/build/dist/Server/Utils/Response.js +97 -0
- package/build/dist/Server/Utils/Response.js.map +1 -1
- package/build/dist/Server/Utils/StartServer.js +9 -1
- package/build/dist/Server/Utils/StartServer.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/AppMetrics.js +41 -0
- package/build/dist/Server/Utils/Telemetry/AppMetrics.js.map +1 -1
- package/build/dist/Types/Calendar/ICalendar.js +290 -0
- package/build/dist/Types/Calendar/ICalendar.js.map +1 -0
- package/build/dist/Types/Email/EmailTemplateType.js +2 -0
- package/build/dist/Types/Email/EmailTemplateType.js.map +1 -1
- package/build/dist/Types/NotificationSetting/NotificationSettingEventType.js +3 -0
- package/build/dist/Types/NotificationSetting/NotificationSettingEventType.js.map +1 -1
- package/build/dist/Types/OnCallDutyPolicy/CalendarFeedWindow.js +97 -0
- package/build/dist/Types/OnCallDutyPolicy/CalendarFeedWindow.js.map +1 -0
- package/build/dist/Types/OnCallDutyPolicy/Layer.js +113 -19
- package/build/dist/Types/OnCallDutyPolicy/Layer.js.map +1 -1
- package/build/dist/Types/OnCallDutyPolicy/MaterializedShift.js +134 -0
- package/build/dist/Types/OnCallDutyPolicy/MaterializedShift.js.map +1 -0
- package/build/dist/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.js +800 -0
- package/build/dist/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.js.map +1 -0
- package/build/dist/Types/OnCallDutyPolicy/ScheduleShiftUtil.js +97 -3
- package/build/dist/Types/OnCallDutyPolicy/ScheduleShiftUtil.js.map +1 -1
- package/build/dist/Types/OnCallDutyPolicy/ShiftSeamUtil.js +77 -0
- package/build/dist/Types/OnCallDutyPolicy/ShiftSeamUtil.js.map +1 -0
- 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
|