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 <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23610?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
@@ -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",
|
||||
|
||||
+4
-7
@@ -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<string[]> => {
|
||||
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) {
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
export const CALL_RECORDING_REQUEST_STATUS = {
|
||||
REQUESTED: 'REQUESTED',
|
||||
} as const;
|
||||
-4
@@ -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;
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const FIREFLIES_API_MAX_ATTEMPTS = 3;
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const FIREFLIES_API_RETRY_DELAY_MILLISECONDS = 250;
|
||||
+52
@@ -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<Map<string, CallRecordingFieldState>> => {
|
||||
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<string, CallRecordingFieldState>();
|
||||
|
||||
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;
|
||||
};
|
||||
+173
@@ -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<void> => {};
|
||||
|
||||
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' },
|
||||
});
|
||||
};
|
||||
+77
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
+40
-8
@@ -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<SyncFirefliesCallResult, { status: 'updated' }> =>
|
||||
(
|
||||
result,
|
||||
): result is Extract<SyncFirefliesCallResult, { status: 'updated' }> =>
|
||||
result.status === 'updated',
|
||||
);
|
||||
|
||||
|
||||
+12
-5
@@ -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') {
|
||||
|
||||
+33
@@ -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);
|
||||
});
|
||||
});
|
||||
+36
@@ -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(),
|
||||
});
|
||||
+9
@@ -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;
|
||||
};
|
||||
+17
@@ -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;
|
||||
};
|
||||
+1
-1
@@ -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' }
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export type FirefliesSyncableField = 'transcript' | 'summary';
|
||||
+17
@@ -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;
|
||||
};
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export type TranscriptEntryWord = {
|
||||
text: string;
|
||||
start_timestamp?: { relative: number };
|
||||
end_timestamp?: { relative: number };
|
||||
};
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import { type TranscriptEntryWord } from 'src/logic-functions/types/transcript-entry-word.type';
|
||||
|
||||
export type TranscriptEntry = {
|
||||
participant: { name: string };
|
||||
words: TranscriptEntryWord[];
|
||||
};
|
||||
+103
@@ -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);
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -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', () => {
|
||||
|
||||
+149
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
+51
-3
@@ -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<string, number> = {
|
||||
request_timeout: 408,
|
||||
too_many_requests: 429,
|
||||
invariant_violation: 500,
|
||||
};
|
||||
|
||||
type FirefliesGraphqlEnvelope<TData> = {
|
||||
data?: TData;
|
||||
errors?: FirefliesGraphqlError[];
|
||||
@@ -36,7 +47,39 @@ type FirefliesApiRequestParams = {
|
||||
variables?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export const firefliesApiRequest = async <TData = unknown>({
|
||||
export const firefliesApiRequest = async <TData = unknown>(
|
||||
params: FirefliesApiRequestParams,
|
||||
): Promise<FirefliesApiResult<TData>> =>
|
||||
performFirefliesApiRequestWithRetries<TData>({ params, attemptNumber: 1 });
|
||||
|
||||
const performFirefliesApiRequestWithRetries = async <TData>({
|
||||
params,
|
||||
attemptNumber,
|
||||
}: {
|
||||
params: FirefliesApiRequestParams;
|
||||
attemptNumber: number;
|
||||
}): Promise<FirefliesApiResult<TData>> => {
|
||||
const result = await performFirefliesApiRequest<TData>(params);
|
||||
|
||||
if (
|
||||
result.ok ||
|
||||
!isRetryableFirefliesApiStatus(result.status) ||
|
||||
attemptNumber >= FIREFLIES_API_MAX_ATTEMPTS
|
||||
) {
|
||||
return result;
|
||||
}
|
||||
|
||||
await sleepForMilliseconds(
|
||||
FIREFLIES_API_RETRY_DELAY_MILLISECONDS * attemptNumber,
|
||||
);
|
||||
|
||||
return performFirefliesApiRequestWithRetries<TData>({
|
||||
params,
|
||||
attemptNumber: attemptNumber + 1,
|
||||
});
|
||||
};
|
||||
|
||||
const performFirefliesApiRequest = async <TData = unknown>({
|
||||
apiKey,
|
||||
query,
|
||||
variables,
|
||||
@@ -74,11 +117,16 @@ export const firefliesApiRequest = async <TData = unknown>({
|
||||
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,
|
||||
)}`,
|
||||
};
|
||||
}
|
||||
|
||||
+3
@@ -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;
|
||||
+1
-11
@@ -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);
|
||||
|
||||
-186
@@ -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<CallRecordingWriteFields, 'startedAt' | 'endedAt'> => {
|
||||
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<SyncFirefliesCallResult> => {
|
||||
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,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
+268
@@ -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<CallRecordingSyncFields, 'startedAt' | 'endedAt'> => {
|
||||
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<SyncFirefliesCallResult> => {
|
||||
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<void> => {
|
||||
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,
|
||||
},
|
||||
});
|
||||
};
|
||||
+108
@@ -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<boolean> => {
|
||||
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<void> => {
|
||||
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<UpsertCallRecordingOrThrowResult> => {
|
||||
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 };
|
||||
}
|
||||
};
|
||||
-96
@@ -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<string | undefined> => {
|
||||
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<void> => {
|
||||
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<UpsertCallRecordingResult> => {
|
||||
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 };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export const sleepForMilliseconds = (milliseconds: number): Promise<void> =>
|
||||
new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user