@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,159 @@
1
+ import CreateBy from "../Types/Database/CreateBy";
2
+ import UpdateBy from "../Types/Database/UpdateBy";
3
+ import { OnCreate, OnUpdate } from "../Types/Database/Hooks";
4
+ import DatabaseService from "./DatabaseService";
5
+ import CalendarFeedToken, {
6
+ CalendarFeedRotation,
7
+ MintedCalendarFeedToken,
8
+ } from "../Utils/OnCall/CalendarFeedToken";
9
+ import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
10
+ import Model from "../../Models/DatabaseModels/UserOnCallCalendarFeed";
11
+ import BadDataException from "../../Types/Exception/BadDataException";
12
+ import NotAuthorizedException from "../../Types/Exception/NotAuthorizedException";
13
+ import NotFoundException from "../../Types/Exception/NotFoundException";
14
+ import CalendarFeedWindow from "../../Types/OnCallDutyPolicy/CalendarFeedWindow";
15
+ import ObjectID from "../../Types/ObjectID";
16
+ import OneUptimeDate from "../../Types/Date";
17
+
18
+ /*
19
+ * The personal calendar feed row: one per (project, user).
20
+ *
21
+ * WHAT THE HOOKS GUARANTEE
22
+ *
23
+ * - Only root creates one. The table create list is already empty, but the
24
+ * hook says it out loud: the plaintext token is minted here or by the
25
+ * calendar API, never by a request body.
26
+ * - A created row always carries a consistent token set (see
27
+ * CalendarFeedToken.applyTokenColumnsOnCreate): there is no path to a row
28
+ * whose hash does not match its token.
29
+ * - pastDays / futureDays are clamped to the CalendarFeedWindow bounds on
30
+ * every write, so a feed can never be asked to render a year.
31
+ *
32
+ * Rotation lives here too (rotateTokenById) so the grace-hash bookkeeping is
33
+ * written once and the API, the member-leave cleanup and the tests share it.
34
+ * Cache purging is the caller's job - this service knows nothing about the
35
+ * feed cache.
36
+ */
37
+ export class Service extends DatabaseService<Model> {
38
+ public constructor() {
39
+ super(Model);
40
+ }
41
+
42
+ @CaptureSpan()
43
+ protected override async onBeforeCreate(
44
+ createBy: CreateBy<Model>,
45
+ ): Promise<OnCreate<Model>> {
46
+ if (!createBy.props.isRoot) {
47
+ throw new NotAuthorizedException(
48
+ "Calendar feeds are generated by the calendar API, not created directly.",
49
+ );
50
+ }
51
+
52
+ const projectId: ObjectID | undefined =
53
+ createBy.data.projectId || createBy.props.tenantId;
54
+
55
+ if (!projectId) {
56
+ throw new BadDataException("projectId is required");
57
+ }
58
+
59
+ if (!createBy.data.userId) {
60
+ throw new BadDataException("userId is required");
61
+ }
62
+
63
+ createBy.data.projectId = projectId;
64
+
65
+ // Root only reaches this line, so a pre-minted token may be trusted.
66
+ CalendarFeedToken.applyTokenColumnsOnCreate(createBy.data, {
67
+ trustSuppliedToken: true,
68
+ });
69
+
70
+ createBy.data.pastDays = CalendarFeedWindow.clampPastDays(
71
+ createBy.data.pastDays,
72
+ );
73
+ createBy.data.futureDays = CalendarFeedWindow.clampFutureDays(
74
+ createBy.data.futureDays,
75
+ );
76
+
77
+ return { createBy, carryForward: null };
78
+ }
79
+
80
+ @CaptureSpan()
81
+ protected override async onBeforeUpdate(
82
+ updateBy: UpdateBy<Model>,
83
+ ): Promise<OnUpdate<Model>> {
84
+ if (updateBy.data.pastDays !== undefined) {
85
+ updateBy.data.pastDays = CalendarFeedWindow.clampPastDays(
86
+ updateBy.data.pastDays,
87
+ );
88
+ }
89
+
90
+ if (updateBy.data.futureDays !== undefined) {
91
+ updateBy.data.futureDays = CalendarFeedWindow.clampFutureDays(
92
+ updateBy.data.futureDays,
93
+ );
94
+ }
95
+
96
+ return { updateBy, carryForward: null };
97
+ }
98
+
99
+ /**
100
+ * Create the feed row for a user in a project and hand the plaintext token
101
+ * back ONCE, so the caller can build the subscription URL without a
102
+ * decrypt round-trip. Root only; the caller is responsible for holding the
103
+ * per-(project, user) lock the API uses around mint-or-rotate.
104
+ */
105
+ @CaptureSpan()
106
+ public async createForUser(data: {
107
+ projectId: ObjectID;
108
+ userId: ObjectID;
109
+ }): Promise<{ feed: Model; minted: MintedCalendarFeedToken }> {
110
+ const minted: MintedCalendarFeedToken = CalendarFeedToken.mintSet();
111
+
112
+ const feed: Model = new Model();
113
+ feed.projectId = data.projectId;
114
+ feed.userId = data.userId;
115
+ feed.token = minted.token;
116
+ feed.rotatedAt = OneUptimeDate.getCurrentDate();
117
+
118
+ const created: Model = await this.create({
119
+ data: feed,
120
+ props: { isRoot: true },
121
+ });
122
+
123
+ return { feed: created, minted };
124
+ }
125
+
126
+ /**
127
+ * Rotate the token of an existing feed: mint a new one, move the current
128
+ * hash into the 30-day grace slot, stamp rotatedAt. Returns the new
129
+ * plaintext token ONCE. Root write; the caller purges caches.
130
+ */
131
+ @CaptureSpan()
132
+ public async rotateTokenById(data: {
133
+ id: ObjectID;
134
+ }): Promise<CalendarFeedRotation> {
135
+ const existing: Model | null = await this.findOneBy({
136
+ query: { _id: data.id },
137
+ select: { _id: true, tokenHash: true },
138
+ props: { isRoot: true, ignoreHooks: true },
139
+ });
140
+
141
+ if (!existing) {
142
+ throw new NotFoundException("Calendar feed not found.");
143
+ }
144
+
145
+ const rotation: CalendarFeedRotation = CalendarFeedToken.buildRotation({
146
+ currentTokenHash: existing.tokenHash,
147
+ });
148
+
149
+ await this.updateOneById({
150
+ id: data.id,
151
+ data: CalendarFeedToken.toRotationUpdateData(rotation),
152
+ props: { isRoot: true },
153
+ });
154
+
155
+ return rotation;
156
+ }
157
+ }
158
+
159
+ export default new Service();
@@ -0,0 +1,151 @@
1
+ import CreateBy from "../Types/Database/CreateBy";
2
+ import DeleteBy from "../Types/Database/DeleteBy";
3
+ import UpdateBy from "../Types/Database/UpdateBy";
4
+ import { OnCreate, OnDelete, OnUpdate } from "../Types/Database/Hooks";
5
+ import DatabaseService from "./DatabaseService";
6
+ import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
7
+ import Model, {
8
+ UserOnCallShiftReminderLogKind,
9
+ } from "../../Models/DatabaseModels/UserOnCallShiftReminderLog";
10
+ import BadDataException from "../../Types/Exception/BadDataException";
11
+ import NotAuthorizedException from "../../Types/Exception/NotAuthorizedException";
12
+ import ObjectID from "../../Types/ObjectID";
13
+ import OneUptimeDate from "../../Types/Date";
14
+
15
+ const MILLISECONDS_PER_MINUTE: number = 60 * 1000;
16
+
17
+ const ALLOWED_KINDS: Set<string> = new Set<string>(
18
+ Object.values(UserOnCallShiftReminderLogKind),
19
+ );
20
+
21
+ /*
22
+ * The shift-reminder ledger. Root only, in every direction: a claim row is
23
+ * the thing that makes "send exactly once" true, so nothing outside the
24
+ * worker may insert, edit or remove one.
25
+ *
26
+ * The create hook normalises the idempotency key so two workers that compute
27
+ * the same shift a few milliseconds apart still collide on the unique index:
28
+ * shiftStartsAt is truncated to the minute here as well as by the worker
29
+ * (belt and braces - the seam normalisation upstream already makes starts
30
+ * minute-granular, this makes it a guarantee of the table rather than of one
31
+ * caller), and minutesBeforeShift defaults to 0 for change notices.
32
+ */
33
+ export class Service extends DatabaseService<Model> {
34
+ public constructor() {
35
+ super(Model);
36
+ }
37
+
38
+ @CaptureSpan()
39
+ protected override async onBeforeCreate(
40
+ createBy: CreateBy<Model>,
41
+ ): Promise<OnCreate<Model>> {
42
+ if (!createBy.props.isRoot) {
43
+ throw new NotAuthorizedException(
44
+ "Shift reminder logs are written by the reminder worker only.",
45
+ );
46
+ }
47
+
48
+ const projectId: ObjectID | undefined =
49
+ createBy.data.projectId || createBy.props.tenantId;
50
+
51
+ if (!projectId) {
52
+ throw new BadDataException("projectId is required");
53
+ }
54
+
55
+ if (!createBy.data.userId) {
56
+ throw new BadDataException("userId is required");
57
+ }
58
+
59
+ if (!createBy.data.onCallDutyPolicyScheduleId) {
60
+ throw new BadDataException("onCallDutyPolicyScheduleId is required");
61
+ }
62
+
63
+ if (!createBy.data.shiftStartsAt) {
64
+ throw new BadDataException("shiftStartsAt is required");
65
+ }
66
+
67
+ if (
68
+ !createBy.data.kind ||
69
+ !ALLOWED_KINDS.has(createBy.data.kind as string)
70
+ ) {
71
+ throw new BadDataException(
72
+ `kind must be one of: ${Object.values(UserOnCallShiftReminderLogKind).join(", ")}`,
73
+ );
74
+ }
75
+
76
+ createBy.data.projectId = projectId;
77
+
78
+ createBy.data.shiftStartsAt = Service.truncateToMinute(
79
+ OneUptimeDate.fromString(createBy.data.shiftStartsAt),
80
+ );
81
+
82
+ createBy.data.minutesBeforeShift = Service.validateMinutesBeforeShift(
83
+ createBy.data.minutesBeforeShift,
84
+ );
85
+
86
+ if (!createBy.data.claimedAt) {
87
+ createBy.data.claimedAt = OneUptimeDate.getCurrentDate();
88
+ }
89
+
90
+ return { createBy, carryForward: null };
91
+ }
92
+
93
+ @CaptureSpan()
94
+ protected override async onBeforeUpdate(
95
+ updateBy: UpdateBy<Model>,
96
+ ): Promise<OnUpdate<Model>> {
97
+ if (!updateBy.props.isRoot) {
98
+ throw new NotAuthorizedException(
99
+ "Shift reminder logs are written by the reminder worker only.",
100
+ );
101
+ }
102
+
103
+ return { updateBy, carryForward: null };
104
+ }
105
+
106
+ @CaptureSpan()
107
+ protected override async onBeforeDelete(
108
+ deleteBy: DeleteBy<Model>,
109
+ ): Promise<OnDelete<Model>> {
110
+ if (!deleteBy.props.isRoot) {
111
+ throw new NotAuthorizedException(
112
+ "Shift reminder logs are written by the reminder worker only.",
113
+ );
114
+ }
115
+
116
+ return { deleteBy, carryForward: null };
117
+ }
118
+
119
+ // Zero the seconds and milliseconds. Part of the idempotency key.
120
+ public static truncateToMinute(date: Date): Date {
121
+ return new Date(
122
+ Math.floor(date.getTime() / MILLISECONDS_PER_MINUTE) *
123
+ MILLISECONDS_PER_MINUTE,
124
+ );
125
+ }
126
+
127
+ /*
128
+ * 0 (a change notice) or a positive whole number of minutes. Missing means
129
+ * 0. Anything else is a caller bug, not a value to store.
130
+ */
131
+ public static validateMinutesBeforeShift(value: unknown): number {
132
+ if (value === undefined || value === null) {
133
+ return 0;
134
+ }
135
+
136
+ const parsed: number =
137
+ typeof value === "string" && value.trim() !== ""
138
+ ? Number(value)
139
+ : (value as number);
140
+
141
+ if (typeof parsed !== "number" || !Number.isInteger(parsed) || parsed < 0) {
142
+ throw new BadDataException(
143
+ "minutesBeforeShift must be a whole number of minutes, 0 or more.",
144
+ );
145
+ }
146
+
147
+ return parsed;
148
+ }
149
+ }
150
+
151
+ export default new Service();
@@ -0,0 +1,112 @@
1
+ import CreateBy from "../Types/Database/CreateBy";
2
+ import UpdateBy from "../Types/Database/UpdateBy";
3
+ import { OnCreate, OnUpdate } from "../Types/Database/Hooks";
4
+ import DatabaseService from "./DatabaseService";
5
+ import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
6
+ import Model, {
7
+ MAX_MINUTES_BEFORE_SHIFT,
8
+ MIN_MINUTES_BEFORE_SHIFT,
9
+ } from "../../Models/DatabaseModels/UserOnCallShiftReminder";
10
+ import BadDataException from "../../Types/Exception/BadDataException";
11
+ import ObjectID from "../../Types/ObjectID";
12
+ import PositiveNumber from "../../Types/PositiveNumber";
13
+
14
+ /*
15
+ * "Remind me N minutes before my shift" rows.
16
+ *
17
+ * The hooks keep the table honest for the reminder worker: every row has an
18
+ * owner (defaulted to the session user when the client did not send one, so
19
+ * the settings page can post `{ minutesBeforeShift }` and nothing else), a
20
+ * project, and a lead time inside 15 minutes ... 2 weeks. A duplicate lead
21
+ * is answered with a message rather than a unique-violation 500.
22
+ */
23
+ export class Service extends DatabaseService<Model> {
24
+ public constructor() {
25
+ super(Model);
26
+ }
27
+
28
+ @CaptureSpan()
29
+ protected override async onBeforeCreate(
30
+ createBy: CreateBy<Model>,
31
+ ): Promise<OnCreate<Model>> {
32
+ const projectId: ObjectID | undefined =
33
+ createBy.data.projectId || createBy.props.tenantId;
34
+
35
+ if (!projectId) {
36
+ throw new BadDataException("projectId is required");
37
+ }
38
+
39
+ if (!createBy.data.userId && createBy.props.userId) {
40
+ createBy.data.userId = createBy.props.userId;
41
+ }
42
+
43
+ if (!createBy.data.userId) {
44
+ throw new BadDataException("userId is required");
45
+ }
46
+
47
+ createBy.data.projectId = projectId;
48
+
49
+ createBy.data.minutesBeforeShift = Service.validateMinutesBeforeShift(
50
+ createBy.data.minutesBeforeShift,
51
+ );
52
+
53
+ const duplicates: PositiveNumber = await this.countBy({
54
+ query: {
55
+ projectId: projectId,
56
+ userId: createBy.data.userId,
57
+ minutesBeforeShift: createBy.data.minutesBeforeShift,
58
+ },
59
+ props: {
60
+ isRoot: true,
61
+ },
62
+ });
63
+
64
+ if (duplicates.toNumber() > 0) {
65
+ throw new BadDataException(
66
+ `You already have a reminder ${createBy.data.minutesBeforeShift} minutes before your shifts.`,
67
+ );
68
+ }
69
+
70
+ return { createBy, carryForward: null };
71
+ }
72
+
73
+ @CaptureSpan()
74
+ protected override async onBeforeUpdate(
75
+ updateBy: UpdateBy<Model>,
76
+ ): Promise<OnUpdate<Model>> {
77
+ if (updateBy.data.minutesBeforeShift !== undefined) {
78
+ updateBy.data.minutesBeforeShift = Service.validateMinutesBeforeShift(
79
+ updateBy.data.minutesBeforeShift,
80
+ );
81
+ }
82
+
83
+ return { updateBy, carryForward: null };
84
+ }
85
+
86
+ /*
87
+ * A whole number of minutes between MIN_MINUTES_BEFORE_SHIFT and
88
+ * MAX_MINUTES_BEFORE_SHIFT. Numeric strings are accepted (a form may post
89
+ * one); missing, fractional, negative or out-of-range values are a 400.
90
+ */
91
+ public static validateMinutesBeforeShift(value: unknown): number {
92
+ const parsed: number =
93
+ typeof value === "string" && value.trim() !== ""
94
+ ? Number(value)
95
+ : (value as number);
96
+
97
+ if (
98
+ typeof parsed !== "number" ||
99
+ !Number.isInteger(parsed) ||
100
+ parsed < MIN_MINUTES_BEFORE_SHIFT ||
101
+ parsed > MAX_MINUTES_BEFORE_SHIFT
102
+ ) {
103
+ throw new BadDataException(
104
+ `Reminder lead time must be a whole number of minutes between ${MIN_MINUTES_BEFORE_SHIFT} and ${MAX_MINUTES_BEFORE_SHIFT} (two weeks).`,
105
+ );
106
+ }
107
+
108
+ return parsed;
109
+ }
110
+ }
111
+
112
+ export default new Service();
@@ -0,0 +1,241 @@
1
+ import crypto from "crypto";
2
+ import OneUptimeDate from "../../../Types/Date";
3
+ import { PREVIOUS_TOKEN_GRACE_DAYS } from "../../../Types/OnCallDutyPolicy/CalendarFeedWindow";
4
+
5
+ /*
6
+ * The capability token that addresses an on-call calendar feed.
7
+ *
8
+ * A calendar client keeps the feed URL forever and re-fetches it unattended,
9
+ * so the token has to be both unguessable and cheap to look up: 256 bits from
10
+ * the CSPRNG, carried in the URL as base64url (43 characters, no padding), and
11
+ * stored ONLY as its unkeyed SHA-256 hex digest. The digest is what the public
12
+ * route queries by, which keeps the plaintext out of the indexed column; a
13
+ * database dump therefore yields hashes, not working feed URLs.
14
+ *
15
+ * Unkeyed on purpose: a keyed hash (HMAC over EncryptionSecret, the backup-code
16
+ * recipe) would tie every feed to the instance secret, and rotating that secret
17
+ * would silently break every subscribed calendar in the world with no way to
18
+ * tell the user. The token already carries 256 bits of entropy, so a rainbow
19
+ * table buys nothing and the key adds no strength worth that failure mode.
20
+ *
21
+ * The hint is the last four characters - enough for the settings page to say
22
+ * "link ending in …k3Qx" so a user can tell two links apart, far too little to
23
+ * recover anything.
24
+ */
25
+
26
+ // crypto.randomBytes(32) -> 32 bytes -> ceil(32 * 8 / 6) = 43 base64url chars.
27
+ const TOKEN_BYTE_LENGTH: number = 32;
28
+
29
+ export const CALENDAR_FEED_TOKEN_LENGTH: number = 43;
30
+
31
+ export const CALENDAR_FEED_TOKEN_HINT_LENGTH: number = 4;
32
+
33
+ /*
34
+ * Shape guard for the public route. Anything that does not match is answered
35
+ * 404 before any database work, so a scanner probing the path costs one regex.
36
+ */
37
+ export const CALENDAR_FEED_TOKEN_REGEX: RegExp = /^[A-Za-z0-9_-]{43}$/;
38
+
39
+ export interface MintedCalendarFeedToken {
40
+ /** The plaintext token. Goes into the URL and, encrypted, into `token`. */
41
+ token: string;
42
+ /** Unkeyed SHA-256 hex digest of the token. The indexed lookup column. */
43
+ tokenHash: string;
44
+ /** Last four characters of the token, for display only. */
45
+ tokenHint: string;
46
+ }
47
+
48
+ /*
49
+ * Everything a feed row's token columns hold after a rotation: the new
50
+ * token set, when it was minted, and the rotated-out hash with the instant
51
+ * it stops being honoured. `previousTokenHash` is null when there was no
52
+ * token before (a first mint), so a fresh feed never carries a grace entry.
53
+ */
54
+ export interface CalendarFeedRotation extends MintedCalendarFeedToken {
55
+ rotatedAt: Date;
56
+ previousTokenHash: string | null;
57
+ previousTokenExpiresAt: Date | null;
58
+ }
59
+
60
+ /*
61
+ * The token columns every feed model carries (UserOnCallCalendarFeed,
62
+ * OnCallDutyPolicyScheduleCalendarFeed, ProjectOnCallCalendarFeed). Typed
63
+ * structurally so the three services share one rule without a common base.
64
+ */
65
+ export interface CalendarFeedTokenColumns {
66
+ token?: string | undefined;
67
+ tokenHash?: string | undefined;
68
+ tokenHint?: string | undefined;
69
+ rotatedAt?: Date | undefined;
70
+ previousTokenHash?: string | undefined;
71
+ previousTokenExpiresAt?: Date | undefined;
72
+ }
73
+
74
+ export interface CalendarFeedRotationUpdateData {
75
+ token: string;
76
+ tokenHash: string;
77
+ tokenHint: string;
78
+ rotatedAt: Date;
79
+ previousTokenHash: string;
80
+ previousTokenExpiresAt: Date;
81
+ }
82
+
83
+ export default class CalendarFeedToken {
84
+ /**
85
+ * A fresh 43-character base64url token from 32 CSPRNG bytes.
86
+ */
87
+ public static mint(): string {
88
+ return crypto.randomBytes(TOKEN_BYTE_LENGTH).toString("base64url");
89
+ }
90
+
91
+ /**
92
+ * Unkeyed SHA-256 of the token, lowercase hex (64 characters). This is the
93
+ * value stored in `tokenHash` / `previousTokenHash` and the value the public
94
+ * route looks up by. Deterministic: the same token always hashes the same.
95
+ */
96
+ public static hash(token: string): string {
97
+ return crypto.createHash("sha256").update(token, "utf8").digest("hex");
98
+ }
99
+
100
+ /**
101
+ * The display hint - the last four characters of the token. Never enough to
102
+ * reconstruct the token; enough to tell "…k3Qx" from "…Q9zA" in the UI.
103
+ */
104
+ public static hint(token: string): string {
105
+ return token.slice(-CALENDAR_FEED_TOKEN_HINT_LENGTH);
106
+ }
107
+
108
+ /**
109
+ * True when the string has exactly the shape a minted token has. Used as
110
+ * the shape guard on the public route so a malformed path never reaches the
111
+ * database.
112
+ */
113
+ public static isValidShape(token: unknown): token is string {
114
+ return typeof token === "string" && CALENDAR_FEED_TOKEN_REGEX.test(token);
115
+ }
116
+
117
+ /**
118
+ * Everything a feed row needs when a token is minted or rotated.
119
+ */
120
+ public static mintSet(): MintedCalendarFeedToken {
121
+ const token: string = CalendarFeedToken.mint();
122
+
123
+ return {
124
+ token: token,
125
+ tokenHash: CalendarFeedToken.hash(token),
126
+ tokenHint: CalendarFeedToken.hint(token),
127
+ };
128
+ }
129
+
130
+ /**
131
+ * The token columns a feed row should hold after minting or rotating.
132
+ *
133
+ * The hash the row held before (if any) moves to `previousTokenHash` and
134
+ * keeps serving an EMPTY calendar until `previousTokenExpiresAt`
135
+ * (PREVIOUS_TOKEN_GRACE_DAYS from now), so a still-subscribed client clears
136
+ * its copy instead of showing "could not fetch" until the user notices.
137
+ */
138
+ public static buildRotation(data: {
139
+ currentTokenHash: string | null | undefined;
140
+ now?: Date | undefined;
141
+ }): CalendarFeedRotation {
142
+ const now: Date = data.now || OneUptimeDate.getCurrentDate();
143
+ const minted: MintedCalendarFeedToken = CalendarFeedToken.mintSet();
144
+
145
+ const previousTokenHash: string | null = data.currentTokenHash || null;
146
+
147
+ return {
148
+ ...minted,
149
+ rotatedAt: now,
150
+ previousTokenHash: previousTokenHash,
151
+ previousTokenExpiresAt: previousTokenHash
152
+ ? OneUptimeDate.addRemoveDays(now, PREVIOUS_TOKEN_GRACE_DAYS)
153
+ : null,
154
+ };
155
+ }
156
+
157
+ /**
158
+ * Populate the token columns of a row about to be INSERTED.
159
+ *
160
+ * With `trustSuppliedToken`, a well-formed plaintext token already on the
161
+ * row (the calendar API mints one up front so it can build the URL in the
162
+ * same response) is kept and the hash and hint are DERIVED from it. Without
163
+ * it - every non-root create - whatever the request carried is discarded
164
+ * and a fresh token is minted. Either way a malformed token or a
165
+ * caller-chosen hash is replaced: there is no path to a row whose hash
166
+ * disagrees with its token, and no path for a request body to choose the
167
+ * secret. A brand-new row never carries a grace-period hash.
168
+ */
169
+ public static applyTokenColumnsOnCreate(
170
+ data: CalendarFeedTokenColumns,
171
+ options: { trustSuppliedToken: boolean },
172
+ ): MintedCalendarFeedToken {
173
+ let minted: MintedCalendarFeedToken;
174
+
175
+ if (
176
+ options.trustSuppliedToken &&
177
+ CalendarFeedToken.isValidShape(data.token)
178
+ ) {
179
+ minted = {
180
+ token: data.token,
181
+ tokenHash: CalendarFeedToken.hash(data.token),
182
+ tokenHint: CalendarFeedToken.hint(data.token),
183
+ };
184
+ } else {
185
+ minted = CalendarFeedToken.mintSet();
186
+ }
187
+
188
+ data.token = minted.token;
189
+ data.tokenHash = minted.tokenHash;
190
+ data.tokenHint = minted.tokenHint;
191
+ data.rotatedAt = OneUptimeDate.getCurrentDate();
192
+ data.previousTokenHash = undefined;
193
+ data.previousTokenExpiresAt = undefined;
194
+
195
+ return minted;
196
+ }
197
+
198
+ /**
199
+ * The partial row a rotation writes. `previousTokenHash` /
200
+ * `previousTokenExpiresAt` are null on a first mint, which the update path
201
+ * has to be able to write; the model types them as optional strings/dates,
202
+ * hence the cast at this one boundary.
203
+ */
204
+ public static toRotationUpdateData(
205
+ rotation: CalendarFeedRotation,
206
+ ): CalendarFeedRotationUpdateData {
207
+ return {
208
+ token: rotation.token,
209
+ tokenHash: rotation.tokenHash,
210
+ tokenHint: rotation.tokenHint,
211
+ rotatedAt: rotation.rotatedAt,
212
+ previousTokenHash: rotation.previousTokenHash as unknown as string,
213
+ previousTokenExpiresAt:
214
+ rotation.previousTokenExpiresAt as unknown as Date,
215
+ };
216
+ }
217
+
218
+ /**
219
+ * Constant-time comparison of two hashes, for callers that compare a
220
+ * freshly computed digest against a stored one in memory rather than in a
221
+ * WHERE clause. Length mismatch is answered false without leaking timing on
222
+ * the content.
223
+ */
224
+ public static isHashEqual(a: string, b: string): boolean {
225
+ /*
226
+ * Plain Uint8Arrays rather than Buffers: the two are interchangeable at
227
+ * runtime, but the App build's @types/node rejects Buffer where an
228
+ * ArrayBufferView is expected (the same mismatch PasswordHash and
229
+ * TwoFactorBackupCode trip over), and TextEncoder is global on every
230
+ * supported Node.
231
+ */
232
+ const bytesA: Uint8Array = new TextEncoder().encode(a);
233
+ const bytesB: Uint8Array = new TextEncoder().encode(b);
234
+
235
+ if (bytesA.length !== bytesB.length) {
236
+ return false;
237
+ }
238
+
239
+ return crypto.timingSafeEqual(bytesA, bytesB);
240
+ }
241
+ }