@twentyhq/call-recorder 1.0.0

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 (229) hide show
  1. package/.env.example +5 -0
  2. package/.nvmrc +1 -0
  3. package/.oxlintrc.json +20 -0
  4. package/.yarnrc.yml +1 -0
  5. package/AGENTS.md +67 -0
  6. package/CLAUDE.md +67 -0
  7. package/README.md +24 -0
  8. package/SETUP.md +95 -0
  9. package/package.json +42 -0
  10. package/public/gallery/call-recorder-cover.png +0 -0
  11. package/public/logo.svg +5 -0
  12. package/src/__tests__/global-setup.ts +100 -0
  13. package/src/__tests__/schema.integration-test.ts +104 -0
  14. package/src/application-config.ts +96 -0
  15. package/src/constants/__tests__/call-recording-field-universal-identifiers.test.ts +19 -0
  16. package/src/constants/app-description.ts +2 -0
  17. package/src/constants/app-display-name.ts +1 -0
  18. package/src/constants/application-universal-identifier.ts +2 -0
  19. package/src/constants/calendar-event-reconciliation-logic-function-universal-identifier.ts +2 -0
  20. package/src/constants/calendar-event-record-page-layout-universal-identifier.ts +2 -0
  21. package/src/constants/calendar-event-recording-front-component-universal-identifier.ts +2 -0
  22. package/src/constants/calendar-event-recording-page-layout-tab-universal-identifier.ts +2 -0
  23. package/src/constants/calendar-event-recording-page-layout-widget-universal-identifier.ts +2 -0
  24. package/src/constants/call-recorder-everyone-left-timeout-seconds-app-variable-universal-identifier.ts +2 -0
  25. package/src/constants/call-recorder-failure-reason-on-call-recording-field-universal-identifier.ts +2 -0
  26. package/src/constants/call-recorder-join-early-minutes-app-variable-universal-identifier.ts +2 -0
  27. package/src/constants/call-recorder-name-app-variable-universal-identifier.ts +2 -0
  28. package/src/constants/call-recorder-noone-joined-timeout-seconds-app-variable-universal-identifier.ts +2 -0
  29. package/src/constants/call-recorder-preference-off-option-id.ts +2 -0
  30. package/src/constants/call-recorder-preference-on-calendar-event-field-universal-identifier.ts +2 -0
  31. package/src/constants/call-recorder-preference-on-calendar-event-view-field-universal-identifier.ts +2 -0
  32. package/src/constants/call-recorder-preference-on-option-id.ts +2 -0
  33. package/src/constants/call-recorder-preference.ts +4 -0
  34. package/src/constants/call-recorder-waiting-room-timeout-seconds-app-variable-universal-identifier.ts +2 -0
  35. package/src/constants/call-recording-audio-field-universal-identifier.ts +2 -0
  36. package/src/constants/call-recording-video-field-universal-identifier.ts +2 -0
  37. package/src/constants/default-role-universal-identifier.ts +2 -0
  38. package/src/constants/process-recall-webhook-logic-function-universal-identifier.ts +2 -0
  39. package/src/constants/recall-webhook-logic-function-universal-identifier.ts +2 -0
  40. package/src/constants/stale-bot-state-logic-function-universal-identifier.ts +2 -0
  41. package/src/default-role.ts +69 -0
  42. package/src/fields/call-recorder-failure-reason-on-call-recording.field.ts +22 -0
  43. package/src/fields/call-recorder-preference-on-calendar-event.field.ts +41 -0
  44. package/src/front-components/calendar-event-recording.front-component.tsx +13 -0
  45. package/src/front-components/components/CalendarEventRecording.tsx +39 -0
  46. package/src/front-components/components/CalendarEventRecordingBody.tsx +96 -0
  47. package/src/front-components/components/CalendarEventRecordingContent.tsx +111 -0
  48. package/src/front-components/components/RecordingTranscript.tsx +92 -0
  49. package/src/front-components/components/RecordingVideoPlayer.tsx +52 -0
  50. package/src/front-components/components/TranscriptEntryList.tsx +61 -0
  51. package/src/front-components/components/TranscriptEntryListItem.tsx +115 -0
  52. package/src/front-components/components/TranscriptErrorBox.tsx +48 -0
  53. package/src/front-components/components/TranscriptSpeakerAvatar.tsx +141 -0
  54. package/src/front-components/components/TranscriptSpeakerChip.tsx +51 -0
  55. package/src/front-components/constants/recording-theme-css-variables.ts +40 -0
  56. package/src/front-components/hooks/use-calendar-event-participants.ts +172 -0
  57. package/src/front-components/hooks/use-calendar-event-recording.ts +155 -0
  58. package/src/front-components/types/calendar-event-participant-by-speaker-name.type.ts +6 -0
  59. package/src/front-components/types/calendar-event-recording-participant.type.ts +7 -0
  60. package/src/front-components/types/transcript-entry.type.ts +13 -0
  61. package/src/front-components/utils/__tests__/find-active-transcript-entry-index.test.ts +66 -0
  62. package/src/front-components/utils/__tests__/format-transcript-timestamp.test.ts +29 -0
  63. package/src/front-components/utils/__tests__/get-speaker-name-match-keys.test.ts +22 -0
  64. package/src/front-components/utils/__tests__/parse-transcript-entries.test.ts +162 -0
  65. package/src/front-components/utils/build-calendar-event-participant-by-speaker-name.util.ts +45 -0
  66. package/src/front-components/utils/find-active-transcript-entry-index.util.ts +77 -0
  67. package/src/front-components/utils/format-transcript-timestamp.util.ts +16 -0
  68. package/src/front-components/utils/get-absolute-avatar-url.util.ts +48 -0
  69. package/src/front-components/utils/get-calendar-event-participant-for-speaker-name.util.ts +24 -0
  70. package/src/front-components/utils/get-speaker-name-match-keys.util.ts +64 -0
  71. package/src/front-components/utils/get-video-file-extension.util.ts +23 -0
  72. package/src/front-components/utils/parse-transcript-entries.util.ts +85 -0
  73. package/src/logic-functions/__tests__/process-recall-webhook.test.ts +62 -0
  74. package/src/logic-functions/__tests__/recall-webhook.test.ts +180 -0
  75. package/src/logic-functions/constants/call-recorder-everyone-left-timeout-seconds-env-var-name.ts +2 -0
  76. package/src/logic-functions/constants/call-recorder-everyone-left-timeout-seconds.ts +1 -0
  77. package/src/logic-functions/constants/call-recorder-join-early-minutes-env-var-name.ts +2 -0
  78. package/src/logic-functions/constants/call-recorder-name-env-var-name.ts +1 -0
  79. package/src/logic-functions/constants/call-recorder-noone-joined-timeout-seconds-env-var-name.ts +2 -0
  80. package/src/logic-functions/constants/call-recorder-noone-joined-timeout-seconds.ts +1 -0
  81. package/src/logic-functions/constants/call-recorder-recording-retention-hours-env-var-name.ts +2 -0
  82. package/src/logic-functions/constants/call-recorder-waiting-room-timeout-seconds-env-var-name.ts +2 -0
  83. package/src/logic-functions/constants/call-recorder-waiting-room-timeout-seconds.ts +1 -0
  84. package/src/logic-functions/constants/call-recording-micro-credits-per-hour.ts +1 -0
  85. package/src/logic-functions/constants/call-recording-request-status.ts +5 -0
  86. package/src/logic-functions/constants/call-recording-status.ts +9 -0
  87. package/src/logic-functions/constants/default-call-recorder-join-early-minutes.ts +1 -0
  88. package/src/logic-functions/constants/default-call-recorder-name.ts +1 -0
  89. package/src/logic-functions/constants/default-call-recorder-recording-retention-hours.ts +2 -0
  90. package/src/logic-functions/constants/default-recall-region.ts +1 -0
  91. package/src/logic-functions/constants/milliseconds-per-minute.ts +1 -0
  92. package/src/logic-functions/constants/non-terminal-call-recording-statuses.ts +8 -0
  93. package/src/logic-functions/constants/recall-api-key-env-var-name.ts +1 -0
  94. package/src/logic-functions/constants/recall-api-max-attempts.ts +1 -0
  95. package/src/logic-functions/constants/recall-api-retry-delay-ms.ts +1 -0
  96. package/src/logic-functions/constants/recall-bot-automatic-leave.ts +74 -0
  97. package/src/logic-functions/constants/recall-bot-everyone-left-min-activate-after-seconds.ts +1 -0
  98. package/src/logic-functions/constants/recall-bot-recording-config.ts +34 -0
  99. package/src/logic-functions/constants/recall-region-env-var-name.ts +1 -0
  100. package/src/logic-functions/constants/recall-webhook-secret-env-var-name.ts +1 -0
  101. package/src/logic-functions/constants/restricted-field-placeholder.ts +3 -0
  102. package/src/logic-functions/constants/stale-bot-state-cron-pattern.ts +1 -0
  103. package/src/logic-functions/constants/twenty-page-size.ts +1 -0
  104. package/src/logic-functions/data/__tests__/complete-call-recording-ingestion.test.ts +55 -0
  105. package/src/logic-functions/data/__tests__/fetch-all-nodes.test.ts +43 -0
  106. package/src/logic-functions/data/__tests__/get-current-workspace-id.test.ts +38 -0
  107. package/src/logic-functions/data/__tests__/strip-restricted-field-value.test.ts +22 -0
  108. package/src/logic-functions/data/complete-call-recording-ingestion.util.ts +24 -0
  109. package/src/logic-functions/data/create-call-recording.util.ts +41 -0
  110. package/src/logic-functions/data/fetch-all-nodes.util.ts +44 -0
  111. package/src/logic-functions/data/fetch-calendar-events-by-filter.util.ts +80 -0
  112. package/src/logic-functions/data/fetch-calendar-events-by-ids.util.ts +20 -0
  113. package/src/logic-functions/data/fetch-calendar-events-by-starts-at-values.util.ts +19 -0
  114. package/src/logic-functions/data/find-call-recordings-by-calendar-event-ids.util.ts +17 -0
  115. package/src/logic-functions/data/find-call-recordings-by-filter.util.ts +102 -0
  116. package/src/logic-functions/data/find-call-recordings-by-ids.util.ts +17 -0
  117. package/src/logic-functions/data/find-open-scheduled-call-recordings.util.ts +14 -0
  118. package/src/logic-functions/data/get-current-workspace-id.util.ts +36 -0
  119. package/src/logic-functions/data/strip-restricted-field-value.util.ts +6 -0
  120. package/src/logic-functions/data/update-call-recording.util.ts +24 -0
  121. package/src/logic-functions/domain/__tests__/build-call-recorder-policy-result.test.ts +47 -0
  122. package/src/logic-functions/domain/__tests__/compute-call-recording-charge.test.ts +71 -0
  123. package/src/logic-functions/domain/__tests__/compute-call-recording-id-for-meeting.test.ts +37 -0
  124. package/src/logic-functions/domain/__tests__/compute-real-meeting-key.test.ts +88 -0
  125. package/src/logic-functions/domain/__tests__/is-call-recording-ingestion-complete.test.ts +59 -0
  126. package/src/logic-functions/domain/__tests__/is-call-recording-status-downgrade.test.ts +37 -0
  127. package/src/logic-functions/domain/__tests__/resolve-call-recorder-policy-result.test.ts +120 -0
  128. package/src/logic-functions/domain/__tests__/should-complete-call-recording-ingestion.test.ts +102 -0
  129. package/src/logic-functions/domain/aggregate-call-recorder-policy-results-by-meeting.util.ts +42 -0
  130. package/src/logic-functions/domain/build-call-recorder-policy-result.util.ts +53 -0
  131. package/src/logic-functions/domain/build-failed-transcript-marker.util.ts +13 -0
  132. package/src/logic-functions/domain/build-pending-transcript-marker.util.ts +13 -0
  133. package/src/logic-functions/domain/build-recall-routing-metadata.util.ts +12 -0
  134. package/src/logic-functions/domain/build-transcript-failure-reason.util.ts +7 -0
  135. package/src/logic-functions/domain/compute-call-recording-charge.util.ts +41 -0
  136. package/src/logic-functions/domain/compute-call-recording-id-for-meeting.util.ts +16 -0
  137. package/src/logic-functions/domain/compute-real-meeting-key.util.ts +48 -0
  138. package/src/logic-functions/domain/compute-recall-bot-join-at.util.ts +34 -0
  139. package/src/logic-functions/domain/is-call-recording-ingestion-complete.util.ts +19 -0
  140. package/src/logic-functions/domain/is-call-recording-status-downgrade.util.ts +37 -0
  141. package/src/logic-functions/domain/is-recall-recording-done-signal.util.ts +13 -0
  142. package/src/logic-functions/domain/map-recall-status-code-to-call-recording-status.util.ts +26 -0
  143. package/src/logic-functions/domain/parse-transcript-marker.util.ts +29 -0
  144. package/src/logic-functions/domain/resolve-call-recorder-policy-result.util.ts +72 -0
  145. package/src/logic-functions/domain/should-complete-call-recording-ingestion.util.ts +32 -0
  146. package/src/logic-functions/flows/__tests__/charge-completed-call-recording.test.ts +45 -0
  147. package/src/logic-functions/flows/__tests__/complete-and-charge-call-recording.test.ts +61 -0
  148. package/src/logic-functions/flows/__tests__/converge-diverged-call-recordings.test.ts +727 -0
  149. package/src/logic-functions/flows/__tests__/download-transcript.test.ts +74 -0
  150. package/src/logic-functions/flows/__tests__/handle-recall-webhook.test.ts +1301 -0
  151. package/src/logic-functions/flows/__tests__/heal-call-recordings-missing-bot.test.ts +225 -0
  152. package/src/logic-functions/flows/__tests__/ingest-call-recording-media.test.ts +153 -0
  153. package/src/logic-functions/flows/__tests__/reap-orphaned-call-recorders.test.ts +425 -0
  154. package/src/logic-functions/flows/__tests__/reconcile-call-recorder.test.ts +1007 -0
  155. package/src/logic-functions/flows/cancel-call-recording-request.util.ts +46 -0
  156. package/src/logic-functions/flows/charge-completed-call-recording.util.ts +31 -0
  157. package/src/logic-functions/flows/complete-and-charge-call-recording.util.ts +29 -0
  158. package/src/logic-functions/flows/converge-diverged-call-recordings-result.type.ts +8 -0
  159. package/src/logic-functions/flows/converge-diverged-call-recordings.util.ts +447 -0
  160. package/src/logic-functions/flows/download-transcript.util.ts +67 -0
  161. package/src/logic-functions/flows/ensure-call-recorder.util.ts +73 -0
  162. package/src/logic-functions/flows/handle-recall-webhook.util.ts +672 -0
  163. package/src/logic-functions/flows/heal-call-recordings-missing-bot.util.ts +82 -0
  164. package/src/logic-functions/flows/ingest-call-recording-media.util.ts +128 -0
  165. package/src/logic-functions/flows/persist-call-recording-progress.util.ts +58 -0
  166. package/src/logic-functions/flows/reap-orphaned-call-recorders.util.ts +183 -0
  167. package/src/logic-functions/flows/reconcile-call-recorder.util.ts +495 -0
  168. package/src/logic-functions/flows/reconcile-call-recording-transcript-artifact-result.type.ts +11 -0
  169. package/src/logic-functions/flows/reconcile-call-recording-transcript-artifact.util.ts +182 -0
  170. package/src/logic-functions/flows/reschedule-call-recording-bot.util.ts +69 -0
  171. package/src/logic-functions/process-recall-webhook.ts +23 -0
  172. package/src/logic-functions/recall-api/__tests__/extract-recall-bot-convergence.test.ts +153 -0
  173. package/src/logic-functions/recall-api/__tests__/extract-recall-media-urls.test.ts +67 -0
  174. package/src/logic-functions/recall-api/__tests__/recall-bot-api.test.ts +744 -0
  175. package/src/logic-functions/recall-api/__tests__/verify-recall-webhook-signature.test.ts +122 -0
  176. package/src/logic-functions/recall-api/cancel-recall-bot.util.ts +28 -0
  177. package/src/logic-functions/recall-api/create-async-recall-transcript.util.ts +47 -0
  178. package/src/logic-functions/recall-api/eject-recall-bot.util.ts +28 -0
  179. package/src/logic-functions/recall-api/extract-recall-bot-convergence.util.ts +149 -0
  180. package/src/logic-functions/recall-api/extract-recall-bot-id.util.ts +10 -0
  181. package/src/logic-functions/recall-api/extract-recall-media-urls.util.ts +30 -0
  182. package/src/logic-functions/recall-api/extract-twenty-workspace-id-from-recall-webhook.util.ts +8 -0
  183. package/src/logic-functions/recall-api/get-recall-api-config.util.ts +59 -0
  184. package/src/logic-functions/recall-api/get-recall-bot.util.ts +42 -0
  185. package/src/logic-functions/recall-api/get-recall-recording.util.ts +31 -0
  186. package/src/logic-functions/recall-api/get-recall-webhook-bot-metadata.util.ts +18 -0
  187. package/src/logic-functions/recall-api/list-recall-transcripts.util.ts +141 -0
  188. package/src/logic-functions/recall-api/list-scheduled-recall-bots.util.ts +106 -0
  189. package/src/logic-functions/recall-api/normalize-recall-timestamp.util.ts +14 -0
  190. package/src/logic-functions/recall-api/parse-recall-webhook-event.util.ts +88 -0
  191. package/src/logic-functions/recall-api/recall-bot-api-request.util.ts +165 -0
  192. package/src/logic-functions/recall-api/recall-transcript-summary.type.ts +5 -0
  193. package/src/logic-functions/recall-api/reschedule-recall-bot.util.ts +56 -0
  194. package/src/logic-functions/recall-api/retrieve-recall-transcript.util.ts +71 -0
  195. package/src/logic-functions/recall-api/schedule-recall-bot.util.ts +68 -0
  196. package/src/logic-functions/recall-api/verify-recall-webhook-signature.util.ts +109 -0
  197. package/src/logic-functions/recall-webhook.ts +90 -0
  198. package/src/logic-functions/reconcile-call-recorder-calendar-event.ts +178 -0
  199. package/src/logic-functions/reconcile-stale-bot-state.ts +106 -0
  200. package/src/logic-functions/types/calendar-event-record.type.ts +5 -0
  201. package/src/logic-functions/types/call-recorder-policy-calendar-event-input.type.ts +10 -0
  202. package/src/logic-functions/types/call-recorder-policy-input.type.ts +9 -0
  203. package/src/logic-functions/types/call-recorder-policy-not-required-reason.type.ts +5 -0
  204. package/src/logic-functions/types/call-recorder-policy-required-reason.type.ts +1 -0
  205. package/src/logic-functions/types/call-recorder-policy-result-for-calendar-event.type.ts +9 -0
  206. package/src/logic-functions/types/call-recorder-policy-result-for-meeting.type.ts +6 -0
  207. package/src/logic-functions/types/call-recorder-policy-result.type.ts +12 -0
  208. package/src/logic-functions/types/call-recorder-reconciliation-result.type.ts +16 -0
  209. package/src/logic-functions/types/call-recording-media-file.type.ts +1 -0
  210. package/src/logic-functions/types/call-recording-record.type.ts +15 -0
  211. package/src/logic-functions/types/call-recording-update-fields.type.ts +20 -0
  212. package/src/logic-functions/types/files-field-value.type.ts +1 -0
  213. package/src/logic-functions/types/meeting-recording.type.ts +7 -0
  214. package/src/logic-functions/types/recall-bot-operation-result.type.ts +19 -0
  215. package/src/logic-functions/types/recall-routing-metadata.type.ts +4 -0
  216. package/src/logic-functions/types/removed-call-recorder-occurrence.type.ts +6 -0
  217. package/src/logic-functions/types/transcript-marker.type.ts +6 -0
  218. package/src/logic-functions/utils/as-record.util.ts +6 -0
  219. package/src/logic-functions/utils/get-application-variable-value.util.ts +3 -0
  220. package/src/logic-functions/utils/get-record-at-path.util.ts +10 -0
  221. package/src/logic-functions/utils/get-string.util.ts +4 -0
  222. package/src/logic-functions/utils/get-unique-sorted-ids.util.ts +8 -0
  223. package/src/logic-functions/utils/is-non-empty-string.util.ts +5 -0
  224. package/src/page-layouts/calendar-event-recording-tab.ts +33 -0
  225. package/src/view-fields/call-recorder-preference-on-calendar-event.view-field.ts +27 -0
  226. package/tsconfig.json +42 -0
  227. package/tsconfig.spec.json +9 -0
  228. package/vitest.config.ts +31 -0
  229. package/vitest.unit.config.ts +14 -0
@@ -0,0 +1,1007 @@
1
+ import { type CoreApiClient } from 'twenty-client-sdk/core';
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ import { computeCallRecordingIdForMeeting } from 'src/logic-functions/domain/compute-call-recording-id-for-meeting.util';
5
+ import { reconcileCallRecorderForCalendarEventIds } from 'src/logic-functions/flows/reconcile-call-recorder.util';
6
+
7
+ const scheduleRecallBotMock = vi.hoisted(() => vi.fn());
8
+ const rescheduleRecallBotMock = vi.hoisted(() => vi.fn());
9
+ const cancelRecallBotMock = vi.hoisted(() => vi.fn());
10
+ const getCurrentWorkspaceIdMock = vi.hoisted(() => vi.fn());
11
+
12
+ vi.mock('src/logic-functions/data/get-current-workspace-id.util', () => ({
13
+ getCurrentWorkspaceId: getCurrentWorkspaceIdMock,
14
+ }));
15
+
16
+ vi.mock('src/logic-functions/recall-api/schedule-recall-bot.util', () => ({
17
+ scheduleRecallBot: scheduleRecallBotMock,
18
+ }));
19
+
20
+ vi.mock('src/logic-functions/recall-api/reschedule-recall-bot.util', () => ({
21
+ rescheduleRecallBot: rescheduleRecallBotMock,
22
+ }));
23
+
24
+ vi.mock('src/logic-functions/recall-api/cancel-recall-bot.util', () => ({
25
+ cancelRecallBot: cancelRecallBotMock,
26
+ }));
27
+
28
+ const NOW = new Date('2026-01-01T12:00:00.000Z');
29
+ const WORKSPACE_ID = '123e4567-e89b-12d3-a456-426614174000';
30
+ const FUTURE_STARTS_AT = '2026-01-01T13:00:00.000Z';
31
+ const FUTURE_RECALL_BOT_JOIN_AT = '2026-01-01T12:59:00.000Z';
32
+ const FUTURE_ENDS_AT = '2026-01-01T14:00:00.000Z';
33
+
34
+ const buildCustomerSyncCallRecordingId = (
35
+ startsAt: string = FUTURE_STARTS_AT,
36
+ ): string =>
37
+ computeCallRecordingIdForMeeting(
38
+ `link:meet.example.com/customer-sync:${startsAt}`,
39
+ );
40
+
41
+ type CalendarEventNode = {
42
+ id: string;
43
+ title?: string | null;
44
+ isCanceled?: boolean | null;
45
+ startsAt?: string | null;
46
+ endsAt?: string | null;
47
+ iCalUid?: string | null;
48
+ conferenceLink?: { primaryLinkUrl?: string | null } | null;
49
+ callRecorderPreference?: string | null;
50
+ };
51
+
52
+ type CallRecordingNode = {
53
+ id: string;
54
+ title?: string | null;
55
+ status?: string | null;
56
+ recordingRequestStatus?: string | null;
57
+ startedAt?: string | null;
58
+ endedAt?: string | null;
59
+ calendarEventId?: string | null;
60
+ externalBotId?: string | null;
61
+ externalRecordingId?: string | null;
62
+ };
63
+
64
+ type FakeCoreApiClientFixture = {
65
+ calendarEvents: CalendarEventNode[];
66
+ callRecordings?: CallRecordingNode[];
67
+ };
68
+
69
+ class FakeCoreApiClient {
70
+ calendarEvents: CalendarEventNode[];
71
+ callRecordings: CallRecordingNode[];
72
+ mutations: Array<{ name: string; args: unknown }> = [];
73
+
74
+ constructor({
75
+ calendarEvents,
76
+ callRecordings = [],
77
+ }: FakeCoreApiClientFixture) {
78
+ this.calendarEvents = calendarEvents;
79
+ this.callRecordings = callRecordings;
80
+ }
81
+
82
+ async query(query: any): Promise<any> {
83
+ if (query.calendarEvents !== undefined) {
84
+ return {
85
+ calendarEvents: buildConnection(
86
+ this.filterCalendarEvents(query.calendarEvents.__args.filter),
87
+ ),
88
+ };
89
+ }
90
+
91
+ if (query.callRecordings !== undefined) {
92
+ const filter = query.callRecordings.__args.filter;
93
+
94
+ if (filter.id?.in !== undefined) {
95
+ return {
96
+ callRecordings: buildConnection(
97
+ this.callRecordings.filter((callRecording) =>
98
+ filter.id.in.includes(callRecording.id),
99
+ ),
100
+ ),
101
+ };
102
+ }
103
+
104
+ return {
105
+ callRecordings: buildConnection(
106
+ this.callRecordings.filter((callRecording) =>
107
+ filter.calendarEventId.in.includes(callRecording.calendarEventId),
108
+ ),
109
+ ),
110
+ };
111
+ }
112
+
113
+ throw new Error(`Unhandled query: ${JSON.stringify(query)}`);
114
+ }
115
+
116
+ async mutation(mutation: any): Promise<any> {
117
+ if (mutation.createCallRecording !== undefined) {
118
+ const data = mutation.createCallRecording.__args.data;
119
+
120
+ if (this.callRecordings.some((candidate) => candidate.id === data.id)) {
121
+ throw new Error(`Duplicate call recording id ${data.id}`);
122
+ }
123
+
124
+ const createdCallRecording = { ...data };
125
+
126
+ this.callRecordings.push(createdCallRecording);
127
+ this.mutations.push({
128
+ name: 'createCallRecording',
129
+ args: data,
130
+ });
131
+
132
+ return {
133
+ createCallRecording: {
134
+ id: createdCallRecording.id,
135
+ },
136
+ };
137
+ }
138
+
139
+ if (mutation.updateCallRecording !== undefined) {
140
+ const { id, data } = mutation.updateCallRecording.__args;
141
+ const callRecording = this.callRecordings.find(
142
+ (candidate) => candidate.id === id,
143
+ );
144
+
145
+ if (callRecording === undefined) {
146
+ throw new Error(`Could not find call recording ${id}`);
147
+ }
148
+
149
+ Object.assign(callRecording, data);
150
+ this.mutations.push({
151
+ name: 'updateCallRecording',
152
+ args: { id, data },
153
+ });
154
+
155
+ return {
156
+ updateCallRecording: {
157
+ id,
158
+ },
159
+ };
160
+ }
161
+
162
+ throw new Error(`Unhandled mutation: ${JSON.stringify(mutation)}`);
163
+ }
164
+
165
+ private filterCalendarEvents(filter: any): CalendarEventNode[] {
166
+ if (filter.id?.in !== undefined) {
167
+ return this.calendarEvents.filter((calendarEvent) =>
168
+ filter.id.in.includes(calendarEvent.id),
169
+ );
170
+ }
171
+
172
+ if (filter.startsAt?.in !== undefined) {
173
+ return this.calendarEvents.filter((calendarEvent) =>
174
+ filter.startsAt.in.includes(calendarEvent.startsAt),
175
+ );
176
+ }
177
+
178
+ throw new Error(
179
+ `Unhandled calendar event filter: ${JSON.stringify(filter)}`,
180
+ );
181
+ }
182
+ }
183
+
184
+ const buildConnection = <Node>(nodes: Node[]) => ({
185
+ pageInfo: {
186
+ hasNextPage: false,
187
+ endCursor: undefined,
188
+ },
189
+ edges: nodes.map((node) => ({ node })),
190
+ });
191
+
192
+ const buildCalendarEvent = (
193
+ overrides: Partial<CalendarEventNode> = {},
194
+ ): CalendarEventNode => ({
195
+ id: 'calendar-event-1',
196
+ title: 'Customer Sync',
197
+ isCanceled: false,
198
+ startsAt: FUTURE_STARTS_AT,
199
+ endsAt: FUTURE_ENDS_AT,
200
+ iCalUid: 'calendar-event-uid',
201
+ conferenceLink: {
202
+ primaryLinkUrl: 'https://meet.example.com/customer-sync',
203
+ },
204
+ callRecorderPreference: 'ON',
205
+ ...overrides,
206
+ });
207
+
208
+ const buildFakeCoreApiClient = (
209
+ fixture: FakeCoreApiClientFixture,
210
+ ): FakeCoreApiClient => new FakeCoreApiClient(fixture);
211
+
212
+ describe('reconcileCallRecorderForCalendarEventIds', () => {
213
+ beforeEach(() => {
214
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
215
+ vi.spyOn(console, 'error').mockImplementation(() => {});
216
+ getCurrentWorkspaceIdMock.mockReset();
217
+ getCurrentWorkspaceIdMock.mockReturnValue(WORKSPACE_ID);
218
+ scheduleRecallBotMock.mockReset();
219
+ scheduleRecallBotMock.mockResolvedValue({
220
+ ok: true,
221
+ externalBotId: 'recall-bot-1',
222
+ });
223
+ rescheduleRecallBotMock.mockReset();
224
+ rescheduleRecallBotMock.mockResolvedValue({
225
+ ok: true,
226
+ externalBotId: 'recall-bot-1',
227
+ });
228
+ cancelRecallBotMock.mockReset();
229
+ cancelRecallBotMock.mockResolvedValue({
230
+ ok: true,
231
+ externalBotId: null,
232
+ });
233
+ });
234
+
235
+ it('creates a scheduled call recording when the policy requests a bot', async () => {
236
+ const client = buildFakeCoreApiClient({
237
+ calendarEvents: [buildCalendarEvent()],
238
+ });
239
+
240
+ const result = await reconcileCallRecorderForCalendarEventIds({
241
+ client: client as unknown as CoreApiClient,
242
+ calendarEventIds: ['calendar-event-1'],
243
+ now: NOW,
244
+ });
245
+
246
+ expect(result).toEqual([
247
+ expect.objectContaining({
248
+ action: 'CREATED',
249
+ callRecordingId: buildCustomerSyncCallRecordingId(),
250
+ }),
251
+ ]);
252
+ expect(client.callRecordings).toEqual([
253
+ {
254
+ id: buildCustomerSyncCallRecordingId(),
255
+ title: 'Customer Sync',
256
+ status: 'SCHEDULED',
257
+ recordingRequestStatus: 'REQUESTED',
258
+ calendarEventId: 'calendar-event-1',
259
+ externalBotId: 'recall-bot-1',
260
+ },
261
+ ]);
262
+ expect(scheduleRecallBotMock).toHaveBeenCalledWith({
263
+ meetingUrl: 'https://meet.example.com/customer-sync',
264
+ joinAt: FUTURE_RECALL_BOT_JOIN_AT,
265
+ metadata: {
266
+ twentyWorkspaceId: WORKSPACE_ID,
267
+ twentyCallRecordingId: buildCustomerSyncCallRecordingId(),
268
+ },
269
+ });
270
+ });
271
+
272
+ it('creates a scheduled call recording for the default ON preference', async () => {
273
+ const client = buildFakeCoreApiClient({
274
+ calendarEvents: [buildCalendarEvent({ callRecorderPreference: null })],
275
+ });
276
+
277
+ const result = await reconcileCallRecorderForCalendarEventIds({
278
+ client: client as unknown as CoreApiClient,
279
+ calendarEventIds: ['calendar-event-1'],
280
+ now: NOW,
281
+ });
282
+
283
+ expect(result).toEqual([
284
+ expect.objectContaining({
285
+ action: 'CREATED',
286
+ callRecordingId: buildCustomerSyncCallRecordingId(),
287
+ }),
288
+ ]);
289
+ expect(scheduleRecallBotMock).toHaveBeenCalledTimes(1);
290
+ });
291
+
292
+ it('creates a recording for an in-progress meeting that has not ended', async () => {
293
+ const client = buildFakeCoreApiClient({
294
+ calendarEvents: [
295
+ buildCalendarEvent({
296
+ callRecorderPreference: null,
297
+ startsAt: '2026-01-01T11:30:00.000Z',
298
+ endsAt: '2026-01-01T13:00:00.000Z',
299
+ }),
300
+ ],
301
+ });
302
+
303
+ const result = await reconcileCallRecorderForCalendarEventIds({
304
+ client: client as unknown as CoreApiClient,
305
+ calendarEventIds: ['calendar-event-1'],
306
+ now: NOW,
307
+ });
308
+
309
+ expect(result).toEqual([
310
+ expect.objectContaining({
311
+ action: 'CREATED',
312
+ callRecordingId: buildCustomerSyncCallRecordingId(
313
+ '2026-01-01T11:30:00.000Z',
314
+ ),
315
+ }),
316
+ ]);
317
+ expect(scheduleRecallBotMock).toHaveBeenCalledTimes(1);
318
+ });
319
+
320
+ it('updates an existing in-progress recording', async () => {
321
+ const client = buildFakeCoreApiClient({
322
+ calendarEvents: [
323
+ buildCalendarEvent({
324
+ callRecorderPreference: null,
325
+ title: 'Updated Customer Sync',
326
+ startsAt: '2026-01-01T11:30:00.000Z',
327
+ endsAt: '2026-01-01T13:00:00.000Z',
328
+ }),
329
+ ],
330
+ callRecordings: [
331
+ {
332
+ id: buildCustomerSyncCallRecordingId('2026-01-01T11:30:00.000Z'),
333
+ title: 'Old Customer Sync',
334
+ status: 'SCHEDULED',
335
+ recordingRequestStatus: 'REQUESTED',
336
+ calendarEventId: 'calendar-event-1',
337
+ externalBotId: 'recall-bot-1',
338
+ },
339
+ ],
340
+ });
341
+
342
+ const result = await reconcileCallRecorderForCalendarEventIds({
343
+ client: client as unknown as CoreApiClient,
344
+ calendarEventIds: ['calendar-event-1'],
345
+ now: NOW,
346
+ });
347
+
348
+ expect(result).toEqual([
349
+ expect.objectContaining({
350
+ action: 'UPDATED',
351
+ callRecordingId: buildCustomerSyncCallRecordingId(
352
+ '2026-01-01T11:30:00.000Z',
353
+ ),
354
+ }),
355
+ ]);
356
+ expect(client.callRecordings).toEqual([
357
+ expect.objectContaining({
358
+ title: 'Updated Customer Sync',
359
+ recordingRequestStatus: 'REQUESTED',
360
+ }),
361
+ ]);
362
+ });
363
+
364
+ it('updates an existing policy-managed scheduled call recording', async () => {
365
+ const client = buildFakeCoreApiClient({
366
+ calendarEvents: [
367
+ buildCalendarEvent({
368
+ title: 'Updated Customer Sync',
369
+ }),
370
+ ],
371
+ callRecordings: [
372
+ {
373
+ id: buildCustomerSyncCallRecordingId(),
374
+ title: 'Old Customer Sync',
375
+ status: 'SCHEDULED',
376
+ recordingRequestStatus: 'REQUESTED',
377
+ startedAt: FUTURE_STARTS_AT,
378
+ endedAt: FUTURE_ENDS_AT,
379
+ calendarEventId: 'calendar-event-1',
380
+ externalBotId: 'recall-bot-1',
381
+ },
382
+ ],
383
+ });
384
+
385
+ const result = await reconcileCallRecorderForCalendarEventIds({
386
+ client: client as unknown as CoreApiClient,
387
+ calendarEventIds: ['calendar-event-1'],
388
+ now: NOW,
389
+ });
390
+
391
+ expect(result).toEqual([
392
+ expect.objectContaining({
393
+ action: 'UPDATED',
394
+ callRecordingId: buildCustomerSyncCallRecordingId(),
395
+ }),
396
+ ]);
397
+ expect(client.callRecordings).toEqual([
398
+ expect.objectContaining({
399
+ id: buildCustomerSyncCallRecordingId(),
400
+ title: 'Updated Customer Sync',
401
+ status: 'SCHEDULED',
402
+ recordingRequestStatus: 'REQUESTED',
403
+ startedAt: FUTURE_STARTS_AT,
404
+ endedAt: FUTURE_ENDS_AT,
405
+ calendarEventId: 'calendar-event-1',
406
+ externalBotId: 'recall-bot-1',
407
+ }),
408
+ ]);
409
+ expect(rescheduleRecallBotMock).toHaveBeenCalledWith({
410
+ externalBotId: 'recall-bot-1',
411
+ meetingUrl: 'https://meet.example.com/customer-sync',
412
+ joinAt: FUTURE_RECALL_BOT_JOIN_AT,
413
+ metadata: {
414
+ twentyWorkspaceId: WORKSPACE_ID,
415
+ twentyCallRecordingId: buildCustomerSyncCallRecordingId(),
416
+ },
417
+ });
418
+ });
419
+
420
+ it('cancels an existing scheduled request when the policy no longer requests a bot', async () => {
421
+ const client = buildFakeCoreApiClient({
422
+ calendarEvents: [
423
+ buildCalendarEvent({
424
+ callRecorderPreference: 'OFF',
425
+ }),
426
+ ],
427
+ callRecordings: [
428
+ {
429
+ id: 'call-recording-1',
430
+ title: 'Customer Sync',
431
+ status: 'SCHEDULED',
432
+ recordingRequestStatus: 'REQUESTED',
433
+ startedAt: FUTURE_STARTS_AT,
434
+ endedAt: FUTURE_ENDS_AT,
435
+ calendarEventId: 'calendar-event-1',
436
+ externalBotId: 'recall-bot-1',
437
+ },
438
+ ],
439
+ });
440
+
441
+ const result = await reconcileCallRecorderForCalendarEventIds({
442
+ client: client as unknown as CoreApiClient,
443
+ calendarEventIds: ['calendar-event-1'],
444
+ now: NOW,
445
+ });
446
+
447
+ expect(result).toEqual([
448
+ expect.objectContaining({
449
+ action: 'CANCELED',
450
+ callRecordingId: 'call-recording-1',
451
+ }),
452
+ ]);
453
+ expect(client.callRecordings).toEqual([
454
+ expect.objectContaining({
455
+ id: 'call-recording-1',
456
+ recordingRequestStatus: 'CANCELED',
457
+ externalBotId: null,
458
+ }),
459
+ ]);
460
+ expect(cancelRecallBotMock).toHaveBeenCalledWith({
461
+ externalBotId: 'recall-bot-1',
462
+ });
463
+ });
464
+
465
+ it('persists the cancel intent and leaves the bot for the planned stale-state cron when the Recall cancel fails', async () => {
466
+ cancelRecallBotMock.mockResolvedValue({
467
+ ok: false,
468
+ status: 500,
469
+ errorMessage: 'Recall API responded with HTTP 500',
470
+ });
471
+
472
+ const client = buildFakeCoreApiClient({
473
+ calendarEvents: [
474
+ buildCalendarEvent({
475
+ callRecorderPreference: 'OFF',
476
+ }),
477
+ ],
478
+ callRecordings: [
479
+ {
480
+ id: 'call-recording-1',
481
+ title: 'Customer Sync',
482
+ status: 'SCHEDULED',
483
+ recordingRequestStatus: 'REQUESTED',
484
+ startedAt: FUTURE_STARTS_AT,
485
+ endedAt: FUTURE_ENDS_AT,
486
+ calendarEventId: 'calendar-event-1',
487
+ externalBotId: 'recall-bot-1',
488
+ },
489
+ ],
490
+ });
491
+
492
+ const result = await reconcileCallRecorderForCalendarEventIds({
493
+ client: client as unknown as CoreApiClient,
494
+ calendarEventIds: ['calendar-event-1'],
495
+ now: NOW,
496
+ });
497
+
498
+ expect(result).toEqual([
499
+ expect.objectContaining({
500
+ action: 'CANCELED',
501
+ callRecordingId: 'call-recording-1',
502
+ }),
503
+ ]);
504
+ expect(client.callRecordings).toEqual([
505
+ expect.objectContaining({
506
+ id: 'call-recording-1',
507
+ recordingRequestStatus: 'CANCELED',
508
+ externalBotId: 'recall-bot-1',
509
+ }),
510
+ ]);
511
+ });
512
+
513
+ it('does not reset the status of a recording whose bot is already live', async () => {
514
+ const client = buildFakeCoreApiClient({
515
+ calendarEvents: [
516
+ buildCalendarEvent({
517
+ title: 'Renamed Customer Sync',
518
+ }),
519
+ ],
520
+ callRecordings: [
521
+ {
522
+ id: buildCustomerSyncCallRecordingId(),
523
+ title: 'Customer Sync',
524
+ status: 'JOINING',
525
+ recordingRequestStatus: 'REQUESTED',
526
+ startedAt: FUTURE_STARTS_AT,
527
+ endedAt: FUTURE_ENDS_AT,
528
+ calendarEventId: 'calendar-event-1',
529
+ externalBotId: 'recall-bot-1',
530
+ },
531
+ ],
532
+ });
533
+
534
+ const result = await reconcileCallRecorderForCalendarEventIds({
535
+ client: client as unknown as CoreApiClient,
536
+ calendarEventIds: ['calendar-event-1'],
537
+ now: NOW,
538
+ });
539
+
540
+ expect(result).toEqual([
541
+ expect.objectContaining({
542
+ action: 'UPDATED',
543
+ callRecordingId: buildCustomerSyncCallRecordingId(),
544
+ }),
545
+ ]);
546
+ expect(client.callRecordings).toEqual([
547
+ expect.objectContaining({
548
+ id: buildCustomerSyncCallRecordingId(),
549
+ title: 'Renamed Customer Sync',
550
+ status: 'JOINING',
551
+ }),
552
+ ]);
553
+ });
554
+
555
+ it('creates a single recording when duplicate synced rows share the same real meeting', async () => {
556
+ const client = buildFakeCoreApiClient({
557
+ calendarEvents: [
558
+ buildCalendarEvent(),
559
+ buildCalendarEvent({
560
+ id: 'calendar-event-2',
561
+ iCalUid: 'calendar-event-uid-from-other-channel',
562
+ }),
563
+ ],
564
+ });
565
+
566
+ const result = await reconcileCallRecorderForCalendarEventIds({
567
+ client: client as unknown as CoreApiClient,
568
+ calendarEventIds: ['calendar-event-1'],
569
+ now: NOW,
570
+ });
571
+
572
+ expect(result).toEqual([
573
+ expect.objectContaining({
574
+ action: 'CREATED',
575
+ callRecordingId: buildCustomerSyncCallRecordingId(),
576
+ }),
577
+ ]);
578
+ expect(client.callRecordings).toHaveLength(1);
579
+ expect(scheduleRecallBotMock).toHaveBeenCalledTimes(1);
580
+ });
581
+
582
+ it('does not create a duplicate when a non-policy-managed open recording already exists', async () => {
583
+ const client = buildFakeCoreApiClient({
584
+ calendarEvents: [buildCalendarEvent()],
585
+ callRecordings: [
586
+ {
587
+ id: 'call-recording-1',
588
+ title: 'Manual Recording',
589
+ status: 'SCHEDULED',
590
+ recordingRequestStatus: null,
591
+ startedAt: FUTURE_STARTS_AT,
592
+ endedAt: FUTURE_ENDS_AT,
593
+ calendarEventId: 'calendar-event-1',
594
+ },
595
+ ],
596
+ });
597
+
598
+ const result = await reconcileCallRecorderForCalendarEventIds({
599
+ client: client as unknown as CoreApiClient,
600
+ calendarEventIds: ['calendar-event-1'],
601
+ now: NOW,
602
+ });
603
+
604
+ expect(result).toEqual([
605
+ expect.objectContaining({
606
+ action: 'SKIPPED',
607
+ callRecordingId: 'call-recording-1',
608
+ }),
609
+ ]);
610
+ expect(client.callRecordings).toHaveLength(1);
611
+ expect(client.mutations).toEqual([]);
612
+ expect(scheduleRecallBotMock).not.toHaveBeenCalled();
613
+ });
614
+
615
+ it('cancels the scheduled request when the calendar event is deleted', async () => {
616
+ const client = buildFakeCoreApiClient({
617
+ calendarEvents: [],
618
+ callRecordings: [
619
+ {
620
+ id: 'call-recording-1',
621
+ title: 'Customer Sync',
622
+ status: 'SCHEDULED',
623
+ recordingRequestStatus: 'REQUESTED',
624
+ startedAt: FUTURE_STARTS_AT,
625
+ endedAt: FUTURE_ENDS_AT,
626
+ calendarEventId: 'calendar-event-1',
627
+ externalBotId: 'recall-bot-1',
628
+ },
629
+ ],
630
+ });
631
+
632
+ const result = await reconcileCallRecorderForCalendarEventIds({
633
+ client: client as unknown as CoreApiClient,
634
+ calendarEventIds: [],
635
+ removedOccurrences: [
636
+ {
637
+ calendarEventId: 'calendar-event-1',
638
+ realMeetingKey: `link:meet.example.com/customer-sync:${FUTURE_STARTS_AT}`,
639
+ startsAt: FUTURE_STARTS_AT,
640
+ },
641
+ ],
642
+ now: NOW,
643
+ });
644
+
645
+ expect(result).toEqual([
646
+ expect.objectContaining({
647
+ action: 'CANCELED',
648
+ callRecordingId: 'call-recording-1',
649
+ }),
650
+ ]);
651
+ expect(client.callRecordings).toEqual([
652
+ expect.objectContaining({
653
+ id: 'call-recording-1',
654
+ recordingRequestStatus: 'CANCELED',
655
+ externalBotId: null,
656
+ }),
657
+ ]);
658
+ expect(cancelRecallBotMock).toHaveBeenCalledWith({
659
+ externalBotId: 'recall-bot-1',
660
+ });
661
+ });
662
+
663
+ it('cancels the old occurrence and creates a fresh recording when the meeting moves to a new time', async () => {
664
+ const NEW_STARTS_AT = '2026-01-02T13:00:00.000Z';
665
+ const NEW_RECALL_BOT_JOIN_AT = '2026-01-02T12:59:00.000Z';
666
+ const NEW_ENDS_AT = '2026-01-02T14:00:00.000Z';
667
+ const client = buildFakeCoreApiClient({
668
+ calendarEvents: [
669
+ buildCalendarEvent({
670
+ startsAt: NEW_STARTS_AT,
671
+ endsAt: NEW_ENDS_AT,
672
+ }),
673
+ ],
674
+ callRecordings: [
675
+ {
676
+ id: buildCustomerSyncCallRecordingId(),
677
+ title: 'Customer Sync',
678
+ status: 'SCHEDULED',
679
+ recordingRequestStatus: 'REQUESTED',
680
+ startedAt: FUTURE_STARTS_AT,
681
+ endedAt: FUTURE_ENDS_AT,
682
+ calendarEventId: 'calendar-event-1',
683
+ externalBotId: 'recall-bot-old',
684
+ },
685
+ ],
686
+ });
687
+
688
+ const result = await reconcileCallRecorderForCalendarEventIds({
689
+ client: client as unknown as CoreApiClient,
690
+ calendarEventIds: ['calendar-event-1'],
691
+ removedOccurrences: [
692
+ {
693
+ calendarEventId: 'calendar-event-1',
694
+ realMeetingKey: `link:meet.example.com/customer-sync:${FUTURE_STARTS_AT}`,
695
+ startsAt: FUTURE_STARTS_AT,
696
+ },
697
+ ],
698
+ now: NOW,
699
+ });
700
+
701
+ expect(result).toEqual([
702
+ expect.objectContaining({
703
+ action: 'CANCELED',
704
+ callRecordingId: buildCustomerSyncCallRecordingId(),
705
+ }),
706
+ expect.objectContaining({
707
+ action: 'CREATED',
708
+ callRecordingId: buildCustomerSyncCallRecordingId(NEW_STARTS_AT),
709
+ }),
710
+ ]);
711
+ expect(cancelRecallBotMock).toHaveBeenCalledExactlyOnceWith({
712
+ externalBotId: 'recall-bot-old',
713
+ });
714
+ expect(scheduleRecallBotMock).toHaveBeenCalledExactlyOnceWith(
715
+ expect.objectContaining({ joinAt: NEW_RECALL_BOT_JOIN_AT }),
716
+ );
717
+ expect(client.callRecordings).toEqual([
718
+ expect.objectContaining({
719
+ id: buildCustomerSyncCallRecordingId(),
720
+ recordingRequestStatus: 'CANCELED',
721
+ externalBotId: null,
722
+ }),
723
+ expect.objectContaining({
724
+ id: buildCustomerSyncCallRecordingId(NEW_STARTS_AT),
725
+ recordingRequestStatus: 'REQUESTED',
726
+ externalBotId: 'recall-bot-1',
727
+ }),
728
+ ]);
729
+ });
730
+
731
+ it('reconciles the remaining meetings when one meeting fails', async () => {
732
+ cancelRecallBotMock.mockRejectedValue(new Error('recall exploded'));
733
+
734
+ const client = buildFakeCoreApiClient({
735
+ calendarEvents: [
736
+ buildCalendarEvent({
737
+ callRecorderPreference: 'OFF',
738
+ }),
739
+ buildCalendarEvent({
740
+ id: 'calendar-event-2',
741
+ iCalUid: 'other-meeting-uid',
742
+ conferenceLink: {
743
+ primaryLinkUrl: 'https://meet.example.com/other-sync',
744
+ },
745
+ }),
746
+ ],
747
+ callRecordings: [
748
+ {
749
+ id: 'call-recording-1',
750
+ title: 'Customer Sync',
751
+ status: 'SCHEDULED',
752
+ recordingRequestStatus: 'REQUESTED',
753
+ startedAt: FUTURE_STARTS_AT,
754
+ endedAt: FUTURE_ENDS_AT,
755
+ calendarEventId: 'calendar-event-1',
756
+ externalBotId: 'recall-bot-1',
757
+ },
758
+ ],
759
+ });
760
+
761
+ const result = await reconcileCallRecorderForCalendarEventIds({
762
+ client: client as unknown as CoreApiClient,
763
+ calendarEventIds: ['calendar-event-1', 'calendar-event-2'],
764
+ now: NOW,
765
+ });
766
+
767
+ expect(result).toEqual([
768
+ expect.objectContaining({
769
+ action: 'FAILED',
770
+ realMeetingKey: `link:meet.example.com/customer-sync:${FUTURE_STARTS_AT}`,
771
+ errorMessage: 'recall exploded',
772
+ }),
773
+ expect.objectContaining({ action: 'CREATED' }),
774
+ ]);
775
+ expect(client.callRecordings).toEqual([
776
+ expect.objectContaining({
777
+ id: 'call-recording-1',
778
+ recordingRequestStatus: 'CANCELED',
779
+ externalBotId: 'recall-bot-1',
780
+ }),
781
+ expect.objectContaining({
782
+ calendarEventId: 'calendar-event-2',
783
+ status: 'SCHEDULED',
784
+ }),
785
+ ]);
786
+ });
787
+
788
+ it('cancels the scheduled request when the conference link is removed', async () => {
789
+ const client = buildFakeCoreApiClient({
790
+ calendarEvents: [
791
+ buildCalendarEvent({
792
+ conferenceLink: null,
793
+ }),
794
+ ],
795
+ callRecordings: [
796
+ {
797
+ id: 'call-recording-1',
798
+ title: 'Customer Sync',
799
+ status: 'SCHEDULED',
800
+ recordingRequestStatus: 'REQUESTED',
801
+ startedAt: FUTURE_STARTS_AT,
802
+ endedAt: FUTURE_ENDS_AT,
803
+ calendarEventId: 'calendar-event-1',
804
+ externalBotId: 'recall-bot-1',
805
+ },
806
+ ],
807
+ });
808
+
809
+ const result = await reconcileCallRecorderForCalendarEventIds({
810
+ client: client as unknown as CoreApiClient,
811
+ calendarEventIds: ['calendar-event-1'],
812
+ now: NOW,
813
+ });
814
+
815
+ expect(result).toEqual([
816
+ expect.objectContaining({
817
+ action: 'CANCELED',
818
+ callRecordingId: 'call-recording-1',
819
+ }),
820
+ ]);
821
+ expect(client.callRecordings).toEqual([
822
+ expect.objectContaining({
823
+ id: 'call-recording-1',
824
+ recordingRequestStatus: 'CANCELED',
825
+ externalBotId: null,
826
+ }),
827
+ ]);
828
+ });
829
+
830
+ it('clears the stale bot id for the stale-state cron to re-create when the existing Recall bot no longer exists', async () => {
831
+ rescheduleRecallBotMock.mockResolvedValue({
832
+ ok: false,
833
+ status: 404,
834
+ errorMessage: 'Recall API responded with HTTP 404',
835
+ });
836
+
837
+ const client = buildFakeCoreApiClient({
838
+ calendarEvents: [buildCalendarEvent()],
839
+ callRecordings: [
840
+ {
841
+ id: buildCustomerSyncCallRecordingId(),
842
+ title: 'Customer Sync',
843
+ status: 'SCHEDULED',
844
+ recordingRequestStatus: 'REQUESTED',
845
+ startedAt: FUTURE_STARTS_AT,
846
+ endedAt: FUTURE_ENDS_AT,
847
+ calendarEventId: 'calendar-event-1',
848
+ externalBotId: 'recall-bot-stale',
849
+ },
850
+ ],
851
+ });
852
+
853
+ const result = await reconcileCallRecorderForCalendarEventIds({
854
+ client: client as unknown as CoreApiClient,
855
+ calendarEventIds: ['calendar-event-1'],
856
+ now: NOW,
857
+ });
858
+
859
+ expect(result).toEqual([
860
+ expect.objectContaining({
861
+ action: 'UPDATED',
862
+ callRecordingId: buildCustomerSyncCallRecordingId(),
863
+ }),
864
+ ]);
865
+ expect(rescheduleRecallBotMock).toHaveBeenCalledWith(
866
+ expect.objectContaining({ externalBotId: 'recall-bot-stale' }),
867
+ );
868
+ // The event path no longer re-creates the bot; the stale id is cleared and the cron heals the botless row.
869
+ expect(scheduleRecallBotMock).not.toHaveBeenCalled();
870
+ expect(client.callRecordings).toEqual([
871
+ expect.objectContaining({
872
+ id: buildCustomerSyncCallRecordingId(),
873
+ externalBotId: null,
874
+ }),
875
+ ]);
876
+ });
877
+
878
+ it('adopts the concurrently created recording when it loses the deterministic-id insert race', async () => {
879
+ class InsertRaceFakeCoreApiClient extends FakeCoreApiClient {
880
+ override async mutation(mutation: any): Promise<any> {
881
+ if (mutation.createCallRecording !== undefined) {
882
+ const concurrentlyInsertedId =
883
+ mutation.createCallRecording.__args.data.id;
884
+
885
+ if (
886
+ !this.callRecordings.some(
887
+ (candidate) => candidate.id === concurrentlyInsertedId,
888
+ )
889
+ ) {
890
+ this.callRecordings.push({
891
+ id: concurrentlyInsertedId,
892
+ status: 'SCHEDULED',
893
+ recordingRequestStatus: 'REQUESTED',
894
+ calendarEventId: 'calendar-event-1',
895
+ externalBotId: 'sibling-bot',
896
+ });
897
+ }
898
+ }
899
+
900
+ return super.mutation(mutation);
901
+ }
902
+ }
903
+
904
+ const client = new InsertRaceFakeCoreApiClient({
905
+ calendarEvents: [buildCalendarEvent()],
906
+ });
907
+
908
+ const result = await reconcileCallRecorderForCalendarEventIds({
909
+ client: client as unknown as CoreApiClient,
910
+ calendarEventIds: ['calendar-event-1'],
911
+ now: NOW,
912
+ });
913
+
914
+ expect(result).toEqual([
915
+ expect.objectContaining({
916
+ action: 'UPDATED',
917
+ callRecordingId: buildCustomerSyncCallRecordingId(),
918
+ }),
919
+ ]);
920
+ expect(client.callRecordings).toHaveLength(1);
921
+ expect(scheduleRecallBotMock).not.toHaveBeenCalled();
922
+ expect(rescheduleRecallBotMock).toHaveBeenCalledWith(
923
+ expect.objectContaining({ externalBotId: 'sibling-bot' }),
924
+ );
925
+ });
926
+
927
+ it('fails the meeting when the create conflicts without a readable recording', async () => {
928
+ class TombstoneFakeCoreApiClient extends FakeCoreApiClient {
929
+ override async mutation(mutation: any): Promise<any> {
930
+ if (mutation.createCallRecording !== undefined) {
931
+ throw new Error('Duplicate id on a soft-deleted record');
932
+ }
933
+
934
+ return super.mutation(mutation);
935
+ }
936
+ }
937
+
938
+ const client = new TombstoneFakeCoreApiClient({
939
+ calendarEvents: [buildCalendarEvent()],
940
+ });
941
+
942
+ const result = await reconcileCallRecorderForCalendarEventIds({
943
+ client: client as unknown as CoreApiClient,
944
+ calendarEventIds: ['calendar-event-1'],
945
+ now: NOW,
946
+ });
947
+
948
+ expect(result).toEqual([
949
+ expect.objectContaining({
950
+ action: 'FAILED',
951
+ errorMessage: 'Duplicate id on a soft-deleted record',
952
+ }),
953
+ ]);
954
+ expect(scheduleRecallBotMock).not.toHaveBeenCalled();
955
+ });
956
+
957
+ it('schedules exactly one bot when concurrent reconciles race for the same meeting', async () => {
958
+ const client = buildFakeCoreApiClient({
959
+ calendarEvents: [buildCalendarEvent()],
960
+ });
961
+
962
+ await Promise.all(
963
+ Array.from({ length: 4 }, () =>
964
+ reconcileCallRecorderForCalendarEventIds({
965
+ client: client as unknown as CoreApiClient,
966
+ calendarEventIds: ['calendar-event-1'],
967
+ now: NOW,
968
+ }),
969
+ ),
970
+ );
971
+
972
+ expect(client.callRecordings).toHaveLength(1);
973
+ expect(scheduleRecallBotMock).toHaveBeenCalledTimes(1);
974
+ expect(client.callRecordings[0].externalBotId).toBe('recall-bot-1');
975
+ });
976
+
977
+ it('does not schedule a bot when the recording is canceled between decide and schedule', async () => {
978
+ class CancelRaceFakeCoreApiClient extends FakeCoreApiClient {
979
+ override async query(query: any): Promise<any> {
980
+ if (query.callRecordings?.__args.filter.id?.in !== undefined) {
981
+ const callRecording = this.callRecordings.find((candidate) =>
982
+ query.callRecordings.__args.filter.id.in.includes(candidate.id),
983
+ );
984
+
985
+ if (callRecording !== undefined) {
986
+ callRecording.recordingRequestStatus = 'CANCELED';
987
+ }
988
+ }
989
+
990
+ return super.query(query);
991
+ }
992
+ }
993
+
994
+ const client = new CancelRaceFakeCoreApiClient({
995
+ calendarEvents: [buildCalendarEvent()],
996
+ });
997
+
998
+ const result = await reconcileCallRecorderForCalendarEventIds({
999
+ client: client as unknown as CoreApiClient,
1000
+ calendarEventIds: ['calendar-event-1'],
1001
+ now: NOW,
1002
+ });
1003
+
1004
+ expect(result).toEqual([expect.objectContaining({ action: 'CREATED' })]);
1005
+ expect(scheduleRecallBotMock).not.toHaveBeenCalled();
1006
+ });
1007
+ });