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:
nitin
2026-07-31 16:56:46 +05:30
committed by GitHub
parent cc9c6ad0ea
commit f8ed432e2a
32 changed files with 1177 additions and 323 deletions
@@ -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",
@@ -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) {
@@ -0,0 +1,3 @@
export const CALL_RECORDING_REQUEST_STATUS = {
REQUESTED: 'REQUESTED',
} as const;
@@ -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;
@@ -0,0 +1 @@
export const FIREFLIES_API_MAX_ATTEMPTS = 3;
@@ -0,0 +1 @@
export const FIREFLIES_API_RETRY_DELAY_MILLISECONDS = 250;
@@ -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;
};
@@ -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' },
});
};
@@ -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,
},
});
});
});
@@ -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',
);
@@ -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') {
@@ -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);
});
});
@@ -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(),
});
@@ -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;
};
@@ -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,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' }
@@ -0,0 +1 @@
export type FirefliesSyncableField = 'transcript' | 'summary';
@@ -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;
};
@@ -0,0 +1,5 @@
export type TranscriptEntryWord = {
text: string;
start_timestamp?: { relative: number };
end_timestamp?: { relative: number };
};
@@ -0,0 +1,6 @@
import { type TranscriptEntryWord } from 'src/logic-functions/types/transcript-entry-word.type';
export type TranscriptEntry = {
participant: { name: string };
words: TranscriptEntryWord[];
};
@@ -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,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', () => {
@@ -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,
},
});
});
});
@@ -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,
)}`,
};
}
@@ -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,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);
@@ -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,
},
},
};
};
@@ -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,
},
});
};
@@ -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 };
}
};
@@ -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