@oneuptime/common 12.0.29 → 12.0.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (189) hide show
  1. package/Models/DatabaseModels/Index.ts +11 -0
  2. package/Models/DatabaseModels/OnCallDutyPolicySchedule.ts +43 -0
  3. package/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.ts +662 -0
  4. package/Models/DatabaseModels/ProjectOnCallCalendarFeed.ts +584 -0
  5. package/Models/DatabaseModels/UserOnCallCalendarFeed.ts +546 -0
  6. package/Models/DatabaseModels/UserOnCallShiftReminder.ts +231 -0
  7. package/Models/DatabaseModels/UserOnCallShiftReminderLog.ts +331 -0
  8. package/Server/API/OnCallCalendarAPI.ts +2194 -0
  9. package/Server/EnvironmentConfig.ts +138 -0
  10. package/Server/Infrastructure/OnCallCalendarFeedCache.ts +1050 -0
  11. package/Server/Infrastructure/Postgres/SchemaMigrations/1790400000000-AddOnCallCalendarFeeds.ts +253 -0
  12. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  13. package/Server/Middleware/OnCallCalendarFeedRateLimit.ts +487 -0
  14. package/Server/Services/Index.ts +12 -0
  15. package/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.ts +32 -0
  16. package/Server/Services/OnCallDutyPolicyScheduleCalendarFeedService.ts +260 -0
  17. package/Server/Services/OnCallDutyPolicyScheduleLayerService.ts +70 -3
  18. package/Server/Services/OnCallDutyPolicyScheduleLayerUserService.ts +138 -3
  19. package/Server/Services/OnCallDutyPolicyScheduleService.ts +1465 -134
  20. package/Server/Services/OnCallDutyPolicyUserOverrideService.ts +167 -31
  21. package/Server/Services/ProjectOnCallCalendarFeedService.ts +192 -0
  22. package/Server/Services/TeamMemberService.ts +489 -1
  23. package/Server/Services/TeamService.ts +50 -0
  24. package/Server/Services/UserNotificationSettingService.ts +44 -1
  25. package/Server/Services/UserOnCallCalendarFeedService.ts +159 -0
  26. package/Server/Services/UserOnCallShiftReminderLogService.ts +151 -0
  27. package/Server/Services/UserOnCallShiftReminderService.ts +112 -0
  28. package/Server/Utils/OnCall/CalendarFeedToken.ts +241 -0
  29. package/Server/Utils/OnCall/OnCallCalendarFeedRenderer.ts +1488 -0
  30. package/Server/Utils/OnCall/OnCallCalendarFeedUrls.ts +230 -0
  31. package/Server/Utils/OnCall/OnCallShiftChangeListeners.ts +227 -0
  32. package/Server/Utils/OnCall/OnCallShiftMaterializer.ts +1127 -0
  33. package/Server/Utils/OnCall/OnCallShiftReminderListener.ts +174 -0
  34. package/Server/Utils/OnCall/OnCallShiftReminderRunner.ts +2584 -0
  35. package/Server/Utils/Response.ts +134 -0
  36. package/Server/Utils/StartServer.ts +10 -0
  37. package/Server/Utils/Telemetry/AppMetrics.ts +51 -0
  38. package/Tests/App/Dashboard/OnCallCalendarFeedLinks.test.tsx +229 -0
  39. package/Tests/App/Dashboard/OnCallCalendarFeedPlanGate.test.tsx +489 -0
  40. package/Tests/App/Dashboard/OnCallCalendarFeedSideMenus.test.tsx +149 -0
  41. package/Tests/App/Dashboard/OnCallCalendarFeedUtil.test.ts +855 -0
  42. package/Tests/App/Dashboard/OnCallPersonalCalendarFeed.test.tsx +1209 -0
  43. package/Tests/App/Dashboard/OnCallSharedCalendarFeedCard.test.tsx +1019 -0
  44. package/Tests/App/Dashboard/OnCallShiftRemindersAndUpcomingShifts.test.tsx +655 -0
  45. package/Tests/App/Dashboard/UserSettingsSetupChecklistModel.test.ts +73 -0
  46. package/Tests/Models/OnCallCalendarFeedModels.test.ts +642 -0
  47. package/Tests/Models/OnCallDutyPolicyScheduleCalendarFeed.test.ts +288 -0
  48. package/Tests/Models/OnCallDutyPolicyScheduleShiftConfigVersion.test.ts +88 -0
  49. package/Tests/Models/UserOnCallShiftReminderModels.test.ts +368 -0
  50. package/Tests/Server/API/Helpers.ts +8 -0
  51. package/Tests/Server/API/OnCallCalendarAPI.test.ts +4461 -0
  52. package/Tests/Server/EnvironmentConfigOnCallCalendarFeed.test.ts +357 -0
  53. package/Tests/Server/Infrastructure/OnCallCalendarFeedCache.test.ts +1589 -0
  54. package/Tests/Server/Infrastructure/Postgres/AddOnCallCalendarFeedsMigration.test.ts +369 -0
  55. package/Tests/Server/Middleware/OnCallCalendarFeedRateLimit.test.ts +1365 -0
  56. package/Tests/Server/Services/FeedRetentionConsistency.test.ts +70 -0
  57. package/Tests/Server/Services/OnCallCalendarFeedServices.test.ts +1103 -0
  58. package/Tests/Server/Services/OnCallDutyPolicyScheduleResolver.test.ts +1037 -0
  59. package/Tests/Server/Services/OnCallShiftConfigPropagation.test.ts +1270 -0
  60. package/Tests/Server/Services/TeamDeleteMemberCleanup.test.ts +156 -0
  61. package/Tests/Server/Services/TeamMemberOnCallCleanup.test.ts +755 -0
  62. package/Tests/Server/Services/UserNotificationSettingShiftReminderDefaults.test.ts +267 -0
  63. package/Tests/Server/Services/UserOnCallShiftReminderServices.test.ts +583 -0
  64. package/Tests/Server/Types/Database/Permissions/OnCallCalendarFeedPermissions.test.ts +254 -0
  65. package/Tests/Server/Utils/OnCall/CalendarFeedToken.test.ts +467 -0
  66. package/Tests/Server/Utils/OnCall/OnCallCalendarFeedRenderer.test.ts +2142 -0
  67. package/Tests/Server/Utils/OnCall/OnCallCalendarFeedUrls.test.ts +278 -0
  68. package/Tests/Server/Utils/OnCall/OnCallResolverTestHarness.ts +440 -0
  69. package/Tests/Server/Utils/OnCall/OnCallShiftChangeListeners.test.ts +314 -0
  70. package/Tests/Server/Utils/OnCall/OnCallShiftMaterializer.test.ts +1502 -0
  71. package/Tests/Server/Utils/OnCall/OnCallShiftReminderListener.test.ts +491 -0
  72. package/Tests/Server/Utils/OnCall/OnCallShiftReminderRunner.test.ts +3088 -0
  73. package/Tests/Server/Utils/OnCall/OnCallShiftReminderTestHarness.ts +910 -0
  74. package/Tests/Server/Utils/ResponseSendCalendarResponse.test.ts +561 -0
  75. package/Tests/Server/Utils/StartServerEncryptionSecretWarning.test.ts +64 -0
  76. package/Tests/Server/Utils/Telemetry/AppMetrics.test.ts +29 -0
  77. package/Tests/Types/Calendar/ICalendar.test.ts +488 -0
  78. package/Tests/Types/NotificationSetting/NotificationSettingEventType.test.ts +53 -0
  79. package/Tests/Types/OnCallDutyPolicy/CalendarFeedTestFixtures.ts +310 -0
  80. package/Tests/Types/OnCallDutyPolicy/CalendarFeedWindow.test.ts +205 -0
  81. package/Tests/Types/OnCallDutyPolicy/LayerUtilLayerMetaAndIterationCap.test.ts +458 -0
  82. package/Tests/Types/OnCallDutyPolicy/MaterializedShift.test.ts +197 -0
  83. package/Tests/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.test.ts +1986 -0
  84. package/Tests/Types/OnCallDutyPolicy/ScheduleShiftUtilGroupKey.test.ts +678 -0
  85. package/Tests/Types/OnCallDutyPolicy/ShiftSeamUtil.test.ts +417 -0
  86. package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +10 -0
  87. package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +2 -0
  88. package/Types/Calendar/ICalendar.ts +442 -0
  89. package/Types/Email/EmailTemplateType.ts +2 -0
  90. package/Types/NotificationSetting/NotificationSettingEventType.ts +4 -0
  91. package/Types/OnCallDutyPolicy/CalendarFeedWindow.ts +140 -0
  92. package/Types/OnCallDutyPolicy/Layer.ts +190 -35
  93. package/Types/OnCallDutyPolicy/MaterializedShift.ts +274 -0
  94. package/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.ts +1415 -0
  95. package/Types/OnCallDutyPolicy/ScheduleShiftUtil.ts +159 -4
  96. package/Types/OnCallDutyPolicy/ShiftSeamUtil.ts +106 -0
  97. package/build/dist/Models/DatabaseModels/Index.js +11 -0
  98. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  99. package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js +44 -0
  100. package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js.map +1 -1
  101. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.js +699 -0
  102. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.js.map +1 -0
  103. package/build/dist/Models/DatabaseModels/ProjectOnCallCalendarFeed.js +620 -0
  104. package/build/dist/Models/DatabaseModels/ProjectOnCallCalendarFeed.js.map +1 -0
  105. package/build/dist/Models/DatabaseModels/UserOnCallCalendarFeed.js +585 -0
  106. package/build/dist/Models/DatabaseModels/UserOnCallCalendarFeed.js.map +1 -0
  107. package/build/dist/Models/DatabaseModels/UserOnCallShiftReminder.js +247 -0
  108. package/build/dist/Models/DatabaseModels/UserOnCallShiftReminder.js.map +1 -0
  109. package/build/dist/Models/DatabaseModels/UserOnCallShiftReminderLog.js +355 -0
  110. package/build/dist/Models/DatabaseModels/UserOnCallShiftReminderLog.js.map +1 -0
  111. package/build/dist/Server/API/OnCallCalendarAPI.js +1371 -0
  112. package/build/dist/Server/API/OnCallCalendarAPI.js.map +1 -0
  113. package/build/dist/Server/EnvironmentConfig.js +102 -0
  114. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  115. package/build/dist/Server/Infrastructure/OnCallCalendarFeedCache.js +663 -0
  116. package/build/dist/Server/Infrastructure/OnCallCalendarFeedCache.js.map +1 -0
  117. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790400000000-AddOnCallCalendarFeeds.js +103 -0
  118. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790400000000-AddOnCallCalendarFeeds.js.map +1 -0
  119. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  120. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  121. package/build/dist/Server/Middleware/OnCallCalendarFeedRateLimit.js +342 -0
  122. package/build/dist/Server/Middleware/OnCallCalendarFeedRateLimit.js.map +1 -0
  123. package/build/dist/Server/Services/Index.js +12 -0
  124. package/build/dist/Server/Services/Index.js.map +1 -1
  125. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js +20 -0
  126. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js.map +1 -1
  127. package/build/dist/Server/Services/OnCallDutyPolicyScheduleCalendarFeedService.js +219 -0
  128. package/build/dist/Server/Services/OnCallDutyPolicyScheduleCalendarFeedService.js.map +1 -0
  129. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerService.js +47 -3
  130. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerService.js.map +1 -1
  131. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerUserService.js +112 -3
  132. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerUserService.js.map +1 -1
  133. package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js +955 -9
  134. package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js.map +1 -1
  135. package/build/dist/Server/Services/OnCallDutyPolicyUserOverrideService.js +93 -6
  136. package/build/dist/Server/Services/OnCallDutyPolicyUserOverrideService.js.map +1 -1
  137. package/build/dist/Server/Services/ProjectOnCallCalendarFeedService.js +157 -0
  138. package/build/dist/Server/Services/ProjectOnCallCalendarFeedService.js.map +1 -0
  139. package/build/dist/Server/Services/TeamMemberService.js +396 -1
  140. package/build/dist/Server/Services/TeamMemberService.js.map +1 -1
  141. package/build/dist/Server/Services/TeamService.js +43 -0
  142. package/build/dist/Server/Services/TeamService.js.map +1 -1
  143. package/build/dist/Server/Services/UserNotificationSettingService.js +26 -1
  144. package/build/dist/Server/Services/UserNotificationSettingService.js.map +1 -1
  145. package/build/dist/Server/Services/UserOnCallCalendarFeedService.js +140 -0
  146. package/build/dist/Server/Services/UserOnCallCalendarFeedService.js.map +1 -0
  147. package/build/dist/Server/Services/UserOnCallShiftReminderLogService.js +116 -0
  148. package/build/dist/Server/Services/UserOnCallShiftReminderLogService.js.map +1 -0
  149. package/build/dist/Server/Services/UserOnCallShiftReminderService.js +92 -0
  150. package/build/dist/Server/Services/UserOnCallShiftReminderService.js.map +1 -0
  151. package/build/dist/Server/Utils/OnCall/CalendarFeedToken.js +161 -0
  152. package/build/dist/Server/Utils/OnCall/CalendarFeedToken.js.map +1 -0
  153. package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedRenderer.js +983 -0
  154. package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedRenderer.js.map +1 -0
  155. package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedUrls.js +155 -0
  156. package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedUrls.js.map +1 -0
  157. package/build/dist/Server/Utils/OnCall/OnCallShiftChangeListeners.js +145 -0
  158. package/build/dist/Server/Utils/OnCall/OnCallShiftChangeListeners.js.map +1 -0
  159. package/build/dist/Server/Utils/OnCall/OnCallShiftMaterializer.js +752 -0
  160. package/build/dist/Server/Utils/OnCall/OnCallShiftMaterializer.js.map +1 -0
  161. package/build/dist/Server/Utils/OnCall/OnCallShiftReminderListener.js +92 -0
  162. package/build/dist/Server/Utils/OnCall/OnCallShiftReminderListener.js.map +1 -0
  163. package/build/dist/Server/Utils/OnCall/OnCallShiftReminderRunner.js +1786 -0
  164. package/build/dist/Server/Utils/OnCall/OnCallShiftReminderRunner.js.map +1 -0
  165. package/build/dist/Server/Utils/Response.js +97 -0
  166. package/build/dist/Server/Utils/Response.js.map +1 -1
  167. package/build/dist/Server/Utils/StartServer.js +9 -1
  168. package/build/dist/Server/Utils/StartServer.js.map +1 -1
  169. package/build/dist/Server/Utils/Telemetry/AppMetrics.js +41 -0
  170. package/build/dist/Server/Utils/Telemetry/AppMetrics.js.map +1 -1
  171. package/build/dist/Types/Calendar/ICalendar.js +290 -0
  172. package/build/dist/Types/Calendar/ICalendar.js.map +1 -0
  173. package/build/dist/Types/Email/EmailTemplateType.js +2 -0
  174. package/build/dist/Types/Email/EmailTemplateType.js.map +1 -1
  175. package/build/dist/Types/NotificationSetting/NotificationSettingEventType.js +3 -0
  176. package/build/dist/Types/NotificationSetting/NotificationSettingEventType.js.map +1 -1
  177. package/build/dist/Types/OnCallDutyPolicy/CalendarFeedWindow.js +97 -0
  178. package/build/dist/Types/OnCallDutyPolicy/CalendarFeedWindow.js.map +1 -0
  179. package/build/dist/Types/OnCallDutyPolicy/Layer.js +113 -19
  180. package/build/dist/Types/OnCallDutyPolicy/Layer.js.map +1 -1
  181. package/build/dist/Types/OnCallDutyPolicy/MaterializedShift.js +134 -0
  182. package/build/dist/Types/OnCallDutyPolicy/MaterializedShift.js.map +1 -0
  183. package/build/dist/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.js +800 -0
  184. package/build/dist/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.js.map +1 -0
  185. package/build/dist/Types/OnCallDutyPolicy/ScheduleShiftUtil.js +97 -3
  186. package/build/dist/Types/OnCallDutyPolicy/ScheduleShiftUtil.js.map +1 -1
  187. package/build/dist/Types/OnCallDutyPolicy/ShiftSeamUtil.js +77 -0
  188. package/build/dist/Types/OnCallDutyPolicy/ShiftSeamUtil.js.map +1 -0
  189. package/package.json +1 -1
@@ -0,0 +1,1488 @@
1
+ import DatabaseConfig from "../../DatabaseConfig";
2
+ import { IsBillingEnabled, getAllEnvVars } from "../../EnvironmentConfig";
3
+ import OnCallCalendarFeedCache, {
4
+ CachedCalendarBody,
5
+ OnCallCalendarFeedCacheScope,
6
+ ScheduleSegmentsCacheEntry,
7
+ } from "../../Infrastructure/OnCallCalendarFeedCache";
8
+ import OnCallDutyPolicyScheduleService from "../../Services/OnCallDutyPolicyScheduleService";
9
+ import ProjectService from "../../Services/ProjectService";
10
+ import UserService from "../../Services/UserService";
11
+ import QueryHelper from "../../Types/Database/QueryHelper";
12
+ import logger from "../Logger";
13
+ import Response from "../Response";
14
+ import AppMetrics from "../Telemetry/AppMetrics";
15
+ import CaptureSpan from "../Telemetry/CaptureSpan";
16
+ import OnCallCalendarFeedUrls from "./OnCallCalendarFeedUrls";
17
+ import OnCallShiftMaterializer, {
18
+ MaterializeResult,
19
+ MaterializedScheduleInfo,
20
+ } from "./OnCallShiftMaterializer";
21
+ import OnCallDutyPolicySchedule from "../../../Models/DatabaseModels/OnCallDutyPolicySchedule";
22
+ import Project from "../../../Models/DatabaseModels/Project";
23
+ import User from "../../../Models/DatabaseModels/User";
24
+ import SubscriptionPlan, {
25
+ PlanType,
26
+ } from "../../../Types/Billing/SubscriptionPlan";
27
+ import { ICalendarEvent } from "../../../Types/Calendar/ICalendar";
28
+ import OneUptimeDate from "../../../Types/Date";
29
+ import { LIMIT_PER_PROJECT } from "../../../Types/Database/LimitMax";
30
+ import ObjectID from "../../../Types/ObjectID";
31
+ import SortOrder from "../../../Types/BaseDatabase/SortOrder";
32
+ import CalendarFeedWindow, {
33
+ BODY_CACHE_TTL_SECONDS,
34
+ FEED_SIMULATION_ITERATION_CAP,
35
+ FeedWindow,
36
+ MAX_EVENTS,
37
+ MAX_GAP_EVENTS,
38
+ SCHEDULE_CACHE_TTL_SECONDS,
39
+ } from "../../../Types/OnCallDutyPolicy/CalendarFeedWindow";
40
+ import MaterializedShiftUtil, {
41
+ MaterializedShift,
42
+ MaterializedShiftJson,
43
+ } from "../../../Types/OnCallDutyPolicy/MaterializedShift";
44
+ import OnCallCalendarFeedUtil, {
45
+ CoverageEnvelopeResult,
46
+ CoverageGapEventsResult,
47
+ FeedRenderResult,
48
+ OnCallCalendarFeedKind,
49
+ WindowShrinkResult,
50
+ } from "../../../Types/OnCallDutyPolicy/OnCallCalendarFeedUtil";
51
+ import { TimeSegment } from "../../../Types/OnCallDutyPolicy/ShiftSeamUtil";
52
+ import { createHash } from "crypto";
53
+
54
+ /*
55
+ * Turns one feed row plus its request into a calendar body.
56
+ *
57
+ * Everything between "the token resolved to this row" and "send these bytes"
58
+ * lives here so the three public routes (personal, schedule, project) and
59
+ * the session-authenticated /my-shifts route share one pipeline:
60
+ *
61
+ * authorisation (re-derived on EVERY fetch; a valid token is not
62
+ * authorisation) -> body cache -> render slot -> schedule-level cache ->
63
+ * materialize -> shrink to MAX_EVENTS -> coverage gaps -> serialize ->
64
+ * body + last-good cache -> metrics.
65
+ *
66
+ * Three outcomes come out of it, matching the one response rule the routes
67
+ * apply: a rendered body (200), an empty calendar that says why (200, so
68
+ * subscribed clients clear their copy instead of dropping the calendar), or
69
+ * "unavailable" (503 + Retry-After) when nothing at all can be served.
70
+ *
71
+ * Stale-while-error: when a render throws, hits the per-process render cap
72
+ * or trips a schedule's iteration cap, the most recent body that rendered
73
+ * successfully for the same key is served instead, flagged `stale` so the
74
+ * route adds `Warning: 110` and the bookkeeping records lastRenderTruncated.
75
+ * Only when there is nothing to fall back to does the outcome turn into a
76
+ * 503.
77
+ *
78
+ * Nothing here ever sees the plaintext token. The body-cache key carries the
79
+ * token HASH (so a rotation is a new key) and the log lines carry the feed
80
+ * kind and row id.
81
+ */
82
+
83
+ export enum FeedRenderStatus {
84
+ Rendered = "rendered",
85
+ Empty = "empty",
86
+ Unavailable = "unavailable",
87
+ }
88
+
89
+ export interface FeedRenderOutcome {
90
+ status: FeedRenderStatus;
91
+ kind: OnCallCalendarFeedKind;
92
+ body: string;
93
+ etag: string;
94
+ lastModified: Date;
95
+ /* Served from the last-good tier after a failed or capped render. */
96
+ stale: boolean;
97
+ /* Window shrunk, iteration cap hit, or gap list capped -- anything cut. */
98
+ truncated: boolean;
99
+ eventCount: number;
100
+ /* The body came from the body cache (no render happened). */
101
+ cacheHit: boolean;
102
+ /* Why the calendar is empty (status Empty); goes into X-WR-CALDESC. */
103
+ reason: string | null;
104
+ /* For status Unavailable: what to put in Retry-After. */
105
+ retryAfterSeconds: number | null;
106
+ }
107
+
108
+ interface BaseFeedRenderRequest {
109
+ feedId: ObjectID;
110
+ projectId: ObjectID;
111
+ /* The stored lookup hash, used in cache keys. NEVER the token. */
112
+ tokenHash: string;
113
+ pastDays: number;
114
+ futureDays: number;
115
+ now?: Date | undefined;
116
+ }
117
+
118
+ export interface PersonalFeedRenderRequest extends BaseFeedRenderRequest {
119
+ kind: OnCallCalendarFeedKind.Personal;
120
+ userId: ObjectID;
121
+ includeCoveringShifts: boolean;
122
+ /* The `?schedule=` filter, already validated as a UUID. */
123
+ scheduleFilterId?: ObjectID | undefined;
124
+ }
125
+
126
+ export interface ScheduleFeedRenderRequest extends BaseFeedRenderRequest {
127
+ kind: OnCallCalendarFeedKind.Schedule;
128
+ scheduleId: ObjectID;
129
+ includeCoverageGaps: boolean;
130
+ minimumGapMinutes: number;
131
+ }
132
+
133
+ export interface ProjectFeedRenderRequest extends BaseFeedRenderRequest {
134
+ kind: OnCallCalendarFeedKind.Project;
135
+ includeCoverageGaps: boolean;
136
+ minimumGapMinutes: number;
137
+ }
138
+
139
+ export type FeedRenderRequest =
140
+ | PersonalFeedRenderRequest
141
+ | ScheduleFeedRenderRequest
142
+ | ProjectFeedRenderRequest;
143
+
144
+ /* One schedule's cached, JSON-safe materialization over one window. */
145
+ export interface CachedScheduleSegments {
146
+ scheduleId: string;
147
+ scheduleName: string;
148
+ scheduleTimezone: string | null;
149
+ projectId: string;
150
+ projectName: string | null;
151
+ shiftConfigVersion: number;
152
+ lastModifiedAt: string;
153
+ /* The engine hit its iteration cap: shifts may be missing. */
154
+ truncated: boolean;
155
+ /* Every user's shifts on the schedule, policy variants included. */
156
+ shifts: Array<MaterializedShiftJson>;
157
+ /* Where the layers INTENDED coverage (for gap events), ISO strings. */
158
+ envelope: Array<{ start: string; end: string }>;
159
+ envelopeTruncated: boolean;
160
+ }
161
+
162
+ export interface UserShiftsResult {
163
+ shifts: Array<MaterializedShift>;
164
+ truncated: boolean;
165
+ generatedAt: Date;
166
+ }
167
+
168
+ export interface EmptyOutcomeOptions {
169
+ kind: OnCallCalendarFeedKind;
170
+ reason: string;
171
+ scheduleName?: string | undefined;
172
+ projectName?: string | undefined;
173
+ filterScheduleName?: string | undefined;
174
+ timezone?: string | undefined;
175
+ now?: Date | undefined;
176
+ }
177
+
178
+ /* The schedule facts every render needs, read once per request. */
179
+ interface ScheduleInfo {
180
+ id: ObjectID;
181
+ name: string;
182
+ timezone?: string | undefined;
183
+ projectId: ObjectID;
184
+ shiftConfigVersion: number;
185
+ }
186
+
187
+ /* What resolveContext hands the pipeline, or the reason it cannot. */
188
+ type RenderContext =
189
+ | {
190
+ empty: false;
191
+ schedules: Array<ScheduleInfo>;
192
+ viewerTimezone?: string | undefined;
193
+ calendarTimezone?: string | undefined;
194
+ scheduleName?: string | undefined;
195
+ projectName?: string | undefined;
196
+ filterScheduleName?: string | undefined;
197
+ }
198
+ | {
199
+ empty: true;
200
+ reason: string;
201
+ scheduleName?: string | undefined;
202
+ projectName?: string | undefined;
203
+ filterScheduleName?: string | undefined;
204
+ timezone?: string | undefined;
205
+ };
206
+
207
+ export const RENDER_CAP_RETRY_AFTER_SECONDS: number = 60;
208
+
209
+ /*
210
+ * The X-WR-CALDESC sentences for every "200 but empty" case. Exported so the
211
+ * routes, the tests and (through the docs) the users see the same words.
212
+ */
213
+ export const FEED_DISABLED_REASON: string =
214
+ "This calendar feed is turned off in OneUptime. Turn it back on from the Calendar Feed settings to see shifts here again.";
215
+
216
+ export const TOKEN_ROTATED_REASON: string =
217
+ "This calendar link was regenerated in OneUptime. Subscribe to the new link from the Calendar Feed settings; this one will stop working soon.";
218
+
219
+ export const PLAN_REASON: string =
220
+ "Calendar feeds need the Growth plan or higher. Upgrade the project's plan in OneUptime to see shifts here.";
221
+
222
+ export const USER_MISSING_REASON: string =
223
+ "The user this calendar link belongs to no longer exists.";
224
+
225
+ export const NO_SCHEDULES_REASON: string =
226
+ "You are not on any on-call schedule in this project right now. Shifts appear here once you are added to a schedule.";
227
+
228
+ export const FILTERED_SCHEDULE_REASON: string =
229
+ "You are not on the on-call schedule this calendar link is filtered to.";
230
+
231
+ export const SCHEDULE_MISSING_REASON: string =
232
+ "The on-call schedule this calendar link belongs to no longer exists.";
233
+
234
+ export const PROJECT_MISSING_REASON: string =
235
+ "The project this calendar link belongs to no longer exists.";
236
+
237
+ export const NO_PROJECT_SCHEDULES_REASON: string =
238
+ "This project has no on-call schedules yet. Shifts appear here once a schedule is created.";
239
+
240
+ export default class OnCallCalendarFeedRenderer {
241
+ // -- Outcomes ----------------------------------------------------------
242
+
243
+ /*
244
+ * The empty VCALENDAR outcome for one of the "200 but nothing to show"
245
+ * cases. Cheap enough not to cache: it is a header and a sentence.
246
+ */
247
+ public static buildEmptyOutcome(
248
+ options: EmptyOutcomeOptions,
249
+ ): FeedRenderOutcome {
250
+ const now: Date = options.now || OneUptimeDate.getCurrentDate();
251
+
252
+ const body: string = OnCallCalendarFeedUtil.renderEmpty({
253
+ kind: options.kind,
254
+ reason: options.reason,
255
+ scheduleName: options.scheduleName,
256
+ projectName: options.projectName,
257
+ filterScheduleName: options.filterScheduleName,
258
+ timezone: options.timezone,
259
+ });
260
+
261
+ return {
262
+ status: FeedRenderStatus.Empty,
263
+ kind: options.kind,
264
+ body,
265
+ etag: Response.getCalendarETag(body),
266
+ lastModified: now,
267
+ stale: false,
268
+ truncated: false,
269
+ eventCount: 0,
270
+ cacheHit: false,
271
+ reason: options.reason,
272
+ retryAfterSeconds: null,
273
+ };
274
+ }
275
+
276
+ public static buildUnavailableOutcome(
277
+ kind: OnCallCalendarFeedKind,
278
+ retryAfterSeconds: number,
279
+ ): FeedRenderOutcome {
280
+ return {
281
+ status: FeedRenderStatus.Unavailable,
282
+ kind,
283
+ body: "",
284
+ etag: "",
285
+ lastModified: OneUptimeDate.getCurrentDate(),
286
+ stale: false,
287
+ truncated: false,
288
+ eventCount: 0,
289
+ cacheHit: false,
290
+ reason: null,
291
+ retryAfterSeconds,
292
+ };
293
+ }
294
+
295
+ private static outcomeFromCachedBody(data: {
296
+ kind: OnCallCalendarFeedKind;
297
+ cached: CachedCalendarBody;
298
+ stale: boolean;
299
+ cacheHit: boolean;
300
+ truncated: boolean;
301
+ }): FeedRenderOutcome {
302
+ return {
303
+ status: FeedRenderStatus.Rendered,
304
+ kind: data.kind,
305
+ body: data.cached.body,
306
+ etag: data.cached.etag,
307
+ lastModified: data.cached.lastModified,
308
+ stale: data.stale,
309
+ truncated: data.truncated,
310
+ eventCount: OnCallCalendarFeedRenderer.countEvents(data.cached.body),
311
+ cacheHit: data.cacheHit,
312
+ reason: null,
313
+ retryAfterSeconds: null,
314
+ };
315
+ }
316
+
317
+ // -- Plan --------------------------------------------------------------
318
+
319
+ /*
320
+ * Decision 3: below-plan projects render an EMPTY calendar, never a 404.
321
+ * The check is imperative because the public routes read as root, so the
322
+ * models' @TableBillingAccessControl never runs for them. A project whose
323
+ * plan cannot be read (deleted mid-request, or no plan yet) is treated as
324
+ * below plan: failing closed here costs one empty calendar, failing open
325
+ * would hand out rosters for free.
326
+ */
327
+ @CaptureSpan()
328
+ public static async isProjectOnPlan(
329
+ projectId: ObjectID,
330
+ options?: { billingEnabled?: boolean | undefined } | undefined,
331
+ ): Promise<boolean> {
332
+ const billingEnabled: boolean =
333
+ options?.billingEnabled === undefined
334
+ ? IsBillingEnabled
335
+ : options.billingEnabled;
336
+
337
+ if (!billingEnabled) {
338
+ return true;
339
+ }
340
+
341
+ try {
342
+ const current: { plan: PlanType | null; isSubscriptionUnpaid: boolean } =
343
+ await ProjectService.getCurrentPlan(projectId);
344
+
345
+ if (!current.plan) {
346
+ return false;
347
+ }
348
+
349
+ return SubscriptionPlan.isFeatureAccessibleOnCurrentPlan(
350
+ PlanType.Growth,
351
+ current.plan,
352
+ getAllEnvVars(),
353
+ );
354
+ } catch (err) {
355
+ logger.warn(
356
+ `OnCallCalendarFeedRenderer: could not read the plan of project ${projectId.toString()}; treating it as below plan.`,
357
+ );
358
+ logger.warn(err);
359
+ return false;
360
+ }
361
+ }
362
+
363
+ // -- Render ------------------------------------------------------------
364
+
365
+ @CaptureSpan()
366
+ public static async render(
367
+ request: FeedRenderRequest,
368
+ ): Promise<FeedRenderOutcome> {
369
+ const now: Date = request.now || OneUptimeDate.getCurrentDate();
370
+
371
+ const pastDays: number = CalendarFeedWindow.clampPastDays(request.pastDays);
372
+ const futureDays: number = CalendarFeedWindow.clampFutureDays(
373
+ request.futureDays,
374
+ );
375
+
376
+ const window: FeedWindow = CalendarFeedWindow.computeFeedWindow({
377
+ now,
378
+ pastDays,
379
+ futureDays,
380
+ });
381
+
382
+ const context: RenderContext =
383
+ await OnCallCalendarFeedRenderer.resolveContext(request, window);
384
+
385
+ if (context.empty) {
386
+ return OnCallCalendarFeedRenderer.buildEmptyOutcome({
387
+ kind: request.kind,
388
+ reason: context.reason,
389
+ scheduleName: context.scheduleName,
390
+ projectName: context.projectName,
391
+ filterScheduleName: context.filterScheduleName,
392
+ timezone: context.timezone,
393
+ now,
394
+ });
395
+ }
396
+
397
+ const scheduleIds: Array<string> = context.schedules.map(
398
+ (schedule: ScheduleInfo) => {
399
+ return schedule.id.toString();
400
+ },
401
+ );
402
+
403
+ const bodyKey: string = OnCallCalendarFeedRenderer.buildBodyCacheKey({
404
+ request,
405
+ pastDays,
406
+ futureDays,
407
+ schedules: context.schedules,
408
+ now,
409
+ });
410
+
411
+ const lastGoodKey: string =
412
+ OnCallCalendarFeedRenderer.buildLastGoodCacheKey({
413
+ request,
414
+ pastDays,
415
+ futureDays,
416
+ schedules: context.schedules,
417
+ });
418
+
419
+ const scope: OnCallCalendarFeedCacheScope = {
420
+ projectId: request.projectId.toString(),
421
+ userId:
422
+ request.kind === OnCallCalendarFeedKind.Personal
423
+ ? request.userId.toString()
424
+ : undefined,
425
+ scheduleIds,
426
+ };
427
+
428
+ const cachedBody: CachedCalendarBody | null =
429
+ await OnCallCalendarFeedCache.getBody({ key: bodyKey, scope });
430
+
431
+ if (cachedBody) {
432
+ return OnCallCalendarFeedRenderer.outcomeFromCachedBody({
433
+ kind: request.kind,
434
+ cached: cachedBody,
435
+ stale: false,
436
+ cacheHit: true,
437
+ truncated: false,
438
+ });
439
+ }
440
+
441
+ if (!OnCallCalendarFeedCache.tryAcquireRenderSlot()) {
442
+ return await OnCallCalendarFeedRenderer.fallBackToLastGood({
443
+ kind: request.kind,
444
+ lastGoodKey,
445
+ why: "render cap reached",
446
+ });
447
+ }
448
+
449
+ const startedAt: number = Date.now();
450
+
451
+ try {
452
+ const segments: Array<CachedScheduleSegments> =
453
+ await OnCallCalendarFeedRenderer.loadScheduleSegmentsBatch({
454
+ schedules: context.schedules,
455
+ windowStart: window.feedStart,
456
+ windowEnd: window.feedEnd,
457
+ now,
458
+ });
459
+
460
+ const iterationTruncated: Array<CachedScheduleSegments> = segments.filter(
461
+ (segment: CachedScheduleSegments) => {
462
+ return segment.truncated;
463
+ },
464
+ );
465
+
466
+ /*
467
+ * A capped expansion is missing shifts. A complete body from an
468
+ * earlier render is better than a partial one now; only when there is
469
+ * none does the partial render go out, with a note saying so.
470
+ */
471
+ if (iterationTruncated.length > 0) {
472
+ const lastGood: CachedCalendarBody | null =
473
+ await OnCallCalendarFeedCache.getLastGood(lastGoodKey);
474
+
475
+ if (lastGood) {
476
+ logger.warn(
477
+ `OnCallCalendarFeedRenderer: ${request.kind} feed ${request.feedId.toString()} hit the iteration cap; serving the last good body.`,
478
+ );
479
+
480
+ return OnCallCalendarFeedRenderer.outcomeFromCachedBody({
481
+ kind: request.kind,
482
+ cached: lastGood,
483
+ stale: true,
484
+ cacheHit: false,
485
+ truncated: true,
486
+ });
487
+ }
488
+ }
489
+
490
+ /*
491
+ * The schedules' full resolution, before the personal feed narrows it to
492
+ * the subscriber. A policy-scoped override materializes as a variant
493
+ * shift belonging to the SUBSTITUTE, so filtering it away would leave
494
+ * the rostered user's own event claiming to page them through a policy
495
+ * that pages somebody else. The unfiltered list is handed to the mapper
496
+ * as context (it never becomes VEVENTs) so that event can say so.
497
+ */
498
+ const allScheduleShifts: Array<MaterializedShift> =
499
+ OnCallCalendarFeedRenderer.collectShifts(segments, now);
500
+
501
+ let shifts: Array<MaterializedShift> = allScheduleShifts;
502
+
503
+ if (request.kind === OnCallCalendarFeedKind.Personal) {
504
+ shifts = OnCallShiftMaterializer.filterShiftsForUser(
505
+ shifts,
506
+ request.userId,
507
+ );
508
+ }
509
+
510
+ const shrink: WindowShrinkResult =
511
+ OnCallCalendarFeedUtil.shrinkWindowToFit({
512
+ shifts,
513
+ feedStart: window.feedStart,
514
+ feedEnd: window.feedEnd,
515
+ });
516
+
517
+ const notes: Array<string> = [];
518
+
519
+ if (shrink.truncated) {
520
+ notes.push(
521
+ `Shortened to ${Math.max(
522
+ 0,
523
+ futureDays - shrink.daysDropped,
524
+ )} days ahead because the feed would exceed ${MAX_EVENTS} events.`,
525
+ );
526
+ }
527
+
528
+ if (iterationTruncated.length > 0) {
529
+ const names: string = iterationTruncated
530
+ .map((segment: CachedScheduleSegments) => {
531
+ return segment.scheduleName;
532
+ })
533
+ .join(", ");
534
+
535
+ notes.push(
536
+ `Some shifts may be missing: the schedule ${names} is too complex to expand fully. Simplify its layers or shorten the feed window.`,
537
+ );
538
+ }
539
+
540
+ let gapEvents: Array<ICalendarEvent> = [];
541
+ let gapsTruncated: boolean = false;
542
+
543
+ const dashboardUrl: string = (
544
+ await DatabaseConfig.getDashboardUrl()
545
+ ).toString();
546
+
547
+ if (
548
+ request.kind !== OnCallCalendarFeedKind.Personal &&
549
+ request.includeCoverageGaps
550
+ ) {
551
+ const gaps: { events: Array<ICalendarEvent>; truncated: boolean } =
552
+ OnCallCalendarFeedRenderer.buildGapEvents({
553
+ segments,
554
+ feedStart: window.feedStart,
555
+ feedEnd: shrink.feedEnd,
556
+ minimumGapMinutes: request.minimumGapMinutes,
557
+ dashboardUrl,
558
+ });
559
+
560
+ gapEvents = gaps.events;
561
+ gapsTruncated = gaps.truncated;
562
+
563
+ if (gapsTruncated) {
564
+ notes.push(
565
+ `Only the first ${MAX_GAP_EVENTS} coverage gaps are shown.`,
566
+ );
567
+ }
568
+ }
569
+
570
+ const rendered: FeedRenderResult = OnCallCalendarFeedUtil.render({
571
+ kind: request.kind,
572
+ shifts: shrink.shifts,
573
+ contextShifts: allScheduleShifts,
574
+ dashboardUrl,
575
+ viewerTimezone: context.viewerTimezone,
576
+ calendarTimezone: context.calendarTimezone,
577
+ scheduleName: context.scheduleName,
578
+ projectName: context.projectName,
579
+ filterScheduleName: context.filterScheduleName,
580
+ notes,
581
+ gapEvents,
582
+ });
583
+
584
+ const value: CachedCalendarBody = {
585
+ body: rendered.body,
586
+ etag: Response.getCalendarETag(rendered.body),
587
+ lastModified: rendered.lastModifiedAt || now,
588
+ };
589
+
590
+ await OnCallCalendarFeedCache.setBody({
591
+ key: bodyKey,
592
+ scope,
593
+ value,
594
+ ttlSeconds: BODY_CACHE_TTL_SECONDS,
595
+ });
596
+
597
+ /*
598
+ * A partial render must never become the "last good" body: the next
599
+ * complete render would then be shadowed by it on the next failure.
600
+ */
601
+ if (iterationTruncated.length === 0) {
602
+ await OnCallCalendarFeedCache.setLastGood(lastGoodKey, value);
603
+ }
604
+
605
+ OnCallCalendarFeedRenderer.recordMetrics({
606
+ kind: request.kind,
607
+ durationMs: Date.now() - startedAt,
608
+ eventCount: rendered.eventCount,
609
+ });
610
+
611
+ return {
612
+ status: FeedRenderStatus.Rendered,
613
+ kind: request.kind,
614
+ body: value.body,
615
+ etag: value.etag,
616
+ lastModified: value.lastModified,
617
+ stale: false,
618
+ truncated:
619
+ shrink.truncated || iterationTruncated.length > 0 || gapsTruncated,
620
+ eventCount: rendered.eventCount,
621
+ cacheHit: false,
622
+ reason: null,
623
+ retryAfterSeconds: null,
624
+ };
625
+ } catch (err) {
626
+ logger.error(
627
+ `OnCallCalendarFeedRenderer: rendering the ${request.kind} feed ${request.feedId.toString()} failed.`,
628
+ );
629
+ logger.error(err);
630
+
631
+ return await OnCallCalendarFeedRenderer.fallBackToLastGood({
632
+ kind: request.kind,
633
+ lastGoodKey,
634
+ why: "render failed",
635
+ });
636
+ } finally {
637
+ OnCallCalendarFeedCache.releaseRenderSlot();
638
+ }
639
+ }
640
+
641
+ // -- /my-shifts --------------------------------------------------------
642
+
643
+ /*
644
+ * The caller's own shifts between two instants, through the same
645
+ * schedule-level cache the feeds use. The window handed to the cache is
646
+ * widened to whole UTC days so a mobile client asking for "now to +30 d"
647
+ * every few minutes keys the same entries as the feeds do, and the result
648
+ * is then cut back to what was asked for.
649
+ */
650
+ @CaptureSpan()
651
+ public static async materializeUserShifts(data: {
652
+ userId: ObjectID;
653
+ projectIds?: Array<ObjectID> | undefined;
654
+ from: Date;
655
+ to: Date;
656
+ now?: Date | undefined;
657
+ }): Promise<UserShiftsResult> {
658
+ const now: Date = data.now || OneUptimeDate.getCurrentDate();
659
+
660
+ const windowStart: Date = new Date(
661
+ CalendarFeedWindow.startOfUtcDay(data.from),
662
+ );
663
+
664
+ const endDayStart: number = CalendarFeedWindow.startOfUtcDay(data.to);
665
+ const windowEnd: Date = new Date(
666
+ data.to.getTime() > endDayStart
667
+ ? endDayStart + 24 * 60 * 60 * 1000
668
+ : endDayStart,
669
+ );
670
+
671
+ const candidateIds: Array<ObjectID> =
672
+ await OnCallShiftMaterializer.getCandidateScheduleIdsForUser({
673
+ userId: data.userId,
674
+ projectIds: data.projectIds,
675
+ windowStart,
676
+ windowEnd,
677
+ includeCoveringShifts: true,
678
+ });
679
+
680
+ if (candidateIds.length === 0) {
681
+ return { shifts: [], truncated: false, generatedAt: now };
682
+ }
683
+
684
+ const schedules: Array<ScheduleInfo> =
685
+ await OnCallCalendarFeedRenderer.loadSchedules(candidateIds);
686
+
687
+ const segments: Array<CachedScheduleSegments> =
688
+ await OnCallCalendarFeedRenderer.loadScheduleSegmentsBatch({
689
+ schedules,
690
+ windowStart,
691
+ windowEnd,
692
+ now,
693
+ });
694
+
695
+ const own: Array<MaterializedShift> =
696
+ OnCallShiftMaterializer.filterShiftsForUser(
697
+ OnCallCalendarFeedRenderer.collectShifts(segments, now),
698
+ data.userId,
699
+ );
700
+
701
+ const inRange: Array<MaterializedShift> = own.filter(
702
+ (shift: MaterializedShift) => {
703
+ return shift.start < data.to && shift.end > data.from;
704
+ },
705
+ );
706
+
707
+ return {
708
+ shifts: MaterializedShiftUtil.sortByStart(inRange),
709
+ truncated: segments.some((segment: CachedScheduleSegments) => {
710
+ return segment.truncated;
711
+ }),
712
+ generatedAt: now,
713
+ };
714
+ }
715
+
716
+ // -- Schedule-level cache ---------------------------------------------
717
+
718
+ /*
719
+ * One schedule's materialization over one window, from the shared cache or
720
+ * freshly rendered into it. The key is the schedule's shiftConfigVersion
721
+ * plus the exact window, so every feed and /my-shifts call that lands on
722
+ * the same day-aligned window shares one LayerUtil expansion.
723
+ *
724
+ * The coverage envelope is computed in the same callback: it is the same
725
+ * cost class as the expansion and the gap-event path would otherwise
726
+ * expand every layer a second time.
727
+ */
728
+ @CaptureSpan()
729
+ public static async loadScheduleSegments(data: {
730
+ schedule: ScheduleInfo;
731
+ windowStart: Date;
732
+ windowEnd: Date;
733
+ now: Date;
734
+ }): Promise<CachedScheduleSegments> {
735
+ const loaded: Array<CachedScheduleSegments> =
736
+ await OnCallCalendarFeedRenderer.loadScheduleSegmentsBatch({
737
+ schedules: [data.schedule],
738
+ windowStart: data.windowStart,
739
+ windowEnd: data.windowEnd,
740
+ now: data.now,
741
+ });
742
+
743
+ const segments: CachedScheduleSegments | undefined = loaded[0];
744
+
745
+ if (!segments) {
746
+ throw new Error(
747
+ `OnCallCalendarFeedRenderer: no segments were produced for schedule ${data.schedule.id.toString()}.`,
748
+ );
749
+ }
750
+
751
+ return segments;
752
+ }
753
+
754
+ /*
755
+ * Every schedule of one feed, over one window, in ONE pass: the cache
756
+ * separates hits from misses and the misses are materialized together.
757
+ * Reading them one by one made each schedule its own resolver call --
758
+ * seven queries per schedule and the same user and project rows read again
759
+ * for every one of them -- fanned out concurrently, which for a project
760
+ * feed over fifty schedules was hundreds of statements in flight against a
761
+ * fifty-connection pool. The order of `schedules` is preserved.
762
+ */
763
+ @CaptureSpan()
764
+ public static async loadScheduleSegmentsBatch(data: {
765
+ schedules: Array<ScheduleInfo>;
766
+ windowStart: Date;
767
+ windowEnd: Date;
768
+ now: Date;
769
+ }): Promise<Array<CachedScheduleSegments>> {
770
+ if (data.schedules.length === 0) {
771
+ return [];
772
+ }
773
+
774
+ const byId: Map<string, ScheduleInfo> = new Map();
775
+
776
+ for (const schedule of data.schedules) {
777
+ byId.set(schedule.id.toString(), schedule);
778
+ }
779
+
780
+ const entries: Array<ScheduleSegmentsCacheEntry> = data.schedules.map(
781
+ (schedule: ScheduleInfo): ScheduleSegmentsCacheEntry => {
782
+ return {
783
+ scheduleId: schedule.id.toString(),
784
+ key: `${
785
+ schedule.shiftConfigVersion
786
+ }:${data.windowStart.toISOString()}:${data.windowEnd.toISOString()}`,
787
+ };
788
+ },
789
+ );
790
+
791
+ const rendered: Map<string, CachedScheduleSegments> =
792
+ await OnCallCalendarFeedCache.getOrRenderScheduleSegmentsBatch<CachedScheduleSegments>(
793
+ {
794
+ entries,
795
+ ttlSeconds: SCHEDULE_CACHE_TTL_SECONDS,
796
+ renderMissing: async (
797
+ missing: Array<ScheduleSegmentsCacheEntry>,
798
+ ): Promise<Map<string, CachedScheduleSegments>> => {
799
+ const schedules: Array<ScheduleInfo> = [];
800
+
801
+ for (const entry of missing) {
802
+ const schedule: ScheduleInfo | undefined = byId.get(
803
+ entry.scheduleId,
804
+ );
805
+
806
+ if (schedule) {
807
+ schedules.push(schedule);
808
+ }
809
+ }
810
+
811
+ return await OnCallCalendarFeedRenderer.renderScheduleSegments({
812
+ schedules,
813
+ windowStart: data.windowStart,
814
+ windowEnd: data.windowEnd,
815
+ now: data.now,
816
+ });
817
+ },
818
+ },
819
+ );
820
+
821
+ const ordered: Array<CachedScheduleSegments> = [];
822
+
823
+ for (const schedule of data.schedules) {
824
+ const segments: CachedScheduleSegments | undefined = rendered.get(
825
+ schedule.id.toString(),
826
+ );
827
+
828
+ if (segments) {
829
+ ordered.push(segments);
830
+ }
831
+ }
832
+
833
+ return ordered;
834
+ }
835
+
836
+ /*
837
+ * ONE resolver call for every schedule that missed the cache, split back
838
+ * into one cache entry per schedule.
839
+ */
840
+ private static async renderScheduleSegments(data: {
841
+ schedules: Array<ScheduleInfo>;
842
+ windowStart: Date;
843
+ windowEnd: Date;
844
+ now: Date;
845
+ }): Promise<Map<string, CachedScheduleSegments>> {
846
+ const out: Map<string, CachedScheduleSegments> = new Map();
847
+
848
+ if (data.schedules.length === 0) {
849
+ return out;
850
+ }
851
+
852
+ const result: MaterializeResult =
853
+ await OnCallShiftMaterializer.materializeForSchedules({
854
+ scheduleIds: data.schedules.map((schedule: ScheduleInfo) => {
855
+ return schedule.id;
856
+ }),
857
+ windowStart: data.windowStart,
858
+ windowEnd: data.windowEnd,
859
+ now: data.now,
860
+ maxSimulationIterations: FEED_SIMULATION_ITERATION_CAP,
861
+ });
862
+
863
+ const shiftsBySchedule: Map<string, Array<MaterializedShift>> = new Map();
864
+
865
+ for (const shift of result.shifts) {
866
+ const list: Array<MaterializedShift> =
867
+ shiftsBySchedule.get(shift.scheduleId) || [];
868
+
869
+ list.push(shift);
870
+ shiftsBySchedule.set(shift.scheduleId, list);
871
+ }
872
+
873
+ for (const schedule of data.schedules) {
874
+ const scheduleId: string = schedule.id.toString();
875
+
876
+ const info: MaterializedScheduleInfo | undefined = result.schedules.find(
877
+ (candidate: MaterializedScheduleInfo) => {
878
+ return candidate.scheduleId === scheduleId;
879
+ },
880
+ );
881
+
882
+ let envelope: CoverageEnvelopeResult = { segments: [], truncated: false };
883
+
884
+ if (info) {
885
+ envelope = OnCallCalendarFeedUtil.computeCoverageEnvelope({
886
+ layers: info.layerProps,
887
+ windowStart: data.windowStart,
888
+ windowEnd: data.windowEnd,
889
+ maxSimulationIterations: FEED_SIMULATION_ITERATION_CAP,
890
+ });
891
+ }
892
+
893
+ out.set(scheduleId, {
894
+ scheduleId,
895
+ scheduleName: info?.scheduleName || schedule.name,
896
+ scheduleTimezone: info?.scheduleTimezone ?? schedule.timezone ?? null,
897
+ projectId: info?.projectId || schedule.projectId.toString(),
898
+ projectName: info?.projectName ?? null,
899
+ shiftConfigVersion:
900
+ info?.shiftConfigVersion ?? schedule.shiftConfigVersion,
901
+ lastModifiedAt: (info?.lastModifiedAt || data.now).toISOString(),
902
+ truncated: info ? info.truncated : result.truncated,
903
+ shifts: MaterializedShiftUtil.toJSONArray(
904
+ shiftsBySchedule.get(scheduleId) || [],
905
+ ),
906
+ envelope: envelope.segments.map((segment: TimeSegment) => {
907
+ return {
908
+ start: segment.start.toISOString(),
909
+ end: segment.end.toISOString(),
910
+ };
911
+ }),
912
+ envelopeTruncated: envelope.truncated,
913
+ });
914
+ }
915
+
916
+ return out;
917
+ }
918
+
919
+ // -- Cache key ---------------------------------------------------------
920
+
921
+ /*
922
+ * `${kind}:${tokenHash}:${filters}:${window}` -- everything that identifies
923
+ * the FEED, with nothing in it that changes when the roster does. Shared by
924
+ * the body key and the last-good key.
925
+ */
926
+ private static buildFeedIdentityKey(data: {
927
+ request: FeedRenderRequest;
928
+ pastDays: number;
929
+ futureDays: number;
930
+ }): string {
931
+ const request: FeedRenderRequest = data.request;
932
+
933
+ let filters: string;
934
+
935
+ if (request.kind === OnCallCalendarFeedKind.Personal) {
936
+ filters = `s=${request.scheduleFilterId?.toString() || ""};c=${
937
+ request.includeCoveringShifts ? 1 : 0
938
+ }`;
939
+ } else {
940
+ filters = `g=${request.includeCoverageGaps ? 1 : 0};m=${
941
+ request.minimumGapMinutes
942
+ }`;
943
+ }
944
+
945
+ return [
946
+ request.kind,
947
+ request.tokenHash,
948
+ filters,
949
+ `w=${data.pastDays}/${data.futureDays}`,
950
+ ].join(":");
951
+ }
952
+
953
+ private static digestOf(value: string): string {
954
+ return createHash("sha256")
955
+ .update(value, "utf8")
956
+ .digest("hex")
957
+ .slice(0, 24);
958
+ }
959
+
960
+ /*
961
+ * `${identity}:${digest(scheduleIds+versions)}:${day}`.
962
+ * The candidate set and every shiftConfigVersion are read from Postgres on
963
+ * each request, so any configuration edit changes the key immediately --
964
+ * no counters to lose. The day bucket rolls the window at UTC midnight.
965
+ */
966
+ public static buildBodyCacheKey(data: {
967
+ request: FeedRenderRequest;
968
+ pastDays: number;
969
+ futureDays: number;
970
+ schedules: Array<ScheduleInfo>;
971
+ now: Date;
972
+ }): string {
973
+ const versions: string = data.schedules
974
+ .map((schedule: ScheduleInfo) => {
975
+ return `${schedule.id.toString()}@${schedule.shiftConfigVersion}`;
976
+ })
977
+ .sort()
978
+ .join(",");
979
+
980
+ return [
981
+ OnCallCalendarFeedRenderer.buildFeedIdentityKey(data),
982
+ OnCallCalendarFeedRenderer.digestOf(versions),
983
+ CalendarFeedWindow.getUtcDayBucket(data.now),
984
+ ].join(":");
985
+ }
986
+
987
+ /*
988
+ * The key of the stale-while-error tier: `${identity}:${digest(scheduleIds)}`
989
+ * -- deliberately NOT the body key.
990
+ *
991
+ * The body key carries every schedule's shiftConfigVersion and the UTC day
992
+ * bucket, both of which change constantly: every layer/override/attachment
993
+ * edit bumps a version, and every feed's day bucket rolls at midnight UTC.
994
+ * Keying the last-good body on those meant the fallback existed only until
995
+ * the next edit or the next midnight -- and those are exactly the moments
996
+ * it is needed, because they are also when every subscriber's body cache
997
+ * misses at once and the four render slots are scarcest. The result was a
998
+ * 503 (which some clients answer by dropping the subscription) with a
999
+ * perfectly good body sitting one key away.
1000
+ *
1001
+ * The SORTED SCHEDULE-ID SET stays in the key. That is the authorisation
1002
+ * property: a body rendered when the user was on schedules {A, B} can never
1003
+ * be served after they are removed from B, because the key is different.
1004
+ * The token hash is in it too, so a rotated token never reaches an old body.
1005
+ * What is dropped is only "the roster changed" and "the day rolled", which
1006
+ * is precisely what "stale" means.
1007
+ */
1008
+ public static buildLastGoodCacheKey(data: {
1009
+ request: FeedRenderRequest;
1010
+ pastDays: number;
1011
+ futureDays: number;
1012
+ schedules: Array<ScheduleInfo>;
1013
+ }): string {
1014
+ const ids: string = data.schedules
1015
+ .map((schedule: ScheduleInfo) => {
1016
+ return schedule.id.toString();
1017
+ })
1018
+ .sort()
1019
+ .join(",");
1020
+
1021
+ return [
1022
+ OnCallCalendarFeedRenderer.buildFeedIdentityKey(data),
1023
+ OnCallCalendarFeedRenderer.digestOf(ids),
1024
+ ].join(":");
1025
+ }
1026
+
1027
+ // -- Context (authorization, re-derived per fetch) ----------------------
1028
+
1029
+ private static async resolveContext(
1030
+ request: FeedRenderRequest,
1031
+ window: FeedWindow,
1032
+ ): Promise<RenderContext> {
1033
+ switch (request.kind) {
1034
+ case OnCallCalendarFeedKind.Personal:
1035
+ return await OnCallCalendarFeedRenderer.resolvePersonalContext(
1036
+ request,
1037
+ window,
1038
+ );
1039
+ case OnCallCalendarFeedKind.Schedule:
1040
+ return await OnCallCalendarFeedRenderer.resolveScheduleContext(request);
1041
+ case OnCallCalendarFeedKind.Project:
1042
+ return await OnCallCalendarFeedRenderer.resolveProjectContext(request);
1043
+ default:
1044
+ return { empty: true, reason: USER_MISSING_REASON };
1045
+ }
1046
+ }
1047
+
1048
+ private static async resolvePersonalContext(
1049
+ request: PersonalFeedRenderRequest,
1050
+ window: FeedWindow,
1051
+ ): Promise<RenderContext> {
1052
+ const user: User | null = await UserService.findOneById({
1053
+ id: request.userId,
1054
+ select: {
1055
+ _id: true,
1056
+ timezone: true,
1057
+ },
1058
+ props: {
1059
+ isRoot: true,
1060
+ ignoreHooks: true,
1061
+ },
1062
+ });
1063
+
1064
+ if (!user) {
1065
+ return { empty: true, reason: USER_MISSING_REASON };
1066
+ }
1067
+
1068
+ const viewerTimezone: string | undefined = user.timezone
1069
+ ? user.timezone.toString()
1070
+ : undefined;
1071
+
1072
+ if (
1073
+ !(await OnCallCalendarFeedRenderer.isProjectOnPlan(request.projectId))
1074
+ ) {
1075
+ return { empty: true, reason: PLAN_REASON, timezone: viewerTimezone };
1076
+ }
1077
+
1078
+ const candidateIds: Array<ObjectID> =
1079
+ await OnCallShiftMaterializer.getCandidateScheduleIdsForUser({
1080
+ userId: request.userId,
1081
+ projectIds: [request.projectId],
1082
+ scheduleId: request.scheduleFilterId,
1083
+ windowStart: window.feedStart,
1084
+ windowEnd: window.feedEnd,
1085
+ includeCoveringShifts: request.includeCoveringShifts,
1086
+ });
1087
+
1088
+ let filterScheduleName: string | undefined = undefined;
1089
+
1090
+ if (request.scheduleFilterId) {
1091
+ const filtered: Array<ScheduleInfo> =
1092
+ await OnCallCalendarFeedRenderer.loadSchedules([
1093
+ request.scheduleFilterId,
1094
+ ]);
1095
+
1096
+ const candidate: ScheduleInfo | undefined = filtered[0];
1097
+
1098
+ /*
1099
+ * loadSchedules is a root read by id, and the id came from the query
1100
+ * string. The name goes into X-WR-CALNAME/X-WR-CALDESC of a 200 body,
1101
+ * so naming a schedule that belongs to another project would turn any
1102
+ * personal feed link into an id-to-name oracle across tenants. Only a
1103
+ * schedule in the token's own project is ever named.
1104
+ */
1105
+ if (
1106
+ candidate &&
1107
+ candidate.projectId.toString() === request.projectId.toString()
1108
+ ) {
1109
+ filterScheduleName = candidate.name;
1110
+ }
1111
+ }
1112
+
1113
+ if (candidateIds.length === 0) {
1114
+ return {
1115
+ empty: true,
1116
+ reason: request.scheduleFilterId
1117
+ ? FILTERED_SCHEDULE_REASON
1118
+ : NO_SCHEDULES_REASON,
1119
+ filterScheduleName,
1120
+ timezone: viewerTimezone,
1121
+ };
1122
+ }
1123
+
1124
+ const schedules: Array<ScheduleInfo> = (
1125
+ await OnCallCalendarFeedRenderer.loadSchedules(candidateIds)
1126
+ ).filter((schedule: ScheduleInfo) => {
1127
+ /*
1128
+ * Belt and braces: the candidate query was scoped to the project, but
1129
+ * a schedule row from another project must never render under this
1130
+ * project's token.
1131
+ */
1132
+ return schedule.projectId.toString() === request.projectId.toString();
1133
+ });
1134
+
1135
+ if (schedules.length === 0) {
1136
+ return {
1137
+ empty: true,
1138
+ reason: request.scheduleFilterId
1139
+ ? FILTERED_SCHEDULE_REASON
1140
+ : NO_SCHEDULES_REASON,
1141
+ filterScheduleName,
1142
+ timezone: viewerTimezone,
1143
+ };
1144
+ }
1145
+
1146
+ return {
1147
+ empty: false,
1148
+ schedules,
1149
+ viewerTimezone,
1150
+ calendarTimezone: viewerTimezone,
1151
+ filterScheduleName,
1152
+ };
1153
+ }
1154
+
1155
+ private static async resolveScheduleContext(
1156
+ request: ScheduleFeedRenderRequest,
1157
+ ): Promise<RenderContext> {
1158
+ const schedules: Array<ScheduleInfo> =
1159
+ await OnCallCalendarFeedRenderer.loadSchedules([request.scheduleId]);
1160
+
1161
+ const schedule: ScheduleInfo | undefined = schedules[0];
1162
+
1163
+ if (
1164
+ !schedule ||
1165
+ schedule.projectId.toString() !== request.projectId.toString()
1166
+ ) {
1167
+ return { empty: true, reason: SCHEDULE_MISSING_REASON };
1168
+ }
1169
+
1170
+ if (
1171
+ !(await OnCallCalendarFeedRenderer.isProjectOnPlan(request.projectId))
1172
+ ) {
1173
+ return {
1174
+ empty: true,
1175
+ reason: PLAN_REASON,
1176
+ scheduleName: schedule.name,
1177
+ timezone: schedule.timezone,
1178
+ };
1179
+ }
1180
+
1181
+ return {
1182
+ empty: false,
1183
+ schedules: [schedule],
1184
+ scheduleName: schedule.name,
1185
+ calendarTimezone: schedule.timezone,
1186
+ };
1187
+ }
1188
+
1189
+ private static async resolveProjectContext(
1190
+ request: ProjectFeedRenderRequest,
1191
+ ): Promise<RenderContext> {
1192
+ const project: Project | null = await ProjectService.findOneById({
1193
+ id: request.projectId,
1194
+ select: {
1195
+ _id: true,
1196
+ name: true,
1197
+ },
1198
+ props: {
1199
+ isRoot: true,
1200
+ ignoreHooks: true,
1201
+ },
1202
+ });
1203
+
1204
+ if (!project) {
1205
+ return { empty: true, reason: PROJECT_MISSING_REASON };
1206
+ }
1207
+
1208
+ const projectName: string | undefined = project.name || undefined;
1209
+
1210
+ if (
1211
+ !(await OnCallCalendarFeedRenderer.isProjectOnPlan(request.projectId))
1212
+ ) {
1213
+ return { empty: true, reason: PLAN_REASON, projectName };
1214
+ }
1215
+
1216
+ const rows: Array<OnCallDutyPolicySchedule> =
1217
+ await OnCallDutyPolicyScheduleService.findBy({
1218
+ query: {
1219
+ projectId: request.projectId,
1220
+ },
1221
+ select: OnCallCalendarFeedRenderer.scheduleSelect(),
1222
+ limit: LIMIT_PER_PROJECT,
1223
+ skip: 0,
1224
+ sort: {
1225
+ name: SortOrder.Ascending,
1226
+ },
1227
+ props: {
1228
+ isRoot: true,
1229
+ ignoreHooks: true,
1230
+ },
1231
+ });
1232
+
1233
+ const schedules: Array<ScheduleInfo> =
1234
+ OnCallCalendarFeedRenderer.toScheduleInfos(rows);
1235
+
1236
+ if (schedules.length === 0) {
1237
+ return { empty: true, reason: NO_PROJECT_SCHEDULES_REASON, projectName };
1238
+ }
1239
+
1240
+ return {
1241
+ empty: false,
1242
+ schedules,
1243
+ projectName,
1244
+ };
1245
+ }
1246
+
1247
+ // -- Helpers -----------------------------------------------------------
1248
+
1249
+ private static scheduleSelect(): {
1250
+ _id: true;
1251
+ name: true;
1252
+ timezone: true;
1253
+ projectId: true;
1254
+ shiftConfigVersion: true;
1255
+ } {
1256
+ return {
1257
+ _id: true,
1258
+ name: true,
1259
+ timezone: true,
1260
+ projectId: true,
1261
+ shiftConfigVersion: true,
1262
+ };
1263
+ }
1264
+
1265
+ /*
1266
+ * Root read of the schedule facts the render needs (name, zone, project,
1267
+ * shiftConfigVersion). Input order is preserved; unknown ids are dropped.
1268
+ */
1269
+ @CaptureSpan()
1270
+ public static async loadSchedules(
1271
+ ids: Array<ObjectID>,
1272
+ ): Promise<Array<ScheduleInfo>> {
1273
+ if (ids.length === 0) {
1274
+ return [];
1275
+ }
1276
+
1277
+ const rows: Array<OnCallDutyPolicySchedule> =
1278
+ await OnCallDutyPolicyScheduleService.findBy({
1279
+ query: {
1280
+ _id: QueryHelper.any(ids),
1281
+ },
1282
+ select: OnCallCalendarFeedRenderer.scheduleSelect(),
1283
+ limit: LIMIT_PER_PROJECT,
1284
+ skip: 0,
1285
+ props: {
1286
+ isRoot: true,
1287
+ ignoreHooks: true,
1288
+ },
1289
+ });
1290
+
1291
+ const byId: Map<string, ScheduleInfo> = new Map();
1292
+
1293
+ for (const info of OnCallCalendarFeedRenderer.toScheduleInfos(rows)) {
1294
+ byId.set(info.id.toString(), info);
1295
+ }
1296
+
1297
+ const ordered: Array<ScheduleInfo> = [];
1298
+
1299
+ for (const id of ids) {
1300
+ const info: ScheduleInfo | undefined = byId.get(id.toString());
1301
+
1302
+ if (info && !ordered.includes(info)) {
1303
+ ordered.push(info);
1304
+ }
1305
+ }
1306
+
1307
+ return ordered;
1308
+ }
1309
+
1310
+ private static toScheduleInfos(
1311
+ rows: Array<OnCallDutyPolicySchedule>,
1312
+ ): Array<ScheduleInfo> {
1313
+ const infos: Array<ScheduleInfo> = [];
1314
+
1315
+ for (const row of rows) {
1316
+ if (!row.id || !row.projectId) {
1317
+ continue;
1318
+ }
1319
+
1320
+ const timezone: string | undefined = row.timezone
1321
+ ? row.timezone.toString()
1322
+ : undefined;
1323
+
1324
+ const info: ScheduleInfo = {
1325
+ id: row.id,
1326
+ name: row.name || "",
1327
+ projectId: row.projectId,
1328
+ shiftConfigVersion:
1329
+ typeof row.shiftConfigVersion === "number" &&
1330
+ Number.isFinite(row.shiftConfigVersion)
1331
+ ? row.shiftConfigVersion
1332
+ : Number(row.shiftConfigVersion) || 0,
1333
+ };
1334
+
1335
+ if (timezone) {
1336
+ info.timezone = timezone;
1337
+ }
1338
+
1339
+ infos.push(info);
1340
+ }
1341
+
1342
+ return infos;
1343
+ }
1344
+
1345
+ /*
1346
+ * Every cached shift of every schedule as MaterializedShift objects, with
1347
+ * isPast re-evaluated against THIS request's clock (a schedule entry can be
1348
+ * up to an hour old).
1349
+ */
1350
+ private static collectShifts(
1351
+ segments: Array<CachedScheduleSegments>,
1352
+ now: Date,
1353
+ ): Array<MaterializedShift> {
1354
+ const shifts: Array<MaterializedShift> = [];
1355
+
1356
+ for (const segment of segments) {
1357
+ for (const shift of MaterializedShiftUtil.fromJSONArray(segment.shifts)) {
1358
+ shift.isPast = shift.start.getTime() < now.getTime();
1359
+ shifts.push(shift);
1360
+ }
1361
+ }
1362
+
1363
+ return MaterializedShiftUtil.sortByStart(shifts);
1364
+ }
1365
+
1366
+ private static buildGapEvents(data: {
1367
+ segments: Array<CachedScheduleSegments>;
1368
+ feedStart: Date;
1369
+ feedEnd: Date;
1370
+ minimumGapMinutes: number;
1371
+ dashboardUrl: string;
1372
+ }): { events: Array<ICalendarEvent>; truncated: boolean } {
1373
+ const collected: Array<ICalendarEvent> = [];
1374
+ let truncated: boolean = false;
1375
+
1376
+ const minimumGapSeconds: number =
1377
+ Math.max(0, Math.floor(data.minimumGapMinutes)) * 60;
1378
+
1379
+ for (const segment of data.segments) {
1380
+ const shifts: Array<TimeSegment> = segment.shifts.map(
1381
+ (shift: MaterializedShiftJson) => {
1382
+ return {
1383
+ start: new Date(shift.start),
1384
+ end: new Date(shift.end),
1385
+ };
1386
+ },
1387
+ );
1388
+
1389
+ const envelope: Array<TimeSegment> = segment.envelope.map(
1390
+ (window: { start: string; end: string }) => {
1391
+ return { start: new Date(window.start), end: new Date(window.end) };
1392
+ },
1393
+ );
1394
+
1395
+ const result: CoverageGapEventsResult =
1396
+ OnCallCalendarFeedUtil.buildCoverageGapEvents({
1397
+ scheduleId: segment.scheduleId,
1398
+ scheduleName: segment.scheduleName,
1399
+ projectId: segment.projectId,
1400
+ shifts,
1401
+ feedStart: data.feedStart,
1402
+ feedEnd: data.feedEnd,
1403
+ envelope,
1404
+ minimumGapSeconds,
1405
+ lastModifiedAt: new Date(segment.lastModifiedAt),
1406
+ shiftConfigVersion: segment.shiftConfigVersion,
1407
+ dashboardUrl: data.dashboardUrl,
1408
+ maxGapEvents: MAX_GAP_EVENTS,
1409
+ });
1410
+
1411
+ truncated = truncated || result.truncated;
1412
+ collected.push(...result.events);
1413
+ }
1414
+
1415
+ collected.sort((a: ICalendarEvent, b: ICalendarEvent) => {
1416
+ return a.start.getTime() - b.start.getTime();
1417
+ });
1418
+
1419
+ if (collected.length > MAX_GAP_EVENTS) {
1420
+ return { events: collected.slice(0, MAX_GAP_EVENTS), truncated: true };
1421
+ }
1422
+
1423
+ return { events: collected, truncated };
1424
+ }
1425
+
1426
+ private static async fallBackToLastGood(data: {
1427
+ kind: OnCallCalendarFeedKind;
1428
+ lastGoodKey: string;
1429
+ why: string;
1430
+ }): Promise<FeedRenderOutcome> {
1431
+ const lastGood: CachedCalendarBody | null =
1432
+ await OnCallCalendarFeedCache.getLastGood(data.lastGoodKey);
1433
+
1434
+ if (lastGood) {
1435
+ logger.warn(
1436
+ `OnCallCalendarFeedRenderer: ${data.why}; serving the last good ${data.kind} feed body.`,
1437
+ );
1438
+
1439
+ return OnCallCalendarFeedRenderer.outcomeFromCachedBody({
1440
+ kind: data.kind,
1441
+ cached: lastGood,
1442
+ stale: true,
1443
+ cacheHit: false,
1444
+ truncated: true,
1445
+ });
1446
+ }
1447
+
1448
+ logger.warn(
1449
+ `OnCallCalendarFeedRenderer: ${data.why} and nothing is cached for this ${data.kind} feed; answering unavailable.`,
1450
+ );
1451
+
1452
+ return OnCallCalendarFeedRenderer.buildUnavailableOutcome(
1453
+ data.kind,
1454
+ RENDER_CAP_RETRY_AFTER_SECONDS,
1455
+ );
1456
+ }
1457
+
1458
+ private static recordMetrics(data: {
1459
+ kind: OnCallCalendarFeedKind;
1460
+ durationMs: number;
1461
+ eventCount: number;
1462
+ }): void {
1463
+ try {
1464
+ const attributes: Record<string, string> = {
1465
+ [AppMetrics.ON_CALL_CALENDAR_FEED_KIND_ATTRIBUTE]:
1466
+ OnCallCalendarFeedUrls.getKindSegment(data.kind),
1467
+ };
1468
+
1469
+ AppMetrics.getOnCallCalendarRenderDuration().record(
1470
+ data.durationMs,
1471
+ attributes,
1472
+ );
1473
+ AppMetrics.getOnCallCalendarRenderEvents().record(
1474
+ data.eventCount,
1475
+ attributes,
1476
+ );
1477
+ } catch (err) {
1478
+ logger.debug(
1479
+ `OnCallCalendarFeedRenderer: metrics unavailable (${String(err)})`,
1480
+ );
1481
+ }
1482
+ }
1483
+
1484
+ /* VEVENT count of a serialized body, for outcomes rebuilt from a cache. */
1485
+ public static countEvents(body: string): number {
1486
+ return (body.match(/BEGIN:VEVENT/g) || []).length;
1487
+ }
1488
+ }