snapshot of https://github.com/twentyhq/twenty.git (main, 2026-09-17; blobs > 131072B omitted)
Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 3s

This commit is contained in:
upstream-mirror
2026-09-17 23:28:13 +05:00
commit e7da97194a
32688 changed files with 4208681 additions and 0 deletions

View File

@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest';
import { type FathomMediaReconciliationCandidate } from 'src/logic-functions/types/fathom-media-reconciliation-plan.type';
import { buildDisconnectedFathomMediaReconciliationPlan } from 'src/logic-functions/utils/build-disconnected-fathom-media-reconciliation-plan.util';
const buildCallRecording = (
overrides: Partial<FathomMediaReconciliationCandidate> = {},
): FathomMediaReconciliationCandidate => ({
id: 'call-recording-id',
fathomRecordingImportId: 'fathom-recording-import-id',
fathomRecordingImportUpdatedAt: '2026-09-04T12:01:00.000Z',
recordingId: '42',
hasVideo: false,
hasAudio: false,
hasTranscript: true,
hasSummary: true,
failureReason: undefined,
connectedAccountId: 'connected-account-id',
downloadId: undefined,
uploadCheckpoint: undefined,
status: 'PROCESSING',
updatedAt: '2026-09-04T12:00:00.000Z',
...overrides,
});
const buildReference = (callRecordingId: string) => ({
callRecordingId,
callRecordingUpdatedAt: '2026-09-04T12:00:00.000Z',
fathomRecordingImportId: 'fathom-recording-import-id',
fathomRecordingImportUpdatedAt: '2026-09-04T12:01:00.000Z',
});
describe('buildDisconnectedFathomMediaReconciliationPlan', () => {
it('settles missing media and completes imports only when a transcript exists', () => {
const plan = buildDisconnectedFathomMediaReconciliationPlan([
buildCallRecording({ id: 'ready-to-settle-and-complete' }),
buildCallRecording({
id: 'ready-to-settle',
status: 'COMPLETED',
}),
buildCallRecording({
id: 'ready-to-complete',
hasVideo: true,
}),
buildCallRecording({
id: 'processing-without-transcript',
hasTranscript: false,
hasAudio: true,
}),
buildCallRecording({
id: 'missing-media-without-transcript',
hasTranscript: false,
}),
]);
expect(plan).toEqual({
callRecordingsToSettle: [buildReference('ready-to-settle')],
callRecordingsToSettleAndComplete: [
buildReference('ready-to-settle-and-complete'),
],
callRecordingsToSettleAndFail: [
buildReference('missing-media-without-transcript'),
],
callRecordingsToComplete: [buildReference('ready-to-complete')],
callRecordingsToFail: [buildReference('processing-without-transcript')],
});
});
});

View File

@@ -0,0 +1,202 @@
import { describe, expect, it } from 'vitest';
import { type CallRecordingMediaState } from 'src/logic-functions/types/call-recording-media-state.type';
import { type CallRecordingSyncFields } from 'src/logic-functions/types/call-recording-sync-fields.type';
import { buildFathomCallRecordingUpsertFields } from 'src/logic-functions/utils/build-fathom-call-recording-upsert-fields.util';
const buildFields = ({
existingCallRecording,
retryMedia = false,
sharedFields = { transcript: [] },
}: {
existingCallRecording?: Partial<CallRecordingMediaState>;
retryMedia?: boolean;
sharedFields?: CallRecordingSyncFields;
} = {}) =>
buildFathomCallRecordingUpsertFields({
sharedFields,
existingCallRecording: existingCallRecording
? {
hasVideo: false,
hasAudio: false,
hasTranscript: false,
hasSummary: false,
failureReason: undefined,
downloadId: undefined,
connectedAccountId: undefined,
...existingCallRecording,
}
: undefined,
connectedAccountId: 'current-connected-account-id',
callRecordingId: 'call-recording-id',
recordingId: '123',
retryMedia,
});
describe('buildFathomCallRecordingUpsertFields', () => {
it('creates new recordings in processing while media is pending', () => {
const fields = buildFields();
expect(fields.createCallRecordingFields).toEqual({
transcript: [],
status: 'PROCESSING',
});
expect(fields.recordingImportFields).toEqual({
callRecordingId: 'call-recording-id',
recordingId: '123',
connectedAccountId: 'current-connected-account-id',
});
});
it('does not move an existing recording back to processing automatically', () => {
expect(buildFields().updateCallRecordingFields).not.toHaveProperty(
'status',
);
});
it('does not request an automatic retry for settled media', () => {
const fields = buildFields({
existingCallRecording: {
failureReason: 'download_forbidden',
connectedAccountId: 'connected-account-id',
},
});
expect(fields.isMediaDownloadRequestNeeded).toBe(false);
});
it('reopens a settled failure only for an explicit media retry', () => {
const fields = buildFields({
retryMedia: true,
existingCallRecording: {
failureReason: 'download_forbidden',
connectedAccountId: 'previous-connected-account-id',
},
});
expect(fields.updateCallRecordingFields).toEqual({
transcript: [],
status: 'PROCESSING',
});
expect(fields.recordingImportFields).toEqual({
callRecordingId: 'call-recording-id',
recordingId: '123',
connectedAccountId: 'current-connected-account-id',
mediaDownloadId: null,
mediaUploadCheckpoint: null,
mediaFailureReason: null,
mediaImportClaimedAt: null,
});
expect(fields.isMediaDownloadRequestNeeded).toBe(true);
});
it('keeps the owner of an active private Fathom download', () => {
const fields = buildFields({
existingCallRecording: {
downloadId: 'download-id',
connectedAccountId: 'current-connected-account-id',
},
});
expect(fields.recordingImportFields).not.toHaveProperty(
'connectedAccountId',
);
expect(fields.isMediaDownloadRequestNeeded).toBe(false);
});
it('replaces a download owned by another connection', () => {
const fields = buildFields({
existingCallRecording: {
downloadId: 'download-id',
connectedAccountId: 'previous-connected-account-id',
},
});
expect(fields.updateCallRecordingFields).toEqual({
transcript: [],
});
expect(fields.recordingImportFields).toEqual({
callRecordingId: 'call-recording-id',
recordingId: '123',
connectedAccountId: 'current-connected-account-id',
mediaDownloadId: null,
mediaUploadCheckpoint: null,
mediaImportClaimedAt: null,
});
expect(fields.isMediaDownloadRequestNeeded).toBe(true);
});
it.each([undefined, 'current-connected-account-id'])(
'requests missing media after an interrupted enqueue with owner %s',
(connectedAccountId) => {
const fields = buildFields({
existingCallRecording: { connectedAccountId },
});
expect(fields.isMediaDownloadRequestNeeded).toBe(true);
expect(fields.updateCallRecordingFields).not.toHaveProperty('status');
},
);
it('preserves populated transcript and summary on a repeated import', () => {
const fields = buildFields({
existingCallRecording: { hasTranscript: true, hasSummary: true },
sharedFields: {
transcript: [
{
participant: { name: 'Speaker' },
words: [{ text: 'New source transcript' }],
},
],
summary: { markdown: 'New source summary', blocknote: null },
},
});
expect(fields.updateCallRecordingFields.transcript).toBeUndefined();
expect(fields.updateCallRecordingFields.summary).toBeUndefined();
});
it('fills an empty summary without replacing a populated transcript', () => {
const fields = buildFields({
existingCallRecording: { hasTranscript: true, hasSummary: false },
sharedFields: {
transcript: [
{
participant: { name: 'Speaker' },
words: [{ text: 'New source transcript' }],
},
],
summary: { markdown: 'New source summary', blocknote: null },
},
});
expect(fields.updateCallRecordingFields.transcript).toBeUndefined();
expect(fields.updateCallRecordingFields.summary).toEqual({
markdown: 'New source summary',
blocknote: null,
});
});
it.each([
'download_forbidden',
'no_downloadable_media',
'download_expired',
'media_file_too_large',
'download_timed_out',
'connected_account_unavailable',
'completed_without_file',
])('does not automatically reopen terminal failure %s', (failureReason) => {
const fields = buildFields({
existingCallRecording: {
connectedAccountId: 'previous-connected-account-id',
failureReason,
},
});
expect(fields.isMediaDownloadRequestNeeded).toBe(false);
expect(fields.recordingImportFields).not.toHaveProperty(
'mediaFailureReason',
);
expect(fields.updateCallRecordingFields).not.toHaveProperty('status');
});
});

View File

@@ -0,0 +1,132 @@
import { describe, expect, it } from 'vitest';
import { buildFathomMediaReconciliationPlan } from 'src/logic-functions/utils/build-fathom-media-reconciliation-plan.util';
import { type FathomMediaReconciliationCandidate } from 'src/logic-functions/types/fathom-media-reconciliation-plan.type';
const buildCallRecording = (
overrides: Partial<FathomMediaReconciliationCandidate> = {},
): FathomMediaReconciliationCandidate => ({
id: 'call-recording-id',
fathomRecordingImportId: 'fathom-recording-import-id',
fathomRecordingImportUpdatedAt: '2026-09-04T12:01:00.000Z',
recordingId: '42',
hasVideo: false,
hasAudio: false,
hasTranscript: true,
hasSummary: true,
failureReason: undefined,
connectedAccountId: 'connected-account-id',
downloadId: undefined,
uploadCheckpoint: undefined,
status: 'PROCESSING',
updatedAt: '2026-09-04T12:00:00.000Z',
...overrides,
});
const buildReference = (callRecordingId: string) => ({
callRecordingId,
callRecordingUpdatedAt: '2026-09-04T12:00:00.000Z',
fathomRecordingImportId: 'fathom-recording-import-id',
fathomRecordingImportUpdatedAt: '2026-09-04T12:01:00.000Z',
});
describe('buildFathomMediaReconciliationPlan', () => {
it('completes settled recordings and groups missing imports by account', () => {
const plan = buildFathomMediaReconciliationPlan({
callRecordings: [
buildCallRecording({ id: 'video-ready', hasVideo: true }),
buildCallRecording({
id: 'failure-settled',
failureReason: 'download_forbidden',
}),
buildCallRecording({ id: 'missing-media-1' }),
buildCallRecording({ id: 'missing-media-2' }),
buildCallRecording({
id: 'download-ready-to-poll',
downloadId: 'download-id',
}),
buildCallRecording({
id: 'another-account',
connectedAccountId: 'another-connected-account-id',
}),
],
activeConnectedAccountIds: [
'connected-account-id',
'another-connected-account-id',
],
});
expect(plan).toEqual({
callRecordingsToComplete: [
buildReference('video-ready'),
buildReference('failure-settled'),
],
importGroups: [
{
connectedAccountId: 'connected-account-id',
callRecordingIdsToRequest: ['missing-media-1', 'missing-media-2'],
downloadsToPoll: [
{
callRecordingId: 'download-ready-to-poll',
downloadId: 'download-id',
},
],
},
{
connectedAccountId: 'another-connected-account-id',
callRecordingIdsToRequest: ['another-account'],
downloadsToPoll: [],
},
],
disconnectedAccountIds: [],
});
});
it('does not retry settled media while its transcript is still absent', () => {
const plan = buildFathomMediaReconciliationPlan({
callRecordings: [
buildCallRecording({ hasTranscript: false, hasAudio: true }),
],
activeConnectedAccountIds: ['connected-account-id'],
});
expect(plan).toEqual({
callRecordingsToComplete: [],
importGroups: [],
disconnectedAccountIds: [],
});
});
it('repairs missing media on a legacy completed recording', () => {
const plan = buildFathomMediaReconciliationPlan({
callRecordings: [
buildCallRecording({ status: 'COMPLETED', hasTranscript: false }),
],
activeConnectedAccountIds: ['connected-account-id'],
});
expect(plan.importGroups).toEqual([
{
connectedAccountId: 'connected-account-id',
callRecordingIdsToRequest: ['call-recording-id'],
downloadsToPoll: [],
},
]);
});
it('routes unfinished imports for disconnected accounts to cleanup', () => {
const plan = buildFathomMediaReconciliationPlan({
callRecordings: [
buildCallRecording(),
buildCallRecording({ id: 'settled-recording', hasVideo: true }),
],
activeConnectedAccountIds: [],
});
expect(plan).toEqual({
callRecordingsToComplete: [],
importGroups: [],
disconnectedAccountIds: ['connected-account-id'],
});
});
});

View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { computeCallRecordingIdForFathomMeeting } from 'src/logic-functions/utils/compute-call-recording-id-for-fathom-meeting.util';
describe('computeCallRecordingIdForFathomMeeting', () => {
it('returns one stable UUID for a Fathom recording', () => {
const firstId = computeCallRecordingIdForFathomMeeting(123456789);
const secondId = computeCallRecordingIdForFathomMeeting(123456789);
expect(firstId).toBe(secondId);
expect(firstId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
);
});
it('uses a different UUID for a different recording', () => {
expect(computeCallRecordingIdForFathomMeeting(1)).not.toBe(
computeCallRecordingIdForFathomMeeting(2),
);
});
});

View File

@@ -0,0 +1,226 @@
import { type Meeting } from 'fathom-typescript/sdk/models/shared';
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { describe, expect, it, vi } from 'vitest';
import { MAX_CALENDAR_EVENT_PAGES } from 'src/constants/fathom.constant';
import { findMatchingCalendarEvent } from 'src/logic-functions/utils/find-matching-calendar-event.util';
const buildMeeting = (overrides: Partial<Meeting> = {}): Meeting => ({
title: 'Customer call',
meetingTitle: 'Customer call',
meetingType: null,
recordingId: 123,
url: 'https://fathom.video/calls/123',
meetingUrl: 'https://meet.google.com/abc-defg-hij',
shareUrl: 'https://fathom.video/share/123',
createdAt: new Date('2026-01-01T10:00:00.000Z'),
scheduledStartTime: new Date('2026-01-01T10:00:00.000Z'),
scheduledEndTime: new Date('2026-01-01T10:30:00.000Z'),
recordingStartTime: new Date('2026-01-01T10:00:10.000Z'),
recordingEndTime: new Date('2026-01-01T10:29:00.000Z'),
calendarInviteesDomainsType: 'one_or_more_external',
sharedWith: 'no_teams',
transcriptLanguage: 'en',
calendarInvitees: [],
recordedBy: {
name: 'Ada',
email: 'ada@example.com',
emailDomain: 'example.com',
team: null,
},
...overrides,
});
type CalendarEventCandidateFixture = {
id: string;
startsAt: string;
conferenceLink: { primaryLinkUrl: string };
};
const buildPage = (
candidates: CalendarEventCandidateFixture[],
pageInfo: { hasNextPage: boolean; endCursor: string | null } = {
hasNextPage: false,
endCursor: null,
},
) => ({
calendarEvents: {
edges: candidates.map((candidate) => ({ node: candidate })),
pageInfo,
},
});
const buildCoreApiClient = (
candidates: CalendarEventCandidateFixture[],
): Pick<CoreApiClient, 'query'> => ({
query: vi.fn().mockResolvedValue(buildPage(candidates)),
});
const EXACT_EVENT: CalendarEventCandidateFixture = {
id: 'exact-event',
startsAt: '2026-01-01T10:00:00.000Z',
conferenceLink: { primaryLinkUrl: 'https://meet.google.com/abc-defg-hij' },
};
describe('findMatchingCalendarEvent', () => {
it('queries live events in a five minute window around the scheduled start', async () => {
const coreApiClient = buildCoreApiClient([]);
await findMatchingCalendarEvent({ coreApiClient, meeting: buildMeeting() });
expect(coreApiClient.query).toHaveBeenCalledWith(
expect.objectContaining({
calendarEvents: expect.objectContaining({
__args: expect.objectContaining({
filter: {
and: [
{ startsAt: { gte: '2026-01-01T09:55:00.000Z' } },
{ startsAt: { lte: '2026-01-01T10:05:00.000Z' } },
{ isCanceled: { eq: false } },
],
},
}),
}),
}),
);
});
it('matches an exact meeting URL to the nearest scheduled event', async () => {
const calendarEventId = await findMatchingCalendarEvent({
coreApiClient: buildCoreApiClient([
{
id: 'later-event',
startsAt: '2026-01-01T10:03:00.000Z',
conferenceLink: {
primaryLinkUrl: 'https://meet.google.com/abc-defg-hij/',
},
},
EXACT_EVENT,
]),
meeting: buildMeeting(),
});
expect(calendarEventId).toBe('exact-event');
});
it('ignores www, case, query strings, fragments and trailing slashes when comparing URLs', async () => {
const calendarEventId = await findMatchingCalendarEvent({
coreApiClient: buildCoreApiClient([
{
...EXACT_EVENT,
conferenceLink: {
primaryLinkUrl:
'https://WWW.meet.google.com/abc-defg-hij//?authuser=1#details',
},
},
]),
meeting: buildMeeting({
meetingUrl: 'https://meet.google.com/abc-defg-hij?hs=122',
}),
});
expect(calendarEventId).toBe('exact-event');
});
it('does not guess when two reused-link events are equally close', async () => {
const calendarEventId = await findMatchingCalendarEvent({
coreApiClient: buildCoreApiClient([
{
...EXACT_EVENT,
id: 'before-event',
startsAt: '2026-01-01T09:59:00.000Z',
},
{
...EXACT_EVENT,
id: 'after-event',
startsAt: '2026-01-01T10:01:00.000Z',
},
]),
meeting: buildMeeting(),
});
expect(calendarEventId).toBeUndefined();
});
it('matches an event that only appears on a later page', async () => {
const query = vi
.fn()
.mockResolvedValueOnce(
buildPage(
[
{
id: 'unrelated-event',
startsAt: '2026-01-01T10:00:00.000Z',
conferenceLink: {
primaryLinkUrl: 'https://meet.google.com/zzz-zzzz-zzz',
},
},
],
{ hasNextPage: true, endCursor: 'cursor-1' },
),
)
.mockResolvedValueOnce(
buildPage([{ ...EXACT_EVENT, id: 'paged-event' }]),
);
expect(
await findMatchingCalendarEvent({
coreApiClient: { query },
meeting: buildMeeting(),
}),
).toBe('paged-event');
expect(query).toHaveBeenCalledTimes(2);
expect(query).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
calendarEvents: expect.objectContaining({
__args: expect.objectContaining({ after: 'cursor-1' }),
}),
}),
);
});
it('does not guess from a window it could not read to the end', async () => {
const query = vi
.fn()
.mockResolvedValue(
buildPage([EXACT_EVENT], { hasNextPage: true, endCursor: 'cursor-1' }),
);
expect(
await findMatchingCalendarEvent({
coreApiClient: { query },
meeting: buildMeeting(),
}),
).toBeUndefined();
expect(query).toHaveBeenCalledTimes(MAX_CALENDAR_EVENT_PAGES);
});
it('does not guess when a page claims more results but carries no cursor', async () => {
const query = vi
.fn()
.mockResolvedValue(
buildPage([EXACT_EVENT], { hasNextPage: true, endCursor: null }),
);
expect(
await findMatchingCalendarEvent({
coreApiClient: { query },
meeting: buildMeeting(),
}),
).toBeUndefined();
expect(query).toHaveBeenCalledTimes(1);
});
it('does not query CalendarEvents without Fathom meeting_url', async () => {
const coreApiClient = buildCoreApiClient([]);
expect(
await findMatchingCalendarEvent({
coreApiClient,
meeting: buildMeeting({ meetingUrl: null }),
}),
).toBeUndefined();
expect(coreApiClient.query).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import { formatFathomSummary } from 'src/logic-functions/utils/format-fathom-summary.util';
describe('formatFathomSummary', () => {
it('combines the Fathom summary and action items', () => {
expect(
formatFathomSummary({
summaryMarkdown: '## Summary\n\nPricing was discussed.',
actionItems: [
{
description: 'Send the proposal',
userGenerated: false,
completed: false,
recordingTimestamp: '00:10:45',
recordingPlaybackUrl: 'https://fathom.video/calls/1?timestamp=645',
assignee: { name: 'Ada', email: 'ada@example.com', team: null },
},
],
}),
).toBe(
'## Summary\n\nPricing was discussed.\n\n## Action items\n\n- [ ] Send the proposal — Ada',
);
});
it('returns action items when no summary is available', () => {
expect(
formatFathomSummary({
summaryMarkdown: null,
actionItems: [
{
description: 'Follow up',
userGenerated: true,
completed: true,
recordingTimestamp: '00:01:00',
recordingPlaybackUrl: 'https://fathom.video/calls/1?timestamp=60',
assignee: { name: null, email: null, team: null },
},
],
}),
).toBe('## Action items\n\n- [x] Follow up');
});
});

View File

@@ -0,0 +1,60 @@
import { type RecordingDownload } from 'fathom-typescript/sdk/models/shared';
import { describe, expect, it } from 'vitest';
import { getFathomMediaDownloadFailureReason } from 'src/logic-functions/utils/get-fathom-media-download-failure-reason.util';
const DOWNLOAD_FILE = {
url: 'https://example.com/recording',
contentType: 'video/mp4',
fileSizeBytes: 1_024,
expiresAt: new Date('2026-09-06T12:00:00.000Z'),
};
describe('getFathomMediaDownloadFailureReason', () => {
it.each<{
description: string;
download: Pick<
RecordingDownload,
'status' | 'failureReason' | 'video' | 'audio'
>;
expectedReason: string | undefined;
}>([
{
description: 'processing without files remains pending',
download: { status: 'processing' },
expectedReason: undefined,
},
{
description: 'completed video is importable',
download: { status: 'completed', video: DOWNLOAD_FILE },
expectedReason: undefined,
},
{
description: 'completed audio is importable',
download: { status: 'completed', audio: DOWNLOAD_FILE },
expectedReason: undefined,
},
{
description: 'expired downloads cannot reuse an expired file',
download: { status: 'expired', video: DOWNLOAD_FILE },
expectedReason: 'download_expired',
},
{
description: 'failed downloads retain the provider reason',
download: { status: 'failed', failureReason: 'generation_timeout' },
expectedReason: 'generation_timeout',
},
{
description: 'failed downloads without a reason use the fallback',
download: { status: 'failed' },
expectedReason: 'generation_failed',
},
{
description: 'completed downloads without media are unavailable',
download: { status: 'completed' },
expectedReason: 'completed_without_file',
},
])('$description', ({ download, expectedReason }) => {
expect(getFathomMediaDownloadFailureReason(download)).toBe(expectedReason);
});
});

View File

@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import {
FATHOM_MEDIA_DOWNLOAD_INITIAL_POLL_DELAY_MILLISECONDS,
FATHOM_MEDIA_DOWNLOAD_MAX_POLL_ATTEMPTS,
FATHOM_MEDIA_DOWNLOAD_MAX_POLL_DELAY_MILLISECONDS,
} from 'src/constants/fathom.constant';
import { getFathomMediaDownloadPollDelay } from 'src/logic-functions/utils/get-fathom-media-download-poll-delay.util';
describe('getFathomMediaDownloadPollDelay', () => {
it('starts at the initial delay and grows with each attempt', () => {
expect(getFathomMediaDownloadPollDelay(0)).toBe(
FATHOM_MEDIA_DOWNLOAD_INITIAL_POLL_DELAY_MILLISECONDS,
);
expect(getFathomMediaDownloadPollDelay(1)).toBeGreaterThan(
getFathomMediaDownloadPollDelay(0),
);
expect(getFathomMediaDownloadPollDelay(2)).toBeGreaterThan(
getFathomMediaDownloadPollDelay(1),
);
});
it('caps the delay so a stalled download keeps polling on a fixed cadence', () => {
expect(
getFathomMediaDownloadPollDelay(FATHOM_MEDIA_DOWNLOAD_MAX_POLL_ATTEMPTS),
).toBe(FATHOM_MEDIA_DOWNLOAD_MAX_POLL_DELAY_MILLISECONDS);
});
});

View File

@@ -0,0 +1,29 @@
import { FathomError } from 'fathom-typescript/sdk/models/errors';
import { describe, expect, it } from 'vitest';
import { getFathomMediaFailureReasonForError } from 'src/logic-functions/utils/get-fathom-media-failure-reason-for-error.util';
describe('getFathomMediaFailureReasonForError', () => {
it.each([
[403, 'download_forbidden'],
[422, 'no_downloadable_media'],
[404, undefined],
[429, undefined],
[503, undefined],
])('classifies Fathom status %i as %s', (status, expectedReason) => {
const error = new FathomError('Download request failed', {
request: new Request('https://api.fathom.ai/external/v1/recordings/1'),
response: new Response(null, { status }),
body: '',
});
expect(getFathomMediaFailureReasonForError(error)).toBe(expectedReason);
});
it.each([undefined, null, new Error('Upload failed'), { statusCode: 403 }])(
'does not classify unrelated errors: %s',
(error) => {
expect(getFathomMediaFailureReasonForError(error)).toBeUndefined();
},
);
});

View File

@@ -0,0 +1,62 @@
import { FathomError } from 'fathom-typescript/sdk/models/errors';
import { describe, expect, it } from 'vitest';
import { getFathomRetryAfterDelay } from 'src/logic-functions/utils/get-fathom-retry-after-delay.util';
const NOW = new Date('2026-09-05T12:00:00.000Z');
describe('getFathomRetryAfterDelay', () => {
it.each([
['120', 120_000],
['0', 0],
['1.5', 1_500],
[' 90 ', 90_000],
['Sat, 05 Sep 2026 12:02:00 GMT', 120_000],
['Sat, 05 Sep 2026 12:00:00 GMT', 0],
['Sat, 05 Sep 2026 11:59:00 GMT', 60_000],
[undefined, 60_000],
['', 60_000],
[' ', 60_000],
['invalid', 60_000],
['-1', 60_000],
['Infinity', 60_000],
['NaN', 60_000],
['1e309', 60_000],
['1e308', 60_000],
])(
'delays retry-after %s by %i milliseconds',
(retryAfter, expectedDelay) => {
const error = new FathomError('Rate limit exceeded', {
request: new Request('https://api.fathom.ai/external/v1/meetings'),
response: new Response(null, {
status: 429,
headers:
retryAfter === undefined ? {} : { 'retry-after': retryAfter },
}),
body: '',
});
expect(getFathomRetryAfterDelay({ error, now: NOW })).toBe(expectedDelay);
},
);
it('does not classify other Fathom errors as rate limits', () => {
const error = new FathomError('Service unavailable', {
request: new Request('https://api.fathom.ai/external/v1/meetings'),
response: new Response(null, {
status: 503,
headers: { 'retry-after': '120' },
}),
body: '',
});
expect(getFathomRetryAfterDelay({ error, now: NOW })).toBeUndefined();
});
it.each([undefined, null, new Error('Request failed'), { statusCode: 429 }])(
'ignores errors without a Fathom rate-limit response: %s',
(error) => {
expect(getFathomRetryAfterDelay({ error, now: NOW })).toBeUndefined();
},
);
});

View File

@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { getFathomWebhookDestinationUrl } from 'src/logic-functions/utils/get-fathom-webhook-destination-url.util';
describe('getFathomWebhookDestinationUrl', () => {
it('builds the server route with the connection identifier', () => {
expect(
getFathomWebhookDestinationUrl({
apiUrl: 'https://twenty.example.com',
connectedAccountId: 'connection-id',
}),
).toBe(
'https://twenty.example.com/webhooks/server/72b52885-e1ba-419f-8e2e-052700f2c9f2?connectionId=connection-id',
);
});
it('preserves a base path on the API URL', () => {
expect(
getFathomWebhookDestinationUrl({
apiUrl: 'https://twenty.example.com/api/',
connectedAccountId: 'connection-id',
}),
).toBe(
'https://twenty.example.com/api/webhooks/server/72b52885-e1ba-419f-8e2e-052700f2c9f2?connectionId=connection-id',
);
});
});

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import { isFathomCallRecordingImportComplete } from 'src/logic-functions/utils/is-fathom-call-recording-import-complete.util';
describe('isFathomCallRecordingImportComplete', () => {
it.each([
{ hasVideo: true, hasAudio: false, failureReason: undefined },
{ hasVideo: false, hasAudio: true, failureReason: undefined },
{
hasVideo: false,
hasAudio: false,
failureReason: 'no_downloadable_media',
},
])('completes when transcript and media are terminal', (mediaState) => {
expect(
isFathomCallRecordingImportComplete({
hasTranscript: true,
...mediaState,
}),
).toBe(true);
});
it('waits for media after the transcript arrives', () => {
expect(
isFathomCallRecordingImportComplete({
hasTranscript: true,
hasVideo: false,
hasAudio: false,
failureReason: undefined,
}),
).toBe(false);
});
it('waits for the transcript after media arrives', () => {
expect(
isFathomCallRecordingImportComplete({
hasTranscript: false,
hasVideo: true,
hasAudio: false,
failureReason: undefined,
}),
).toBe(false);
});
});

View File

@@ -0,0 +1,47 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { listFathomConnectionsForRequest } from 'src/logic-functions/utils/list-fathom-connections-for-request.util';
const sdkMocks = vi.hoisted(() => ({
listConnections: vi.fn(),
}));
vi.mock('twenty-sdk/logic-function', async (importOriginal) => ({
...(await importOriginal<typeof import('twenty-sdk/logic-function')>()),
listConnections: sdkMocks.listConnections,
}));
const buildConnection = (id: string, userWorkspaceId: string) => ({
id,
visibility: 'user',
userWorkspaceId,
accessToken: `token-${id}`,
});
const CONNECTIONS = [
buildConnection('connection-1', 'user-workspace-1'),
buildConnection('connection-2', 'user-workspace-2'),
];
describe('listFathomConnectionsForRequest', () => {
beforeEach(() => {
vi.resetAllMocks();
sdkMocks.listConnections.mockResolvedValue(CONNECTIONS);
});
it('returns every connected account for a run with nobody behind it', async () => {
const sharedConnection = {
...buildConnection('connection-shared', 'user-workspace-1'),
visibility: 'workspace',
};
sdkMocks.listConnections.mockResolvedValue([
sharedConnection,
...CONNECTIONS,
]);
expect(
await listFathomConnectionsForRequest({ userWorkspaceId: null }),
).toEqual([sharedConnection, ...CONNECTIONS]);
});
});

View File

@@ -0,0 +1,37 @@
import { describe, expect, it, vi } from 'vitest';
import { buildFathomMeeting } from 'src/__tests__/utils/build-fathom-meeting.util';
import { buildFathomMeetingPages } from 'src/__tests__/utils/build-fathom-meeting-pages.util';
import { MAX_FATHOM_MEETING_PAGES } from 'src/constants/fathom.constant';
import { listFathomMeetings } from 'src/logic-functions/utils/list-fathom-meetings.util';
describe('listFathomMeetings', () => {
it('stops paging as soon as the caller is satisfied', async () => {
const listMeetings = vi.fn(
buildFathomMeetingPages([
[buildFathomMeeting({ recordingId: 1 })],
[buildFathomMeeting({ recordingId: 2 })],
]),
);
const meetings = await listFathomMeetings({
fathomClient: { listMeetings },
stopWhen: (listedMeetings) =>
listedMeetings.some((meeting) => meeting.recordingId === 1),
});
expect(meetings.map((meeting) => meeting.recordingId)).toEqual([1]);
expect(listMeetings).toHaveBeenCalledTimes(1);
});
it('gives up instead of walking an unbounded history', async () => {
const listMeetings = vi.fn(async () => ({
result: { items: [], limit: null, nextCursor: 'more' },
}));
await expect(
listFathomMeetings({ fathomClient: { listMeetings } }),
).rejects.toThrow(`exceeded ${MAX_FATHOM_MEETING_PAGES} pages`);
expect(listMeetings).toHaveBeenCalledTimes(MAX_FATHOM_MEETING_PAGES);
});
});

View File

@@ -0,0 +1,113 @@
import { describe, expect, it } from 'vitest';
import { mapCallRecordingMediaState } from 'src/logic-functions/utils/map-call-recording-media-state.util';
describe('mapCallRecordingMediaState', () => {
it('maps persisted media state and a valid upload checkpoint', () => {
expect(
mapCallRecordingMediaState({
id: 'call-recording-id',
updatedAt: '2026-09-05T00:00:00.000Z',
video: [{ fileId: 'video-file-id' }],
audio: [{ fileId: null }],
fathomRecordingImports: {
edges: [
{
node: {
id: 'fathom-recording-import-id',
updatedAt: '2026-09-05T00:01:00.000Z',
recordingId: 'recording-id',
mediaFailureReason: 'failure-reason',
connectedAccountId: 'connected-account-id',
mediaDownloadId: 'download-id',
mediaImportClaimedAt: '2026-09-05T00:00:30.000Z',
mediaUploadCheckpoint: {
downloadId: 'download-id',
fileId: 'video-file-id',
kind: 'video',
},
},
},
],
},
transcript: [{}],
summary: { markdown: 'Summary', blocknote: null },
}),
).toEqual({
id: 'call-recording-id',
updatedAt: '2026-09-05T00:00:00.000Z',
fathomRecordingImportId: 'fathom-recording-import-id',
fathomRecordingImportUpdatedAt: '2026-09-05T00:01:00.000Z',
recordingId: 'recording-id',
hasVideo: true,
hasAudio: false,
hasTranscript: true,
hasSummary: true,
failureReason: 'failure-reason',
connectedAccountId: 'connected-account-id',
downloadId: 'download-id',
uploadCheckpoint: {
downloadId: 'download-id',
fileId: 'video-file-id',
kind: 'video',
},
});
});
it('normalizes empty media fields and rejects a malformed checkpoint', () => {
expect(
mapCallRecordingMediaState({
id: 'call-recording-id',
updatedAt: '2026-09-05T00:00:00.000Z',
video: [{ fileId: '' }, {}, { fileId: null }],
audio: null,
fathomRecordingImports: {
edges: [
{
node: {
id: '',
updatedAt: '',
recordingId: '',
mediaFailureReason: '',
connectedAccountId: null,
mediaDownloadId: null,
mediaUploadCheckpoint: {
downloadId: 'download-id',
fileId: '',
kind: 'video',
},
},
},
],
},
transcript: {},
summary: { markdown: '', blocknote: null },
}),
).toEqual({
id: 'call-recording-id',
updatedAt: '2026-09-05T00:00:00.000Z',
fathomRecordingImportId: undefined,
fathomRecordingImportUpdatedAt: undefined,
recordingId: undefined,
hasVideo: false,
hasAudio: false,
hasTranscript: false,
hasSummary: false,
failureReason: undefined,
connectedAccountId: undefined,
downloadId: undefined,
uploadCheckpoint: undefined,
});
});
it('detects summary content stored as blocknote', () => {
const mediaState = mapCallRecordingMediaState({
id: 'call-recording-id',
updatedAt: '2026-09-05T00:00:00.000Z',
fathomRecordingImports: null,
summary: { markdown: null, blocknote: {} },
});
expect(mediaState.hasSummary).toBe(true);
});
});

View File

@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest';
import { mapFathomTranscriptToEntries } from 'src/logic-functions/utils/map-fathom-transcript-to-entries.util';
describe('mapFathomTranscriptToEntries', () => {
it('maps Fathom utterances to CallRecording transcript entries', () => {
expect(
mapFathomTranscriptToEntries([
{
speaker: { displayName: 'Ada' },
text: 'Ship the integration.',
timestamp: '01:02:03',
},
]),
).toEqual([
{
participant: { name: 'Ada' },
words: [
{
text: 'Ship the integration.',
start_timestamp: { relative: 3723 },
},
],
},
]);
});
it('drops empty utterances and tolerates an invalid timestamp', () => {
expect(
mapFathomTranscriptToEntries([
{
speaker: { displayName: '' },
text: ' ',
timestamp: '00:00:01',
},
{
speaker: { displayName: '' },
text: 'Hello',
timestamp: 'invalid',
},
{
speaker: { displayName: 'Ada' },
text: 'Out of range',
timestamp: '00:75:00',
},
]),
).toEqual([
{
participant: { name: 'Speaker' },
words: [{ text: 'Hello' }],
},
{
participant: { name: 'Ada' },
words: [{ text: 'Out of range' }],
},
]);
});
});

View File

@@ -0,0 +1,54 @@
import { createServer, type Server } from 'node:http';
import { afterEach, describe, expect, it } from 'vitest';
import { openFathomMediaDownload } from 'src/logic-functions/utils/open-fathom-media-download.util';
const openServers: Server[] = [];
const startEmptyDownloadServer = async (): Promise<string> => {
const server = createServer((_request, response) => {
response.writeHead(200, { 'content-length': '0' });
response.end();
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
openServers.push(server);
const address = server.address();
if (address === null || typeof address === 'string') {
throw new Error('Download test server did not bind a TCP port');
}
return `http://127.0.0.1:${address.port}/download`;
};
afterEach(async () => {
await Promise.all(
openServers
.splice(0)
.map(
async (server) =>
await new Promise<void>((resolve, reject) =>
server.close((error) =>
error === undefined ? resolve() : reject(error),
),
),
),
);
});
describe('openFathomMediaDownload', () => {
it('reports an empty provider download without opening an upload', async () => {
const downloadUrl = await startEmptyDownloadServer();
await expect(
openFathomMediaDownload({
callRecordingId: 'call-recording-id',
fileName: 'video.mp4',
downloadFile: { url: downloadUrl },
}),
).resolves.toEqual({ outcome: 'empty' });
});
});

View File

@@ -0,0 +1,102 @@
import { describe, expect, it } from 'vitest';
import { parseFathomMediaReconciliationPage } from 'src/logic-functions/utils/parse-fathom-media-reconciliation-page.util';
describe('parseFathomMediaReconciliationPage', () => {
it.each([{}, { callRecordings: null }])(
'returns an empty terminal page for an absent connection: %j',
(queryResult) => {
expect(parseFathomMediaReconciliationPage(queryResult)).toEqual({
callRecordings: [],
hasNextPage: false,
});
},
);
it.each([false, true])(
'preserves the continuation flag %s and normalizes reconciliation state',
(hasNextPage) => {
expect(
parseFathomMediaReconciliationPage({
callRecordings: {
pageInfo: { hasNextPage },
edges: [
{
node: {
id: 'call-recording-id',
updatedAt: '2026-09-05T00:00:00.000Z',
status: 'PROCESSING',
fathomRecordingImports: {
edges: [
{
node: {
id: 'fathom-recording-import-id',
updatedAt: '2026-09-05T00:01:00.000Z',
recordingId: '42',
connectedAccountId: 'connected-account-id',
mediaDownloadId: 'download-id',
mediaFailureReason: '',
mediaUploadCheckpoint: {
downloadId: 'download-id',
fileId: 'audio-file-id',
kind: 'audio',
},
},
},
],
},
video: [],
audio: [{ fileId: 'audio-file-id' }],
transcript: [],
},
},
],
},
}),
).toEqual({
callRecordings: [
{
id: 'call-recording-id',
updatedAt: '2026-09-05T00:00:00.000Z',
status: 'PROCESSING',
fathomRecordingImportId: 'fathom-recording-import-id',
fathomRecordingImportUpdatedAt: '2026-09-05T00:01:00.000Z',
recordingId: '42',
connectedAccountId: 'connected-account-id',
downloadId: 'download-id',
failureReason: undefined,
hasVideo: false,
hasAudio: true,
hasTranscript: false,
hasSummary: false,
uploadCheckpoint: {
downloadId: 'download-id',
fileId: 'audio-file-id',
kind: 'audio',
},
},
],
hasNextPage,
});
},
);
it('rejects malformed pages instead of treating them as completed runs', () => {
expect(() =>
parseFathomMediaReconciliationPage({
callRecordings: { edges: [], pageInfo: {} },
}),
).toThrow();
});
it('rejects a recording without its optimistic concurrency timestamp', () => {
expect(() =>
parseFathomMediaReconciliationPage({
callRecordings: {
pageInfo: { hasNextPage: false },
edges: [{ node: { id: 'call-recording-id', status: 'PROCESSING' } }],
},
}),
).toThrow();
});
});

View File

@@ -0,0 +1,125 @@
import { createServer, type Server } from 'node:http';
import { afterEach, describe, expect, it } from 'vitest';
import { putFathomMediaBodyToUploadTarget } from 'src/logic-functions/utils/put-fathom-media-body-to-upload-target.util';
const openServers: Server[] = [];
const startUploadServer = async ({
statusCode,
uploadedChunks,
receivedHeaders,
}: {
statusCode: number;
uploadedChunks?: Buffer[];
receivedHeaders?: Record<string, string | string[] | undefined>;
}): Promise<string> => {
const server = createServer((request, response) => {
if (receivedHeaders !== undefined) {
Object.assign(receivedHeaders, request.headers);
}
request.on('data', (chunk: Buffer) => uploadedChunks?.push(chunk));
request.on('end', () => {
response.writeHead(statusCode);
response.end();
});
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
openServers.push(server);
const address = server.address();
if (address === null || typeof address === 'string') {
throw new Error('Upload test server did not bind a TCP port');
}
return `http://127.0.0.1:${address.port}/upload`;
};
const buildMediaBody = (bytes: number[]): ReadableStream<Uint8Array> =>
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(Uint8Array.from(bytes));
controller.close();
},
});
afterEach(async () => {
await Promise.all(
openServers
.splice(0)
.map(
async (server) =>
await new Promise<void>((resolve, reject) =>
server.close((error) =>
error === undefined ? resolve() : reject(error),
),
),
),
);
});
describe('putFathomMediaBodyToUploadTarget', () => {
it('streams the media body to an upload target with its declared headers', async () => {
const uploadedChunks: Buffer[] = [];
const receivedHeaders: Record<string, string | string[] | undefined> = {};
const uploadUrl = await startUploadServer({
statusCode: 200,
uploadedChunks,
receivedHeaders,
});
await putFathomMediaBodyToUploadTarget({
callRecordingId: 'call-recording-id',
fileName: 'video.mp4',
mediaDownloadBody: buildMediaBody([1, 2, 3, 4]),
sizeBytes: 4,
uploadTarget: { uploadUrl, contentType: 'video/mp4' },
});
expect(receivedHeaders['content-type']).toBe('video/mp4');
expect(receivedHeaders['content-length']).toBe('4');
expect(Buffer.concat(uploadedChunks)).toEqual(Buffer.from([1, 2, 3, 4]));
});
it('rejects a failed storage response', async () => {
const uploadUrl = await startUploadServer({ statusCode: 500 });
await expect(
putFathomMediaBodyToUploadTarget({
callRecordingId: 'call-recording-id',
fileName: 'video.mp4',
mediaDownloadBody: buildMediaBody([1, 2, 3, 4]),
sizeBytes: 4,
uploadTarget: { uploadUrl, contentType: 'video/mp4' },
}),
).rejects.toThrow('upload of video.mp4 failed with status 500');
});
it('cancels the media body when the upload request cannot be created', async () => {
let isCancelled = false;
const mediaDownloadBody = new ReadableStream<Uint8Array>({
cancel() {
isCancelled = true;
},
});
await expect(
putFathomMediaBodyToUploadTarget({
callRecordingId: 'call-recording-id',
fileName: 'video.mp4',
mediaDownloadBody,
sizeBytes: 4,
uploadTarget: {
uploadUrl: 'not-a-valid-upload-url',
contentType: 'video/mp4',
},
}),
).rejects.toThrow();
expect(isCancelled).toBe(true);
});
});

View File

@@ -0,0 +1,98 @@
import { type RecordingDownload } from 'fathom-typescript/sdk/models/shared';
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { FATHOM_MEDIA_FAILURE_REASON } from 'src/constants/fathom-media-failure-reason.constant';
import { type FathomMediaUploadCheckpoint } from 'src/logic-functions/types/fathom-media-upload-checkpoint.type';
import { type FathomMediaWriteContext } from 'src/logic-functions/types/fathom-media-write-context.type';
import { completeFathomMediaFileUpload } from 'src/logic-functions/utils/complete-fathom-media-file-upload.util';
import { getFathomMediaDownloadFailureReason } from 'src/logic-functions/utils/get-fathom-media-download-failure-reason.util';
import { importFathomMediaFile } from 'src/logic-functions/utils/import-fathom-media-file.util';
import { recordFathomMediaFailure } from 'src/logic-functions/utils/record-fathom-media-failure.util';
import { settleCallRecordingMedia } from 'src/logic-functions/utils/settle-call-recording-media.util';
import { isDefined } from 'src/utils/is-defined';
export const applyFathomMediaDownload = async ({
coreApiClient,
callRecordingId,
writeContext,
uploadCheckpoint,
download,
}: {
coreApiClient: Pick<CoreApiClient, 'query' | 'mutation'>;
callRecordingId: string;
writeContext: FathomMediaWriteContext & { downloadId: string };
uploadCheckpoint: FathomMediaUploadCheckpoint | undefined;
download: Pick<
RecordingDownload,
'status' | 'failureReason' | 'video' | 'audio'
>;
}): Promise<'imported' | 'pending' | 'unavailable' | 'stale'> => {
const updateOptions = { coreApiClient, callRecordingId, writeContext };
if (uploadCheckpoint?.downloadId === writeContext.downloadId) {
await completeFathomMediaFileUpload({ fileId: uploadCheckpoint.fileId });
const isApplied = await settleCallRecordingMedia({
...updateOptions,
fields: {
[uploadCheckpoint.kind]: [
{
fileId: uploadCheckpoint.fileId,
label:
uploadCheckpoint.kind === 'video' ? 'video.mp4' : 'audio.mp3',
},
],
mediaFailureReason: null,
},
});
return isApplied ? 'imported' : 'stale';
}
if (download.status === 'processing') {
return 'pending';
}
const downloadFile = download.video ?? download.audio;
const failureReason = getFathomMediaDownloadFailureReason(download);
if (isDefined(failureReason) || !isDefined(downloadFile)) {
const isApplied = await recordFathomMediaFailure({
...updateOptions,
reason:
failureReason ?? FATHOM_MEDIA_FAILURE_REASON.COMPLETED_WITHOUT_FILE,
});
return isApplied ? 'unavailable' : 'stale';
}
const kind = isDefined(download.video) ? 'video' : 'audio';
const result = await importFathomMediaFile({
...updateOptions,
downloadId: writeContext.downloadId,
kind,
downloadFile,
});
if (result.outcome === 'stale') {
return 'stale';
}
if (result.outcome !== 'imported') {
const isApplied = await recordFathomMediaFailure({
...updateOptions,
reason:
result.outcome === 'too-large'
? FATHOM_MEDIA_FAILURE_REASON.FILE_TOO_LARGE
: FATHOM_MEDIA_FAILURE_REASON.COMPLETED_WITHOUT_FILE,
});
return isApplied ? 'unavailable' : 'stale';
}
const isApplied = await settleCallRecordingMedia({
...updateOptions,
fields: { [kind]: result.files, mediaFailureReason: null },
});
return isApplied ? 'imported' : 'stale';
};

View File

@@ -0,0 +1,59 @@
import { isDefined } from 'src/utils/is-defined';
import {
type DisconnectedFathomMediaReconciliationPlan,
type FathomMediaReconciliationCandidate,
} from 'src/logic-functions/types/fathom-media-reconciliation-plan.type';
import { buildFathomRecordingImportReference } from 'src/logic-functions/utils/build-fathom-recording-import-reference.util';
import { isFathomMediaSettled } from 'src/logic-functions/utils/is-fathom-media-settled.util';
export const buildDisconnectedFathomMediaReconciliationPlan = (
callRecordings: FathomMediaReconciliationCandidate[],
): DisconnectedFathomMediaReconciliationPlan => {
const plan: DisconnectedFathomMediaReconciliationPlan = {
callRecordingsToSettle: [],
callRecordingsToSettleAndComplete: [],
callRecordingsToSettleAndFail: [],
callRecordingsToComplete: [],
callRecordingsToFail: [],
};
for (const callRecording of callRecordings) {
const reference = buildFathomRecordingImportReference(callRecording);
if (!isDefined(reference)) {
continue;
}
const isSettled = isFathomMediaSettled(callRecording);
const isProcessing = callRecording.status === 'PROCESSING';
if (!isProcessing && !isSettled) {
plan.callRecordingsToSettle.push(reference);
continue;
}
if (!isProcessing) {
continue;
}
if (!isSettled && callRecording.hasTranscript) {
plan.callRecordingsToSettleAndComplete.push(reference);
continue;
}
if (!isSettled) {
plan.callRecordingsToSettleAndFail.push(reference);
continue;
}
if (callRecording.hasTranscript) {
plan.callRecordingsToComplete.push(reference);
continue;
}
plan.callRecordingsToFail.push(reference);
}
return plan;
};

View File

@@ -0,0 +1,94 @@
import { isDefined } from 'src/utils/is-defined';
import { type CallRecordingSyncFields } from 'src/logic-functions/types/call-recording-sync-fields.type';
import { type CallRecordingMediaState } from 'src/logic-functions/types/call-recording-media-state.type';
import { type FathomRecordingImportFields } from 'src/logic-functions/types/fathom-recording-import-fields.type';
export const buildFathomCallRecordingUpsertFields = ({
sharedFields,
existingCallRecording,
connectedAccountId,
callRecordingId,
recordingId,
retryMedia,
}: {
sharedFields: CallRecordingSyncFields;
existingCallRecording:
| Pick<
CallRecordingMediaState,
| 'hasVideo'
| 'hasAudio'
| 'failureReason'
| 'downloadId'
| 'connectedAccountId'
| 'hasTranscript'
| 'hasSummary'
>
| undefined;
connectedAccountId: string;
callRecordingId: string;
recordingId: string;
retryMedia: boolean;
}): {
createCallRecordingFields: CallRecordingSyncFields;
updateCallRecordingFields: CallRecordingSyncFields;
recordingImportFields: FathomRecordingImportFields & {
callRecordingId: string;
recordingId: string;
};
isMediaDownloadRequestNeeded: boolean;
} => {
const isRetryingSettledMedia =
retryMedia &&
isDefined(existingCallRecording?.failureReason) &&
!existingCallRecording.hasVideo &&
!existingCallRecording.hasAudio;
const hasActiveDownloadForCurrentConnection =
!isRetryingSettledMedia &&
isDefined(existingCallRecording?.downloadId) &&
existingCallRecording.connectedAccountId === connectedAccountId;
const isReplacingActiveDownload =
isDefined(existingCallRecording) &&
existingCallRecording.connectedAccountId !== connectedAccountId &&
!existingCallRecording.hasVideo &&
!existingCallRecording.hasAudio &&
!isDefined(existingCallRecording.failureReason);
const isMissingMedia =
!existingCallRecording?.hasVideo &&
!existingCallRecording?.hasAudio &&
!isDefined(existingCallRecording?.downloadId) &&
!isDefined(existingCallRecording?.failureReason);
const ownershipFields = hasActiveDownloadForCurrentConnection
? {}
: { connectedAccountId };
return {
createCallRecordingFields: {
...sharedFields,
status: 'PROCESSING',
},
updateCallRecordingFields: {
...sharedFields,
...(existingCallRecording?.hasTranscript
? { transcript: undefined }
: {}),
...(existingCallRecording?.hasSummary ? { summary: undefined } : {}),
...(isRetryingSettledMedia ? { status: 'PROCESSING' } : {}),
},
recordingImportFields: {
callRecordingId,
recordingId,
...ownershipFields,
...(isReplacingActiveDownload || isRetryingSettledMedia
? {
mediaDownloadId: null,
mediaUploadCheckpoint: null,
mediaImportClaimedAt: null,
}
: {}),
...(isRetryingSettledMedia ? { mediaFailureReason: null } : {}),
},
isMediaDownloadRequestNeeded:
isRetryingSettledMedia || isReplacingActiveDownload || isMissingMedia,
};
};

View File

@@ -0,0 +1,88 @@
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'src/utils/is-defined';
import { type FathomRecordingImportReference } from 'src/logic-functions/types/fathom-recording-import-reference.type';
import {
type FathomMediaReconciliationCandidate,
type FathomMediaReconciliationPlan,
} from 'src/logic-functions/types/fathom-media-reconciliation-plan.type';
import { buildFathomRecordingImportReference } from 'src/logic-functions/utils/build-fathom-recording-import-reference.util';
import { isFathomCallRecordingImportComplete } from 'src/logic-functions/utils/is-fathom-call-recording-import-complete.util';
import { isFathomMediaSettled } from 'src/logic-functions/utils/is-fathom-media-settled.util';
export const buildFathomMediaReconciliationPlan = ({
callRecordings,
activeConnectedAccountIds,
}: {
callRecordings: FathomMediaReconciliationCandidate[];
activeConnectedAccountIds: string[];
}): FathomMediaReconciliationPlan => {
const callRecordingsToComplete: FathomRecordingImportReference[] = [];
const activeConnectedAccountIdSet = new Set(activeConnectedAccountIds);
const disconnectedAccountIdSet = new Set<string>();
const importGroupsByConnectedAccountId = new Map<
string,
Omit<
FathomMediaReconciliationPlan['importGroups'][number],
'connectedAccountId'
>
>();
for (const callRecording of callRecordings) {
if (!isNonEmptyString(callRecording.connectedAccountId)) {
continue;
}
const reference = buildFathomRecordingImportReference(callRecording);
if (!isDefined(reference)) {
continue;
}
if (!activeConnectedAccountIdSet.has(callRecording.connectedAccountId)) {
disconnectedAccountIdSet.add(callRecording.connectedAccountId);
continue;
}
if (
callRecording.status === 'PROCESSING' &&
isFathomCallRecordingImportComplete(callRecording)
) {
callRecordingsToComplete.push(reference);
continue;
}
if (isFathomMediaSettled(callRecording)) {
continue;
}
const existingImportGroup = importGroupsByConnectedAccountId.get(
callRecording.connectedAccountId,
) ?? { callRecordingIdsToRequest: [], downloadsToPoll: [] };
if (isNonEmptyString(callRecording.downloadId)) {
existingImportGroup.downloadsToPoll.push({
callRecordingId: callRecording.id,
downloadId: callRecording.downloadId,
});
} else {
existingImportGroup.callRecordingIdsToRequest.push(callRecording.id);
}
importGroupsByConnectedAccountId.set(
callRecording.connectedAccountId,
existingImportGroup,
);
}
return {
callRecordingsToComplete,
importGroups: [...importGroupsByConnectedAccountId].map(
([connectedAccountId, importGroup]) => ({
connectedAccountId,
...importGroup,
}),
),
disconnectedAccountIds: [...disconnectedAccountIdSet],
};
};

View File

@@ -0,0 +1,13 @@
import { type FathomMediaWriteContext } from 'src/logic-functions/types/fathom-media-write-context.type';
import { isDefined } from 'src/utils/is-defined';
export const buildFathomMediaWriteFence = (
writeContext: FathomMediaWriteContext,
) => ({
id: { eq: writeContext.fathomRecordingImportId },
connectedAccountId: { eq: writeContext.connectedAccountId },
mediaImportClaimedAt: { eq: writeContext.claimedAt },
mediaDownloadId: isDefined(writeContext.downloadId)
? { eq: writeContext.downloadId }
: { is: 'NULL' as const },
});

View File

@@ -0,0 +1,29 @@
import { isNonEmptyString } from '@sniptt/guards';
import { type FathomMediaReconciliationCandidate } from 'src/logic-functions/types/fathom-media-reconciliation-plan.type';
import { type FathomRecordingImportReference } from 'src/logic-functions/types/fathom-recording-import-reference.type';
export const buildFathomRecordingImportReference = (
callRecording: Pick<
FathomMediaReconciliationCandidate,
| 'id'
| 'updatedAt'
| 'fathomRecordingImportId'
| 'fathomRecordingImportUpdatedAt'
>,
): FathomRecordingImportReference | undefined => {
if (
!isNonEmptyString(callRecording.fathomRecordingImportId) ||
!isNonEmptyString(callRecording.fathomRecordingImportUpdatedAt)
) {
return undefined;
}
return {
callRecordingId: callRecording.id,
callRecordingUpdatedAt: callRecording.updatedAt,
fathomRecordingImportId: callRecording.fathomRecordingImportId,
fathomRecordingImportUpdatedAt:
callRecording.fathomRecordingImportUpdatedAt,
};
};

View File

@@ -0,0 +1,17 @@
import { RetryableLogicFunctionError } from 'twenty-sdk/logic-function';
import { toErrorMessage } from 'src/logic-functions/utils/to-error-message.util';
export const buildRetryableFathomError = ({
operation,
error,
}: {
operation: string;
error: unknown;
}): RetryableLogicFunctionError =>
Object.assign(
new RetryableLogicFunctionError(
`[fathom] ${operation} failed: ${toErrorMessage(error)}`,
),
{ cause: error },
);

View File

@@ -0,0 +1,23 @@
import { isNull } from '@sniptt/guards';
import { toErrorMessage } from 'src/logic-functions/utils/to-error-message.util';
export const cancelFathomMediaDownloadBody = async ({
body,
callRecordingId,
fileName,
}: {
body: ReadableStream<Uint8Array> | null;
callRecordingId: string;
fileName: string;
}): Promise<void> => {
if (isNull(body)) {
return;
}
await body.cancel().catch((error: unknown) => {
console.warn(
`[fathom] media download body cancellation failed callRecordingId=${callRecordingId} fileName=${fileName}: ${toErrorMessage(error)}`,
);
});
};

View File

@@ -0,0 +1,41 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { FATHOM_MEDIA_IMPORT_CLAIM_DURATION_MILLISECONDS } from 'src/constants/fathom.constant';
export type FathomMediaImportClaim = {
claimedAt: string;
};
export const claimFathomMediaImport = async ({
coreApiClient,
fathomRecordingImportId,
now,
}: {
coreApiClient: Pick<CoreApiClient, 'mutation'>;
fathomRecordingImportId: string;
now: Date;
}): Promise<FathomMediaImportClaim | undefined> => {
const claimedAt = now.toISOString();
const staleBefore = new Date(
now.getTime() - FATHOM_MEDIA_IMPORT_CLAIM_DURATION_MILLISECONDS,
).toISOString();
const mutationResult = await coreApiClient.mutation({
updateFathomRecordingImports: {
__args: {
filter: {
id: { eq: fathomRecordingImportId },
or: [
{ mediaImportClaimedAt: { is: 'NULL' } },
{ mediaImportClaimedAt: { lte: staleBefore } },
],
},
data: { mediaImportClaimedAt: claimedAt },
},
id: true,
},
});
return (mutationResult.updateFathomRecordingImports ?? []).length === 0
? undefined
: { claimedAt };
};

View File

@@ -0,0 +1,146 @@
import { isNonEmptyArray } from '@sniptt/guards';
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { isDefined } from 'src/utils/is-defined';
import { FATHOM_MEDIA_FAILURE_REASON } from 'src/constants/fathom-media-failure-reason.constant';
import { buildDisconnectedFathomMediaReconciliationPlan } from 'src/logic-functions/utils/build-disconnected-fathom-media-reconciliation-plan.util';
import { findDisconnectedFathomMediaImports } from 'src/logic-functions/utils/find-disconnected-fathom-media-imports.util';
import { listFathomConnections } from 'src/logic-functions/utils/list-fathom-connections.util';
export const cleanupDisconnectedFathomMediaImports = async ({
coreApiClient,
connectedAccountId,
}: {
coreApiClient: Pick<CoreApiClient, 'query' | 'mutation'>;
connectedAccountId: string;
}) => {
const connections = await listFathomConnections();
if (connections.some(({ id }) => id === connectedAccountId)) {
return {
candidateCount: 0,
updatedRecordingCount: 0,
shouldContinue: false,
skipped: true,
};
}
const page = await findDisconnectedFathomMediaImports({
coreApiClient,
connectedAccountId,
});
const plan = buildDisconnectedFathomMediaReconciliationPlan(
page.callRecordings,
);
let updatedRecordingCount = 0;
const clearedImportFields = {
mediaDownloadId: null,
mediaImportClaimedAt: null,
mediaUploadCheckpoint: null,
};
const settledData = {
...clearedImportFields,
mediaFailureReason:
FATHOM_MEDIA_FAILURE_REASON.CONNECTED_ACCOUNT_UNAVAILABLE,
};
const updates = [
{
callRecordings: plan.callRecordingsToSettleAndComplete,
importData: settledData,
status: 'COMPLETED',
},
{
callRecordings: plan.callRecordingsToSettleAndFail,
importData: settledData,
status: 'FAILED',
},
{
callRecordings: plan.callRecordingsToSettle,
importData: settledData,
},
{
callRecordings: plan.callRecordingsToComplete,
importData: clearedImportFields,
status: 'COMPLETED',
},
{
callRecordings: plan.callRecordingsToFail,
importData: clearedImportFields,
status: 'FAILED',
},
];
for (const { callRecordings, importData, status } of updates) {
if (!isNonEmptyArray(callRecordings)) {
continue;
}
const importResult = await coreApiClient.mutation({
updateFathomRecordingImports: {
__args: {
filter: {
connectedAccountId: { eq: connectedAccountId },
or: callRecordings.map((reference) => ({
id: { eq: reference.fathomRecordingImportId },
updatedAt: {
eq: reference.fathomRecordingImportUpdatedAt,
},
})),
},
data: importData,
},
id: true,
},
});
const updatedImportIds = new Set(
(importResult.updateFathomRecordingImports ?? []).map(
({ id }: { id: string }) => id,
),
);
if (!isDefined(status)) {
updatedRecordingCount += updatedImportIds.size;
continue;
}
const callRecordingsWithUpdatedImports = callRecordings.filter(
({ fathomRecordingImportId }) =>
updatedImportIds.has(fathomRecordingImportId),
);
if (!isNonEmptyArray(callRecordingsWithUpdatedImports)) {
continue;
}
const callRecordingResult = await coreApiClient.mutation({
updateCallRecordings: {
__args: {
filter: {
status: { eq: 'PROCESSING' },
or: callRecordingsWithUpdatedImports.map((reference) => ({
id: { eq: reference.callRecordingId },
updatedAt: { eq: reference.callRecordingUpdatedAt },
fathomRecordingImports: {
id: { eq: reference.fathomRecordingImportId },
connectedAccountId: { eq: connectedAccountId },
},
})),
},
data: { status },
},
id: true,
},
});
updatedRecordingCount +=
callRecordingResult.updateCallRecordings?.length ?? 0;
}
return {
candidateCount: page.callRecordings.length,
updatedRecordingCount,
shouldContinue:
page.hasNextPage || updatedRecordingCount < page.callRecordings.length,
skipped: false,
};
};

View File

@@ -0,0 +1,50 @@
import { isNonEmptyString } from '@sniptt/guards';
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { isDefined } from 'src/utils/is-defined';
import { findCallRecordingMediaState } from 'src/logic-functions/utils/find-call-recording-media-state.util';
import { isFathomCallRecordingImportComplete } from 'src/logic-functions/utils/is-fathom-call-recording-import-complete.util';
export const completeFathomCallRecordingImport = async ({
coreApiClient,
callRecordingId,
}: {
coreApiClient: Pick<CoreApiClient, 'query' | 'mutation'>;
callRecordingId: string;
}): Promise<boolean> => {
const callRecording = await findCallRecordingMediaState({
coreApiClient,
callRecordingId,
});
if (
!isDefined(callRecording) ||
!isNonEmptyString(callRecording.fathomRecordingImportId) ||
!isNonEmptyString(callRecording.fathomRecordingImportUpdatedAt) ||
!isFathomCallRecordingImportComplete(callRecording)
) {
return false;
}
const mutationResult = await coreApiClient.mutation({
updateCallRecordings: {
__args: {
filter: {
id: { eq: callRecordingId },
updatedAt: { eq: callRecording.updatedAt },
status: { eq: 'PROCESSING' },
fathomRecordingImports: {
id: { eq: callRecording.fathomRecordingImportId },
updatedAt: {
eq: callRecording.fathomRecordingImportUpdatedAt,
},
},
},
data: { status: 'COMPLETED' },
},
id: true,
},
});
return (mutationResult.updateCallRecordings ?? []).length > 0;
};

View File

@@ -0,0 +1,48 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { type FathomRecordingImportReference } from 'src/logic-functions/types/fathom-recording-import-reference.type';
import { chunkIntoBatches } from 'src/utils/chunk-into-batches.util';
const CALL_RECORDING_COMPLETION_BATCH_SIZE = 200;
export const completeFathomCallRecordingImports = async ({
coreApiClient,
callRecordings,
}: {
coreApiClient: Pick<CoreApiClient, 'mutation'>;
callRecordings: FathomRecordingImportReference[];
}): Promise<number> => {
let completedCallRecordingCount = 0;
for (const callRecordingBatch of chunkIntoBatches(
callRecordings,
CALL_RECORDING_COMPLETION_BATCH_SIZE,
)) {
const mutationResult = await coreApiClient.mutation({
updateCallRecordings: {
__args: {
filter: {
status: { eq: 'PROCESSING' },
or: callRecordingBatch.map((reference) => ({
id: { eq: reference.callRecordingId },
updatedAt: { eq: reference.callRecordingUpdatedAt },
fathomRecordingImports: {
id: { eq: reference.fathomRecordingImportId },
updatedAt: {
eq: reference.fathomRecordingImportUpdatedAt,
},
},
})),
},
data: { status: 'COMPLETED' },
},
id: true,
},
});
completedCallRecordingCount +=
mutationResult.updateCallRecordings?.length ?? 0;
}
return completedCallRecordingCount;
};

View File

@@ -0,0 +1,23 @@
import { isNonEmptyString } from '@sniptt/guards';
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
export const completeFathomMediaFileUpload = async ({
fileId,
}: {
fileId: string;
}): Promise<string> => {
const metadataClient = new MetadataApiClient();
const mutationResult = await metadataClient.mutation({
completeFileUpload: {
__args: { fileId },
id: true,
},
});
const uploadedFileId = mutationResult.completeFileUpload?.id;
if (!isNonEmptyString(uploadedFileId)) {
throw new Error('completeFileUpload mutation did not return a file id');
}
return uploadedFileId;
};

View File

@@ -0,0 +1,14 @@
import { createHash } from 'crypto';
export const computeCallRecordingIdForFathomMeeting = (
recordingId: number,
): string => {
const bytes = createHash('sha256').update(`fathom:${recordingId}`).digest();
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hexadecimalValue = bytes.subarray(0, 16).toString('hex');
return `${hexadecimalValue.slice(0, 8)}-${hexadecimalValue.slice(8, 12)}-${hexadecimalValue.slice(12, 16)}-${hexadecimalValue.slice(16, 20)}-${hexadecimalValue.slice(20)}`;
};

View File

@@ -0,0 +1,19 @@
import { Fathom } from 'fathom-typescript';
import { FATHOM_REQUEST_RETRY_MAX_ELAPSED_MILLISECONDS } from 'src/constants/fathom.constant';
// The SDK default retries 429s for five minutes, past every function timeout here.
export const createFathomClient = (accessToken: string): Fathom =>
new Fathom({
security: { bearerAuth: accessToken },
retryConfig: {
strategy: 'backoff',
backoff: {
initialInterval: 500,
maxInterval: 10_000,
exponent: 1.5,
maxElapsedTime: FATHOM_REQUEST_RETRY_MAX_ELAPSED_MILLISECONDS,
},
retryConnectionErrors: false,
},
});

View File

@@ -0,0 +1,21 @@
import { type Fathom } from 'fathom-typescript';
import { isFathomNotFoundError } from 'src/logic-functions/utils/is-fathom-not-found-error.util';
// A webhook Fathom already dropped is the only failure safe to move past:
// anything else would orphan the old webhook once it is replaced.
export const deleteStaleFathomWebhook = async ({
fathomClient,
webhookId,
}: {
fathomClient: Fathom;
webhookId: string;
}): Promise<void> => {
try {
await fathomClient.deleteWebhook({ id: webhookId });
} catch (error) {
if (!isFathomNotFoundError(error)) {
throw error;
}
}
};

View File

@@ -0,0 +1,12 @@
import { type Meeting } from 'fathom-typescript/sdk/models/shared';
export const doesFathomMeetingIncludeInvitee = ({
meeting,
normalizedInviteeEmail,
}: {
meeting: Pick<Meeting, 'calendarInvitees'>;
normalizedInviteeEmail: string;
}): boolean =>
meeting.calendarInvitees.some(
(invitee) => invitee.email?.trim().toLowerCase() === normalizedInviteeEmail,
);

View File

@@ -0,0 +1,15 @@
import { FATHOM_BACKFILL_WORKER_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { enqueueFathomJobsOrThrow } from 'src/logic-functions/utils/enqueue-fathom-jobs-or-throw.util';
export const enqueueFathomBackfillWorker = async ({
connectedAccountId,
days,
}: {
connectedAccountId: string;
days: number;
}): Promise<void> =>
enqueueFathomJobsOrThrow({
logicFunctionUniversalIdentifier:
FATHOM_BACKFILL_WORKER_UNIVERSAL_IDENTIFIER,
payloads: [{ connectedAccountId, days }],
});

View File

@@ -0,0 +1,30 @@
import { enqueueJobs, type EnqueueJobsInput } from 'twenty-sdk/logic-function';
import { FATHOM_BACKFILL_JOB_RETRY_LIMIT } from 'src/constants/fathom.constant';
import { chunkIntoBatches } from 'src/utils/chunk-into-batches.util';
const MAX_FATHOM_JOBS_PER_ENQUEUE = 200;
export const enqueueFathomJobsOrThrow = async ({
payloads,
...options
}: Pick<EnqueueJobsInput, 'logicFunctionUniversalIdentifier' | 'delayMs'> & {
payloads: Record<string, unknown>[];
}): Promise<void> => {
for (const payloadBatch of chunkIntoBatches(
payloads,
MAX_FATHOM_JOBS_PER_ENQUEUE,
)) {
const enqueueResult = await enqueueJobs({
...options,
payloads: payloadBatch,
retryLimit: FATHOM_BACKFILL_JOB_RETRY_LIMIT,
});
if (!enqueueResult.enqueued) {
throw new Error(
`Failed to enqueue Fathom job ${options.logicFunctionUniversalIdentifier}`,
);
}
}
};

View File

@@ -0,0 +1,30 @@
import { FATHOM_IMPORT_MEDIA_DOWNLOAD_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { type FathomImportMediaDownloadPayload } from 'src/logic-functions/schemas/fathom-media-download-payload.schema';
import { enqueueFathomJobsOrThrow } from 'src/logic-functions/utils/enqueue-fathom-jobs-or-throw.util';
import { getFathomMediaDownloadPollDelay } from 'src/logic-functions/utils/get-fathom-media-download-poll-delay.util';
import { reserveFathomImportSlots } from 'src/logic-functions/utils/reserve-fathom-import-slots.util';
export const enqueueFathomMediaDownloadPoll = async ({
connectedAccountId,
notBeforeDelayMilliseconds,
...payload
}: FathomImportMediaDownloadPayload & {
connectedAccountId: string;
notBeforeDelayMilliseconds?: number;
}): Promise<void> => {
const { slotDelays } = await reserveFathomImportSlots({
connectedAccountId,
slotCount: 1,
notBeforeDelayMilliseconds: Math.max(
notBeforeDelayMilliseconds ?? 0,
getFathomMediaDownloadPollDelay(payload.attempt),
),
});
await enqueueFathomJobsOrThrow({
logicFunctionUniversalIdentifier:
FATHOM_IMPORT_MEDIA_DOWNLOAD_UNIVERSAL_IDENTIFIER,
payloads: [payload],
delayMs: slotDelays[0],
});
};

View File

@@ -0,0 +1,26 @@
import { FATHOM_REQUEST_MEDIA_DOWNLOAD_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { type FathomRequestMediaDownloadPayload } from 'src/logic-functions/schemas/fathom-media-download-payload.schema';
import { enqueueFathomJobsOrThrow } from 'src/logic-functions/utils/enqueue-fathom-jobs-or-throw.util';
import { reserveFathomImportSlots } from 'src/logic-functions/utils/reserve-fathom-import-slots.util';
export const enqueueFathomMediaDownloadRequest = async ({
connectedAccountId,
notBeforeDelayMilliseconds,
...payload
}: FathomRequestMediaDownloadPayload & {
connectedAccountId: string;
notBeforeDelayMilliseconds?: number;
}): Promise<void> => {
const { slotDelays } = await reserveFathomImportSlots({
connectedAccountId,
slotCount: 1,
notBeforeDelayMilliseconds,
});
await enqueueFathomJobsOrThrow({
logicFunctionUniversalIdentifier:
FATHOM_REQUEST_MEDIA_DOWNLOAD_UNIVERSAL_IDENTIFIER,
payloads: [payload],
delayMs: slotDelays[0],
});
};

View File

@@ -0,0 +1,35 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { type SerializedFathomMeeting } from 'src/logic-functions/types/serialized-fathom-meeting.type';
import { computeCallRecordingIdForFathomMeeting } from 'src/logic-functions/utils/compute-call-recording-id-for-fathom-meeting.util';
import { listDeletedCallRecordingIds } from 'src/logic-functions/utils/list-deleted-call-recording-ids.util';
// A recording someone deleted stays deleted: its CallRecording id is derived
// from the Fathom recording, so importing it again would collide with the
// tombstone that still holds that id.
export const excludeDeletedFathomMeetings = async ({
coreApiClient,
meetings,
}: {
coreApiClient: Pick<CoreApiClient, 'query'>;
meetings: SerializedFathomMeeting[];
}): Promise<SerializedFathomMeeting[]> => {
const identifiedMeetings = meetings.map((meeting) => ({
meeting,
callRecordingId: computeCallRecordingIdForFathomMeeting(
meeting.recordingId,
),
}));
const deletedCallRecordingIds = await listDeletedCallRecordingIds({
coreApiClient,
callRecordingIds: identifiedMeetings.map(
({ callRecordingId }) => callRecordingId,
),
});
return identifiedMeetings
.filter(
({ callRecordingId }) => !deletedCallRecordingIds.has(callRecordingId),
)
.map(({ meeting }) => meeting);
};

View File

@@ -0,0 +1,57 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { isDefined } from 'src/utils/is-defined';
import { callRecordingMediaStateQueryResultSchema } from 'src/logic-functions/schemas/call-recording-media-state-query-result.schema';
import { type CallRecordingMediaState } from 'src/logic-functions/types/call-recording-media-state.type';
import { mapCallRecordingMediaState } from 'src/logic-functions/utils/map-call-recording-media-state.util';
export const findCallRecordingMediaState = async ({
coreApiClient,
callRecordingId,
}: {
coreApiClient: Pick<CoreApiClient, 'query'>;
callRecordingId: string;
}): Promise<CallRecordingMediaState | undefined> => {
const queryResult = await coreApiClient.query({
callRecordings: {
__args: {
filter: { id: { eq: callRecordingId } },
first: 1,
},
edges: {
node: {
id: true,
updatedAt: true,
summary: { markdown: true, blocknote: true },
video: { fileId: true },
audio: { fileId: true },
fathomRecordingImports: {
__args: { first: 1 },
edges: {
node: {
id: true,
updatedAt: true,
recordingId: true,
connectedAccountId: true,
mediaDownloadId: true,
mediaFailureReason: true,
mediaImportClaimedAt: true,
mediaUploadCheckpoint: true,
},
},
},
transcript: true,
},
},
},
});
const parsedQueryResult =
callRecordingMediaStateQueryResultSchema.parse(queryResult);
const node = parsedQueryResult.callRecordings?.edges[0]?.node;
if (!isDefined(node)) {
return undefined;
}
return mapCallRecordingMediaState(node);
};

View File

@@ -0,0 +1,40 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { FATHOM_MEDIA_RECONCILIATION_SELECTION } from 'src/constants/fathom-media-reconciliation-selection.constant';
import { FATHOM_MEDIA_RECONCILIATION_PAGE_SIZE } from 'src/constants/fathom.constant';
import { type FathomMediaReconciliationPage } from 'src/logic-functions/types/fathom-media-reconciliation-plan.type';
import { parseFathomMediaReconciliationPage } from 'src/logic-functions/utils/parse-fathom-media-reconciliation-page.util';
export const findDisconnectedFathomMediaImports = async ({
coreApiClient,
connectedAccountId,
}: {
coreApiClient: Pick<CoreApiClient, 'query'>;
connectedAccountId: string;
}): Promise<FathomMediaReconciliationPage> => {
const queryResult = await coreApiClient.query({
callRecordings: {
__args: {
filter: {
fathomRecordingImports: {
connectedAccountId: { eq: connectedAccountId },
},
or: [
{
fathomRecordingImports: {
mediaFailureReason: { is: 'NULL' },
},
video: { is: 'NULL' },
audio: { is: 'NULL' },
},
{ status: { eq: 'PROCESSING' } },
],
},
first: FATHOM_MEDIA_RECONCILIATION_PAGE_SIZE,
orderBy: [{ id: 'AscNullsLast' }],
},
...FATHOM_MEDIA_RECONCILIATION_SELECTION,
},
});
return parseFathomMediaReconciliationPage(queryResult);
};

View File

@@ -0,0 +1,12 @@
import {
type AppConnection,
findConnectionForRequest,
type LogicFunctionExecutionContext,
} from 'twenty-sdk/logic-function';
import { listFathomConnections } from 'src/logic-functions/utils/list-fathom-connections.util';
export const findFathomConnectionForRequester = async (
context: Pick<LogicFunctionExecutionContext, 'userWorkspaceId'>,
): Promise<AppConnection | null> =>
findConnectionForRequest(await listFathomConnections(), context);

View File

@@ -0,0 +1,67 @@
import { type Meeting } from 'fathom-typescript/sdk/models/shared';
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { isDefined } from 'src/utils/is-defined';
import { listCalendarEventsInWindow } from 'src/logic-functions/utils/list-calendar-events-in-window.util';
import { normalizeMeetingUrl } from 'src/logic-functions/utils/normalize-meeting-url.util';
const MATCHING_WINDOW_MILLISECONDS = 5 * 60 * 1000;
export const findMatchingCalendarEvent = async ({
coreApiClient,
meeting,
}: {
coreApiClient: Pick<CoreApiClient, 'query'>;
meeting: Meeting;
}): Promise<string | undefined> => {
const normalizedFathomMeetingUrl = normalizeMeetingUrl(meeting.meetingUrl);
if (!isDefined(normalizedFathomMeetingUrl)) {
return undefined;
}
const scheduledStartMilliseconds = meeting.scheduledStartTime.getTime();
const calendarEvents = await listCalendarEventsInWindow({
coreApiClient,
earliestStart: new Date(
scheduledStartMilliseconds - MATCHING_WINDOW_MILLISECONDS,
).toISOString(),
latestStart: new Date(
scheduledStartMilliseconds + MATCHING_WINDOW_MILLISECONDS,
).toISOString(),
});
if (!isDefined(calendarEvents)) {
return undefined;
}
const candidates = calendarEvents
.filter(
(candidate) =>
normalizeMeetingUrl(candidate.conferenceLink?.primaryLinkUrl) ===
normalizedFathomMeetingUrl,
)
.map((candidate) => ({
candidate,
startDifference: Math.abs(
new Date(candidate.startsAt).getTime() - scheduledStartMilliseconds,
),
}))
.sort(
(firstCandidate, secondCandidate) =>
firstCandidate.startDifference - secondCandidate.startDifference,
);
if (candidates.length === 0) {
return undefined;
}
if (
candidates.length > 1 &&
candidates[0].startDifference === candidates[1].startDifference
) {
return undefined;
}
return candidates[0].candidate.id;
};

View File

@@ -0,0 +1,88 @@
import { isNonEmptyString } from '@sniptt/guards';
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { FATHOM_MEDIA_RECONCILIATION_SELECTION } from 'src/constants/fathom-media-reconciliation-selection.constant';
import { FATHOM_MEDIA_RECONCILIATION_PAGE_SIZE } from 'src/constants/fathom.constant';
import {
type FathomMediaReconciliationPage,
type FathomMediaReconciliationRun,
} from 'src/logic-functions/types/fathom-media-reconciliation-plan.type';
import { parseFathomMediaReconciliationPage } from 'src/logic-functions/utils/parse-fathom-media-reconciliation-page.util';
export const findStaleFathomMediaImports = async ({
coreApiClient,
run,
activeConnectedAccountIds,
}: {
coreApiClient: Pick<CoreApiClient, 'query'>;
run: FathomMediaReconciliationRun;
activeConnectedAccountIds: string[];
}): Promise<FathomMediaReconciliationPage> => {
const queryResult = await coreApiClient.query({
callRecordings: {
__args: {
filter: {
and: [
{
fathomRecordingImports: {
connectedAccountId: { is: 'NOT_NULL' },
updatedAt: { lte: run.staleBefore },
},
},
{
or: [
{ startedAt: { gte: run.startedAfter } },
{
fathomRecordingImports: {
connectedAccountId: {
notIn: activeConnectedAccountIds,
},
},
},
],
},
...(isNonEmptyString(run.afterId)
? [{ id: { gt: run.afterId } }]
: []),
{
or: [
{
fathomRecordingImports: {
mediaFailureReason: { is: 'NULL' },
},
video: { is: 'NULL' },
audio: { is: 'NULL' },
},
{
status: { eq: 'PROCESSING' },
transcript: { is: 'NOT_NULL' },
or: [
{ video: { is: 'NOT_NULL' } },
{ audio: { is: 'NOT_NULL' } },
{
fathomRecordingImports: {
mediaFailureReason: { is: 'NOT_NULL' },
},
},
],
},
{
status: { eq: 'PROCESSING' },
fathomRecordingImports: {
connectedAccountId: {
notIn: activeConnectedAccountIds,
},
},
},
],
},
],
},
first: FATHOM_MEDIA_RECONCILIATION_PAGE_SIZE,
orderBy: [{ id: 'AscNullsLast' }],
},
...FATHOM_MEDIA_RECONCILIATION_SELECTION,
},
});
return parseFathomMediaReconciliationPage(queryResult);
};

View File

@@ -0,0 +1,35 @@
import { type ActionItem } from 'fathom-typescript/sdk/models/shared';
import { isNonEmptyArray, isNonEmptyString } from '@sniptt/guards';
const formatActionItem = (actionItem: ActionItem): string => {
const completionMarker = actionItem.completed ? 'x' : ' ';
const assigneeName = actionItem.assignee.name?.trim();
const assigneeSuffix = isNonEmptyString(assigneeName)
? ` — ${assigneeName}`
: '';
return `- [${completionMarker}] ${actionItem.description}${assigneeSuffix}`;
};
export const formatFathomSummary = ({
summaryMarkdown,
actionItems,
}: {
summaryMarkdown: string | null | undefined;
actionItems: ActionItem[] | null | undefined;
}): string => {
const sections: string[] = [];
const trimmedSummary = summaryMarkdown?.trim();
if (isNonEmptyString(trimmedSummary)) {
sections.push(trimmedSummary);
}
if (isNonEmptyArray(actionItems)) {
sections.push(
`## Action items\n\n${actionItems.map(formatActionItem).join('\n')}`,
);
}
return sections.join('\n\n');
};

View File

@@ -0,0 +1,3 @@
export const getFathomConnectionClaimKey = (
connectedAccountId: string,
): string => `fathom-connection:${connectedAccountId}`;

View File

@@ -0,0 +1,3 @@
export const getFathomImportScheduleKey = (
connectedAccountId: string,
): string => `fathom-backfill-schedule:${connectedAccountId}`;

View File

@@ -0,0 +1,29 @@
import { type RecordingDownload } from 'fathom-typescript/sdk/models/shared';
import { FATHOM_MEDIA_FAILURE_REASON } from 'src/constants/fathom-media-failure-reason.constant';
import { isDefined } from 'src/utils/is-defined';
export const getFathomMediaDownloadFailureReason = (
download: Pick<
RecordingDownload,
'status' | 'failureReason' | 'video' | 'audio'
>,
): string | undefined => {
if (download.status === 'processing') {
return undefined;
}
if (download.status === 'expired') {
return FATHOM_MEDIA_FAILURE_REASON.DOWNLOAD_EXPIRED;
}
if (download.status === 'failed') {
return download.failureReason ?? 'generation_failed';
}
if (!isDefined(download.video) && !isDefined(download.audio)) {
return FATHOM_MEDIA_FAILURE_REASON.COMPLETED_WITHOUT_FILE;
}
return undefined;
};

View File

@@ -0,0 +1,14 @@
import {
FATHOM_MEDIA_DOWNLOAD_INITIAL_POLL_DELAY_MILLISECONDS,
FATHOM_MEDIA_DOWNLOAD_MAX_POLL_DELAY_MILLISECONDS,
FATHOM_MEDIA_DOWNLOAD_POLL_BACKOFF_EXPONENT,
} from 'src/constants/fathom.constant';
export const getFathomMediaDownloadPollDelay = (attempt: number): number =>
Math.min(
Math.round(
FATHOM_MEDIA_DOWNLOAD_INITIAL_POLL_DELAY_MILLISECONDS *
FATHOM_MEDIA_DOWNLOAD_POLL_BACKOFF_EXPONENT ** attempt,
),
FATHOM_MEDIA_DOWNLOAD_MAX_POLL_DELAY_MILLISECONDS,
);

View File

@@ -0,0 +1,24 @@
import { FathomError } from 'fathom-typescript/sdk/models/errors';
import { FATHOM_MEDIA_FAILURE_REASON } from 'src/constants/fathom-media-failure-reason.constant';
const FORBIDDEN_STATUS_CODE = 403;
const UNPROCESSABLE_STATUS_CODE = 422;
export const getFathomMediaFailureReasonForError = (
error: unknown,
): string | undefined => {
if (!(error instanceof FathomError)) {
return undefined;
}
if (error.statusCode === UNPROCESSABLE_STATUS_CODE) {
return FATHOM_MEDIA_FAILURE_REASON.NO_DOWNLOADABLE_MEDIA;
}
if (error.statusCode === FORBIDDEN_STATUS_CODE) {
return FATHOM_MEDIA_FAILURE_REASON.DOWNLOAD_FORBIDDEN;
}
return undefined;
};

View File

@@ -0,0 +1,10 @@
import { isNonEmptyString } from '@sniptt/guards';
import { type Meeting } from 'fathom-typescript/sdk/models/shared';
export const getFathomMeetingTitle = (
meeting: Pick<Meeting, 'meetingTitle' | 'title'>,
): string => {
const meetingTitle = meeting.meetingTitle?.trim();
return isNonEmptyString(meetingTitle) ? meetingTitle : meeting.title.trim();
};

View File

@@ -0,0 +1,31 @@
import { isNonEmptyString } from '@sniptt/guards';
import { FathomError } from 'fathom-typescript/sdk/models/errors';
const FATHOM_RATE_LIMIT_FALLBACK_DELAY_MILLISECONDS = 60_000;
export const getFathomRetryAfterDelay = ({
error,
now,
}: {
error: unknown;
now: Date;
}): number | undefined => {
if (!(error instanceof FathomError) || error.statusCode !== 429) {
return undefined;
}
const retryAfter = error.headers.get('retry-after')?.trim();
if (!isNonEmptyString(retryAfter)) {
return FATHOM_RATE_LIMIT_FALLBACK_DELAY_MILLISECONDS;
}
const retryAfterSeconds = Number(retryAfter);
const delayMilliseconds = Number.isNaN(retryAfterSeconds)
? Date.parse(retryAfter) - now.getTime()
: retryAfterSeconds * 1_000;
return Number.isFinite(delayMilliseconds) && delayMilliseconds >= 0
? delayMilliseconds
: FATHOM_RATE_LIMIT_FALLBACK_DELAY_MILLISECONDS;
};

View File

@@ -0,0 +1,22 @@
import { FATHOM_WEBHOOK_CONNECTION_QUERY_PARAMETER } from 'src/constants/fathom.constant';
import { FATHOM_WEBHOOK_RESOLVER_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export const getFathomWebhookDestinationUrl = ({
apiUrl,
connectedAccountId,
}: {
apiUrl: string;
connectedAccountId: string;
}): string => {
const destinationUrl = new URL(apiUrl);
const apiBasePath = destinationUrl.pathname.replace(/\/+$/, '');
destinationUrl.pathname = `${apiBasePath}/webhooks/server/${FATHOM_WEBHOOK_RESOLVER_UNIVERSAL_IDENTIFIER}`;
destinationUrl.searchParams.set(
FATHOM_WEBHOOK_CONNECTION_QUERY_PARAMETER,
connectedAccountId,
);
return destinationUrl.toString();
};

View File

@@ -0,0 +1,3 @@
export const getFathomWebhookRegistrationKey = (
connectedAccountId: string,
): string => `fathom-webhook:${connectedAccountId}`;

View File

@@ -0,0 +1,36 @@
import { type Fathom } from 'fathom-typescript';
import { type Meeting } from 'fathom-typescript/sdk/models/shared';
import { type SerializedFathomMeeting } from 'src/logic-functions/types/serialized-fathom-meeting.type';
export const hydrateFathomMeeting = async ({
fathomClient,
serializedMeeting,
}: {
fathomClient: Pick<Fathom, 'getRecordingTranscript' | 'getRecordingSummary'>;
serializedMeeting: SerializedFathomMeeting;
}): Promise<Meeting> => {
const [transcriptResponse, summaryResponse] = await Promise.all([
fathomClient.getRecordingTranscript({
recordingId: serializedMeeting.recordingId,
}),
fathomClient.getRecordingSummary({
recordingId: serializedMeeting.recordingId,
}),
]);
return {
...serializedMeeting,
createdAt: new Date(serializedMeeting.createdAt),
scheduledStartTime: new Date(serializedMeeting.scheduledStartTime),
scheduledEndTime: new Date(serializedMeeting.scheduledEndTime),
recordingStartTime: new Date(serializedMeeting.recordingStartTime),
recordingEndTime: new Date(serializedMeeting.recordingEndTime),
transcript:
'transcript' in transcriptResponse
? transcriptResponse.transcript
: undefined,
defaultSummary:
'summary' in summaryResponse ? summaryResponse.summary : undefined,
};
};

View File

@@ -0,0 +1,101 @@
import { type RecordingDownloadFile } from 'fathom-typescript/sdk/models/shared';
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { FATHOM_MEDIA_MAX_FILE_SIZE_BYTES } from 'src/constants/fathom.constant';
import {
CALL_RECORDING_AUDIO_FIELD_UNIVERSAL_IDENTIFIER,
CALL_RECORDING_VIDEO_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
import { type FathomMediaKind } from 'src/logic-functions/schemas/fathom-media-kind.schema';
import { type CallRecordingMediaFile } from 'src/logic-functions/types/call-recording-media-file.type';
import { type FathomMediaWriteContext } from 'src/logic-functions/types/fathom-media-write-context.type';
import { completeFathomMediaFileUpload } from 'src/logic-functions/utils/complete-fathom-media-file-upload.util';
import { openFathomMediaDownload } from 'src/logic-functions/utils/open-fathom-media-download.util';
import { uploadFathomMediaStream } from 'src/logic-functions/utils/upload-fathom-media-stream.util';
import { updateFathomMediaUploadCheckpoint } from 'src/logic-functions/utils/update-fathom-media-upload-checkpoint.util';
export type { FathomMediaKind } from 'src/logic-functions/schemas/fathom-media-kind.schema';
export type ImportFathomMediaFileResult =
| { outcome: 'imported'; files: CallRecordingMediaFile[] }
| { outcome: 'empty' }
| { outcome: 'stale' }
| { outcome: 'too-large'; sizeBytes: number };
const MEDIA_DESCRIPTOR_BY_KIND = {
video: {
fileName: 'video.mp4',
fieldMetadataUniversalIdentifier:
CALL_RECORDING_VIDEO_FIELD_UNIVERSAL_IDENTIFIER,
},
audio: {
fileName: 'audio.mp3',
fieldMetadataUniversalIdentifier:
CALL_RECORDING_AUDIO_FIELD_UNIVERSAL_IDENTIFIER,
},
} satisfies Record<
FathomMediaKind,
{ fileName: string; fieldMetadataUniversalIdentifier: string }
>;
export const importFathomMediaFile = async ({
callRecordingId,
coreApiClient,
downloadId,
writeContext,
kind,
downloadFile,
}: {
callRecordingId: string;
coreApiClient: Pick<CoreApiClient, 'mutation'>;
downloadId: string;
writeContext: FathomMediaWriteContext;
kind: FathomMediaKind;
downloadFile: Pick<RecordingDownloadFile, 'fileSizeBytes' | 'url'>;
}): Promise<ImportFathomMediaFileResult> => {
const { fileName, fieldMetadataUniversalIdentifier } =
MEDIA_DESCRIPTOR_BY_KIND[kind];
if (
!Number.isSafeInteger(downloadFile.fileSizeBytes) ||
downloadFile.fileSizeBytes <= 0
) {
return { outcome: 'empty' };
}
if (downloadFile.fileSizeBytes > FATHOM_MEDIA_MAX_FILE_SIZE_BYTES) {
return { outcome: 'too-large', sizeBytes: downloadFile.fileSizeBytes };
}
const download = await openFathomMediaDownload({
callRecordingId,
fileName,
downloadFile,
});
if (download.outcome !== 'opened') {
return download;
}
const fileId = await uploadFathomMediaStream({
callRecordingId,
fileName,
fieldMetadataUniversalIdentifier,
body: download.body,
sizeBytes: download.sizeBytes,
});
const wasCheckpointSaved = await updateFathomMediaUploadCheckpoint({
coreApiClient,
writeContext,
uploadCheckpoint: { downloadId, fileId, kind },
});
if (!wasCheckpointSaved) {
return { outcome: 'stale' };
}
await completeFathomMediaFileUpload({ fileId });
return { outcome: 'imported', files: [{ fileId, label: fileName }] };
};

View File

@@ -0,0 +1,10 @@
import { type CallRecordingMediaState } from 'src/logic-functions/types/call-recording-media-state.type';
import { isFathomMediaSettled } from 'src/logic-functions/utils/is-fathom-media-settled.util';
export const isFathomCallRecordingImportComplete = (
callRecording: Pick<
CallRecordingMediaState,
'hasTranscript' | 'hasVideo' | 'hasAudio' | 'failureReason'
>,
): boolean =>
callRecording.hasTranscript && isFathomMediaSettled(callRecording);

View File

@@ -0,0 +1,13 @@
import { isNonEmptyString } from '@sniptt/guards';
import { type CallRecordingMediaState } from 'src/logic-functions/types/call-recording-media-state.type';
export const isFathomMediaSettled = (
callRecording: Pick<
CallRecordingMediaState,
'hasVideo' | 'hasAudio' | 'failureReason'
>,
): boolean =>
callRecording.hasVideo ||
callRecording.hasAudio ||
isNonEmptyString(callRecording.failureReason);

View File

@@ -0,0 +1,6 @@
import { FathomError } from 'fathom-typescript/sdk/models/errors';
const NOT_FOUND_STATUS_CODE = 404;
export const isFathomNotFoundError = (error: unknown): boolean =>
error instanceof FathomError && error.statusCode === NOT_FOUND_STATUS_CODE;

View File

@@ -0,0 +1,13 @@
import {
FathomError,
HTTPClientError,
} from 'fathom-typescript/sdk/models/errors';
const TOO_MANY_REQUESTS_STATUS_CODE = 429;
const SERVER_ERROR_STATUS_CODE_LOWER_BOUND = 500;
export const isTransientFathomError = (error: unknown): boolean =>
error instanceof HTTPClientError ||
(error instanceof FathomError &&
(error.statusCode === TOO_MANY_REQUESTS_STATUS_CODE ||
error.statusCode >= SERVER_ERROR_STATUS_CODE_LOWER_BOUND));

View File

@@ -0,0 +1,79 @@
import { isNonEmptyString } from '@sniptt/guards';
import { type CoreApiClient } from 'twenty-client-sdk/core';
import {
CALENDAR_EVENT_PAGE_SIZE,
MAX_CALENDAR_EVENT_PAGES,
} from 'src/constants/fathom.constant';
import { type CalendarEventCandidate } from 'src/logic-functions/types/calendar-event-candidate.type';
type CalendarEventsPage = {
calendarEvents?: {
edges?: Array<{ node: CalendarEventCandidate }>;
pageInfo?: { hasNextPage?: boolean; endCursor?: string | null };
};
};
// Matching is only conservative when every event in the window was compared,
// so a window that cannot be read to the end yields undefined, not a partial list.
export const listCalendarEventsInWindow = async ({
coreApiClient,
earliestStart,
latestStart,
}: {
coreApiClient: Pick<CoreApiClient, 'query'>;
earliestStart: string;
latestStart: string;
}): Promise<CalendarEventCandidate[] | undefined> => {
const calendarEvents: CalendarEventCandidate[] = [];
let cursor: string | undefined = undefined;
for (let page = 0; page < MAX_CALENDAR_EVENT_PAGES; page++) {
const queryResult: CalendarEventsPage = await coreApiClient.query({
calendarEvents: {
__args: {
// The API applies one operator per field filter, so a range is two and-ed entries.
filter: {
and: [
{ startsAt: { gte: earliestStart } },
{ startsAt: { lte: latestStart } },
{ isCanceled: { eq: false } },
],
},
first: CALENDAR_EVENT_PAGE_SIZE,
...(cursor === undefined ? {} : { after: cursor }),
},
edges: {
node: {
id: true,
startsAt: true,
conferenceLink: {
primaryLinkUrl: true,
},
},
},
pageInfo: {
hasNextPage: true,
endCursor: true,
},
},
});
const pageInfo = queryResult.calendarEvents?.pageInfo;
calendarEvents.push(
...(queryResult.calendarEvents?.edges ?? []).map((edge) => edge.node),
);
if (pageInfo?.hasNextPage !== true) {
return calendarEvents;
}
if (!isNonEmptyString(pageInfo.endCursor)) {
return undefined;
}
cursor = pageInfo.endCursor;
}
return undefined;
};

View File

@@ -0,0 +1,49 @@
import { isNonEmptyString } from '@sniptt/guards';
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { CALL_RECORDING_IDS_PER_QUERY } from 'src/constants/fathom.constant';
// Ids are unique, so one page per chunk already returns every match.
export const listDeletedCallRecordingIds = async ({
coreApiClient,
callRecordingIds,
}: {
coreApiClient: Pick<CoreApiClient, 'query'>;
callRecordingIds: string[];
}): Promise<Set<string>> => {
const deletedCallRecordingIds = new Set<string>();
for (
let queryStart = 0;
queryStart < callRecordingIds.length;
queryStart += CALL_RECORDING_IDS_PER_QUERY
) {
const queriedCallRecordingIds = callRecordingIds.slice(
queryStart,
queryStart + CALL_RECORDING_IDS_PER_QUERY,
);
const queryResult = await coreApiClient.query({
callRecordings: {
__args: {
filter: {
id: { in: queriedCallRecordingIds },
deletedAt: { is: 'NOT_NULL' },
},
first: queriedCallRecordingIds.length,
},
edges: { node: { id: true } },
},
});
for (const edge of queryResult.callRecordings?.edges ?? []) {
const id = edge?.node?.id;
if (isNonEmptyString(id)) {
deletedCallRecordingIds.add(id);
}
}
}
return deletedCallRecordingIds;
};

View File

@@ -0,0 +1,25 @@
import {
type AppConnection,
findConnectionForRequest,
type LogicFunctionExecutionContext,
} from 'twenty-sdk/logic-function';
import { isDefined } from 'src/utils/is-defined';
import { listFathomConnections } from 'src/logic-functions/utils/list-fathom-connections.util';
// A person acts through their own Fathom account. A run with nobody behind it
// (database-event or cron workflow) sees the union of the connected accounts,
// which is what the webhooks already sync into the shared CallRecording object.
export const listFathomConnectionsForRequest = async (
context: Pick<LogicFunctionExecutionContext, 'userWorkspaceId'>,
): Promise<AppConnection[]> => {
const connections = await listFathomConnections();
if (!isDefined(context.userWorkspaceId)) {
return connections;
}
const requesterConnection = findConnectionForRequest(connections, context);
return isDefined(requesterConnection) ? [requesterConnection] : [];
};

View File

@@ -0,0 +1,6 @@
import { type AppConnection, listConnections } from 'twenty-sdk/logic-function';
import { FATHOM_PROVIDER_NAME } from 'src/constants/fathom.constant';
export const listFathomConnections = (): Promise<AppConnection[]> =>
listConnections({ providerName: FATHOM_PROVIDER_NAME });

View File

@@ -0,0 +1,27 @@
import { type Meeting } from 'fathom-typescript/sdk/models/shared';
import { isDefined } from 'src/utils/is-defined';
import { type FathomMeetingLister } from 'src/logic-functions/types/fathom-meeting-lister.type';
export type FathomMeetingPage = {
meetings: Meeting[];
nextCursor: string | null;
};
export const listFathomMeetingPage = async ({
fathomClient,
createdAfter,
cursor,
}: {
fathomClient: FathomMeetingLister;
createdAfter?: string;
cursor?: string;
}): Promise<FathomMeetingPage> => {
const { result } = await fathomClient.listMeetings({
...(isDefined(createdAfter) ? { createdAfter } : {}),
...(isDefined(cursor) ? { cursor } : {}),
includeActionItems: true,
});
return { meetings: result.items, nextCursor: result.nextCursor };
};

View File

@@ -0,0 +1,42 @@
import { isNonEmptyString } from '@sniptt/guards';
import { type Meeting } from 'fathom-typescript/sdk/models/shared';
import { MAX_FATHOM_MEETING_PAGES } from 'src/constants/fathom.constant';
import { type FathomMeetingLister } from 'src/logic-functions/types/fathom-meeting-lister.type';
import { listFathomMeetingPage } from 'src/logic-functions/utils/list-fathom-meeting-page.util';
// Fathom has no endpoint for one meeting, so on-demand lookups walk the list
// until the caller has what it needs; the page cap keeps a miss inside the
// logic function timeout.
export const listFathomMeetings = async ({
fathomClient,
createdAfter,
stopWhen,
}: {
fathomClient: FathomMeetingLister;
createdAfter?: string;
stopWhen?: (meetings: Meeting[]) => boolean;
}): Promise<Meeting[]> => {
const meetings: Meeting[] = [];
let cursor: string | undefined = undefined;
for (let page = 0; page < MAX_FATHOM_MEETING_PAGES; page++) {
const meetingPage = await listFathomMeetingPage({
fathomClient,
createdAfter,
cursor,
});
meetings.push(...meetingPage.meetings);
if (stopWhen?.(meetings) || !isNonEmptyString(meetingPage.nextCursor)) {
return meetings;
}
cursor = meetingPage.nextCursor;
}
throw new Error(
`Fathom meeting listing exceeded ${MAX_FATHOM_MEETING_PAGES} pages`,
);
};

View File

@@ -0,0 +1,54 @@
import { isNonEmptyArray, isNonEmptyString } from '@sniptt/guards';
import { type z } from 'zod';
import {
type callRecordingMediaStateNodeSchema,
fathomMediaUploadCheckpointSchema,
} from 'src/logic-functions/schemas/call-recording-media-state-query-result.schema';
import { type CallRecordingMediaState } from 'src/logic-functions/types/call-recording-media-state.type';
import { isDefined } from 'src/utils/is-defined';
export const mapCallRecordingMediaState = (
node: z.infer<typeof callRecordingMediaStateNodeSchema>,
): CallRecordingMediaState => {
const recordingImport = node.fathomRecordingImports?.edges[0]?.node;
const uploadCheckpointResult = fathomMediaUploadCheckpointSchema.safeParse(
recordingImport?.mediaUploadCheckpoint,
);
return {
id: node.id,
updatedAt: node.updatedAt,
fathomRecordingImportId: isNonEmptyString(recordingImport?.id)
? recordingImport.id
: undefined,
fathomRecordingImportUpdatedAt: isNonEmptyString(
recordingImport?.updatedAt,
)
? recordingImport.updatedAt
: undefined,
recordingId: isNonEmptyString(recordingImport?.recordingId)
? recordingImport.recordingId
: undefined,
hasVideo:
node.video?.some((file) => isNonEmptyString(file.fileId)) ?? false,
hasAudio:
node.audio?.some((file) => isNonEmptyString(file.fileId)) ?? false,
hasTranscript: isNonEmptyArray(node.transcript),
hasSummary:
isNonEmptyString(node.summary?.markdown) ||
isDefined(node.summary?.blocknote),
failureReason: isNonEmptyString(recordingImport?.mediaFailureReason)
? recordingImport.mediaFailureReason
: undefined,
connectedAccountId: isNonEmptyString(recordingImport?.connectedAccountId)
? recordingImport.connectedAccountId
: undefined,
downloadId: isNonEmptyString(recordingImport?.mediaDownloadId)
? recordingImport.mediaDownloadId
: undefined,
uploadCheckpoint: uploadCheckpointResult.success
? uploadCheckpointResult.data
: undefined,
};
};

View File

@@ -0,0 +1,28 @@
import { isNonEmptyString } from '@sniptt/guards';
import { type Meeting } from 'fathom-typescript/sdk/models/shared';
import { type FathomCallSummary } from 'src/logic-functions/types/fathom-call-summary.type';
import { getFathomMeetingTitle } from 'src/logic-functions/utils/get-fathom-meeting-title.util';
const MILLISECONDS_PER_MINUTE = 60_000;
export const mapFathomMeetingToCallSummary = (
meeting: Meeting,
): FathomCallSummary => ({
recordingId: meeting.recordingId,
title: getFathomMeetingTitle(meeting),
startedAt: meeting.recordingStartTime.toISOString(),
durationMinutes: Math.round(
(meeting.recordingEndTime.getTime() -
meeting.recordingStartTime.getTime()) /
MILLISECONDS_PER_MINUTE,
),
participants: meeting.calendarInvitees
.map((invitee) => invitee.email)
.filter(isNonEmptyString),
recordedBy: meeting.recordedBy.email,
fathomUrl: meeting.url,
...(isNonEmptyString(meeting.meetingUrl)
? { meetingUrl: meeting.meetingUrl }
: {}),
});

View File

@@ -0,0 +1,44 @@
import { isNonEmptyString } from '@sniptt/guards';
import { type TranscriptItem } from 'fathom-typescript/sdk/models/shared';
import { type TranscriptEntry } from 'src/logic-functions/types/transcript-entry.type';
import { parseFathomTimestamp } from 'src/logic-functions/utils/parse-fathom-timestamp.util';
const UNKNOWN_SPEAKER_LABEL = 'Speaker';
export const mapFathomTranscriptToEntries = (
transcript: TranscriptItem[] | null | undefined,
): TranscriptEntry[] => {
if (!Array.isArray(transcript)) {
return [];
}
return transcript.flatMap((transcriptItem) => {
const text = transcriptItem.text.trim();
if (!isNonEmptyString(text)) {
return [];
}
const speakerName = transcriptItem.speaker.displayName.trim();
const startTimestamp = parseFathomTimestamp(transcriptItem.timestamp);
return [
{
participant: {
name: isNonEmptyString(speakerName)
? speakerName
: UNKNOWN_SPEAKER_LABEL,
},
words: [
{
text,
...(startTimestamp === undefined
? {}
: { start_timestamp: { relative: startTimestamp } }),
},
],
},
];
});
};

View File

@@ -0,0 +1,22 @@
import { isNonEmptyString } from '@sniptt/guards';
export const normalizeMeetingUrl = (
meetingUrl: string | null | undefined,
): string | undefined => {
if (!isNonEmptyString(meetingUrl)) {
return undefined;
}
try {
const parsedUrl = new URL(meetingUrl);
parsedUrl.hash = '';
parsedUrl.search = '';
parsedUrl.hostname = parsedUrl.hostname.toLowerCase().replace(/^www\./, '');
parsedUrl.pathname = parsedUrl.pathname.replace(/\/+$/, '');
return parsedUrl.toString();
} catch {
return undefined;
}
};

View File

@@ -0,0 +1,81 @@
import { isNonEmptyString, isNull, isUndefined } from '@sniptt/guards';
import { type RecordingDownloadFile } from 'fathom-typescript/sdk/models/shared';
import {
FATHOM_MEDIA_DOWNLOAD_TIMEOUT_MILLISECONDS,
FATHOM_MEDIA_MAX_FILE_SIZE_BYTES,
} from 'src/constants/fathom.constant';
import { cancelFathomMediaDownloadBody } from 'src/logic-functions/utils/cancel-fathom-media-download-body.util';
export type OpenFathomMediaDownloadResult =
| { outcome: 'opened'; body: ReadableStream<Uint8Array>; sizeBytes: number }
| { outcome: 'empty' }
| { outcome: 'too-large'; sizeBytes: number };
export const openFathomMediaDownload = async ({
callRecordingId,
fileName,
downloadFile,
}: {
callRecordingId: string;
fileName: string;
downloadFile: Pick<RecordingDownloadFile, 'url'>;
}): Promise<OpenFathomMediaDownloadResult> => {
const response = await fetch(downloadFile.url, {
signal: AbortSignal.timeout(FATHOM_MEDIA_DOWNLOAD_TIMEOUT_MILLISECONDS),
});
const contentLengthBytes = parseContentLengthBytes(
response.headers.get('content-length'),
);
if (
!response.ok ||
isUndefined(contentLengthBytes) ||
contentLengthBytes > FATHOM_MEDIA_MAX_FILE_SIZE_BYTES ||
contentLengthBytes === 0
) {
await cancelFathomMediaDownloadBody({
callRecordingId,
fileName,
body: response.body,
});
if (!response.ok) {
throw new Error(`download failed with status ${response.status}`);
}
if (isUndefined(contentLengthBytes)) {
throw new Error('download response is missing content-length');
}
if (contentLengthBytes > FATHOM_MEDIA_MAX_FILE_SIZE_BYTES) {
return { outcome: 'too-large', sizeBytes: contentLengthBytes };
}
return { outcome: 'empty' };
}
if (isNull(response.body)) {
throw new Error('download returned no body');
}
return {
outcome: 'opened',
body: response.body,
sizeBytes: contentLengthBytes,
};
};
const parseContentLengthBytes = (
headerValue: string | null,
): number | undefined => {
if (!isNonEmptyString(headerValue)) {
return undefined;
}
const parsedBytes = Number(headerValue.trim());
return Number.isSafeInteger(parsedBytes) && parsedBytes >= 0
? parsedBytes
: undefined;
};

View File

@@ -0,0 +1,25 @@
import { fathomMediaReconciliationQueryResultSchema } from 'src/logic-functions/schemas/fathom-media-reconciliation-query-result.schema';
import { type FathomMediaReconciliationPage } from 'src/logic-functions/types/fathom-media-reconciliation-plan.type';
import { mapCallRecordingMediaState } from 'src/logic-functions/utils/map-call-recording-media-state.util';
import { isDefined } from 'src/utils/is-defined';
export const parseFathomMediaReconciliationPage = (
queryResult: unknown,
): FathomMediaReconciliationPage => {
const connection =
fathomMediaReconciliationQueryResultSchema.parse(
queryResult,
).callRecordings;
if (!isDefined(connection)) {
return { callRecordings: [], hasNextPage: false };
}
return {
callRecordings: connection.edges.map(({ node }) => ({
...mapCallRecordingMediaState(node),
status: node.status,
})),
hasNextPage: connection.pageInfo.hasNextPage,
};
};

View File

@@ -0,0 +1,13 @@
const FATHOM_TIMESTAMP_PATTERN = /^(\d+):([0-5]?\d):([0-5]?\d)$/;
export const parseFathomTimestamp = (timestamp: string): number | undefined => {
const timestampMatch = FATHOM_TIMESTAMP_PATTERN.exec(timestamp);
if (timestampMatch === null) {
return undefined;
}
const [, hours, minutes, seconds] = timestampMatch.map(Number);
return hours * 3600 + minutes * 60 + seconds;
};

View File

@@ -0,0 +1,145 @@
import { request as requestOverHttp, type IncomingMessage } from 'node:http';
import { request as requestOverHttps } from 'node:https';
import { Readable } from 'node:stream';
import { finished, pipeline } from 'node:stream/promises';
import { FATHOM_MEDIA_UPLOAD_TIMEOUT_MILLISECONDS } from 'src/constants/fathom.constant';
import { cancelFathomMediaDownloadBody } from 'src/logic-functions/utils/cancel-fathom-media-download-body.util';
type MediaUploadTarget = {
uploadUrl: string;
contentType: string;
};
const HTTP_STATUS_OK_LOWER_BOUND = 200;
const HTTP_STATUS_OK_UPPER_BOUND = 300;
export const putFathomMediaBodyToUploadTarget = async ({
callRecordingId,
mediaDownloadBody,
fileName,
sizeBytes,
uploadTarget,
}: {
callRecordingId: string;
mediaDownloadBody: ReadableStream<Uint8Array>;
fileName: string;
sizeBytes: number;
uploadTarget: MediaUploadTarget;
}): Promise<void> => {
const mediaDownloadReadable = Readable.from(
readMediaDownloadBody({ mediaDownloadBody }),
);
await streamMediaDownloadReadableToUploadTarget({
fileName,
mediaDownloadReadable,
sizeBytes,
uploadTarget,
}).catch(async (error: unknown) => {
await cancelFathomMediaDownloadBody({
body: mediaDownloadBody,
callRecordingId,
fileName,
});
throw error;
});
};
const readMediaDownloadBody = async function* ({
mediaDownloadBody,
}: {
mediaDownloadBody: ReadableStream<Uint8Array>;
}) {
const reader = mediaDownloadBody.getReader();
let isComplete = false;
try {
while (true) {
const chunk = await reader.read();
if (chunk.done) {
isComplete = true;
return;
}
yield chunk.value;
}
} finally {
if (!isComplete) {
await reader.cancel().catch(() => undefined);
}
reader.releaseLock();
}
};
const streamMediaDownloadReadableToUploadTarget = async ({
fileName,
mediaDownloadReadable,
sizeBytes,
uploadTarget,
}: {
fileName: string;
mediaDownloadReadable: Readable;
sizeBytes: number;
uploadTarget: MediaUploadTarget;
}): Promise<void> => {
const uploadUrl = new URL(uploadTarget.uploadUrl);
const requestUpload =
uploadUrl.protocol === 'http:' ? requestOverHttp : requestOverHttps;
const uploadRequest = requestUpload(uploadUrl, {
method: 'PUT',
headers: {
'Content-Type': uploadTarget.contentType,
'Content-Length': sizeBytes,
},
signal: AbortSignal.timeout(FATHOM_MEDIA_UPLOAD_TIMEOUT_MILLISECONDS),
});
const uploadResponsePromise = new Promise<IncomingMessage>(
(resolve, reject) => {
uploadRequest.once('response', resolve);
uploadRequest.once('error', reject);
},
);
const uploadPipelinePromise = pipeline(mediaDownloadReadable, uploadRequest);
const uploadResponse = await Promise.race([
uploadResponsePromise,
uploadPipelinePromise.then(async () => await uploadResponsePromise),
]).catch(async (error: unknown) => {
mediaDownloadReadable.destroy();
uploadRequest.destroy();
await uploadPipelinePromise.catch(() => undefined);
throw error;
});
uploadResponse.resume();
const uploadResponseBodyDrainPromise = finished(uploadResponse);
const uploadStatusCode = uploadResponse.statusCode ?? 0;
if (
uploadStatusCode < HTTP_STATUS_OK_LOWER_BOUND ||
uploadStatusCode >= HTTP_STATUS_OK_UPPER_BOUND
) {
const uploadError = new Error(
`upload of ${fileName} failed with status ${uploadStatusCode}`,
);
mediaDownloadReadable.destroy(uploadError);
uploadRequest.destroy(uploadError);
await Promise.allSettled([
uploadPipelinePromise,
uploadResponseBodyDrainPromise,
]);
throw uploadError;
}
await Promise.all([uploadPipelinePromise, uploadResponseBodyDrainPromise]);
};

View File

@@ -0,0 +1,101 @@
import { isNonEmptyString } from '@sniptt/guards';
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { FATHOM_MEDIA_RECONCILIATION_BATCH_SIZE } from 'src/constants/fathom.constant';
import {
FATHOM_IMPORT_MEDIA_DOWNLOAD_UNIVERSAL_IDENTIFIER,
FATHOM_REQUEST_MEDIA_DOWNLOAD_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
import { type FathomMediaReconciliationRun } from 'src/logic-functions/types/fathom-media-reconciliation-plan.type';
import { buildFathomMediaReconciliationPlan } from 'src/logic-functions/utils/build-fathom-media-reconciliation-plan.util';
import { completeFathomCallRecordingImports } from 'src/logic-functions/utils/complete-fathom-call-recording-imports.util';
import { enqueueFathomJobsOrThrow } from 'src/logic-functions/utils/enqueue-fathom-jobs-or-throw.util';
import { findStaleFathomMediaImports } from 'src/logic-functions/utils/find-stale-fathom-media-imports.util';
import { listFathomConnections } from 'src/logic-functions/utils/list-fathom-connections.util';
import { reserveFathomImportSlots } from 'src/logic-functions/utils/reserve-fathom-import-slots.util';
import { chunkIntoBatches } from 'src/utils/chunk-into-batches.util';
export const reconcileFathomMediaImports = async ({
coreApiClient,
run,
}: {
coreApiClient: Pick<CoreApiClient, 'query' | 'mutation'>;
run: FathomMediaReconciliationRun;
}) => {
const connections = await listFathomConnections();
const activeConnectedAccountIds = connections.map(({ id }) => id);
const reconciliationPage = await findStaleFathomMediaImports({
coreApiClient,
run,
activeConnectedAccountIds,
});
const reconciliationPlan = buildFathomMediaReconciliationPlan({
callRecordings: reconciliationPage.callRecordings,
activeConnectedAccountIds,
});
const completedCallRecordingCount = await completeFathomCallRecordingImports({
coreApiClient,
callRecordings: reconciliationPlan.callRecordingsToComplete,
});
let enqueuedCallRecordingCount = 0;
for (const importGroup of reconciliationPlan.importGroups) {
const requestBatches = chunkIntoBatches(
importGroup.callRecordingIdsToRequest,
FATHOM_MEDIA_RECONCILIATION_BATCH_SIZE,
);
const pollBatches = chunkIntoBatches(
importGroup.downloadsToPoll,
FATHOM_MEDIA_RECONCILIATION_BATCH_SIZE,
);
const { slotDelays } = await reserveFathomImportSlots({
connectedAccountId: importGroup.connectedAccountId,
slotCount: requestBatches.length + pollBatches.length,
});
let slotIndex = 0;
for (const callRecordingIds of requestBatches) {
await enqueueFathomJobsOrThrow({
logicFunctionUniversalIdentifier:
FATHOM_REQUEST_MEDIA_DOWNLOAD_UNIVERSAL_IDENTIFIER,
payloads: callRecordingIds.map((callRecordingId) => ({
callRecordingId,
})),
delayMs: slotDelays[slotIndex],
});
enqueuedCallRecordingCount += callRecordingIds.length;
slotIndex += 1;
}
for (const downloads of pollBatches) {
await enqueueFathomJobsOrThrow({
logicFunctionUniversalIdentifier:
FATHOM_IMPORT_MEDIA_DOWNLOAD_UNIVERSAL_IDENTIFIER,
payloads: downloads.map(({ callRecordingId, downloadId }) => ({
callRecordingId,
downloadId,
attempt: 0,
})),
delayMs: slotDelays[slotIndex],
});
enqueuedCallRecordingCount += downloads.length;
slotIndex += 1;
}
}
const nextAfterId =
reconciliationPage.callRecordings[
reconciliationPage.callRecordings.length - 1
]?.id;
return {
candidateCount: reconciliationPage.callRecordings.length,
completedCallRecordingCount,
enqueuedCallRecordingCount,
disconnectedAccountIds: reconciliationPlan.disconnectedAccountIds,
hasNextPage:
reconciliationPage.hasNextPage && isNonEmptyString(nextAfterId),
nextAfterId,
};
};

View File

@@ -0,0 +1,23 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { settleCallRecordingMedia } from 'src/logic-functions/utils/settle-call-recording-media.util';
import { type FathomMediaWriteContext } from 'src/logic-functions/types/fathom-media-write-context.type';
export const recordFathomMediaFailure = async ({
coreApiClient,
callRecordingId,
reason,
writeContext,
}: {
coreApiClient: Pick<CoreApiClient, 'query' | 'mutation'>;
callRecordingId: string;
reason: string;
writeContext: FathomMediaWriteContext;
}): Promise<boolean> => {
return settleCallRecordingMedia({
coreApiClient,
callRecordingId,
writeContext,
fields: { mediaFailureReason: reason },
});
};

View File

@@ -0,0 +1,26 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { type FathomMediaImportClaim } from 'src/logic-functions/utils/claim-fathom-media-import.util';
export const releaseFathomMediaImportClaim = async ({
coreApiClient,
fathomRecordingImportId,
claim,
}: {
coreApiClient: Pick<CoreApiClient, 'mutation'>;
fathomRecordingImportId: string;
claim: FathomMediaImportClaim;
}): Promise<void> => {
await coreApiClient.mutation({
updateFathomRecordingImports: {
__args: {
filter: {
id: { eq: fathomRecordingImportId },
mediaImportClaimedAt: { eq: claim.claimedAt },
},
data: { mediaImportClaimedAt: null },
},
id: true,
},
});
};

View File

@@ -0,0 +1,41 @@
import { kv } from 'twenty-sdk/logic-function';
import { FATHOM_IMPORT_SLOT_INTERVAL_MILLISECONDS } from 'src/constants/fathom.constant';
import { getFathomImportScheduleKey } from 'src/logic-functions/utils/get-fathom-import-schedule-key.util';
type FathomImportSchedule = {
nextBatchAvailableAt: number;
};
export const reserveFathomImportSlots = async ({
connectedAccountId,
slotCount,
notBeforeDelayMilliseconds = 0,
}: {
connectedAccountId: string;
slotCount: number;
notBeforeDelayMilliseconds?: number;
}): Promise<{ slotDelays: number[]; continuationDelay: number }> => {
const now = Date.now();
const scheduleKey = getFathomImportScheduleKey(connectedAccountId);
const existingSchedule = await kv.get<FathomImportSchedule>(scheduleKey);
const scheduleStart = Math.max(
now + notBeforeDelayMilliseconds,
existingSchedule?.nextBatchAvailableAt ?? now,
);
const slotDelays = Array.from(
{ length: slotCount },
(_, slotIndex) =>
scheduleStart -
now +
slotIndex * FATHOM_IMPORT_SLOT_INTERVAL_MILLISECONDS,
);
const nextSlotAvailableAt =
scheduleStart + slotCount * FATHOM_IMPORT_SLOT_INTERVAL_MILLISECONDS;
if (slotCount > 0) {
await kv.set(scheduleKey, { nextBatchAvailableAt: nextSlotAvailableAt });
}
return { slotDelays, continuationDelay: nextSlotAvailableAt - now };
};

View File

@@ -0,0 +1,74 @@
import { isNonEmptyString } from '@sniptt/guards';
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { isDefined } from 'src/utils/is-defined';
import { findCallRecordingMediaState } from 'src/logic-functions/utils/find-call-recording-media-state.util';
import { isFathomMediaSettled } from 'src/logic-functions/utils/is-fathom-media-settled.util';
import { type FathomMediaUploadCheckpoint } from 'src/logic-functions/types/fathom-media-upload-checkpoint.type';
export type ResolveFathomMediaImportTargetResult =
| {
status: 'proceed';
fathomRecordingImportId: string;
connectedAccountId: string;
recordingId: number;
downloadId: string | undefined;
uploadCheckpoint: FathomMediaUploadCheckpoint | undefined;
}
| { status: 'skipped'; reason: string };
export const resolveFathomMediaImportTarget = async ({
coreApiClient,
callRecordingId,
}: {
coreApiClient: Pick<CoreApiClient, 'query'>;
callRecordingId: string;
}): Promise<ResolveFathomMediaImportTargetResult> => {
const mediaState = await findCallRecordingMediaState({
coreApiClient,
callRecordingId,
});
if (!isDefined(mediaState)) {
return { status: 'skipped', reason: 'no matching call recording' };
}
if (!isNonEmptyString(mediaState.fathomRecordingImportId)) {
return { status: 'skipped', reason: 'call recording has no Fathom import' };
}
const recordingId = Number(mediaState.recordingId);
if (!Number.isSafeInteger(recordingId) || recordingId <= 0) {
return {
status: 'skipped',
reason: 'call recording has an invalid Fathom id',
};
}
if (isFathomMediaSettled(mediaState)) {
return {
status: 'skipped',
reason:
mediaState.hasVideo || mediaState.hasAudio
? 'media already imported'
: `media unavailable: ${mediaState.failureReason}`,
};
}
if (!isNonEmptyString(mediaState.connectedAccountId)) {
return {
status: 'skipped',
reason: 'call recording has no Fathom connected account',
};
}
return {
status: 'proceed',
fathomRecordingImportId: mediaState.fathomRecordingImportId,
connectedAccountId: mediaState.connectedAccountId,
recordingId,
downloadId: mediaState.downloadId,
uploadCheckpoint: mediaState.uploadCheckpoint,
};
};

View File

@@ -0,0 +1,230 @@
import { Fathom } from 'fathom-typescript';
import { type RecordingDownload } from 'fathom-typescript/sdk/models/shared';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { getConnection } from 'twenty-sdk/logic-function';
import { FATHOM_MEDIA_FAILURE_REASON } from 'src/constants/fathom-media-failure-reason.constant';
import {
FATHOM_MEDIA_DOWNLOAD_MAX_POLL_ATTEMPTS,
FATHOM_MEDIA_IMPORT_CLAIM_DURATION_MILLISECONDS,
} from 'src/constants/fathom.constant';
import { fathomMediaImportPayloadSchema } from 'src/logic-functions/schemas/fathom-media-download-payload.schema';
import { applyFathomMediaDownload } from 'src/logic-functions/utils/apply-fathom-media-download.util';
import { buildRetryableFathomError } from 'src/logic-functions/utils/build-retryable-fathom-error.util';
import { claimFathomMediaImport } from 'src/logic-functions/utils/claim-fathom-media-import.util';
import { completeFathomCallRecordingImport } from 'src/logic-functions/utils/complete-fathom-call-recording-import.util';
import { enqueueFathomMediaDownloadPoll } from 'src/logic-functions/utils/enqueue-fathom-media-download-poll.util';
import { enqueueFathomMediaDownloadRequest } from 'src/logic-functions/utils/enqueue-fathom-media-download-request.util';
import { getFathomMediaFailureReasonForError } from 'src/logic-functions/utils/get-fathom-media-failure-reason-for-error.util';
import { getFathomRetryAfterDelay } from 'src/logic-functions/utils/get-fathom-retry-after-delay.util';
import { recordFathomMediaFailure } from 'src/logic-functions/utils/record-fathom-media-failure.util';
import { releaseFathomMediaImportClaim } from 'src/logic-functions/utils/release-fathom-media-import-claim.util';
import { resolveFathomMediaImportTarget } from 'src/logic-functions/utils/resolve-fathom-media-import-target.util';
import { updateFathomMediaDownloadId } from 'src/logic-functions/utils/update-fathom-media-download-id.util';
import { isDefined } from 'src/utils/is-defined';
const MAX_RATE_LIMIT_RETRIES = 3;
export const runFathomMediaImport = async (rawPayload: unknown) => {
const payload = fathomMediaImportPayloadSchema.parse(rawPayload);
const { callRecordingId, attempt } = payload;
const coreApiClient = new CoreApiClient({ runAs: 'application' });
const recordingContext = { coreApiClient, callRecordingId };
const fathomRecordingImportId = callRecordingId;
const claim = await claimFathomMediaImport({
coreApiClient,
fathomRecordingImportId,
now: new Date(),
}).catch((error: unknown) => {
throw buildRetryableFathomError({
operation: `claim media import callRecordingId=${callRecordingId}`,
error,
});
});
try {
const target = await resolveFathomMediaImportTarget(recordingContext);
if (target.status === 'skipped') {
await completeFathomCallRecordingImport(recordingContext);
return { success: true, outcome: 'skipped' };
}
if (
isDefined(payload.downloadId) &&
payload.downloadId !== target.downloadId
) {
return { success: true, outcome: 'stale' };
}
const enqueueNextAttempt = async ({
downloadId,
nextAttempt = attempt,
rateLimitAttempt = payload.rateLimitAttempt,
delay,
}: {
downloadId: string | undefined;
nextAttempt?: number;
rateLimitAttempt?: number;
delay?: number;
}) => {
const options = {
callRecordingId,
connectedAccountId: target.connectedAccountId,
rateLimitAttempt,
notBeforeDelayMilliseconds: delay,
};
if (isDefined(downloadId)) {
await enqueueFathomMediaDownloadPoll({
...options,
downloadId,
attempt: nextAttempt,
});
} else {
await enqueueFathomMediaDownloadRequest(options);
}
};
if (!isDefined(claim)) {
await enqueueNextAttempt({
downloadId: target.downloadId,
delay: FATHOM_MEDIA_IMPORT_CLAIM_DURATION_MILLISECONDS,
});
return { success: true, outcome: 'in-progress' };
}
let writeContext = {
fathomRecordingImportId: target.fathomRecordingImportId,
connectedAccountId: target.connectedAccountId,
claimedAt: claim.claimedAt,
downloadId: target.downloadId ?? null,
};
try {
const connection = await getConnection(target.connectedAccountId);
const fathomClient = new Fathom({
security: { bearerAuth: connection.accessToken },
retryConfig: { strategy: 'none' },
});
let download: Pick<
RecordingDownload,
'downloadId' | 'status' | 'failureReason' | 'video' | 'audio'
>;
if (
isDefined(target.uploadCheckpoint) &&
target.uploadCheckpoint.downloadId === target.downloadId
) {
download = { downloadId: target.downloadId, status: 'completed' };
} else if (isDefined(target.downloadId)) {
download = await fathomClient.getRecordingDownload({
recordingId: target.recordingId,
downloadId: target.downloadId,
});
} else {
download = await fathomClient.createRecordingDownload({
recordingId: target.recordingId,
});
}
if (!isDefined(download.downloadId)) {
throw new Error('Fathom download did not return an identifier');
}
if (!isDefined(target.downloadId)) {
const isApplied = await updateFathomMediaDownloadId({
...recordingContext,
writeContext,
downloadId: download.downloadId,
});
if (!isApplied) {
return { success: true, outcome: 'stale' };
}
}
writeContext = { ...writeContext, downloadId: download.downloadId };
const outcome = await applyFathomMediaDownload({
...recordingContext,
writeContext: { ...writeContext, downloadId: download.downloadId },
uploadCheckpoint: target.uploadCheckpoint,
download,
});
if (outcome !== 'pending') {
return { success: true, outcome };
}
if (attempt + 1 >= FATHOM_MEDIA_DOWNLOAD_MAX_POLL_ATTEMPTS) {
const isApplied = await recordFathomMediaFailure({
...recordingContext,
writeContext,
reason: FATHOM_MEDIA_FAILURE_REASON.DOWNLOAD_TIMED_OUT,
});
return { success: true, outcome: isApplied ? 'unavailable' : 'stale' };
}
await enqueueNextAttempt({
downloadId: download.downloadId,
nextAttempt: attempt + 1,
rateLimitAttempt: 0,
});
return { success: true, outcome: 'pending' };
} catch (error) {
const retryDelay = getFathomRetryAfterDelay({ error, now: new Date() });
const rateLimitAttempt = payload.rateLimitAttempt ?? 0;
if (isDefined(retryDelay)) {
if (rateLimitAttempt >= MAX_RATE_LIMIT_RETRIES) {
return { success: true, outcome: 'deferred' };
}
await enqueueNextAttempt({
downloadId: writeContext.downloadId ?? undefined,
rateLimitAttempt: rateLimitAttempt + 1,
delay: retryDelay,
});
return { success: true, outcome: 'rate-limited' };
}
const failureReason = getFathomMediaFailureReasonForError(error);
if (!isDefined(failureReason)) {
throw error;
}
const isApplied = await recordFathomMediaFailure({
...recordingContext,
writeContext,
reason: failureReason,
});
return { success: true, outcome: isApplied ? 'unavailable' : 'stale' };
}
} catch (error) {
throw buildRetryableFathomError({
operation: `media import callRecordingId=${callRecordingId}`,
error,
});
} finally {
if (isDefined(claim)) {
await releaseFathomMediaImportClaim({
coreApiClient,
fathomRecordingImportId,
claim,
}).catch((error: unknown) => {
throw buildRetryableFathomError({
operation: `release media import callRecordingId=${callRecordingId}`,
error,
});
});
}
}
};

View File

@@ -0,0 +1,16 @@
import { type Meeting } from 'fathom-typescript/sdk/models/shared';
import { type SerializedFathomMeeting } from 'src/logic-functions/types/serialized-fathom-meeting.type';
export const serializeFathomMeeting = ({
transcript: _transcript,
defaultSummary: _defaultSummary,
...meeting
}: Meeting): SerializedFathomMeeting => ({
...meeting,
createdAt: meeting.createdAt.toISOString(),
scheduledStartTime: meeting.scheduledStartTime.toISOString(),
scheduledEndTime: meeting.scheduledEndTime.toISOString(),
recordingStartTime: meeting.recordingStartTime.toISOString(),
recordingEndTime: meeting.recordingEndTime.toISOString(),
});

View File

@@ -0,0 +1,61 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { type CallRecordingSyncFields } from 'src/logic-functions/types/call-recording-sync-fields.type';
import { type FathomRecordingImportFields } from 'src/logic-functions/types/fathom-recording-import-fields.type';
import { type FathomMediaWriteContext } from 'src/logic-functions/types/fathom-media-write-context.type';
import { completeFathomCallRecordingImport } from 'src/logic-functions/utils/complete-fathom-call-recording-import.util';
import { updateCallRecordingMedia } from 'src/logic-functions/utils/update-call-recording-media.util';
import { updateFathomRecordingImport } from 'src/logic-functions/utils/update-fathom-recording-import.util';
import { isDefined } from 'src/utils/is-defined';
export const settleCallRecordingMedia = async ({
coreApiClient,
callRecordingId,
fields,
writeContext,
}: {
coreApiClient: Pick<CoreApiClient, 'query' | 'mutation'>;
callRecordingId: string;
writeContext: FathomMediaWriteContext;
fields: Pick<CallRecordingSyncFields, 'video' | 'audio'> &
Pick<FathomRecordingImportFields, 'mediaFailureReason'>;
}): Promise<boolean> => {
if (isDefined(fields.video) || isDefined(fields.audio)) {
const isMediaApplied = await updateCallRecordingMedia({
coreApiClient,
callRecordingId,
writeContext,
fields: {
...(isDefined(fields.video) ? { video: fields.video } : {}),
...(isDefined(fields.audio) ? { audio: fields.audio } : {}),
},
});
if (!isMediaApplied) {
return false;
}
}
const isImportApplied = await updateFathomRecordingImport({
coreApiClient,
writeContext,
fields: {
...(isDefined(fields.mediaFailureReason)
? { mediaFailureReason: fields.mediaFailureReason }
: {}),
mediaDownloadId: null,
mediaUploadCheckpoint: null,
},
});
if (!isImportApplied) {
return false;
}
await completeFathomCallRecordingImport({
coreApiClient,
callRecordingId,
});
return true;
};

View File

@@ -0,0 +1,47 @@
import { type Fathom } from 'fathom-typescript';
import { kv } from 'twenty-sdk/logic-function';
import { type FathomWebhookRegistration } from 'src/logic-functions/types/fathom-webhook-registration.type';
import { deleteStaleFathomWebhook } from 'src/logic-functions/utils/delete-stale-fathom-webhook.util';
import { toErrorMessage } from 'src/logic-functions/utils/to-error-message.util';
export const storeFathomWebhookRegistration = async ({
fathomClient,
connectedAccountId,
registrationKey,
registration,
}: {
fathomClient: Fathom;
connectedAccountId: string;
registrationKey: string;
registration: FathomWebhookRegistration;
}): Promise<void> => {
try {
await kv.set(registrationKey, registration);
} catch (error) {
// A write whose response was lost may still have committed.
const storedRegistration = await kv
.get<FathomWebhookRegistration>(registrationKey)
.catch(() => null);
if (storedRegistration?.webhookId === registration.webhookId) {
return;
}
// Fathom has no webhook listing endpoint, so a webhook this app never
// recorded can never be found again: undo it before the retry creates
// a second one against the same destination.
try {
await deleteStaleFathomWebhook({
fathomClient,
webhookId: registration.webhookId,
});
} catch (deleteError) {
console.error(
`[fathom] leaked webhook ${registration.webhookId} for connected account ${connectedAccountId}: ${toErrorMessage(deleteError)}`,
);
}
throw error;
}
};

View File

@@ -0,0 +1,108 @@
import { isNonEmptyString } from '@sniptt/guards';
import { type Meeting } from 'fathom-typescript/sdk/models/shared';
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { type CallRecordingSyncFields } from 'src/logic-functions/types/call-recording-sync-fields.type';
import { buildFathomCallRecordingUpsertFields } from 'src/logic-functions/utils/build-fathom-call-recording-upsert-fields.util';
import { completeFathomCallRecordingImport } from 'src/logic-functions/utils/complete-fathom-call-recording-import.util';
import { computeCallRecordingIdForFathomMeeting } from 'src/logic-functions/utils/compute-call-recording-id-for-fathom-meeting.util';
import { enqueueFathomMediaDownloadRequest } from 'src/logic-functions/utils/enqueue-fathom-media-download-request.util';
import { findCallRecordingMediaState } from 'src/logic-functions/utils/find-call-recording-media-state.util';
import { findMatchingCalendarEvent } from 'src/logic-functions/utils/find-matching-calendar-event.util';
import { formatFathomSummary } from 'src/logic-functions/utils/format-fathom-summary.util';
import { getFathomMeetingTitle } from 'src/logic-functions/utils/get-fathom-meeting-title.util';
import { mapFathomTranscriptToEntries } from 'src/logic-functions/utils/map-fathom-transcript-to-entries.util';
import { upsertCallRecording } from 'src/logic-functions/utils/upsert-call-recording.util';
import { upsertFathomRecordingImport } from 'src/logic-functions/utils/upsert-fathom-recording-import.util';
export const syncFathomMeetingToCallRecording = async ({
coreApiClient,
meeting,
connectedAccountId,
retryMedia = false,
}: {
coreApiClient: Pick<CoreApiClient, 'query' | 'mutation'>;
meeting: Meeting;
connectedAccountId: string;
retryMedia?: boolean;
}): Promise<{
callRecordingId: string;
calendarEventId?: string;
created: boolean;
}> => {
const transcriptEntries = mapFathomTranscriptToEntries(meeting.transcript);
const summaryMarkdown = formatFathomSummary({
summaryMarkdown: meeting.defaultSummary?.markdownFormatted,
actionItems: meeting.actionItems,
});
const calendarEventId = await findMatchingCalendarEvent({
coreApiClient,
meeting,
});
const title = getFathomMeetingTitle(meeting);
const callRecordingId = computeCallRecordingIdForFathomMeeting(
meeting.recordingId,
);
const existingCallRecording = await findCallRecordingMediaState({
coreApiClient,
callRecordingId,
});
const sharedFields: CallRecordingSyncFields = {
...(isNonEmptyString(title) ? { title } : {}),
recordingRequestStatus: 'REQUESTED',
startedAt: meeting.recordingStartTime.toISOString(),
endedAt: meeting.recordingEndTime.toISOString(),
...(transcriptEntries.length === 0
? {}
: { transcript: transcriptEntries }),
...(isNonEmptyString(summaryMarkdown)
? { summary: { markdown: summaryMarkdown, blocknote: null } }
: {}),
...(calendarEventId === undefined ? {} : { calendarEventId }),
};
const {
createCallRecordingFields,
updateCallRecordingFields,
recordingImportFields,
isMediaDownloadRequestNeeded,
} = buildFathomCallRecordingUpsertFields({
sharedFields,
existingCallRecording,
connectedAccountId,
callRecordingId,
recordingId: String(meeting.recordingId),
retryMedia,
});
const upsertResult = await upsertCallRecording({
coreApiClient,
callRecordingId,
createFields: createCallRecordingFields,
updateFields: updateCallRecordingFields,
expectedUpdatedAt: existingCallRecording?.updatedAt,
});
await upsertFathomRecordingImport({
coreApiClient,
fathomRecordingImportId: callRecordingId,
fields: recordingImportFields,
expectedUpdatedAt: existingCallRecording?.fathomRecordingImportUpdatedAt,
});
await completeFathomCallRecordingImport({
coreApiClient,
callRecordingId,
});
if (upsertResult.created || isMediaDownloadRequestNeeded) {
await enqueueFathomMediaDownloadRequest({
callRecordingId,
connectedAccountId,
});
}
return {
callRecordingId,
calendarEventId,
created: upsertResult.created,
};
};

View File

@@ -0,0 +1,2 @@
export const toErrorMessage = (error: unknown): string =>
error instanceof Error ? error.message : String(error);

View File

@@ -0,0 +1,32 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { type CallRecordingSyncFields } from 'src/logic-functions/types/call-recording-sync-fields.type';
import { type FathomMediaWriteContext } from 'src/logic-functions/types/fathom-media-write-context.type';
import { buildFathomMediaWriteFence } from 'src/logic-functions/utils/build-fathom-media-write-fence.util';
export const updateCallRecordingMedia = async ({
coreApiClient,
callRecordingId,
fields,
writeContext,
}: {
coreApiClient: Pick<CoreApiClient, 'mutation'>;
callRecordingId: string;
writeContext: FathomMediaWriteContext;
fields: Pick<CallRecordingSyncFields, 'video' | 'audio'>;
}): Promise<boolean> => {
const result = await coreApiClient.mutation({
updateCallRecordings: {
__args: {
filter: {
id: { eq: callRecordingId },
fathomRecordingImports: buildFathomMediaWriteFence(writeContext),
},
data: fields,
},
id: true,
},
});
return (result.updateCallRecordings ?? []).length > 0;
};

View File

@@ -0,0 +1,38 @@
import { setTimeout } from 'node:timers/promises';
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { type FathomMediaWriteContext } from 'src/logic-functions/types/fathom-media-write-context.type';
import { updateFathomRecordingImport } from 'src/logic-functions/utils/update-fathom-recording-import.util';
const MAX_DOWNLOAD_ID_SAVE_ATTEMPTS = 3;
const DOWNLOAD_ID_SAVE_RETRY_DELAY_MILLISECONDS = 500;
export const updateFathomMediaDownloadId = async ({
coreApiClient,
downloadId,
writeContext,
}: {
coreApiClient: Pick<CoreApiClient, 'mutation'>;
downloadId: string | null;
writeContext: FathomMediaWriteContext;
}): Promise<boolean> => {
const saveDownloadId = () =>
updateFathomRecordingImport({
coreApiClient,
writeContext,
fields: {
mediaDownloadId: downloadId,
mediaUploadCheckpoint: null,
},
});
for (let attempt = 1; attempt < MAX_DOWNLOAD_ID_SAVE_ATTEMPTS; attempt++) {
try {
return await saveDownloadId();
} catch {
await setTimeout(DOWNLOAD_ID_SAVE_RETRY_DELAY_MILLISECONDS * attempt);
}
}
return saveDownloadId();
};

View File

@@ -0,0 +1,20 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { type FathomMediaUploadCheckpoint } from 'src/logic-functions/types/fathom-media-upload-checkpoint.type';
import { type FathomMediaWriteContext } from 'src/logic-functions/types/fathom-media-write-context.type';
import { updateFathomRecordingImport } from 'src/logic-functions/utils/update-fathom-recording-import.util';
export const updateFathomMediaUploadCheckpoint = async ({
coreApiClient,
uploadCheckpoint,
writeContext,
}: {
coreApiClient: Pick<CoreApiClient, 'mutation'>;
uploadCheckpoint: FathomMediaUploadCheckpoint | null;
writeContext: FathomMediaWriteContext;
}): Promise<boolean> =>
updateFathomRecordingImport({
coreApiClient,
writeContext,
fields: { mediaUploadCheckpoint: uploadCheckpoint },
});

View File

@@ -0,0 +1,30 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { type FathomRecordingImportFields } from 'src/logic-functions/types/fathom-recording-import-fields.type';
import { type FathomMediaWriteContext } from 'src/logic-functions/types/fathom-media-write-context.type';
import { buildFathomMediaWriteFence } from 'src/logic-functions/utils/build-fathom-media-write-fence.util';
export const updateFathomRecordingImport = async ({
coreApiClient,
fields,
writeContext,
}: {
coreApiClient: Pick<CoreApiClient, 'mutation'>;
writeContext: FathomMediaWriteContext;
fields: Pick<
FathomRecordingImportFields,
'mediaFailureReason' | 'mediaDownloadId' | 'mediaUploadCheckpoint'
>;
}): Promise<boolean> => {
const result = await coreApiClient.mutation({
updateFathomRecordingImports: {
__args: {
filter: buildFathomMediaWriteFence(writeContext),
data: fields,
},
id: true,
},
});
return (result.updateFathomRecordingImports ?? []).length > 0;
};

View File

@@ -0,0 +1,69 @@
import { isNonEmptyString, isUndefined } from '@sniptt/guards';
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { FATHOM_MEDIA_FILE_FOLDER } from 'src/constants/fathom.constant';
import { cancelFathomMediaDownloadBody } from 'src/logic-functions/utils/cancel-fathom-media-download-body.util';
import { putFathomMediaBodyToUploadTarget } from 'src/logic-functions/utils/put-fathom-media-body-to-upload-target.util';
export const uploadFathomMediaStream = async ({
callRecordingId,
fileName,
fieldMetadataUniversalIdentifier,
body,
sizeBytes,
}: {
callRecordingId: string;
fileName: string;
fieldMetadataUniversalIdentifier: string;
body: ReadableStream<Uint8Array>;
sizeBytes: number;
}): Promise<string> => {
const metadataClient = new MetadataApiClient();
const uploadTarget = await metadataClient
.mutation({
createFileUpload: {
__args: {
filename: fileName,
size: sizeBytes,
fileFolder: FATHOM_MEDIA_FILE_FOLDER,
fieldMetadataUniversalIdentifier,
},
fileId: true,
uploadUrl: true,
contentType: true,
},
})
.then((mutationResult) => {
const uploadTarget = mutationResult.createFileUpload;
if (
isUndefined(uploadTarget) ||
!isNonEmptyString(uploadTarget.fileId) ||
!isNonEmptyString(uploadTarget.uploadUrl) ||
!isNonEmptyString(uploadTarget.contentType)
) {
throw new Error('createFileUpload mutation returned an invalid target');
}
return uploadTarget;
})
.catch(async (error: unknown) => {
await cancelFathomMediaDownloadBody({
callRecordingId,
fileName,
body,
});
throw error;
});
await putFathomMediaBodyToUploadTarget({
callRecordingId,
fileName,
mediaDownloadBody: body,
sizeBytes,
uploadTarget,
});
return uploadTarget.fileId;
};

View File

@@ -0,0 +1,64 @@
import { isNonEmptyArray } from '@sniptt/guards';
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { type CallRecordingSyncFields } from 'src/logic-functions/types/call-recording-sync-fields.type';
import { isDefined } from 'src/utils/is-defined';
export const upsertCallRecording = async ({
coreApiClient,
callRecordingId,
createFields,
updateFields,
expectedUpdatedAt,
}: {
coreApiClient: Pick<CoreApiClient, 'query' | 'mutation'>;
callRecordingId: string;
createFields: CallRecordingSyncFields;
updateFields: CallRecordingSyncFields;
expectedUpdatedAt: string | undefined;
}): Promise<{ callRecordingId: string; created: boolean }> => {
if (!isDefined(expectedUpdatedAt)) {
try {
await coreApiClient.mutation({
createCallRecording: {
__args: { data: { id: callRecordingId, ...createFields } },
id: true,
},
});
} catch (error) {
const result = await coreApiClient.query({
callRecordings: {
__args: { filter: { id: { eq: callRecordingId } }, first: 1 },
edges: { node: { id: true } },
},
});
if (!isDefined(result.callRecordings?.edges[0]?.node)) {
throw error;
}
return { callRecordingId, created: false };
}
return { callRecordingId, created: true };
}
const result = await coreApiClient.mutation({
updateCallRecordings: {
__args: {
filter: {
id: { eq: callRecordingId },
updatedAt: { eq: expectedUpdatedAt },
},
data: updateFields,
},
id: true,
},
});
if (!isNonEmptyArray(result.updateCallRecordings)) {
throw new Error('Fathom recording changed during import; retry the import');
}
return { callRecordingId, created: false };
};

View File

@@ -0,0 +1,71 @@
import { type CoreApiClient } from 'twenty-client-sdk/core';
import { type FathomRecordingImportFields } from 'src/logic-functions/types/fathom-recording-import-fields.type';
import { isDefined } from 'src/utils/is-defined';
export const upsertFathomRecordingImport = async ({
coreApiClient,
fathomRecordingImportId,
fields,
expectedUpdatedAt,
}: {
coreApiClient: Pick<CoreApiClient, 'query' | 'mutation'>;
fathomRecordingImportId: string;
fields: FathomRecordingImportFields & { recordingId: string };
expectedUpdatedAt: string | undefined;
}): Promise<void> => {
if (!isDefined(expectedUpdatedAt)) {
try {
await coreApiClient.mutation({
createFathomRecordingImport: {
__args: {
data: { id: fathomRecordingImportId, ...fields },
},
id: true,
},
});
return;
} catch (error) {
const result = await coreApiClient.query({
fathomRecordingImports: {
__args: {
filter: { id: { eq: fathomRecordingImportId } },
first: 1,
},
edges: { node: { updatedAt: true } },
},
});
const currentUpdatedAt =
result.fathomRecordingImports?.edges[0]?.node?.updatedAt;
if (!isDefined(currentUpdatedAt)) {
throw error;
}
return upsertFathomRecordingImport({
coreApiClient,
fathomRecordingImportId,
fields,
expectedUpdatedAt: currentUpdatedAt,
});
}
}
const result = await coreApiClient.mutation({
updateFathomRecordingImports: {
__args: {
filter: {
id: { eq: fathomRecordingImportId },
updatedAt: { eq: expectedUpdatedAt },
},
data: fields,
},
id: true,
},
});
if ((result.updateFathomRecordingImports ?? []).length === 0) {
throw new Error('Fathom recording changed during import; retry the import');
}
};