@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,1415 @@
|
|
|
1
|
+
import moment from "moment-timezone";
|
|
2
|
+
import User from "../../Models/DatabaseModels/User";
|
|
3
|
+
import ICalendar, {
|
|
4
|
+
ICalendarCalendar,
|
|
5
|
+
ICalendarDocument,
|
|
6
|
+
ICalendarEvent,
|
|
7
|
+
ICalendarEventStatus,
|
|
8
|
+
ICalendarTransparency,
|
|
9
|
+
} from "../Calendar/ICalendar";
|
|
10
|
+
import OneUptimeDate from "../Date";
|
|
11
|
+
import ObjectID from "../ObjectID";
|
|
12
|
+
import Timezone from "../Timezone";
|
|
13
|
+
import CalendarFeedWindow, {
|
|
14
|
+
MAX_EVENTS,
|
|
15
|
+
MAX_GAP_EVENTS,
|
|
16
|
+
} from "./CalendarFeedWindow";
|
|
17
|
+
import LayerUtil, { LayerEventsResult, LayerProps } from "./Layer";
|
|
18
|
+
import MaterializedShiftUtil, {
|
|
19
|
+
MaterializedShift,
|
|
20
|
+
MaterializedShiftPolicy,
|
|
21
|
+
} from "./MaterializedShift";
|
|
22
|
+
import ScheduleShiftUtil, {
|
|
23
|
+
CoverageGap,
|
|
24
|
+
OnCallShift,
|
|
25
|
+
} from "./ScheduleShiftUtil";
|
|
26
|
+
import ShiftSeamUtil, { TimeSegment } from "./ShiftSeamUtil";
|
|
27
|
+
|
|
28
|
+
/*
|
|
29
|
+
* Pure mapper from materialized on-call shifts to an iCalendar document.
|
|
30
|
+
*
|
|
31
|
+
* Everything here is deterministic on its inputs (no clock reads, no I/O), so
|
|
32
|
+
* the same shifts always serialize to the same bytes — the property the body
|
|
33
|
+
* cache and ETag depend on. The server-side renderer decides WHAT to feed in
|
|
34
|
+
* (which shifts, which window, which timezone) and this module decides how
|
|
35
|
+
* that looks in a calendar.
|
|
36
|
+
*
|
|
37
|
+
* Rules implemented (see the on-call calendar feeds design):
|
|
38
|
+
* - UID is (schedule, seam-normalised start), never the user, so an override
|
|
39
|
+
* swap updates the event in place; policy variants add the policy id, gaps
|
|
40
|
+
* have their own namespace.
|
|
41
|
+
* - DTSTAMP/LAST-MODIFIED are the schedule inputs' last-modified instant and
|
|
42
|
+
* SEQUENCE is the schedule's shiftConfigVersion, so an unchanged schedule
|
|
43
|
+
* renders byte-identically.
|
|
44
|
+
* - Every DTSTART/DTEND is UTC; the DESCRIPTION carries the schedule-zone,
|
|
45
|
+
* UTC and viewer-zone wall clock.
|
|
46
|
+
* - TRANSP:TRANSPARENT, STATUS:CONFIRMED, CATEGORIES:On-Call; no CLASS.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
export enum OnCallCalendarFeedKind {
|
|
50
|
+
Personal = "personal",
|
|
51
|
+
Schedule = "schedule",
|
|
52
|
+
Project = "project",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface CalendarNameOptions {
|
|
56
|
+
kind: OnCallCalendarFeedKind;
|
|
57
|
+
scheduleName?: string | undefined;
|
|
58
|
+
projectName?: string | undefined;
|
|
59
|
+
// Personal feed with a ?schedule= filter.
|
|
60
|
+
filterScheduleName?: string | undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface CalendarName {
|
|
64
|
+
// Full name (NAME).
|
|
65
|
+
name: string;
|
|
66
|
+
// What clients display (X-WR-CALNAME); truncated for the schedule feed.
|
|
67
|
+
displayName: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface CalendarHeaderOptions extends CalendarNameOptions {
|
|
71
|
+
// X-WR-TIMEZONE; invalid or missing falls back to UTC.
|
|
72
|
+
timezone?: string | undefined;
|
|
73
|
+
lastModifiedAt?: Date | undefined;
|
|
74
|
+
// Extra sentences appended to X-WR-CALDESC (truncation, empty reason, ...).
|
|
75
|
+
notes?: Array<string> | undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/*
|
|
79
|
+
* The policy-variant shifts of a feed, bucketed by schedule id and sorted
|
|
80
|
+
* inside each bucket. See buildVariantIndex.
|
|
81
|
+
*/
|
|
82
|
+
export type PolicyVariantIndex = Map<string, Array<MaterializedShift>>;
|
|
83
|
+
|
|
84
|
+
export interface ShiftEventContext {
|
|
85
|
+
kind: OnCallCalendarFeedKind;
|
|
86
|
+
// Dashboard base URL including the /dashboard segment, no trailing slash needed.
|
|
87
|
+
dashboardUrl: string;
|
|
88
|
+
// The subscriber's own zone (personal feed); used for the "your zone" line.
|
|
89
|
+
viewerTimezone?: string | undefined;
|
|
90
|
+
/*
|
|
91
|
+
* Pre-built policy-variant index for the mirror lines (see
|
|
92
|
+
* buildVariantIndex). `null` means "built, and the feed has no variants";
|
|
93
|
+
* `undefined` means "not built" and falls back to scanning whatever shift
|
|
94
|
+
* list the caller passes to buildDescription / shiftToEvent.
|
|
95
|
+
*/
|
|
96
|
+
variantIndex?: PolicyVariantIndex | null | undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface CoverageEnvelopeInput {
|
|
100
|
+
layers: Array<LayerProps>;
|
|
101
|
+
windowStart: Date;
|
|
102
|
+
windowEnd: Date;
|
|
103
|
+
maxSimulationIterations?: number | undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface CoverageEnvelopeResult {
|
|
107
|
+
// Union of every layer's restriction windows over the feed window, merged.
|
|
108
|
+
segments: Array<TimeSegment>;
|
|
109
|
+
truncated: boolean;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface CoverageGapEventsInput {
|
|
113
|
+
scheduleId: string;
|
|
114
|
+
scheduleName: string;
|
|
115
|
+
projectId: string;
|
|
116
|
+
// Resolved shifts of this schedule (any object with start/end).
|
|
117
|
+
shifts: Array<TimeSegment>;
|
|
118
|
+
feedStart: Date;
|
|
119
|
+
feedEnd: Date;
|
|
120
|
+
// Where a layer INTENDED coverage (see computeCoverageEnvelope).
|
|
121
|
+
envelope: Array<TimeSegment>;
|
|
122
|
+
// A hole shorter than this (after envelope clipping) is not reported.
|
|
123
|
+
minimumGapSeconds?: number | undefined;
|
|
124
|
+
lastModifiedAt: Date;
|
|
125
|
+
shiftConfigVersion: number;
|
|
126
|
+
dashboardUrl: string;
|
|
127
|
+
maxGapEvents?: number | undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface CoverageGapEventsResult {
|
|
131
|
+
events: Array<ICalendarEvent>;
|
|
132
|
+
// The gaps behind the events, same order.
|
|
133
|
+
gaps: Array<TimeSegment>;
|
|
134
|
+
// True when more gaps existed than maxGapEvents allowed.
|
|
135
|
+
truncated: boolean;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface WindowShrinkInput {
|
|
139
|
+
shifts: Array<MaterializedShift>;
|
|
140
|
+
feedStart: Date;
|
|
141
|
+
feedEnd: Date;
|
|
142
|
+
maxEvents?: number | undefined;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface WindowShrinkResult {
|
|
146
|
+
// Shifts overlapping [feedStart, feedEnd), sorted, at most maxEvents.
|
|
147
|
+
shifts: Array<MaterializedShift>;
|
|
148
|
+
// The (possibly earlier) exclusive end actually used.
|
|
149
|
+
feedEnd: Date;
|
|
150
|
+
truncated: boolean;
|
|
151
|
+
// Whole days cut off the end of the window.
|
|
152
|
+
daysDropped: number;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface FeedRenderInput extends ShiftEventContext {
|
|
156
|
+
shifts: Array<MaterializedShift>;
|
|
157
|
+
/*
|
|
158
|
+
* Every shift of the candidate schedules, BEFORE any per-user filtering.
|
|
159
|
+
* Only the shifts in `shifts` become VEVENTs; these are the extra context
|
|
160
|
+
* an event's DESCRIPTION may need to explain itself. The personal feed
|
|
161
|
+
* keeps only the subscriber's own shifts, so without this a policy-scoped
|
|
162
|
+
* override that hands one of the schedule's policies to somebody else would
|
|
163
|
+
* be invisible on the rostered user's own event — it would claim to page
|
|
164
|
+
* them through a policy that pages the substitute. Defaults to `shifts`.
|
|
165
|
+
*/
|
|
166
|
+
contextShifts?: Array<MaterializedShift> | undefined;
|
|
167
|
+
scheduleName?: string | undefined;
|
|
168
|
+
projectName?: string | undefined;
|
|
169
|
+
filterScheduleName?: string | undefined;
|
|
170
|
+
// X-WR-TIMEZONE; defaults to viewerTimezone, then UTC.
|
|
171
|
+
calendarTimezone?: string | undefined;
|
|
172
|
+
notes?: Array<string> | undefined;
|
|
173
|
+
gapEvents?: Array<ICalendarEvent> | undefined;
|
|
174
|
+
// Overrides the max of the shifts' lastModifiedAt.
|
|
175
|
+
lastModifiedAt?: Date | undefined;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export interface FeedRenderResult {
|
|
179
|
+
body: string;
|
|
180
|
+
eventCount: number;
|
|
181
|
+
lastModifiedAt: Date | null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export interface EmptyFeedInput extends CalendarNameOptions {
|
|
185
|
+
// Why the calendar is empty; goes into X-WR-CALDESC so a subscriber can tell.
|
|
186
|
+
reason: string;
|
|
187
|
+
timezone?: string | undefined;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const MILLISECONDS_PER_DAY: number = 24 * 60 * 60 * 1000;
|
|
191
|
+
|
|
192
|
+
// Engine artefact tolerance when merging envelope segments (1 s seams).
|
|
193
|
+
const ENVELOPE_MERGE_TOLERANCE_MILLISECONDS: number = 1000;
|
|
194
|
+
|
|
195
|
+
/*
|
|
196
|
+
* ScheduleShiftUtil.getCoverageGaps ignores holes of five seconds or less
|
|
197
|
+
* (its contiguity tolerance for the engine's one-second seams). The trailing
|
|
198
|
+
* hole this file adds itself is held to the same threshold.
|
|
199
|
+
*/
|
|
200
|
+
const TRAILING_GAP_TOLERANCE_MILLISECONDS: number = 5 * 1000;
|
|
201
|
+
|
|
202
|
+
const ENVELOPE_USER_ID: string = "00000000-0000-4000-8000-000000000000";
|
|
203
|
+
|
|
204
|
+
const EMAIL_LIKE_REGEX: RegExp = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
205
|
+
|
|
206
|
+
export default class OnCallCalendarFeedUtil {
|
|
207
|
+
public static readonly PRODUCT_ID: string =
|
|
208
|
+
"-//OneUptime//On-Call Calendar Feed//EN";
|
|
209
|
+
|
|
210
|
+
public static readonly REFRESH_INTERVAL: string = "PT1H";
|
|
211
|
+
|
|
212
|
+
public static readonly UID_DOMAIN: string = "oneuptime";
|
|
213
|
+
|
|
214
|
+
public static readonly CATEGORY: string = "On-Call";
|
|
215
|
+
|
|
216
|
+
public static readonly PERSONAL_CALENDAR_NAME: string = "OneUptime On-Call";
|
|
217
|
+
|
|
218
|
+
// Confluence Team Calendars rejects longer calendar names.
|
|
219
|
+
public static readonly MAX_SCHEDULE_CALENDAR_NAME_LENGTH: number = 28;
|
|
220
|
+
|
|
221
|
+
public static readonly FALLBACK_USER_NAME: string = "Unnamed user";
|
|
222
|
+
|
|
223
|
+
public static readonly REFRESH_CAVEAT: string =
|
|
224
|
+
"Calendar apps refresh subscribed feeds on their own schedule (Google Calendar every 12-24 h, Outlook on the web about every 3 h, Apple Calendar per its fetch setting), so recent changes can lag.";
|
|
225
|
+
|
|
226
|
+
public static readonly REFRESH_LINE: string =
|
|
227
|
+
"Changes appear after your calendar app next refreshes (Google Calendar: up to 24 h).";
|
|
228
|
+
|
|
229
|
+
public static readonly PAST_SHIFT_LINE: string =
|
|
230
|
+
"Past shifts reflect the current rotation, not who was actually paged — see the On-Call Time Log report.";
|
|
231
|
+
|
|
232
|
+
public static readonly NO_POLICY_LINE: string =
|
|
233
|
+
"Not attached to any escalation policy, so it will not page anyone.";
|
|
234
|
+
|
|
235
|
+
public static readonly LEGACY_TIMEZONE_NOTE: string =
|
|
236
|
+
"schedule has no timezone set (expanded in the server's zone, as paging does)";
|
|
237
|
+
|
|
238
|
+
/*
|
|
239
|
+
* ---------------------------------------------------------------------
|
|
240
|
+
* Identity and links
|
|
241
|
+
* ---------------------------------------------------------------------
|
|
242
|
+
*/
|
|
243
|
+
|
|
244
|
+
// oncall-<scheduleId>-<startEpochSeconds>@oneuptime (+ -<policyId> for variants).
|
|
245
|
+
public static getShiftUid(shift: MaterializedShift): string {
|
|
246
|
+
const base: string = `oncall-${shift.scheduleId}-${OnCallCalendarFeedUtil.epochSeconds(shift.start)}`;
|
|
247
|
+
const suffix: string = shift.policyVariantOf
|
|
248
|
+
? `-${shift.policyVariantOf.policyId}`
|
|
249
|
+
: "";
|
|
250
|
+
return `${base}${suffix}@${OnCallCalendarFeedUtil.UID_DOMAIN}`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
public static getGapUid(scheduleId: string, start: Date): string {
|
|
254
|
+
return `oncall-gap-${scheduleId}-${OnCallCalendarFeedUtil.epochSeconds(start)}@${OnCallCalendarFeedUtil.UID_DOMAIN}`;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
public static getScheduleUrl(
|
|
258
|
+
dashboardUrl: string,
|
|
259
|
+
projectId: string,
|
|
260
|
+
scheduleId: string,
|
|
261
|
+
): string {
|
|
262
|
+
return `${OnCallCalendarFeedUtil.trimTrailingSlash(dashboardUrl)}/${projectId}/on-call-duty/schedules/${scheduleId}`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
public static getUserOverridesUrl(
|
|
266
|
+
dashboardUrl: string,
|
|
267
|
+
projectId: string,
|
|
268
|
+
): string {
|
|
269
|
+
return `${OnCallCalendarFeedUtil.trimTrailingSlash(dashboardUrl)}/${projectId}/on-call-duty/user-overrides`;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
public static getTimeLogUrl(dashboardUrl: string, projectId: string): string {
|
|
273
|
+
return `${OnCallCalendarFeedUtil.trimTrailingSlash(dashboardUrl)}/${projectId}/on-call-duty/user-time-logs`;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/*
|
|
277
|
+
* ---------------------------------------------------------------------
|
|
278
|
+
* Calendar header
|
|
279
|
+
* ---------------------------------------------------------------------
|
|
280
|
+
*/
|
|
281
|
+
|
|
282
|
+
public static buildCalendarName(options: CalendarNameOptions): CalendarName {
|
|
283
|
+
const personal: string = OnCallCalendarFeedUtil.PERSONAL_CALENDAR_NAME;
|
|
284
|
+
|
|
285
|
+
if (options.kind === OnCallCalendarFeedKind.Schedule) {
|
|
286
|
+
const name: string =
|
|
287
|
+
OnCallCalendarFeedUtil.cleanName(options.scheduleName) ||
|
|
288
|
+
"On-Call Schedule";
|
|
289
|
+
return {
|
|
290
|
+
name,
|
|
291
|
+
displayName: OnCallCalendarFeedUtil.truncateName(
|
|
292
|
+
name,
|
|
293
|
+
OnCallCalendarFeedUtil.MAX_SCHEDULE_CALENDAR_NAME_LENGTH,
|
|
294
|
+
),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (options.kind === OnCallCalendarFeedKind.Project) {
|
|
299
|
+
const project: string =
|
|
300
|
+
OnCallCalendarFeedUtil.cleanName(options.projectName) || "Project";
|
|
301
|
+
const name: string = `${personal} · ${project}`;
|
|
302
|
+
return { name, displayName: name };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const filter: string = OnCallCalendarFeedUtil.cleanName(
|
|
306
|
+
options.filterScheduleName,
|
|
307
|
+
);
|
|
308
|
+
const name: string = filter ? `${personal} · ${filter}` : personal;
|
|
309
|
+
return { name, displayName: name };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
public static buildCalendarDescription(
|
|
313
|
+
options: CalendarNameOptions & { notes?: Array<string> | undefined },
|
|
314
|
+
): string {
|
|
315
|
+
let intro: string;
|
|
316
|
+
|
|
317
|
+
if (options.kind === OnCallCalendarFeedKind.Schedule) {
|
|
318
|
+
intro = `Everyone's on-call shifts on ${
|
|
319
|
+
OnCallCalendarFeedUtil.cleanName(options.scheduleName) ||
|
|
320
|
+
"this schedule"
|
|
321
|
+
} from OneUptime.`;
|
|
322
|
+
} else if (options.kind === OnCallCalendarFeedKind.Project) {
|
|
323
|
+
intro = `Everyone's on-call shifts across ${
|
|
324
|
+
OnCallCalendarFeedUtil.cleanName(options.projectName) || "this project"
|
|
325
|
+
} from OneUptime.`;
|
|
326
|
+
} else {
|
|
327
|
+
const filter: string = OnCallCalendarFeedUtil.cleanName(
|
|
328
|
+
options.filterScheduleName,
|
|
329
|
+
);
|
|
330
|
+
intro = filter
|
|
331
|
+
? `Your on-call shifts on ${filter} from OneUptime.`
|
|
332
|
+
: "Your on-call shifts from OneUptime.";
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const notes: Array<string> = (options.notes ?? [])
|
|
336
|
+
.map((note: string) => {
|
|
337
|
+
return note.trim();
|
|
338
|
+
})
|
|
339
|
+
.filter((note: string) => {
|
|
340
|
+
return note !== "";
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
return [intro, OnCallCalendarFeedUtil.REFRESH_CAVEAT, ...notes].join(" ");
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
public static buildCalendarHeader(
|
|
347
|
+
options: CalendarHeaderOptions,
|
|
348
|
+
): ICalendarCalendar {
|
|
349
|
+
const names: CalendarName =
|
|
350
|
+
OnCallCalendarFeedUtil.buildCalendarName(options);
|
|
351
|
+
|
|
352
|
+
const header: ICalendarCalendar = {
|
|
353
|
+
productId: OnCallCalendarFeedUtil.PRODUCT_ID,
|
|
354
|
+
name: names.name,
|
|
355
|
+
displayName: names.displayName,
|
|
356
|
+
description: OnCallCalendarFeedUtil.buildCalendarDescription(options),
|
|
357
|
+
timezone: OnCallCalendarFeedUtil.isValidTimezone(options.timezone)
|
|
358
|
+
? options.timezone
|
|
359
|
+
: Timezone.UTC,
|
|
360
|
+
refreshInterval: OnCallCalendarFeedUtil.REFRESH_INTERVAL,
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
if (options.lastModifiedAt !== undefined) {
|
|
364
|
+
header.lastModified = options.lastModifiedAt;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return header;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/*
|
|
371
|
+
* ---------------------------------------------------------------------
|
|
372
|
+
* Shift -> VEVENT
|
|
373
|
+
* ---------------------------------------------------------------------
|
|
374
|
+
*/
|
|
375
|
+
|
|
376
|
+
/*
|
|
377
|
+
* How a person is named in this feed. The personal feed shows whatever the
|
|
378
|
+
* materializer resolved (an email fallback is fine — it is the subscriber's
|
|
379
|
+
* own data); the shared schedule/project feeds never show an email.
|
|
380
|
+
*/
|
|
381
|
+
public static getDisplayName(
|
|
382
|
+
name: string | null | undefined,
|
|
383
|
+
kind: OnCallCalendarFeedKind,
|
|
384
|
+
): string {
|
|
385
|
+
const trimmed: string = (name ?? "").trim();
|
|
386
|
+
|
|
387
|
+
if (trimmed === "") {
|
|
388
|
+
return OnCallCalendarFeedUtil.FALLBACK_USER_NAME;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (
|
|
392
|
+
kind !== OnCallCalendarFeedKind.Personal &&
|
|
393
|
+
EMAIL_LIKE_REGEX.test(trimmed)
|
|
394
|
+
) {
|
|
395
|
+
return OnCallCalendarFeedUtil.FALLBACK_USER_NAME;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return trimmed;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
public static buildSummary(
|
|
402
|
+
shift: MaterializedShift,
|
|
403
|
+
kind: OnCallCalendarFeedKind,
|
|
404
|
+
): string {
|
|
405
|
+
const scheduleName: string =
|
|
406
|
+
OnCallCalendarFeedUtil.cleanName(shift.scheduleName) || "Schedule";
|
|
407
|
+
|
|
408
|
+
let summary: string = `On-call · ${scheduleName}`;
|
|
409
|
+
|
|
410
|
+
const distinctPolicies: Array<MaterializedShiftPolicy> =
|
|
411
|
+
OnCallCalendarFeedUtil.getDistinctPolicies(shift.policies);
|
|
412
|
+
|
|
413
|
+
if (shift.policyVariantOf) {
|
|
414
|
+
summary += ` · ${shift.policyVariantOf.policyName}`;
|
|
415
|
+
} else if (
|
|
416
|
+
kind === OnCallCalendarFeedKind.Personal &&
|
|
417
|
+
distinctPolicies.length === 1
|
|
418
|
+
) {
|
|
419
|
+
summary += ` · ${distinctPolicies[0]!.policyName}`;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (shift.override) {
|
|
423
|
+
summary += ` (covering for ${OnCallCalendarFeedUtil.getDisplayName(
|
|
424
|
+
shift.override.originalUserName,
|
|
425
|
+
kind,
|
|
426
|
+
)})`;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (kind === OnCallCalendarFeedKind.Personal) {
|
|
430
|
+
return summary;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return `${OnCallCalendarFeedUtil.getDisplayName(shift.userName, kind)} · ${summary}`;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
public static buildDescription(
|
|
437
|
+
shift: MaterializedShift,
|
|
438
|
+
context: ShiftEventContext,
|
|
439
|
+
allShifts?: Array<MaterializedShift> | undefined,
|
|
440
|
+
): string {
|
|
441
|
+
const kind: OnCallCalendarFeedKind = context.kind;
|
|
442
|
+
const lines: Array<string> = [];
|
|
443
|
+
|
|
444
|
+
const userName: string = OnCallCalendarFeedUtil.getDisplayName(
|
|
445
|
+
shift.userName,
|
|
446
|
+
kind,
|
|
447
|
+
);
|
|
448
|
+
|
|
449
|
+
const originalUserName: string | null = shift.override
|
|
450
|
+
? OnCallCalendarFeedUtil.getDisplayName(
|
|
451
|
+
shift.override.originalUserName,
|
|
452
|
+
kind,
|
|
453
|
+
)
|
|
454
|
+
: null;
|
|
455
|
+
|
|
456
|
+
lines.push(
|
|
457
|
+
`Who: ${userName}${originalUserName ? `, covering for ${originalUserName}` : ""}`,
|
|
458
|
+
);
|
|
459
|
+
|
|
460
|
+
const scheduleZone: string = OnCallCalendarFeedUtil.getScheduleZone(shift);
|
|
461
|
+
const isLegacyZone: boolean = !OnCallCalendarFeedUtil.isValidTimezone(
|
|
462
|
+
shift.scheduleTimezone,
|
|
463
|
+
);
|
|
464
|
+
|
|
465
|
+
lines.push(
|
|
466
|
+
`Schedule: ${OnCallCalendarFeedUtil.cleanName(shift.scheduleName) || "Schedule"} (${
|
|
467
|
+
isLegacyZone
|
|
468
|
+
? OnCallCalendarFeedUtil.LEGACY_TIMEZONE_NOTE
|
|
469
|
+
: scheduleZone
|
|
470
|
+
})`,
|
|
471
|
+
);
|
|
472
|
+
|
|
473
|
+
if (shift.layerName) {
|
|
474
|
+
lines.push(`Layer: ${shift.layerName}`);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
lines.push(
|
|
478
|
+
...OnCallCalendarFeedUtil.buildShiftTimeLines(
|
|
479
|
+
shift,
|
|
480
|
+
scheduleZone,
|
|
481
|
+
isLegacyZone,
|
|
482
|
+
context.viewerTimezone,
|
|
483
|
+
),
|
|
484
|
+
);
|
|
485
|
+
|
|
486
|
+
const distinctPolicies: Array<MaterializedShiftPolicy> =
|
|
487
|
+
OnCallCalendarFeedUtil.getDistinctPolicies(shift.policies);
|
|
488
|
+
|
|
489
|
+
// A variant pages through ONE policy, not the schedule's whole attachment set.
|
|
490
|
+
const pagingPolicies: Array<MaterializedShiftPolicy> =
|
|
491
|
+
OnCallCalendarFeedUtil.getPagingPolicies(shift);
|
|
492
|
+
|
|
493
|
+
if (pagingPolicies.length === 0) {
|
|
494
|
+
lines.push(OnCallCalendarFeedUtil.NO_POLICY_LINE);
|
|
495
|
+
} else {
|
|
496
|
+
const label: string =
|
|
497
|
+
kind === OnCallCalendarFeedKind.Personal
|
|
498
|
+
? "Pages you via"
|
|
499
|
+
: "Pages via";
|
|
500
|
+
lines.push(
|
|
501
|
+
`${label}: ${OnCallCalendarFeedUtil.describePolicies(pagingPolicies)}`,
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if (shift.override) {
|
|
506
|
+
const scope: string = shift.override.onCallDutyPolicyId
|
|
507
|
+
? `scoped to ${OnCallCalendarFeedUtil.findPolicyName(
|
|
508
|
+
distinctPolicies,
|
|
509
|
+
shift.override.onCallDutyPolicyId,
|
|
510
|
+
)}`
|
|
511
|
+
: "global override";
|
|
512
|
+
|
|
513
|
+
lines.push(
|
|
514
|
+
`Override: ${originalUserName} → ${userName} from ${OnCallCalendarFeedUtil.formatInZone(
|
|
515
|
+
shift.override.overrideStartsAt,
|
|
516
|
+
scheduleZone,
|
|
517
|
+
)} to ${OnCallCalendarFeedUtil.formatInZone(
|
|
518
|
+
shift.override.overrideEndsAt,
|
|
519
|
+
scheduleZone,
|
|
520
|
+
)} (${scope})`,
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if (shift.policyVariantOf) {
|
|
525
|
+
const insteadOf: string = originalUserName ?? "the rostered user";
|
|
526
|
+
lines.push(
|
|
527
|
+
kind === OnCallCalendarFeedKind.Personal
|
|
528
|
+
? `For ${shift.policyVariantOf.policyName} you are paged instead of ${insteadOf} because of a policy-specific override.`
|
|
529
|
+
: `For ${shift.policyVariantOf.policyName}, ${userName} is paged instead of ${insteadOf} because of a policy-specific override.`,
|
|
530
|
+
);
|
|
531
|
+
} else {
|
|
532
|
+
/*
|
|
533
|
+
* A pre-built index (every render builds one) beats re-scanning the
|
|
534
|
+
* whole shift array for every shift, which is quadratic at feed sizes
|
|
535
|
+
* near MAX_EVENTS.
|
|
536
|
+
*/
|
|
537
|
+
const variantIndex: PolicyVariantIndex | null =
|
|
538
|
+
context.variantIndex !== undefined
|
|
539
|
+
? context.variantIndex
|
|
540
|
+
: allShifts
|
|
541
|
+
? OnCallCalendarFeedUtil.buildVariantIndex(allShifts)
|
|
542
|
+
: null;
|
|
543
|
+
|
|
544
|
+
if (variantIndex) {
|
|
545
|
+
lines.push(
|
|
546
|
+
...OnCallCalendarFeedUtil.buildVariantMirrorLines(
|
|
547
|
+
shift,
|
|
548
|
+
variantIndex,
|
|
549
|
+
kind,
|
|
550
|
+
scheduleZone,
|
|
551
|
+
),
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (shift.isPast) {
|
|
557
|
+
lines.push(
|
|
558
|
+
`${OnCallCalendarFeedUtil.PAST_SHIFT_LINE} ${OnCallCalendarFeedUtil.getTimeLogUrl(
|
|
559
|
+
context.dashboardUrl,
|
|
560
|
+
shift.projectId,
|
|
561
|
+
)}`,
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
lines.push(
|
|
566
|
+
`Need cover? ${OnCallCalendarFeedUtil.getUserOverridesUrl(
|
|
567
|
+
context.dashboardUrl,
|
|
568
|
+
shift.projectId,
|
|
569
|
+
)}`,
|
|
570
|
+
);
|
|
571
|
+
|
|
572
|
+
lines.push(OnCallCalendarFeedUtil.REFRESH_LINE);
|
|
573
|
+
|
|
574
|
+
return lines.join("\n");
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
public static shiftToEvent(
|
|
578
|
+
shift: MaterializedShift,
|
|
579
|
+
context: ShiftEventContext,
|
|
580
|
+
allShifts?: Array<MaterializedShift> | undefined,
|
|
581
|
+
): ICalendarEvent {
|
|
582
|
+
return {
|
|
583
|
+
uid: OnCallCalendarFeedUtil.getShiftUid(shift),
|
|
584
|
+
dtStamp: shift.lastModifiedAt,
|
|
585
|
+
lastModified: shift.lastModifiedAt,
|
|
586
|
+
sequence: OnCallCalendarFeedUtil.toSequence(shift.shiftConfigVersion),
|
|
587
|
+
start: shift.start,
|
|
588
|
+
end: shift.end,
|
|
589
|
+
summary: OnCallCalendarFeedUtil.buildSummary(shift, context.kind),
|
|
590
|
+
description: OnCallCalendarFeedUtil.buildDescription(
|
|
591
|
+
shift,
|
|
592
|
+
context,
|
|
593
|
+
allShifts,
|
|
594
|
+
),
|
|
595
|
+
url: OnCallCalendarFeedUtil.getScheduleUrl(
|
|
596
|
+
context.dashboardUrl,
|
|
597
|
+
shift.projectId,
|
|
598
|
+
shift.scheduleId,
|
|
599
|
+
),
|
|
600
|
+
status: ICalendarEventStatus.Confirmed,
|
|
601
|
+
transparency: ICalendarTransparency.Transparent,
|
|
602
|
+
categories: [OnCallCalendarFeedUtil.CATEGORY],
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/*
|
|
607
|
+
* One VEVENT per shift, in deterministic (start, schedule, key) order.
|
|
608
|
+
*
|
|
609
|
+
* `contextShifts` are shifts that do NOT become events but may explain the
|
|
610
|
+
* ones that do (the personal feed's pre-filter list); it defaults to the
|
|
611
|
+
* rendered shifts.
|
|
612
|
+
*/
|
|
613
|
+
public static shiftsToEvents(
|
|
614
|
+
shifts: Array<MaterializedShift>,
|
|
615
|
+
context: ShiftEventContext,
|
|
616
|
+
contextShifts?: Array<MaterializedShift> | undefined,
|
|
617
|
+
): Array<ICalendarEvent> {
|
|
618
|
+
const sorted: Array<MaterializedShift> =
|
|
619
|
+
MaterializedShiftUtil.sortByStart(shifts);
|
|
620
|
+
|
|
621
|
+
const eventContext: ShiftEventContext = {
|
|
622
|
+
...context,
|
|
623
|
+
variantIndex: OnCallCalendarFeedUtil.buildVariantIndex(
|
|
624
|
+
contextShifts ?? sorted,
|
|
625
|
+
),
|
|
626
|
+
};
|
|
627
|
+
|
|
628
|
+
return sorted.map((shift: MaterializedShift) => {
|
|
629
|
+
return OnCallCalendarFeedUtil.shiftToEvent(shift, eventContext);
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/*
|
|
634
|
+
* ---------------------------------------------------------------------
|
|
635
|
+
* Window
|
|
636
|
+
* ---------------------------------------------------------------------
|
|
637
|
+
*/
|
|
638
|
+
|
|
639
|
+
// Shifts that overlap [feedStart, feedEnd), unclipped, sorted.
|
|
640
|
+
public static filterShiftsToWindow(
|
|
641
|
+
shifts: Array<MaterializedShift>,
|
|
642
|
+
feedStart: Date,
|
|
643
|
+
feedEnd: Date,
|
|
644
|
+
): Array<MaterializedShift> {
|
|
645
|
+
return MaterializedShiftUtil.sortByStart(shifts).filter(
|
|
646
|
+
(shift: MaterializedShift) => {
|
|
647
|
+
return (
|
|
648
|
+
shift.start.getTime() < feedEnd.getTime() &&
|
|
649
|
+
shift.end.getTime() > feedStart.getTime()
|
|
650
|
+
);
|
|
651
|
+
},
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/*
|
|
656
|
+
* Keep the feed under maxEvents by cutting whole UTC days off the END of
|
|
657
|
+
* the window (the nearest future matters most). When even the first day
|
|
658
|
+
* holds more than maxEvents shifts, the cut lands on the first excluded
|
|
659
|
+
* shift's start instead.
|
|
660
|
+
*/
|
|
661
|
+
public static shrinkWindowToFit(
|
|
662
|
+
input: WindowShrinkInput,
|
|
663
|
+
): WindowShrinkResult {
|
|
664
|
+
const maxEvents: number =
|
|
665
|
+
input.maxEvents !== undefined && input.maxEvents > 0
|
|
666
|
+
? Math.floor(input.maxEvents)
|
|
667
|
+
: MAX_EVENTS;
|
|
668
|
+
|
|
669
|
+
const inWindow: Array<MaterializedShift> =
|
|
670
|
+
OnCallCalendarFeedUtil.filterShiftsToWindow(
|
|
671
|
+
input.shifts,
|
|
672
|
+
input.feedStart,
|
|
673
|
+
input.feedEnd,
|
|
674
|
+
);
|
|
675
|
+
|
|
676
|
+
if (inWindow.length <= maxEvents) {
|
|
677
|
+
return {
|
|
678
|
+
shifts: inWindow,
|
|
679
|
+
feedEnd: input.feedEnd,
|
|
680
|
+
truncated: false,
|
|
681
|
+
daysDropped: 0,
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const firstExcluded: MaterializedShift = inWindow[maxEvents]!;
|
|
686
|
+
|
|
687
|
+
let newFeedEnd: Date = new Date(
|
|
688
|
+
CalendarFeedWindow.startOfUtcDay(firstExcluded.start),
|
|
689
|
+
);
|
|
690
|
+
|
|
691
|
+
if (newFeedEnd.getTime() <= input.feedStart.getTime()) {
|
|
692
|
+
newFeedEnd = new Date(firstExcluded.start.getTime());
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
let kept: Array<MaterializedShift> = inWindow.filter(
|
|
696
|
+
(shift: MaterializedShift) => {
|
|
697
|
+
return shift.start.getTime() < newFeedEnd.getTime();
|
|
698
|
+
},
|
|
699
|
+
);
|
|
700
|
+
|
|
701
|
+
/*
|
|
702
|
+
* Shifts that started before the window (in progress at feedStart) all
|
|
703
|
+
* share "start < newFeedEnd"; if there are more than maxEvents of those
|
|
704
|
+
* no cut point exists and a hard slice is the only option left.
|
|
705
|
+
*/
|
|
706
|
+
if (kept.length > maxEvents) {
|
|
707
|
+
kept = kept.slice(0, maxEvents);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
const daysDropped: number = Math.max(
|
|
711
|
+
0,
|
|
712
|
+
Math.ceil(
|
|
713
|
+
(input.feedEnd.getTime() - newFeedEnd.getTime()) / MILLISECONDS_PER_DAY,
|
|
714
|
+
),
|
|
715
|
+
);
|
|
716
|
+
|
|
717
|
+
return { shifts: kept, feedEnd: newFeedEnd, truncated: true, daysDropped };
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/*
|
|
721
|
+
* ---------------------------------------------------------------------
|
|
722
|
+
* Coverage gaps
|
|
723
|
+
* ---------------------------------------------------------------------
|
|
724
|
+
*/
|
|
725
|
+
|
|
726
|
+
/*
|
|
727
|
+
* Where the schedule's layers INTEND to cover, regardless of who (or
|
|
728
|
+
* whether anyone) is assigned and of when the layer starts: each layer is
|
|
729
|
+
* expanded with one synthetic user from the window start, so the result is
|
|
730
|
+
* exactly its restriction windows — the whole window for an unrestricted
|
|
731
|
+
* layer, Mon-Fri 09:00-17:00 for a business-hours layer. A coverage hole
|
|
732
|
+
* is only worth an event when it falls inside this envelope.
|
|
733
|
+
*/
|
|
734
|
+
public static computeCoverageEnvelope(
|
|
735
|
+
input: CoverageEnvelopeInput,
|
|
736
|
+
): CoverageEnvelopeResult {
|
|
737
|
+
const layerUtil: LayerUtil = new LayerUtil();
|
|
738
|
+
const segments: Array<TimeSegment> = [];
|
|
739
|
+
let truncated: boolean = false;
|
|
740
|
+
|
|
741
|
+
const syntheticUser: User = new User();
|
|
742
|
+
syntheticUser.id = new ObjectID(ENVELOPE_USER_ID);
|
|
743
|
+
|
|
744
|
+
for (const layer of input.layers) {
|
|
745
|
+
const result: LayerEventsResult = layerUtil.getEventsWithMeta(
|
|
746
|
+
{
|
|
747
|
+
users: [syntheticUser],
|
|
748
|
+
startDateTimeOfLayer: input.windowStart,
|
|
749
|
+
restrictionTimes: layer.restrictionTimes,
|
|
750
|
+
handOffTime: layer.handOffTime,
|
|
751
|
+
rotation: layer.rotation,
|
|
752
|
+
timezone: layer.timezone,
|
|
753
|
+
calendarStartDate: input.windowStart,
|
|
754
|
+
calendarEndDate: input.windowEnd,
|
|
755
|
+
},
|
|
756
|
+
input.maxSimulationIterations !== undefined
|
|
757
|
+
? { maxSimulationIterations: input.maxSimulationIterations }
|
|
758
|
+
: undefined,
|
|
759
|
+
);
|
|
760
|
+
|
|
761
|
+
truncated = truncated || result.truncated;
|
|
762
|
+
|
|
763
|
+
for (const event of result.events) {
|
|
764
|
+
segments.push({ start: event.start, end: event.end });
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/*
|
|
769
|
+
* The engine's 1-second seams (09:00:01 starts) would otherwise leak into
|
|
770
|
+
* gap events; normalise them exactly as the shifts themselves are.
|
|
771
|
+
*/
|
|
772
|
+
return {
|
|
773
|
+
segments: OnCallCalendarFeedUtil.mergeSegments(
|
|
774
|
+
ShiftSeamUtil.normalizeSeams(segments),
|
|
775
|
+
ENVELOPE_MERGE_TOLERANCE_MILLISECONDS,
|
|
776
|
+
),
|
|
777
|
+
truncated,
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/*
|
|
782
|
+
* "No coverage" events: every hole before, between and after the shifts
|
|
783
|
+
* inside [feedStart, feedEnd) that intersects the envelope, clipped to the
|
|
784
|
+
* envelope, merged, at least minimumGapSeconds long, oldest first, capped at
|
|
785
|
+
* maxGapEvents. Off-hours of a business-hours schedule are outside the
|
|
786
|
+
* envelope and are therefore never emitted.
|
|
787
|
+
*/
|
|
788
|
+
public static buildCoverageGapEvents(
|
|
789
|
+
input: CoverageGapEventsInput,
|
|
790
|
+
): CoverageGapEventsResult {
|
|
791
|
+
const maxGapEvents: number =
|
|
792
|
+
input.maxGapEvents !== undefined && input.maxGapEvents >= 0
|
|
793
|
+
? Math.floor(input.maxGapEvents)
|
|
794
|
+
: MAX_GAP_EVENTS;
|
|
795
|
+
|
|
796
|
+
const minimumGapMilliseconds: number =
|
|
797
|
+
Math.max(0, input.minimumGapSeconds ?? 0) * 1000;
|
|
798
|
+
|
|
799
|
+
const shifts: Array<OnCallShift> = [...input.shifts]
|
|
800
|
+
.sort((a: TimeSegment, b: TimeSegment) => {
|
|
801
|
+
return a.start.getTime() - b.start.getTime();
|
|
802
|
+
})
|
|
803
|
+
.map((segment: TimeSegment) => {
|
|
804
|
+
return {
|
|
805
|
+
userId: "",
|
|
806
|
+
start: segment.start,
|
|
807
|
+
end: segment.end,
|
|
808
|
+
coverageSeconds: OneUptimeDate.getDifferenceInSeconds(
|
|
809
|
+
segment.end,
|
|
810
|
+
segment.start,
|
|
811
|
+
),
|
|
812
|
+
};
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
const rawGaps: Array<CoverageGap> = ScheduleShiftUtil.getCoverageGaps(
|
|
816
|
+
shifts,
|
|
817
|
+
input.feedStart,
|
|
818
|
+
input.feedEnd,
|
|
819
|
+
);
|
|
820
|
+
|
|
821
|
+
/*
|
|
822
|
+
* getCoverageGaps (shared with the dashboard's coverage view) treats the
|
|
823
|
+
* window end as an arbitrary cut and never reports the stretch after the
|
|
824
|
+
* last shift. For a feed the window end IS the horizon the envelope was
|
|
825
|
+
* computed for, so a rotation that stops mid-window (every user removed
|
|
826
|
+
* after a date, a layer whose users run out) must show its tail as "No
|
|
827
|
+
* coverage" too. The envelope intersection below still clips it to the
|
|
828
|
+
* hours a layer intended to cover, so a business-hours schedule whose last
|
|
829
|
+
* shift ends on Friday still emits nothing for the weekend.
|
|
830
|
+
*/
|
|
831
|
+
if (shifts.length > 0) {
|
|
832
|
+
const coveredUntil: number = shifts.reduce(
|
|
833
|
+
(max: number, shift: OnCallShift): number => {
|
|
834
|
+
return Math.max(max, shift.end.getTime());
|
|
835
|
+
},
|
|
836
|
+
shifts[0]!.end.getTime(),
|
|
837
|
+
);
|
|
838
|
+
|
|
839
|
+
if (
|
|
840
|
+
input.feedEnd.getTime() - coveredUntil >
|
|
841
|
+
TRAILING_GAP_TOLERANCE_MILLISECONDS
|
|
842
|
+
) {
|
|
843
|
+
rawGaps.push({ start: new Date(coveredUntil), end: input.feedEnd });
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
const envelope: Array<TimeSegment> = OnCallCalendarFeedUtil.mergeSegments(
|
|
848
|
+
input.envelope
|
|
849
|
+
.map((segment: TimeSegment) => {
|
|
850
|
+
return {
|
|
851
|
+
start: new Date(
|
|
852
|
+
Math.max(segment.start.getTime(), input.feedStart.getTime()),
|
|
853
|
+
),
|
|
854
|
+
end: new Date(
|
|
855
|
+
Math.min(segment.end.getTime(), input.feedEnd.getTime()),
|
|
856
|
+
),
|
|
857
|
+
};
|
|
858
|
+
})
|
|
859
|
+
.filter((segment: TimeSegment) => {
|
|
860
|
+
return segment.end.getTime() > segment.start.getTime();
|
|
861
|
+
}),
|
|
862
|
+
ENVELOPE_MERGE_TOLERANCE_MILLISECONDS,
|
|
863
|
+
);
|
|
864
|
+
|
|
865
|
+
const pieces: Array<TimeSegment> = [];
|
|
866
|
+
|
|
867
|
+
for (const gap of rawGaps) {
|
|
868
|
+
for (const intended of envelope) {
|
|
869
|
+
const start: number = Math.max(
|
|
870
|
+
gap.start.getTime(),
|
|
871
|
+
intended.start.getTime(),
|
|
872
|
+
);
|
|
873
|
+
const end: number = Math.min(gap.end.getTime(), intended.end.getTime());
|
|
874
|
+
|
|
875
|
+
if (end > start) {
|
|
876
|
+
pieces.push({ start: new Date(start), end: new Date(end) });
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
const gaps: Array<TimeSegment> = OnCallCalendarFeedUtil.mergeSegments(
|
|
882
|
+
pieces,
|
|
883
|
+
0,
|
|
884
|
+
).filter((gap: TimeSegment) => {
|
|
885
|
+
return gap.end.getTime() - gap.start.getTime() >= minimumGapMilliseconds;
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
const truncated: boolean = gaps.length > maxGapEvents;
|
|
889
|
+
const kept: Array<TimeSegment> = gaps.slice(0, maxGapEvents);
|
|
890
|
+
|
|
891
|
+
return {
|
|
892
|
+
events: kept.map((gap: TimeSegment) => {
|
|
893
|
+
return OnCallCalendarFeedUtil.gapToEvent(gap, input);
|
|
894
|
+
}),
|
|
895
|
+
gaps: kept,
|
|
896
|
+
truncated,
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
public static gapToEvent(
|
|
901
|
+
gap: TimeSegment,
|
|
902
|
+
input: {
|
|
903
|
+
scheduleId: string;
|
|
904
|
+
scheduleName: string;
|
|
905
|
+
projectId: string;
|
|
906
|
+
lastModifiedAt: Date;
|
|
907
|
+
shiftConfigVersion: number;
|
|
908
|
+
dashboardUrl: string;
|
|
909
|
+
},
|
|
910
|
+
): ICalendarEvent {
|
|
911
|
+
const scheduleName: string =
|
|
912
|
+
OnCallCalendarFeedUtil.cleanName(input.scheduleName) || "Schedule";
|
|
913
|
+
|
|
914
|
+
const url: string = OnCallCalendarFeedUtil.getScheduleUrl(
|
|
915
|
+
input.dashboardUrl,
|
|
916
|
+
input.projectId,
|
|
917
|
+
input.scheduleId,
|
|
918
|
+
);
|
|
919
|
+
|
|
920
|
+
return {
|
|
921
|
+
uid: OnCallCalendarFeedUtil.getGapUid(input.scheduleId, gap.start),
|
|
922
|
+
dtStamp: input.lastModifiedAt,
|
|
923
|
+
lastModified: input.lastModifiedAt,
|
|
924
|
+
sequence: OnCallCalendarFeedUtil.toSequence(input.shiftConfigVersion),
|
|
925
|
+
start: gap.start,
|
|
926
|
+
end: gap.end,
|
|
927
|
+
summary: `No coverage · ${scheduleName}`,
|
|
928
|
+
description: [
|
|
929
|
+
`Nobody is on call for ${scheduleName} during this time, although a layer is meant to cover it.`,
|
|
930
|
+
`Fix the rotation: ${url}`,
|
|
931
|
+
OnCallCalendarFeedUtil.REFRESH_LINE,
|
|
932
|
+
].join("\n"),
|
|
933
|
+
url,
|
|
934
|
+
status: ICalendarEventStatus.Confirmed,
|
|
935
|
+
transparency: ICalendarTransparency.Transparent,
|
|
936
|
+
categories: [OnCallCalendarFeedUtil.CATEGORY],
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/*
|
|
941
|
+
* ---------------------------------------------------------------------
|
|
942
|
+
* Whole documents
|
|
943
|
+
* ---------------------------------------------------------------------
|
|
944
|
+
*/
|
|
945
|
+
|
|
946
|
+
public static buildDocument(input: FeedRenderInput): ICalendarDocument {
|
|
947
|
+
const context: ShiftEventContext = {
|
|
948
|
+
kind: input.kind,
|
|
949
|
+
dashboardUrl: input.dashboardUrl,
|
|
950
|
+
viewerTimezone: input.viewerTimezone,
|
|
951
|
+
};
|
|
952
|
+
|
|
953
|
+
const events: Array<ICalendarEvent> = [
|
|
954
|
+
...OnCallCalendarFeedUtil.shiftsToEvents(
|
|
955
|
+
input.shifts,
|
|
956
|
+
context,
|
|
957
|
+
input.contextShifts,
|
|
958
|
+
),
|
|
959
|
+
...(input.gapEvents ?? []),
|
|
960
|
+
].sort((a: ICalendarEvent, b: ICalendarEvent) => {
|
|
961
|
+
const byStart: number = a.start.getTime() - b.start.getTime();
|
|
962
|
+
if (byStart !== 0) {
|
|
963
|
+
return byStart;
|
|
964
|
+
}
|
|
965
|
+
if (a.uid === b.uid) {
|
|
966
|
+
return 0;
|
|
967
|
+
}
|
|
968
|
+
return a.uid < b.uid ? -1 : 1;
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
const lastModifiedAt: Date | null =
|
|
972
|
+
input.lastModifiedAt ??
|
|
973
|
+
OnCallCalendarFeedUtil.getLatestModification(
|
|
974
|
+
input.shifts,
|
|
975
|
+
input.gapEvents,
|
|
976
|
+
);
|
|
977
|
+
|
|
978
|
+
const calendar: ICalendarCalendar =
|
|
979
|
+
OnCallCalendarFeedUtil.buildCalendarHeader({
|
|
980
|
+
kind: input.kind,
|
|
981
|
+
scheduleName: input.scheduleName,
|
|
982
|
+
projectName: input.projectName,
|
|
983
|
+
filterScheduleName: input.filterScheduleName,
|
|
984
|
+
timezone: input.calendarTimezone ?? input.viewerTimezone,
|
|
985
|
+
lastModifiedAt: lastModifiedAt ?? undefined,
|
|
986
|
+
notes: input.notes,
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
return { calendar, events };
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
public static render(input: FeedRenderInput): FeedRenderResult {
|
|
993
|
+
const document: ICalendarDocument =
|
|
994
|
+
OnCallCalendarFeedUtil.buildDocument(input);
|
|
995
|
+
|
|
996
|
+
return {
|
|
997
|
+
body: ICalendar.serialize(document),
|
|
998
|
+
eventCount: document.events.length,
|
|
999
|
+
lastModifiedAt: document.calendar.lastModified ?? null,
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/*
|
|
1004
|
+
* The empty VCALENDAR every "200 but nothing to show" case serves (feed
|
|
1005
|
+
* disabled, rotated token inside its grace, project below plan, no eligible
|
|
1006
|
+
* schedule). X-WR-CALDESC carries the reason so a subscriber who looks can
|
|
1007
|
+
* tell why their calendar went blank.
|
|
1008
|
+
*/
|
|
1009
|
+
public static renderEmpty(input: EmptyFeedInput): string {
|
|
1010
|
+
const calendar: ICalendarCalendar =
|
|
1011
|
+
OnCallCalendarFeedUtil.buildCalendarHeader({
|
|
1012
|
+
kind: input.kind,
|
|
1013
|
+
scheduleName: input.scheduleName,
|
|
1014
|
+
projectName: input.projectName,
|
|
1015
|
+
filterScheduleName: input.filterScheduleName,
|
|
1016
|
+
timezone: input.timezone,
|
|
1017
|
+
notes: [input.reason],
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
return ICalendar.serialize({ calendar, events: [] });
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
/*
|
|
1024
|
+
* ---------------------------------------------------------------------
|
|
1025
|
+
* Helpers
|
|
1026
|
+
* ---------------------------------------------------------------------
|
|
1027
|
+
*/
|
|
1028
|
+
|
|
1029
|
+
public static isValidTimezone(timezone: string | null | undefined): boolean {
|
|
1030
|
+
return (
|
|
1031
|
+
typeof timezone === "string" &&
|
|
1032
|
+
timezone.trim() !== "" &&
|
|
1033
|
+
moment.tz.zone(timezone) !== null
|
|
1034
|
+
);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// The zone a shift's wall clock is rendered in; legacy schedules use UTC.
|
|
1038
|
+
public static getScheduleZone(shift: MaterializedShift): string {
|
|
1039
|
+
return OnCallCalendarFeedUtil.isValidTimezone(shift.scheduleTimezone)
|
|
1040
|
+
? shift.scheduleTimezone!
|
|
1041
|
+
: Timezone.UTC;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
public static truncateName(name: string, maxLength: number): string {
|
|
1045
|
+
const characters: Array<string> = Array.from(name);
|
|
1046
|
+
|
|
1047
|
+
if (characters.length <= maxLength) {
|
|
1048
|
+
return name;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
return `${characters
|
|
1052
|
+
.slice(0, Math.max(0, maxLength - 1))
|
|
1053
|
+
.join("")
|
|
1054
|
+
.trimEnd()}…`;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
// Sort by start and merge segments that overlap or sit within `toleranceMs`.
|
|
1058
|
+
public static mergeSegments(
|
|
1059
|
+
segments: Array<TimeSegment>,
|
|
1060
|
+
toleranceMilliseconds: number,
|
|
1061
|
+
): Array<TimeSegment> {
|
|
1062
|
+
const sorted: Array<TimeSegment> = segments
|
|
1063
|
+
.filter((segment: TimeSegment) => {
|
|
1064
|
+
return segment.end.getTime() > segment.start.getTime();
|
|
1065
|
+
})
|
|
1066
|
+
.map((segment: TimeSegment) => {
|
|
1067
|
+
return {
|
|
1068
|
+
start: new Date(segment.start.getTime()),
|
|
1069
|
+
end: new Date(segment.end.getTime()),
|
|
1070
|
+
};
|
|
1071
|
+
})
|
|
1072
|
+
.sort((a: TimeSegment, b: TimeSegment) => {
|
|
1073
|
+
return a.start.getTime() - b.start.getTime();
|
|
1074
|
+
});
|
|
1075
|
+
|
|
1076
|
+
const merged: Array<TimeSegment> = [];
|
|
1077
|
+
|
|
1078
|
+
for (const segment of sorted) {
|
|
1079
|
+
const last: TimeSegment | undefined = merged[merged.length - 1];
|
|
1080
|
+
|
|
1081
|
+
if (
|
|
1082
|
+
last &&
|
|
1083
|
+
segment.start.getTime() <= last.end.getTime() + toleranceMilliseconds
|
|
1084
|
+
) {
|
|
1085
|
+
if (segment.end.getTime() > last.end.getTime()) {
|
|
1086
|
+
last.end = segment.end;
|
|
1087
|
+
}
|
|
1088
|
+
continue;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
merged.push(segment);
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
return merged;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
public static getDistinctPolicies(
|
|
1098
|
+
policies: Array<MaterializedShiftPolicy>,
|
|
1099
|
+
): Array<MaterializedShiftPolicy> {
|
|
1100
|
+
const seen: Set<string> = new Set<string>();
|
|
1101
|
+
const distinct: Array<MaterializedShiftPolicy> = [];
|
|
1102
|
+
|
|
1103
|
+
for (const policy of policies) {
|
|
1104
|
+
if (seen.has(policy.policyId)) {
|
|
1105
|
+
continue;
|
|
1106
|
+
}
|
|
1107
|
+
seen.add(policy.policyId);
|
|
1108
|
+
distinct.push(policy);
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
return distinct.sort(
|
|
1112
|
+
(a: MaterializedShiftPolicy, b: MaterializedShiftPolicy) => {
|
|
1113
|
+
return OnCallCalendarFeedUtil.compareStrings(
|
|
1114
|
+
a.policyName,
|
|
1115
|
+
b.policyName,
|
|
1116
|
+
);
|
|
1117
|
+
},
|
|
1118
|
+
);
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
// "Payments › Primary (step 1); Billing › Backup (step 2)"
|
|
1122
|
+
public static describePolicies(
|
|
1123
|
+
policies: Array<MaterializedShiftPolicy>,
|
|
1124
|
+
): string {
|
|
1125
|
+
return [...policies]
|
|
1126
|
+
.sort((a: MaterializedShiftPolicy, b: MaterializedShiftPolicy) => {
|
|
1127
|
+
return (
|
|
1128
|
+
OnCallCalendarFeedUtil.compareStrings(a.policyName, b.policyName) ||
|
|
1129
|
+
a.ruleOrder - b.ruleOrder ||
|
|
1130
|
+
OnCallCalendarFeedUtil.compareStrings(a.ruleName, b.ruleName)
|
|
1131
|
+
);
|
|
1132
|
+
})
|
|
1133
|
+
.map((policy: MaterializedShiftPolicy) => {
|
|
1134
|
+
return `${policy.policyName} › ${policy.ruleName} (step ${policy.ruleOrder})`;
|
|
1135
|
+
})
|
|
1136
|
+
.join("; ");
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
private static buildShiftTimeLines(
|
|
1140
|
+
shift: MaterializedShift,
|
|
1141
|
+
scheduleZone: string,
|
|
1142
|
+
isLegacyZone: boolean,
|
|
1143
|
+
viewerTimezone: string | undefined,
|
|
1144
|
+
): Array<string> {
|
|
1145
|
+
const zones: Array<string> = [scheduleZone];
|
|
1146
|
+
|
|
1147
|
+
if (scheduleZone !== Timezone.UTC) {
|
|
1148
|
+
zones.push(Timezone.UTC);
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
if (
|
|
1152
|
+
OnCallCalendarFeedUtil.isValidTimezone(viewerTimezone) &&
|
|
1153
|
+
!zones.includes(viewerTimezone!)
|
|
1154
|
+
) {
|
|
1155
|
+
zones.push(viewerTimezone!);
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
const starts: Array<string> = OnCallCalendarFeedUtil.formatInZones(
|
|
1159
|
+
shift.start,
|
|
1160
|
+
zones,
|
|
1161
|
+
);
|
|
1162
|
+
const ends: Array<string> = OnCallCalendarFeedUtil.formatInZones(
|
|
1163
|
+
shift.end,
|
|
1164
|
+
zones,
|
|
1165
|
+
);
|
|
1166
|
+
|
|
1167
|
+
const lines: Array<string> = [];
|
|
1168
|
+
|
|
1169
|
+
for (let i: number = 0; i < zones.length; i++) {
|
|
1170
|
+
const zone: string = zones[i]!;
|
|
1171
|
+
const range: string = `${starts[i]} → ${ends[i]}`;
|
|
1172
|
+
|
|
1173
|
+
if (i === 0) {
|
|
1174
|
+
lines.push(
|
|
1175
|
+
`Shift: ${range} (${
|
|
1176
|
+
isLegacyZone
|
|
1177
|
+
? `UTC — ${OnCallCalendarFeedUtil.LEGACY_TIMEZONE_NOTE}`
|
|
1178
|
+
: `${zone} — schedule zone`
|
|
1179
|
+
})`,
|
|
1180
|
+
);
|
|
1181
|
+
continue;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
const label: string =
|
|
1185
|
+
zone === Timezone.UTC && zone !== viewerTimezone
|
|
1186
|
+
? "UTC"
|
|
1187
|
+
: `${zone} (your zone)`;
|
|
1188
|
+
|
|
1189
|
+
lines.push(`Shift in ${label}: ${range}`);
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
return lines;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
/*
|
|
1196
|
+
* Mirror lines on a base shift for every policy variant that takes part of
|
|
1197
|
+
* it over.
|
|
1198
|
+
*
|
|
1199
|
+
* Matching is (same schedule) + (time overlap) + (different person). The
|
|
1200
|
+
* variant's own `policyVariantOf.globalUserId` is deliberately NOT required
|
|
1201
|
+
* to equal this shift's user: the materializer records only the FIRST
|
|
1202
|
+
* overlapping base user, so a variant spanning a handover would otherwise
|
|
1203
|
+
* inform the first person and leave the second — whose shift the variant
|
|
1204
|
+
* also takes over — reading "Pages you via <policy>" for a window in which
|
|
1205
|
+
* somebody else is paged for it.
|
|
1206
|
+
*
|
|
1207
|
+
* The quoted window is clipped to the overlap so the sentence names exactly
|
|
1208
|
+
* the part of THIS shift that is taken over.
|
|
1209
|
+
*/
|
|
1210
|
+
private static buildVariantMirrorLines(
|
|
1211
|
+
shift: MaterializedShift,
|
|
1212
|
+
variantIndex: PolicyVariantIndex,
|
|
1213
|
+
kind: OnCallCalendarFeedKind,
|
|
1214
|
+
scheduleZone: string,
|
|
1215
|
+
): Array<string> {
|
|
1216
|
+
const candidates: Array<MaterializedShift> | undefined = variantIndex.get(
|
|
1217
|
+
shift.scheduleId,
|
|
1218
|
+
);
|
|
1219
|
+
|
|
1220
|
+
if (!candidates || candidates.length === 0) {
|
|
1221
|
+
return [];
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
const lines: Array<string> = [];
|
|
1225
|
+
|
|
1226
|
+
for (const other of candidates) {
|
|
1227
|
+
if (!other.policyVariantOf || other.userId === shift.userId) {
|
|
1228
|
+
continue;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
const overlapStart: number = Math.max(
|
|
1232
|
+
other.start.getTime(),
|
|
1233
|
+
shift.start.getTime(),
|
|
1234
|
+
);
|
|
1235
|
+
const overlapEnd: number = Math.min(
|
|
1236
|
+
other.end.getTime(),
|
|
1237
|
+
shift.end.getTime(),
|
|
1238
|
+
);
|
|
1239
|
+
|
|
1240
|
+
if (overlapStart >= overlapEnd) {
|
|
1241
|
+
continue;
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
lines.push(
|
|
1245
|
+
`For ${other.policyVariantOf.policyName}, ${OnCallCalendarFeedUtil.getDisplayName(
|
|
1246
|
+
other.userName,
|
|
1247
|
+
kind,
|
|
1248
|
+
)} is paged instead from ${OnCallCalendarFeedUtil.formatInZone(
|
|
1249
|
+
new Date(overlapStart),
|
|
1250
|
+
scheduleZone,
|
|
1251
|
+
)} to ${OnCallCalendarFeedUtil.formatInZone(
|
|
1252
|
+
new Date(overlapEnd),
|
|
1253
|
+
scheduleZone,
|
|
1254
|
+
)}.`,
|
|
1255
|
+
);
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
return lines;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
/*
|
|
1262
|
+
* Bucket the policy-variant shifts of a feed by schedule id, sorted inside
|
|
1263
|
+
* each bucket so the DESCRIPTION lines are deterministic (the body cache and
|
|
1264
|
+
* the ETag depend on it). Returns null when there is not a single variant —
|
|
1265
|
+
* the overwhelmingly common case — so the mirror-line pass is skipped
|
|
1266
|
+
* outright instead of scanning every shift for every shift.
|
|
1267
|
+
*/
|
|
1268
|
+
public static buildVariantIndex(
|
|
1269
|
+
shifts: Array<MaterializedShift>,
|
|
1270
|
+
): PolicyVariantIndex | null {
|
|
1271
|
+
let index: PolicyVariantIndex | null = null;
|
|
1272
|
+
|
|
1273
|
+
for (const shift of shifts) {
|
|
1274
|
+
if (!shift.policyVariantOf) {
|
|
1275
|
+
continue;
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
if (!index) {
|
|
1279
|
+
index = new Map<string, Array<MaterializedShift>>();
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
const bucket: Array<MaterializedShift> | undefined = index.get(
|
|
1283
|
+
shift.scheduleId,
|
|
1284
|
+
);
|
|
1285
|
+
|
|
1286
|
+
if (bucket) {
|
|
1287
|
+
bucket.push(shift);
|
|
1288
|
+
} else {
|
|
1289
|
+
index.set(shift.scheduleId, [shift]);
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
if (!index) {
|
|
1294
|
+
return null;
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
for (const bucket of index.values()) {
|
|
1298
|
+
bucket.sort((a: MaterializedShift, b: MaterializedShift) => {
|
|
1299
|
+
return (
|
|
1300
|
+
a.start.getTime() - b.start.getTime() ||
|
|
1301
|
+
a.end.getTime() - b.end.getTime() ||
|
|
1302
|
+
OnCallCalendarFeedUtil.compareStrings(
|
|
1303
|
+
a.policyVariantOf!.policyId,
|
|
1304
|
+
b.policyVariantOf!.policyId,
|
|
1305
|
+
) ||
|
|
1306
|
+
OnCallCalendarFeedUtil.compareStrings(a.userId, b.userId)
|
|
1307
|
+
);
|
|
1308
|
+
});
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
return index;
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
/*
|
|
1315
|
+
* The policies this shift actually pages through. A policy-variant shift
|
|
1316
|
+
* exists for exactly one policy — the schedule's other policies still page
|
|
1317
|
+
* the rostered user — but the materializer stamps every shift of a schedule
|
|
1318
|
+
* with its full attachment set, so listing them all on a variant would
|
|
1319
|
+
* contradict the sentence directly below it. Falls back to the full list if
|
|
1320
|
+
* the variant's policy is somehow not among them, so a shift never claims to
|
|
1321
|
+
* page nobody.
|
|
1322
|
+
*/
|
|
1323
|
+
public static getPagingPolicies(
|
|
1324
|
+
shift: MaterializedShift,
|
|
1325
|
+
): Array<MaterializedShiftPolicy> {
|
|
1326
|
+
if (!shift.policyVariantOf) {
|
|
1327
|
+
return shift.policies;
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
const variantPolicyId: string = shift.policyVariantOf.policyId;
|
|
1331
|
+
|
|
1332
|
+
const scoped: Array<MaterializedShiftPolicy> = shift.policies.filter(
|
|
1333
|
+
(policy: MaterializedShiftPolicy) => {
|
|
1334
|
+
return policy.policyId === variantPolicyId;
|
|
1335
|
+
},
|
|
1336
|
+
);
|
|
1337
|
+
|
|
1338
|
+
return scoped.length > 0 ? scoped : shift.policies;
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
private static formatInZones(
|
|
1342
|
+
date: Date,
|
|
1343
|
+
zones: Array<string>,
|
|
1344
|
+
): Array<string> {
|
|
1345
|
+
return OneUptimeDate.getDateAsFormattedStringInMultipleTimezones({
|
|
1346
|
+
date,
|
|
1347
|
+
timezones: zones as Array<Timezone>,
|
|
1348
|
+
use12HourFormat: false,
|
|
1349
|
+
}).split("\n");
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
// Same formatter as the shift lines, so every date in a DESCRIPTION matches.
|
|
1353
|
+
private static formatInZone(date: Date, zone: string): string {
|
|
1354
|
+
return OnCallCalendarFeedUtil.formatInZones(date, [zone])[0] ?? "";
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
private static findPolicyName(
|
|
1358
|
+
policies: Array<MaterializedShiftPolicy>,
|
|
1359
|
+
policyId: string,
|
|
1360
|
+
): string {
|
|
1361
|
+
const match: MaterializedShiftPolicy | undefined = policies.find(
|
|
1362
|
+
(policy: MaterializedShiftPolicy) => {
|
|
1363
|
+
return policy.policyId === policyId;
|
|
1364
|
+
},
|
|
1365
|
+
);
|
|
1366
|
+
|
|
1367
|
+
return match ? match.policyName : "an escalation policy";
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
private static getLatestModification(
|
|
1371
|
+
shifts: Array<MaterializedShift>,
|
|
1372
|
+
gapEvents: Array<ICalendarEvent> | undefined,
|
|
1373
|
+
): Date | null {
|
|
1374
|
+
let latest: number | null = null;
|
|
1375
|
+
|
|
1376
|
+
for (const shift of shifts) {
|
|
1377
|
+
const time: number = shift.lastModifiedAt.getTime();
|
|
1378
|
+
if (latest === null || time > latest) {
|
|
1379
|
+
latest = time;
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
for (const event of gapEvents ?? []) {
|
|
1384
|
+
const time: number = event.dtStamp.getTime();
|
|
1385
|
+
if (latest === null || time > latest) {
|
|
1386
|
+
latest = time;
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
return latest === null ? null : new Date(latest);
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
private static toSequence(version: number): number {
|
|
1394
|
+
return Number.isFinite(version) ? Math.max(0, Math.floor(version)) : 0;
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
private static epochSeconds(date: Date): number {
|
|
1398
|
+
return Math.floor(date.getTime() / 1000);
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
private static cleanName(name: string | null | undefined): string {
|
|
1402
|
+
return (name ?? "").replace(/\s+/g, " ").trim();
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
private static trimTrailingSlash(url: string): string {
|
|
1406
|
+
return url.replace(/\/+$/, "");
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
private static compareStrings(a: string, b: string): number {
|
|
1410
|
+
if (a === b) {
|
|
1411
|
+
return 0;
|
|
1412
|
+
}
|
|
1413
|
+
return a < b ? -1 : 1;
|
|
1414
|
+
}
|
|
1415
|
+
}
|