@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,55 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ import { completeCallRecordingIngestion } from 'src/logic-functions/data/complete-call-recording-ingestion.util';
4
+
5
+ describe('completeCallRecordingIngestion', () => {
6
+ it('guards the flip with non-terminal statuses and returns true when the row is claimed', async () => {
7
+ let capturedArgs: { filter: unknown; data: unknown } | undefined;
8
+ const mutation = vi.fn(async (mutationArg: any) => {
9
+ capturedArgs = mutationArg.updateCallRecordings.__args;
10
+
11
+ return { updateCallRecordings: [{ id: 'call-recording-1' }] };
12
+ });
13
+
14
+ const claimed = await completeCallRecordingIngestion(
15
+ { mutation } as never,
16
+ {
17
+ id: 'call-recording-1',
18
+ },
19
+ );
20
+
21
+ expect(claimed).toBe(true);
22
+ expect(mutation).toHaveBeenCalledTimes(1);
23
+ expect(capturedArgs?.filter).toEqual({
24
+ id: { eq: 'call-recording-1' },
25
+ status: { in: ['SCHEDULED', 'JOINING', 'RECORDING', 'PROCESSING'] },
26
+ });
27
+ expect(capturedArgs?.data).toEqual({ status: 'COMPLETED' });
28
+ });
29
+
30
+ it('returns false when the row was already COMPLETED, so the loser cannot charge', async () => {
31
+ const mutation = vi.fn(async () => ({ updateCallRecordings: [] }));
32
+
33
+ const claimed = await completeCallRecordingIngestion(
34
+ { mutation } as never,
35
+ {
36
+ id: 'call-recording-1',
37
+ },
38
+ );
39
+
40
+ expect(claimed).toBe(false);
41
+ });
42
+
43
+ it('returns false when the API omits the result list', async () => {
44
+ const mutation = vi.fn(async () => ({}));
45
+
46
+ const claimed = await completeCallRecordingIngestion(
47
+ { mutation } as never,
48
+ {
49
+ id: 'call-recording-1',
50
+ },
51
+ );
52
+
53
+ expect(claimed).toBe(false);
54
+ });
55
+ });
@@ -0,0 +1,43 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ import { fetchAllNodes } from 'src/logic-functions/data/fetch-all-nodes.util';
4
+
5
+ describe('fetchAllNodes', () => {
6
+ it('collects nodes across pages until hasNextPage is false', async () => {
7
+ const fetchPage = vi
8
+ .fn()
9
+ .mockResolvedValueOnce({
10
+ pageInfo: { hasNextPage: true, endCursor: 'cursor-1' },
11
+ edges: [{ node: 'node-1' }, { node: 'node-2' }],
12
+ })
13
+ .mockResolvedValueOnce({
14
+ pageInfo: { hasNextPage: false, endCursor: 'cursor-2' },
15
+ edges: [{ node: 'node-3' }],
16
+ });
17
+
18
+ const nodes = await fetchAllNodes<string>(fetchPage);
19
+
20
+ expect(nodes).toEqual(['node-1', 'node-2', 'node-3']);
21
+ expect(fetchPage).toHaveBeenNthCalledWith(1, undefined);
22
+ expect(fetchPage).toHaveBeenNthCalledWith(2, 'cursor-1');
23
+ });
24
+
25
+ it('throws when hasNextPage is true without an endCursor', async () => {
26
+ const fetchPage = vi.fn().mockResolvedValue({
27
+ pageInfo: { hasNextPage: true, endCursor: null },
28
+ edges: [{ node: 'node-1' }],
29
+ });
30
+
31
+ await expect(fetchAllNodes<string>(fetchPage)).rejects.toThrow(
32
+ 'Inconsistent pagination state: hasNextPage is true without an endCursor',
33
+ );
34
+ });
35
+
36
+ it('throws when the query returns no connection', async () => {
37
+ const fetchPage = vi.fn().mockResolvedValue(undefined);
38
+
39
+ await expect(fetchAllNodes<string>(fetchPage)).rejects.toThrow(
40
+ 'Pagination query returned no connection',
41
+ );
42
+ });
43
+ });
@@ -0,0 +1,38 @@
1
+ import { afterEach, describe, expect, it } from 'vitest';
2
+
3
+ import { getCurrentWorkspaceId } from 'src/logic-functions/data/get-current-workspace-id.util';
4
+
5
+ const APP_ACCESS_TOKEN_ENV_VAR_NAME = 'TWENTY_APP_ACCESS_TOKEN';
6
+ const ORIGINAL_APP_ACCESS_TOKEN = process.env[APP_ACCESS_TOKEN_ENV_VAR_NAME];
7
+ const WORKSPACE_ID = '123e4567-e89b-12d3-a456-426614174000';
8
+
9
+ const restoreOriginalAppAccessToken = () => {
10
+ if (ORIGINAL_APP_ACCESS_TOKEN === undefined) {
11
+ delete process.env[APP_ACCESS_TOKEN_ENV_VAR_NAME];
12
+
13
+ return;
14
+ }
15
+
16
+ process.env[APP_ACCESS_TOKEN_ENV_VAR_NAME] = ORIGINAL_APP_ACCESS_TOKEN;
17
+ };
18
+
19
+ const buildAccessToken = (payload: Record<string, unknown>): string =>
20
+ [
21
+ Buffer.from(JSON.stringify({ alg: 'none' })).toString('base64url'),
22
+ Buffer.from(JSON.stringify(payload)).toString('base64url'),
23
+ 'signature',
24
+ ].join('.');
25
+
26
+ describe('getCurrentWorkspaceId', () => {
27
+ afterEach(() => {
28
+ restoreOriginalAppAccessToken();
29
+ });
30
+
31
+ it('reads the workspace id from the app access token payload', () => {
32
+ process.env[APP_ACCESS_TOKEN_ENV_VAR_NAME] = buildAccessToken({
33
+ workspaceId: WORKSPACE_ID,
34
+ });
35
+
36
+ expect(getCurrentWorkspaceId()).toBe(WORKSPACE_ID);
37
+ });
38
+ });
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { RESTRICTED_FIELD_PLACEHOLDER } from 'src/logic-functions/constants/restricted-field-placeholder';
4
+ import { stripRestrictedFieldValue } from 'src/logic-functions/data/strip-restricted-field-value.util';
5
+
6
+ describe('stripRestrictedFieldValue', () => {
7
+ it('drops the calendar visibility restriction placeholder', () => {
8
+ expect(
9
+ stripRestrictedFieldValue(RESTRICTED_FIELD_PLACEHOLDER),
10
+ ).toBeUndefined();
11
+ });
12
+
13
+ it('keeps regular values', () => {
14
+ expect(stripRestrictedFieldValue('Customer Discovery Call')).toBe(
15
+ 'Customer Discovery Call',
16
+ );
17
+ });
18
+
19
+ it('keeps undefined', () => {
20
+ expect(stripRestrictedFieldValue(undefined)).toBeUndefined();
21
+ });
22
+ });
@@ -0,0 +1,24 @@
1
+ import { type CoreApiClient } from 'twenty-client-sdk/core';
2
+
3
+ import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status';
4
+ import { NON_TERMINAL_CALL_RECORDING_STATUSES } from 'src/logic-functions/constants/non-terminal-call-recording-statuses';
5
+
6
+ export const completeCallRecordingIngestion = async (
7
+ client: CoreApiClient,
8
+ { id }: { id: string },
9
+ ): Promise<boolean> => {
10
+ const result = await client.mutation({
11
+ updateCallRecordings: {
12
+ __args: {
13
+ filter: {
14
+ id: { eq: id },
15
+ status: { in: NON_TERMINAL_CALL_RECORDING_STATUSES },
16
+ },
17
+ data: { status: CallRecordingStatus.COMPLETED },
18
+ },
19
+ id: true,
20
+ },
21
+ });
22
+
23
+ return (result.updateCallRecordings ?? []).length > 0;
24
+ };
@@ -0,0 +1,41 @@
1
+ import { isUndefined } from '@sniptt/guards';
2
+ import { type CoreApiClient } from 'twenty-client-sdk/core';
3
+
4
+ import { type CallRecordingRequestStatus } from 'src/logic-functions/constants/call-recording-request-status';
5
+ import { type CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status';
6
+
7
+ export type ScheduledCallRecordingFields = {
8
+ title: string | null;
9
+ status: CallRecordingStatus.SCHEDULED;
10
+ recordingRequestStatus: CallRecordingRequestStatus.REQUESTED;
11
+ calendarEventId: string;
12
+ };
13
+
14
+ export const createCallRecording = async (
15
+ client: CoreApiClient,
16
+ {
17
+ id,
18
+ data,
19
+ }: {
20
+ id: string;
21
+ data: ScheduledCallRecordingFields;
22
+ },
23
+ ): Promise<string> => {
24
+ const mutationResult = await client.mutation({
25
+ createCallRecording: {
26
+ __args: {
27
+ data: { id, ...data },
28
+ },
29
+ id: true,
30
+ },
31
+ });
32
+ const createdCallRecordingId = mutationResult.createCallRecording?.id;
33
+
34
+ if (isUndefined(createdCallRecordingId)) {
35
+ throw new Error(
36
+ 'createCallRecording mutation did not return a call recording id',
37
+ );
38
+ }
39
+
40
+ return createdCallRecordingId;
41
+ };
@@ -0,0 +1,44 @@
1
+ import { isString, isUndefined } from '@sniptt/guards';
2
+
3
+ export type ConnectionPage<TNode> = {
4
+ pageInfo?: {
5
+ hasNextPage?: boolean | null;
6
+ endCursor?: string | null;
7
+ } | null;
8
+ edges?: Array<{ node: TNode }> | null;
9
+ };
10
+
11
+ export const fetchAllNodes = async <TNode>(
12
+ fetchPage: (
13
+ afterCursor: string | undefined,
14
+ ) => Promise<ConnectionPage<TNode> | undefined>,
15
+ ): Promise<TNode[]> => {
16
+ const nodes: TNode[] = [];
17
+ let hasNextPage = true;
18
+ let afterCursor: string | undefined;
19
+
20
+ while (hasNextPage) {
21
+ const connection = await fetchPage(afterCursor);
22
+
23
+ if (isUndefined(connection)) {
24
+ throw new Error('Pagination query returned no connection');
25
+ }
26
+
27
+ for (const edge of connection.edges ?? []) {
28
+ nodes.push(edge.node);
29
+ }
30
+
31
+ hasNextPage = connection.pageInfo?.hasNextPage === true;
32
+ const endCursor = connection.pageInfo?.endCursor;
33
+
34
+ if (hasNextPage && !isString(endCursor)) {
35
+ throw new Error(
36
+ 'Inconsistent pagination state: hasNextPage is true without an endCursor',
37
+ );
38
+ }
39
+
40
+ afterCursor = isString(endCursor) ? endCursor : undefined;
41
+ }
42
+
43
+ return nodes;
44
+ };
@@ -0,0 +1,80 @@
1
+ import { isString, isUndefined } from '@sniptt/guards';
2
+ import { type CoreApiClient } from 'twenty-client-sdk/core';
3
+
4
+ import { TWENTY_PAGE_SIZE } from 'src/logic-functions/constants/twenty-page-size';
5
+ import { type CalendarEventRecord } from 'src/logic-functions/types/calendar-event-record.type';
6
+ import {
7
+ fetchAllNodes,
8
+ type ConnectionPage,
9
+ } from 'src/logic-functions/data/fetch-all-nodes.util';
10
+ import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util';
11
+ import { stripRestrictedFieldValue } from 'src/logic-functions/data/strip-restricted-field-value.util';
12
+
13
+ type CalendarEventNode = {
14
+ id: string;
15
+ title?: string | null;
16
+ isCanceled?: boolean | null;
17
+ startsAt?: string | null;
18
+ endsAt?: string | null;
19
+ iCalUid?: string | null;
20
+ conferenceLink?: { primaryLinkUrl?: string | null } | null;
21
+ callRecorderPreference?: string | null;
22
+ };
23
+
24
+ export const fetchCalendarEventsByFilter = async (
25
+ client: CoreApiClient,
26
+ filter: Record<string, unknown>,
27
+ ): Promise<CalendarEventRecord[]> => {
28
+ const calendarEventNodes = await fetchAllNodes<CalendarEventNode>(
29
+ async (afterCursor) => {
30
+ const queryResult = await client.query({
31
+ calendarEvents: {
32
+ __args: {
33
+ filter,
34
+ first: TWENTY_PAGE_SIZE,
35
+ ...(isUndefined(afterCursor) ? {} : { after: afterCursor }),
36
+ },
37
+ pageInfo: {
38
+ hasNextPage: true,
39
+ endCursor: true,
40
+ },
41
+ edges: {
42
+ node: {
43
+ id: true,
44
+ title: true,
45
+ isCanceled: true,
46
+ startsAt: true,
47
+ endsAt: true,
48
+ iCalUid: true,
49
+ conferenceLink: {
50
+ primaryLinkUrl: true,
51
+ },
52
+ callRecorderPreference: true,
53
+ },
54
+ },
55
+ },
56
+ });
57
+
58
+ return queryResult.calendarEvents as
59
+ | ConnectionPage<CalendarEventNode>
60
+ | undefined;
61
+ },
62
+ );
63
+
64
+ return calendarEventNodes.map((calendarEvent) => ({
65
+ id: calendarEvent.id,
66
+ title: stripRestrictedFieldValue(calendarEvent.title ?? undefined),
67
+ isCanceled: calendarEvent.isCanceled ?? false,
68
+ startsAt: calendarEvent.startsAt ?? undefined,
69
+ endsAt: calendarEvent.endsAt ?? undefined,
70
+ iCalUid: calendarEvent.iCalUid ?? undefined,
71
+ conferenceLinkUrl: isNonEmptyString(
72
+ calendarEvent.conferenceLink?.primaryLinkUrl,
73
+ )
74
+ ? calendarEvent.conferenceLink.primaryLinkUrl
75
+ : undefined,
76
+ callRecorderPreference: isString(calendarEvent.callRecorderPreference)
77
+ ? calendarEvent.callRecorderPreference
78
+ : undefined,
79
+ }));
80
+ };
@@ -0,0 +1,20 @@
1
+ import { type CoreApiClient } from 'twenty-client-sdk/core';
2
+
3
+ import { type CalendarEventRecord } from 'src/logic-functions/types/calendar-event-record.type';
4
+ import { fetchCalendarEventsByFilter } from 'src/logic-functions/data/fetch-calendar-events-by-filter.util';
5
+ import { getUniqueSortedIds } from 'src/logic-functions/utils/get-unique-sorted-ids.util';
6
+
7
+ export const fetchCalendarEventsByIds = async (
8
+ client: CoreApiClient,
9
+ calendarEventIds: string[],
10
+ ): Promise<CalendarEventRecord[]> => {
11
+ const uniqueCalendarEventIds = getUniqueSortedIds(calendarEventIds);
12
+
13
+ if (uniqueCalendarEventIds.length === 0) {
14
+ return [];
15
+ }
16
+
17
+ return fetchCalendarEventsByFilter(client, {
18
+ id: { in: uniqueCalendarEventIds },
19
+ });
20
+ };
@@ -0,0 +1,19 @@
1
+ import { type CoreApiClient } from 'twenty-client-sdk/core';
2
+
3
+ import { type CalendarEventRecord } from 'src/logic-functions/types/calendar-event-record.type';
4
+ import { fetchCalendarEventsByFilter } from 'src/logic-functions/data/fetch-calendar-events-by-filter.util';
5
+
6
+ export const fetchCalendarEventsByStartsAtValues = async (
7
+ client: CoreApiClient,
8
+ startsAtValues: string[],
9
+ ): Promise<CalendarEventRecord[]> => {
10
+ const uniqueStartsAtValues = [...new Set(startsAtValues)].sort();
11
+
12
+ if (uniqueStartsAtValues.length === 0) {
13
+ return [];
14
+ }
15
+
16
+ return fetchCalendarEventsByFilter(client, {
17
+ startsAt: { in: uniqueStartsAtValues },
18
+ });
19
+ };
@@ -0,0 +1,17 @@
1
+ import { type CoreApiClient } from 'twenty-client-sdk/core';
2
+
3
+ import { type CallRecordingRecord } from 'src/logic-functions/types/call-recording-record.type';
4
+ import { findCallRecordingsByFilter } from 'src/logic-functions/data/find-call-recordings-by-filter.util';
5
+
6
+ export const findCallRecordingsByCalendarEventIds = async (
7
+ client: CoreApiClient,
8
+ calendarEventIds: string[],
9
+ ): Promise<CallRecordingRecord[]> => {
10
+ if (calendarEventIds.length === 0) {
11
+ return [];
12
+ }
13
+
14
+ return findCallRecordingsByFilter(client, {
15
+ calendarEventId: { in: calendarEventIds },
16
+ });
17
+ };
@@ -0,0 +1,102 @@
1
+ import { isUndefined } from '@sniptt/guards';
2
+ import { type CoreApiClient } from 'twenty-client-sdk/core';
3
+
4
+ import { CallRecordingRequestStatus } from 'src/logic-functions/constants/call-recording-request-status';
5
+ import { TWENTY_PAGE_SIZE } from 'src/logic-functions/constants/twenty-page-size';
6
+ import { type CallRecordingRecord } from 'src/logic-functions/types/call-recording-record.type';
7
+ import {
8
+ fetchAllNodes,
9
+ type ConnectionPage,
10
+ } from 'src/logic-functions/data/fetch-all-nodes.util';
11
+ import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util';
12
+
13
+ type CallRecordingNode = {
14
+ id: string;
15
+ title?: string | null;
16
+ status?: string | null;
17
+ recordingRequestStatus?: unknown;
18
+ startedAt?: string | null;
19
+ endedAt?: string | null;
20
+ calendarEventId?: string | null;
21
+ externalBotId?: string | null;
22
+ externalRecordingId?: string | null;
23
+ callRecorderFailureReason?: string | null;
24
+ };
25
+
26
+ export const findCallRecordingsByFilter = async (
27
+ client: CoreApiClient,
28
+ filter: Record<string, unknown>,
29
+ ): Promise<CallRecordingRecord[]> => {
30
+ const callRecordingNodes = await fetchAllNodes<CallRecordingNode>(
31
+ async (afterCursor) => {
32
+ const queryResult = await client.query({
33
+ callRecordings: {
34
+ __args: {
35
+ filter,
36
+ first: TWENTY_PAGE_SIZE,
37
+ ...(isUndefined(afterCursor) ? {} : { after: afterCursor }),
38
+ },
39
+ pageInfo: {
40
+ hasNextPage: true,
41
+ endCursor: true,
42
+ },
43
+ edges: {
44
+ node: {
45
+ id: true,
46
+ title: true,
47
+ status: true,
48
+ recordingRequestStatus: true,
49
+ startedAt: true,
50
+ endedAt: true,
51
+ calendarEventId: true,
52
+ externalBotId: true,
53
+ externalRecordingId: true,
54
+ callRecorderFailureReason: true,
55
+ },
56
+ },
57
+ },
58
+ });
59
+
60
+ return queryResult.callRecordings as
61
+ | ConnectionPage<CallRecordingNode>
62
+ | undefined;
63
+ },
64
+ );
65
+
66
+ return callRecordingNodes.map((callRecording) => ({
67
+ id: callRecording.id,
68
+ title: callRecording.title ?? undefined,
69
+ status: callRecording.status ?? undefined,
70
+ recordingRequestStatus: normalizeCallRecordingRequestStatus(
71
+ callRecording.recordingRequestStatus,
72
+ ),
73
+ startedAt: callRecording.startedAt ?? undefined,
74
+ endedAt: callRecording.endedAt ?? undefined,
75
+ calendarEventId: callRecording.calendarEventId ?? undefined,
76
+ externalBotId: normalizeOptionalString(callRecording.externalBotId),
77
+ externalRecordingId: normalizeOptionalString(
78
+ callRecording.externalRecordingId,
79
+ ),
80
+ callRecorderFailureReason: normalizeOptionalString(
81
+ callRecording.callRecorderFailureReason,
82
+ ),
83
+ }));
84
+ };
85
+
86
+ const normalizeOptionalString = (
87
+ value: string | null | undefined,
88
+ ): string | undefined => (isNonEmptyString(value) ? value : undefined);
89
+
90
+ const normalizeCallRecordingRequestStatus = (
91
+ recordingRequestStatus: unknown,
92
+ ): CallRecordingRequestStatus | undefined => {
93
+ if (recordingRequestStatus === CallRecordingRequestStatus.REQUESTED) {
94
+ return recordingRequestStatus;
95
+ }
96
+
97
+ if (recordingRequestStatus === CallRecordingRequestStatus.CANCELED) {
98
+ return recordingRequestStatus;
99
+ }
100
+
101
+ return undefined;
102
+ };
@@ -0,0 +1,17 @@
1
+ import { type CoreApiClient } from 'twenty-client-sdk/core';
2
+
3
+ import { type CallRecordingRecord } from 'src/logic-functions/types/call-recording-record.type';
4
+ import { findCallRecordingsByFilter } from 'src/logic-functions/data/find-call-recordings-by-filter.util';
5
+
6
+ export const findCallRecordingsByIds = async (
7
+ client: CoreApiClient,
8
+ callRecordingIds: string[],
9
+ ): Promise<CallRecordingRecord[]> => {
10
+ if (callRecordingIds.length === 0) {
11
+ return [];
12
+ }
13
+
14
+ return findCallRecordingsByFilter(client, {
15
+ id: { in: callRecordingIds },
16
+ });
17
+ };
@@ -0,0 +1,14 @@
1
+ import { type CoreApiClient } from 'twenty-client-sdk/core';
2
+
3
+ import { CallRecordingRequestStatus } from 'src/logic-functions/constants/call-recording-request-status';
4
+ import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status';
5
+ import { type CallRecordingRecord } from 'src/logic-functions/types/call-recording-record.type';
6
+ import { findCallRecordingsByFilter } from 'src/logic-functions/data/find-call-recordings-by-filter.util';
7
+
8
+ export const findOpenScheduledCallRecordings = async (
9
+ client: CoreApiClient,
10
+ ): Promise<CallRecordingRecord[]> =>
11
+ findCallRecordingsByFilter(client, {
12
+ recordingRequestStatus: { eq: CallRecordingRequestStatus.REQUESTED },
13
+ status: { eq: CallRecordingStatus.SCHEDULED },
14
+ });
@@ -0,0 +1,36 @@
1
+ import { isUndefined } from '@sniptt/guards';
2
+
3
+ import { asRecord } from 'src/logic-functions/utils/as-record.util';
4
+ import { getString } from 'src/logic-functions/utils/get-string.util';
5
+
6
+ const APP_ACCESS_TOKEN_ENV_VAR_NAME = 'TWENTY_APP_ACCESS_TOKEN';
7
+
8
+ export const getCurrentWorkspaceId = (): string | undefined => {
9
+ const accessToken = getString(process.env[APP_ACCESS_TOKEN_ENV_VAR_NAME]);
10
+
11
+ if (isUndefined(accessToken)) {
12
+ return undefined;
13
+ }
14
+
15
+ return getWorkspaceIdFromAccessToken(accessToken);
16
+ };
17
+
18
+ const getWorkspaceIdFromAccessToken = (
19
+ accessToken: string,
20
+ ): string | undefined => {
21
+ const encodedPayload = accessToken.split('.')[1];
22
+
23
+ if (isUndefined(encodedPayload)) {
24
+ return undefined;
25
+ }
26
+
27
+ try {
28
+ const payload = asRecord(
29
+ JSON.parse(Buffer.from(encodedPayload, 'base64url').toString('utf8')),
30
+ );
31
+
32
+ return getString(payload?.workspaceId);
33
+ } catch {
34
+ return undefined;
35
+ }
36
+ };
@@ -0,0 +1,6 @@
1
+ import { RESTRICTED_FIELD_PLACEHOLDER } from 'src/logic-functions/constants/restricted-field-placeholder';
2
+
3
+ export const stripRestrictedFieldValue = (
4
+ value: string | undefined,
5
+ ): string | undefined =>
6
+ value === RESTRICTED_FIELD_PLACEHOLDER ? undefined : value;
@@ -0,0 +1,24 @@
1
+ import { type CoreApiClient } from 'twenty-client-sdk/core';
2
+
3
+ import { type CallRecordingUpdateFields } from 'src/logic-functions/types/call-recording-update-fields.type';
4
+
5
+ export const updateCallRecording = async (
6
+ client: CoreApiClient,
7
+ {
8
+ id,
9
+ data,
10
+ }: {
11
+ id: string;
12
+ data: CallRecordingUpdateFields;
13
+ },
14
+ ): Promise<void> => {
15
+ await client.mutation({
16
+ updateCallRecording: {
17
+ __args: {
18
+ id,
19
+ data,
20
+ },
21
+ id: true,
22
+ },
23
+ });
24
+ };
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { buildCallRecorderPolicyResult } from 'src/logic-functions/domain/build-call-recorder-policy-result.util';
4
+ import { type CallRecorderPolicyCalendarEventInput } from 'src/logic-functions/types/call-recorder-policy-calendar-event-input.type';
5
+
6
+ const NOW = new Date('2026-01-01T12:00:00.000Z');
7
+
8
+ const buildCalendarEventInput = (
9
+ overrides: Partial<CallRecorderPolicyCalendarEventInput>,
10
+ ): CallRecorderPolicyCalendarEventInput => ({
11
+ id: 'calendar-event-1',
12
+ isCanceled: false,
13
+ startsAt: '2026-01-01T13:00:00.000Z',
14
+ endsAt: '2026-01-01T14:00:00.000Z',
15
+ iCalUid: 'ical-uid-1',
16
+ conferenceLinkUrl: 'https://meet.example.com/customer-sync',
17
+ callRecorderPreference: undefined,
18
+ ...overrides,
19
+ });
20
+
21
+ describe('buildCallRecorderPolicyResult', () => {
22
+ it('requests a bot for the ON wire value', () => {
23
+ const policyResult = buildCallRecorderPolicyResult(
24
+ buildCalendarEventInput({
25
+ callRecorderPreference: 'ON',
26
+ }),
27
+ NOW,
28
+ );
29
+
30
+ expect(policyResult.callRecorderPreference).toBe('ON');
31
+ expect(policyResult.shouldRequestBot).toBe(true);
32
+ expect(policyResult.reason).toBe('RECORDING_ENABLED');
33
+ });
34
+
35
+ it('does not request a bot for the OFF wire value', () => {
36
+ const policyResult = buildCallRecorderPolicyResult(
37
+ buildCalendarEventInput({
38
+ callRecorderPreference: 'OFF',
39
+ }),
40
+ NOW,
41
+ );
42
+
43
+ expect(policyResult.callRecorderPreference).toBe('OFF');
44
+ expect(policyResult.shouldRequestBot).toBe(false);
45
+ expect(policyResult.reason).toBe('PREFERENCE_OFF');
46
+ });
47
+ });