From f8ed432e2aa2c7df4031a9ddfde88c8a60d25de7 Mon Sep 17 00:00:00 2001 From: nitin <142569587+ehconitin@users.noreply.github.com> Date: Fri, 31 Jul 2026 16:56:46 +0530 Subject: [PATCH] Harden Fireflies call synchronization lifecycle (#23610) Makes Fireflies call syncing (webhook and manual) resilient and lifecycle-correct. - Keeps a call recording `PROCESSING` until both transcript and summary are filled, then marks it `COMPLETED` - Looks up existing call recording field state first so only missing fields are fetched from Fireflies - Makes the call recording write race-safe: deterministic-id create with a concurrent-create fallback - Adds bounded retries with rate-limit handling to Fireflies API requests - Bumps twenty-sdk to 2.25.0 and validates query results with zod Review in cubic --- .../twenty-apps/public/fireflies/package.json | 3 +- .../twenty-fireflies.integration-test.ts | 11 +- .../call-recording-request-status.constant.ts | 3 + ...s.ts => call-recording-status.constant.ts} | 4 - .../fireflies-api-max-attempts.constant.ts | 1 + ...s-api-retry-delay-milliseconds.constant.ts | 1 + ...ll-recording-field-states-or-throw.util.ts | 52 ++++ ...rt-missing-fireflies-calls.test-support.ts | 173 +++++++++++ .../fireflies-webhook-handler.test.ts | 77 +++++ .../handlers/fireflies-sync-call-handler.ts | 48 +++- .../handlers/fireflies-webhook-handler.ts | 17 +- ...ng-field-state-query-result.schema.test.ts | 33 +++ ...cording-field-state-query-result.schema.ts | 36 +++ .../types/call-recording-field-state.type.ts | 9 + .../types/call-recording-sync-fields.type.ts | 17 ++ .../types/fireflies-sync-call-result.type.ts | 2 +- .../types/fireflies-syncable-field.type.ts | 1 + .../types/sync-fireflies-call-result.type.ts | 17 ++ .../types/transcript-entry-word.type.ts | 5 + .../types/transcript-entry.type.ts | 6 + .../__tests__/fireflies-api-request.test.ts | 103 +++++++ ...es-sentences-to-transcript-entries.test.ts | 2 +- ...c-fireflies-call-to-call-recording.test.ts | 149 ++++++++++ .../utils/fireflies-api-request.ts | 54 +++- .../is-retryable-fireflies-api-status.util.ts | 3 + ...s-sentences-to-transcript-entries.util.ts} | 12 +- .../sync-fireflies-call-to-call-recording.ts | 186 ------------ ...c-fireflies-call-to-call-recording.util.ts | 268 ++++++++++++++++++ .../upsert-call-recording-or-throw.util.ts | 108 +++++++ .../utils/upsert-call-recording.ts | 96 ------- .../src/utils/sleep-for-milliseconds.util.ts | 2 + .../twenty-apps/public/fireflies/yarn.lock | 1 + 32 files changed, 1177 insertions(+), 323 deletions(-) create mode 100644 packages/twenty-apps/public/fireflies/src/logic-functions/constants/call-recording-request-status.constant.ts rename packages/twenty-apps/public/fireflies/src/logic-functions/constants/{call-recording-status.ts => call-recording-status.constant.ts} (70%) create mode 100644 packages/twenty-apps/public/fireflies/src/logic-functions/constants/fireflies-api-max-attempts.constant.ts create mode 100644 packages/twenty-apps/public/fireflies/src/logic-functions/constants/fireflies-api-retry-delay-milliseconds.constant.ts create mode 100644 packages/twenty-apps/public/fireflies/src/logic-functions/data/find-call-recording-field-states-or-throw.util.ts create mode 100644 packages/twenty-apps/public/fireflies/src/logic-functions/flows/__tests__/import-missing-fireflies-calls.test-support.ts create mode 100644 packages/twenty-apps/public/fireflies/src/logic-functions/handlers/__tests__/fireflies-webhook-handler.test.ts create mode 100644 packages/twenty-apps/public/fireflies/src/logic-functions/schemas/__tests__/call-recording-field-state-query-result.schema.test.ts create mode 100644 packages/twenty-apps/public/fireflies/src/logic-functions/schemas/call-recording-field-state-query-result.schema.ts create mode 100644 packages/twenty-apps/public/fireflies/src/logic-functions/types/call-recording-field-state.type.ts create mode 100644 packages/twenty-apps/public/fireflies/src/logic-functions/types/call-recording-sync-fields.type.ts create mode 100644 packages/twenty-apps/public/fireflies/src/logic-functions/types/fireflies-syncable-field.type.ts create mode 100644 packages/twenty-apps/public/fireflies/src/logic-functions/types/sync-fireflies-call-result.type.ts create mode 100644 packages/twenty-apps/public/fireflies/src/logic-functions/types/transcript-entry-word.type.ts create mode 100644 packages/twenty-apps/public/fireflies/src/logic-functions/types/transcript-entry.type.ts create mode 100644 packages/twenty-apps/public/fireflies/src/logic-functions/utils/__tests__/fireflies-api-request.test.ts create mode 100644 packages/twenty-apps/public/fireflies/src/logic-functions/utils/__tests__/sync-fireflies-call-to-call-recording.test.ts create mode 100644 packages/twenty-apps/public/fireflies/src/logic-functions/utils/is-retryable-fireflies-api-status.util.ts rename packages/twenty-apps/public/fireflies/src/logic-functions/utils/{map-fireflies-sentences-to-transcript-entries.ts => map-fireflies-sentences-to-transcript-entries.util.ts} (86%) delete mode 100644 packages/twenty-apps/public/fireflies/src/logic-functions/utils/sync-fireflies-call-to-call-recording.ts create mode 100644 packages/twenty-apps/public/fireflies/src/logic-functions/utils/sync-fireflies-call-to-call-recording.util.ts create mode 100644 packages/twenty-apps/public/fireflies/src/logic-functions/utils/upsert-call-recording-or-throw.util.ts delete mode 100644 packages/twenty-apps/public/fireflies/src/logic-functions/utils/upsert-call-recording.ts create mode 100644 packages/twenty-apps/public/fireflies/src/utils/sleep-for-milliseconds.util.ts diff --git a/packages/twenty-apps/public/fireflies/package.json b/packages/twenty-apps/public/fireflies/package.json index 21f91d0356..8d20b08d11 100644 --- a/packages/twenty-apps/public/fireflies/package.json +++ b/packages/twenty-apps/public/fireflies/package.json @@ -23,7 +23,8 @@ "test:unit": "vitest run --config vitest.unit.config.ts" }, "dependencies": { - "@sniptt/guards": "^0.2.0" + "@sniptt/guards": "^0.2.0", + "zod": "^4.1.11" }, "devDependencies": { "@emotion/react": "^11.14.0", diff --git a/packages/twenty-apps/public/fireflies/src/__tests__/twenty-fireflies.integration-test.ts b/packages/twenty-apps/public/fireflies/src/__tests__/twenty-fireflies.integration-test.ts index 4d0af6b506..9996562fac 100644 --- a/packages/twenty-apps/public/fireflies/src/__tests__/twenty-fireflies.integration-test.ts +++ b/packages/twenty-apps/public/fireflies/src/__tests__/twenty-fireflies.integration-test.ts @@ -3,10 +3,8 @@ import { MetadataApiClient } from 'twenty-client-sdk/metadata'; import { describe, expect, it } from 'vitest'; import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; -import { - CALL_RECORDING_REQUEST_STATUS, - CALL_RECORDING_STATUS, -} from 'src/logic-functions/constants/call-recording-status'; +import { CALL_RECORDING_REQUEST_STATUS } from 'src/logic-functions/constants/call-recording-request-status.constant'; +import { CALL_RECORDING_STATUS } from 'src/logic-functions/constants/call-recording-status.constant'; describe('App installation', () => { it('should find the installed Fireflies app in the applications list', async () => { @@ -37,9 +35,8 @@ type GeneratedCoreSchemaRuntime = { }; const getServerCallRecordingStatuses = async (): Promise => { - const generatedCoreSchema = (await import( - 'twenty-client-sdk/core' - )) as unknown as GeneratedCoreSchemaRuntime; + const generatedCoreSchema = + (await import('twenty-client-sdk/core')) as unknown as GeneratedCoreSchemaRuntime; const statusEnum = generatedCoreSchema.enumCallRecordingStatusEnum; if (statusEnum === undefined) { diff --git a/packages/twenty-apps/public/fireflies/src/logic-functions/constants/call-recording-request-status.constant.ts b/packages/twenty-apps/public/fireflies/src/logic-functions/constants/call-recording-request-status.constant.ts new file mode 100644 index 0000000000..db4a7e1caf --- /dev/null +++ b/packages/twenty-apps/public/fireflies/src/logic-functions/constants/call-recording-request-status.constant.ts @@ -0,0 +1,3 @@ +export const CALL_RECORDING_REQUEST_STATUS = { + REQUESTED: 'REQUESTED', +} as const; diff --git a/packages/twenty-apps/public/fireflies/src/logic-functions/constants/call-recording-status.ts b/packages/twenty-apps/public/fireflies/src/logic-functions/constants/call-recording-status.constant.ts similarity index 70% rename from packages/twenty-apps/public/fireflies/src/logic-functions/constants/call-recording-status.ts rename to packages/twenty-apps/public/fireflies/src/logic-functions/constants/call-recording-status.constant.ts index 3f242047a9..d56bfc4f8a 100644 --- a/packages/twenty-apps/public/fireflies/src/logic-functions/constants/call-recording-status.ts +++ b/packages/twenty-apps/public/fireflies/src/logic-functions/constants/call-recording-status.constant.ts @@ -3,7 +3,3 @@ export const CALL_RECORDING_STATUS = { PROCESSING: 'PROCESSING', COMPLETED: 'COMPLETED', } as const; - -export const CALL_RECORDING_REQUEST_STATUS = { - REQUESTED: 'REQUESTED', -} as const; diff --git a/packages/twenty-apps/public/fireflies/src/logic-functions/constants/fireflies-api-max-attempts.constant.ts b/packages/twenty-apps/public/fireflies/src/logic-functions/constants/fireflies-api-max-attempts.constant.ts new file mode 100644 index 0000000000..93566b3c39 --- /dev/null +++ b/packages/twenty-apps/public/fireflies/src/logic-functions/constants/fireflies-api-max-attempts.constant.ts @@ -0,0 +1 @@ +export const FIREFLIES_API_MAX_ATTEMPTS = 3; diff --git a/packages/twenty-apps/public/fireflies/src/logic-functions/constants/fireflies-api-retry-delay-milliseconds.constant.ts b/packages/twenty-apps/public/fireflies/src/logic-functions/constants/fireflies-api-retry-delay-milliseconds.constant.ts new file mode 100644 index 0000000000..54233f20fb --- /dev/null +++ b/packages/twenty-apps/public/fireflies/src/logic-functions/constants/fireflies-api-retry-delay-milliseconds.constant.ts @@ -0,0 +1 @@ +export const FIREFLIES_API_RETRY_DELAY_MILLISECONDS = 250; diff --git a/packages/twenty-apps/public/fireflies/src/logic-functions/data/find-call-recording-field-states-or-throw.util.ts b/packages/twenty-apps/public/fireflies/src/logic-functions/data/find-call-recording-field-states-or-throw.util.ts new file mode 100644 index 0000000000..72cc7bb762 --- /dev/null +++ b/packages/twenty-apps/public/fireflies/src/logic-functions/data/find-call-recording-field-states-or-throw.util.ts @@ -0,0 +1,52 @@ +import { isNonEmptyString } from '@sniptt/guards'; +import { type CoreApiClient } from 'twenty-client-sdk/core'; +import { isDefined } from 'src/utils/is-defined'; + +import { callRecordingFieldStateQueryResultSchema } from 'src/logic-functions/schemas/call-recording-field-state-query-result.schema'; +import { type CallRecordingFieldState } from 'src/logic-functions/types/call-recording-field-state.type'; + +export const findCallRecordingFieldStatesOrThrow = async ({ + coreApiClient, + callRecordingIds, +}: { + coreApiClient: CoreApiClient; + callRecordingIds: string[]; +}): Promise> => { + const fieldStateQueryResult = await coreApiClient.query({ + callRecordings: { + __args: { + filter: { id: { in: callRecordingIds } }, + first: callRecordingIds.length, + }, + edges: { + node: { + id: true, + status: true, + transcript: true, + summary: { + markdown: true, + }, + }, + }, + }, + }); + const parsedFieldStateQueryResult = + callRecordingFieldStateQueryResultSchema.parse(fieldStateQueryResult); + const callRecordingFieldStates = new Map(); + + for (const edge of parsedFieldStateQueryResult.callRecordings?.edges ?? []) { + const node = edge?.node; + + if (!isDefined(node)) { + continue; + } + + callRecordingFieldStates.set(node.id, { + isTranscriptFilled: isDefined(node.transcript), + isSummaryFilled: isNonEmptyString(node.summary?.markdown?.trim()), + status: node.status, + }); + } + + return callRecordingFieldStates; +}; diff --git a/packages/twenty-apps/public/fireflies/src/logic-functions/flows/__tests__/import-missing-fireflies-calls.test-support.ts b/packages/twenty-apps/public/fireflies/src/logic-functions/flows/__tests__/import-missing-fireflies-calls.test-support.ts new file mode 100644 index 0000000000..4ef5dd17b8 --- /dev/null +++ b/packages/twenty-apps/public/fireflies/src/logic-functions/flows/__tests__/import-missing-fireflies-calls.test-support.ts @@ -0,0 +1,173 @@ +import { type Mock, vi } from 'vitest'; + +export const FIREFLIES_API_KEY = 'fireflies-api-key'; +export const FIREFLIES_BACKFILL_FROM_DATE = '2026-05-01T00:00:00.000Z'; +export const FAR_DEADLINE_MILLISECONDS = Number.MAX_SAFE_INTEGER; +export const INITIAL_FIREFLIES_BACKFILL_CURSOR = { + fromDate: FIREFLIES_BACKFILL_FROM_DATE, + toDate: '2026-07-30T00:00:00.000Z', + skip: 0, +}; + +export const skipSleep = async (): Promise => {}; + +type RawListedTranscript = { + id: string; + title: string; + date: number; + duration: number; + participants: string[]; + host_email: string | null; + transcript_url: string | null; + meeting_link: string | null; +}; + +export const buildListedTranscript = ( + id: string, + dateMilliseconds: number, +): RawListedTranscript => ({ + id, + title: `Call ${id}`, + date: dateMilliseconds, + duration: 30, + participants: ['a@example.com'], + host_email: 'a@example.com', + transcript_url: null, + meeting_link: null, +}); + +export const buildGraphqlResponse = (data: object): Response => + new Response(JSON.stringify({ data }), { status: 200 }); + +export const serveFirefliesApi = ( + pages: RawListedTranscript[][], + fetchMock: Mock, +) => { + let listRequestIndex = 0; + + fetchMock.mockImplementation(async (_url: string, init: RequestInit) => { + const body = JSON.parse(String(init.body)) as { query: string }; + + if (body.query.includes('query Transcripts(')) { + const page = pages[listRequestIndex] ?? []; + + listRequestIndex += 1; + + return buildGraphqlResponse({ transcripts: page }); + } + + return buildGraphqlResponse({ + transcript: { + id: 'detail', + title: 'Call detail', + date: Date.parse(FIREFLIES_BACKFILL_FROM_DATE), + duration: 30, + meeting_link: null, + participants: ['a@example.com'], + organizer_email: 'a@example.com', + calendar_id: null, + cal_id: null, + calendar_type: null, + sentences: [ + { + speaker_name: 'A', + text: 'hello', + start_time: 0, + end_time: 1, + }, + ], + summary: null, + }, + }); + }); +}; + +type StoredCallRecording = { + id: string; + status?: string; + transcript?: unknown; + summary?: { markdown: string } | null; +}; + +export const answerTwentyQueries = ({ + queryMock, + callRecordings = [], +}: { + queryMock: Mock; + callRecordings?: StoredCallRecording[]; +}) => { + queryMock.mockImplementation( + async (query: { + callRecordings?: { + __args?: { + filter?: { + id?: { in?: string[]; eq?: string }; + }; + }; + }; + }) => { + const idFilter = query.callRecordings?.__args?.filter?.id; + + if (Array.isArray(idFilter?.in)) { + return { + callRecordings: { + edges: callRecordings + .filter(({ id }) => idFilter.in?.includes(id)) + .map((node) => ({ node })), + }, + }; + } + + if (idFilter?.eq) { + const callRecording = callRecordings.find( + ({ id }) => id === idFilter.eq, + ); + + return { + callRecordings: { + edges: callRecording ? [{ node: callRecording }] : [], + }, + }; + } + + return { + calendarChannelEventAssociations: { edges: [] }, + calendarEvents: { edges: [] }, + }; + }, + ); +}; + +export const getListRequestVariables = ( + fetchMock: Mock, +): Array<{ + fromDate?: string; + skip?: number; + toDate?: string; +}> => + fetchMock.mock.calls + .map(([, init]) => JSON.parse(String((init as RequestInit).body))) + .filter((body: { query: string }) => + body.query.includes('query Transcripts('), + ) + .map( + (body: { variables: { skip?: number; toDate?: string } }) => + body.variables, + ); + +export const setUpImportMissingFirefliesCallsTest = ({ + fetchMock, + queryMock, + mutationMock, +}: { + fetchMock: Mock; + queryMock: Mock; + mutationMock: Mock; +}) => { + vi.clearAllMocks(); + vi.stubGlobal('fetch', fetchMock); + answerTwentyQueries({ queryMock }); + mutationMock.mockResolvedValue({ + createCallRecording: { id: 'created' }, + }); +}; diff --git a/packages/twenty-apps/public/fireflies/src/logic-functions/handlers/__tests__/fireflies-webhook-handler.test.ts b/packages/twenty-apps/public/fireflies/src/logic-functions/handlers/__tests__/fireflies-webhook-handler.test.ts new file mode 100644 index 0000000000..d89dff356c --- /dev/null +++ b/packages/twenty-apps/public/fireflies/src/logic-functions/handlers/__tests__/fireflies-webhook-handler.test.ts @@ -0,0 +1,77 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { CALL_RECORDING_STATUS } from 'src/logic-functions/constants/call-recording-status.constant'; +import { + answerTwentyQueries, + buildGraphqlResponse, +} from 'src/logic-functions/flows/__tests__/import-missing-fireflies-calls.test-support'; +import { firefliesWebhookHandler } from 'src/logic-functions/handlers/fireflies-webhook-handler'; + +const queryMock = vi.hoisted(() => vi.fn()); +const mutationMock = vi.hoisted(() => vi.fn()); + +vi.mock('twenty-client-sdk/core', () => ({ + CoreApiClient: class { + query = queryMock; + mutation = mutationMock; + }, +})); + +describe('firefliesWebhookHandler', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv('FIREFLIES_API_KEY', 'fireflies-api-key'); + answerTwentyQueries({ queryMock }); + mutationMock.mockResolvedValue({ + createCallRecording: { id: 'created' }, + }); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + }); + + it('keeps a summary-only webhook recording processing until its transcript arrives', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + buildGraphqlResponse({ + transcript: { + id: 'summary-first-call', + title: 'Summary first call', + date: Date.parse('2026-06-02T10:00:00.000Z'), + duration: 30, + meeting_link: null, + participants: ['a@example.com'], + organizer_email: 'a@example.com', + calendar_id: null, + cal_id: null, + calendar_type: null, + meeting_info: { summary_status: 'processed' }, + summary: { overview: 'Summary arrived first' }, + }, + }), + ), + ); + + await firefliesWebhookHandler({ + meetingId: 'summary-first-call', + eventType: 'meeting.summarized', + }); + + expect(mutationMock).toHaveBeenCalledWith({ + createCallRecording: { + __args: { + data: expect.objectContaining({ + status: CALL_RECORDING_STATUS.PROCESSING, + summary: expect.objectContaining({ + markdown: expect.stringContaining('Summary arrived first'), + }), + }), + }, + id: true, + }, + }); + }); +}); diff --git a/packages/twenty-apps/public/fireflies/src/logic-functions/handlers/fireflies-sync-call-handler.ts b/packages/twenty-apps/public/fireflies/src/logic-functions/handlers/fireflies-sync-call-handler.ts index d9eeb566dd..d07450a3e7 100644 --- a/packages/twenty-apps/public/fireflies/src/logic-functions/handlers/fireflies-sync-call-handler.ts +++ b/packages/twenty-apps/public/fireflies/src/logic-functions/handlers/fireflies-sync-call-handler.ts @@ -1,17 +1,18 @@ import { isNonEmptyString } from '@sniptt/guards'; import { CoreApiClient } from 'twenty-client-sdk/core'; +import { CALL_RECORDING_STATUS } from 'src/logic-functions/constants/call-recording-status.constant'; +import { findCallRecordingFieldStatesOrThrow } from 'src/logic-functions/data/find-call-recording-field-states-or-throw.util'; import { type FirefliesSyncCallInput } from 'src/logic-functions/types/fireflies-sync-call-input.type'; import { type FirefliesSyncCallFieldOutcome, type FirefliesSyncCallResult, } from 'src/logic-functions/types/fireflies-sync-call-result.type'; import { getFirefliesApiKey } from 'src/logic-functions/utils/get-fireflies-api-key'; -import { - type FirefliesSyncableField, - type SyncFirefliesCallResult, - syncFirefliesCallToCallRecording, -} from 'src/logic-functions/utils/sync-fireflies-call-to-call-recording'; +import { type FirefliesSyncableField } from 'src/logic-functions/types/fireflies-syncable-field.type'; +import { type SyncFirefliesCallResult } from 'src/logic-functions/types/sync-fireflies-call-result.type'; +import { computeCallRecordingIdForFirefliesMeeting } from 'src/logic-functions/utils/compute-call-recording-id-for-fireflies-meeting'; +import { syncFirefliesCallToCallRecording } from 'src/logic-functions/utils/sync-fireflies-call-to-call-recording.util'; const ALL_FIELDS: FirefliesSyncableField[] = ['transcript', 'summary']; @@ -48,18 +49,47 @@ export const firefliesSyncCallHandler = async ( ); } - const client = new CoreApiClient(); + const coreApiClient = new CoreApiClient(); + const callRecordingId = + computeCallRecordingIdForFirefliesMeeting(transcriptId); + const callRecordingFieldStates = await findCallRecordingFieldStatesOrThrow({ + coreApiClient, + callRecordingIds: [callRecordingId], + }); + const initialCallRecordingFieldState = + callRecordingFieldStates.get(callRecordingId); // Sequential: both fields upsert the same row, concurrent runs would race on the create. const results: SyncFirefliesCallResult[] = []; for (const field of ALL_FIELDS) { + const isTranscriptFilled = + (initialCallRecordingFieldState?.isTranscriptFilled ?? false) || + results.some( + (result) => + result.status === 'updated' && result.field === 'transcript', + ); + const isSummaryFilled = + (initialCallRecordingFieldState?.isSummaryFilled ?? false) || + results.some( + (result) => result.status === 'updated' && result.field === 'summary', + ); + const currentCallRecordingFieldState = { + isTranscriptFilled, + isSummaryFilled, + status: + isTranscriptFilled && isSummaryFilled + ? CALL_RECORDING_STATUS.COMPLETED + : initialCallRecordingFieldState?.status, + }; + results.push( await syncFirefliesCallToCallRecording({ apiKey: apiKeyResult.apiKey, - client, + coreApiClient, transcriptId, field, + callRecordingFieldState: currentCallRecordingFieldState, }), ); } @@ -85,7 +115,9 @@ export const firefliesSyncCallHandler = async ( .map((outcome) => outcome.field); const updatedResult = results.find( - (result): result is Extract => + ( + result, + ): result is Extract => result.status === 'updated', ); diff --git a/packages/twenty-apps/public/fireflies/src/logic-functions/handlers/fireflies-webhook-handler.ts b/packages/twenty-apps/public/fireflies/src/logic-functions/handlers/fireflies-webhook-handler.ts index ca86fcbb87..f52f70912e 100644 --- a/packages/twenty-apps/public/fireflies/src/logic-functions/handlers/fireflies-webhook-handler.ts +++ b/packages/twenty-apps/public/fireflies/src/logic-functions/handlers/fireflies-webhook-handler.ts @@ -1,12 +1,12 @@ import { isNonEmptyString } from '@sniptt/guards'; import { CoreApiClient } from 'twenty-client-sdk/core'; +import { findCallRecordingFieldStatesOrThrow } from 'src/logic-functions/data/find-call-recording-field-states-or-throw.util'; import { type FirefliesWebhookResult } from 'src/logic-functions/types/fireflies-webhook-payload.type'; +import { computeCallRecordingIdForFirefliesMeeting } from 'src/logic-functions/utils/compute-call-recording-id-for-fireflies-meeting'; import { getFirefliesApiKey } from 'src/logic-functions/utils/get-fireflies-api-key'; -import { - type FirefliesSyncableField, - syncFirefliesCallToCallRecording, -} from 'src/logic-functions/utils/sync-fireflies-call-to-call-recording'; +import { type FirefliesSyncableField } from 'src/logic-functions/types/fireflies-syncable-field.type'; +import { syncFirefliesCallToCallRecording } from 'src/logic-functions/utils/sync-fireflies-call-to-call-recording.util'; const TRANSCRIPT_READY_EVENT = 'meeting.transcribed'; const SUMMARY_READY_EVENT = 'meeting.summarized'; @@ -47,11 +47,18 @@ export const firefliesWebhookHandler = async ({ return { error: apiKeyResult.error, meetingId }; } + const coreApiClient = new CoreApiClient(); + const callRecordingId = computeCallRecordingIdForFirefliesMeeting(meetingId); + const callRecordingFieldStates = await findCallRecordingFieldStatesOrThrow({ + coreApiClient, + callRecordingIds: [callRecordingId], + }); const syncResult = await syncFirefliesCallToCallRecording({ apiKey: apiKeyResult.apiKey, - client: new CoreApiClient(), + coreApiClient, transcriptId: meetingId, field, + callRecordingFieldState: callRecordingFieldStates.get(callRecordingId), }); if (syncResult.status === 'error') { diff --git a/packages/twenty-apps/public/fireflies/src/logic-functions/schemas/__tests__/call-recording-field-state-query-result.schema.test.ts b/packages/twenty-apps/public/fireflies/src/logic-functions/schemas/__tests__/call-recording-field-state-query-result.schema.test.ts new file mode 100644 index 0000000000..a94b2ecbf1 --- /dev/null +++ b/packages/twenty-apps/public/fireflies/src/logic-functions/schemas/__tests__/call-recording-field-state-query-result.schema.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; + +import { callRecordingFieldStateQueryResultSchema } from 'src/logic-functions/schemas/call-recording-field-state-query-result.schema'; + +describe('callRecordingFieldStateQueryResultSchema', () => { + it('accepts the queried call recording field state', () => { + expect( + callRecordingFieldStateQueryResultSchema.safeParse({ + callRecordings: { + edges: [ + { + node: { + id: 'call-recording-id', + transcript: [{ speakerName: 'A', text: 'hello' }], + summary: { markdown: 'Imported summary' }, + }, + }, + ], + }, + }).success, + ).toBe(true); + }); + + it('rejects a call recording without an id', () => { + expect( + callRecordingFieldStateQueryResultSchema.safeParse({ + callRecordings: { + edges: [{ node: { summary: null } }], + }, + }).success, + ).toBe(false); + }); +}); diff --git a/packages/twenty-apps/public/fireflies/src/logic-functions/schemas/call-recording-field-state-query-result.schema.ts b/packages/twenty-apps/public/fireflies/src/logic-functions/schemas/call-recording-field-state-query-result.schema.ts new file mode 100644 index 0000000000..16fd93274b --- /dev/null +++ b/packages/twenty-apps/public/fireflies/src/logic-functions/schemas/call-recording-field-state-query-result.schema.ts @@ -0,0 +1,36 @@ +import { z } from 'zod'; + +import { CALL_RECORDING_STATUS } from 'src/logic-functions/constants/call-recording-status.constant'; + +export const callRecordingFieldStateQueryResultSchema = z.object({ + callRecordings: z + .object({ + edges: z.array( + z + .object({ + node: z + .object({ + id: z.string(), + status: z + .enum([ + CALL_RECORDING_STATUS.PROCESSING, + CALL_RECORDING_STATUS.COMPLETED, + ]) + .optional(), + transcript: z.unknown().nullable().optional(), + summary: z + .object({ + markdown: z.string().nullable().optional(), + }) + .nullable() + .optional(), + }) + .nullable() + .optional(), + }) + .nullable(), + ), + }) + .nullable() + .optional(), +}); diff --git a/packages/twenty-apps/public/fireflies/src/logic-functions/types/call-recording-field-state.type.ts b/packages/twenty-apps/public/fireflies/src/logic-functions/types/call-recording-field-state.type.ts new file mode 100644 index 0000000000..cf0a6ecfb1 --- /dev/null +++ b/packages/twenty-apps/public/fireflies/src/logic-functions/types/call-recording-field-state.type.ts @@ -0,0 +1,9 @@ +import { type CALL_RECORDING_STATUS } from 'src/logic-functions/constants/call-recording-status.constant'; + +export type CallRecordingFieldState = { + isTranscriptFilled: boolean; + isSummaryFilled: boolean; + status?: + | typeof CALL_RECORDING_STATUS.PROCESSING + | typeof CALL_RECORDING_STATUS.COMPLETED; +}; diff --git a/packages/twenty-apps/public/fireflies/src/logic-functions/types/call-recording-sync-fields.type.ts b/packages/twenty-apps/public/fireflies/src/logic-functions/types/call-recording-sync-fields.type.ts new file mode 100644 index 0000000000..8748cf3e42 --- /dev/null +++ b/packages/twenty-apps/public/fireflies/src/logic-functions/types/call-recording-sync-fields.type.ts @@ -0,0 +1,17 @@ +import type { CALL_RECORDING_REQUEST_STATUS } from 'src/logic-functions/constants/call-recording-request-status.constant'; +import type { CALL_RECORDING_STATUS } from 'src/logic-functions/constants/call-recording-status.constant'; +import { type TranscriptEntry } from 'src/logic-functions/types/transcript-entry.type'; + +export type CallRecordingSyncFields = { + title?: string; + status?: + | typeof CALL_RECORDING_STATUS.PROCESSING + | typeof CALL_RECORDING_STATUS.COMPLETED; + recordingRequestStatus?: typeof CALL_RECORDING_REQUEST_STATUS.REQUESTED; + externalRecordingId?: string; + startedAt?: string; + endedAt?: string; + transcript?: TranscriptEntry[]; + summary?: { markdown: string; blocknote: null }; + calendarEventId?: string; +}; diff --git a/packages/twenty-apps/public/fireflies/src/logic-functions/types/fireflies-sync-call-result.type.ts b/packages/twenty-apps/public/fireflies/src/logic-functions/types/fireflies-sync-call-result.type.ts index 53edb99966..9eb715317f 100644 --- a/packages/twenty-apps/public/fireflies/src/logic-functions/types/fireflies-sync-call-result.type.ts +++ b/packages/twenty-apps/public/fireflies/src/logic-functions/types/fireflies-sync-call-result.type.ts @@ -1,4 +1,4 @@ -import { type FirefliesSyncableField } from 'src/logic-functions/utils/sync-fireflies-call-to-call-recording'; +import { type FirefliesSyncableField } from 'src/logic-functions/types/fireflies-syncable-field.type'; export type FirefliesSyncCallFieldOutcome = | { field: FirefliesSyncableField; status: 'updated' } diff --git a/packages/twenty-apps/public/fireflies/src/logic-functions/types/fireflies-syncable-field.type.ts b/packages/twenty-apps/public/fireflies/src/logic-functions/types/fireflies-syncable-field.type.ts new file mode 100644 index 0000000000..8581ab270a --- /dev/null +++ b/packages/twenty-apps/public/fireflies/src/logic-functions/types/fireflies-syncable-field.type.ts @@ -0,0 +1 @@ +export type FirefliesSyncableField = 'transcript' | 'summary'; diff --git a/packages/twenty-apps/public/fireflies/src/logic-functions/types/sync-fireflies-call-result.type.ts b/packages/twenty-apps/public/fireflies/src/logic-functions/types/sync-fireflies-call-result.type.ts new file mode 100644 index 0000000000..a6f1054481 --- /dev/null +++ b/packages/twenty-apps/public/fireflies/src/logic-functions/types/sync-fireflies-call-result.type.ts @@ -0,0 +1,17 @@ +import { type FirefliesSyncableField } from 'src/logic-functions/types/fireflies-syncable-field.type'; + +export type SyncFirefliesCallResult = + | { + status: 'updated'; + field: FirefliesSyncableField; + callRecordingId: string; + calendarEventId?: string; + created: boolean; + } + | { status: 'skipped'; field: FirefliesSyncableField; reason: string } + | { + status: 'error'; + field: FirefliesSyncableField; + error: string; + httpStatus?: number; + }; diff --git a/packages/twenty-apps/public/fireflies/src/logic-functions/types/transcript-entry-word.type.ts b/packages/twenty-apps/public/fireflies/src/logic-functions/types/transcript-entry-word.type.ts new file mode 100644 index 0000000000..8ed027cc3d --- /dev/null +++ b/packages/twenty-apps/public/fireflies/src/logic-functions/types/transcript-entry-word.type.ts @@ -0,0 +1,5 @@ +export type TranscriptEntryWord = { + text: string; + start_timestamp?: { relative: number }; + end_timestamp?: { relative: number }; +}; diff --git a/packages/twenty-apps/public/fireflies/src/logic-functions/types/transcript-entry.type.ts b/packages/twenty-apps/public/fireflies/src/logic-functions/types/transcript-entry.type.ts new file mode 100644 index 0000000000..08323deaeb --- /dev/null +++ b/packages/twenty-apps/public/fireflies/src/logic-functions/types/transcript-entry.type.ts @@ -0,0 +1,6 @@ +import { type TranscriptEntryWord } from 'src/logic-functions/types/transcript-entry-word.type'; + +export type TranscriptEntry = { + participant: { name: string }; + words: TranscriptEntryWord[]; +}; diff --git a/packages/twenty-apps/public/fireflies/src/logic-functions/utils/__tests__/fireflies-api-request.test.ts b/packages/twenty-apps/public/fireflies/src/logic-functions/utils/__tests__/fireflies-api-request.test.ts new file mode 100644 index 0000000000..1d0a60bfd1 --- /dev/null +++ b/packages/twenty-apps/public/fireflies/src/logic-functions/utils/__tests__/fireflies-api-request.test.ts @@ -0,0 +1,103 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { firefliesApiRequest } from 'src/logic-functions/utils/fireflies-api-request'; + +describe('firefliesApiRequest', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it.each([ + { errorCode: 'request_timeout', expectedStatus: 408 }, + { errorCode: 'too_many_requests', expectedStatus: 429 }, + { errorCode: 'invariant_violation', expectedStatus: 500 }, + ])( + 'maps Fireflies $errorCode errors returned over HTTP 200 to status $expectedStatus', + async ({ errorCode, expectedStatus }) => { + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation( + async () => + new Response( + JSON.stringify({ + errors: [ + { + message: 'Fireflies request failed', + extensions: { code: errorCode }, + }, + ], + }), + { status: 200 }, + ), + ), + ); + + const result = await firefliesApiRequest({ + apiKey: 'api-key', + query: 'query Test { user { user_id } }', + }); + + expect(result).toEqual( + expect.objectContaining({ + ok: false, + status: expectedStatus, + }), + ); + }, + ); + + it('retries a transient server error and returns the eventual success', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response('server error', { status: 500 })) + .mockResolvedValueOnce( + new Response(JSON.stringify({ data: { user: { user_id: '1' } } }), { + status: 200, + }), + ); + + vi.stubGlobal('fetch', fetchMock); + + const result = await firefliesApiRequest({ + apiKey: 'api-key', + query: 'query Test { user { user_id } }', + }); + + expect(result).toEqual(expect.objectContaining({ ok: true })); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('gives up after the bounded attempts on a persistent server error', async () => { + const fetchMock = vi + .fn() + .mockImplementation( + async () => new Response('server error', { status: 500 }), + ); + + vi.stubGlobal('fetch', fetchMock); + + const result = await firefliesApiRequest({ + apiKey: 'api-key', + query: 'query Test { user { user_id } }', + }); + + expect(result).toEqual(expect.objectContaining({ ok: false, status: 500 })); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it('does not retry rate limiting so the sweep can pause and continue', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(new Response('rate limited', { status: 429 })); + + vi.stubGlobal('fetch', fetchMock); + + const result = await firefliesApiRequest({ + apiKey: 'api-key', + query: 'query Test { user { user_id } }', + }); + + expect(result).toEqual(expect.objectContaining({ ok: false, status: 429 })); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/twenty-apps/public/fireflies/src/logic-functions/utils/__tests__/map-fireflies-sentences-to-transcript-entries.test.ts b/packages/twenty-apps/public/fireflies/src/logic-functions/utils/__tests__/map-fireflies-sentences-to-transcript-entries.test.ts index 782f8bdc9e..58ef155957 100644 --- a/packages/twenty-apps/public/fireflies/src/logic-functions/utils/__tests__/map-fireflies-sentences-to-transcript-entries.test.ts +++ b/packages/twenty-apps/public/fireflies/src/logic-functions/utils/__tests__/map-fireflies-sentences-to-transcript-entries.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { mapFirefliesSentencesToTranscriptEntries } from 'src/logic-functions/utils/map-fireflies-sentences-to-transcript-entries'; +import { mapFirefliesSentencesToTranscriptEntries } from 'src/logic-functions/utils/map-fireflies-sentences-to-transcript-entries.util'; describe('mapFirefliesSentencesToTranscriptEntries', () => { it('should map sentences to diarized entries with relative timestamps', () => { diff --git a/packages/twenty-apps/public/fireflies/src/logic-functions/utils/__tests__/sync-fireflies-call-to-call-recording.test.ts b/packages/twenty-apps/public/fireflies/src/logic-functions/utils/__tests__/sync-fireflies-call-to-call-recording.test.ts new file mode 100644 index 0000000000..84b9af0655 --- /dev/null +++ b/packages/twenty-apps/public/fireflies/src/logic-functions/utils/__tests__/sync-fireflies-call-to-call-recording.test.ts @@ -0,0 +1,149 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { CALL_RECORDING_STATUS } from 'src/logic-functions/constants/call-recording-status.constant'; +import { + answerTwentyQueries, + buildGraphqlResponse, +} from 'src/logic-functions/flows/__tests__/import-missing-fireflies-calls.test-support'; +import { computeCallRecordingIdForFirefliesMeeting } from 'src/logic-functions/utils/compute-call-recording-id-for-fireflies-meeting'; +import { syncFirefliesCallToCallRecording } from 'src/logic-functions/utils/sync-fireflies-call-to-call-recording.util'; + +const queryMock = vi.hoisted(() => vi.fn()); +const mutationMock = vi.hoisted(() => vi.fn()); + +vi.mock('twenty-client-sdk/core', () => ({ + CoreApiClient: class { + query = queryMock; + mutation = mutationMock; + }, +})); + +describe('syncFirefliesCallToCallRecording', () => { + beforeEach(() => { + vi.clearAllMocks(); + mutationMock.mockResolvedValue({ + updateCallRecording: { id: 'updated' }, + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('preserves an existing summary without fetching it again', async () => { + const fetchMock = vi.fn(); + + vi.stubGlobal('fetch', fetchMock); + + const { CoreApiClient } = await import('twenty-client-sdk/core'); + const result = await syncFirefliesCallToCallRecording({ + apiKey: 'fireflies-api-key', + coreApiClient: new CoreApiClient(), + transcriptId: 'already-populated-call', + field: 'summary', + callRecordingFieldState: { + isTranscriptFilled: false, + isSummaryFilled: true, + status: CALL_RECORDING_STATUS.PROCESSING, + }, + }); + + expect(result).toEqual( + expect.objectContaining({ status: 'skipped', field: 'summary' }), + ); + expect(fetchMock).not.toHaveBeenCalled(); + expect(mutationMock).not.toHaveBeenCalled(); + }); + + it('does not complete a recording without a Fireflies summary', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + buildGraphqlResponse({ + transcript: { id: 'call-without-summary', summary: null }, + }), + ), + ); + + const { CoreApiClient } = await import('twenty-client-sdk/core'); + const result = await syncFirefliesCallToCallRecording({ + apiKey: 'fireflies-api-key', + coreApiClient: new CoreApiClient(), + transcriptId: 'call-without-summary', + field: 'summary', + callRecordingFieldState: { + isTranscriptFilled: true, + isSummaryFilled: false, + status: CALL_RECORDING_STATUS.PROCESSING, + }, + }); + + expect(result).toEqual( + expect.objectContaining({ status: 'skipped', field: 'summary' }), + ); + expect(mutationMock).not.toHaveBeenCalled(); + }); + + it('completes the recording when the opposite field arrives after its state snapshot', async () => { + const transcriptId = 'concurrent-webhook-call'; + const callRecordingId = + computeCallRecordingIdForFirefliesMeeting(transcriptId); + + answerTwentyQueries({ + queryMock, + callRecordings: [ + { + id: callRecordingId, + status: CALL_RECORDING_STATUS.PROCESSING, + transcript: [{ text: 'Transcript arrived concurrently' }], + summary: { markdown: 'Stored summary' }, + }, + ], + }); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + buildGraphqlResponse({ + transcript: { + id: transcriptId, + title: 'Concurrent webhook call', + date: Date.parse('2026-06-02T10:00:00.000Z'), + duration: 30, + meeting_link: null, + participants: ['a@example.com'], + organizer_email: 'a@example.com', + calendar_id: null, + cal_id: null, + calendar_type: null, + meeting_info: { summary_status: 'processed' }, + summary: { overview: 'Summary arrived concurrently' }, + }, + }), + ), + ); + + const { CoreApiClient } = await import('twenty-client-sdk/core'); + + await syncFirefliesCallToCallRecording({ + apiKey: 'fireflies-api-key', + coreApiClient: new CoreApiClient(), + transcriptId, + field: 'summary', + callRecordingFieldState: { + isTranscriptFilled: false, + isSummaryFilled: false, + status: CALL_RECORDING_STATUS.PROCESSING, + }, + }); + + expect(mutationMock).toHaveBeenCalledWith({ + updateCallRecording: { + __args: { + id: callRecordingId, + data: { status: CALL_RECORDING_STATUS.COMPLETED }, + }, + id: true, + }, + }); + }); +}); diff --git a/packages/twenty-apps/public/fireflies/src/logic-functions/utils/fireflies-api-request.ts b/packages/twenty-apps/public/fireflies/src/logic-functions/utils/fireflies-api-request.ts index d26be77386..25b31f36d2 100644 --- a/packages/twenty-apps/public/fireflies/src/logic-functions/utils/fireflies-api-request.ts +++ b/packages/twenty-apps/public/fireflies/src/logic-functions/utils/fireflies-api-request.ts @@ -1,4 +1,9 @@ import { isDefined } from 'src/utils/is-defined'; +import { sleepForMilliseconds } from 'src/utils/sleep-for-milliseconds.util'; + +import { FIREFLIES_API_MAX_ATTEMPTS } from 'src/logic-functions/constants/fireflies-api-max-attempts.constant'; +import { FIREFLIES_API_RETRY_DELAY_MILLISECONDS } from 'src/logic-functions/constants/fireflies-api-retry-delay-milliseconds.constant'; +import { isRetryableFirefliesApiStatus } from 'src/logic-functions/utils/is-retryable-fireflies-api-status.util'; const FIREFLIES_API_URL = 'https://api.fireflies.ai/graphql'; @@ -25,6 +30,12 @@ type FirefliesGraphqlError = { }; }; +const HTTP_STATUS_BY_FIREFLIES_ERROR_CODE: Record = { + request_timeout: 408, + too_many_requests: 429, + invariant_violation: 500, +}; + type FirefliesGraphqlEnvelope = { data?: TData; errors?: FirefliesGraphqlError[]; @@ -36,7 +47,39 @@ type FirefliesApiRequestParams = { variables?: Record; }; -export const firefliesApiRequest = async ({ +export const firefliesApiRequest = async ( + params: FirefliesApiRequestParams, +): Promise> => + performFirefliesApiRequestWithRetries({ params, attemptNumber: 1 }); + +const performFirefliesApiRequestWithRetries = async ({ + params, + attemptNumber, +}: { + params: FirefliesApiRequestParams; + attemptNumber: number; +}): Promise> => { + const result = await performFirefliesApiRequest(params); + + if ( + result.ok || + !isRetryableFirefliesApiStatus(result.status) || + attemptNumber >= FIREFLIES_API_MAX_ATTEMPTS + ) { + return result; + } + + await sleepForMilliseconds( + FIREFLIES_API_RETRY_DELAY_MILLISECONDS * attemptNumber, + ); + + return performFirefliesApiRequestWithRetries({ + params, + attemptNumber: attemptNumber + 1, + }); +}; + +const performFirefliesApiRequest = async ({ apiKey, query, variables, @@ -74,11 +117,16 @@ export const firefliesApiRequest = async ({ isDefined(envelope.errors) && envelope.errors.length > 0 ) { + const firefliesGraphqlError = envelope.errors[0]; + return { ok: false, - status: response.status, + status: + HTTP_STATUS_BY_FIREFLIES_ERROR_CODE[ + firefliesGraphqlError?.extensions?.code ?? '' + ] ?? response.status, errorMessage: `Fireflies GraphQL error: ${formatFirefliesGraphqlError( - envelope.errors[0], + firefliesGraphqlError, )}`, }; } diff --git a/packages/twenty-apps/public/fireflies/src/logic-functions/utils/is-retryable-fireflies-api-status.util.ts b/packages/twenty-apps/public/fireflies/src/logic-functions/utils/is-retryable-fireflies-api-status.util.ts new file mode 100644 index 0000000000..da3ded9bb9 --- /dev/null +++ b/packages/twenty-apps/public/fireflies/src/logic-functions/utils/is-retryable-fireflies-api-status.util.ts @@ -0,0 +1,3 @@ +// 429 is excluded: rate limits pause the sweep and continue via cursor instead. +export const isRetryableFirefliesApiStatus = (status: number): boolean => + status === 0 || status === 408 || status >= 500; diff --git a/packages/twenty-apps/public/fireflies/src/logic-functions/utils/map-fireflies-sentences-to-transcript-entries.ts b/packages/twenty-apps/public/fireflies/src/logic-functions/utils/map-fireflies-sentences-to-transcript-entries.util.ts similarity index 86% rename from packages/twenty-apps/public/fireflies/src/logic-functions/utils/map-fireflies-sentences-to-transcript-entries.ts rename to packages/twenty-apps/public/fireflies/src/logic-functions/utils/map-fireflies-sentences-to-transcript-entries.util.ts index 0710694322..1efdd39da2 100644 --- a/packages/twenty-apps/public/fireflies/src/logic-functions/utils/map-fireflies-sentences-to-transcript-entries.ts +++ b/packages/twenty-apps/public/fireflies/src/logic-functions/utils/map-fireflies-sentences-to-transcript-entries.util.ts @@ -1,20 +1,10 @@ import { isNonEmptyString } from '@sniptt/guards'; import { type FirefliesTranscriptSentence } from 'src/logic-functions/types/fireflies-transcript.type'; +import { type TranscriptEntry } from 'src/logic-functions/types/transcript-entry.type'; const UNKNOWN_SPEAKER_LABEL = 'Speaker'; -export type TranscriptEntryWord = { - text: string; - start_timestamp?: { relative: number }; - end_timestamp?: { relative: number }; -}; - -export type TranscriptEntry = { - participant: { name: string }; - words: TranscriptEntryWord[]; -}; - const isFiniteNumber = (value: number | null | undefined): value is number => typeof value === 'number' && Number.isFinite(value); diff --git a/packages/twenty-apps/public/fireflies/src/logic-functions/utils/sync-fireflies-call-to-call-recording.ts b/packages/twenty-apps/public/fireflies/src/logic-functions/utils/sync-fireflies-call-to-call-recording.ts deleted file mode 100644 index 735854cd36..0000000000 --- a/packages/twenty-apps/public/fireflies/src/logic-functions/utils/sync-fireflies-call-to-call-recording.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { isNonEmptyArray, isNonEmptyString } from '@sniptt/guards'; -import { type CoreApiClient } from 'twenty-client-sdk/core'; -import { isDefined } from 'src/utils/is-defined'; - -import { - CALL_RECORDING_REQUEST_STATUS, - CALL_RECORDING_STATUS, -} from 'src/logic-functions/constants/call-recording-status'; -import { type FirefliesTranscript } from 'src/logic-functions/types/fireflies-transcript.type'; -import { computeCallRecordingIdForFirefliesMeeting } from 'src/logic-functions/utils/compute-call-recording-id-for-fireflies-meeting'; -import { fetchFirefliesSummary } from 'src/logic-functions/utils/fetch-fireflies-summary'; -import { fetchFirefliesTranscript } from 'src/logic-functions/utils/fetch-fireflies-transcript'; -import { findMatchingCalendarEvent } from 'src/logic-functions/utils/find-matching-calendar-event'; -import { formatSummaryAsMarkdown } from 'src/logic-functions/utils/format-summary-as-markdown'; -import { mapFirefliesSentencesToTranscriptEntries } from 'src/logic-functions/utils/map-fireflies-sentences-to-transcript-entries'; -import { - type CallRecordingWriteFields, - upsertCallRecording, -} from 'src/logic-functions/utils/upsert-call-recording'; - -export type FirefliesSyncableField = 'transcript' | 'summary'; - -export type SyncFirefliesCallResult = - | { - status: 'updated'; - field: FirefliesSyncableField; - callRecordingId: string; - calendarEventId?: string; - created: boolean; - } - | { status: 'skipped'; field: FirefliesSyncableField; reason: string } - | { status: 'error'; field: FirefliesSyncableField; error: string }; - -const MINUTES_TO_MILLISECONDS = 60_000; - -const buildTimestampFields = ( - transcript: FirefliesTranscript, -): Pick => { - const dateMs = transcript.date; - - if (!isDefined(dateMs) || !Number.isFinite(dateMs)) { - return {}; - } - - const startedAt = new Date(dateMs).toISOString(); - - // Fireflies duration is in minutes. - const durationMinutes = transcript.duration; - - if (!isDefined(durationMinutes) || !Number.isFinite(durationMinutes)) { - return { startedAt }; - } - - return { - startedAt, - endedAt: new Date( - dateMs + durationMinutes * MINUTES_TO_MILLISECONDS, - ).toISOString(), - }; -}; - -export const syncFirefliesCallToCallRecording = async ({ - apiKey, - client, - transcriptId, - field, -}: { - apiKey: string; - client: CoreApiClient; - transcriptId: string; - field: FirefliesSyncableField; -}): Promise => { - const fetchResult = - field === 'transcript' - ? await fetchFirefliesTranscript({ apiKey, transcriptId }) - : await fetchFirefliesSummary({ apiKey, transcriptId }); - - if (!fetchResult.ok) { - return { status: 'error', field, error: fetchResult.errorMessage }; - } - - const firefliesTranscript = fetchResult.data; - - const fieldUpdate = buildFieldUpdate({ field, firefliesTranscript }); - - if (fieldUpdate.empty) { - return { status: 'skipped', field, reason: fieldUpdate.reason }; - } - - const match = await findMatchingCalendarEvent({ - client, - transcript: firefliesTranscript, - }); - const calendarEventId = match.matched ? match.calendarEventId : undefined; - - const callRecordingId = computeCallRecordingIdForFirefliesMeeting( - firefliesTranscript.id, - ); - - const title = firefliesTranscript.title?.trim(); - const sharedFields: CallRecordingWriteFields = { - ...(isNonEmptyString(title) ? { title } : {}), - externalRecordingId: firefliesTranscript.id, - ...buildTimestampFields(firefliesTranscript), - ...(isDefined(calendarEventId) ? { calendarEventId } : {}), - ...fieldUpdate.fields, - }; - - try { - const upsertResult = await upsertCallRecording(client, { - id: callRecordingId, - createFields: { - ...sharedFields, - recordingRequestStatus: CALL_RECORDING_REQUEST_STATUS.REQUESTED, - status: sharedFields.status ?? CALL_RECORDING_STATUS.PROCESSING, - }, - updateFields: sharedFields, - }); - - return { - status: 'updated', - field, - callRecordingId: upsertResult.callRecordingId, - calendarEventId, - created: upsertResult.created, - }; - } catch (error) { - return { - status: 'error', - field, - error: `Failed to upsert CallRecording ${callRecordingId} for Fireflies transcript ${transcriptId}: ${ - (error as Error).message - }`, - }; - } -}; - -const buildFieldUpdate = ({ - field, - firefliesTranscript, -}: { - field: FirefliesSyncableField; - firefliesTranscript: FirefliesTranscript; -}): - | { empty: false; fields: CallRecordingWriteFields } - | { empty: true; reason: string } => { - if (field === 'transcript') { - const entries = mapFirefliesSentencesToTranscriptEntries( - firefliesTranscript.sentences, - ); - - if (!isNonEmptyArray(entries)) { - return { - empty: true, - reason: - 'Fireflies returned no transcript sentences for this meeting; nothing to sync.', - }; - } - - return { - empty: false, - fields: { - transcript: entries, - status: CALL_RECORDING_STATUS.COMPLETED, - }, - }; - } - - if (!isDefined(firefliesTranscript.summary)) { - return { - empty: true, - reason: - 'Fireflies returned no summary content for this meeting; nothing to sync.', - }; - } - - return { - empty: false, - fields: { - summary: { - markdown: formatSummaryAsMarkdown(firefliesTranscript), - blocknote: null, - }, - }, - }; -}; diff --git a/packages/twenty-apps/public/fireflies/src/logic-functions/utils/sync-fireflies-call-to-call-recording.util.ts b/packages/twenty-apps/public/fireflies/src/logic-functions/utils/sync-fireflies-call-to-call-recording.util.ts new file mode 100644 index 0000000000..84612318e5 --- /dev/null +++ b/packages/twenty-apps/public/fireflies/src/logic-functions/utils/sync-fireflies-call-to-call-recording.util.ts @@ -0,0 +1,268 @@ +import { isNonEmptyArray, isNonEmptyString } from '@sniptt/guards'; +import { type CoreApiClient } from 'twenty-client-sdk/core'; +import { isDefined } from 'src/utils/is-defined'; + +import { CALL_RECORDING_REQUEST_STATUS } from 'src/logic-functions/constants/call-recording-request-status.constant'; +import { CALL_RECORDING_STATUS } from 'src/logic-functions/constants/call-recording-status.constant'; +import { findCallRecordingFieldStatesOrThrow } from 'src/logic-functions/data/find-call-recording-field-states-or-throw.util'; +import { type CallRecordingFieldState } from 'src/logic-functions/types/call-recording-field-state.type'; +import { type CallRecordingSyncFields } from 'src/logic-functions/types/call-recording-sync-fields.type'; +import { type FirefliesSyncableField } from 'src/logic-functions/types/fireflies-syncable-field.type'; +import { type FirefliesTranscript } from 'src/logic-functions/types/fireflies-transcript.type'; +import { type SyncFirefliesCallResult } from 'src/logic-functions/types/sync-fireflies-call-result.type'; +import { computeCallRecordingIdForFirefliesMeeting } from 'src/logic-functions/utils/compute-call-recording-id-for-fireflies-meeting'; +import { fetchFirefliesSummary } from 'src/logic-functions/utils/fetch-fireflies-summary'; +import { fetchFirefliesTranscript } from 'src/logic-functions/utils/fetch-fireflies-transcript'; +import { findMatchingCalendarEvent } from 'src/logic-functions/utils/find-matching-calendar-event'; +import { formatSummaryAsMarkdown } from 'src/logic-functions/utils/format-summary-as-markdown'; +import { mapFirefliesSentencesToTranscriptEntries } from 'src/logic-functions/utils/map-fireflies-sentences-to-transcript-entries.util'; +import { upsertCallRecordingOrThrow } from 'src/logic-functions/utils/upsert-call-recording-or-throw.util'; + +const MINUTES_TO_MILLISECONDS = 60_000; + +const buildTimestampFields = ( + transcript: FirefliesTranscript, +): Pick => { + const dateMs = transcript.date; + + if (!isDefined(dateMs) || !Number.isFinite(dateMs)) { + return {}; + } + + const startedAt = new Date(dateMs).toISOString(); + + // Fireflies duration is in minutes. + const durationMinutes = transcript.duration; + + if (!isDefined(durationMinutes) || !Number.isFinite(durationMinutes)) { + return { startedAt }; + } + + return { + startedAt, + endedAt: new Date( + dateMs + durationMinutes * MINUTES_TO_MILLISECONDS, + ).toISOString(), + }; +}; + +export const syncFirefliesCallToCallRecording = async ({ + apiKey, + coreApiClient, + transcriptId, + field, + callRecordingFieldState, +}: { + apiKey: string; + coreApiClient: CoreApiClient; + transcriptId: string; + field: FirefliesSyncableField; + callRecordingFieldState: CallRecordingFieldState | undefined; +}): Promise => { + if (isDefined(callRecordingFieldState)) { + const isRequestedFieldFilled = + field === 'transcript' + ? callRecordingFieldState.isTranscriptFilled + : callRecordingFieldState.isSummaryFilled; + + if (isRequestedFieldFilled) { + if ( + callRecordingFieldState.isTranscriptFilled && + callRecordingFieldState.isSummaryFilled + ) { + await completeCallRecordingIfBothFirefliesFieldsAreFilled({ + coreApiClient, + callRecordingId: + computeCallRecordingIdForFirefliesMeeting(transcriptId), + currentStatus: callRecordingFieldState.status, + }); + } + + return { + status: 'skipped', + field, + reason: `CallRecording ${field} is already populated; preserving the existing value.`, + }; + } + } + + const fetchResult = + field === 'transcript' + ? await fetchFirefliesTranscript({ apiKey, transcriptId }) + : await fetchFirefliesSummary({ apiKey, transcriptId }); + + if (!fetchResult.ok) { + return { + status: 'error', + field, + error: fetchResult.errorMessage, + httpStatus: fetchResult.status, + }; + } + + const firefliesTranscript = fetchResult.data; + + const fieldUpdate = buildFieldUpdate({ field, firefliesTranscript }); + + if (field === 'transcript' && fieldUpdate.empty) { + return { status: 'skipped', field, reason: fieldUpdate.reason }; + } + + if ( + fieldUpdate.empty && + callRecordingFieldState?.status !== CALL_RECORDING_STATUS.COMPLETED + ) { + return { status: 'skipped', field, reason: fieldUpdate.reason }; + } + + const match = await findMatchingCalendarEvent({ + client: coreApiClient, + transcript: firefliesTranscript, + }); + const calendarEventId = match.matched ? match.calendarEventId : undefined; + + const callRecordingId = computeCallRecordingIdForFirefliesMeeting( + firefliesTranscript.id, + ); + + const title = firefliesTranscript.title?.trim(); + const isCallRecordingComplete = + !fieldUpdate.empty && + (field === 'transcript' + ? (callRecordingFieldState?.isSummaryFilled ?? false) + : (callRecordingFieldState?.isTranscriptFilled ?? false)); + const targetStatus = isCallRecordingComplete + ? CALL_RECORDING_STATUS.COMPLETED + : CALL_RECORDING_STATUS.PROCESSING; + const sharedFields: CallRecordingSyncFields = { + ...(isNonEmptyString(title) ? { title } : {}), + externalRecordingId: firefliesTranscript.id, + ...buildTimestampFields(firefliesTranscript), + ...(isDefined(calendarEventId) ? { calendarEventId } : {}), + status: targetStatus, + ...(fieldUpdate.empty ? {} : fieldUpdate.fields), + }; + + try { + const upsertResult = await upsertCallRecordingOrThrow({ + coreApiClient, + callRecordingId, + createFields: { + ...sharedFields, + recordingRequestStatus: CALL_RECORDING_REQUEST_STATUS.REQUESTED, + }, + updateFields: sharedFields, + }); + + await completeCallRecordingIfBothFirefliesFieldsAreFilled({ + coreApiClient, + callRecordingId: upsertResult.callRecordingId, + currentStatus: sharedFields.status, + }); + + if (fieldUpdate.empty) { + return { status: 'skipped', field, reason: fieldUpdate.reason }; + } + + return { + status: 'updated', + field, + callRecordingId: upsertResult.callRecordingId, + calendarEventId, + created: upsertResult.created, + }; + } catch (error) { + return { + status: 'error', + field, + error: `Failed to upsert CallRecording ${callRecordingId} for Fireflies transcript ${transcriptId}: ${ + error instanceof Error ? error.message : String(error) + }`, + }; + } +}; + +const buildFieldUpdate = ({ + field, + firefliesTranscript, +}: { + field: FirefliesSyncableField; + firefliesTranscript: FirefliesTranscript; +}): + | { empty: false; fields: CallRecordingSyncFields } + | { empty: true; reason: string } => { + if (field === 'transcript') { + const entries = mapFirefliesSentencesToTranscriptEntries( + firefliesTranscript.sentences, + ); + + if (!isNonEmptyArray(entries)) { + return { + empty: true, + reason: + 'Fireflies returned no transcript sentences for this meeting; nothing to sync.', + }; + } + + return { + empty: false, + fields: { transcript: entries }, + }; + } + + if (!isDefined(firefliesTranscript.summary)) { + return { + empty: true, + reason: + 'Fireflies returned no summary content for this meeting; nothing to sync.', + }; + } + + return { + empty: false, + fields: { + summary: { + markdown: formatSummaryAsMarkdown(firefliesTranscript), + blocknote: null, + }, + }, + }; +}; + +const completeCallRecordingIfBothFirefliesFieldsAreFilled = async ({ + coreApiClient, + callRecordingId, + currentStatus, +}: { + coreApiClient: CoreApiClient; + callRecordingId: string; + currentStatus: CallRecordingSyncFields['status']; +}): Promise => { + if (currentStatus === CALL_RECORDING_STATUS.COMPLETED) { + return; + } + + const callRecordingFieldStates = await findCallRecordingFieldStatesOrThrow({ + coreApiClient, + callRecordingIds: [callRecordingId], + }); + const callRecordingFieldState = callRecordingFieldStates.get(callRecordingId); + + if ( + !isDefined(callRecordingFieldState) || + !callRecordingFieldState.isTranscriptFilled || + !callRecordingFieldState.isSummaryFilled + ) { + return; + } + + await coreApiClient.mutation({ + updateCallRecording: { + __args: { + id: callRecordingId, + data: { status: CALL_RECORDING_STATUS.COMPLETED }, + }, + id: true, + }, + }); +}; diff --git a/packages/twenty-apps/public/fireflies/src/logic-functions/utils/upsert-call-recording-or-throw.util.ts b/packages/twenty-apps/public/fireflies/src/logic-functions/utils/upsert-call-recording-or-throw.util.ts new file mode 100644 index 0000000000..f7bc884dce --- /dev/null +++ b/packages/twenty-apps/public/fireflies/src/logic-functions/utils/upsert-call-recording-or-throw.util.ts @@ -0,0 +1,108 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; +import { isDefined } from 'src/utils/is-defined'; + +import { type CallRecordingSyncFields } from 'src/logic-functions/types/call-recording-sync-fields.type'; + +type UpsertCallRecordingOrThrowResult = { + callRecordingId: string; + created: boolean; +}; + +const doesCallRecordingExist = async ({ + coreApiClient, + callRecordingId, +}: { + coreApiClient: CoreApiClient; + callRecordingId: string; +}): Promise => { + const queryResult = await coreApiClient.query({ + callRecordings: { + __args: { + filter: { id: { eq: callRecordingId } }, + first: 1, + }, + edges: { + node: { + id: true, + }, + }, + }, + }); + + const node = queryResult.callRecordings?.edges?.[0]?.node; + + return isDefined(node); +}; + +const updateCallRecordingOrThrow = async ({ + coreApiClient, + callRecordingId, + fields, +}: { + coreApiClient: CoreApiClient; + callRecordingId: string; + fields: CallRecordingSyncFields; +}): Promise => { + await coreApiClient.mutation({ + updateCallRecording: { + __args: { id: callRecordingId, data: fields }, + id: true, + }, + }); +}; + +export const upsertCallRecordingOrThrow = async ({ + coreApiClient, + callRecordingId, + createFields, + updateFields, +}: { + coreApiClient: CoreApiClient; + callRecordingId: string; + createFields: CallRecordingSyncFields; + updateFields: CallRecordingSyncFields; +}): Promise => { + const doesRecordingExist = await doesCallRecordingExist({ + coreApiClient, + callRecordingId, + }); + + if (doesRecordingExist) { + await updateCallRecordingOrThrow({ + coreApiClient, + callRecordingId, + fields: updateFields, + }); + + return { callRecordingId, created: false }; + } + + try { + await coreApiClient.mutation({ + createCallRecording: { + __args: { data: { id: callRecordingId, ...createFields } }, + id: true, + }, + }); + + return { callRecordingId, created: true }; + } catch (error) { + // A concurrent create on the same deterministic id may have won the race. + const didConcurrentCreateWin = await doesCallRecordingExist({ + coreApiClient, + callRecordingId, + }); + + if (!didConcurrentCreateWin) { + throw error; + } + + await updateCallRecordingOrThrow({ + coreApiClient, + callRecordingId, + fields: updateFields, + }); + + return { callRecordingId, created: false }; + } +}; diff --git a/packages/twenty-apps/public/fireflies/src/logic-functions/utils/upsert-call-recording.ts b/packages/twenty-apps/public/fireflies/src/logic-functions/utils/upsert-call-recording.ts deleted file mode 100644 index 5d5c9d51fe..0000000000 --- a/packages/twenty-apps/public/fireflies/src/logic-functions/utils/upsert-call-recording.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { type CoreApiClient } from 'twenty-client-sdk/core'; -import { isDefined } from 'src/utils/is-defined'; - -export type CallRecordingWriteFields = { - title?: string; - status?: string; - recordingRequestStatus?: string; - externalRecordingId?: string; - startedAt?: string; - endedAt?: string; - transcript?: unknown; - summary?: { markdown: string; blocknote: null }; - calendarEventId?: string; -}; - -type UpsertCallRecordingResult = { - callRecordingId: string; - created: boolean; -}; - -const findCallRecordingId = async ( - client: CoreApiClient, - id: string, -): Promise => { - const queryResult = await client.query({ - callRecordings: { - __args: { - filter: { id: { eq: id } }, - first: 1, - }, - edges: { - node: { - id: true, - }, - }, - }, - }); - - return queryResult.callRecordings?.edges?.[0]?.node?.id ?? undefined; -}; - -const updateCallRecording = async ( - client: CoreApiClient, - id: string, - data: CallRecordingWriteFields, -): Promise => { - await client.mutation({ - updateCallRecording: { - __args: { id, data }, - id: true, - }, - }); -}; - -export const upsertCallRecording = async ( - client: CoreApiClient, - { - id, - createFields, - updateFields, - }: { - id: string; - createFields: CallRecordingWriteFields; - updateFields: CallRecordingWriteFields; - }, -): Promise => { - const existingId = await findCallRecordingId(client, id); - - if (isDefined(existingId)) { - await updateCallRecording(client, id, updateFields); - - return { callRecordingId: id, created: false }; - } - - try { - await client.mutation({ - createCallRecording: { - __args: { data: { id, ...createFields } }, - id: true, - }, - }); - - return { callRecordingId: id, created: true }; - } catch (error) { - // A concurrent create on the same deterministic id may have won the race. - const raceWinnerId = await findCallRecordingId(client, id); - - if (!isDefined(raceWinnerId)) { - throw error; - } - - await updateCallRecording(client, id, updateFields); - - return { callRecordingId: id, created: false }; - } -}; diff --git a/packages/twenty-apps/public/fireflies/src/utils/sleep-for-milliseconds.util.ts b/packages/twenty-apps/public/fireflies/src/utils/sleep-for-milliseconds.util.ts new file mode 100644 index 0000000000..ddfad0990e --- /dev/null +++ b/packages/twenty-apps/public/fireflies/src/utils/sleep-for-milliseconds.util.ts @@ -0,0 +1,2 @@ +export const sleepForMilliseconds = (milliseconds: number): Promise => + new Promise((resolve) => setTimeout(resolve, milliseconds)); diff --git a/packages/twenty-apps/public/fireflies/yarn.lock b/packages/twenty-apps/public/fireflies/yarn.lock index f5c5189b83..9674b419ad 100644 --- a/packages/twenty-apps/public/fireflies/yarn.lock +++ b/packages/twenty-apps/public/fireflies/yarn.lock @@ -1381,6 +1381,7 @@ __metadata: typescript: "npm:^5.9.3" vite-tsconfig-paths: "npm:^4.2.1" vitest: "npm:^4.0.0" + zod: "npm:^4.1.11" languageName: unknown linkType: soft