@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,155 @@
1
+ import { isUndefined } from '@sniptt/guards';
2
+ import { useEffect, useState } from 'react';
3
+ import { CoreApiClient } from 'twenty-client-sdk/core';
4
+
5
+ type CalendarEventRecordingState = {
6
+ transcript: unknown;
7
+ videoFile: CalendarEventRecordingVideoFile | undefined;
8
+ isCalendarEventRecordingQueryLoading: boolean;
9
+ errorMessage: string | undefined;
10
+ };
11
+
12
+ type CalendarEventRecordingVideoFile = {
13
+ fileId: string;
14
+ label: string | null;
15
+ url: string | null;
16
+ extension: string | null;
17
+ };
18
+
19
+ type CalendarEventRecordingCallRecordingNode = {
20
+ id: string;
21
+ transcript: unknown;
22
+ video: CalendarEventRecordingVideoFile[] | null;
23
+ };
24
+
25
+ type CalendarEventRecordingCallRecordingEdge = {
26
+ node: CalendarEventRecordingCallRecordingNode;
27
+ };
28
+
29
+ const CALENDAR_EVENT_RECORDING_LOOKUP_LIMIT = 10;
30
+ const CALENDAR_EVENT_RECORDING_ERROR_MESSAGE = 'Please try again later.';
31
+
32
+ export const useCalendarEventRecording = (
33
+ calendarEventId: string | undefined,
34
+ ): CalendarEventRecordingState => {
35
+ const [state, setState] = useState<CalendarEventRecordingState>({
36
+ transcript: undefined,
37
+ videoFile: undefined,
38
+ isCalendarEventRecordingQueryLoading: !isUndefined(calendarEventId),
39
+ errorMessage: undefined,
40
+ });
41
+
42
+ useEffect(() => {
43
+ if (isUndefined(calendarEventId)) {
44
+ setState({
45
+ transcript: undefined,
46
+ videoFile: undefined,
47
+ isCalendarEventRecordingQueryLoading: false,
48
+ errorMessage: undefined,
49
+ });
50
+ return;
51
+ }
52
+
53
+ let cancelled = false;
54
+
55
+ const fetchRecording = async () => {
56
+ setState({
57
+ transcript: undefined,
58
+ videoFile: undefined,
59
+ isCalendarEventRecordingQueryLoading: true,
60
+ errorMessage: undefined,
61
+ });
62
+
63
+ try {
64
+ const client = new CoreApiClient();
65
+ const queryResult = await client.query({
66
+ callRecordings: {
67
+ __args: {
68
+ filter: { calendarEventId: { eq: calendarEventId } },
69
+ orderBy: [{ startedAt: 'DescNullsLast' }],
70
+ first: CALENDAR_EVENT_RECORDING_LOOKUP_LIMIT,
71
+ },
72
+ edges: {
73
+ node: {
74
+ id: true,
75
+ transcript: true,
76
+ video: {
77
+ fileId: true,
78
+ label: true,
79
+ url: true,
80
+ extension: true,
81
+ },
82
+ },
83
+ },
84
+ },
85
+ });
86
+
87
+ if (cancelled) {
88
+ return;
89
+ }
90
+
91
+ const callRecordingEdges = (queryResult.callRecordings?.edges ??
92
+ []) as CalendarEventRecordingCallRecordingEdge[];
93
+ const callRecordingNodes = callRecordingEdges.map(
94
+ (callRecordingEdge) => callRecordingEdge.node,
95
+ );
96
+ const callRecordingNode =
97
+ selectCalendarEventRecording(callRecordingNodes);
98
+
99
+ setState({
100
+ transcript: callRecordingNode?.transcript ?? undefined,
101
+ videoFile: isUndefined(callRecordingNode)
102
+ ? undefined
103
+ : getVideoFile(callRecordingNode),
104
+ isCalendarEventRecordingQueryLoading: false,
105
+ errorMessage: undefined,
106
+ });
107
+ } catch {
108
+ if (cancelled) {
109
+ return;
110
+ }
111
+
112
+ setState({
113
+ transcript: undefined,
114
+ videoFile: undefined,
115
+ isCalendarEventRecordingQueryLoading: false,
116
+ errorMessage: CALENDAR_EVENT_RECORDING_ERROR_MESSAGE,
117
+ });
118
+ }
119
+ };
120
+
121
+ fetchRecording();
122
+
123
+ return () => {
124
+ cancelled = true;
125
+ };
126
+ }, [calendarEventId]);
127
+
128
+ return state;
129
+ };
130
+
131
+ const hasTranscript = (
132
+ callRecordingNode: CalendarEventRecordingCallRecordingNode,
133
+ ): boolean =>
134
+ !isUndefined(callRecordingNode.transcript) &&
135
+ callRecordingNode.transcript !== null;
136
+
137
+ const getVideoFile = (
138
+ callRecordingNode: CalendarEventRecordingCallRecordingNode,
139
+ ): CalendarEventRecordingVideoFile | undefined =>
140
+ callRecordingNode.video?.find(
141
+ (videoFile) => !isUndefined(videoFile.url) && videoFile.url !== null,
142
+ );
143
+
144
+ const selectCalendarEventRecording = (
145
+ callRecordingNodes: CalendarEventRecordingCallRecordingNode[],
146
+ ): CalendarEventRecordingCallRecordingNode | undefined =>
147
+ callRecordingNodes.find(
148
+ (callRecordingNode) =>
149
+ hasTranscript(callRecordingNode) &&
150
+ !isUndefined(getVideoFile(callRecordingNode)),
151
+ ) ??
152
+ callRecordingNodes.find(hasTranscript) ??
153
+ callRecordingNodes.find(
154
+ (callRecordingNode) => !isUndefined(getVideoFile(callRecordingNode)),
155
+ );
@@ -0,0 +1,6 @@
1
+ import { type CalendarEventRecordingParticipant } from 'src/front-components/types/calendar-event-recording-participant.type';
2
+
3
+ export type CalendarEventParticipantBySpeakerName = Map<
4
+ string,
5
+ CalendarEventRecordingParticipant
6
+ >;
@@ -0,0 +1,7 @@
1
+ export type CalendarEventRecordingParticipant = {
2
+ id: string;
3
+ avatarUrl: string | undefined;
4
+ displayName: string | undefined;
5
+ nameCandidates: string[];
6
+ placeholderColorSeed: string;
7
+ };
@@ -0,0 +1,13 @@
1
+ export type TranscriptWord = {
2
+ text: string;
3
+ startSeconds: number | undefined;
4
+ endSeconds: number | undefined;
5
+ };
6
+
7
+ export type TranscriptEntry = {
8
+ speakerName: string;
9
+ startSeconds: number | undefined;
10
+ endSeconds: number | undefined;
11
+ text: string;
12
+ words: TranscriptWord[];
13
+ };
@@ -0,0 +1,66 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { type TranscriptEntry } from 'src/front-components/types/transcript-entry.type';
4
+ import { findActiveTranscriptEntryIndex } from 'src/front-components/utils/find-active-transcript-entry-index.util';
5
+
6
+ const makeTranscriptEntry = ({
7
+ startSeconds,
8
+ endSeconds,
9
+ }: {
10
+ startSeconds: number | undefined;
11
+ endSeconds: number | undefined;
12
+ }): TranscriptEntry => ({
13
+ speakerName: 'Ada Lovelace',
14
+ startSeconds,
15
+ endSeconds,
16
+ text: 'Hello',
17
+ words: [{ text: 'Hello', startSeconds, endSeconds }],
18
+ });
19
+
20
+ describe('findActiveTranscriptEntryIndex', () => {
21
+ it('does not keep an open-ended entry active after the next entry starts', () => {
22
+ expect(
23
+ findActiveTranscriptEntryIndex(
24
+ [
25
+ makeTranscriptEntry({ startSeconds: 1, endSeconds: undefined }),
26
+ makeTranscriptEntry({ startSeconds: 10, endSeconds: 20 }),
27
+ ],
28
+ 25,
29
+ ),
30
+ ).toBe(-1);
31
+ });
32
+
33
+ it('uses the next known start as the boundary for entries without an end time', () => {
34
+ expect(
35
+ findActiveTranscriptEntryIndex(
36
+ [
37
+ makeTranscriptEntry({ startSeconds: 1, endSeconds: undefined }),
38
+ makeTranscriptEntry({ startSeconds: 10, endSeconds: 20 }),
39
+ ],
40
+ 9,
41
+ ),
42
+ ).toBe(0);
43
+
44
+ expect(
45
+ findActiveTranscriptEntryIndex(
46
+ [
47
+ makeTranscriptEntry({ startSeconds: 1, endSeconds: undefined }),
48
+ makeTranscriptEntry({ startSeconds: 10, endSeconds: 20 }),
49
+ ],
50
+ 10,
51
+ ),
52
+ ).toBe(1);
53
+ });
54
+
55
+ it('keeps the final open-ended entry active after it starts', () => {
56
+ expect(
57
+ findActiveTranscriptEntryIndex(
58
+ [
59
+ makeTranscriptEntry({ startSeconds: 1, endSeconds: 2 }),
60
+ makeTranscriptEntry({ startSeconds: 10, endSeconds: undefined }),
61
+ ],
62
+ 25,
63
+ ),
64
+ ).toBe(1);
65
+ });
66
+ });
@@ -0,0 +1,29 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { formatTranscriptTimestamp } from 'src/front-components/utils/format-transcript-timestamp.util';
4
+
5
+ describe('formatTranscriptTimestamp', () => {
6
+ it('formats sub-hour durations as minutes and padded seconds', () => {
7
+ expect(formatTranscriptTimestamp(0)).toBe('0:00');
8
+ expect(formatTranscriptTimestamp(5)).toBe('0:05');
9
+ expect(formatTranscriptTimestamp(65)).toBe('1:05');
10
+ expect(formatTranscriptTimestamp(3599)).toBe('59:59');
11
+ });
12
+
13
+ it('adds an hour part with padded minutes past one hour', () => {
14
+ expect(formatTranscriptTimestamp(3600)).toBe('1:00:00');
15
+ expect(formatTranscriptTimestamp(3725)).toBe('1:02:05');
16
+ expect(formatTranscriptTimestamp(7322)).toBe('2:02:02');
17
+ });
18
+
19
+ it('floors fractional seconds', () => {
20
+ expect(formatTranscriptTimestamp(1.9)).toBe('0:01');
21
+ expect(formatTranscriptTimestamp(59.999)).toBe('0:59');
22
+ });
23
+
24
+ it('clamps negative and non-finite input to zero', () => {
25
+ expect(formatTranscriptTimestamp(-12)).toBe('0:00');
26
+ expect(formatTranscriptTimestamp(Number.NaN)).toBe('0:00');
27
+ expect(formatTranscriptTimestamp(Number.POSITIVE_INFINITY)).toBe('0:00');
28
+ });
29
+ });
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { getSpeakerNameMatchKeys } from 'src/front-components/utils/get-speaker-name-match-keys.util';
4
+
5
+ describe('getSpeakerNameMatchKeys', () => {
6
+ it('matches transcript full names to compact calendar aliases', () => {
7
+ expect(getSpeakerNameMatchKeys('Martin Muller')).toContain('martmull');
8
+ expect(getSpeakerNameMatchKeys('Martmull92')).toContain('martmull');
9
+ });
10
+
11
+ it('keeps exact normalized full names available for regular participant names', () => {
12
+ expect(getSpeakerNameMatchKeys('Nitin Koche')).toEqual([
13
+ 'nitin koche',
14
+ 'nitinkoche',
15
+ 'nitikoch',
16
+ ]);
17
+ });
18
+
19
+ it('folds accents before generating compact match keys', () => {
20
+ expect(getSpeakerNameMatchKeys('Martin Müller')).toContain('martmull');
21
+ });
22
+ });
@@ -0,0 +1,162 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { parseTranscriptEntries } from 'src/front-components/utils/parse-transcript-entries.util';
4
+
5
+ describe('parseTranscriptEntries', () => {
6
+ it('parses diarized entries into speaker, start time, and joined text', () => {
7
+ expect(
8
+ parseTranscriptEntries([
9
+ {
10
+ participant: { id: 100, name: 'Ada Lovelace' },
11
+ words: [
12
+ {
13
+ text: 'Hello',
14
+ start_timestamp: {
15
+ relative: 1.2,
16
+ absolute: '2026-06-12T10:00:01Z',
17
+ },
18
+ end_timestamp: {
19
+ relative: 1.6,
20
+ absolute: '2026-06-12T10:00:01Z',
21
+ },
22
+ },
23
+ {
24
+ text: 'there',
25
+ start_timestamp: {
26
+ relative: 1.7,
27
+ absolute: '2026-06-12T10:00:02Z',
28
+ },
29
+ end_timestamp: {
30
+ relative: 2.1,
31
+ absolute: '2026-06-12T10:00:02Z',
32
+ },
33
+ },
34
+ ],
35
+ },
36
+ {
37
+ participant: { id: 101, name: 'Grace Hopper' },
38
+ words: [
39
+ {
40
+ text: 'Hi',
41
+ start_timestamp: {
42
+ relative: 3.4,
43
+ absolute: '2026-06-12T10:00:03Z',
44
+ },
45
+ },
46
+ ],
47
+ },
48
+ ]),
49
+ ).toEqual([
50
+ {
51
+ speakerName: 'Ada Lovelace',
52
+ startSeconds: 1.2,
53
+ endSeconds: 2.1,
54
+ text: 'Hello there',
55
+ words: [
56
+ { text: 'Hello', startSeconds: 1.2, endSeconds: 1.6 },
57
+ { text: 'there', startSeconds: 1.7, endSeconds: 2.1 },
58
+ ],
59
+ },
60
+ {
61
+ speakerName: 'Grace Hopper',
62
+ startSeconds: 3.4,
63
+ endSeconds: undefined,
64
+ text: 'Hi',
65
+ words: [{ text: 'Hi', startSeconds: 3.4, endSeconds: undefined }],
66
+ },
67
+ ]);
68
+ });
69
+
70
+ it('falls back to an unknown speaker when the participant has no name', () => {
71
+ expect(
72
+ parseTranscriptEntries([
73
+ { participant: { id: 100, name: null }, words: [{ text: 'Hello' }] },
74
+ { words: [{ text: 'Hi' }] },
75
+ ]),
76
+ ).toEqual([
77
+ {
78
+ speakerName: 'Unknown speaker',
79
+ startSeconds: undefined,
80
+ endSeconds: undefined,
81
+ text: 'Hello',
82
+ words: [
83
+ { text: 'Hello', startSeconds: undefined, endSeconds: undefined },
84
+ ],
85
+ },
86
+ {
87
+ speakerName: 'Unknown speaker',
88
+ startSeconds: undefined,
89
+ endSeconds: undefined,
90
+ text: 'Hi',
91
+ words: [{ text: 'Hi', startSeconds: undefined, endSeconds: undefined }],
92
+ },
93
+ ]);
94
+ });
95
+
96
+ it('returns an undefined start time when the first word has no relative timestamp', () => {
97
+ expect(
98
+ parseTranscriptEntries([
99
+ {
100
+ participant: { name: 'Ada Lovelace' },
101
+ words: [
102
+ {
103
+ text: 'Hello',
104
+ start_timestamp: { absolute: '2026-06-12T10:00:01Z' },
105
+ },
106
+ ],
107
+ },
108
+ ]),
109
+ ).toEqual([
110
+ {
111
+ speakerName: 'Ada Lovelace',
112
+ startSeconds: undefined,
113
+ endSeconds: undefined,
114
+ text: 'Hello',
115
+ words: [
116
+ { text: 'Hello', startSeconds: undefined, endSeconds: undefined },
117
+ ],
118
+ },
119
+ ]);
120
+ });
121
+
122
+ it('skips entries without usable words instead of failing the whole transcript', () => {
123
+ expect(
124
+ parseTranscriptEntries([
125
+ { participant: { name: 'Ada Lovelace' }, words: [] },
126
+ { participant: { name: 'Grace Hopper' } },
127
+ {
128
+ participant: { name: 'Alan Turing' },
129
+ words: [{ text: ' ' }, 42, null],
130
+ },
131
+ { participant: { name: 'Joan Clarke' }, words: [{ text: 'Kept' }] },
132
+ 'not an entry',
133
+ ]),
134
+ ).toEqual([
135
+ {
136
+ speakerName: 'Joan Clarke',
137
+ startSeconds: undefined,
138
+ endSeconds: undefined,
139
+ text: 'Kept',
140
+ words: [
141
+ { text: 'Kept', startSeconds: undefined, endSeconds: undefined },
142
+ ],
143
+ },
144
+ ]);
145
+ });
146
+
147
+ it('returns an empty list for an empty transcript array', () => {
148
+ expect(parseTranscriptEntries([])).toEqual([]);
149
+ });
150
+
151
+ it('returns undefined for values that are not a diarized transcript array', () => {
152
+ expect(parseTranscriptEntries(null)).toBeUndefined();
153
+ expect(parseTranscriptEntries(undefined)).toBeUndefined();
154
+ expect(parseTranscriptEntries('transcript text')).toBeUndefined();
155
+ expect(
156
+ parseTranscriptEntries({
157
+ recallTranscriptId: 'recall-transcript-1',
158
+ status: 'PENDING',
159
+ }),
160
+ ).toBeUndefined();
161
+ });
162
+ });
@@ -0,0 +1,45 @@
1
+ import { isUndefined } from '@sniptt/guards';
2
+
3
+ import { type CalendarEventParticipantBySpeakerName } from 'src/front-components/types/calendar-event-participant-by-speaker-name.type';
4
+ import { type CalendarEventRecordingParticipant } from 'src/front-components/types/calendar-event-recording-participant.type';
5
+ import { getSpeakerNameMatchKeys } from 'src/front-components/utils/get-speaker-name-match-keys.util';
6
+
7
+ export const buildCalendarEventParticipantBySpeakerName = (
8
+ calendarEventParticipants: CalendarEventRecordingParticipant[],
9
+ ): CalendarEventParticipantBySpeakerName => {
10
+ const calendarEventParticipantBySpeakerName: CalendarEventParticipantBySpeakerName =
11
+ new Map();
12
+ const ambiguousSpeakerNameMatchKeys = new Set<string>();
13
+
14
+ for (const calendarEventParticipant of calendarEventParticipants) {
15
+ for (const nameCandidate of calendarEventParticipant.nameCandidates) {
16
+ const speakerNameMatchKeys = getSpeakerNameMatchKeys(nameCandidate);
17
+
18
+ for (const speakerNameMatchKey of speakerNameMatchKeys) {
19
+ const matchingCalendarEventParticipant =
20
+ calendarEventParticipantBySpeakerName.get(speakerNameMatchKey);
21
+
22
+ if (ambiguousSpeakerNameMatchKeys.has(speakerNameMatchKey)) {
23
+ continue;
24
+ }
25
+
26
+ if (isUndefined(matchingCalendarEventParticipant)) {
27
+ calendarEventParticipantBySpeakerName.set(
28
+ speakerNameMatchKey,
29
+ calendarEventParticipant,
30
+ );
31
+ continue;
32
+ }
33
+
34
+ if (
35
+ matchingCalendarEventParticipant.id !== calendarEventParticipant.id
36
+ ) {
37
+ calendarEventParticipantBySpeakerName.delete(speakerNameMatchKey);
38
+ ambiguousSpeakerNameMatchKeys.add(speakerNameMatchKey);
39
+ }
40
+ }
41
+ }
42
+ }
43
+
44
+ return calendarEventParticipantBySpeakerName;
45
+ };
@@ -0,0 +1,77 @@
1
+ import { isUndefined } from '@sniptt/guards';
2
+
3
+ import { type TranscriptEntry } from 'src/front-components/types/transcript-entry.type';
4
+
5
+ export const findActiveTranscriptEntryIndex = (
6
+ entries: TranscriptEntry[],
7
+ currentTimeSeconds: number,
8
+ ): number => {
9
+ for (let entryIndex = entries.length - 1; entryIndex >= 0; entryIndex--) {
10
+ const entry = entries[entryIndex];
11
+
12
+ if (
13
+ isTranscriptEntryActive({
14
+ entries,
15
+ entry,
16
+ entryIndex,
17
+ currentTimeSeconds,
18
+ })
19
+ ) {
20
+ return entryIndex;
21
+ }
22
+ }
23
+
24
+ return -1;
25
+ };
26
+
27
+ const isTranscriptEntryActive = ({
28
+ entries,
29
+ entry,
30
+ entryIndex,
31
+ currentTimeSeconds,
32
+ }: {
33
+ entries: TranscriptEntry[];
34
+ entry: TranscriptEntry;
35
+ entryIndex: number;
36
+ currentTimeSeconds: number;
37
+ }): boolean => {
38
+ if (
39
+ isUndefined(entry.startSeconds) ||
40
+ currentTimeSeconds < entry.startSeconds
41
+ ) {
42
+ return false;
43
+ }
44
+
45
+ if (!isUndefined(entry.endSeconds)) {
46
+ return currentTimeSeconds <= entry.endSeconds;
47
+ }
48
+
49
+ const nextTranscriptEntryStartSeconds = findNextTranscriptEntryStartSeconds(
50
+ entries,
51
+ entryIndex,
52
+ );
53
+
54
+ return isUndefined(nextTranscriptEntryStartSeconds)
55
+ ? true
56
+ : currentTimeSeconds < nextTranscriptEntryStartSeconds;
57
+ };
58
+
59
+ const findNextTranscriptEntryStartSeconds = (
60
+ entries: TranscriptEntry[],
61
+ entryIndex: number,
62
+ ): number | undefined => {
63
+ for (
64
+ let nextEntryIndex = entryIndex + 1;
65
+ nextEntryIndex < entries.length;
66
+ nextEntryIndex++
67
+ ) {
68
+ const nextTranscriptEntryStartSeconds =
69
+ entries[nextEntryIndex].startSeconds;
70
+
71
+ if (!isUndefined(nextTranscriptEntryStartSeconds)) {
72
+ return nextTranscriptEntryStartSeconds;
73
+ }
74
+ }
75
+
76
+ return undefined;
77
+ };
@@ -0,0 +1,16 @@
1
+ export const formatTranscriptTimestamp = (totalSeconds: number): string => {
2
+ const safeSeconds = Number.isFinite(totalSeconds)
3
+ ? Math.max(0, Math.floor(totalSeconds))
4
+ : 0;
5
+
6
+ const hours = Math.floor(safeSeconds / 3600);
7
+ const minutes = Math.floor((safeSeconds % 3600) / 60);
8
+ const seconds = safeSeconds % 60;
9
+ const paddedSeconds = String(seconds).padStart(2, '0');
10
+
11
+ if (hours > 0) {
12
+ return `${hours}:${String(minutes).padStart(2, '0')}:${paddedSeconds}`;
13
+ }
14
+
15
+ return `${minutes}:${paddedSeconds}`;
16
+ };
@@ -0,0 +1,48 @@
1
+ // Duplicates minimal front image URL logic for this app.
2
+ // Remove once shared front utilities can be imported safely in front components.
3
+ import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util';
4
+
5
+ type GetImageAbsoluteUrlArgs = {
6
+ imageUrl: string;
7
+ baseUrl: string;
8
+ };
9
+
10
+ const getImageAbsoluteUrl = ({
11
+ imageUrl,
12
+ baseUrl,
13
+ }: GetImageAbsoluteUrlArgs): string => {
14
+ const lowerCaseImageUrl = imageUrl.toLowerCase();
15
+ const isAlreadyAbsoluteUrl =
16
+ ['http:', 'https:', 'data:', 'blob:'].some((scheme) =>
17
+ lowerCaseImageUrl.startsWith(scheme),
18
+ ) || imageUrl.startsWith('//');
19
+
20
+ if (isAlreadyAbsoluteUrl) {
21
+ return imageUrl;
22
+ }
23
+
24
+ if (imageUrl.startsWith('/')) {
25
+ return new URL(`/files${imageUrl}`, baseUrl).toString();
26
+ }
27
+
28
+ return new URL(`/files/${imageUrl}`, baseUrl).toString();
29
+ };
30
+
31
+ export const getAbsoluteAvatarUrl = (
32
+ avatarUrl: string | null | undefined,
33
+ ): string | undefined => {
34
+ if (!isNonEmptyString(avatarUrl)) {
35
+ return undefined;
36
+ }
37
+
38
+ const apiBaseUrl = process.env.TWENTY_API_URL;
39
+
40
+ if (!isNonEmptyString(apiBaseUrl)) {
41
+ return avatarUrl.trim();
42
+ }
43
+
44
+ return getImageAbsoluteUrl({
45
+ imageUrl: avatarUrl.trim(),
46
+ baseUrl: apiBaseUrl,
47
+ });
48
+ };
@@ -0,0 +1,24 @@
1
+ import { isUndefined } from '@sniptt/guards';
2
+
3
+ import { type CalendarEventParticipantBySpeakerName } from 'src/front-components/types/calendar-event-participant-by-speaker-name.type';
4
+ import { type CalendarEventRecordingParticipant } from 'src/front-components/types/calendar-event-recording-participant.type';
5
+ import { getSpeakerNameMatchKeys } from 'src/front-components/utils/get-speaker-name-match-keys.util';
6
+
7
+ export const getCalendarEventParticipantForSpeakerName = ({
8
+ speakerName,
9
+ calendarEventParticipantBySpeakerName,
10
+ }: {
11
+ speakerName: string;
12
+ calendarEventParticipantBySpeakerName: CalendarEventParticipantBySpeakerName;
13
+ }): CalendarEventRecordingParticipant | undefined => {
14
+ for (const speakerNameMatchKey of getSpeakerNameMatchKeys(speakerName)) {
15
+ const calendarEventParticipant =
16
+ calendarEventParticipantBySpeakerName.get(speakerNameMatchKey);
17
+
18
+ if (!isUndefined(calendarEventParticipant)) {
19
+ return calendarEventParticipant;
20
+ }
21
+ }
22
+
23
+ return undefined;
24
+ };